Skip to content

Commit a334ca3

Browse files
committed
CARTO: Automatically add labels to line & polygon layers (#9449)
1 parent 04c6ca8 commit a334ca3

File tree

9 files changed

+1006
-71
lines changed

9 files changed

+1006
-71
lines changed

modules/carto/src/layers/cluster-utils.ts

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {cellToParent} from 'quadbin';
66
import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers';
77
import {Accessor, log} from '@deck.gl/core';
88
import {BinaryFeatureCollection} from '@loaders.gl/schema';
9+
import {createBinaryPointFeature, createEmptyBinary} from '../utils';
910

1011
export type Aggregation = 'any' | 'average' | 'count' | 'min' | 'max' | 'sum';
1112
export type AggregationProperties<FeaturePropertiesT> = {
@@ -137,15 +138,6 @@ export function computeAggregationStats<FeaturePropertiesT>(
137138
return stats;
138139
}
139140

140-
const EMPTY_UINT16ARRAY = new Uint16Array();
141-
const EMPTY_BINARY_PROPS = {
142-
positions: {value: new Float32Array(), size: 2},
143-
properties: [],
144-
numericProps: {},
145-
featureIds: {value: EMPTY_UINT16ARRAY, size: 1},
146-
globalFeatureIds: {value: EMPTY_UINT16ARRAY, size: 1}
147-
};
148-
149141
type BinaryFeatureCollectionWithStats<FeaturePropertiesT> = Omit<
150142
BinaryFeatureCollection,
151143
'points'
@@ -168,25 +160,7 @@ export function clustersToBinary<FeaturePropertiesT>(
168160
}
169161

170162
return {
171-
shape: 'binary-feature-collection',
172-
points: {
173-
type: 'Point',
174-
positions: {value: positions, size: 2},
175-
properties: data,
176-
numericProps: {},
177-
featureIds: {value: featureIds, size: 1},
178-
globalFeatureIds: {value: featureIds, size: 1}
179-
},
180-
lines: {
181-
type: 'LineString',
182-
pathIndices: {value: EMPTY_UINT16ARRAY, size: 1},
183-
...EMPTY_BINARY_PROPS
184-
},
185-
polygons: {
186-
type: 'Polygon',
187-
polygonIndices: {value: EMPTY_UINT16ARRAY, size: 1},
188-
primitivePolygonIndices: {value: EMPTY_UINT16ARRAY, size: 1},
189-
...EMPTY_BINARY_PROPS
190-
}
163+
...createEmptyBinary(),
164+
points: createBinaryPointFeature(positions, featureIds, featureIds, {}, data)
191165
};
192166
}
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import {GeoBoundingBox} from '@deck.gl/geo-layers';
2+
import {TypedArray} from '@loaders.gl/loader-utils';
3+
import {BinaryPointFeature, BinaryLineFeature, BinaryPolygonFeature} from '@loaders.gl/schema';
4+
import {copyNumericProps, createBinaryPointFeature, initializeNumericProps} from '../utils';
5+
6+
type Vec2 = [number, number] | TypedArray;
7+
type TileBBox = GeoBoundingBox;
8+
type Properties = BinaryPointFeature['properties'];
9+
type LineInfo = {index: number; length: number};
10+
11+
export function createPointsFromLines(
12+
lines: BinaryLineFeature,
13+
uniqueIdProperty?: string
14+
): BinaryPointFeature | null {
15+
const hasNumericUniqueId = uniqueIdProperty ? uniqueIdProperty in lines.numericProps : false;
16+
const idToLineInfo = new Map<string | number | undefined, LineInfo>();
17+
18+
// First pass: find the longest line for each unique ID
19+
// If we don't have a uniqueIdProperty, treat each line as unique
20+
for (let i = 0; i < lines.pathIndices.value.length - 1; i++) {
21+
const pathIndex = lines.pathIndices.value[i];
22+
const featureId = lines.featureIds.value[pathIndex];
23+
let uniqueId: string | number | undefined;
24+
25+
if (uniqueIdProperty === undefined) {
26+
uniqueId = featureId;
27+
} else if (hasNumericUniqueId) {
28+
uniqueId = lines.numericProps[uniqueIdProperty].value[pathIndex];
29+
} else if (lines.properties[featureId] && uniqueIdProperty in lines.properties[featureId]) {
30+
uniqueId = lines.properties[featureId][uniqueIdProperty];
31+
} else {
32+
uniqueId = undefined;
33+
}
34+
const length = getLineLength(lines, i);
35+
if (!idToLineInfo.has(uniqueId) || length > idToLineInfo.get(uniqueId)!.length) {
36+
idToLineInfo.set(uniqueId, {index: i, length});
37+
}
38+
}
39+
40+
const positions: number[] = [];
41+
const properties: Properties = [];
42+
const featureIds: number[] = [];
43+
const globalFeatureIds: number[] = [];
44+
const numericProps = initializeNumericProps(idToLineInfo.size, lines.numericProps);
45+
46+
// Second pass: create points for the longest line of each unique ID
47+
let pointIndex = 0;
48+
for (const [_, {index}] of idToLineInfo) {
49+
const midpoint = getLineMidpoint(lines, index);
50+
positions.push(...midpoint);
51+
52+
const pathIndex = lines.pathIndices.value[index];
53+
const featureId = lines.featureIds.value[pathIndex];
54+
featureIds.push(pointIndex);
55+
properties.push(lines.properties[featureId]);
56+
globalFeatureIds.push(lines.globalFeatureIds.value[pathIndex]);
57+
copyNumericProps(lines.numericProps, numericProps, pathIndex, pointIndex);
58+
pointIndex++;
59+
}
60+
61+
return createBinaryPointFeature(
62+
positions,
63+
featureIds,
64+
globalFeatureIds,
65+
numericProps,
66+
properties
67+
);
68+
}
69+
70+
export function createPointsFromPolygons(
71+
polygons: Required<BinaryPolygonFeature>,
72+
tileBbox: TileBBox,
73+
props: any
74+
): BinaryPointFeature {
75+
const {west, south, east, north} = tileBbox;
76+
const tileArea = (east - west) * (north - south);
77+
const minPolygonArea = tileArea * 0.0001; // 0.1% threshold
78+
79+
const positions: number[] = [];
80+
const properties: Properties = [];
81+
const featureIds: number[] = [];
82+
const globalFeatureIds: number[] = [];
83+
const numericProps = initializeNumericProps(
84+
polygons.polygonIndices.value.length - 1,
85+
polygons.numericProps
86+
);
87+
88+
// Process each polygon
89+
let pointIndex = 0;
90+
let triangleIndex = 0;
91+
const {extruded} = props;
92+
for (let i = 0; i < polygons.polygonIndices.value.length - 1; i++) {
93+
const startIndex = polygons.polygonIndices.value[i];
94+
const endIndex = polygons.polygonIndices.value[i + 1];
95+
96+
// Skip small polygons
97+
if (getPolygonArea(polygons, i) < minPolygonArea) {
98+
continue;
99+
}
100+
101+
const centroid = getPolygonCentroid(polygons, i);
102+
let maxArea = -1;
103+
let largestTriangleCenter: [number, number] = [0, 0];
104+
let centroidIsInside = false;
105+
106+
// Scan triangles until we find ones that don't belong to this polygon
107+
while (triangleIndex < polygons.triangles.value.length) {
108+
const i1 = polygons.triangles.value[triangleIndex];
109+
110+
// If we've moved past the current polygon's triangles, break
111+
if (i1 >= endIndex) {
112+
break;
113+
}
114+
115+
// If we've already found a triangle containing the centroid, skip the rest
116+
if (centroidIsInside) {
117+
triangleIndex += 3;
118+
continue;
119+
}
120+
121+
const i2 = polygons.triangles.value[triangleIndex + 1];
122+
const i3 = polygons.triangles.value[triangleIndex + 2];
123+
const v1 = polygons.positions.value.subarray(
124+
i1 * polygons.positions.size,
125+
i1 * polygons.positions.size + polygons.positions.size
126+
);
127+
const v2 = polygons.positions.value.subarray(
128+
i2 * polygons.positions.size,
129+
i2 * polygons.positions.size + polygons.positions.size
130+
);
131+
const v3 = polygons.positions.value.subarray(
132+
i3 * polygons.positions.size,
133+
i3 * polygons.positions.size + polygons.positions.size
134+
);
135+
136+
if (isPointInTriangle(centroid, v1, v2, v3)) {
137+
centroidIsInside = true;
138+
} else {
139+
const area = getTriangleArea(v1, v2, v3);
140+
if (area > maxArea) {
141+
maxArea = area;
142+
largestTriangleCenter = [(v1[0] + v2[0] + v3[0]) / 3, (v1[1] + v2[1] + v3[1]) / 3];
143+
}
144+
}
145+
146+
triangleIndex += 3;
147+
}
148+
149+
const labelPoint = centroidIsInside ? centroid : largestTriangleCenter;
150+
if (isPointInBounds(labelPoint, tileBbox)) {
151+
positions.push(...labelPoint);
152+
const featureId = polygons.featureIds.value[startIndex];
153+
if (extruded) {
154+
const elevation = props.getElevation(undefined, {
155+
data: polygons,
156+
index: featureId
157+
});
158+
positions.push(elevation * props.elevationScale);
159+
}
160+
properties.push(polygons.properties[featureId]);
161+
featureIds.push(pointIndex);
162+
globalFeatureIds.push(polygons.globalFeatureIds.value[startIndex]);
163+
copyNumericProps(polygons.numericProps, numericProps, startIndex, pointIndex);
164+
pointIndex++;
165+
}
166+
}
167+
168+
// Trim numeric properties arrays to actual size
169+
if (polygons.numericProps) {
170+
Object.keys(numericProps).forEach(prop => {
171+
numericProps[prop].value = numericProps[prop].value.slice(0, pointIndex);
172+
});
173+
}
174+
175+
return createBinaryPointFeature(
176+
positions,
177+
featureIds,
178+
globalFeatureIds,
179+
numericProps,
180+
properties,
181+
extruded ? 3 : 2
182+
);
183+
}
184+
185+
// Helper functions
186+
function getPolygonArea(polygons: Required<BinaryPolygonFeature>, index: number): number {
187+
const {
188+
positions: {value: positions, size},
189+
polygonIndices: {value: indices},
190+
triangles: {value: triangles}
191+
} = polygons;
192+
193+
const startIndex = indices[index];
194+
const endIndex = indices[index + 1];
195+
let area = 0;
196+
let triangleIndex = 0;
197+
198+
// Find first triangle of this polygon
199+
// Note: this assumes tirnagles and polygon indices are sorted.
200+
// This is true for the current implementation of geojsonToBinary
201+
while (triangleIndex < triangles.length) {
202+
const i1 = triangles[triangleIndex];
203+
if (i1 >= startIndex) break;
204+
triangleIndex += 3;
205+
}
206+
207+
// Process triangles until we hit the next polygon
208+
while (triangleIndex < triangles.length) {
209+
const i1 = triangles[triangleIndex];
210+
if (i1 >= endIndex) break;
211+
212+
const i2 = triangles[triangleIndex + 1];
213+
const i3 = triangles[triangleIndex + 2];
214+
const v1 = positions.subarray(i1 * size, i1 * size + size);
215+
const v2 = positions.subarray(i2 * size, i2 * size + size);
216+
const v3 = positions.subarray(i3 * size, i3 * size + size);
217+
218+
area += getTriangleArea(v1, v2, v3);
219+
triangleIndex += 3;
220+
}
221+
222+
return area;
223+
}
224+
225+
function isPointInBounds([x, y]: [number, number], {west, east, south, north}: TileBBox): boolean {
226+
return x >= west && x < east && y >= south && y < north;
227+
}
228+
229+
function isPointInTriangle(p: Vec2, v1: Vec2, v2: Vec2, v3: Vec2): boolean {
230+
const area = Math.abs((v2[0] - v1[0]) * (v3[1] - v1[1]) - (v3[0] - v1[0]) * (v2[1] - v1[1])) / 2;
231+
const area1 = Math.abs((v1[0] - p[0]) * (v2[1] - p[1]) - (v2[0] - p[0]) * (v1[1] - p[1])) / 2;
232+
const area2 = Math.abs((v2[0] - p[0]) * (v3[1] - p[1]) - (v3[0] - p[0]) * (v2[1] - p[1])) / 2;
233+
const area3 = Math.abs((v3[0] - p[0]) * (v1[1] - p[1]) - (v1[0] - p[0]) * (v3[1] - p[1])) / 2;
234+
235+
// Account for floating point precision
236+
return Math.abs(area - (area1 + area2 + area3)) < 1e-10;
237+
}
238+
239+
function getTriangleArea([x1, y1]: Vec2, [x2, y2]: Vec2, [x3, y3]: Vec2): number {
240+
return Math.abs((x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) / 2);
241+
}
242+
243+
function getPolygonCentroid(polygons: BinaryPolygonFeature, index: number): [number, number] {
244+
const {
245+
positions: {value: positions, size}
246+
} = polygons;
247+
const startIndex = size * polygons.polygonIndices.value[index];
248+
const endIndex = size * polygons.polygonIndices.value[index + 1];
249+
250+
let minX = Infinity;
251+
let minY = Infinity;
252+
let maxX = -Infinity;
253+
let maxY = -Infinity;
254+
255+
for (let i = startIndex; i < endIndex; i += size) {
256+
const [x, y] = positions.subarray(i, i + 2);
257+
minX = Math.min(minX, x);
258+
minY = Math.min(minY, y);
259+
maxX = Math.max(maxX, x);
260+
maxY = Math.max(maxY, y);
261+
}
262+
263+
return [(minX + maxX) / 2, (minY + maxY) / 2];
264+
}
265+
266+
function getSegmentLength(lines: BinaryLineFeature, index: number): number {
267+
const {
268+
positions: {value}
269+
} = lines;
270+
const [x1, y1, x2, y2] = value.subarray(index, index + 4);
271+
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
272+
}
273+
274+
function getLineLength(lines: BinaryLineFeature, index: number): number {
275+
const {
276+
positions: {size}
277+
} = lines;
278+
const startIndex = size * lines.pathIndices.value[index];
279+
const endIndex = size * lines.pathIndices.value[index + 1];
280+
let length = 0;
281+
for (let j = startIndex; j < endIndex; j += size) {
282+
length += getSegmentLength(lines, j);
283+
}
284+
return length;
285+
}
286+
287+
function getLineMidpoint(lines: BinaryLineFeature, index: number): [number, number] {
288+
const {
289+
positions: {value: positions},
290+
pathIndices: {value: pathIndices}
291+
} = lines;
292+
const startIndex = pathIndices[index] * 2;
293+
const endIndex = pathIndices[index + 1] * 2;
294+
const numPoints = (endIndex - startIndex) / 2;
295+
296+
if (numPoints === 2) {
297+
// For lines with only two vertices, interpolate between them
298+
const [x1, y1, x2, y2] = positions.subarray(startIndex, startIndex + 4);
299+
return [(x1 + x2) / 2, (y1 + y2) / 2];
300+
}
301+
// For lines with multiple vertices, use the middle vertex
302+
const midPointIndex = startIndex + Math.floor(numPoints / 2) * 2;
303+
return [positions[midPointIndex], positions[midPointIndex + 1]];
304+
}

0 commit comments

Comments
 (0)