refactor: easy polylabel algorithm to view polygon label

This commit is contained in:
taDuc
2026-05-12 18:43:12 +07:00
parent 7b1f7538ab
commit 51f432f0fe
6 changed files with 258 additions and 7 deletions
+226 -2
View File
@@ -13,6 +13,16 @@ import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
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;
};
export function applyBackgroundLayerVisibility(
map: maplibregl.Map,
visibility: BackgroundLayerVisibility
@@ -193,12 +203,39 @@ export function decoratePointFeaturesWithLabels(fc: FeatureCollection): FeatureC
...feature,
properties: {
...feature.properties,
point_label: getSingleEntityPointLabel(feature),
point_label: getSingleEntityFeatureLabel(feature),
},
})),
};
}
export function buildPolygonLabelFeatureCollection(fc: FeatureCollection): FeatureCollection {
const features: Feature[] = [];
for (const feature of fc.features) {
const label = getSingleEntityFeatureLabel(feature);
if (!label) continue;
const labelPoint = getPolygonLabelPoint(feature.geometry);
if (!labelPoint) continue;
features.push({
type: "Feature",
properties: {
...feature.properties,
id: `${feature.properties.id}:polygon-label`,
polygon_label: label,
},
geometry: {
type: "Point",
coordinates: labelPoint,
},
});
}
return { type: "FeatureCollection", features };
}
export function setSelectedFeatureState(
map: maplibregl.Map,
id: string | number | null,
@@ -558,7 +595,7 @@ export function roundZoom(value: number): number {
return Math.round(value * 10) / 10;
}
function getSingleEntityPointLabel(feature: Feature): string | null {
function getSingleEntityFeatureLabel(feature: Feature): string | null {
const rawEntityIds = Array.isArray(feature.properties.entity_ids)
? feature.properties.entity_ids
: (typeof feature.properties.entity_id === "string" && feature.properties.entity_id.trim().length > 0
@@ -581,6 +618,193 @@ function getSingleEntityPointLabel(feature: Feature): string | null {
return name.length ? name : null;
}
function getPolygonLabelPoint(geometry: Geometry): Coordinate | null {
if (geometry.type === "Polygon") {
return getPolygonLabelCandidate(geometry.coordinates)?.point || null;
}
if (geometry.type === "MultiPolygon") {
let best: { point: Coordinate; distance: number } | null = null;
for (const polygon of geometry.coordinates) {
const candidate = getPolygonLabelCandidate(polygon);
if (!candidate) continue;
if (!best || candidate.distance > best.distance) {
best = candidate;
}
}
return best?.point || null;
}
return null;
}
function getPolygonLabelCandidate(polygon: PolygonCoordinates): { point: Coordinate; distance: number } | null {
const outerRing = polygon[0];
if (!outerRing || outerRing.length < 3) return null;
const bbox = getRingBbox(outerRing);
if (!bbox) return null;
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;
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;
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 (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,
};
}
function getRingBbox(ring: Coordinate[]): { minX: number; minY: number; maxX: number; maxY: number } | null {
if (!ring.length) return null;
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const [x, y] of ring) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
return null;
}
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();
}
+13 -2
View File
@@ -10,8 +10,8 @@ import {
POLYGON_STROKE_BY_TYPE,
} from "@/uhm/lib/map/styles/style";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID } from "@/uhm/lib/map/constants";
import { ensurePointGeotypeIcons, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypes";
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypes";
import {
applyBackgroundLayerVisibility,
buildTypeMatchExpression,
@@ -325,6 +325,12 @@ export function setupMapLayers(
promoteId: "id",
});
map.addSource(POLYGON_LABEL_SOURCE_ID, {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
promoteId: "id",
});
ensurePointGeotypeIcons(map);
const geotypeLayers = getAllGeotypeLayers("countries", PATH_ARROW_SOURCE_ID, "places");
@@ -332,6 +338,11 @@ export function setupMapLayers(
map.addLayer(layer);
}
const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID);
for (const layer of geotypeLabelLayers) {
map.addLayer(layer);
}
// editing overlays
map.addSource("edit-shape", {
type: "geojson",
+10 -3
View File
@@ -3,9 +3,10 @@ import maplibregl from "maplibre-gl";
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID } from "@/uhm/lib/map/constants";
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import {
applyBackgroundLayerVisibility,
buildPolygonLabelFeatureCollection,
buildPathArrowFeatureCollection,
decoratePointFeaturesWithLabels,
filterDraftByBinding,
@@ -29,7 +30,10 @@ type UseMapSyncProps = {
focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions;
allowGeometryEditing: boolean;
editingEngineRef: React.MutableRefObject<unknown>;
editingEngineRef: React.MutableRefObject<{
editingRef: React.MutableRefObject<{ id: string | number } | null>;
clearEditing: () => void;
} | null>;
geolocationCenteredRef: React.MutableRefObject<boolean>;
};
@@ -84,8 +88,9 @@ export function useMapSync({
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
const polygonLabelSource = map.getSource(POLYGON_LABEL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (!countriesSource || !placesSource) return;
if (!countriesSource || !placesSource || !polygonLabelSource) return;
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
if (map.getSource(sourceId)) {
@@ -99,10 +104,12 @@ export function useMapSync({
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
const { polygons, points } = splitDraftFeatures(visibleDraft);
const labeledPoints = decoratePointFeaturesWithLabels(points);
const polygonLabels = buildPolygonLabelFeatureCollection(polygons);
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
countriesSource.setData(polygons);
placesSource.setData(labeledPoints);
polygonLabelSource.setData(polygonLabels);
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
const currentSelectedIds = selectedFeatureIdsRef.current;