refactor: modularize Map component logic into dedicated hooks for map instance management, layers, interactions, and state synchronization
This commit is contained in:
+1
-1
@@ -19,7 +19,7 @@ export default function RootLayout({
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${inter.className} dark:bg-gray-900`}>
|
<body className={`${inter.className} dark:bg-gray-900`}>
|
||||||
<StoreProvider>
|
<StoreProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
|
|||||||
+4
-4
@@ -520,8 +520,8 @@ export default function Page() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggleBackgroundLayer(layer.id)}
|
onClick={() => handleToggleBackgroundLayer(layer.id)}
|
||||||
className={`rounded-md border px-2.5 py-1 text-xs transition ${active
|
className={`rounded-md border px-2.5 py-1 text-xs transition ${active
|
||||||
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
|
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
|
||||||
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{layer.label}
|
{layer.label}
|
||||||
@@ -549,8 +549,8 @@ export default function Page() {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active
|
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active
|
||||||
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
|
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
|
||||||
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{typeKey.replaceAll("_", " ")}
|
{typeKey.replaceAll("_", " ")}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import UserDropdown from "@/components/header/UserDropdown";
|
|||||||
import { useSidebar } from "@/context/SidebarContext";
|
import { useSidebar } from "@/context/SidebarContext";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React, { useState ,useEffect,useRef} from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
const AppHeader: React.FC = () => {
|
const AppHeader: React.FC = () => {
|
||||||
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
|
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
|
||||||
@@ -156,16 +156,15 @@ const AppHeader: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${isApplicationMenuOpen ? "flex" : "hidden"
|
||||||
isApplicationMenuOpen ? "flex" : "hidden"
|
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
|
||||||
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 2xsm:gap-3">
|
<div className="flex items-center gap-2 2xsm:gap-3">
|
||||||
{/* <!-- Dark Mode Toggler --> */}
|
{/* <!-- Dark Mode Toggler --> */}
|
||||||
{/* <ThemeToggleButton /> */}
|
{/* <ThemeToggleButton /> */}
|
||||||
{/* <!-- Dark Mode Toggler --> */}
|
{/* <!-- Dark Mode Toggler --> */}
|
||||||
|
|
||||||
{/* <NotificationDropdown /> */}
|
{/* <NotificationDropdown /> */}
|
||||||
{/* <!-- Notification Menu Area --> */}
|
{/* <!-- Notification Menu Area --> */}
|
||||||
</div>
|
</div>
|
||||||
{/* <!-- User Area --> */}
|
{/* <!-- User Area --> */}
|
||||||
|
|||||||
@@ -164,11 +164,10 @@ const AppSidebar: React.FC = () => {
|
|||||||
{nav.subItems ? (
|
{nav.subItems ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||||
className={`menu-item group uppercase ${
|
className={`menu-item group uppercase ${openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
|
||||||
? "menu-item-active"
|
? "menu-item-active"
|
||||||
: "menu-item-inactive"
|
: "menu-item-inactive"
|
||||||
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
|
|||||||
+82
-1826
File diff suppressed because it is too large
Load Diff
@@ -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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string, boolean> | 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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement | null>(null);
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
const [fatalInitError, setFatalInitError] = useState<string | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<maplibregl.Map | null>;
|
||||||
|
mode: EditorMode;
|
||||||
|
modeRef: React.MutableRefObject<EditorMode>;
|
||||||
|
draftRef: React.MutableRefObject<FeatureCollection>;
|
||||||
|
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<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
|
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
|
||||||
|
const previousModeRef = useRef<EditorMode>(mode);
|
||||||
|
const mapCleanupFnsRef = useRef<Array<() => 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<maplibregl.Map | null>;
|
||||||
|
draft: FeatureCollection;
|
||||||
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
|
geometryVisibility?: Record<string, boolean>;
|
||||||
|
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<any>;
|
||||||
|
geolocationCenteredRef: React.MutableRefObject<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMapSync({
|
||||||
|
mapRef,
|
||||||
|
draft,
|
||||||
|
backgroundVisibility,
|
||||||
|
geometryVisibility,
|
||||||
|
selectedFeatureIds,
|
||||||
|
respectBindingFilter,
|
||||||
|
fitToDraftBounds,
|
||||||
|
fitBoundsKey,
|
||||||
|
highlightFeatures,
|
||||||
|
focusFeatureCollection,
|
||||||
|
focusRequestKey,
|
||||||
|
focusPadding,
|
||||||
|
allowGeometryEditing,
|
||||||
|
editingEngineRef,
|
||||||
|
geolocationCenteredRef,
|
||||||
|
}: UseMapSyncProps) {
|
||||||
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
|
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||||
|
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
|
||||||
|
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
|
||||||
|
const respectBindingFilterRef = useRef(respectBindingFilter);
|
||||||
|
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||||
|
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
||||||
|
const focusFeatureCollectionRef = useRef<FeatureCollection | null>(focusFeatureCollection || null);
|
||||||
|
const focusRequestKeyRef = useRef<string | number | null>(focusRequestKey || null);
|
||||||
|
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user