"use client"; import { useEffect, useRef, useCallback } 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 { 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-path" | "add-circle"; draft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; 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 POINT_ICON_SPECS: PointIconSpec[] = [ { id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" }, { id: "point-icon-castle", fill: "#7c3aed", stroke: "#4c1d95", label: "F" }, { id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" }, { id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "T" }, { 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 = { country: "point-icon-country", castle: "point-icon-castle", kingdom: "point-icon-kingdom", city: "point-icon-city", region: "point-icon-region", event: "point-icon-event", }; export default function Map({ mode, draft, backgroundVisibility, 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 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 editingEngineRef = useRef | null>(null); useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { const map = mapRef.current; if (!map) return; 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(() => { 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 { polygons, points } = splitDraftFeatures(fc); countriesSource.setData(polygons); placesSource.setData(points); }, []); useEffect(() => { const map = new maplibregl.Map({ container: "map", attributionControl: false, 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": "#334155", "fill-opacity": 0.28, }, }, { 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 placesMinZoom = 5; applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current); const hasPathArrowIcon = ensurePathArrowIcon(map); // 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-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 "#f59e0b", // linked entity ], "fill-opacity": 0.5, }, }); map.addLayer({ id: "countries-line", type: "line", source: "countries", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": "#fbbf24", "line-width": 2, }, }); map.addLayer({ id: "routes-line", type: "line", source: "countries", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": [ "case", ["boolean", ["feature-state", "selected"], false], "#22c55e", ["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444", "#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", filter: ["==", ["geometry-type"], "LineString"], 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: placesMinZoom, paint: { "circle-color": [ "case", [ "==", ["coalesce", ["get", "entity_id"], ""], "", ], "#ef4444", "#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: placesMinZoom, 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, }, 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, }, 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, }, 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, }, geometry, }); } ); map.on("remove", cleanupCircle); map.on("remove", cleanupPath); map.on("remove", cleanupPoint); map.on("remove", cleanupSelect); map.on("remove", cleanup); // after everything mounted, push current draft to sources applyDraftToMap(draftRef.current); editingEngineRef.current?.bindEditEvents(map); }); return () => map.remove(); }, [applyDraftToMap]); // 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, applyDraftToMap]); return
; } 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 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 = "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; }