From 40945c818b5a4e5301b53f844362b3ce1f76dd40 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Wed, 4 Mar 2026 16:02:54 +0300 Subject: [PATCH] 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. --- frontend-nuxt/lib/Character.ts | 76 +++++++++++++++++-- frontend-nuxt/lib/__tests__/Character.test.ts | 52 +++++++++++++ 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/frontend-nuxt/lib/Character.ts b/frontend-nuxt/lib/Character.ts index 8f35a3c..71b51fc 100644 --- a/frontend-nuxt/lib/Character.ts +++ b/frontend-nuxt/lib/Character.ts @@ -1,6 +1,6 @@ import type L from 'leaflet' import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors' -import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' +import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes' export type LeafletApi = L @@ -16,9 +16,10 @@ function buildCharacterIconUrl(colors: CharacterColors): string { export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon { return new L.Icon({ iconUrl: buildCharacterIconUrl(colors), - iconSize: [24, 32], - iconAnchor: [12, 32], + iconSize: [25, 32], + iconAnchor: [12, 17], popupAnchor: [0, -32], + tooltipAnchor: [12, 0], }) } @@ -54,10 +55,17 @@ export interface MapCharacter { 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 { let leafletMarker: L.Marker | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null let ownedByMe = data.ownedByMe ?? false + let animationFrameId: number | null = null const colors = getColorForCharacterId(data.id, { ownedByMe }) let characterIcon = createCharacterIcon(L, colors) @@ -81,6 +89,10 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte }, remove(mapview: CharacterMapViewRef): void { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } if (leafletMarker) { const layer = mapview.markerLayer ?? mapview.map layer.removeLayer(leafletMarker) @@ -91,12 +103,22 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte 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, { 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) => { if (onClick) onClick(e) }) const targetLayer = mapview.markerLayer ?? mapview.map 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 } if (!leafletMarker && character.map === mapview.mapid) { character.add(mapview) + return } - if (leafletMarker) { - const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) - leafletMarker.setLatLng(position) + if (!leafletMarker) return + + 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 { diff --git a/frontend-nuxt/lib/__tests__/Character.test.ts b/frontend-nuxt/lib/__tests__/Character.test.ts index 01d45f2..8d10f2e 100644 --- a/frontend-nuxt/lib/__tests__/Character.test.ts +++ b/frontend-nuxt/lib/__tests__/Character.test.ts @@ -10,6 +10,9 @@ const { leafletMock } = vi.hoisted(() => { addTo: vi.fn().mockReturnThis(), setLatLng: 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) { return {} @@ -17,6 +20,7 @@ const { leafletMock } = vi.hoisted(() => { const L = { marker: vi.fn(() => markerMock), Icon, + point: vi.fn((x: number, y: number) => ({ x, y })), } return { leafletMock: L } }) @@ -30,6 +34,7 @@ vi.mock('leaflet', () => ({ vi.mock('~/lib/LeafletCustomTypes', () => ({ HnHMaxZoom: 6, + TileSize: 100, })) function getL(): L { @@ -89,6 +94,21 @@ describe('createCharacter', () => { 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 } + expect(marker.bindTooltip).toHaveBeenCalledWith( + 'Hero · 1, 2', + expect.objectContaining({ direction: 'top', permanent: false }) + ) + }) + it('add does not create marker for different map', () => { const char = createCharacter(makeCharData({ map: 2 }), getL()) const mapview = makeMapViewRef(1) @@ -132,4 +152,36 @@ describe('createCharacter', () => { char.update(mapview, makeCharData({ ownedByMe: true })) 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 } + 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 } + 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() + }) })