Refactor Dockerignore and enhance Leaflet styles for improved map functionality
- Updated .dockerignore to streamline build context by ensuring unnecessary files are excluded. - Refined CSS styles in leaflet-overrides.css to enhance visual consistency and user experience for map tooltips and popups. - Improved map initialization and update handling in useMapApi and useMapUpdates composables for better performance and reliability.
This commit is contained in:
@@ -1,422 +1,422 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/store"
|
||||
"go.etcd.io/bbolt"
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type zoomproc struct {
|
||||
c app.Coord
|
||||
m int
|
||||
}
|
||||
|
||||
// MapService handles map, markers, grids, tiles business logic.
|
||||
type MapService struct {
|
||||
st *store.Store
|
||||
gridStorage string
|
||||
gridUpdates *app.Topic[app.TileData]
|
||||
mergeUpdates *app.Topic[app.Merge]
|
||||
getChars func() []app.Character
|
||||
}
|
||||
|
||||
// MapServiceDeps holds dependencies for MapService construction.
|
||||
type MapServiceDeps struct {
|
||||
Store *store.Store
|
||||
GridStorage string
|
||||
GridUpdates *app.Topic[app.TileData]
|
||||
MergeUpdates *app.Topic[app.Merge]
|
||||
GetChars func() []app.Character
|
||||
}
|
||||
|
||||
// NewMapService creates a MapService with the given dependencies.
|
||||
func NewMapService(d MapServiceDeps) *MapService {
|
||||
return &MapService{
|
||||
st: d.Store,
|
||||
gridStorage: d.GridStorage,
|
||||
gridUpdates: d.GridUpdates,
|
||||
mergeUpdates: d.MergeUpdates,
|
||||
getChars: d.GetChars,
|
||||
}
|
||||
}
|
||||
|
||||
// GridStorage returns the grid storage directory path.
|
||||
func (s *MapService) GridStorage() string { return s.gridStorage }
|
||||
|
||||
// GetCharacters returns all current characters.
|
||||
func (s *MapService) GetCharacters() []app.Character {
|
||||
if s.getChars == nil {
|
||||
return nil
|
||||
}
|
||||
return s.getChars()
|
||||
}
|
||||
|
||||
// GetMarkers returns all markers with computed map positions.
|
||||
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
|
||||
var markers []app.FrontendMarker
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
grid := s.st.GetMarkersGridBucket(tx)
|
||||
if grid == nil {
|
||||
return nil
|
||||
}
|
||||
grids := tx.Bucket(store.BucketGrids)
|
||||
if grids == nil {
|
||||
return nil
|
||||
}
|
||||
return grid.ForEach(func(k, v []byte) error {
|
||||
marker := app.Marker{}
|
||||
if err := json.Unmarshal(v, &marker); err != nil {
|
||||
return err
|
||||
}
|
||||
graw := grids.Get([]byte(marker.GridID))
|
||||
if graw == nil {
|
||||
return nil
|
||||
}
|
||||
g := app.GridData{}
|
||||
if err := json.Unmarshal(graw, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
markers = append(markers, app.FrontendMarker{
|
||||
Image: marker.Image,
|
||||
Hidden: marker.Hidden,
|
||||
ID: marker.ID,
|
||||
Name: marker.Name,
|
||||
Map: g.Map,
|
||||
Position: app.Position{
|
||||
X: marker.Position.X + g.Coord.X*app.GridSize,
|
||||
Y: marker.Position.Y + g.Coord.Y*app.GridSize,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return markers, err
|
||||
}
|
||||
|
||||
// GetMaps returns all maps, optionally including hidden ones.
|
||||
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
|
||||
maps := make(map[int]*app.MapInfo)
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachMap(tx, func(k, v []byte) error {
|
||||
mapid, err := strconv.Atoi(string(k))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mi := &app.MapInfo{}
|
||||
if err := json.Unmarshal(v, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
if mi.Hidden && !showHidden {
|
||||
return nil
|
||||
}
|
||||
maps[mapid] = mi
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return maps, err
|
||||
}
|
||||
|
||||
// GetConfig returns the application config for the frontend.
|
||||
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
|
||||
config := app.Config{Auths: auths}
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
title := s.st.GetConfig(tx, "title")
|
||||
if title != nil {
|
||||
config.Title = string(title)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return config, err
|
||||
}
|
||||
|
||||
// GetPage returns page metadata (title).
|
||||
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
|
||||
p := app.Page{}
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
title := s.st.GetConfig(tx, "title")
|
||||
if title != nil {
|
||||
p.Title = string(title)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return p, err
|
||||
}
|
||||
|
||||
// GetGrid returns a grid by its ID.
|
||||
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
|
||||
var gd *app.GridData
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetGrid(tx, id)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
gd = &app.GridData{}
|
||||
return json.Unmarshal(raw, gd)
|
||||
})
|
||||
return gd, err
|
||||
}
|
||||
|
||||
// GetTile returns a tile by map ID, coordinate, and zoom level.
|
||||
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
|
||||
var td *app.TileData
|
||||
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
|
||||
if raw != nil {
|
||||
td = &app.TileData{}
|
||||
return json.Unmarshal(raw, td)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
return td
|
||||
}
|
||||
|
||||
// getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z).
|
||||
// Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel.
|
||||
func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData {
|
||||
coords := []app.Coord{
|
||||
{X: c.X*2 + 0, Y: c.Y*2 + 0},
|
||||
{X: c.X*2 + 1, Y: c.Y*2 + 0},
|
||||
{X: c.X*2 + 0, Y: c.Y*2 + 1},
|
||||
{X: c.X*2 + 1, Y: c.Y*2 + 1},
|
||||
}
|
||||
keys := make([]string, len(coords))
|
||||
for i := range coords {
|
||||
keys[i] = coords[i].Name()
|
||||
}
|
||||
var rawMap map[string][]byte
|
||||
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
rawMap = s.st.GetTiles(tx, mapid, z-1, keys)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]*app.TileData, 4)
|
||||
for i, k := range keys {
|
||||
if raw, ok := rawMap[k]; ok && len(raw) > 0 {
|
||||
td := &app.TileData{}
|
||||
if json.Unmarshal(raw, td) == nil {
|
||||
result[i] = td
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SaveTile persists a tile and broadcasts the update.
|
||||
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
|
||||
_ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
td := &app.TileData{
|
||||
MapID: mapid,
|
||||
Coord: c,
|
||||
Zoom: z,
|
||||
File: f,
|
||||
Cache: t,
|
||||
}
|
||||
raw, err := json.Marshal(td)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.gridUpdates.Send(td)
|
||||
return s.st.PutTile(tx, mapid, z, c.Name(), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads).
|
||||
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
|
||||
subTiles := s.getSubTiles(ctx, mapid, c, z)
|
||||
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
|
||||
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
||||
for i := 0; i < 4; i++ {
|
||||
td := subTiles[i]
|
||||
if td == nil || td.File == "" {
|
||||
continue
|
||||
}
|
||||
x := i % 2
|
||||
y := i / 2
|
||||
subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
subimg, _, err := image.Decode(subf)
|
||||
subf.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
|
||||
}
|
||||
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
|
||||
slog.Error("failed to create zoom dir", "error", err)
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
|
||||
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
slog.Error("failed to create tile file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
slog.Error("failed to encode tile PNG", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
slog.Error("failed to close tile file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// RebuildZooms rebuilds all zoom levels from base tiles.
|
||||
// It can take a long time for many grids; the client should account for request timeouts.
|
||||
func (s *MapService) RebuildZooms(ctx context.Context) error {
|
||||
needProcess := map[zoomproc]struct{}{}
|
||||
saveGrid := map[zoomproc]string{}
|
||||
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(store.BucketGrids)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if err := b.ForEach(func(k, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
grid := app.GridData{}
|
||||
if err := json.Unmarshal(v, &grid); err != nil {
|
||||
return err
|
||||
}
|
||||
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
|
||||
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
slog.Error("RebuildZooms: failed to update store", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for g, id := range saveGrid {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
continue
|
||||
}
|
||||
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
|
||||
}
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
process := needProcess
|
||||
needProcess = map[zoomproc]struct{}{}
|
||||
for p := range process {
|
||||
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
||||
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReportMerge sends a merge event.
|
||||
func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
|
||||
s.mergeUpdates.Send(&app.Merge{
|
||||
From: from,
|
||||
To: to,
|
||||
Shift: shift,
|
||||
})
|
||||
}
|
||||
|
||||
// WatchTiles creates a channel that receives tile updates.
|
||||
func (s *MapService) WatchTiles() chan *app.TileData {
|
||||
c := make(chan *app.TileData, app.SSETileChannelSize)
|
||||
s.gridUpdates.Watch(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// WatchMerges creates a channel that receives merge updates.
|
||||
func (s *MapService) WatchMerges() chan *app.Merge {
|
||||
c := make(chan *app.Merge, app.SSEMergeChannelSize)
|
||||
s.mergeUpdates.Watch(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// GetAllTileCache returns all tiles for the initial SSE cache dump.
|
||||
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
|
||||
var cache []TileCache
|
||||
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
td := app.TileData{}
|
||||
if err := json.Unmarshal(v, &td); err != nil {
|
||||
return err
|
||||
}
|
||||
cache = append(cache, TileCache{
|
||||
M: td.MapID,
|
||||
X: td.Coord.X,
|
||||
Y: td.Coord.Y,
|
||||
Z: td.Zoom,
|
||||
T: int(td.Cache),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
// TileCache represents a minimal tile entry for SSE streaming.
|
||||
type TileCache struct {
|
||||
M, X, Y, Z, T int
|
||||
}
|
||||
|
||||
// ProcessZoomLevels processes zoom levels for a set of tile operations.
|
||||
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
|
||||
needProcess := map[zoomproc]struct{}{}
|
||||
for _, op := range ops {
|
||||
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
|
||||
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
|
||||
}
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
process := needProcess
|
||||
needProcess = map[zoomproc]struct{}{}
|
||||
for p := range process {
|
||||
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
||||
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TileOp represents a tile save operation.
|
||||
type TileOp struct {
|
||||
MapID int
|
||||
X, Y int
|
||||
File string
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/store"
|
||||
"go.etcd.io/bbolt"
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type zoomproc struct {
|
||||
c app.Coord
|
||||
m int
|
||||
}
|
||||
|
||||
// MapService handles map, markers, grids, tiles business logic.
|
||||
type MapService struct {
|
||||
st *store.Store
|
||||
gridStorage string
|
||||
gridUpdates *app.Topic[app.TileData]
|
||||
mergeUpdates *app.Topic[app.Merge]
|
||||
getChars func() []app.Character
|
||||
}
|
||||
|
||||
// MapServiceDeps holds dependencies for MapService construction.
|
||||
type MapServiceDeps struct {
|
||||
Store *store.Store
|
||||
GridStorage string
|
||||
GridUpdates *app.Topic[app.TileData]
|
||||
MergeUpdates *app.Topic[app.Merge]
|
||||
GetChars func() []app.Character
|
||||
}
|
||||
|
||||
// NewMapService creates a MapService with the given dependencies.
|
||||
func NewMapService(d MapServiceDeps) *MapService {
|
||||
return &MapService{
|
||||
st: d.Store,
|
||||
gridStorage: d.GridStorage,
|
||||
gridUpdates: d.GridUpdates,
|
||||
mergeUpdates: d.MergeUpdates,
|
||||
getChars: d.GetChars,
|
||||
}
|
||||
}
|
||||
|
||||
// GridStorage returns the grid storage directory path.
|
||||
func (s *MapService) GridStorage() string { return s.gridStorage }
|
||||
|
||||
// GetCharacters returns all current characters.
|
||||
func (s *MapService) GetCharacters() []app.Character {
|
||||
if s.getChars == nil {
|
||||
return nil
|
||||
}
|
||||
return s.getChars()
|
||||
}
|
||||
|
||||
// GetMarkers returns all markers with computed map positions.
|
||||
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
|
||||
var markers []app.FrontendMarker
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
grid := s.st.GetMarkersGridBucket(tx)
|
||||
if grid == nil {
|
||||
return nil
|
||||
}
|
||||
grids := tx.Bucket(store.BucketGrids)
|
||||
if grids == nil {
|
||||
return nil
|
||||
}
|
||||
return grid.ForEach(func(k, v []byte) error {
|
||||
marker := app.Marker{}
|
||||
if err := json.Unmarshal(v, &marker); err != nil {
|
||||
return err
|
||||
}
|
||||
graw := grids.Get([]byte(marker.GridID))
|
||||
if graw == nil {
|
||||
return nil
|
||||
}
|
||||
g := app.GridData{}
|
||||
if err := json.Unmarshal(graw, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
markers = append(markers, app.FrontendMarker{
|
||||
Image: marker.Image,
|
||||
Hidden: marker.Hidden,
|
||||
ID: marker.ID,
|
||||
Name: marker.Name,
|
||||
Map: g.Map,
|
||||
Position: app.Position{
|
||||
X: marker.Position.X + g.Coord.X*app.GridSize,
|
||||
Y: marker.Position.Y + g.Coord.Y*app.GridSize,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return markers, err
|
||||
}
|
||||
|
||||
// GetMaps returns all maps, optionally including hidden ones.
|
||||
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
|
||||
maps := make(map[int]*app.MapInfo)
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachMap(tx, func(k, v []byte) error {
|
||||
mapid, err := strconv.Atoi(string(k))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mi := &app.MapInfo{}
|
||||
if err := json.Unmarshal(v, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
if mi.Hidden && !showHidden {
|
||||
return nil
|
||||
}
|
||||
maps[mapid] = mi
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return maps, err
|
||||
}
|
||||
|
||||
// GetConfig returns the application config for the frontend.
|
||||
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
|
||||
config := app.Config{Auths: auths}
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
title := s.st.GetConfig(tx, "title")
|
||||
if title != nil {
|
||||
config.Title = string(title)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return config, err
|
||||
}
|
||||
|
||||
// GetPage returns page metadata (title).
|
||||
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
|
||||
p := app.Page{}
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
title := s.st.GetConfig(tx, "title")
|
||||
if title != nil {
|
||||
p.Title = string(title)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return p, err
|
||||
}
|
||||
|
||||
// GetGrid returns a grid by its ID.
|
||||
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
|
||||
var gd *app.GridData
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetGrid(tx, id)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
gd = &app.GridData{}
|
||||
return json.Unmarshal(raw, gd)
|
||||
})
|
||||
return gd, err
|
||||
}
|
||||
|
||||
// GetTile returns a tile by map ID, coordinate, and zoom level.
|
||||
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
|
||||
var td *app.TileData
|
||||
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
|
||||
if raw != nil {
|
||||
td = &app.TileData{}
|
||||
return json.Unmarshal(raw, td)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
return td
|
||||
}
|
||||
|
||||
// getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z).
|
||||
// Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel.
|
||||
func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData {
|
||||
coords := []app.Coord{
|
||||
{X: c.X*2 + 0, Y: c.Y*2 + 0},
|
||||
{X: c.X*2 + 1, Y: c.Y*2 + 0},
|
||||
{X: c.X*2 + 0, Y: c.Y*2 + 1},
|
||||
{X: c.X*2 + 1, Y: c.Y*2 + 1},
|
||||
}
|
||||
keys := make([]string, len(coords))
|
||||
for i := range coords {
|
||||
keys[i] = coords[i].Name()
|
||||
}
|
||||
var rawMap map[string][]byte
|
||||
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
rawMap = s.st.GetTiles(tx, mapid, z-1, keys)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]*app.TileData, 4)
|
||||
for i, k := range keys {
|
||||
if raw, ok := rawMap[k]; ok && len(raw) > 0 {
|
||||
td := &app.TileData{}
|
||||
if json.Unmarshal(raw, td) == nil {
|
||||
result[i] = td
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SaveTile persists a tile and broadcasts the update.
|
||||
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
|
||||
_ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
td := &app.TileData{
|
||||
MapID: mapid,
|
||||
Coord: c,
|
||||
Zoom: z,
|
||||
File: f,
|
||||
Cache: t,
|
||||
}
|
||||
raw, err := json.Marshal(td)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.gridUpdates.Send(td)
|
||||
return s.st.PutTile(tx, mapid, z, c.Name(), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads).
|
||||
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
|
||||
subTiles := s.getSubTiles(ctx, mapid, c, z)
|
||||
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
|
||||
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
||||
for i := 0; i < 4; i++ {
|
||||
td := subTiles[i]
|
||||
if td == nil || td.File == "" {
|
||||
continue
|
||||
}
|
||||
x := i % 2
|
||||
y := i / 2
|
||||
subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
subimg, _, err := image.Decode(subf)
|
||||
subf.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
|
||||
}
|
||||
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
|
||||
slog.Error("failed to create zoom dir", "error", err)
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
|
||||
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
slog.Error("failed to create tile file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
slog.Error("failed to encode tile PNG", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
slog.Error("failed to close tile file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// RebuildZooms rebuilds all zoom levels from base tiles.
|
||||
// It can take a long time for many grids; the client should account for request timeouts.
|
||||
func (s *MapService) RebuildZooms(ctx context.Context) error {
|
||||
needProcess := map[zoomproc]struct{}{}
|
||||
saveGrid := map[zoomproc]string{}
|
||||
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(store.BucketGrids)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if err := b.ForEach(func(k, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
grid := app.GridData{}
|
||||
if err := json.Unmarshal(v, &grid); err != nil {
|
||||
return err
|
||||
}
|
||||
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
|
||||
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
slog.Error("RebuildZooms: failed to update store", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for g, id := range saveGrid {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
continue
|
||||
}
|
||||
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
|
||||
}
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
process := needProcess
|
||||
needProcess = map[zoomproc]struct{}{}
|
||||
for p := range process {
|
||||
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
||||
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReportMerge sends a merge event.
|
||||
func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
|
||||
s.mergeUpdates.Send(&app.Merge{
|
||||
From: from,
|
||||
To: to,
|
||||
Shift: shift,
|
||||
})
|
||||
}
|
||||
|
||||
// WatchTiles creates a channel that receives tile updates.
|
||||
func (s *MapService) WatchTiles() chan *app.TileData {
|
||||
c := make(chan *app.TileData, app.SSETileChannelSize)
|
||||
s.gridUpdates.Watch(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// WatchMerges creates a channel that receives merge updates.
|
||||
func (s *MapService) WatchMerges() chan *app.Merge {
|
||||
c := make(chan *app.Merge, app.SSEMergeChannelSize)
|
||||
s.mergeUpdates.Watch(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// GetAllTileCache returns all tiles for the initial SSE cache dump.
|
||||
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
|
||||
var cache []TileCache
|
||||
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
td := app.TileData{}
|
||||
if err := json.Unmarshal(v, &td); err != nil {
|
||||
return err
|
||||
}
|
||||
cache = append(cache, TileCache{
|
||||
M: td.MapID,
|
||||
X: td.Coord.X,
|
||||
Y: td.Coord.Y,
|
||||
Z: td.Zoom,
|
||||
T: int(td.Cache),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
// TileCache represents a minimal tile entry for SSE streaming.
|
||||
type TileCache struct {
|
||||
M, X, Y, Z, T int
|
||||
}
|
||||
|
||||
// ProcessZoomLevels processes zoom levels for a set of tile operations.
|
||||
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
|
||||
needProcess := map[zoomproc]struct{}{}
|
||||
for _, op := range ops {
|
||||
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
|
||||
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
|
||||
}
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
process := needProcess
|
||||
needProcess = map[zoomproc]struct{}{}
|
||||
for p := range process {
|
||||
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
||||
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TileOp represents a tile save operation.
|
||||
type TileOp struct {
|
||||
MapID int
|
||||
X, Y int
|
||||
File string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user