- Updated frontend-nuxt.mdc to specify usage of composables for API calls. - Added new AuthCard and ConfirmModal components for improved UI consistency. - Introduced UserAvatar component for user profile display, replacing previous Gravatar implementation. - Implemented useFormSubmit composable for handling form submissions with loading and error states. - Enhanced vitest.config.ts to include coverage reporting for composables and components. - Removed deprecated useAdminApi and useAuth composables to streamline API interactions. - Updated login and setup pages to utilize new components and composables for better user experience.
375 lines
10 KiB
Go
375 lines
10 KiB
Go
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
|
|
}
|
|
|
|
// 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.
|
|
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
|
|
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
|
|
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
|
for x := 0; x <= 1; x++ {
|
|
for y := 0; y <= 1; y++ {
|
|
subC := c
|
|
subC.X *= 2
|
|
subC.Y *= 2
|
|
subC.X += x
|
|
subC.Y += y
|
|
td := s.GetTile(ctx, mapid, subC, z-1)
|
|
if td == nil || td.File == "" {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
b.ForEach(func(k, v []byte) error {
|
|
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
|
|
})
|
|
tx.DeleteBucket(store.BucketTiles)
|
|
return nil
|
|
}); err != nil {
|
|
slog.Error("RebuildZooms: failed to update store", "error", err)
|
|
return err
|
|
}
|
|
|
|
for g, id := range saveGrid {
|
|
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++ {
|
|
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 {
|
|
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
|
|
}
|