504 lines
17 KiB
TypeScript
504 lines
17 KiB
TypeScript
"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<maplibregl.Map | null>(null);
|
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
|
const draftRef = useRef<FeatureCollection>(draft);
|
|
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
|
const onCreateRef = useRef(onCreateFeature);
|
|
const onDeleteRef = useRef(onDeleteFeature);
|
|
const onUpdateRef = useRef(onUpdateFeature);
|
|
|
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | 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 <div id="map" style={{ flex: 1, height: "100vh" }} />;
|
|
}
|
|
|
|
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 };
|
|
}
|