refactor: easy polylabel algorithm to view polygon label
This commit is contained in:
@@ -13,6 +13,16 @@ import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
|
|||||||
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
|
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
|
||||||
import { newId } from "@/uhm/lib/utils/id";
|
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(
|
export function applyBackgroundLayerVisibility(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
visibility: BackgroundLayerVisibility
|
visibility: BackgroundLayerVisibility
|
||||||
@@ -193,12 +203,39 @@ export function decoratePointFeaturesWithLabels(fc: FeatureCollection): FeatureC
|
|||||||
...feature,
|
...feature,
|
||||||
properties: {
|
properties: {
|
||||||
...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(
|
export function setSelectedFeatureState(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
id: string | number | null,
|
id: string | number | null,
|
||||||
@@ -558,7 +595,7 @@ export function roundZoom(value: number): number {
|
|||||||
return Math.round(value * 10) / 10;
|
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)
|
const rawEntityIds = Array.isArray(feature.properties.entity_ids)
|
||||||
? 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" && feature.properties.entity_id.trim().length > 0
|
||||||
@@ -581,6 +618,193 @@ function getSingleEntityPointLabel(feature: Feature): string | null {
|
|||||||
return name.length ? name : 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 {
|
export function buildClientFeatureId(): string {
|
||||||
return newId();
|
return newId();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
POLYGON_STROKE_BY_TYPE,
|
POLYGON_STROKE_BY_TYPE,
|
||||||
} from "@/uhm/lib/map/styles/style";
|
} from "@/uhm/lib/map/styles/style";
|
||||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
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 { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||||
import { ensurePointGeotypeIcons, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypes";
|
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypes";
|
||||||
import {
|
import {
|
||||||
applyBackgroundLayerVisibility,
|
applyBackgroundLayerVisibility,
|
||||||
buildTypeMatchExpression,
|
buildTypeMatchExpression,
|
||||||
@@ -325,6 +325,12 @@ export function setupMapLayers(
|
|||||||
promoteId: "id",
|
promoteId: "id",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
map.addSource(POLYGON_LABEL_SOURCE_ID, {
|
||||||
|
type: "geojson",
|
||||||
|
data: EMPTY_FEATURE_COLLECTION,
|
||||||
|
promoteId: "id",
|
||||||
|
});
|
||||||
|
|
||||||
ensurePointGeotypeIcons(map);
|
ensurePointGeotypeIcons(map);
|
||||||
|
|
||||||
const geotypeLayers = getAllGeotypeLayers("countries", PATH_ARROW_SOURCE_ID, "places");
|
const geotypeLayers = getAllGeotypeLayers("countries", PATH_ARROW_SOURCE_ID, "places");
|
||||||
@@ -332,6 +338,11 @@ export function setupMapLayers(
|
|||||||
map.addLayer(layer);
|
map.addLayer(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID);
|
||||||
|
for (const layer of geotypeLabelLayers) {
|
||||||
|
map.addLayer(layer);
|
||||||
|
}
|
||||||
|
|
||||||
// editing overlays
|
// editing overlays
|
||||||
map.addSource("edit-shape", {
|
map.addSource("edit-shape", {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import maplibregl from "maplibre-gl";
|
|||||||
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
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 {
|
import {
|
||||||
applyBackgroundLayerVisibility,
|
applyBackgroundLayerVisibility,
|
||||||
|
buildPolygonLabelFeatureCollection,
|
||||||
buildPathArrowFeatureCollection,
|
buildPathArrowFeatureCollection,
|
||||||
decoratePointFeaturesWithLabels,
|
decoratePointFeaturesWithLabels,
|
||||||
filterDraftByBinding,
|
filterDraftByBinding,
|
||||||
@@ -29,7 +30,10 @@ type UseMapSyncProps = {
|
|||||||
focusRequestKey?: string | number | null;
|
focusRequestKey?: string | number | null;
|
||||||
focusPadding?: number | maplibregl.PaddingOptions;
|
focusPadding?: number | maplibregl.PaddingOptions;
|
||||||
allowGeometryEditing: boolean;
|
allowGeometryEditing: boolean;
|
||||||
editingEngineRef: React.MutableRefObject<unknown>;
|
editingEngineRef: React.MutableRefObject<{
|
||||||
|
editingRef: React.MutableRefObject<{ id: string | number } | null>;
|
||||||
|
clearEditing: () => void;
|
||||||
|
} | null>;
|
||||||
geolocationCenteredRef: React.MutableRefObject<boolean>;
|
geolocationCenteredRef: React.MutableRefObject<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,8 +88,9 @@ export function useMapSync({
|
|||||||
|
|
||||||
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
|
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
|
||||||
const placesSource = map.getSource("places") 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) {
|
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
|
||||||
if (map.getSource(sourceId)) {
|
if (map.getSource(sourceId)) {
|
||||||
@@ -99,10 +104,12 @@ export function useMapSync({
|
|||||||
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
|
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
|
||||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||||
const labeledPoints = decoratePointFeaturesWithLabels(points);
|
const labeledPoints = decoratePointFeaturesWithLabels(points);
|
||||||
|
const polygonLabels = buildPolygonLabelFeatureCollection(polygons);
|
||||||
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
|
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
|
||||||
|
|
||||||
countriesSource.setData(polygons);
|
countriesSource.setData(polygons);
|
||||||
placesSource.setData(labeledPoints);
|
placesSource.setData(labeledPoints);
|
||||||
|
polygonLabelSource.setData(polygonLabels);
|
||||||
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
|
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
|
||||||
|
|
||||||
const currentSelectedIds = selectedFeatureIdsRef.current;
|
const currentSelectedIds = selectedFeatureIdsRef.current;
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
|||||||
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||||
|
|
||||||
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
||||||
|
export const POLYGON_LABEL_SOURCE_ID = "polygon-labels";
|
||||||
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { getCastleLayers } from "./castle";
|
|||||||
import { getRuinLayers } from "./ruin";
|
import { getRuinLayers } from "./ruin";
|
||||||
import { getPortLayers } from "./port";
|
import { getPortLayers } from "./port";
|
||||||
import { getBridgeLayers } from "./bridge";
|
import { getBridgeLayers } from "./bridge";
|
||||||
|
import { getPolygonLabelLayers } from "./polygonLabels";
|
||||||
|
|
||||||
import { LayerSpecification } from "maplibre-gl";
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
|
||||||
@@ -63,3 +64,9 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
|
|||||||
...getBridgeLayers(sourceId, pathArrowSourceId, pointSourceId)
|
...getBridgeLayers(sourceId, pathArrowSourceId, pointSourceId)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllGeotypeLabelLayers(polygonLabelSourceId: string): LayerSpecification[] {
|
||||||
|
return [
|
||||||
|
...getPolygonLabelLayers(polygonLabelSourceId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type FeatureProperties = {
|
|||||||
entity_names?: string[];
|
entity_names?: string[];
|
||||||
entity_type_id?: string | null;
|
entity_type_id?: string | null;
|
||||||
point_label?: string | null;
|
point_label?: string | null;
|
||||||
|
polygon_label?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Feature = {
|
export type Feature = {
|
||||||
|
|||||||
Reference in New Issue
Block a user