1166 lines
38 KiB
TypeScript
1166 lines
38 KiB
TypeScript
"use client";
|
|
|
|
import { type CSSProperties, 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;
|
|
onSelectFeatureId: (id: string | number | null) => void;
|
|
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
|
onDeleteFeature?: (id: string | number) => void;
|
|
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
|
allowGeometryEditing?: boolean;
|
|
respectBindingFilter?: boolean;
|
|
height?: CSSProperties["height"];
|
|
};
|
|
|
|
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
|
const POINT_ICON_URL = "/point.png";
|
|
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
|
const MAP_MIN_ZOOM = 1;
|
|
const MAP_MAX_ZOOM = 10;
|
|
const RASTER_BASE_SOURCE_ID = "rasterBase";
|
|
const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
|
const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
|
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<string, string> = {
|
|
country: "#2563eb",
|
|
state: "#0ea5e9",
|
|
empire: "#f59e0b",
|
|
kingdom: "#d97706",
|
|
war: "#dc2626",
|
|
battle: "#f43f5e",
|
|
civilization: "#14b8a6",
|
|
rebellion_zone: "#7c3aed",
|
|
};
|
|
|
|
const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
|
country: "#1e3a8a",
|
|
state: "#0c4a6e",
|
|
empire: "#7c2d12",
|
|
kingdom: "#9a3412",
|
|
war: "#7f1d1d",
|
|
battle: "#9f1239",
|
|
civilization: "#134e4a",
|
|
rebellion_zone: "#4c1d95",
|
|
};
|
|
|
|
const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
|
war: 0.3,
|
|
battle: 0.34,
|
|
civilization: 0.38,
|
|
rebellion_zone: 0.32,
|
|
};
|
|
|
|
const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
|
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 PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
|
attack_route: true,
|
|
retreat_route: true,
|
|
invasion_route: true,
|
|
migration_route: true,
|
|
refugee_route: true,
|
|
trade_route: true,
|
|
shipping_route: true,
|
|
};
|
|
|
|
export default function Map({
|
|
mode,
|
|
draft,
|
|
backgroundVisibility,
|
|
selectedFeatureId,
|
|
onSelectFeatureId,
|
|
onCreateFeature,
|
|
onDeleteFeature,
|
|
onUpdateFeature,
|
|
allowGeometryEditing = true,
|
|
respectBindingFilter = true,
|
|
height = "100vh",
|
|
}: 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 selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
|
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
|
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
|
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
|
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
|
const [zoomLevel, setZoomLevel] = useState(2);
|
|
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
|
|
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
|
|
|
useEffect(() => {
|
|
modeRef.current = mode;
|
|
}, [mode]);
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map || !map.isStyleLoaded()) 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(() => {
|
|
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 = respectBindingFilter
|
|
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
|
: fc;
|
|
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
|
|
|
countriesSource.setData(polygons);
|
|
placesSource.setData(points);
|
|
}, [respectBindingFilter]);
|
|
|
|
useEffect(() => {
|
|
const map = new maplibregl.Map({
|
|
container: "map",
|
|
attributionControl: false,
|
|
minZoom: MAP_MIN_ZOOM,
|
|
maxZoom: MAP_MAX_ZOOM,
|
|
style: {
|
|
version: 8,
|
|
sources: {
|
|
base: {
|
|
type: "vector",
|
|
tiles: [getVectorTileTemplateUrl()],
|
|
minzoom: 0,
|
|
maxzoom: 6,
|
|
},
|
|
},
|
|
layers: [
|
|
{
|
|
id: "background",
|
|
type: "background",
|
|
paint: {
|
|
"background-color": "#0b1220",
|
|
},
|
|
},
|
|
{
|
|
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 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",
|
|
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",
|
|
filter: [
|
|
"all",
|
|
["==", ["geometry-type"], "LineString"],
|
|
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
|
|
],
|
|
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",
|
|
paint: {
|
|
"circle-color": "#ef4444",
|
|
"circle-radius": 4,
|
|
"circle-stroke-color": "#ffffff",
|
|
"circle-stroke-width": 1,
|
|
"circle-opacity": 0.85,
|
|
},
|
|
});
|
|
|
|
addPointSymbolLayer(map);
|
|
|
|
// init drawing
|
|
const cleanup = initDrawing(
|
|
map,
|
|
() => modeRef.current,
|
|
(geometry: Geometry) => {
|
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
|
onCreateRef.current?.({
|
|
type: "Feature",
|
|
properties: {
|
|
id,
|
|
type: null,
|
|
geometry_preset: "polygon",
|
|
entity_id: null,
|
|
entity_ids: [],
|
|
entity_name: null,
|
|
entity_type_id: null,
|
|
binding: [],
|
|
},
|
|
geometry,
|
|
});
|
|
}
|
|
);
|
|
|
|
const cleanupSelect = initSelect(
|
|
map,
|
|
() => modeRef.current,
|
|
allowGeometryEditing
|
|
? (id: string | number) => {
|
|
// ensure edit overlays are cleared when a feature gets removed
|
|
editingEngineRef.current?.clearEditing();
|
|
onSelectFeatureIdRef.current?.(null);
|
|
onDeleteRef.current?.(id);
|
|
}
|
|
: undefined,
|
|
allowGeometryEditing
|
|
? (feature) => editingEngineRef.current?.beginEditing(feature)
|
|
: undefined,
|
|
(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,
|
|
type: null,
|
|
geometry_preset: "point",
|
|
entity_id: null,
|
|
entity_ids: [],
|
|
entity_name: null,
|
|
entity_type_id: 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,
|
|
type: "defense_line",
|
|
geometry_preset: "line",
|
|
entity_id: null,
|
|
entity_ids: [],
|
|
entity_name: null,
|
|
entity_type_id: null,
|
|
binding: [],
|
|
},
|
|
geometry,
|
|
});
|
|
}
|
|
);
|
|
|
|
const cleanupPath = initPath(
|
|
map,
|
|
() => modeRef.current,
|
|
(geometry: Geometry) => {
|
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
|
onCreateRef.current?.({
|
|
type: "Feature",
|
|
properties: {
|
|
id,
|
|
type: "attack_route",
|
|
geometry_preset: "line",
|
|
entity_id: null,
|
|
entity_ids: [],
|
|
entity_name: null,
|
|
entity_type_id: null,
|
|
binding: [],
|
|
},
|
|
geometry,
|
|
});
|
|
}
|
|
);
|
|
|
|
const cleanupCircle = initCircle(
|
|
map,
|
|
() => modeRef.current,
|
|
(geometry: Geometry) => {
|
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
|
onCreateRef.current?.({
|
|
type: "Feature",
|
|
properties: {
|
|
id,
|
|
type: null,
|
|
geometry_preset: "circle-area",
|
|
entity_id: null,
|
|
entity_ids: [],
|
|
entity_name: null,
|
|
entity_type_id: 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);
|
|
|
|
if (allowGeometryEditing) {
|
|
editingEngineRef.current?.bindEditEvents(map);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
if (mapRef.current === map) {
|
|
mapRef.current = null;
|
|
}
|
|
map.remove();
|
|
};
|
|
}, [allowGeometryEditing, 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 (allowGeometryEditing && editingId !== undefined && editingId !== null) {
|
|
const stillExists = draft.features.some((f) => f.properties.id === editingId);
|
|
if (!stillExists) {
|
|
editingEngineRef.current?.clearEditing();
|
|
}
|
|
}
|
|
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
|
|
|
|
return (
|
|
<div style={{ width: "100%", height, position: "relative" }}>
|
|
<div id="map" style={{ width: "100%", height: "100%" }} />
|
|
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: "10px",
|
|
left: "16px",
|
|
right: "16px",
|
|
zIndex: 12,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
maxWidth: "520px",
|
|
margin: "0 auto",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "10px",
|
|
background: "rgba(15, 23, 42, 0.88)",
|
|
border: "1px solid rgba(148, 163, 184, 0.38)",
|
|
borderRadius: "999px",
|
|
padding: "8px 12px",
|
|
color: "#e2e8f0",
|
|
backdropFilter: "blur(3px)",
|
|
pointerEvents: "auto",
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleZoomByStep(-0.8)}
|
|
style={zoomButtonStyle}
|
|
aria-label="Zoom out"
|
|
>
|
|
-
|
|
</button>
|
|
|
|
<input
|
|
type="range"
|
|
min={zoomBounds.min}
|
|
max={zoomBounds.max}
|
|
step={0.1}
|
|
value={zoomLevel}
|
|
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
|
style={{
|
|
flex: 1,
|
|
accentColor: "#38bdf8",
|
|
cursor: "pointer",
|
|
}}
|
|
aria-label="Map zoom"
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleZoomByStep(0.8)}
|
|
style={zoomButtonStyle}
|
|
aria-label="Zoom in"
|
|
>
|
|
+
|
|
</button>
|
|
|
|
<div
|
|
style={{
|
|
minWidth: "56px",
|
|
textAlign: "right",
|
|
fontSize: "12px",
|
|
color: "#cbd5e1",
|
|
fontVariantNumeric: "tabular-nums",
|
|
}}
|
|
>
|
|
{zoomLevel.toFixed(1)}x
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function applyBackgroundLayerVisibility(
|
|
map: maplibregl.Map,
|
|
visibility: BackgroundLayerVisibility
|
|
) {
|
|
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
|
|
|
|
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
|
if (layer.id === RASTER_BASE_LAYER_ID) continue;
|
|
if (!map.getLayer(layer.id)) continue;
|
|
map.setLayoutProperty(
|
|
layer.id,
|
|
"visibility",
|
|
visibility[layer.id] ? "visible" : "none"
|
|
);
|
|
}
|
|
}
|
|
|
|
function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
|
|
if (shouldShow) {
|
|
ensureRasterBaseLayer(map);
|
|
return;
|
|
}
|
|
|
|
removeRasterBaseLayer(map);
|
|
}
|
|
|
|
function ensureRasterBaseLayer(map: maplibregl.Map) {
|
|
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
|
|
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
|
|
}
|
|
|
|
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
|
|
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
|
|
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
|
|
: undefined;
|
|
map.addLayer(createRasterBaseLayer(), beforeId);
|
|
}
|
|
|
|
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
|
|
}
|
|
|
|
function removeRasterBaseLayer(map: maplibregl.Map) {
|
|
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
|
|
map.removeLayer(RASTER_BASE_LAYER_ID);
|
|
}
|
|
|
|
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
|
|
map.removeSource(RASTER_BASE_SOURCE_ID);
|
|
}
|
|
}
|
|
|
|
function createRasterBaseSource() {
|
|
return {
|
|
type: "raster" as const,
|
|
tiles: [getRasterTileTemplateUrl()],
|
|
tileSize: 256,
|
|
minzoom: 0,
|
|
maxzoom: 6,
|
|
};
|
|
}
|
|
|
|
function createRasterBaseLayer() {
|
|
return {
|
|
id: RASTER_BASE_LAYER_ID,
|
|
type: "raster" as const,
|
|
source: RASTER_BASE_SOURCE_ID,
|
|
paint: {
|
|
"raster-opacity": 0.92,
|
|
"raster-resampling": "linear" as const,
|
|
},
|
|
};
|
|
}
|
|
|
|
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<string>();
|
|
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 addPointSymbolLayer(map: maplibregl.Map) {
|
|
void ensurePointAssetIcon(map).then((hasPointIcon) => {
|
|
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
|
|
|
map.addLayer({
|
|
id: "places-symbol",
|
|
type: "symbol",
|
|
source: "places",
|
|
layout: {
|
|
"icon-image": DEFAULT_POINT_ICON_ID,
|
|
"icon-size": 0.06,
|
|
"icon-anchor": "center",
|
|
"icon-allow-overlap": true,
|
|
},
|
|
});
|
|
|
|
if (map.getLayer("places-circle")) {
|
|
map.setLayoutProperty("places-circle", "visibility", "none");
|
|
}
|
|
});
|
|
}
|
|
|
|
async function ensurePointAssetIcon(map: maplibregl.Map): Promise<boolean> {
|
|
if (map.hasImage(DEFAULT_POINT_ICON_ID)) return true;
|
|
|
|
try {
|
|
const image = await map.loadImage(POINT_ICON_URL);
|
|
if (!map.hasImage(DEFAULT_POINT_ICON_ID)) {
|
|
map.addImage(DEFAULT_POINT_ICON_ID, image.data);
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Failed to load point icon asset: ${POINT_ICON_URL}`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function buildTypeMatchExpression(
|
|
valueByType: Record<string, string | number | boolean>,
|
|
fallback: string | number | boolean
|
|
): maplibregl.ExpressionSpecification {
|
|
const expression: unknown[] = ["match", getFeatureTypeExpression()];
|
|
|
|
for (const [typeId, value] of Object.entries(valueByType)) {
|
|
expression.push(typeId, value);
|
|
}
|
|
|
|
expression.push(fallback);
|
|
return expression as maplibregl.ExpressionSpecification;
|
|
}
|
|
|
|
function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
|
|
return [
|
|
"coalesce",
|
|
["get", "type"],
|
|
["get", "entity_type_id"],
|
|
"",
|
|
] 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",
|
|
};
|