Files
hnh-map/internal/app/tile.go
Nikolay Tatarinov 605a31567e Add initial project structure with backend and frontend setup
- Created backend structure with Go, including main application logic and API endpoints.
- Added Docker support for both development and production environments.
- Introduced frontend using Nuxt 3 with Tailwind CSS for styling.
- Included configuration files for Docker and environment variables.
- Established basic documentation for contributing, development, and deployment processes.
- Set up .gitignore and .dockerignore files to manage ignored files in the repository.
2026-02-24 22:27:05 +03:00

270 lines
6.1 KiB
Go

package app
import (
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"regexp"
"strconv"
"time"
"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([]byte("tiles"))
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([]byte("tiles"))
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([]byte("tiles"))
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))
}