"use client"; import { useEffect, useRef, useCallback, useState } from "react"; import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles"; import { initDrawing } from "@/lib/drawingEngine"; import { initSelect } from "@/lib/selectingEngine"; import { initPoint } from "@/lib/pointEngine"; import { initLine } from "@/lib/lineEngine"; import { initPath } from "@/lib/pathEngine"; import { initCircle } from "@/lib/circleEngine"; import { createEditingEngine } from "@/lib/editingEngine"; import { FeatureCollection, Geometry } from "@/lib/useEditorState"; import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers"; type MapProps = { mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; draft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; selectedFeatureId: string | number | null; selectedEntityId: string | null; selectedEntityName: string | null; selectedEntityTypeId: string | null; onSelectFeatureId: (id: string | number | null) => void; onCreateFeature: (feature: FeatureCollection["features"][number]) => void; onDeleteFeature: (id: string | number) => void; onUpdateFeature: (id: string | number, geometry: Geometry) => void; }; type PointIconSpec = { id: string; fill: string; stroke: string; label: string; }; const DEFAULT_POINT_ICON_ID = "point-icon-default"; const PATH_ARROW_ICON_ID = "path-arrow-icon"; const MAP_MIN_ZOOM = 1; const MAP_MAX_ZOOM = 10; const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [ "coalesce", ["get", "MAPCOLOR7"], ["get", "MAPCOLOR9"], ["get", "scalerank"], 0, ]; const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [ "match", COUNTRY_COLOR_KEY_EXPRESSION, 1, "#ef4444", 2, "#f97316", 3, "#f59e0b", 4, "#22c55e", 5, "#06b6d4", 6, "#3b82f6", 7, "#8b5cf6", 8, "#a855f7", 9, "#d946ef", 10, "#14b8a6", "#64748b", ]; const POLYGON_FILL_BY_TYPE: Record = { country: "#2563eb", state: "#0ea5e9", empire: "#f59e0b", kingdom: "#d97706", war: "#dc2626", battle: "#f43f5e", civilization: "#14b8a6", rebellion_zone: "#7c3aed", }; const POLYGON_STROKE_BY_TYPE: Record = { country: "#1e3a8a", state: "#0c4a6e", empire: "#7c2d12", kingdom: "#9a3412", war: "#7f1d1d", battle: "#9f1239", civilization: "#134e4a", rebellion_zone: "#4c1d95", }; const POLYGON_OPACITY_BY_TYPE: Record = { war: 0.3, battle: 0.34, civilization: 0.38, rebellion_zone: 0.32, }; const LINE_COLOR_BY_TYPE: Record = { defense_line: "#f97316", attack_route: "#ef4444", retreat_route: "#94a3b8", invasion_route: "#b91c1c", migration_route: "#0ea5e9", refugee_route: "#06b6d4", trade_route: "#eab308", shipping_route: "#2563eb", }; const POINT_COLOR_BY_TYPE: Record = { person_deathplace: "#dc2626", person_birthplace: "#2563eb", person_activity: "#14b8a6", temple: "#7c3aed", capital: "#f59e0b", city: "#0f766e", fortress: "#b91c1c", castle: "#6d28d9", ruin: "#57534e", port: "#0c4a6e", bridge: "#9a3412", }; const POINT_ICON_SPECS: PointIconSpec[] = [ { id: "point-icon-person-deathplace", fill: "#dc2626", stroke: "#7f1d1d", label: "D" }, { id: "point-icon-person-birthplace", fill: "#2563eb", stroke: "#1e3a8a", label: "B" }, { id: "point-icon-person-activity", fill: "#14b8a6", stroke: "#134e4a", label: "A" }, { id: "point-icon-temple", fill: "#7c3aed", stroke: "#4c1d95", label: "T" }, { id: "point-icon-capital", fill: "#f59e0b", stroke: "#92400e", label: "Ca" }, { id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "Ci" }, { id: "point-icon-fortress", fill: "#b91c1c", stroke: "#7f1d1d", label: "F" }, { id: "point-icon-castle", fill: "#6d28d9", stroke: "#4c1d95", label: "Cs" }, { id: "point-icon-ruin", fill: "#57534e", stroke: "#292524", label: "R" }, { id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" }, { id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" }, // Backward-compatible icon ids used by older type_id values. { id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" }, { id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" }, { id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" }, { id: "point-icon-event", fill: "#be123c", stroke: "#881337", label: "E" }, { id: DEFAULT_POINT_ICON_ID, fill: "#475569", stroke: "#1e293b", label: "P" }, ]; const POINT_ICON_BY_TYPE: Record = { person_deathplace: "point-icon-person-deathplace", person_birthplace: "point-icon-person-birthplace", person_activity: "point-icon-person-activity", temple: "point-icon-temple", capital: "point-icon-capital", city: "point-icon-city", fortress: "point-icon-fortress", country: "point-icon-country", castle: "point-icon-castle", kingdom: "point-icon-kingdom", ruin: "point-icon-ruin", port: "point-icon-port", bridge: "point-icon-bridge", region: "point-icon-region", event: "point-icon-event", }; export default function Map({ mode, draft, backgroundVisibility, selectedFeatureId, selectedEntityId, selectedEntityName, selectedEntityTypeId, onSelectFeatureId, onCreateFeature, onDeleteFeature, onUpdateFeature, }: MapProps) { const mapRef = useRef(null); const modeRef = useRef(mode); const draftRef = useRef(draft); const backgroundVisibilityRef = useRef(backgroundVisibility); const selectedFeatureIdRef = useRef(selectedFeatureId); const selectedEntityIdRef = useRef(selectedEntityId); const selectedEntityNameRef = useRef(selectedEntityName); const selectedEntityTypeIdRef = useRef(selectedEntityTypeId); const onSelectFeatureIdRef = useRef(onSelectFeatureId); const onCreateRef = useRef(onCreateFeature); const onDeleteRef = useRef(onDeleteFeature); const onUpdateRef = useRef(onUpdateFeature); const [zoomLevel, setZoomLevel] = useState(2); const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); const editingEngineRef = useRef | null>(null); useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { const map = mapRef.current; if (!map) return; if (mode !== "add-line") { (map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: [], }); } if (mode !== "add-path") { (map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: [], }); } if (mode !== "add-circle") { (map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: [], }); } }, [mode]); useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { selectedFeatureIdRef.current = selectedFeatureId; }, [selectedFeatureId]); useEffect(() => { selectedEntityIdRef.current = selectedEntityId; }, [selectedEntityId]); useEffect(() => { selectedEntityNameRef.current = selectedEntityName; }, [selectedEntityName]); useEffect(() => { selectedEntityTypeIdRef.current = selectedEntityTypeId; }, [selectedEntityTypeId]); useEffect(() => { onSelectFeatureIdRef.current = onSelectFeatureId; }, [onSelectFeatureId]); useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; const map = mapRef.current; if (!map || !map.isStyleLoaded()) return; applyBackgroundLayerVisibility(map, backgroundVisibility); }, [backgroundVisibility]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); useEffect(() => { if (!editingEngineRef.current) { editingEngineRef.current = createEditingEngine({ mapRef, onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry), }); } }, []); /** * Push given draft into map sources (idempotent). * Always clear feature-state to avoid stale selection overlays after undo/replace. */ const applyDraftToMap = useCallback((fc: FeatureCollection) => { const map = mapRef.current; if (!map) return; const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined; const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined; if (!countriesSource || !placesSource) return; // clear all feature-state (selection) to prevent ghost layers after undo map.removeFeatureState({ source: "countries" }); const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current); const { polygons, points } = splitDraftFeatures(visibleDraft); countriesSource.setData(polygons); placesSource.setData(points); }, []); useEffect(() => { const map = new maplibregl.Map({ container: "map", attributionControl: false, minZoom: MAP_MIN_ZOOM, maxZoom: MAP_MAX_ZOOM, style: { version: 8, sources: { rasterBase: { type: "raster", tiles: [getRasterTileTemplateUrl()], tileSize: 256, minzoom: 0, maxzoom: 6, }, base: { type: "vector", tiles: [getVectorTileTemplateUrl()], minzoom: 0, maxzoom: 6, }, }, layers: [ { id: "background", type: "background", paint: { "background-color": "#0b1220", }, }, { id: "raster-base-layer", type: "raster", source: "rasterBase", paint: { "raster-opacity": 0.92, "raster-resampling": "linear", }, }, { id: "graticules-line", type: "line", source: "base", "source-layer": "graticules", paint: { "line-color": "#334155", "line-width": [ "interpolate", ["linear"], ["zoom"], 0, 0.3, 4, 0.6, 6, 0.8, ], "line-opacity": 0.55, }, }, { id: "land", type: "fill", source: "base", "source-layer": "land", paint: { "fill-color": "#1e293b", "fill-opacity": 0.25, }, }, { id: "bg-countries-fill", type: "fill", source: "base", "source-layer": "countries", paint: { "fill-color": COUNTRY_FILL_COLOR_EXPRESSION, "fill-opacity": 0.38, }, }, { id: "bg-country-borders-line", type: "line", source: "base", "source-layer": "country_borders", paint: { "line-color": "#cbd5e1", "line-width": [ "interpolate", ["linear"], ["zoom"], 0, 0.2, 4, 0.5, 6, 1.1, ], "line-opacity": 0.85, }, }, { id: "regions-line", type: "line", source: "base", "source-layer": "regions", paint: { "line-color": "#475569", "line-width": [ "interpolate", ["linear"], ["zoom"], 0, 0.2, 4, 0.6, 6, 1, ], "line-opacity": 0.6, }, }, { id: "lakes-fill", type: "fill", source: "base", "source-layer": "lakes", paint: { "fill-color": "#1d4ed8", "fill-opacity": 0.45, }, }, { id: "rivers-line", type: "line", source: "base", "source-layer": "rivers", paint: { "line-color": "#38bdf8", "line-width": [ "interpolate", ["linear"], ["zoom"], 0, 0.25, 4, 0.8, 6, 1.5, ], "line-opacity": 0.85, }, }, { id: "geolines-line", type: "line", source: "base", "source-layer": "geolines", paint: { "line-color": "#94a3b8", "line-width": 1.2, "line-opacity": 0.8, }, }, ], }, center: [0, 20], zoom: 2, }); mapRef.current = map; map.on("load", async () => { const geometryMinZoom = 5; const syncZoomLevel = () => { setZoomLevel(roundZoom(map.getZoom())); }; applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current); const hasPathArrowIcon = ensurePathArrowIcon(map); setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); syncZoomLevel(); map.on("zoom", syncZoomLevel); // preview (drawing) map.addSource("draw-preview", { type: "geojson", data: { type: "FeatureCollection", features: [], }, }); map.addLayer({ id: "draw-preview-fill", type: "fill", source: "draw-preview", paint: { "fill-color": "#22c55e", "fill-opacity": 0.4, }, }); map.addLayer({ id: "draw-preview-line", type: "line", source: "draw-preview", paint: { "line-color": "#16a34a", "line-width": 2, }, }); map.addSource("draw-circle-preview", { type: "geojson", data: { type: "FeatureCollection", features: [], }, }); map.addLayer({ id: "draw-circle-preview-fill", type: "fill", source: "draw-circle-preview", paint: { "fill-color": "#0ea5e9", "fill-opacity": 0.25, }, }); map.addLayer({ id: "draw-circle-preview-line", type: "line", source: "draw-circle-preview", paint: { "line-color": "#0284c7", "line-width": 2, "line-opacity": 0.95, }, }); map.addSource("draw-line-preview", { type: "geojson", data: { type: "FeatureCollection", features: [], }, }); map.addLayer({ id: "draw-line-preview-line", type: "line", source: "draw-line-preview", paint: { "line-color": "#38bdf8", "line-width": 3, "line-opacity": 0.9, "line-dasharray": [1.2, 0.9], }, }); map.addSource("draw-path-preview", { type: "geojson", data: { type: "FeatureCollection", features: [], }, }); map.addLayer({ id: "draw-path-preview-line", type: "line", source: "draw-path-preview", paint: { "line-color": "#38bdf8", "line-width": 3, "line-opacity": 0.9, "line-dasharray": [1.2, 0.9], }, }); if (hasPathArrowIcon) { map.addLayer({ id: "draw-path-preview-arrows", type: "symbol", source: "draw-path-preview", layout: { "symbol-placement": "line", "symbol-spacing": 56, "icon-image": PATH_ARROW_ICON_ID, "icon-size": 0.45, "icon-allow-overlap": true, "icon-ignore-placement": true, }, }); } // data thật map.addSource("countries", { type: "geojson", data: { type: "FeatureCollection", features: [], }, promoteId: "id", }); map.addLayer({ id: "countries-fill", type: "fill", source: "countries", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": [ "case", ["boolean", ["feature-state", "selected"], false], "#22c55e", // selected [ "==", ["coalesce", ["get", "entity_id"], ""], "", ], "#ef4444", // no entity buildTypeMatchExpression(POLYGON_FILL_BY_TYPE, "#f59e0b"), ], "fill-opacity": [ "case", ["boolean", ["feature-state", "selected"], false], 0.6, buildTypeMatchExpression(POLYGON_OPACITY_BY_TYPE, 0.5), ], }, }); map.addLayer({ id: "countries-line", type: "line", source: "countries", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": [ "case", ["boolean", ["feature-state", "selected"], false], "#14532d", buildTypeMatchExpression(POLYGON_STROKE_BY_TYPE, "#fbbf24"), ], "line-width": 2, }, }); map.addLayer({ id: "routes-line", type: "line", source: "countries", minzoom: geometryMinZoom, filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": [ "case", ["boolean", ["feature-state", "selected"], false], "#22c55e", ["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444", buildTypeMatchExpression(LINE_COLOR_BY_TYPE, "#38bdf8"), ], "line-width": [ "interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2, ], "line-opacity": 0.9, }, }); if (hasPathArrowIcon) { map.addLayer({ id: "routes-arrow", type: "symbol", source: "countries", minzoom: geometryMinZoom, filter: [ "all", ["==", ["geometry-type"], "LineString"], ["==", ["coalesce", ["get", "line_mode"], "path"], "path"], ], layout: { "symbol-placement": "line", "symbol-spacing": 60, "icon-image": PATH_ARROW_ICON_ID, "icon-size": 0.5, "icon-allow-overlap": true, "icon-ignore-placement": true, }, }); } map.addSource("places", { type: "geojson", data: { type: "FeatureCollection", features: [], }, }); // editing overlays map.addSource("edit-shape", { type: "geojson", data: { type: "FeatureCollection", features: [] }, }); map.addSource("edit-handles", { type: "geojson", data: { type: "FeatureCollection", features: [] }, }); map.addLayer({ id: "edit-shape-line", type: "line", source: "edit-shape", paint: { "line-color": "#38bdf8", "line-width": 3, }, }); map.addLayer({ id: "edit-handles-circle", type: "circle", source: "edit-handles", paint: { "circle-color": "#f97316", "circle-radius": 12, "circle-stroke-color": "#0f172a", "circle-stroke-width": 3, }, }); // fallback layer so points are still visible even if icon cannot be loaded map.addLayer({ id: "places-circle", type: "circle", source: "places", minzoom: geometryMinZoom, paint: { "circle-color": [ "case", [ "==", ["coalesce", ["get", "entity_id"], ""], "", ], "#ef4444", buildTypeMatchExpression(POINT_COLOR_BY_TYPE, "#10b981"), ], "circle-radius": 4, "circle-stroke-color": "#ffffff", "circle-stroke-width": 1, "circle-opacity": 0.85, }, }); // Add type-specific point icons (country/castle/kingdom/...) and render with symbol layer. const hasTypeIcons = ensurePointIcons(map); if (hasTypeIcons && !map.getLayer("places-symbol")) { map.addLayer({ id: "places-symbol", type: "symbol", source: "places", minzoom: geometryMinZoom, layout: { "icon-image": buildPointIconExpression(), "icon-size": 0.5, "icon-anchor": "center", "icon-allow-overlap": true, }, }); } // init drawing const cleanup = initDrawing( map, () => modeRef.current, (geometry: Geometry) => { const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); onCreateRef.current({ type: "Feature", properties: { id, entity_id: selectedEntityIdRef.current || null, entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], entity_name: selectedEntityNameRef.current || null, entity_type_id: selectedEntityTypeIdRef.current || null, binding: [], }, geometry, }); } ); const cleanupSelect = initSelect( map, () => modeRef.current, (id: string | number) => { // ensure edit overlays are cleared when a feature gets removed editingEngineRef.current?.clearEditing(); onSelectFeatureIdRef.current?.(null); onDeleteRef.current(id); }, (feature) => editingEngineRef.current?.beginEditing(feature), (id) => onSelectFeatureIdRef.current?.(id) ); const cleanupPoint = initPoint( map, () => modeRef.current, (geometry: Geometry) => { const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); onCreateRef.current({ type: "Feature", properties: { id, entity_id: selectedEntityIdRef.current || null, entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], entity_name: selectedEntityNameRef.current || null, entity_type_id: selectedEntityTypeIdRef.current || null, binding: [], }, geometry, }); } ); const cleanupLine = initLine( map, () => modeRef.current, (geometry: Geometry) => { const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); onCreateRef.current({ type: "Feature", properties: { id, entity_id: selectedEntityIdRef.current || null, entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], entity_name: selectedEntityNameRef.current || null, entity_type_id: selectedEntityTypeIdRef.current || null, line_mode: "line", binding: [], }, geometry, }); } ); const cleanupPath = initPath( map, () => modeRef.current, (geometry: Geometry) => { const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); onCreateRef.current({ type: "Feature", properties: { id, entity_id: selectedEntityIdRef.current || null, entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], entity_name: selectedEntityNameRef.current || null, entity_type_id: selectedEntityTypeIdRef.current || null, line_mode: "path", binding: [], }, geometry, }); } ); const cleanupCircle = initCircle( map, () => modeRef.current, (geometry: Geometry) => { const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); onCreateRef.current({ type: "Feature", properties: { id, entity_id: selectedEntityIdRef.current || null, entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], entity_name: selectedEntityNameRef.current || null, entity_type_id: selectedEntityTypeIdRef.current || null, binding: [], }, geometry, }); } ); map.on("remove", cleanupCircle); map.on("remove", cleanupPath); map.on("remove", cleanupLine); map.on("remove", cleanupPoint); map.on("remove", cleanupSelect); map.on("remove", cleanup); map.on("remove", () => map.off("zoom", syncZoomLevel)); // after everything mounted, push current draft to sources applyDraftToMap(draftRef.current); editingEngineRef.current?.bindEditEvents(map); }); return () => map.remove(); }, [applyDraftToMap]); const handleZoomByStep = (delta: number) => { const map = mapRef.current; if (!map) return; const next = clampNumber(zoomLevel + delta, zoomBounds.min, zoomBounds.max); map.easeTo({ zoom: next, duration: 120 }); }; const handleZoomSliderChange = (nextRaw: number) => { const map = mapRef.current; if (!map || !Number.isFinite(nextRaw)) return; const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max); map.easeTo({ zoom: next, duration: 80 }); }; // sync draft -> map sources and drop edit overlays if feature vanished useEffect(() => { applyDraftToMap(draft); const editingId = editingEngineRef.current?.editingRef.current?.id; if (editingId !== undefined && editingId !== null) { const stillExists = draft.features.some((f) => f.properties.id === editingId); if (!stillExists) { editingEngineRef.current?.clearEditing(); } } }, [draft, selectedFeatureId, applyDraftToMap]); return (
handleZoomSliderChange(Number(event.target.value))} style={{ flex: 1, accentColor: "#38bdf8", cursor: "pointer", }} aria-label="Map zoom" />
{zoomLevel.toFixed(1)}x
); } function applyBackgroundLayerVisibility( map: maplibregl.Map, visibility: BackgroundLayerVisibility ) { for (const layer of BACKGROUND_LAYER_OPTIONS) { if (!map.getLayer(layer.id)) continue; map.setLayoutProperty( layer.id, "visibility", visibility[layer.id] ? "visible" : "none" ); } } function filterDraftByBinding( fc: FeatureCollection, selectedFeatureId: string | number | null ): FeatureCollection { const selectedId = selectedFeatureId !== null ? String(selectedFeatureId) : null; if (selectedId === null) { return { ...fc, features: fc.features.filter((feature) => !normalizeBindingIds(feature.properties.binding).length), }; } return { ...fc, features: fc.features.filter((feature) => { const featureId = String(feature.properties.id); if (featureId === selectedId) return true; const bindingIds = normalizeBindingIds(feature.properties.binding); if (!bindingIds.length) return true; return bindingIds.includes(selectedId); }), }; } function normalizeBindingIds(rawBinding: unknown): string[] { if (!Array.isArray(rawBinding)) return []; const deduped: string[] = []; const seen = new Set(); for (const rawId of rawBinding) { if (typeof rawId !== "string" && typeof rawId !== "number") continue; const id = String(rawId).trim(); if (!id || seen.has(id)) continue; seen.add(id); deduped.push(id); } return deduped; } function splitDraftFeatures(fc: FeatureCollection) { const polygons = { type: "FeatureCollection", features: fc.features.filter((f) => f.geometry.type !== "Point"), } as FeatureCollection; const points = { type: "FeatureCollection", features: fc.features.filter((f) => f.geometry.type === "Point"), } as FeatureCollection; return { polygons, points }; } function ensurePathArrowIcon(map: maplibregl.Map): boolean { if (map.hasImage(PATH_ARROW_ICON_ID)) return true; const imageData = createPathArrowImageData(); if (!imageData) return false; map.addImage(PATH_ARROW_ICON_ID, imageData, { pixelRatio: 2 }); return true; } function createPathArrowImageData(): ImageData | null { const size = 56; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); if (!ctx) return null; ctx.clearRect(0, 0, size, size); ctx.strokeStyle = "#0f172a"; ctx.fillStyle = "#38bdf8"; ctx.lineWidth = 4; ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(8, 16); ctx.lineTo(28, 16); ctx.lineTo(28, 10); ctx.lineTo(46, 28); ctx.lineTo(28, 46); ctx.lineTo(28, 40); ctx.lineTo(8, 40); ctx.closePath(); ctx.fill(); ctx.stroke(); return ctx.getImageData(0, 0, size, size); } function ensurePointIcons(map: maplibregl.Map): boolean { let added = false; for (const spec of POINT_ICON_SPECS) { if (map.hasImage(spec.id)) { added = true; continue; } const imageData = createPointIconImageData(spec); if (!imageData) continue; map.addImage(spec.id, imageData, { pixelRatio: 2 }); added = true; } return added; } function createPointIconImageData(spec: PointIconSpec): ImageData | null { const size = 64; const radius = 18; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); if (!ctx) return null; ctx.clearRect(0, 0, size, size); // soft shadow ctx.fillStyle = "rgba(2, 6, 23, 0.28)"; ctx.beginPath(); ctx.arc(size / 2, size / 2 + 4, radius, 0, Math.PI * 2); ctx.fill(); // icon body ctx.fillStyle = spec.fill; ctx.strokeStyle = spec.stroke; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // short type mark ctx.fillStyle = "#ffffff"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = spec.label.length > 1 ? "700 15px sans-serif" : "700 20px sans-serif"; ctx.fillText(spec.label, size / 2, size / 2 + 0.5); return ctx.getImageData(0, 0, size, size); } function buildPointIconExpression(): maplibregl.ExpressionSpecification { const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]]; for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) { expression.push(typeId, iconId); } expression.push(DEFAULT_POINT_ICON_ID); return expression as maplibregl.ExpressionSpecification; } function buildTypeMatchExpression( valueByType: Record, fallback: string | number ): maplibregl.ExpressionSpecification { const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]]; for (const [typeId, value] of Object.entries(valueByType)) { expression.push(typeId, value); } expression.push(fallback); return expression as maplibregl.ExpressionSpecification; } function roundZoom(value: number): number { return Math.round(value * 10) / 10; } function clampNumber(value: number, min: number, max: number): number { if (value < min) return min; if (value > max) return max; return value; } const zoomButtonStyle: React.CSSProperties = { width: "28px", height: "28px", borderRadius: "999px", border: "1px solid #334155", background: "#1e293b", color: "#f8fafc", fontSize: "18px", lineHeight: "1", cursor: "pointer", display: "grid", placeItems: "center", };