From 72d7073c409e89b646ad84fc1e553906bde772d1 Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 12 May 2026 20:48:13 +0700 Subject: [PATCH] refactor: path's label viewed also line --- package-lock.json | 18 ++ package.json | 4 +- src/app/editor/[id]/page.tsx | 1 + src/uhm/components/Map.tsx | 3 + src/uhm/components/map/mapUtils.ts | 266 ++++++++++------------- src/uhm/components/map/useMapLayers.ts | 2 +- src/uhm/components/map/useMapSync.ts | 15 +- src/uhm/lib/map/styles/geotypes/index.ts | 4 +- src/uhm/types/geo.ts | 1 + 9 files changed, 161 insertions(+), 153 deletions(-) diff --git a/package-lock.json b/package-lock.json index 883a642..14b2041 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "flatpickr": "^4.6.13", "maplibre-gl": "^5.20.2", "next": "^16.1.6", + "polylabel": "^2.0.1", "react": "^19.2.0", "react-apexcharts": "^1.8.0", "react-dnd": "^16.0.1", @@ -47,6 +48,7 @@ "@eslint/eslintrc": "^3.3.1", "@svgr/webpack": "^8.1.0", "@types/node": "^20.19.25", + "@types/polylabel": "^1.1.3", "@types/react": "^19.2.1", "@types/react-dom": "^19.2.1", "@types/react-transition-group": "^4.4.12", @@ -4139,6 +4141,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/polylabel": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz", + "integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8724,6 +8733,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/polylabel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz", + "integrity": "sha512-B6Yu+Bdl/8SGtjVhyUfZzD3DwciCS9SPVtHiNdt8idHHatvTHp5Ss8XGDRmQFtfF1ZQnfK+Cj5dXdpkUXBbXgA==", + "license": "ISC", + "dependencies": { + "tinyqueue": "^3.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index a6314bc..78467eb 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "flatpickr": "^4.6.13", "maplibre-gl": "^5.20.2", "next": "^16.1.6", + "polylabel": "^2.0.1", "react": "^19.2.0", "react-apexcharts": "^1.8.0", "react-dnd": "^16.0.1", @@ -37,17 +38,18 @@ "react-dropzone": "^14.3.8", "react-quill-new": "^3.8.3", "react-redux": "^9.2.0", - "uuid": "^13.0.0", "sonner": "^2.0.7", "sweetalert2": "^11.26.24", "swiper": "^11.2.10", "tailwind-merge": "^2.6.0", + "uuid": "^13.0.0", "yet-another-react-lightbox": "^3.30.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@svgr/webpack": "^8.1.0", "@types/node": "^20.19.25", + "@types/polylabel": "^1.1.3", "@types/react": "^19.2.1", "@types/react-dom": "^19.2.1", "@types/react-transition-group": "^4.4.12", diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 52a8edd..3ce8602 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1236,6 +1236,7 @@ export default function Page() { ; selectedFeatureIds: (string | number)[]; onSelectFeatureIds: (ids: (string | number)[]) => void; + labelContextDraft?: FeatureCollection; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onDeleteFeature?: (id: string | number) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void; @@ -48,6 +49,7 @@ export default function Map({ geometryVisibility, selectedFeatureIds, onSelectFeatureIds, + labelContextDraft, onCreateFeature, onDeleteFeature, onUpdateFeature, @@ -117,6 +119,7 @@ export default function Map({ } = useMapSync({ mapRef, draft, + labelContextDraft, backgroundVisibility, geometryVisibility, selectedFeatureIds, diff --git a/src/uhm/components/map/mapUtils.ts b/src/uhm/components/map/mapUtils.ts index 9f0d712..370ac07 100644 --- a/src/uhm/components/map/mapUtils.ts +++ b/src/uhm/components/map/mapUtils.ts @@ -1,4 +1,5 @@ import maplibregl from "maplibre-gl"; +import polylabel from "polylabel"; import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers"; import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { @@ -15,12 +16,9 @@ import { newId } from "@/uhm/lib/utils/id"; type Coordinate = [number, number]; type PolygonCoordinates = Coordinate[][]; -type LabelCell = { - x: number; - y: number; - h: number; - d: number; - max: number; +type FeatureLabelInfo = { + entityId: string; + label: string; }; export function applyBackgroundLayerVisibility( @@ -196,24 +194,40 @@ export function splitDraftFeatures(fc: FeatureCollection) { return { polygons, points }; } -export function decoratePointFeaturesWithLabels(fc: FeatureCollection): FeatureCollection { +export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection { + const getLabel = createFeatureLabelResolver(labelContext); return { ...fc, features: fc.features.map((feature) => ({ ...feature, properties: { ...feature.properties, - point_label: getSingleEntityFeatureLabel(feature), + point_label: getLabel(feature), }, })), }; } -export function buildPolygonLabelFeatureCollection(fc: FeatureCollection): FeatureCollection { +export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection { + const getLabel = createFeatureLabelResolver(labelContext); + return { + ...fc, + features: fc.features.map((feature) => ({ + ...feature, + properties: { + ...feature.properties, + line_label: isLineGeometry(feature.geometry) ? getLabel(feature) : null, + }, + })), + }; +} + +export function buildPolygonLabelFeatureCollection(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection { + const getLabel = createFeatureLabelResolver(labelContext); const features: Feature[] = []; for (const feature of fc.features) { - const label = getSingleEntityFeatureLabel(feature); + const label = getLabel(feature); if (!label) continue; const labelPoint = getPolygonLabelPoint(feature.geometry); @@ -595,27 +609,106 @@ export function roundZoom(value: number): number { return Math.round(value * 10) / 10; } -function getSingleEntityFeatureLabel(feature: Feature): string | null { - const rawEntityIds = Array.isArray(feature.properties.entity_ids) +function createFeatureLabelResolver(fc: FeatureCollection): (feature: Feature) => string | null { + const directLabelsByFeatureId = new Map(); + const inheritedLabelsByChildId = new Map(); + + for (const feature of fc.features) { + const labelInfo = getSingleEntityFeatureLabelInfo(feature); + if (!labelInfo) continue; + directLabelsByFeatureId.set(String(feature.properties.id), labelInfo); + } + + for (const feature of fc.features) { + const parentLabel = directLabelsByFeatureId.get(String(feature.properties.id)); + const featureId = String(feature.properties.id); + const bindingIds = normalizeBindingIds(feature.properties.binding); + + if (parentLabel) { + for (const childId of bindingIds) { + mergeInheritedFeatureLabel(inheritedLabelsByChildId, childId, parentLabel); + } + } + + for (const parentId of bindingIds) { + const linkedParentLabel = directLabelsByFeatureId.get(parentId); + if (linkedParentLabel) { + mergeInheritedFeatureLabel(inheritedLabelsByChildId, featureId, linkedParentLabel); + } + } + } + + return (feature) => { + const featureId = String(feature.properties.id); + const directEntityIds = getFeatureEntityIds(feature); + if (directEntityIds.length > 0) { + return directLabelsByFeatureId.get(featureId)?.label || null; + } + + return inheritedLabelsByChildId.get(featureId)?.label || null; + }; +} + +function mergeInheritedFeatureLabel( + labelsByFeatureId: Map, + targetFeatureId: string, + labelInfo: FeatureLabelInfo +) { + const current = labelsByFeatureId.get(targetFeatureId); + if (current === undefined) { + labelsByFeatureId.set(targetFeatureId, labelInfo); + } else if (current && current.entityId === labelInfo.entityId) { + labelsByFeatureId.set(targetFeatureId, current); + } else { + labelsByFeatureId.set(targetFeatureId, null); + } +} + +function getSingleEntityFeatureLabelInfo(feature: Feature): FeatureLabelInfo | null { + const entityIds = getFeatureEntityIds(feature); + if (entityIds.length !== 1) return null; + + const label = getSingleEntityName(feature); + if (!label) return null; + + return { entityId: entityIds[0], label }; +} + +function getFeatureEntityIds(feature: Feature): string[] { + const rawEntityIds: unknown[] = Array.isArray(feature.properties.entity_ids) ? feature.properties.entity_ids - : (typeof feature.properties.entity_id === "string" && feature.properties.entity_id.trim().length > 0 + : (typeof feature.properties.entity_id === "string" || typeof feature.properties.entity_id === "number" ? [feature.properties.entity_id] : []); - const entityIds = Array.from(new Set( + return Array.from(new Set( rawEntityIds - .filter((id): id is string => typeof id === "string") - .map((id) => id.trim()) + .filter((id): id is string | number => typeof id === "string" || typeof id === "number") + .map((id) => String(id).trim()) .filter((id) => id.length > 0) )); +} - if (entityIds.length !== 1) return null; - - const name = typeof feature.properties.entity_name === "string" +function getSingleEntityName(feature: Feature): string | null { + const directName = typeof feature.properties.entity_name === "string" ? feature.properties.entity_name.trim() : ""; + if (directName.length > 0) return directName; - return name.length ? name : null; + const names = Array.isArray(feature.properties.entity_names) + ? Array.from(new Set( + feature.properties.entity_names + .filter((name): name is string => typeof name === "string") + .map((name) => name.trim()) + .filter((name) => name.length > 0) + )) + : []; + + return names.length === 1 ? names[0] : null; +} + +function isLineGeometry(geometry: Geometry): boolean { + return geometry.type === "LineString" || geometry.type === "MultiLineString"; } function getPolygonLabelPoint(geometry: Geometry): Coordinate | null { @@ -648,67 +741,20 @@ function getPolygonLabelCandidate(polygon: PolygonCoordinates): { point: Coordin const width = bbox.maxX - bbox.minX; const height = bbox.maxY - bbox.minY; if (width <= 0 || height <= 0) { - const fallback = getRingCentroid(outerRing) || [bbox.minX, bbox.minY] as Coordinate; + const fallback: Coordinate = [bbox.minX, bbox.minY]; return { point: fallback, distance: 0 }; } - const cellSize = Math.min(width, height); - const h = cellSize / 2; - const cells: LabelCell[] = []; - - for (let x = bbox.minX; x < bbox.maxX; x += cellSize) { - for (let y = bbox.minY; y < bbox.maxY; y += cellSize) { - cells.push(createLabelCell(x + h, y + h, h, polygon)); - } - } - - let best = createLabelCell(bbox.minX + width / 2, bbox.minY + height / 2, 0, polygon); - const centroid = getRingCentroid(outerRing); - if (centroid) { - const centroidCell = createLabelCell(centroid[0], centroid[1], 0, polygon); - if (centroidCell.d > best.d) { - best = centroidCell; - } - } - const precision = Math.max(Math.max(width, height) / 100, 0.0001); - let iterations = 0; - while (cells.length && iterations < 4096) { - cells.sort((a, b) => b.max - a.max); - const cell = cells.shift(); - if (!cell) break; - iterations += 1; + const result = polylabel(polygon, precision); + const x = result[0]; + const y = result[1]; - if (cell.d > best.d) { - best = cell; - } - - if (cell.max - best.d <= precision) continue; - - const nextH = cell.h / 2; - cells.push(createLabelCell(cell.x - nextH, cell.y - nextH, nextH, polygon)); - cells.push(createLabelCell(cell.x + nextH, cell.y - nextH, nextH, polygon)); - cells.push(createLabelCell(cell.x - nextH, cell.y + nextH, nextH, polygon)); - cells.push(createLabelCell(cell.x + nextH, cell.y + nextH, nextH, polygon)); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return { point: [bbox.minX + width / 2, bbox.minY + height / 2], distance: 0 }; } - if (best.d < 0) { - const fallback = centroid || [bbox.minX + width / 2, bbox.minY + height / 2] as Coordinate; - return { point: fallback, distance: 0 }; - } - - return { point: [best.x, best.y], distance: best.d }; -} - -function createLabelCell(x: number, y: number, h: number, polygon: PolygonCoordinates): LabelCell { - const d = pointToPolygonDistance([x, y], polygon); - return { - x, - y, - h, - d, - max: d + h * Math.SQRT2, - }; + return { point: [x, y], distance: Number.isFinite(result.distance) ? result.distance : 0 }; } function getRingBbox(ring: Coordinate[]): { minX: number; minY: number; maxX: number; maxY: number } | null { @@ -733,78 +779,6 @@ function getRingBbox(ring: Coordinate[]): { minX: number; minY: number; maxX: nu return { minX, minY, maxX, maxY }; } -function getRingCentroid(ring: Coordinate[]): Coordinate | null { - let area = 0; - let x = 0; - let y = 0; - - for (let i = 0, len = ring.length, j = len - 1; i < len; j = i++) { - const a = ring[i]; - const b = ring[j]; - const f = a[0] * b[1] - b[0] * a[1]; - x += (a[0] + b[0]) * f; - y += (a[1] + b[1]) * f; - area += f * 3; - } - - if (area === 0) return null; - return [x / area, y / area]; -} - -function pointToPolygonDistance(point: Coordinate, polygon: PolygonCoordinates): number { - const inside = isPointInRing(point, polygon[0]) && !polygon.slice(1).some((ring) => isPointInRing(point, ring)); - let minDistSq = Number.POSITIVE_INFINITY; - - for (const ring of polygon) { - for (let i = 0, len = ring.length, j = len - 1; i < len; j = i++) { - minDistSq = Math.min(minDistSq, getSegmentDistanceSquared(point, ring[j], ring[i])); - } - } - - const distance = Math.sqrt(minDistSq); - return inside ? distance : -distance; -} - -function isPointInRing(point: Coordinate, ring: Coordinate[] | undefined): boolean { - if (!ring || ring.length < 3) return false; - - const [x, y] = point; - let inside = false; - - for (let i = 0, len = ring.length, j = len - 1; i < len; j = i++) { - const xi = ring[i][0]; - const yi = ring[i][1]; - const xj = ring[j][0]; - const yj = ring[j][1]; - const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; - if (intersects) inside = !inside; - } - - return inside; -} - -function getSegmentDistanceSquared(point: Coordinate, a: Coordinate, b: Coordinate): number { - let x = a[0]; - let y = a[1]; - let dx = b[0] - x; - let dy = b[1] - y; - - if (dx !== 0 || dy !== 0) { - const t = ((point[0] - x) * dx + (point[1] - y) * dy) / (dx * dx + dy * dy); - if (t > 1) { - x = b[0]; - y = b[1]; - } else if (t > 0) { - x += dx * t; - y += dy * t; - } - } - - dx = point[0] - x; - dy = point[1] - y; - return dx * dx + dy * dy; -} - export function buildClientFeatureId(): string { return newId(); } diff --git a/src/uhm/components/map/useMapLayers.ts b/src/uhm/components/map/useMapLayers.ts index 3dd33f6..16bdede 100644 --- a/src/uhm/components/map/useMapLayers.ts +++ b/src/uhm/components/map/useMapLayers.ts @@ -338,7 +338,7 @@ export function setupMapLayers( map.addLayer(layer); } - const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID); + const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID, "countries"); for (const layer of geotypeLabelLayers) { map.addLayer(layer); } diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts index d4c04ac..9cddff4 100644 --- a/src/uhm/components/map/useMapSync.ts +++ b/src/uhm/components/map/useMapSync.ts @@ -8,6 +8,7 @@ import { applyBackgroundLayerVisibility, buildPolygonLabelFeatureCollection, buildPathArrowFeatureCollection, + decorateLineFeaturesWithLabels, decoratePointFeaturesWithLabels, filterDraftByBinding, filterDraftByGeometryVisibility, @@ -19,6 +20,7 @@ import { type UseMapSyncProps = { mapRef: React.MutableRefObject; draft: FeatureCollection; + labelContextDraft?: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; geometryVisibility?: Record; selectedFeatureIds: (string | number)[]; @@ -40,6 +42,7 @@ type UseMapSyncProps = { export function useMapSync({ mapRef, draft, + labelContextDraft, backgroundVisibility, geometryVisibility, selectedFeatureIds, @@ -55,6 +58,7 @@ export function useMapSync({ geolocationCenteredRef, }: UseMapSyncProps) { const draftRef = useRef(draft); + const labelContextDraftRef = useRef(labelContextDraft); const backgroundVisibilityRef = useRef(backgroundVisibility); const geometryVisibilityRef = useRef | undefined>(geometryVisibility); const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds); @@ -68,6 +72,7 @@ export function useMapSync({ const fitBoundsAppliedRef = useRef(false); useEffect(() => { draftRef.current = draft; }, [draft]); + useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]); useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]); useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]); useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]); @@ -102,12 +107,14 @@ export function useMapSync({ ? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current) : fc; const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current); + const labelContext = labelContextDraftRef.current || fc; const { polygons, points } = splitDraftFeatures(visibleDraft); - const labeledPoints = decoratePointFeaturesWithLabels(points); - const polygonLabels = buildPolygonLabelFeatureCollection(polygons); + const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext); + const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext); + const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext); const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft); - countriesSource.setData(polygons); + countriesSource.setData(labeledGeometries); placesSource.setData(labeledPoints); polygonLabelSource.setData(polygonLabels); (map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes); @@ -183,7 +190,7 @@ export function useMapSync({ editingEngineRef.current?.clearEditing(); } } - }, [allowGeometryEditing, draft, selectedFeatureIds, applyDraftToMap, editingEngineRef]); + }, [allowGeometryEditing, draft, labelContextDraft, selectedFeatureIds, applyDraftToMap, editingEngineRef]); useEffect(() => { if (focusRequestKey === null || focusRequestKey === undefined) return; diff --git a/src/uhm/lib/map/styles/geotypes/index.ts b/src/uhm/lib/map/styles/geotypes/index.ts index b2b056c..ff32965 100644 --- a/src/uhm/lib/map/styles/geotypes/index.ts +++ b/src/uhm/lib/map/styles/geotypes/index.ts @@ -29,6 +29,7 @@ import { getCastleLayers } from "./castle"; import { getRuinLayers } from "./ruin"; import { getPortLayers } from "./port"; import { getBridgeLayers } from "./bridge"; +import { getLineLabelLayers } from "./lineLabels"; import { getPolygonLabelLayers } from "./polygonLabels"; import { LayerSpecification } from "maplibre-gl"; @@ -65,8 +66,9 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string ]; } -export function getAllGeotypeLabelLayers(polygonLabelSourceId: string): LayerSpecification[] { +export function getAllGeotypeLabelLayers(polygonLabelSourceId: string, lineSourceId: string): LayerSpecification[] { return [ ...getPolygonLabelLayers(polygonLabelSourceId), + ...getLineLabelLayers(lineSourceId), ]; } diff --git a/src/uhm/types/geo.ts b/src/uhm/types/geo.ts index a13acd7..a489ba3 100644 --- a/src/uhm/types/geo.ts +++ b/src/uhm/types/geo.ts @@ -23,6 +23,7 @@ export type FeatureProperties = { entity_names?: string[]; entity_type_id?: string | null; point_label?: string | null; + line_label?: string | null; polygon_label?: string | null; };