|
| 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