"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 { 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"; draft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; onCreateFeature: (feature: FeatureCollection["features"][number]) => void; onDeleteFeature: (id: string | number) => void; onUpdateFeature: (id: string | number, geometry: Geometry) => void; }; export default function Map({ mode, draft, backgroundVisibility, onCreateFeature, onDeleteFeature, onUpdateFeature, }: MapProps) { const mapRef = useRef(null); const modeRef = useRef(mode); const draftRef = useRef(draft); const backgroundVisibilityRef = useRef(backgroundVisibility); const onCreateRef = useRef(onCreateFeature); const onDeleteRef = useRef(onDeleteFeature); const onUpdateRef = useRef(onUpdateFeature); const editingEngineRef = useRef | null>(null); useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { draftRef.current = draft; }, [draft]); 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); // 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, }, }); // data thật map.addSource("countries", { type: "geojson", data: { type: "FeatureCollection", features: [], }, promoteId: "id", }); map.addLayer({ id: "countries-fill", type: "fill", source: "countries", paint: { "fill-color": [ "case", ["boolean", ["feature-state", "selected"], false], "#22c55e", // selected "#f59e0b", // normal ], "fill-opacity": 0.5, }, }); map.addLayer({ id: "countries-line", type: "line", source: "countries", paint: { "line-color": "#fbbf24", "line-width": 2, }, }); 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": "#ef4444", "circle-radius": 3, "circle-stroke-color": "#ffffff", "circle-stroke-width": 1, }, }); // load icon from /public and hide circle fallback when available try { const imageResponse = await map.loadImage("/point.png"); if (!map.hasImage("point-icon")) { map.addImage("point-icon", imageResponse.data); } if (!map.getLayer("places-symbol")) { map.addLayer({ id: "places-symbol", type: "symbol", source: "places", minzoom: placesMinZoom, layout: { "icon-image": "point-icon", "icon-size": 0.25, "icon-anchor": "bottom", }, }); } map.setLayoutProperty("places-circle", "visibility", "none"); } catch (err) { console.warn("Failed to load point icon, using circle fallback.", err); } // init drawing const cleanup = initDrawing( map, () => modeRef.current, (geometry: Geometry) => { const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); onCreateRef.current({ type: "Feature", properties: { id, kind: "country" }, geometry, }); } ); const cleanupSelect = initSelect( map, () => modeRef.current, (id: string | number) => { // ensure edit overlays are cleared when a feature gets removed editingEngineRef.current?.clearEditing(); onDeleteRef.current(id); }, (feature) => editingEngineRef.current?.beginEditing(feature) ); const cleanupPoint = initPoint( map, () => modeRef.current, (geometry: Geometry) => { const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); onCreateRef.current({ type: "Feature", properties: { id, kind: "place" }, geometry, }); } ); 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 }; }