mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Wrap longitude differences in annotation proximity checks and nearby-place distances so points across the antimeridian remain close. Original contribution by quanticsoul4772. Co-authored-by: quanticsoul4772 <[email protected]>
276 lines
9.0 KiB
JavaScript
276 lines
9.0 KiB
JavaScript
/** Portable polygon and corridor operations; coordinates remain longitude/latitude. */
|
|
export function ringAreaM2(ring) {
|
|
if (!Array.isArray(ring) || ring.length < 3) return 0;
|
|
const mLat = 111_320;
|
|
const mLon = mLat * Math.cos(ring[0][1] * (Math.PI / 180));
|
|
let area = 0;
|
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
const xi = ring[i][0] * mLon;
|
|
const yi = ring[i][1] * mLat;
|
|
const xj = ring[j][0] * mLon;
|
|
const yj = ring[j][1] * mLat;
|
|
area += xj * yi - xi * yj;
|
|
}
|
|
return Math.abs(area) / 2;
|
|
}
|
|
|
|
export function stitchLine(segments) {
|
|
const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2;
|
|
const remaining = segments.map((s) => s.slice());
|
|
let line = remaining.shift();
|
|
let advanced = true;
|
|
while (advanced && remaining.length) {
|
|
advanced = false;
|
|
for (let i = 0; i < remaining.length; i++) {
|
|
const s = remaining[i];
|
|
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
|
|
else if (same(line[line.length - 1], s[s.length - 1]))
|
|
line = line.concat(s.slice(0, -1).reverse());
|
|
else if (same(line[0], s[s.length - 1]))
|
|
line = s.slice(0, -1).concat(line);
|
|
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
|
|
else continue;
|
|
remaining.splice(i, 1);
|
|
advanced = true;
|
|
break;
|
|
}
|
|
}
|
|
return line;
|
|
}
|
|
|
|
export function bufferCorridor(line, halfWidthM) {
|
|
const lat0 = line[0][1];
|
|
const mLon = 111320 * Math.cos((lat0 * Math.PI) / 180);
|
|
const mLat = 111320;
|
|
const P = line.map(([lon, lat]) => [lon * mLon, lat * mLat]);
|
|
const left = [];
|
|
const right = [];
|
|
for (let i = 0; i < P.length; i++) {
|
|
const a = P[Math.max(0, i - 1)];
|
|
const b = P[Math.min(P.length - 1, i + 1)];
|
|
let dx = b[0] - a[0];
|
|
let dy = b[1] - a[1];
|
|
const len = Math.hypot(dx, dy) || 1;
|
|
dx /= len;
|
|
dy /= len;
|
|
const nx = -dy;
|
|
const ny = dx;
|
|
left.push([P[i][0] + nx * halfWidthM, P[i][1] + ny * halfWidthM]);
|
|
right.push([P[i][0] - nx * halfWidthM, P[i][1] - ny * halfWidthM]);
|
|
}
|
|
const ring = [...left, ...right.reverse(), left[0]];
|
|
return ring.map(([x, y]) => [x / mLon, y / mLat]);
|
|
}
|
|
|
|
export function simplifyRing(ring, tolM) {
|
|
if (ring.length <= 24) return ring;
|
|
let pts = ring;
|
|
// Hard-cap the input to Douglas-Peucker so a pathological 50k-point country
|
|
// boundary can't dominate a frame, even with the iterative implementation.
|
|
if (pts.length > 4000) {
|
|
const step = Math.ceil(pts.length / 4000);
|
|
pts = pts.filter((_, i) => i % step === 0 || i === ring.length - 1);
|
|
}
|
|
const lat0 = pts[0][1];
|
|
const tol = tolM / (111320 * Math.cos((lat0 * Math.PI) / 180));
|
|
const out = douglasPeucker(pts, tol);
|
|
return out.length >= 4 ? out : ring;
|
|
}
|
|
|
|
function douglasPeucker(points, tol) {
|
|
const n = points.length;
|
|
if (n < 3) return points.slice();
|
|
// Iterative, index-based (no per-call array slicing / recursion), so a large
|
|
// state/country boundary can't blow the call stack or thrash GC and freeze the
|
|
// main thread during a voice turn.
|
|
const keep = new Uint8Array(n);
|
|
keep[0] = 1;
|
|
keep[n - 1] = 1;
|
|
const stack = [[0, n - 1]];
|
|
while (stack.length) {
|
|
const seg = stack.pop();
|
|
const first = seg[0];
|
|
const last = seg[1];
|
|
let index = -1;
|
|
let maxD = 0;
|
|
const a = points[first];
|
|
const b = points[last];
|
|
for (let i = first + 1; i < last; i += 1) {
|
|
const dist = perpDistance(points[i], a, b);
|
|
if (dist > maxD) {
|
|
maxD = dist;
|
|
index = i;
|
|
}
|
|
}
|
|
if (maxD > tol && index > first) {
|
|
keep[index] = 1;
|
|
stack.push([first, index]);
|
|
stack.push([index, last]);
|
|
}
|
|
}
|
|
const out = [];
|
|
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(points[i]);
|
|
return out;
|
|
}
|
|
|
|
function perpDistance(p, a, b) {
|
|
const dx = b[0] - a[0];
|
|
const dy = b[1] - a[1];
|
|
const len2 = dx * dx + dy * dy;
|
|
if (len2 === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
|
|
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
|
|
t = Math.max(0, Math.min(1, t));
|
|
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
|
}
|
|
|
|
export function stitchRing(ways) {
|
|
if (!ways.length) return [];
|
|
const components = buildRingComponents(ways);
|
|
if (!components.length) return [];
|
|
|
|
const CLOSE_TOL_M = 30; // endpoints within 30 m → a genuinely closed ring
|
|
const closed = components.filter((c) => endpointGapM(c) <= CLOSE_TOL_M);
|
|
if (closed.length) {
|
|
// Pick by projected AREA, not vertex count — a small, highly-detailed island
|
|
// must not beat the large simple mainland. (For a disjoint multipolygon this
|
|
// returns only the largest closed outer ring; full multi-ring rendering is a
|
|
// renderer change tracked separately.)
|
|
return largestByArea(closed);
|
|
}
|
|
|
|
// Nothing closes exactly. Bridge a seam ONLY when the gap is BOTH a small
|
|
// fraction of the feature's own span (so half a compound's boundary missing is
|
|
// rejected) AND under a hard absolute ceiling (so a state/country can never get a
|
|
// kilometres-long chord — the bay blob). A complete relation closes at ~0; a
|
|
// genuine seam (e.g. the Presidio's ~few-hundred-m coastline gap) bridges; a
|
|
// truly incomplete relation is rejected → point fallback.
|
|
const largest = largestByArea(components);
|
|
const allow = Math.min(ringSpanM(largest) * 0.15, 1200);
|
|
return endpointGapM(largest) <= allow ? largest : [];
|
|
}
|
|
|
|
function largestByArea(components) {
|
|
let best = components[0];
|
|
let bestArea = approximateAreaM2(best);
|
|
for (let i = 1; i < components.length; i += 1) {
|
|
const area = approximateAreaM2(components[i]);
|
|
if (area > bestArea) {
|
|
bestArea = area;
|
|
best = components[i];
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function ringSpanM(chain) {
|
|
let minLat = Infinity;
|
|
let maxLat = -Infinity;
|
|
let minLon = Infinity;
|
|
let maxLon = -Infinity;
|
|
for (const p of chain) {
|
|
if (p.lat < minLat) minLat = p.lat;
|
|
if (p.lat > maxLat) maxLat = p.lat;
|
|
if (p.lon < minLon) minLon = p.lon;
|
|
if (p.lon > maxLon) maxLon = p.lon;
|
|
}
|
|
return approximateDistanceM(minLat, minLon, maxLat, maxLon);
|
|
}
|
|
|
|
function buildRingComponents(ways) {
|
|
const same = (a, b) =>
|
|
Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7;
|
|
const remaining = ways.map((w) => w.slice());
|
|
const components = [];
|
|
while (remaining.length) {
|
|
let chain = remaining.shift();
|
|
let grew = true;
|
|
while (grew) {
|
|
grew = false;
|
|
const head = chain[0];
|
|
const tail = chain[chain.length - 1];
|
|
for (let i = 0; i < remaining.length; i += 1) {
|
|
const w = remaining[i];
|
|
const ws = w[0];
|
|
const we = w[w.length - 1];
|
|
if (same(tail, ws)) chain = chain.concat(w.slice(1));
|
|
else if (same(tail, we)) chain = chain.concat(w.slice(0, -1).reverse());
|
|
else if (same(head, we)) chain = w.slice(0, -1).concat(chain);
|
|
else if (same(head, ws)) chain = w.slice(1).reverse().concat(chain);
|
|
else continue;
|
|
remaining.splice(i, 1);
|
|
grew = true;
|
|
break;
|
|
}
|
|
}
|
|
components.push(chain);
|
|
}
|
|
return components;
|
|
}
|
|
|
|
function endpointGapM(chain) {
|
|
if (!chain || chain.length < 2) return Infinity;
|
|
const a = chain[0];
|
|
const b = chain[chain.length - 1];
|
|
return approximateDistanceM(a.lat, a.lon, b.lat, b.lon);
|
|
}
|
|
|
|
export function closeRing(ring) {
|
|
if (ring.length < 3) return ring;
|
|
const [fx, fy] = ring[0];
|
|
const [lx, ly] = ring[ring.length - 1];
|
|
if (fx !== lx || fy !== ly) ring.push([fx, fy]);
|
|
return ring;
|
|
}
|
|
|
|
export function ringCentroid(ring) {
|
|
if (!ring || ring.length < 3) return null;
|
|
let sumLat = 0;
|
|
let sumLon = 0;
|
|
for (const [lon, lat] of ring) {
|
|
sumLat += lat;
|
|
sumLon += lon;
|
|
}
|
|
return { lat: sumLat / ring.length, lon: sumLon / ring.length };
|
|
}
|
|
|
|
export function approximateAreaM2(coords) {
|
|
// Shoelace in a local equirectangular projection (good enough for buildings).
|
|
if (coords.length < 3) return 0;
|
|
const lat0 = coords[0].lat;
|
|
const mPerDegLat = 111_320;
|
|
const mPerDegLon = mPerDegLat * Math.cos(lat0 * (Math.PI / 180));
|
|
let area = 0;
|
|
for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) {
|
|
const xi = coords[i].lon * mPerDegLon;
|
|
const yi = coords[i].lat * mPerDegLat;
|
|
const xj = coords[j].lon * mPerDegLon;
|
|
const yj = coords[j].lat * mPerDegLat;
|
|
area += xj * yi - xi * yj;
|
|
}
|
|
return Math.abs(area) / 2;
|
|
}
|
|
|
|
/** Local equirectangular distance in metres, the short way round 180°. */
|
|
export function approximateDistanceM(latA, lonA, latB, lonB) {
|
|
const latScale = 111_320;
|
|
const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180));
|
|
let dLon = lonB - lonA;
|
|
dLon -= 360 * Math.round(dLon / 360);
|
|
return Math.hypot((latB - latA) * latScale, dLon * lonScale);
|
|
}
|
|
|
|
export function pointInPolygon(lon, lat, coords) {
|
|
let inside = false;
|
|
for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) {
|
|
const a = coords[i];
|
|
const b = coords[j];
|
|
const intersects =
|
|
a.lat > lat !== b.lat > lat &&
|
|
lon <
|
|
((b.lon - a.lon) * (lat - a.lat)) / (b.lat - a.lat || Number.EPSILON) +
|
|
a.lon;
|
|
if (intersects) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|