add line | add circle | zoom | selectable geometry | geometry define entities type
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -15,9 +16,10 @@ 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";
|
||||
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;
|
||||
@@ -36,22 +38,120 @@ type PointIconSpec = {
|
||||
|
||||
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<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 POINT_COLOR_BY_TYPE: Record<string, string> = {
|
||||
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-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<string, string> = {
|
||||
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",
|
||||
city: "point-icon-city",
|
||||
ruin: "point-icon-ruin",
|
||||
port: "point-icon-port",
|
||||
bridge: "point-icon-bridge",
|
||||
region: "point-icon-region",
|
||||
event: "point-icon-event",
|
||||
};
|
||||
@@ -60,6 +160,7 @@ export default function Map({
|
||||
mode,
|
||||
draft,
|
||||
backgroundVisibility,
|
||||
selectedFeatureId,
|
||||
selectedEntityId,
|
||||
selectedEntityName,
|
||||
selectedEntityTypeId,
|
||||
@@ -72,6 +173,7 @@ export default function Map({
|
||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
||||
const selectedEntityIdRef = useRef<string | null>(selectedEntityId);
|
||||
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
|
||||
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
|
||||
@@ -79,6 +181,8 @@ export default function Map({
|
||||
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<ReturnType<typeof createEditingEngine> | null>(null);
|
||||
|
||||
@@ -89,6 +193,12 @@ export default function Map({
|
||||
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",
|
||||
@@ -107,6 +217,10 @@ export default function Map({
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedFeatureIdRef.current = selectedFeatureId;
|
||||
}, [selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityIdRef.current = selectedEntityId;
|
||||
}, [selectedEntityId]);
|
||||
@@ -167,7 +281,8 @@ export default function Map({
|
||||
// clear all feature-state (selection) to prevent ghost layers after undo
|
||||
map.removeFeatureState({ source: "countries" });
|
||||
|
||||
const { polygons, points } = splitDraftFeatures(fc);
|
||||
const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current);
|
||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||
|
||||
countriesSource.setData(polygons);
|
||||
placesSource.setData(points);
|
||||
@@ -177,6 +292,8 @@ export default function Map({
|
||||
const map = new maplibregl.Map({
|
||||
container: "map",
|
||||
attributionControl: false,
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -245,8 +362,8 @@ export default function Map({
|
||||
source: "base",
|
||||
"source-layer": "countries",
|
||||
paint: {
|
||||
"fill-color": "#334155",
|
||||
"fill-opacity": 0.28,
|
||||
"fill-color": COUNTRY_FILL_COLOR_EXPRESSION,
|
||||
"fill-opacity": 0.38,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -333,10 +450,16 @@ export default function Map({
|
||||
mapRef.current = map;
|
||||
|
||||
map.on("load", async () => {
|
||||
const placesMinZoom = 5;
|
||||
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", {
|
||||
@@ -396,6 +519,26 @@ export default function Map({
|
||||
},
|
||||
});
|
||||
|
||||
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: {
|
||||
@@ -459,9 +602,14 @@ export default function Map({
|
||||
"",
|
||||
],
|
||||
"#ef4444", // no entity
|
||||
"#f59e0b", // linked entity
|
||||
buildTypeMatchExpression(POLYGON_FILL_BY_TYPE, "#f59e0b"),
|
||||
],
|
||||
"fill-opacity": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
0.6,
|
||||
buildTypeMatchExpression(POLYGON_OPACITY_BY_TYPE, 0.5),
|
||||
],
|
||||
"fill-opacity": 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -471,7 +619,12 @@ export default function Map({
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"line-color": "#fbbf24",
|
||||
"line-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#14532d",
|
||||
buildTypeMatchExpression(POLYGON_STROKE_BY_TYPE, "#fbbf24"),
|
||||
],
|
||||
"line-width": 2,
|
||||
},
|
||||
});
|
||||
@@ -480,6 +633,7 @@ export default function Map({
|
||||
id: "routes-line",
|
||||
type: "line",
|
||||
source: "countries",
|
||||
minzoom: geometryMinZoom,
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
paint: {
|
||||
"line-color": [
|
||||
@@ -488,7 +642,7 @@ export default function Map({
|
||||
"#22c55e",
|
||||
["==", ["coalesce", ["get", "entity_id"], ""], ""],
|
||||
"#ef4444",
|
||||
"#38bdf8",
|
||||
buildTypeMatchExpression(LINE_COLOR_BY_TYPE, "#38bdf8"),
|
||||
],
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
@@ -507,7 +661,12 @@ export default function Map({
|
||||
id: "routes-arrow",
|
||||
type: "symbol",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
minzoom: geometryMinZoom,
|
||||
filter: [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["coalesce", ["get", "line_mode"], "path"], "path"],
|
||||
],
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": 60,
|
||||
@@ -564,7 +723,7 @@ export default function Map({
|
||||
id: "places-circle",
|
||||
type: "circle",
|
||||
source: "places",
|
||||
minzoom: placesMinZoom,
|
||||
minzoom: geometryMinZoom,
|
||||
paint: {
|
||||
"circle-color": [
|
||||
"case",
|
||||
@@ -574,7 +733,7 @@ export default function Map({
|
||||
"",
|
||||
],
|
||||
"#ef4444",
|
||||
"#10b981",
|
||||
buildTypeMatchExpression(POINT_COLOR_BY_TYPE, "#10b981"),
|
||||
],
|
||||
"circle-radius": 4,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
@@ -590,7 +749,7 @@ export default function Map({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
minzoom: placesMinZoom,
|
||||
minzoom: geometryMinZoom,
|
||||
layout: {
|
||||
"icon-image": buildPointIconExpression(),
|
||||
"icon-size": 0.5,
|
||||
@@ -614,6 +773,7 @@ export default function Map({
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
@@ -646,6 +806,28 @@ export default function Map({
|
||||
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,
|
||||
});
|
||||
@@ -665,6 +847,8 @@ export default function Map({
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
line_mode: "path",
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
@@ -684,6 +868,7 @@ export default function Map({
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
@@ -692,11 +877,13 @@ export default function Map({
|
||||
|
||||
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);
|
||||
@@ -707,6 +894,20 @@ export default function 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);
|
||||
@@ -717,9 +918,86 @@ export default function Map({
|
||||
editingEngineRef.current?.clearEditing();
|
||||
}
|
||||
}
|
||||
}, [draft, applyDraftToMap]);
|
||||
}, [draft, selectedFeatureId, applyDraftToMap]);
|
||||
|
||||
return <div id="map" style={{ width: "100%", height: "100vh" }} />;
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100vh", 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(
|
||||
@@ -736,6 +1014,44 @@ function applyBackgroundLayerVisibility(
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -835,7 +1151,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.font = "700 20px sans-serif";
|
||||
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);
|
||||
@@ -851,3 +1167,41 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
||||
expression.push(DEFAULT_POINT_ICON_ID);
|
||||
return expression as maplibregl.ExpressionSpecification;
|
||||
}
|
||||
|
||||
function buildTypeMatchExpression(
|
||||
valueByType: Record<string, string | number>,
|
||||
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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user