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:
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user