import type L from 'leaflet' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes' import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker' import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character' import { createUniqueList, uniqueListUpdate, uniqueListGetElements, uniqueListById, type UniqueList, } from '~/lib/UniqueList' import type { SmartTileLayer } from '~/lib/SmartTileLayer' import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api' type LeafletModule = L type SmartTileLayerInstance = InstanceType function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export interface MapLayersOptions { /** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */ L: LeafletModule map: L.Map markerLayer: L.LayerGroup layer: SmartTileLayerInstance overlayLayer: SmartTileLayerInstance getCurrentMapId: () => number setCurrentMapId: (id: number) => void setSelectedMapId: (id: number) => void getAuths?: () => string[] getTrackingCharacterId: () => number setTrackingCharacterId: (id: number) => void onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void /** Called when user clicks "Add to saved locations" in marker popup. Receives marker id and getter to resolve marker. */ onAddMarkerToBookmark?: (markerId: number, getMarkerById: (id: number) => MapMarker | undefined) => void /** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */ resolveIconUrl?: (path: string) => string /** Fallback icon URL when a marker image fails to load. */ fallbackIconUrl?: string } export interface MapLayersManager { markers: UniqueList characters: UniqueList changeMap: (id: number) => void updateMarkers: (markersData: ApiMarker[]) => void updateCharacters: (charactersData: ApiCharacter[]) => void getQuestGivers: () => Array<{ id: number; name: string }> getPlayers: () => Array<{ id: number; name: string }> refreshMarkersVisibility: (hidden: boolean) => void refreshOverlayMarkers: (overlayMapIdValue: number) => void findMarkerById: (id: number) => MapMarker | undefined findCharacterById: (id: number) => MapCharacter | undefined } export function createMapLayers(options: MapLayersOptions): MapLayersManager { const { L, map, markerLayer, layer, overlayLayer, getCurrentMapId, setCurrentMapId, setSelectedMapId, getAuths: _getAuths, getTrackingCharacterId, setTrackingCharacterId, onMarkerContextMenu, onAddMarkerToBookmark, resolveIconUrl, fallbackIconUrl, } = options const markers = createUniqueList() const characters = createUniqueList() function markerCtx(): MapViewRef { return { map, markerLayer, mapid: getCurrentMapId() } } function characterCtx(): CharacterMapViewRef { return { map, mapid: getCurrentMapId() } } function changeMap(id: number) { if (id === getCurrentMapId()) return setCurrentMapId(id) setSelectedMapId(id) layer.map = id layer.redraw() overlayLayer.map = -1 overlayLayer.redraw() const ctx = markerCtx() uniqueListGetElements(markers).forEach((it) => it.remove(ctx)) uniqueListGetElements(markers) .filter((it) => it.map === id) .forEach((it) => it.add(ctx)) const cCtx = characterCtx() uniqueListGetElements(characters).forEach((it) => { it.remove(cCtx) it.add(cCtx) }) } function updateMarkers(markersData: ApiMarker[]) { const list = Array.isArray(markersData) ? markersData : [] const ctx = markerCtx() const iconOptions = resolveIconUrl != null ? { resolveIconUrl, fallbackIconUrl } : undefined uniqueListUpdate( markers, list.map((it) => createMarker(it as MarkerData, iconOptions, L)), (marker: MapMarker) => { if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx) marker.setClickCallback(() => { if (marker.leafletMarker) { if (onAddMarkerToBookmark) { const gridX = Math.floor(marker.position.x / TileSize) const gridY = Math.floor(marker.position.y / TileSize) const div = document.createElement('div') div.className = 'map-marker-popup text-sm' div.innerHTML = `

${escapeHtml(marker.name)}

${gridX}, ${gridY}

` const btn = div.querySelector('button') if (btn) { btn.addEventListener('click', () => { onAddMarkerToBookmark(marker.id, findMarkerById) marker.leafletMarker?.closePopup() }) } marker.leafletMarker.unbindPopup() marker.leafletMarker.bindPopup(div, { minWidth: 140, autoPan: true }).openPopup() } else { map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom) } } }) marker.setContextMenu((mev: L.LeafletMouseEvent) => { mev.originalEvent.preventDefault() mev.originalEvent.stopPropagation() onMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name) }) }, (marker: MapMarker) => marker.remove(ctx), (marker: MapMarker, updated: MapMarker) => marker.update(ctx, updated) ) } function updateCharacters(charactersData: ApiCharacter[]) { const list = Array.isArray(charactersData) ? charactersData : [] const ctx = characterCtx() uniqueListUpdate( characters, list.map((it) => createCharacter(it as CharacterData, L)), (character: MapCharacter) => { character.add(ctx) character.setClickCallback(() => setTrackingCharacterId(character.id)) }, (character: MapCharacter) => character.remove(ctx), (character: MapCharacter, updated: MapCharacter) => { if (getTrackingCharacterId() === updated.id) { if (getCurrentMapId() !== updated.map) changeMap(updated.map) const latlng = map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) map.setView(latlng, HnHMaxZoom) } character.update(ctx, updated) } ) } function getQuestGivers(): Array<{ id: number; name: string }> { return uniqueListGetElements(markers) .filter((it) => it.type === 'quest') .map((it) => ({ id: it.id, name: it.name })) } function getPlayers(): Array<{ id: number; name: string }> { return uniqueListGetElements(characters).map((it) => ({ id: it.id, name: it.name })) } function refreshMarkersVisibility(hidden: boolean) { const ctx = markerCtx() if (hidden) { uniqueListGetElements(markers).forEach((it) => it.remove(ctx)) } else { uniqueListGetElements(markers).forEach((it) => it.remove(ctx)) uniqueListGetElements(markers) .filter((it) => it.map === getCurrentMapId() || it.map === overlayLayer.map) .forEach((it) => it.add(ctx)) } } function refreshOverlayMarkers(overlayMapIdValue: number) { overlayLayer.map = overlayMapIdValue overlayLayer.redraw() const ctx = markerCtx() uniqueListGetElements(markers).forEach((it) => it.remove(ctx)) uniqueListGetElements(markers) .filter((it) => it.map === getCurrentMapId() || it.map === overlayMapIdValue) .forEach((it) => it.add(ctx)) } function findMarkerById(id: number): MapMarker | undefined { return uniqueListById(markers, id) } function findCharacterById(id: number): MapCharacter | undefined { return uniqueListById(characters, id) } return { markers, characters, changeMap, updateMarkers, updateCharacters, getQuestGivers, getPlayers, refreshMarkersVisibility, refreshOverlayMarkers, findMarkerById, findCharacterById, } }