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 }