- Added a new AGENTS.md file to document the project structure and conventions. - Updated .gitignore to include node_modules and refined cursor rules. - Introduced new backend and frontend components for improved map interactions, including context menus and controls. - Enhanced API composables for better admin and authentication functionalities. - Refactored existing components for cleaner code and improved user experience. - Updated README.md to clarify production asset serving and user setup instructions.
271 lines
6.2 KiB
Go
271 lines
6.2 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/andyleap/hnh-map/internal/app/store"
|
|
"go.etcd.io/bbolt"
|
|
)
|
|
|
|
var transparentPNG = []byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
|
|
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
|
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
|
0x42, 0x60, 0x82,
|
|
}
|
|
|
|
type TileData struct {
|
|
MapID int
|
|
Coord Coord
|
|
Zoom int
|
|
File string
|
|
Cache int64
|
|
}
|
|
|
|
func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) {
|
|
a.db.View(func(tx *bbolt.Tx) error {
|
|
tiles := tx.Bucket(store.BucketTiles)
|
|
if tiles == nil {
|
|
return nil
|
|
}
|
|
mapb := tiles.Bucket([]byte(strconv.Itoa(mapid)))
|
|
if mapb == nil {
|
|
return nil
|
|
}
|
|
zoom := mapb.Bucket([]byte(strconv.Itoa(z)))
|
|
if zoom == nil {
|
|
return nil
|
|
}
|
|
tileraw := zoom.Get([]byte(c.Name()))
|
|
if tileraw == nil {
|
|
return nil
|
|
}
|
|
json.Unmarshal(tileraw, &td)
|
|
return nil
|
|
})
|
|
return
|
|
}
|
|
|
|
func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) {
|
|
a.db.Update(func(tx *bbolt.Tx) error {
|
|
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(mapid)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(z)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
td := &TileData{
|
|
MapID: mapid,
|
|
Coord: c,
|
|
Zoom: z,
|
|
File: f,
|
|
Cache: t,
|
|
}
|
|
raw, err := json.Marshal(td)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.gridUpdates.Send(td)
|
|
return zoom.Put([]byte(c.Name()), raw)
|
|
})
|
|
return
|
|
}
|
|
|
|
func (a *App) reportMerge(from, to int, shift Coord) {
|
|
a.mergeUpdates.Send(&Merge{
|
|
From: from,
|
|
To: to,
|
|
Shift: shift,
|
|
})
|
|
}
|
|
|
|
type TileCache struct {
|
|
M, X, Y, Z, T int
|
|
}
|
|
|
|
func (a *App) watchGridUpdates(rw http.ResponseWriter, req *http.Request) {
|
|
s := a.getSession(req)
|
|
if !a.canAccessMap(s) {
|
|
rw.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "text/event-stream")
|
|
rw.Header().Set("Access-Control-Allow-Origin", "*")
|
|
rw.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
flusher, ok := rw.(http.Flusher)
|
|
|
|
if !ok {
|
|
http.Error(rw, "Streaming unsupported!", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
c := make(chan *TileData, 1000)
|
|
mc := make(chan *Merge, 5)
|
|
|
|
a.gridUpdates.Watch(c)
|
|
a.mergeUpdates.Watch(mc)
|
|
|
|
tileCache := make([]TileCache, 0, 100)
|
|
|
|
a.db.View(func(tx *bbolt.Tx) error {
|
|
tiles := tx.Bucket(store.BucketTiles)
|
|
if tiles == nil {
|
|
return nil
|
|
}
|
|
return tiles.ForEach(func(mk, mv []byte) error {
|
|
mapb := tiles.Bucket(mk)
|
|
if mapb == nil {
|
|
return nil
|
|
}
|
|
return mapb.ForEach(func(k, v []byte) error {
|
|
zoom := mapb.Bucket(k)
|
|
if zoom == nil {
|
|
return nil
|
|
}
|
|
return zoom.ForEach(func(tk, tv []byte) error {
|
|
td := TileData{}
|
|
json.Unmarshal(tv, &td)
|
|
tileCache = append(tileCache, TileCache{
|
|
M: td.MapID,
|
|
X: td.Coord.X,
|
|
Y: td.Coord.Y,
|
|
Z: td.Zoom,
|
|
T: int(td.Cache),
|
|
})
|
|
return nil
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
raw, _ := json.Marshal(tileCache)
|
|
fmt.Fprint(rw, "data: ")
|
|
rw.Write(raw)
|
|
fmt.Fprint(rw, "\n\n")
|
|
tileCache = tileCache[:0]
|
|
flusher.Flush()
|
|
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
for {
|
|
select {
|
|
case e, ok := <-c:
|
|
if !ok {
|
|
return
|
|
}
|
|
found := false
|
|
for i := range tileCache {
|
|
if tileCache[i].M == e.MapID && tileCache[i].X == e.Coord.X && tileCache[i].Y == e.Coord.Y && tileCache[i].Z == e.Zoom {
|
|
tileCache[i].T = int(e.Cache)
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
tileCache = append(tileCache, TileCache{
|
|
M: e.MapID,
|
|
X: e.Coord.X,
|
|
Y: e.Coord.Y,
|
|
Z: e.Zoom,
|
|
T: int(e.Cache),
|
|
})
|
|
}
|
|
case e, ok := <-mc:
|
|
log.Println(e, ok)
|
|
if !ok {
|
|
return
|
|
}
|
|
raw, err := json.Marshal(e)
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
log.Println(string(raw))
|
|
fmt.Fprint(rw, "event: merge\n")
|
|
fmt.Fprint(rw, "data: ")
|
|
rw.Write(raw)
|
|
fmt.Fprint(rw, "\n\n")
|
|
flusher.Flush()
|
|
case <-ticker.C:
|
|
raw, _ := json.Marshal(tileCache)
|
|
fmt.Fprint(rw, "data: ")
|
|
rw.Write(raw)
|
|
fmt.Fprint(rw, "\n\n")
|
|
tileCache = tileCache[:0]
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
var tileRegex = regexp.MustCompile("([0-9]+)/([0-9]+)/([-0-9]+)_([-0-9]+).png")
|
|
|
|
func (a *App) gridTile(rw http.ResponseWriter, req *http.Request) {
|
|
s := a.getSession(req)
|
|
if !a.canAccessMap(s) {
|
|
rw.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
tile := tileRegex.FindStringSubmatch(req.URL.Path)
|
|
if tile == nil || len(tile) < 5 {
|
|
http.Error(rw, "invalid path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
mapid, err := strconv.Atoi(tile[1])
|
|
if err != nil {
|
|
http.Error(rw, "request parsing error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
z, err := strconv.Atoi(tile[2])
|
|
if err != nil {
|
|
http.Error(rw, "request parsing error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
x, err := strconv.Atoi(tile[3])
|
|
if err != nil {
|
|
http.Error(rw, "request parsing error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
y, err := strconv.Atoi(tile[4])
|
|
if err != nil {
|
|
http.Error(rw, "request parsing error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Map frontend Leaflet zoom (1…6) to storage level (0…5): z=6 → 0 (max detail), z=1..5 → same
|
|
storageZ := z
|
|
if storageZ == 6 {
|
|
storageZ = 0
|
|
}
|
|
if storageZ < 0 || storageZ > 5 {
|
|
storageZ = 0
|
|
}
|
|
td := a.GetTile(mapid, Coord{X: x, Y: y}, storageZ)
|
|
if td == nil {
|
|
rw.Header().Set("Content-Type", "image/png")
|
|
rw.Header().Set("Cache-Control", "private, max-age=3600")
|
|
rw.WriteHeader(http.StatusOK)
|
|
rw.Write(transparentPNG)
|
|
return
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "image/png")
|
|
rw.Header().Set("Cache-Control", "private immutable")
|
|
http.ServeFile(rw, req, filepath.Join(a.gridStorage, td.File))
|
|
}
|