@@ -1473,599 +322,6 @@ export default function Map({
);
}
-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 getSelectableLayers(map: maplibregl.Map): string[] {
- return [
- "countries-fill",
- "countries-line",
- "routes-line",
- "routes-path-arrow-fill",
- "routes-path-arrow-line",
- "routes-path-hit",
- "places-circle",
- "places-symbol",
- ].filter((layerId) => Boolean(map.getLayer(layerId)));
-}
-
-function filterDraftByBinding(
- fc: FeatureCollection,
- selectedFeatureIds: (string | number)[],
- highlightFeatures?: FeatureCollection | null
-): FeatureCollection {
- const selectedIds = new Set(selectedFeatureIds.map(String));
- if (highlightFeatures?.features) {
- for (const f of highlightFeatures.features) {
- if (f.properties?.id != null) selectedIds.add(String(f.properties.id));
- }
- }
-
- // Semantics:
- // - A feature's `binding` is a list of "child" geometry ids.
- // - Child geometries are hidden by default, and only shown when their parent is selected.
- const childIds = new Set
();
- for (const feature of fc.features) {
- for (const id of normalizeBindingIds(feature.properties.binding)) {
- childIds.add(id);
- }
- }
-
- if (selectedIds.size === 0) {
- return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
- }
-
- const selectedChildren = new Set();
- for (const feature of fc.features) {
- if (selectedIds.has(String(feature.properties.id))) {
- for (const id of normalizeBindingIds(feature.properties.binding)) {
- selectedChildren.add(id);
- }
- }
- }
-
- return {
- ...fc,
- features: fc.features.filter((feature) => {
- const featureId = String(feature.properties.id);
- if (selectedIds.has(featureId)) return true;
- if (selectedChildren.has(featureId)) return true;
- return !childIds.has(featureId);
- }),
- };
-}
-
-function filterDraftByGeometryVisibility(
- fc: FeatureCollection,
- visibility: Record | null | undefined
-): FeatureCollection {
- if (!visibility) return fc;
-
- return {
- ...fc,
- features: fc.features.filter((feature) => {
- const key = getFeatureSemanticType(feature);
- if (!key) return true;
- return visibility[key] !== false;
- }),
- };
-}
-
-function normalizeBindingIds(rawBinding: unknown): string[] {
- if (!Array.isArray(rawBinding)) return [];
- const deduped: string[] = [];
- const seen = new Set();
- 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" && f.geometry.type !== "MultiPoint"
- ),
- } as FeatureCollection;
-
- const points = {
- type: "FeatureCollection",
- features: fc.features.filter((f) =>
- f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
- ),
- } as FeatureCollection;
-
- return { polygons, points };
-}
-
-function setSelectedFeatureState(
- map: maplibregl.Map,
- id: string | number | null,
- selected: boolean
-) {
- if (id === null) return;
- for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
- if (!map.getSource(sourceId)) continue;
- map.setFeatureState({ source: sourceId, id }, { selected });
- }
-}
-
-function fitMapToFeatureCollection(
- map: maplibregl.Map,
- fc: FeatureCollection,
- padding?: number | maplibregl.PaddingOptions
-): boolean {
- const bbox = getFeatureCollectionBBox(fc);
- if (!bbox) return false;
-
- const resolvedPadding =
- typeof padding === "number" || padding
- ? padding
- : 58;
-
- const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
- const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
- if (lngSpan < 0.000001 && latSpan < 0.000001) {
- map.easeTo({
- center: [bbox.minLng, bbox.minLat],
- zoom: 6,
- padding: resolvedPadding,
- duration: 0,
- });
- return true;
- }
-
- map.fitBounds(
- [
- [bbox.minLng, bbox.minLat],
- [bbox.maxLng, bbox.maxLat],
- ],
- {
- padding: resolvedPadding,
- maxZoom: 7,
- duration: 0,
- }
- );
- return true;
-}
-
-function getFeatureCollectionBBox(
- fc: FeatureCollection
-): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
- const points = fc.features.flatMap((feature) => collectCoordinatePairs(feature.geometry.coordinates));
- if (!points.length) return null;
-
- let minLng = Number.POSITIVE_INFINITY;
- let minLat = Number.POSITIVE_INFINITY;
- let maxLng = Number.NEGATIVE_INFINITY;
- let maxLat = Number.NEGATIVE_INFINITY;
-
- for (const [lng, lat] of points) {
- minLng = Math.min(minLng, lng);
- minLat = Math.min(minLat, lat);
- maxLng = Math.max(maxLng, lng);
- maxLat = Math.max(maxLat, lat);
- }
-
- return { minLng, minLat, maxLng, maxLat };
-}
-
-function collectCoordinatePairs(value: unknown): Array<[number, number]> {
- if (!Array.isArray(value)) return [];
- if (
- value.length >= 2 &&
- typeof value[0] === "number" &&
- typeof value[1] === "number" &&
- Number.isFinite(value[0]) &&
- Number.isFinite(value[1])
- ) {
- return [[value[0], value[1]]];
- }
- return value.flatMap((item) => collectCoordinatePairs(item));
-}
-
-function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
- const features = fc.features
- .map((feature) => {
- if (!isPathFeature(feature) || feature.geometry.type !== "LineString") return null;
- const geometry = buildPathArrowGeometry(feature.geometry.coordinates);
- if (!geometry) return null;
- return {
- type: "Feature" as const,
- properties: { ...feature.properties },
- geometry,
- };
- })
- .filter((feature): feature is Feature => feature !== null);
-
- return {
- type: "FeatureCollection",
- features,
- };
-}
-
-function isPathFeature(feature: Feature): boolean {
- const featureType = getFeatureSemanticType(feature);
- return Boolean(featureType && PATH_RENDER_BY_TYPE[featureType]);
-}
-
-function getFeatureSemanticType(feature: Feature): string | null {
- const value = feature.properties.type || feature.properties.entity_type_id || null;
- if (!value) return null;
- const normalized = String(value).trim().toLowerCase();
- return normalized.length ? normalized : null;
-}
-
-function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
- const sourceCoords = removeDuplicatePathCoords(coords);
- if (sourceCoords.length < 2) return null;
-
- const origin = sourceCoords[0];
- const originLatRad = toRadians(origin[1]);
- const cosOriginLat = Math.max(Math.cos(originLatRad), 0.000001);
- const projected = sourceCoords.map((coord) => projectLngLat(coord, origin, cosOriginLat));
- const measured = buildMeasuredPath(projected);
- const totalLength = measured[measured.length - 1]?.distance || 0;
- if (totalLength <= 0) return null;
-
- const headLength = clampNumber(totalLength * 0.24, totalLength * 0.12, totalLength * 0.45);
- const bodyEndDistance = Math.max(totalLength - headLength, totalLength * 0.35);
- const bodyPoints = measured
- .filter((point) => point.distance < bodyEndDistance)
- .map(({ x, y, distance }) => ({ x, y, distance }));
- bodyPoints.push(pointAtDistance(measured, bodyEndDistance));
-
- if (bodyPoints.length < 2) return null;
-
- const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
- const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
- const headWidth = shoulderWidth * 2.0;
-
- const leftBody: ProjectedPoint[] = [];
- const rightBody: ProjectedPoint[] = [];
-
- for (let i = 0; i < bodyPoints.length; i += 1) {
- const point = bodyPoints[i];
- const normal = normalAt(bodyPoints, i);
- const progress = bodyEndDistance > 0
- ? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9)
- : 0;
- const width = tailWidth + (shoulderWidth - tailWidth) * progress;
- const half = width / 2;
- leftBody.push({
- x: point.x + normal.x * half,
- y: point.y + normal.y * half,
- });
- rightBody.push({
- x: point.x - normal.x * half,
- y: point.y - normal.y * half,
- });
- }
-
- const base = bodyPoints[bodyPoints.length - 1];
- const tip = pointAtDistance(measured, totalLength);
- const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
- const headHalf = headWidth / 2;
- const headBaseLeft = {
- x: base.x + headNormal.x * headHalf,
- y: base.y + headNormal.y * headHalf,
- };
- const headBaseRight = {
- x: base.x - headNormal.x * headHalf,
- y: base.y - headNormal.y * headHalf,
- };
-
- const ring = [
- ...leftBody,
- headBaseLeft,
- { x: tip.x, y: tip.y },
- headBaseRight,
- ...rightBody.reverse(),
- leftBody[0],
- ].map((point) => unprojectLngLat(point, origin, cosOriginLat));
-
- if (ring.length < 4) return null;
- return {
- type: "Polygon",
- coordinates: [ring],
- };
-}
-
-type ProjectedPoint = {
- x: number;
- y: number;
-};
-
-type MeasuredPoint = ProjectedPoint & {
- distance: number;
-};
-
-function removeDuplicatePathCoords(coords: [number, number][]): [number, number][] {
- const result: [number, number][] = [];
- for (const coord of coords) {
- const last = result[result.length - 1];
- if (last && last[0] === coord[0] && last[1] === coord[1]) continue;
- result.push(coord);
- }
- return result;
-}
-
-function projectLngLat(
- coord: [number, number],
- origin: [number, number],
- cosOriginLat: number
-): ProjectedPoint {
- const earthRadiusMeters = 6371008.8;
- return {
- x: toRadians(coord[0] - origin[0]) * earthRadiusMeters * cosOriginLat,
- y: toRadians(coord[1] - origin[1]) * earthRadiusMeters,
- };
-}
-
-function unprojectLngLat(
- point: ProjectedPoint,
- origin: [number, number],
- cosOriginLat: number
-): [number, number] {
- const earthRadiusMeters = 6371008.8;
- return [
- origin[0] + toDegrees(point.x / (earthRadiusMeters * cosOriginLat)),
- origin[1] + toDegrees(point.y / earthRadiusMeters),
- ];
-}
-
-function buildMeasuredPath(points: ProjectedPoint[]): MeasuredPoint[] {
- let distance = 0;
- return points.map((point, index) => {
- if (index > 0) {
- distance += distanceProjected(points[index - 1], point);
- }
- return {
- ...point,
- distance,
- };
- });
-}
-
-function pointAtDistance(points: MeasuredPoint[], targetDistance: number): MeasuredPoint {
- if (targetDistance <= 0) return points[0];
- for (let i = 1; i < points.length; i += 1) {
- const prev = points[i - 1];
- const next = points[i];
- if (targetDistance > next.distance) continue;
- const segmentLength = next.distance - prev.distance;
- const t = segmentLength > 0 ? (targetDistance - prev.distance) / segmentLength : 0;
- return {
- x: prev.x + (next.x - prev.x) * t,
- y: prev.y + (next.y - prev.y) * t,
- distance: targetDistance,
- };
- }
- return points[points.length - 1];
-}
-
-function normalAt(points: ProjectedPoint[], index: number): ProjectedPoint {
- const prev = points[Math.max(0, index - 1)];
- const next = points[Math.min(points.length - 1, index + 1)];
- return normalFromSegment(prev, next) || { x: 0, y: 1 };
-}
-
-function normalFromSegment(a: ProjectedPoint, b: ProjectedPoint): ProjectedPoint | null {
- const dx = b.x - a.x;
- const dy = b.y - a.y;
- const length = Math.hypot(dx, dy);
- if (length <= 0) return null;
- return {
- x: -dy / length,
- y: dx / length,
- };
-}
-
-function distanceProjected(a: ProjectedPoint, b: ProjectedPoint): number {
- return Math.hypot(b.x - a.x, b.y - a.y);
-}
-
-function toRadians(value: number): number {
- return (value * Math.PI) / 180;
-}
-
-function toDegrees(value: number): number {
- return (value * 180) / Math.PI;
-}
-
-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) => {
- try {
- 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");
- }
- } catch (err) {
- // Map might have been removed while icon was loading.
- console.warn("Add point symbol layer skipped", err);
- }
- });
-}
-
-async function ensurePointAssetIcon(map: maplibregl.Map): Promise {
- 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,
- 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 buildClientFeatureId(): string {
- return newId();
-}
-
-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",
diff --git a/src/uhm/components/map/mapUtils.ts b/src/uhm/components/map/mapUtils.ts
new file mode 100644
index 0000000..2837a62
--- /dev/null
+++ b/src/uhm/components/map/mapUtils.ts
@@ -0,0 +1,601 @@
+import maplibregl from "maplibre-gl";
+import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/uhm/lib/backgroundLayers";
+import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/useEditorState";
+import {
+ DEFAULT_POINT_ICON_ID,
+ FEATURE_STATE_SOURCE_IDS,
+ PATH_ARROW_ICON_ID,
+ POINT_ICON_URL,
+ RASTER_BASE_INSERT_BEFORE_LAYER_ID,
+ RASTER_BASE_LAYER_ID,
+ RASTER_BASE_SOURCE_ID,
+} from "@/uhm/lib/map/constants";
+import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/style";
+import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
+import { newId } from "@/uhm/lib/id";
+
+export 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"
+ );
+ }
+}
+
+export function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
+ if (shouldShow) {
+ ensureRasterBaseLayer(map);
+ return;
+ }
+ removeRasterBaseLayer(map);
+}
+
+export 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");
+}
+
+export 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);
+ }
+}
+
+export function createRasterBaseSource() {
+ return {
+ type: "raster" as const,
+ tiles: [getRasterTileTemplateUrl()],
+ tileSize: 256,
+ minzoom: 0,
+ maxzoom: 6,
+ };
+}
+
+export 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,
+ },
+ };
+}
+
+export function getSelectableLayers(map: maplibregl.Map): string[] {
+ return [
+ "countries-fill",
+ "countries-line",
+ "routes-line",
+ "routes-path-arrow-fill",
+ "routes-path-arrow-line",
+ "routes-path-hit",
+ "places-circle",
+ "places-symbol",
+ ].filter((layerId) => Boolean(map.getLayer(layerId)));
+}
+
+export function filterDraftByBinding(
+ fc: FeatureCollection,
+ selectedFeatureIds: (string | number)[],
+ highlightFeatures?: FeatureCollection | null
+): FeatureCollection {
+ const selectedIds = new Set(selectedFeatureIds.map(String));
+ if (highlightFeatures?.features) {
+ for (const f of highlightFeatures.features) {
+ if (f.properties?.id != null) selectedIds.add(String(f.properties.id));
+ }
+ }
+
+ const childIds = new Set();
+ for (const feature of fc.features) {
+ for (const id of normalizeBindingIds(feature.properties.binding)) {
+ childIds.add(id);
+ }
+ }
+
+ if (selectedIds.size === 0) {
+ return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
+ }
+
+ const selectedChildren = new Set();
+ for (const feature of fc.features) {
+ if (selectedIds.has(String(feature.properties.id))) {
+ for (const id of normalizeBindingIds(feature.properties.binding)) {
+ selectedChildren.add(id);
+ }
+ }
+ }
+
+ return {
+ ...fc,
+ features: fc.features.filter((feature) => {
+ const featureId = String(feature.properties.id);
+ if (selectedIds.has(featureId)) return true;
+ if (selectedChildren.has(featureId)) return true;
+ return !childIds.has(featureId);
+ }),
+ };
+}
+
+export function filterDraftByGeometryVisibility(
+ fc: FeatureCollection,
+ visibility: Record | null | undefined
+): FeatureCollection {
+ if (!visibility) return fc;
+
+ return {
+ ...fc,
+ features: fc.features.filter((feature) => {
+ const key = getFeatureSemanticType(feature);
+ if (!key) return true;
+ return visibility[key] !== false;
+ }),
+ };
+}
+
+export function normalizeBindingIds(rawBinding: unknown): string[] {
+ if (!Array.isArray(rawBinding)) return [];
+ const deduped: string[] = [];
+ const seen = new Set();
+ 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;
+}
+
+export function splitDraftFeatures(fc: FeatureCollection) {
+ const polygons = {
+ type: "FeatureCollection",
+ features: fc.features.filter((f) =>
+ f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint"
+ ),
+ } as FeatureCollection;
+
+ const points = {
+ type: "FeatureCollection",
+ features: fc.features.filter((f) =>
+ f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
+ ),
+ } as FeatureCollection;
+
+ return { polygons, points };
+}
+
+export function setSelectedFeatureState(
+ map: maplibregl.Map,
+ id: string | number | null,
+ selected: boolean
+) {
+ if (id === null) return;
+ for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
+ if (!map.getSource(sourceId)) continue;
+ map.setFeatureState({ source: sourceId, id }, { selected });
+ }
+}
+
+export function fitMapToFeatureCollection(
+ map: maplibregl.Map,
+ fc: FeatureCollection,
+ padding?: number | maplibregl.PaddingOptions
+): boolean {
+ const bbox = getFeatureCollectionBBox(fc);
+ if (!bbox) return false;
+
+ const resolvedPadding = typeof padding === "number" || padding ? padding : 58;
+
+ const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
+ const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
+ if (lngSpan < 0.000001 && latSpan < 0.000001) {
+ map.easeTo({
+ center: [bbox.minLng, bbox.minLat],
+ zoom: 6,
+ padding: resolvedPadding,
+ duration: 0,
+ });
+ return true;
+ }
+
+ map.fitBounds(
+ [
+ [bbox.minLng, bbox.minLat],
+ [bbox.maxLng, bbox.maxLat],
+ ],
+ {
+ padding: resolvedPadding,
+ maxZoom: 7,
+ duration: 0,
+ }
+ );
+ return true;
+}
+
+export function getFeatureCollectionBBox(
+ fc: FeatureCollection
+): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
+ const points = fc.features.flatMap((feature) => collectCoordinatePairs(feature.geometry.coordinates));
+ if (!points.length) return null;
+
+ let minLng = Number.POSITIVE_INFINITY;
+ let minLat = Number.POSITIVE_INFINITY;
+ let maxLng = Number.NEGATIVE_INFINITY;
+ let maxLat = Number.NEGATIVE_INFINITY;
+
+ for (const [lng, lat] of points) {
+ minLng = Math.min(minLng, lng);
+ minLat = Math.min(minLat, lat);
+ maxLng = Math.max(maxLng, lng);
+ maxLat = Math.max(maxLat, lat);
+ }
+
+ return { minLng, minLat, maxLng, maxLat };
+}
+
+export function collectCoordinatePairs(value: unknown): Array<[number, number]> {
+ if (!Array.isArray(value)) return [];
+ if (
+ value.length >= 2 &&
+ typeof value[0] === "number" &&
+ typeof value[1] === "number" &&
+ Number.isFinite(value[0]) &&
+ Number.isFinite(value[1])
+ ) {
+ return [[value[0], value[1]]];
+ }
+ return value.flatMap((item) => collectCoordinatePairs(item));
+}
+
+export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
+ const features = fc.features
+ .map((feature) => {
+ if (!isPathFeature(feature) || feature.geometry.type !== "LineString") return null;
+ const geometry = buildPathArrowGeometry(feature.geometry.coordinates);
+ if (!geometry) return null;
+ return {
+ type: "Feature" as const,
+ properties: { ...feature.properties },
+ geometry,
+ };
+ })
+ .filter((feature): feature is Feature => feature !== null);
+
+ return {
+ type: "FeatureCollection",
+ features,
+ };
+}
+
+export function isPathFeature(feature: Feature): boolean {
+ const featureType = getFeatureSemanticType(feature);
+ return Boolean(featureType && PATH_RENDER_BY_TYPE[featureType]);
+}
+
+export function getFeatureSemanticType(feature: Feature): string | null {
+ const value = feature.properties.type || feature.properties.entity_type_id || null;
+ if (!value) return null;
+ const normalized = String(value).trim().toLowerCase();
+ return normalized.length ? normalized : null;
+}
+
+export function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
+ const sourceCoords = removeDuplicatePathCoords(coords);
+ if (sourceCoords.length < 2) return null;
+
+ const origin = sourceCoords[0];
+ const originLatRad = toRadians(origin[1]);
+ const cosOriginLat = Math.max(Math.cos(originLatRad), 0.000001);
+ const projected = sourceCoords.map((coord) => projectLngLat(coord, origin, cosOriginLat));
+ const measured = buildMeasuredPath(projected);
+ const totalLength = measured[measured.length - 1]?.distance || 0;
+ if (totalLength <= 0) return null;
+
+ const headLength = clampNumber(totalLength * 0.24, totalLength * 0.12, totalLength * 0.45);
+ const bodyEndDistance = Math.max(totalLength - headLength, totalLength * 0.35);
+ const bodyPoints = measured
+ .filter((point) => point.distance < bodyEndDistance)
+ .map(({ x, y, distance }) => ({ x, y, distance }));
+ bodyPoints.push(pointAtDistance(measured, bodyEndDistance));
+
+ if (bodyPoints.length < 2) return null;
+
+ const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
+ const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
+ const headWidth = shoulderWidth * 2.0;
+
+ const leftBody: ProjectedPoint[] = [];
+ const rightBody: ProjectedPoint[] = [];
+
+ for (let i = 0; i < bodyPoints.length; i += 1) {
+ const point = bodyPoints[i];
+ const normal = normalAt(bodyPoints, i);
+ const progress = bodyEndDistance > 0
+ ? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9)
+ : 0;
+ const width = tailWidth + (shoulderWidth - tailWidth) * progress;
+ const half = width / 2;
+ leftBody.push({
+ x: point.x + normal.x * half,
+ y: point.y + normal.y * half,
+ });
+ rightBody.push({
+ x: point.x - normal.x * half,
+ y: point.y - normal.y * half,
+ });
+ }
+
+ const base = bodyPoints[bodyPoints.length - 1];
+ const tip = pointAtDistance(measured, totalLength);
+ const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
+ const headHalf = headWidth / 2;
+ const headBaseLeft = {
+ x: base.x + headNormal.x * headHalf,
+ y: base.y + headNormal.y * headHalf,
+ };
+ const headBaseRight = {
+ x: base.x - headNormal.x * headHalf,
+ y: base.y - headNormal.y * headHalf,
+ };
+
+ const ring = [
+ ...leftBody,
+ headBaseLeft,
+ { x: tip.x, y: tip.y },
+ headBaseRight,
+ ...rightBody.reverse(),
+ leftBody[0],
+ ].map((point) => unprojectLngLat(point, origin, cosOriginLat));
+
+ if (ring.length < 4) return null;
+ return {
+ type: "Polygon",
+ coordinates: [ring],
+ };
+}
+
+export type ProjectedPoint = {
+ x: number;
+ y: number;
+};
+
+export type MeasuredPoint = ProjectedPoint & {
+ distance: number;
+};
+
+export function removeDuplicatePathCoords(coords: [number, number][]): [number, number][] {
+ const result: [number, number][] = [];
+ for (const coord of coords) {
+ const last = result[result.length - 1];
+ if (last && last[0] === coord[0] && last[1] === coord[1]) continue;
+ result.push(coord);
+ }
+ return result;
+}
+
+export function projectLngLat(
+ coord: [number, number],
+ origin: [number, number],
+ cosOriginLat: number
+): ProjectedPoint {
+ const earthRadiusMeters = 6371008.8;
+ return {
+ x: toRadians(coord[0] - origin[0]) * earthRadiusMeters * cosOriginLat,
+ y: toRadians(coord[1] - origin[1]) * earthRadiusMeters,
+ };
+}
+
+export function unprojectLngLat(
+ point: ProjectedPoint,
+ origin: [number, number],
+ cosOriginLat: number
+): [number, number] {
+ const earthRadiusMeters = 6371008.8;
+ return [
+ origin[0] + toDegrees(point.x / (earthRadiusMeters * cosOriginLat)),
+ origin[1] + toDegrees(point.y / earthRadiusMeters),
+ ];
+}
+
+export function buildMeasuredPath(points: ProjectedPoint[]): MeasuredPoint[] {
+ let distance = 0;
+ return points.map((point, index) => {
+ if (index > 0) {
+ distance += distanceProjected(points[index - 1], point);
+ }
+ return {
+ ...point,
+ distance,
+ };
+ });
+}
+
+export function pointAtDistance(points: MeasuredPoint[], targetDistance: number): MeasuredPoint {
+ if (targetDistance <= 0) return points[0];
+ for (let i = 1; i < points.length; i += 1) {
+ const prev = points[i - 1];
+ const next = points[i];
+ if (targetDistance > next.distance) continue;
+ const segmentLength = next.distance - prev.distance;
+ const t = segmentLength > 0 ? (targetDistance - prev.distance) / segmentLength : 0;
+ return {
+ x: prev.x + (next.x - prev.x) * t,
+ y: prev.y + (next.y - prev.y) * t,
+ distance: targetDistance,
+ };
+ }
+ return points[points.length - 1];
+}
+
+export function normalAt(points: ProjectedPoint[], index: number): ProjectedPoint {
+ const prev = points[Math.max(0, index - 1)];
+ const next = points[Math.min(points.length - 1, index + 1)];
+ return normalFromSegment(prev, next) || { x: 0, y: 1 };
+}
+
+export function normalFromSegment(a: ProjectedPoint, b: ProjectedPoint): ProjectedPoint | null {
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const length = Math.hypot(dx, dy);
+ if (length <= 0) return null;
+ return {
+ x: -dy / length,
+ y: dx / length,
+ };
+}
+
+export function distanceProjected(a: ProjectedPoint, b: ProjectedPoint): number {
+ return Math.hypot(b.x - a.x, b.y - a.y);
+}
+
+export function toRadians(value: number): number {
+ return (value * Math.PI) / 180;
+}
+
+export function toDegrees(value: number): number {
+ return (value * 180) / Math.PI;
+}
+
+export 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;
+}
+
+export function createPathArrowImageData(): ImageData | null {
+ const size = 56;
+ if (typeof document === "undefined") return null;
+ 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);
+}
+
+export function addPointSymbolLayer(map: maplibregl.Map) {
+ void ensurePointAssetIcon(map).then((hasPointIcon) => {
+ try {
+ 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");
+ }
+ } catch (err) {
+ console.warn("Add point symbol layer skipped", err);
+ }
+ });
+}
+
+export async function ensurePointAssetIcon(map: maplibregl.Map): Promise {
+ 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;
+ }
+}
+
+export function buildTypeMatchExpression(
+ valueByType: Record,
+ 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;
+}
+
+export function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
+ return [
+ "coalesce",
+ ["get", "type"],
+ ["get", "entity_type_id"],
+ "",
+ ] as maplibregl.ExpressionSpecification;
+}
+
+export function roundZoom(value: number): number {
+ return Math.round(value * 10) / 10;
+}
+
+export function buildClientFeatureId(): string {
+ return newId();
+}
+
+export function clampNumber(value: number, min: number, max: number): number {
+ if (value < min) return min;
+ if (value > max) return max;
+ return value;
+}
diff --git a/src/uhm/components/map/useMapInstance.ts b/src/uhm/components/map/useMapInstance.ts
new file mode 100644
index 0000000..33dcc7f
--- /dev/null
+++ b/src/uhm/components/map/useMapInstance.ts
@@ -0,0 +1,142 @@
+import { useEffect, useRef, useState, useCallback } from "react";
+import maplibregl from "maplibre-gl";
+import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants";
+import { clampNumber, roundZoom } from "./mapUtils";
+import { getBaseMapStyle } from "./useMapLayers";
+
+const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
+
+export function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
+ map.setProjection({ type: isGlobe ? "globe" : "mercator" });
+}
+
+export function useMapInstance() {
+ const containerRef = useRef(null);
+ const mapRef = useRef(null);
+ const [fatalInitError, setFatalInitError] = useState(null);
+
+ const [zoomLevel, setZoomLevel] = useState(2);
+ const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
+
+ const [isGlobeProjection, setIsGlobeProjection] = useState(() => {
+ if (typeof window === "undefined") return false;
+ try {
+ return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe";
+ } catch {
+ return false;
+ }
+ });
+
+ const [isMapLoaded, setIsMapLoaded] = useState(false);
+ const geolocationCenteredRef = useRef(false);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(
+ MAP_PROJECTION_STORAGE_KEY,
+ isGlobeProjection ? "globe" : "mercator"
+ );
+ } catch {
+ // ignore
+ }
+ }, [isGlobeProjection]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ try {
+ const map = new maplibregl.Map({
+ container,
+ attributionControl: false,
+ minZoom: MAP_MIN_ZOOM,
+ maxZoom: MAP_MAX_ZOOM,
+ style: getBaseMapStyle(),
+ center: [0, 20],
+ zoom: 2,
+ });
+
+ mapRef.current = map;
+
+ const syncZoomLevel = () => {
+ setZoomLevel(roundZoom(map.getZoom()));
+ };
+
+ map.on("load", () => {
+ setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
+ syncZoomLevel();
+ map.on("zoom", syncZoomLevel);
+ setIsMapLoaded(true);
+ });
+
+ return () => {
+ map.off("zoom", syncZoomLevel);
+ setIsMapLoaded(false);
+ if (mapRef.current === map) {
+ mapRef.current = null;
+ }
+ map.remove();
+ };
+ } catch (err) {
+ console.error("Map initialization failed", err);
+ setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
+ }
+ }, []);
+
+ // Sync Map Projection
+ useEffect(() => {
+ const map = mapRef.current;
+ if (!map) return;
+ const apply = () => {
+ if (mapRef.current !== map) return;
+ if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
+ applyMapProjection(map, isGlobeProjection);
+ };
+
+ if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) {
+ apply();
+ return;
+ }
+
+ map.once("load", apply);
+ map.once("style.load", apply);
+ return () => {
+ map.off("load", apply);
+ map.off("style.load", apply);
+ };
+ }, [isGlobeProjection]);
+
+ const handleZoomByStep = useCallback((delta: number) => {
+ const map = mapRef.current;
+ if (!map) return;
+ setZoomLevel((prev) => {
+ const next = clampNumber(prev + delta, zoomBounds.min, zoomBounds.max);
+ map.easeTo({ zoom: next, duration: 120 });
+ return next;
+ });
+ }, [zoomBounds]);
+
+ const handleZoomSliderChange = useCallback((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 });
+ setZoomLevel(next);
+ }, [zoomBounds]);
+
+ return {
+ mapRef,
+ containerRef,
+ fatalInitError,
+ setFatalInitError,
+ zoomLevel,
+ zoomBounds,
+ isGlobeProjection,
+ setIsGlobeProjection,
+ isMapLoaded,
+ geolocationCenteredRef,
+ handleZoomByStep,
+ handleZoomSliderChange,
+ };
+}
diff --git a/src/uhm/components/map/useMapInteraction.ts b/src/uhm/components/map/useMapInteraction.ts
new file mode 100644
index 0000000..6ab6f29
--- /dev/null
+++ b/src/uhm/components/map/useMapInteraction.ts
@@ -0,0 +1,317 @@
+import { useEffect, useRef } from "react";
+import maplibregl from "maplibre-gl";
+import { initDrawing } from "@/uhm/lib/engine/drawingEngine";
+import { initSelect } from "@/uhm/lib/engine/selectingEngine";
+import { initPoint } from "@/uhm/lib/engine/pointEngine";
+import { initLine } from "@/uhm/lib/engine/lineEngine";
+import { initPath } from "@/uhm/lib/engine/pathEngine";
+import { initCircle } from "@/uhm/lib/engine/circleEngine";
+import { createEditingEngine } from "@/uhm/lib/engine/editingEngine";
+import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/useEditorState";
+import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
+import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
+import { MapHoverPayload } from "../Map";
+
+type EngineBinding = {
+ cleanup: () => void;
+ cancel?: () => void;
+ clearSelection?: () => void;
+};
+
+type UseMapInteractionProps = {
+ mapRef: React.MutableRefObject;
+ mode: EditorMode;
+ modeRef: React.MutableRefObject;
+ draftRef: React.MutableRefObject;
+ allowGeometryEditing: boolean;
+ selectedFeatureIds: (string | number)[];
+ onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
+ onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
+ onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
+ onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
+ onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
+};
+
+export function useMapInteraction({
+ mapRef,
+ mode,
+ modeRef,
+ draftRef,
+ allowGeometryEditing,
+ selectedFeatureIds,
+ onSelectFeatureIdsRef,
+ onCreateRef,
+ onDeleteRef,
+ onUpdateRef,
+ onHoverFeatureChangeRef,
+}: UseMapInteractionProps) {
+ const editingEngineRef = useRef | null>(null);
+ const engineBindingsRef = useRef>>({});
+ const previousModeRef = useRef(mode);
+ const mapCleanupFnsRef = useRef void>>([]);
+
+ useEffect(() => {
+ if (!editingEngineRef.current) {
+ editingEngineRef.current = createEditingEngine({
+ mapRef,
+ onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry),
+ });
+ }
+ }, [mapRef, onUpdateRef]);
+
+ useEffect(() => {
+ if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
+ editingEngineRef.current?.clearEditing();
+ }
+ }, [mode, selectedFeatureIds]);
+
+ useEffect(() => {
+ const previousMode = previousModeRef.current;
+ if (previousMode !== mode) {
+ engineBindingsRef.current[previousMode]?.cancel?.();
+ previousModeRef.current = mode;
+ }
+
+ const map = mapRef.current;
+ if (!map || !map.isStyleLoaded()) return;
+ if (mode !== "draw") {
+ (map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
+ type: "FeatureCollection",
+ features: [],
+ });
+ }
+ 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, mapRef]);
+
+ const setupMapInteractions = (map: maplibregl.Map) => {
+ const drawingEngine = initDrawing(
+ map,
+ () => modeRef.current,
+ (geometry: Geometry) => {
+ const id = buildClientFeatureId();
+ onCreateRef.current?.({
+ type: "Feature",
+ properties: {
+ id,
+ type: "country",
+ geometry_preset: "polygon",
+ entity_id: null,
+ entity_ids: [],
+ entity_name: null,
+ entity_type_id: null,
+ binding: [],
+ },
+ geometry,
+ });
+ }
+ );
+
+ const selectEngine = initSelect(
+ map,
+ () => modeRef.current,
+ allowGeometryEditing
+ ? (id: string | number) => {
+ editingEngineRef.current?.clearEditing();
+ onSelectFeatureIdsRef.current?.([]);
+ onDeleteRef.current?.(id);
+ }
+ : undefined,
+ allowGeometryEditing
+ ? (feature) => {
+ const rawId = feature.id ?? feature.properties?.id;
+ const originalFeature = draftRef.current.features.find(
+ (item) => String(item.properties.id) === String(rawId)
+ );
+ editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
+ }
+ : undefined,
+ (ids) => onSelectFeatureIdsRef.current?.(ids)
+ );
+
+ const cleanupPoint = initPoint(
+ map,
+ () => modeRef.current,
+ (geometry: Geometry) => {
+ const id = buildClientFeatureId();
+ onCreateRef.current?.({
+ type: "Feature",
+ properties: {
+ id,
+ type: "city",
+ geometry_preset: "point",
+ entity_id: null,
+ entity_ids: [],
+ entity_name: null,
+ entity_type_id: null,
+ binding: [],
+ },
+ geometry,
+ });
+ }
+ );
+
+ const lineEngine = initLine(
+ map,
+ () => modeRef.current,
+ (geometry: Geometry) => {
+ const id = buildClientFeatureId();
+ 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 pathEngine = initPath(
+ map,
+ () => modeRef.current,
+ (geometry: Geometry) => {
+ const id = buildClientFeatureId();
+ 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 circleEngine = initCircle(
+ map,
+ () => modeRef.current,
+ (geometry: Geometry) => {
+ const id = buildClientFeatureId();
+ onCreateRef.current?.({
+ type: "Feature",
+ properties: {
+ id,
+ type: "war",
+ geometry_preset: "circle-area",
+ entity_id: null,
+ entity_ids: [],
+ entity_name: null,
+ entity_type_id: null,
+ binding: [],
+ },
+ geometry,
+ });
+ }
+ );
+
+ engineBindingsRef.current = {
+ draw: drawingEngine,
+ select: selectEngine,
+ "add-line": lineEngine,
+ "add-path": pathEngine,
+ "add-circle": circleEngine,
+ };
+
+ mapCleanupFnsRef.current.push(
+ circleEngine.cleanup,
+ pathEngine.cleanup,
+ lineEngine.cleanup,
+ cleanupPoint,
+ selectEngine.cleanup,
+ drawingEngine.cleanup
+ );
+
+ const handleHoverMove = (event: maplibregl.MapMouseEvent) => {
+ const callback = onHoverFeatureChangeRef.current;
+ if (!callback) return;
+
+ const selectableLayers = getSelectableLayers(map);
+ if (!selectableLayers.length) {
+ callback(null);
+ return;
+ }
+
+ const features = map.queryRenderedFeatures(event.point, {
+ layers: selectableLayers,
+ }) as maplibregl.MapGeoJSONFeature[];
+
+ const feature = features[0];
+ const rawFeatureId = feature?.id ?? feature?.properties?.id;
+ if (rawFeatureId === undefined || rawFeatureId === null) {
+ callback(null);
+ return;
+ }
+
+ const currentFeature =
+ draftRef.current.features.find(
+ (item) => String(item.properties.id) === String(rawFeatureId)
+ ) || null;
+
+ callback({
+ featureId: rawFeatureId,
+ feature: currentFeature,
+ point: { x: event.point.x, y: event.point.y },
+ lngLat: { lng: event.lngLat.lng, lat: event.lngLat.lat },
+ });
+ };
+
+ const handleCanvasMouseLeave = () => {
+ onHoverFeatureChangeRef.current?.(null);
+ };
+
+ map.on("mousemove", handleHoverMove);
+ mapCleanupFnsRef.current.push(() => map.off("mousemove", handleHoverMove));
+
+ map.getCanvasContainer().addEventListener("mouseleave", handleCanvasMouseLeave);
+ mapCleanupFnsRef.current.push(() => {
+ map.getCanvasContainer().removeEventListener("mouseleave", handleCanvasMouseLeave);
+ });
+
+ if (allowGeometryEditing) {
+ editingEngineRef.current?.bindEditEvents(map);
+ }
+ };
+
+ const cleanupMapInteractions = () => {
+ for (const cleanupFn of mapCleanupFnsRef.current) {
+ cleanupFn();
+ }
+ mapCleanupFnsRef.current = [];
+ engineBindingsRef.current = {};
+ };
+
+ return {
+ editingEngineRef,
+ setupMapInteractions,
+ cleanupMapInteractions,
+ };
+}
diff --git a/src/uhm/components/map/useMapLayers.ts b/src/uhm/components/map/useMapLayers.ts
new file mode 100644
index 0000000..ef0ffd4
--- /dev/null
+++ b/src/uhm/components/map/useMapLayers.ts
@@ -0,0 +1,607 @@
+import { useEffect } from "react";
+import maplibregl from "maplibre-gl";
+import { getVectorTileTemplateUrl } from "@/uhm/api/tiles";
+import {
+ COUNTRY_FILL_COLOR_EXPRESSION,
+ LINE_COLOR_BY_TYPE,
+ PATH_RENDER_BY_TYPE,
+ POLYGON_FILL_BY_TYPE,
+ POLYGON_OPACITY_BY_TYPE,
+ POLYGON_STROKE_BY_TYPE,
+} from "@/uhm/lib/map/style";
+import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants";
+import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID } from "@/uhm/lib/map/constants";
+import {
+ addPointSymbolLayer,
+ applyBackgroundLayerVisibility,
+ buildTypeMatchExpression,
+ ensurePathArrowIcon,
+} from "./mapUtils";
+import { BackgroundLayerVisibility } from "@/uhm/lib/backgroundLayers";
+import { FeatureCollection } from "@/uhm/lib/useEditorState";
+
+export function getBaseMapStyle(): maplibregl.StyleSpecification {
+ return {
+ version: 8,
+ glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
+ 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": "ne_10m_graticules_10",
+ 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": "ne_10m_land",
+ paint: {
+ "fill-color": "#1e293b",
+ "fill-opacity": 0.25,
+ },
+ },
+ {
+ id: "bg-countries-fill",
+ type: "fill",
+ source: "base",
+ "source-layer": "ne_10m_admin_0_countries",
+ paint: {
+ "fill-color": COUNTRY_FILL_COLOR_EXPRESSION,
+ "fill-opacity": 0.38,
+ },
+ },
+ {
+ id: "bg-country-borders-line",
+ type: "line",
+ source: "base",
+ "source-layer": "ne_10m_admin_0_boundary_lines_land",
+ paint: {
+ "line-color": "#cbd5e1",
+ "line-width": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 0, 0.2,
+ 4, 0.5,
+ 6, 1.1,
+ ],
+ "line-opacity": 0.85,
+ },
+ },
+ {
+ id: "country-labels",
+ type: "symbol",
+ source: "base",
+ "source-layer": "country_labels",
+ minzoom: 0,
+ layout: {
+ "text-field": [
+ "coalesce",
+ ["get", "NAME_EN"],
+ ["get", "NAME"],
+ ["get", "ADMIN"],
+ ["get", "name"],
+ "",
+ ],
+ "text-size": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 0, 15,
+ 1, 16,
+ 2, 17,
+ 4, 19,
+ 6, 23,
+ ],
+ "text-padding": 0,
+ "text-max-width": 10,
+ "text-allow-overlap": true,
+ "text-ignore-placement": true,
+ "symbol-placement": "point",
+ },
+ paint: {
+ "text-color": "#e2e8f0",
+ "text-halo-color": "#0b1220",
+ "text-halo-width": 1.2,
+ "text-halo-blur": 0.5,
+ },
+ },
+ {
+ id: "regions-line",
+ type: "line",
+ source: "base",
+ "source-layer": "ne_10m_geography_regions_polys",
+ 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": "ne_10m_lakes",
+ paint: {
+ "fill-color": "#1d4ed8",
+ "fill-opacity": 0.45,
+ },
+ },
+ {
+ id: "rivers-line",
+ type: "line",
+ source: "base",
+ "source-layer": "ne_10m_rivers_lake_centerlines",
+ 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": "ne_10m_geographic_lines",
+ paint: {
+ "line-color": "#94a3b8",
+ "line-width": 1.2,
+ "line-opacity": 0.8,
+ },
+ },
+ ],
+ };
+}
+
+export function setupMapLayers(
+ map: maplibregl.Map,
+ backgroundVisibility: BackgroundLayerVisibility,
+ highlightFeatures: FeatureCollection | null,
+ applyHighlightToMap: (fc: FeatureCollection) => void
+) {
+ applyBackgroundLayerVisibility(map, backgroundVisibility);
+ 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-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
+ map.addSource("countries", {
+ type: "geojson",
+ data: { type: "FeatureCollection", features: [] },
+ promoteId: "id",
+ });
+
+ map.addSource(PATH_ARROW_SOURCE_ID, {
+ type: "geojson",
+ data: EMPTY_FEATURE_COLLECTION,
+ 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",
+ [
+ "==",
+ ["coalesce", ["get", "entity_id"], ""],
+ "",
+ ],
+ "#ef4444",
+ 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: [
+ "all",
+ ["==", ["geometry-type"], "LineString"],
+ ["!=", buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false), true],
+ ],
+ 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,
+ },
+ });
+
+ map.addLayer({
+ id: "routes-path-arrow-fill",
+ type: "fill",
+ source: PATH_ARROW_SOURCE_ID,
+ paint: {
+ "fill-color": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ "#22c55e",
+ ["==", ["coalesce", ["get", "entity_id"], ""], ""],
+ "#ef4444",
+ buildTypeMatchExpression(LINE_COLOR_BY_TYPE, "#38bdf8"),
+ ],
+ "fill-opacity": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ 0.92,
+ 0.82,
+ ],
+ },
+ });
+
+ map.addLayer({
+ id: "routes-path-arrow-line",
+ type: "line",
+ source: PATH_ARROW_SOURCE_ID,
+ paint: {
+ "line-color": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ "#14532d",
+ "#0f172a",
+ ],
+ "line-width": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 1, 0.45,
+ 4, 0.8,
+ 6, 1.2,
+ ],
+ "line-opacity": 0.9,
+ },
+ });
+
+ map.addLayer({
+ id: "routes-path-hit",
+ type: "line",
+ source: "countries",
+ filter: [
+ "all",
+ ["==", ["geometry-type"], "LineString"],
+ buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
+ ],
+ paint: {
+ "line-color": "#ffffff",
+ "line-width": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 1, 12,
+ 4, 18,
+ 6, 24,
+ ],
+ "line-opacity": 0,
+ },
+ });
+
+ map.addSource("places", {
+ type: "geojson",
+ data: { type: "FeatureCollection", features: [] },
+ promoteId: "id",
+ });
+
+ // 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,
+ },
+ });
+
+ map.addLayer({
+ id: "places-circle",
+ type: "circle",
+ source: "places",
+ paint: {
+ "circle-color": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ "#22c55e",
+ "#ef4444",
+ ],
+ "circle-radius": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ 8,
+ 4,
+ ],
+ "circle-stroke-color": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ "#14532d",
+ "#ffffff",
+ ],
+ "circle-stroke-width": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ 3,
+ 1,
+ ],
+ "circle-opacity": 0.9,
+ },
+ });
+
+ map.addLayer({
+ id: "places-selected-halo",
+ type: "circle",
+ source: "places",
+ paint: {
+ "circle-color": "#22c55e",
+ "circle-radius": 13,
+ "circle-opacity": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ 0.28,
+ 0,
+ ],
+ "circle-stroke-color": "#14532d",
+ "circle-stroke-width": [
+ "case",
+ ["boolean", ["feature-state", "selected"], false],
+ 2,
+ 0,
+ ],
+ },
+ });
+
+ map.addSource("entity-focus", {
+ type: "geojson",
+ data: EMPTY_FEATURE_COLLECTION,
+ });
+
+ map.addLayer({
+ id: "entity-focus-fill",
+ type: "fill",
+ source: "entity-focus",
+ filter: ["==", ["geometry-type"], "Polygon"],
+ paint: {
+ "fill-color": "#fde047",
+ "fill-opacity": 0.2,
+ },
+ });
+
+ map.addLayer({
+ id: "entity-focus-line",
+ type: "line",
+ source: "entity-focus",
+ paint: {
+ "line-color": "#f59e0b",
+ "line-width": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 1, 2.4,
+ 4, 4,
+ 6, 5.5,
+ ],
+ "line-opacity": 0.98,
+ },
+ });
+
+ map.addLayer({
+ id: "entity-focus-points",
+ type: "circle",
+ source: "entity-focus",
+ filter: ["==", ["geometry-type"], "Point"],
+ paint: {
+ "circle-color": "#f8fafc",
+ "circle-radius": 8,
+ "circle-stroke-color": "#f59e0b",
+ "circle-stroke-width": 3,
+ "circle-opacity": 1,
+ },
+ });
+
+ addPointSymbolLayer(map);
+ applyHighlightToMap(highlightFeatures || EMPTY_FEATURE_COLLECTION);
+}
diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts
new file mode 100644
index 0000000..407e340
--- /dev/null
+++ b/src/uhm/components/map/useMapSync.ts
@@ -0,0 +1,193 @@
+import { useCallback, useEffect, useRef } from "react";
+import maplibregl from "maplibre-gl";
+import { FeatureCollection } from "@/uhm/lib/useEditorState";
+import { BackgroundLayerVisibility } from "@/uhm/lib/backgroundLayers";
+import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants";
+import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID } from "@/uhm/lib/map/constants";
+import {
+ applyBackgroundLayerVisibility,
+ buildPathArrowFeatureCollection,
+ filterDraftByBinding,
+ filterDraftByGeometryVisibility,
+ fitMapToFeatureCollection,
+ setSelectedFeatureState,
+ splitDraftFeatures,
+} from "./mapUtils";
+
+type UseMapSyncProps = {
+ mapRef: React.MutableRefObject;
+ draft: FeatureCollection;
+ backgroundVisibility: BackgroundLayerVisibility;
+ geometryVisibility?: Record;
+ selectedFeatureIds: (string | number)[];
+ respectBindingFilter: boolean;
+ fitToDraftBounds: boolean;
+ fitBoundsKey?: string | number | null;
+ highlightFeatures?: FeatureCollection | null;
+ focusFeatureCollection?: FeatureCollection | null;
+ focusRequestKey?: string | number | null;
+ focusPadding?: number | maplibregl.PaddingOptions;
+ allowGeometryEditing: boolean;
+ editingEngineRef: React.MutableRefObject;
+ geolocationCenteredRef: React.MutableRefObject;
+};
+
+export function useMapSync({
+ mapRef,
+ draft,
+ backgroundVisibility,
+ geometryVisibility,
+ selectedFeatureIds,
+ respectBindingFilter,
+ fitToDraftBounds,
+ fitBoundsKey,
+ highlightFeatures,
+ focusFeatureCollection,
+ focusRequestKey,
+ focusPadding,
+ allowGeometryEditing,
+ editingEngineRef,
+ geolocationCenteredRef,
+}: UseMapSyncProps) {
+ const draftRef = useRef(draft);
+ const backgroundVisibilityRef = useRef(backgroundVisibility);
+ const geometryVisibilityRef = useRef | undefined>(geometryVisibility);
+ const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
+ const respectBindingFilterRef = useRef(respectBindingFilter);
+ const fitToDraftBoundsRef = useRef(fitToDraftBounds);
+ const highlightFeaturesRef = useRef(highlightFeatures || null);
+ const focusFeatureCollectionRef = useRef(focusFeatureCollection || null);
+ const focusRequestKeyRef = useRef(focusRequestKey || null);
+ const focusPaddingRef = useRef(focusPadding);
+
+ const fitBoundsAppliedRef = useRef(false);
+
+ useEffect(() => { draftRef.current = draft; }, [draft]);
+ useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
+ useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
+ useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
+ useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
+ useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
+ useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
+ useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection || null; }, [focusFeatureCollection]);
+ useEffect(() => { focusRequestKeyRef.current = focusRequestKey || null; }, [focusRequestKey]);
+ useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
+
+ useEffect(() => {
+ fitBoundsAppliedRef.current = false;
+ }, [fitBoundsKey]);
+
+ 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;
+
+ for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
+ if (map.getSource(sourceId)) {
+ map.removeFeatureState({ source: sourceId });
+ }
+ }
+
+ const visibleDraftRaw = respectBindingFilterRef.current
+ ? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current)
+ : fc;
+ const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
+ const { polygons, points } = splitDraftFeatures(visibleDraft);
+ const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
+
+ countriesSource.setData(polygons);
+ placesSource.setData(points);
+ (map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
+
+ const currentSelectedIds = selectedFeatureIdsRef.current;
+ currentSelectedIds.forEach((id) => {
+ setSelectedFeatureState(map, id, true);
+ });
+ requestAnimationFrame(() => {
+ if (mapRef.current !== map) return;
+ currentSelectedIds.forEach((id) => {
+ setSelectedFeatureState(map, id, true);
+ });
+ });
+ if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
+ fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
+ }
+ }, [mapRef]);
+
+ const applyHighlightToMap = useCallback((fc: FeatureCollection) => {
+ const map = mapRef.current;
+ if (!map) return;
+
+ const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
+ if (!source) return;
+ source.setData(fc);
+ }, [mapRef]);
+
+ const tryCenterToUserLocation = useCallback(() => {
+ if (geolocationCenteredRef.current) return;
+ if (fitToDraftBoundsRef.current) return;
+ if (typeof window === "undefined") return;
+ if (!("geolocation" in navigator)) return;
+
+ const map = mapRef.current;
+ if (!map) return;
+
+ geolocationCenteredRef.current = true;
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ if (mapRef.current !== map) return;
+ const { longitude, latitude } = pos.coords;
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
+
+ const currentZoom = map.getZoom();
+ const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
+ map.easeTo({ center: [longitude, latitude], zoom: nextZoom, duration: 900 });
+ },
+ () => { },
+ { enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
+ );
+ }, [mapRef, geolocationCenteredRef]);
+
+ useEffect(() => {
+ const map = mapRef.current;
+ if (!map || !map.isStyleLoaded()) return;
+ applyBackgroundLayerVisibility(map, backgroundVisibility);
+ }, [backgroundVisibility, mapRef]);
+
+ useEffect(() => {
+ const map = mapRef.current;
+ if (!map || !map.isStyleLoaded()) return;
+ const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
+ source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
+ }, [highlightFeatures, mapRef]);
+
+ 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, selectedFeatureIds, applyDraftToMap, editingEngineRef]);
+
+ useEffect(() => {
+ if (focusRequestKey === null || focusRequestKey === undefined) return;
+ const map = mapRef.current;
+ if (!map || !map.isStyleLoaded()) return;
+ const target = focusFeatureCollectionRef.current;
+ if (!target || !target.features.length) return;
+ fitMapToFeatureCollection(map, target, focusPaddingRef.current);
+ }, [focusRequestKey, mapRef]);
+
+ return {
+ applyDraftToMap,
+ applyHighlightToMap,
+ tryCenterToUserLocation,
+ };
+}