mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Release God's Eye View as open source
This commit is contained in:
1 parent
a5aaea7e16
commit
880a672b5e
433 files changed
+219893
-23
No files matched your search
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @file Flow→road matching: assign TomTom congestion levels to Overpass roads.
|
||||
*
|
||||
* The traffic layer renders dots along OSM road polylines (Overpass), while
|
||||
* TomTom flow tiles carry their own (differently segmented) polylines with
|
||||
* `traffic_level`. This module snaps flow onto roads geometrically:
|
||||
*
|
||||
* 1. Every flow polyline is exploded into consecutive coord-pair SEGMENTS
|
||||
* (pairs longer than one cell are subdivided so midpoint hashing can't
|
||||
* miss their extremities) and hashed by midpoint into ~100 m grid cells,
|
||||
* projected to local meters (degree cell size cos(lat)-adjusted).
|
||||
* 2. Each road is sampled at up to 7 evenly-spaced points along its length;
|
||||
* each sample looks for the nearest flow segment within 35 m whose
|
||||
* bearing agrees within 30° (folded mod 180° so two-way roads match
|
||||
* opposite-direction flow lines).
|
||||
* 3. A road matches when at least half its samples (minimum 2) matched; its
|
||||
* level is the MEDIAN of the matched samples' trafficLevels, and closure
|
||||
* is true if ANY matched segment is closed.
|
||||
*
|
||||
* Pure and Cesium-free: inputs are plain [[lon,lat],…] polylines, so the
|
||||
* whole pipeline is unit-testable with synthetic geometry.
|
||||
*
|
||||
* @module data/flowMatch
|
||||
*/
|
||||
|
||||
/** @const {number} Meters per degree of latitude (spherical approximation). */
|
||||
const M_PER_DEG_LAT = 111320;
|
||||
/** @const {number} Spatial-hash cell size in meters (~100 m per spec). */
|
||||
const CELL_SIZE_M = 100;
|
||||
/** @const {number} Max snap distance from a road sample to a flow segment. */
|
||||
const MATCH_RADIUS_M = 35;
|
||||
/** @const {number} Max bearing disagreement (degrees, folded mod 180). */
|
||||
const BEARING_TOLERANCE_DEG = 30;
|
||||
/** @const {number} Evenly-spaced samples per road. */
|
||||
const ROAD_SAMPLES = 7;
|
||||
/** @const {number} Minimum matched samples for a road to count as matched. */
|
||||
const MIN_MATCHED_SAMPLES = 2;
|
||||
|
||||
/**
|
||||
* Median of a numeric array (even count averages the two middles).
|
||||
* @param {number[]} values
|
||||
* @returns {number|null} Median, or null for an empty array.
|
||||
*/
|
||||
export function median(values) {
|
||||
if (!Array.isArray(values) || values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = sorted.length >> 1;
|
||||
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
|
||||
function bearingDeg(dx, dy) {
|
||||
return (Math.atan2(dx, dy) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearing disagreement folded mod 180° — a flow line drawn in the opposite
|
||||
* direction of travel still describes the same two-way road.
|
||||
* @param {number} a - Bearing (degrees). @param {number} b - Bearing (degrees).
|
||||
* @returns {number} min(|Δ|, 180 − |Δ|) in [0, 90].
|
||||
*/
|
||||
function bearingDiffDeg(a, b) {
|
||||
let d = Math.abs(a - b) % 360;
|
||||
if (d > 180) d = 360 - d;
|
||||
return Math.min(d, 180 - d);
|
||||
}
|
||||
|
||||
/** Squared distance from point (px,py) to segment (ax,ay)-(bx,by), meters². */
|
||||
function pointSegDist2(px, py, ax, ay, bx, by) {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let t = len2 > 0 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const qx = ax + t * dx;
|
||||
const qy = ay + t * dy;
|
||||
return (px - qx) * (px - qx) + (py - qy) * (py - qy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string}>} roads
|
||||
* Parsed Overpass road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>} flowSegments
|
||||
* Decoded flow polylines from `flowTiles.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing — the denominator for coverage stats.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// Local equirectangular projection anchored at the first flow coordinate —
|
||||
// over a ≤0.05° fetch box the distortion is negligible.
|
||||
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2);
|
||||
if (!anchor) return empty;
|
||||
const [refLon, refLat] = anchor.coords[0];
|
||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||
const projX = (lon) => (lon - refLon) * mPerDegLon;
|
||||
const projY = (lat) => (lat - refLat) * M_PER_DEG_LAT;
|
||||
|
||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||
const grid = new Map();
|
||||
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
|
||||
for (const flow of flowSegments) {
|
||||
const coords = flow?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
const level = flow.trafficLevel;
|
||||
const closure = flow.closure === true;
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const ax = projX(coords[i][0]);
|
||||
const ay = projY(coords[i][1]);
|
||||
const bx = projX(coords[i + 1][0]);
|
||||
const by = projY(coords[i + 1][1]);
|
||||
const segLen = Math.hypot(bx - ax, by - ay);
|
||||
if (!(segLen > 0)) continue;
|
||||
const bearing = bearingDeg(bx - ax, by - ay);
|
||||
// Subdivide long pairs so every piece is ≤ one cell — midpoint hashing
|
||||
// then guarantees a 3×3 cell probe sees everything within 35 m.
|
||||
const pieces = Math.max(1, Math.ceil(segLen / CELL_SIZE_M));
|
||||
for (let p = 0; p < pieces; p++) {
|
||||
const t0 = p / pieces;
|
||||
const t1 = (p + 1) / pieces;
|
||||
const seg = {
|
||||
ax: ax + (bx - ax) * t0,
|
||||
ay: ay + (by - ay) * t0,
|
||||
bx: ax + (bx - ax) * t1,
|
||||
by: ay + (by - ay) * t1,
|
||||
bearing,
|
||||
level,
|
||||
closure,
|
||||
};
|
||||
const key = cellOf((seg.ax + seg.bx) / 2, (seg.ay + seg.by) / 2);
|
||||
let bucket = grid.get(key);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
grid.set(key, bucket);
|
||||
}
|
||||
bucket.push(seg);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grid.size === 0) return empty;
|
||||
|
||||
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
||||
let matchedCount = 0;
|
||||
let candidateCount = 0;
|
||||
|
||||
// ── 2+3. Sample each road and vote ──
|
||||
for (let r = 0; r < roadCount; r++) {
|
||||
const coords = roads[r]?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
|
||||
// Project the road once; accumulate arc length for even sampling.
|
||||
const xs = new Array(coords.length);
|
||||
const ys = new Array(coords.length);
|
||||
const cum = new Array(coords.length);
|
||||
cum[0] = 0;
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
xs[i] = projX(coords[i][0]);
|
||||
ys[i] = projY(coords[i][1]);
|
||||
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
}
|
||||
const totalLen = cum[coords.length - 1];
|
||||
if (!(totalLen > 0)) continue;
|
||||
|
||||
let hadCandidate = false;
|
||||
const matchedLevels = [];
|
||||
let matchedClosure = false;
|
||||
|
||||
let cursor = 1; // cum[] is monotonic; samples advance monotonically too
|
||||
for (let s = 0; s < ROAD_SAMPLES; s++) {
|
||||
// Midpoint-biased fractions keep samples off road endpoints, where
|
||||
// cross-street flow lines meet at intersections.
|
||||
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
|
||||
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
|
||||
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
||||
|
||||
// 3×3 cell probe around the sample.
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(`${gx},${gy}`);
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
hadCandidate = true; // within radius, bearing not yet checked
|
||||
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue;
|
||||
if (d2 <= bestDist2) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
matchedLevels.push(best.level);
|
||||
if (best.closure) matchedClosure = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hadCandidate) candidateCount++;
|
||||
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
|
||||
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
|
||||
matchedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { matches, matchedCount, candidateCount };
|
||||
}
|
||||
Reference in new issue
Block a user