diff --git a/docs/api.md b/docs/api.md
index f0abe6c..1e9fc5f 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -24,9 +24,9 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica
## Map data
- **GET /map/api/config** — client configuration (title, auths). Requires a session.
-- **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data).
+- **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens.
- **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data).
-- **GET /map/api/maps** — list of maps (filtered by permissions and hidden status).
+- **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed).
## Admin (all endpoints below require `admin` permission)
diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue
index 3398d09..6f40a1a 100644
--- a/frontend-nuxt/components/MapView.vue
+++ b/frontend-nuxt/components/MapView.vue
@@ -73,7 +73,7 @@
{{ measurePointA == null ? 'Click first point on map' : 'Click second point' }}
- Distance: {{ measureDistance.toFixed(2) }} units
+ Distance: {{ measureDistance.toFixed(3) }} units
@@ -146,6 +146,8 @@ const props = withDefaults(
const mapContainerRef = ref(null)
const mapRef = ref(null)
const api = useMapApi()
+/** Global state for navbar Live/Offline: true when at least one character is owned by current user's tokens. */
+const mapLive = useState('mapLive', () => false)
const mapLogic = useMapLogic()
const { resolvePath } = useAppPaths()
const mapNavigate = useMapNavigate()
@@ -275,13 +277,18 @@ onMounted(async () => {
])
const mapsList: MapInfo[] = []
- const raw = mapsData as Record
+ const raw = mapsData as Record<
+ string,
+ { ID?: number; Name?: string; id?: number; name?: string; size?: number; Hidden?: boolean; hidden?: boolean }
+ >
for (const id in raw) {
const m = raw[id]
if (!m || typeof m !== 'object') continue
const idVal = m.ID ?? m.id
const nameVal = m.Name ?? m.name
if (idVal == null || nameVal == null) continue
+ const hidden = !!(m.Hidden ?? m.hidden)
+ if (hidden) continue
mapsList.push({ ID: Number(idVal), Name: String(nameVal), size: m.size })
}
mapsList.sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
@@ -354,8 +361,10 @@ onMounted(async () => {
},
})
- layersManager.updateCharacters(Array.isArray(charactersData) ? charactersData : [])
+ const charsList = Array.isArray(charactersData) ? charactersData : []
+ layersManager.updateCharacters(charsList)
players.value = layersManager.getPlayers()
+ mapLive.value = charsList.some((c) => c.ownedByMe)
if (props.characterId !== undefined && props.characterId >= 0) {
mapLogic.state.trackingCharacterId.value = props.characterId
@@ -386,8 +395,10 @@ onMounted(async () => {
api
.getCharacters()
.then((body) => {
- layersManager!.updateCharacters(Array.isArray(body) ? body : [])
+ const list = Array.isArray(body) ? body : []
+ layersManager!.updateCharacters(list)
players.value = layersManager!.getPlayers()
+ mapLive.value = list.some((c) => c.ownedByMe)
})
.catch(() => clearInterval(intervalId!))
}, 2000)
@@ -482,8 +493,8 @@ onMounted(async () => {
if (!measureMode.value) return
e.originalEvent.stopPropagation()
const point = leafletMap!.project(e.latlng, HnHMaxZoom)
- const x = Math.floor(point.x / TileSize)
- const y = Math.floor(point.y / TileSize)
+ const x = point.x / TileSize
+ const y = point.y / TileSize
if (measurePointA.value == null) {
measurePointA.value = { x, y }
} else if (measurePointB.value == null) {
diff --git a/frontend-nuxt/layouts/default.vue b/frontend-nuxt/layouts/default.vue
index 508c992..6b72ae5 100644
--- a/frontend-nuxt/layouts/default.vue
+++ b/frontend-nuxt/layouts/default.vue
@@ -224,7 +224,8 @@ function getInitialDark(): boolean {
const title = ref('HnH Map')
const dark = ref(false)
-const live = ref(false)
+/** Live when at least one of current user's characters is on the map (set by MapView). */
+const live = useState('mapLive', () => false)
const me = useState('me', () => null)
const gravatarErrorDesktop = ref(false)
const gravatarErrorDrawer = ref(false)
@@ -300,6 +301,4 @@ async function doLogout() {
await router.push('/login')
me.value = null
}
-
-defineExpose({ setLive: (v: boolean) => { live.value = v } })
diff --git a/frontend-nuxt/lib/Character.ts b/frontend-nuxt/lib/Character.ts
index 3a29e49..00fba4c 100644
--- a/frontend-nuxt/lib/Character.ts
+++ b/frontend-nuxt/lib/Character.ts
@@ -1,6 +1,23 @@
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
import * as L from 'leaflet'
+/** SVG data URL for character marker icon (teal pin, bottom-center anchor). */
+const CHARACTER_ICON_URL =
+ 'data:image/svg+xml,' +
+ encodeURIComponent(
+ ''
+ )
+
+const CHARACTER_ICON = new L.Icon({
+ iconUrl: CHARACTER_ICON_URL,
+ iconSize: [24, 32],
+ iconAnchor: [12, 32],
+ popupAnchor: [0, -32],
+})
+
export interface CharacterData {
name: string
position: { x: number; y: number }
@@ -58,7 +75,7 @@ export function createCharacter(data: CharacterData): MapCharacter {
add(mapview: CharacterMapViewRef): void {
if (character.map === mapview.mapid) {
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
- leafletMarker = L.marker(position, { title: character.name })
+ leafletMarker = L.marker(position, { icon: CHARACTER_ICON, title: character.name })
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e)
})
diff --git a/frontend-nuxt/types/api.ts b/frontend-nuxt/types/api.ts
index 7d5d02b..456b817 100644
--- a/frontend-nuxt/types/api.ts
+++ b/frontend-nuxt/types/api.ts
@@ -28,6 +28,8 @@ export interface MapInfo {
ID: number
Name: string
size?: number
+ /** Present when returned by API (e.g. for admins); client should exclude Hidden maps from selectors. */
+ Hidden?: boolean
}
export interface Character {
@@ -36,6 +38,8 @@ export interface Character {
map: number
position: { x: number; y: number }
type: string
+ /** True when this character was last updated by one of the current user's tokens. */
+ ownedByMe?: boolean
}
export interface Marker {
diff --git a/internal/app/app.go b/internal/app/app.go
index 3b4f6d7..998c06c 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -170,6 +170,9 @@ type Session struct {
TempAdmin bool
}
+// ClientUsernameKey is the context key for the username that owns a client token (set by client handlers).
+var ClientUsernameKey = &struct{ key string }{key: "clientUsername"}
+
// Character represents a game character on the map.
type Character struct {
Name string `json:"name"`
@@ -178,6 +181,7 @@ type Character struct {
Position Position `json:"position"`
Type string `json:"type"`
Updated time.Time
+ Username string `json:"-"` // owner of the token that last updated this character; not sent to API
}
// Marker represents a map marker stored per grid.
diff --git a/internal/app/handlers/client.go b/internal/app/handlers/client.go
index 75ba29c..1dbfdc9 100644
--- a/internal/app/handlers/client.go
+++ b/internal/app/handlers/client.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "context"
"encoding/json"
"io"
"log/slog"
@@ -26,7 +27,8 @@ func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
- _ = username
+ ctx = context.WithValue(ctx, app.ClientUsernameKey, username)
+ req = req.WithContext(ctx)
switch matches[2] {
case "locate":
diff --git a/internal/app/handlers/map.go b/internal/app/handlers/map.go
index 1a022ab..f232e2e 100644
--- a/internal/app/handlers/map.go
+++ b/internal/app/handlers/map.go
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
+ "time"
"github.com/andyleap/hnh-map/internal/app"
)
@@ -22,6 +23,17 @@ func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) {
JSON(rw, http.StatusOK, config)
}
+// CharacterResponse is the API shape for a character, including ownedByMe for the current session.
+type CharacterResponse struct {
+ Name string `json:"name"`
+ ID int `json:"id"`
+ Map int `json:"map"`
+ Position app.Position `json:"position"`
+ Type string `json:"type"`
+ Updated time.Time `json:"updated,omitempty"`
+ OwnedByMe bool `json:"ownedByMe"`
+}
+
// APIGetChars handles GET /map/api/v1/characters.
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
@@ -35,7 +47,19 @@ func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
return
}
chars := h.Map.GetCharacters()
- JSON(rw, http.StatusOK, chars)
+ out := make([]CharacterResponse, 0, len(chars))
+ for _, c := range chars {
+ out = append(out, CharacterResponse{
+ Name: c.Name,
+ ID: c.ID,
+ Map: c.Map,
+ Position: c.Position,
+ Type: c.Type,
+ Updated: c.Updated.UTC(),
+ OwnedByMe: c.Username == s.Username,
+ })
+ }
+ JSON(rw, http.StatusOK, out)
}
// APIGetMarkers handles GET /map/api/v1/markers.
diff --git a/internal/app/services/client.go b/internal/app/services/client.go
index 64cfcc9..c73fc18 100644
--- a/internal/app/services/client.go
+++ b/internal/app/services/client.go
@@ -374,6 +374,8 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
return nil
})
+ username, _ := ctx.Value(app.ClientUsernameKey).(string)
+
s.withChars(func(chars map[string]app.Character) {
for id, craw := range craws {
gd, ok := gridDataByID[craw.GridID]
@@ -389,8 +391,9 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
X: craw.Coords.X + (gd.Coord.X * app.GridSize),
Y: craw.Coords.Y + (gd.Coord.Y * app.GridSize),
},
- Type: craw.Type,
- Updated: time.Now(),
+ Type: craw.Type,
+ Updated: time.Now(),
+ Username: username,
}
old, ok := chars[id]
if !ok {
@@ -401,6 +404,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
chars[id] = c
} else {
old.Position = c.Position
+ old.Username = username
chars[id] = old
}
} else if old.Type != "unknown" {
@@ -408,6 +412,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
chars[id] = c
} else {
old.Position = c.Position
+ old.Username = username
chars[id] = old
}
} else {