mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into feat/typed-ai-chat
This commit is contained in:
commit
976ea38a7f
6 files changed
+233
-22
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
- Street Traffic gives the two TomTom `one_side` lines of one two-way street
|
||||||
|
a two-way direction's half share of simulated vehicles each, so the street
|
||||||
|
is no longer drawn twice as dense as the same street from OpenStreetMap
|
||||||
|
(quanticsoul4772, #1495).
|
||||||
@@ -6131,6 +6131,11 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
TomTom draws only flow-tile lines with their own flow; roads without flow
|
TomTom draws only flow-tile lines with their own flow; roads without flow
|
||||||
are not drawn and the status says so. A `full`-coverage flow line carries
|
are not drawn and the status says so. A `full`-coverage flow line carries
|
||||||
both travel directions, a `one_side` line the direction it is drawn in.
|
both travel directions, a `one_side` line the direction it is drawn in.
|
||||||
|
When an opposite-direction `one_side` line runs within 5 m of at least two
|
||||||
|
of three points a quarter, half and three quarters along a `one_side` line,
|
||||||
|
the two are one street's directions and each
|
||||||
|
gets a two-way direction's half share of dots, so the street is as dense as
|
||||||
|
one `full` line (#1455).
|
||||||
OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid
|
OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid
|
||||||
draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line
|
draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line
|
||||||
runs within 35 m in the same travel direction (30 degrees), tested every
|
runs within 35 m in the same travel direction (30 degrees), tested every
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) {
|
|||||||
roadType,
|
roadType,
|
||||||
level,
|
level,
|
||||||
closure,
|
closure,
|
||||||
|
flow,
|
||||||
};
|
};
|
||||||
const key = cellKey(
|
const key = cellKey(
|
||||||
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
|
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
|
||||||
@@ -158,7 +159,8 @@ export function createFlowSegmentIndex(flowSegments) {
|
|||||||
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
||||||
/**
|
/**
|
||||||
* Nearest direction-compatible segment within 35 m of a projected point.
|
* Nearest direction-compatible segment within 35 m of a projected point.
|
||||||
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
|
* `radiusFor(segment)` may tighten the radius per segment (never widen it);
|
||||||
|
* 0 leaves the segment out.
|
||||||
*/
|
*/
|
||||||
function nearest(px, py, bearing, radiusFor = null) {
|
function nearest(px, py, bearing, radiusFor = null) {
|
||||||
const cx = Math.floor(px / CELL_SIZE_M);
|
const cx = Math.floor(px / CELL_SIZE_M);
|
||||||
@@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) {
|
|||||||
candidate = true; // within radius, bearing not yet checked
|
candidate = true; // within radius, bearing not yet checked
|
||||||
if (radiusFor) {
|
if (radiusFor) {
|
||||||
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
|
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
|
||||||
if (d2 > radius * radius) continue;
|
if (!(radius > 0) || d2 > radius * radius) continue;
|
||||||
}
|
}
|
||||||
const diff = bearingDiffDeg(seg.bearing, bearing);
|
const diff = bearingDiffDeg(seg.bearing, bearing);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ function tileFreshness(response) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clip a flow line to a box. Every piece keeps its uncut line as `line`, so
|
||||||
|
* pieces of one line that doubles back are never paired with each other.
|
||||||
|
*/
|
||||||
|
function clipFlowSegment(segment, bbox) {
|
||||||
|
const line = segment.line || segment;
|
||||||
|
return clipTileLine(segment.coords, bbox).map((coords) => ({
|
||||||
|
...segment,
|
||||||
|
coords,
|
||||||
|
line,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
||||||
export function createFlowTileSource({
|
export function createFlowTileSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
@@ -41,12 +54,7 @@ export function createFlowTileSource({
|
|||||||
entry?.release();
|
entry?.release();
|
||||||
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
|
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
|
||||||
.filter(isDrivableFlowSegment)
|
.filter(isDrivableFlowSegment)
|
||||||
.flatMap((segment) =>
|
.flatMap((segment) => clipFlowSegment(segment, tileToBBox(z, x, y)));
|
||||||
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
|
|
||||||
...segment,
|
|
||||||
coords,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
if (mark) freshness.set(segments, mark);
|
if (mark) freshness.set(segments, mark);
|
||||||
return segments;
|
return segments;
|
||||||
},
|
},
|
||||||
@@ -87,12 +95,9 @@ export function createFlowTileSource({
|
|||||||
partial = result.partial;
|
partial = result.partial;
|
||||||
const segments = bounds.coverage
|
const segments = bounds.coverage
|
||||||
? result.tiles.flat()
|
? result.tiles.flat()
|
||||||
: result.tiles.flat().flatMap((segment) =>
|
: result.tiles
|
||||||
clipTileLine(segment.coords, bounds).map((coords) => ({
|
.flat()
|
||||||
...segment,
|
.flatMap((segment) => clipFlowSegment(segment, bounds));
|
||||||
coords,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
|
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
|
||||||
const times = marks
|
const times = marks
|
||||||
.map((mark) => mark.fetchedAt)
|
.map((mark) => mark.fetchedAt)
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ const MIN_FILL_M = 40;
|
|||||||
* Same-class overlap keeps the full 35 m matcher radius.
|
* Same-class overlap keeps the full 35 m matcher radius.
|
||||||
*/
|
*/
|
||||||
const CLASS_MISMATCH_RADIUS_M = 15;
|
const CLASS_MISMATCH_RADIUS_M = 15;
|
||||||
|
/**
|
||||||
|
* Opposite-direction TomTom lines this close share one street: TomTom draws
|
||||||
|
* both directions of a two-way street a metre or two apart, and a divided
|
||||||
|
* carriageway's sides further apart.
|
||||||
|
*/
|
||||||
|
const PAIRED_SIDE_RADIUS_M = 5;
|
||||||
|
/** Arc-length positions sampled along a `one_side` line for its partner. */
|
||||||
|
const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75];
|
||||||
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
|
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
|
||||||
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
|
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
|
||||||
|
|
||||||
@@ -85,20 +93,69 @@ export function isDrivableFlowSegment(segment) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share of a `one_side` line's sample points (PAIRED_SIDE_SAMPLES) with an
|
||||||
|
* opposite-direction TomTom line beside them: TomTom draws a two-way street
|
||||||
|
* whose directions differ as two `one_side` lines on one centreline, while a
|
||||||
|
* divided carriageway's other side is further away than PAIRED_SIDE_RADIUS_M.
|
||||||
|
* @returns {number} Paired share in [0, 1].
|
||||||
|
*/
|
||||||
|
function pairedSampleShare(segment, index) {
|
||||||
|
const xs = [];
|
||||||
|
const ys = [];
|
||||||
|
const cum = [0];
|
||||||
|
for (const [lon, lat] of segment.coords) {
|
||||||
|
const [x, y] = index.project(lon, lat);
|
||||||
|
const i = xs.push(x) - 1;
|
||||||
|
ys.push(y);
|
||||||
|
if (i) cum.push(cum[i - 1] + Math.hypot(x - xs[i - 1], y - ys[i - 1]));
|
||||||
|
}
|
||||||
|
const length = cum[cum.length - 1];
|
||||||
|
if (!(length > 0)) return 0;
|
||||||
|
const line = segment.line || segment;
|
||||||
|
let paired = 0;
|
||||||
|
let i = 1;
|
||||||
|
for (const share of PAIRED_SIDE_SAMPLES) {
|
||||||
|
const target = share * length;
|
||||||
|
while (i < cum.length - 1 && cum[i] < target) i++;
|
||||||
|
const dx = xs[i] - xs[i - 1];
|
||||||
|
const dy = ys[i] - ys[i - 1];
|
||||||
|
const t = (target - cum[i - 1]) / (cum[i] - cum[i - 1] || 1);
|
||||||
|
const reverse = (Math.atan2(-dx, -dy) * 180) / Math.PI;
|
||||||
|
if (
|
||||||
|
index.nearest(
|
||||||
|
xs[i - 1] + dx * t,
|
||||||
|
ys[i - 1] + dy * t,
|
||||||
|
reverse,
|
||||||
|
// A line that doubles back beside itself, even clipped into pieces,
|
||||||
|
// is not its own pair.
|
||||||
|
(piece) =>
|
||||||
|
(piece.flow.line || piece.flow) === line ? 0 : PAIRED_SIDE_RADIUS_M,
|
||||||
|
).best
|
||||||
|
)
|
||||||
|
paired++;
|
||||||
|
}
|
||||||
|
return paired / PAIRED_SIDE_SAMPLES.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert decoded TomTom flow lines into road records that carry their own
|
* Convert decoded TomTom flow lines into road records that carry their own
|
||||||
* flow. A line with `coverage: 'full'` describes both travel directions;
|
* flow. A line with `coverage: 'full'` describes both travel directions;
|
||||||
* `one_side` lines describe the direction they are drawn in.
|
* `one_side` lines describe the direction they are drawn in. A `one_side`
|
||||||
|
* line paired with the opposite direction on the same street gets that
|
||||||
|
* direction's half share of dots, as a two-way road's direction does.
|
||||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments
|
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments
|
||||||
* @returns {Array<object>} Road records (`directFlow: true`).
|
* @returns {Array<object>} Road records (`directFlow: true`).
|
||||||
*/
|
*/
|
||||||
export function flowSegmentsToRoads(segments) {
|
export function flowSegmentsToRoads(segments) {
|
||||||
return (segments || [])
|
const drivable = (segments || []).filter(
|
||||||
.filter(
|
(segment) => segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
|
||||||
(segment) =>
|
);
|
||||||
segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
|
const sides = createFlowSegmentIndex(
|
||||||
)
|
drivable.filter((segment) => segment.coverage !== 'full'),
|
||||||
.map((segment) => ({
|
);
|
||||||
|
return drivable.map((segment) => {
|
||||||
|
const road = {
|
||||||
coordinates: segment.coords,
|
coordinates: segment.coords,
|
||||||
type: FLOW_ROAD_TYPES[segment.roadType],
|
type: FLOW_ROAD_TYPES[segment.roadType],
|
||||||
drivable: true,
|
drivable: true,
|
||||||
@@ -110,7 +167,15 @@ export function flowSegmentsToRoads(segments) {
|
|||||||
oneway: segment.coverage === 'full' ? 0 : 1,
|
oneway: segment.coverage === 'full' ? 0 : 1,
|
||||||
flow: { level: segment.trafficLevel, closure: segment.closure === true },
|
flow: { level: segment.trafficLevel, closure: segment.closure === true },
|
||||||
directFlow: true,
|
directFlow: true,
|
||||||
}));
|
};
|
||||||
|
if (
|
||||||
|
segment.coverage !== 'full' &&
|
||||||
|
sides &&
|
||||||
|
pairedSampleShare(segment, sides) >= 0.5
|
||||||
|
)
|
||||||
|
road.densityWeight = 0.5;
|
||||||
|
return road;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cut a projected polyline to the arc-length interval [from, to]. */
|
/** Cut a projected polyline to the arc-length interval [from, to]. */
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { PbfWriter } from 'pbf';
|
||||||
import {
|
import {
|
||||||
flowSegmentsToRoads,
|
flowSegmentsToRoads,
|
||||||
resolveRoadMode,
|
resolveRoadMode,
|
||||||
selectTrafficRoads,
|
selectTrafficRoads,
|
||||||
} from './roadModes.js';
|
} from './roadModes.js';
|
||||||
|
import { createFlowTileSource } from './flowSource.js';
|
||||||
import { createTrafficSource } from './source.js';
|
import { createTrafficSource } from './source.js';
|
||||||
import { createTrafficLayer } from './index.js';
|
import { createTrafficLayer } from './index.js';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
serializeStoredLayerState,
|
serializeStoredLayerState,
|
||||||
parseStoredLayerState,
|
parseStoredLayerState,
|
||||||
} from '../../data/layerState.js';
|
} from '../../data/layerState.js';
|
||||||
|
import { tileToBBox } from '../../data/tomtomTiles.js';
|
||||||
|
|
||||||
const coordinates = [
|
const coordinates = [
|
||||||
[-97.744, 30.267],
|
[-97.744, 30.267],
|
||||||
@@ -116,6 +119,133 @@ test('a full-coverage TomTom line is two-way and covers both OSM directions', ()
|
|||||||
assert.equal(roads.length, 1);
|
assert.equal(roads.length, 1);
|
||||||
assert.equal(roads[0].directFlow, true);
|
assert.equal(roads[0].directFlow, true);
|
||||||
});
|
});
|
||||||
|
test("opposite TomTom sides of one street share a two-way street's dots", () => {
|
||||||
|
// About 1 m and 20 m north of `coordinates`, drawn westbound.
|
||||||
|
const back = (dLat) => ({
|
||||||
|
...flow,
|
||||||
|
coords: coordinates.map(([lon, lat]) => [lon, lat + dLat]).reverse(),
|
||||||
|
});
|
||||||
|
const weights = (segments) =>
|
||||||
|
flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1);
|
||||||
|
assert.deepEqual(weights([flow, back(0.00001)]), [0.5, 0.5]);
|
||||||
|
assert.deepEqual(weights([flow]), [1], 'a lone side keeps the one-way share');
|
||||||
|
assert.deepEqual(
|
||||||
|
weights([flow, back(0.00018)]),
|
||||||
|
[1, 1],
|
||||||
|
'a divided carriageway keeps one-way shares',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
weights([
|
||||||
|
flow,
|
||||||
|
{
|
||||||
|
...flow,
|
||||||
|
coords: coordinates.map(([lon, lat]) => [lon, lat + 0.00001]),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
[1, 1],
|
||||||
|
'same-direction lines are not a pair',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
weights([{ ...flow, coverage: 'full' }, back(0.00001)]),
|
||||||
|
[1, 1],
|
||||||
|
'a full-coverage line is not a side, and pairs with nothing',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('a TomTom side paired along only part of its length keeps its share', () => {
|
||||||
|
// Westbound partners about 1 m north, from the start of `flow` to 30% and
|
||||||
|
// to 60% of its length: one or two of its three sample points.
|
||||||
|
const partner = (toLon) => ({
|
||||||
|
...flow,
|
||||||
|
coords: [
|
||||||
|
[toLon, 30.26701],
|
||||||
|
[-97.744, 30.26701],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const weightOfFlow = (other) =>
|
||||||
|
flowSegmentsToRoads([flow, other])[0].densityWeight ?? 1;
|
||||||
|
assert.equal(weightOfFlow(partner(-97.7434)), 1);
|
||||||
|
assert.equal(weightOfFlow(partner(-97.7428)), 0.5);
|
||||||
|
});
|
||||||
|
test('a TomTom side that doubles back beside itself is not its own pair', () => {
|
||||||
|
// East along `coordinates`, then back west about 1 m north.
|
||||||
|
const hairpin = {
|
||||||
|
...flow,
|
||||||
|
coords: [...coordinates, [-97.742, 30.26701], [-97.744, 30.26701]],
|
||||||
|
};
|
||||||
|
assert.equal(flowSegmentsToRoads([hairpin])[0].densityWeight ?? 1, 1);
|
||||||
|
});
|
||||||
|
/** A flow tile of one-way Major roads; each line is [x, y, x, y, ...]. */
|
||||||
|
function flowTile(lines) {
|
||||||
|
const writer = new PbfWriter();
|
||||||
|
writer.writeMessage(3, writeFlowLayer, lines);
|
||||||
|
return writer.finish();
|
||||||
|
}
|
||||||
|
function writeFlowLayer(lines, layer) {
|
||||||
|
layer.writeVarintField(15, 2);
|
||||||
|
layer.writeStringField(1, 'Traffic flow');
|
||||||
|
layer.writeVarintField(5, 4096);
|
||||||
|
layer.writeStringField(3, 'traffic_level');
|
||||||
|
layer.writeMessage(4, (_, value) => value.writeDoubleField(3, 0.6), null);
|
||||||
|
layer.writeStringField(3, 'road_type');
|
||||||
|
layer.writeMessage(
|
||||||
|
4,
|
||||||
|
(_, value) => value.writeStringField(1, 'Major road'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
layer.writeStringField(3, 'traffic_road_coverage');
|
||||||
|
layer.writeMessage(
|
||||||
|
4,
|
||||||
|
(_, value) => value.writeStringField(1, 'one_side'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
for (const xy of lines) {
|
||||||
|
// MoveTo one point, LineTo the rest, as zigzag deltas.
|
||||||
|
const geometry = [9];
|
||||||
|
for (let i = 0; i < xy.length; i++) {
|
||||||
|
if (i === 2) geometry.push(2 | ((xy.length / 2 - 1) << 3));
|
||||||
|
const delta = xy[i] - (i < 2 ? 0 : xy[i - 2]);
|
||||||
|
geometry.push((delta << 1) ^ (delta >> 31));
|
||||||
|
}
|
||||||
|
layer.writeMessage(
|
||||||
|
2,
|
||||||
|
(_, feature) => {
|
||||||
|
feature.writeVarintField(3, 2);
|
||||||
|
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
|
||||||
|
feature.writePackedVarint(4, geometry);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test('TomTom sides clipped by their tile and the view pair only with other lines', async () => {
|
||||||
|
// A one-way hairpin turning in the tile buffer, its arms one tile unit
|
||||||
|
// (about 2 m) apart, and a separate opposite pair. The tile clip cuts the
|
||||||
|
// hairpin into two arms; the view clip then cuts every line again.
|
||||||
|
const lines = [
|
||||||
|
[500, 1000, 4300, 1000, 4300, 1001, 500, 1001],
|
||||||
|
[-200, 3000, 4300, 3000],
|
||||||
|
[4300, 3001, -200, 3001],
|
||||||
|
];
|
||||||
|
const source = createFlowTileSource({
|
||||||
|
fetchImpl: async () => new Response(flowTile(lines)),
|
||||||
|
});
|
||||||
|
const tile = tileToBBox(12, 935, 1686);
|
||||||
|
const west = tile.west + (tile.east - tile.west) * 0.2;
|
||||||
|
const { segments } = await source.fetchFlowDetail({
|
||||||
|
...tile,
|
||||||
|
west,
|
||||||
|
east: tile.east - 1e-9,
|
||||||
|
south: tile.south + 1e-9,
|
||||||
|
});
|
||||||
|
assert.equal(segments.length, 4);
|
||||||
|
assert.ok(
|
||||||
|
segments.every((s) => s.coords.every(([lon]) => lon >= west - 1e-10)),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1),
|
||||||
|
[1, 1, 0.5, 0.5],
|
||||||
|
);
|
||||||
|
});
|
||||||
test('bearing and distance reject nearby crossing and separate OSM roads', () => {
|
test('bearing and distance reject nearby crossing and separate OSM roads', () => {
|
||||||
const parallel = {
|
const parallel = {
|
||||||
...main,
|
...main,
|
||||||
|
|||||||
Reference in new issue
Block a user