Enhance character marker functionality and tooltip integration

- Updated character icon properties for improved visual representation.
- Introduced tooltip binding to character markers, displaying coordinates and character names.
- Implemented smooth position animation for character markers during updates.
- Added tests to verify tooltip content updates and animation cancellation on removal.
This commit is contained in:
2026-03-04 16:02:54 +03:00
parent 7fcdde3657
commit 40945c818b
2 changed files with 121 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
import type L from 'leaflet' import type L from 'leaflet'
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors' import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
export type LeafletApi = L export type LeafletApi = L
@@ -16,9 +16,10 @@ function buildCharacterIconUrl(colors: CharacterColors): string {
export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon { export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
return new L.Icon({ return new L.Icon({
iconUrl: buildCharacterIconUrl(colors), iconUrl: buildCharacterIconUrl(colors),
iconSize: [24, 32], iconSize: [25, 32],
iconAnchor: [12, 32], iconAnchor: [12, 17],
popupAnchor: [0, -32], popupAnchor: [0, -32],
tooltipAnchor: [12, 0],
}) })
} }
@@ -54,10 +55,17 @@ export interface MapCharacter {
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
} }
const CHARACTER_MOVE_DURATION_MS = 280
function easeOutQuad(t: number): number {
return t * (2 - t)
}
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter { export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
let leafletMarker: L.Marker | null = null let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
let ownedByMe = data.ownedByMe ?? false let ownedByMe = data.ownedByMe ?? false
let animationFrameId: number | null = null
const colors = getColorForCharacterId(data.id, { ownedByMe }) const colors = getColorForCharacterId(data.id, { ownedByMe })
let characterIcon = createCharacterIcon(L, colors) let characterIcon = createCharacterIcon(L, colors)
@@ -81,6 +89,10 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
}, },
remove(mapview: CharacterMapViewRef): void { remove(mapview: CharacterMapViewRef): void {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (leafletMarker) { if (leafletMarker) {
const layer = mapview.markerLayer ?? mapview.map const layer = mapview.markerLayer ?? mapview.map
layer.removeLayer(leafletMarker) layer.removeLayer(leafletMarker)
@@ -91,12 +103,22 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
add(mapview: CharacterMapViewRef): void { add(mapview: CharacterMapViewRef): void {
if (character.map === mapview.mapid) { if (character.map === mapview.mapid) {
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom) const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { icon: characterIcon, title: character.name }) leafletMarker = L.marker(position, { icon: characterIcon })
const gridX = Math.floor(character.position.x / TileSize)
const gridY = Math.floor(character.position.y / TileSize)
const tooltipContent = `${character.name} · ${gridX}, ${gridY}`
leafletMarker.bindTooltip(tooltipContent, {
direction: 'top',
permanent: false,
offset: L.point(-10.5, -18),
})
leafletMarker.on('click', (e: L.LeafletMouseEvent) => { leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e) if (onClick) onClick(e)
}) })
const targetLayer = mapview.markerLayer ?? mapview.map const targetLayer = mapview.markerLayer ?? mapview.map
leafletMarker.addTo(targetLayer) leafletMarker.addTo(targetLayer)
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
if (markerEl) markerEl.setAttribute('aria-label', character.name)
} }
}, },
@@ -114,11 +136,51 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
character.position = { ...updated.position } character.position = { ...updated.position }
if (!leafletMarker && character.map === mapview.mapid) { if (!leafletMarker && character.map === mapview.mapid) {
character.add(mapview) character.add(mapview)
return
} }
if (leafletMarker) { if (!leafletMarker) return
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position) const newLatLng = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
const updateTooltip = (): void => {
const gridX = Math.floor(character.position.x / TileSize)
const gridY = Math.floor(character.position.y / TileSize)
leafletMarker?.setTooltipContent(`${character.name} · ${gridX}, ${gridY}`)
} }
const from = leafletMarker.getLatLng()
const latDelta = newLatLng.lat - from.lat
const lngDelta = newLatLng.lng - from.lng
const distSq = latDelta * latDelta + lngDelta * lngDelta
if (distSq < 1e-12) {
updateTooltip()
return
}
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
const duration = CHARACTER_MOVE_DURATION_MS
const tick = (): void => {
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start
const t = Math.min(1, elapsed / duration)
const eased = easeOutQuad(t)
leafletMarker?.setLatLng({
lat: from.lat + latDelta * eased,
lng: from.lng + lngDelta * eased,
})
if (t >= 1) {
animationFrameId = null
leafletMarker?.setLatLng(newLatLng)
updateTooltip()
return
}
animationFrameId = requestAnimationFrame(tick)
}
animationFrameId = requestAnimationFrame(tick)
}, },
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {

View File

@@ -10,6 +10,9 @@ const { leafletMock } = vi.hoisted(() => {
addTo: vi.fn().mockReturnThis(), addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(), setLatLng: vi.fn().mockReturnThis(),
setIcon: vi.fn().mockReturnThis(), setIcon: vi.fn().mockReturnThis(),
bindTooltip: vi.fn().mockReturnThis(),
setTooltipContent: vi.fn().mockReturnThis(),
getLatLng: vi.fn().mockReturnValue({ lat: 0, lng: 0 }),
} }
const Icon = vi.fn().mockImplementation(function (this: unknown) { const Icon = vi.fn().mockImplementation(function (this: unknown) {
return {} return {}
@@ -17,6 +20,7 @@ const { leafletMock } = vi.hoisted(() => {
const L = { const L = {
marker: vi.fn(() => markerMock), marker: vi.fn(() => markerMock),
Icon, Icon,
point: vi.fn((x: number, y: number) => ({ x, y })),
} }
return { leafletMock: L } return { leafletMock: L }
}) })
@@ -30,6 +34,7 @@ vi.mock('leaflet', () => ({
vi.mock('~/lib/LeafletCustomTypes', () => ({ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
TileSize: 100,
})) }))
function getL(): L { function getL(): L {
@@ -89,6 +94,21 @@ describe('createCharacter', () => {
expect(mapview.map.unproject).toHaveBeenCalled() expect(mapview.map.unproject).toHaveBeenCalled()
}) })
it('add creates marker without title and binds Leaflet tooltip', () => {
const char = createCharacter(makeCharData({ position: { x: 100, y: 200 } }), getL())
const mapview = makeMapViewRef(1)
char.add(mapview)
expect(leafletMock.marker).toHaveBeenCalledWith(
expect.anything(),
expect.not.objectContaining({ title: expect.anything() })
)
const marker = char.leafletMarker as { bindTooltip: ReturnType<typeof vi.fn> }
expect(marker.bindTooltip).toHaveBeenCalledWith(
'Hero · 1, 2',
expect.objectContaining({ direction: 'top', permanent: false })
)
})
it('add does not create marker for different map', () => { it('add does not create marker for different map', () => {
const char = createCharacter(makeCharData({ map: 2 }), getL()) const char = createCharacter(makeCharData({ map: 2 }), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
@@ -132,4 +152,36 @@ describe('createCharacter', () => {
char.update(mapview, makeCharData({ ownedByMe: true })) char.update(mapview, makeCharData({ ownedByMe: true }))
expect(marker.setIcon).toHaveBeenCalledTimes(1) expect(marker.setIcon).toHaveBeenCalledTimes(1)
}) })
it('update with position change updates tooltip content when marker exists', () => {
const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1)
char.add(mapview)
const marker = char.leafletMarker as { setTooltipContent: ReturnType<typeof vi.fn> }
marker.setTooltipContent.mockClear()
char.update(mapview, makeCharData({ position: { x: 350, y: 450 } }))
expect(marker.setTooltipContent).toHaveBeenCalledWith('Hero · 3, 4')
})
it('remove cancels active position animation', () => {
const cancelSpy = vi.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
let rafCallback: (() => void) | null = null
vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb: (() => void) | (FrameRequestCallback)) => {
rafCallback = typeof cb === 'function' ? cb : () => {}
return 1
})
const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1)
mapview.map.unproject = vi.fn(() => ({ lat: 1, lng: 1 }))
char.add(mapview)
const marker = char.leafletMarker as { getLatLng: ReturnType<typeof vi.fn> }
marker.getLatLng.mockReturnValue({ lat: 0, lng: 0 })
char.update(mapview, makeCharData({ position: { x: 200, y: 200 } }))
expect(rafCallback).not.toBeNull()
cancelSpy.mockClear()
char.remove(mapview)
expect(cancelSpy).toHaveBeenCalledWith(1)
cancelSpy.mockRestore()
vi.restoreAllMocks()
})
}) })