import type L from 'leaflet' import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes' export type LeafletApi = L function buildCharacterIconUrl(colors: CharacterColors): string { const svg = '' + `` + '' + '' return 'data:image/svg+xml,' + encodeURIComponent(svg) } export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon { return new L.Icon({ iconUrl: buildCharacterIconUrl(colors), iconSize: [25, 32], iconAnchor: [12, 17], popupAnchor: [0, -32], tooltipAnchor: [12, 0], }) } export interface CharacterData { name: string position: { x: number; y: number } type: string id: number map: number /** True when this character was last updated by one of the current user's tokens. */ ownedByMe?: boolean } export interface CharacterMapViewRef { map: L.Map mapid: number markerLayer?: L.LayerGroup } export interface MapCharacter { id: number name: string position: { x: number; y: number } type: string map: number text: string value: number ownedByMe?: boolean leafletMarker: L.Marker | null remove: (mapview: CharacterMapViewRef) => void add: (mapview: CharacterMapViewRef) => void update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => 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 { 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) const character: MapCharacter = { id: data.id, name: data.name, position: { ...data.position }, type: data.type, map: data.map, text: data.name, value: data.id, get ownedByMe() { return ownedByMe }, set ownedByMe(v: boolean | undefined) { ownedByMe = v ?? false }, get leafletMarker() { return leafletMarker }, remove(mapview: CharacterMapViewRef): void { if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId) animationFrameId = null } if (leafletMarker) { const layer = mapview.markerLayer ?? mapview.map layer.removeLayer(leafletMarker) leafletMarker = null } }, 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 }) 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) } }, update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void { const updatedOwnedByMe = (updated as { ownedByMe?: boolean }).ownedByMe ?? false if (ownedByMe !== updatedOwnedByMe) { ownedByMe = updatedOwnedByMe characterIcon = createCharacterIcon(L, getColorForCharacterId(character.id, { ownedByMe })) if (leafletMarker) leafletMarker.setIcon(characterIcon) } if (character.map !== updated.map) { character.remove(mapview) } character.map = updated.map character.position = { ...updated.position } if (!leafletMarker && character.map === mapview.mapid) { character.add(mapview) return } 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 { onClick = callback }, } return character }