From 7a39d2d29b4ecd186b0c80c408b488d304c189cc Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:51:42 -0700 Subject: [PATCH 1/4] fix(traffic): give paired TomTom sides of one street a two-way share of dots TomTom draws a two-way street whose directions differ as two one_side lines on one centreline, and each got a one-way road's full share of simulated vehicles, so the street drew twice the dots of the same street as one full-coverage line or a two-way OSM road. A one_side line with an opposite-direction one_side line within 5 m of at least two of three points along it now gets a two-way direction's half share. On the decoded Austin flow fixture every one_side line is paired, and road length weighted by direction share falls from 553 to 417 km. Pairing a 16-tile area takes about 41 ms per road pass. Refs #1455. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 ++ src/layers/traffic/roadModes.js | 77 ++++++++++++++++++++++++--- src/layers/traffic/roadModes.test.mjs | 47 ++++++++++++++++ 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 766823550..e882ef822 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5897,6 +5897,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 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. + 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 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 diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index 05b7ba4f7..e9220b635 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -35,6 +35,14 @@ const MIN_FILL_M = 40; * Same-class overlap keeps the full 35 m matcher radius. */ 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_OSM = new Set(['motorway', 'trunk']); @@ -85,20 +93,65 @@ 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; + 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, + () => PAIRED_SIDE_RADIUS_M, + ).best + ) + paired++; + } + return paired / PAIRED_SIDE_SAMPLES.length; +} + /** * Convert decoded TomTom flow lines into road records that carry their own * 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 * @returns {Array} Road records (`directFlow: true`). */ export function flowSegmentsToRoads(segments) { - return (segments || []) - .filter( - (segment) => - segment?.coords?.length >= 2 && isDrivableFlowSegment(segment), - ) - .map((segment) => ({ + const drivable = (segments || []).filter( + (segment) => segment?.coords?.length >= 2 && isDrivableFlowSegment(segment), + ); + const sides = createFlowSegmentIndex( + drivable.filter((segment) => segment.coverage !== 'full'), + ); + return drivable.map((segment) => { + const road = { coordinates: segment.coords, type: FLOW_ROAD_TYPES[segment.roadType], drivable: true, @@ -110,7 +163,15 @@ export function flowSegmentsToRoads(segments) { oneway: segment.coverage === 'full' ? 0 : 1, flow: { level: segment.trafficLevel, closure: segment.closure === 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]. */ diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index 1d1cd4986..465d41496 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -116,6 +116,53 @@ test('a full-coverage TomTom line is two-way and covers both OSM directions', () assert.equal(roads.length, 1); 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('bearing and distance reject nearby crossing and separate OSM roads', () => { const parallel = { ...main, From 1cd4dde26df2f4bf33909fbf3894696399c8a339 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:30:45 -0800 Subject: [PATCH 2/4] docs(changelog): credit #1495 in the fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/1495-traffic-paired-sides.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1495-traffic-paired-sides.md diff --git a/changelog.d/1495-traffic-paired-sides.md b/changelog.d/1495-traffic-paired-sides.md new file mode 100644 index 000000000..4007daf8e --- /dev/null +++ b/changelog.d/1495-traffic-paired-sides.md @@ -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). From 9caa0947b69489cc6975248245152074f8e275f6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:43:22 -0800 Subject: [PATCH 3/4] fix(traffic): keep a TomTom side from pairing with its own pieces A one_side line that doubles back within 5 m of itself, such as a turnaround drawn as one line, matched its own return leg and took a two-way half share with no partner. Flow index pieces now carry their source line, and a radiusFor of 0 leaves a piece out of nearest(), so pairing skips the line's own pieces. Co-Authored-By: Claude Opus 5.5 --- src/data/flowMatch.js | 6 ++++-- src/layers/traffic/roadModes.js | 3 ++- src/layers/traffic/roadModes.test.mjs | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/data/flowMatch.js b/src/data/flowMatch.js index c050f6917..cc4fe2280 100644 --- a/src/data/flowMatch.js +++ b/src/data/flowMatch.js @@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) { roadType, level, closure, + flow, }; const key = cellKey( 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; /** * 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) { const cx = Math.floor(px / CELL_SIZE_M); @@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) { candidate = true; // within radius, bearing not yet checked if (radiusFor) { 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); if ( diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index e9220b635..a6eecc5b5 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -126,7 +126,8 @@ function pairedSampleShare(segment, index) { xs[i - 1] + dx * t, ys[i - 1] + dy * t, reverse, - () => PAIRED_SIDE_RADIUS_M, + // A line that doubles back beside itself is not its own pair. + (piece) => (piece.flow === segment ? 0 : PAIRED_SIDE_RADIUS_M), ).best ) paired++; diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index 465d41496..406dc934b 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -163,6 +163,14 @@ test('a TomTom side paired along only part of its length keeps its share', () => 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); +}); test('bearing and distance reject nearby crossing and separate OSM roads', () => { const parallel = { ...main, From 342d9605820a4621615d7f3d1acc85f33dd34763 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:14:20 +0400 Subject: [PATCH 4/4] fix(traffic): retain line identity across clipping --- src/layers/traffic/flowSource.js | 29 ++++++----- src/layers/traffic/roadModes.js | 7 ++- src/layers/traffic/roadModes.test.mjs | 75 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index f517c99bc..12e746e43 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -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. */ export function createFlowTileSource({ fetchImpl = (...args) => globalThis.fetch(...args), @@ -41,12 +54,7 @@ export function createFlowTileSource({ entry?.release(); const segments = decodeFlowTile(bytes, z, x, y, { strict: true }) .filter(isDrivableFlowSegment) - .flatMap((segment) => - clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({ - ...segment, - coords, - })), - ); + .flatMap((segment) => clipFlowSegment(segment, tileToBBox(z, x, y))); if (mark) freshness.set(segments, mark); return segments; }, @@ -87,12 +95,9 @@ export function createFlowTileSource({ partial = result.partial; const segments = bounds.coverage ? result.tiles.flat() - : result.tiles.flat().flatMap((segment) => - clipTileLine(segment.coords, bounds).map((coords) => ({ - ...segment, - coords, - })), - ); + : result.tiles + .flat() + .flatMap((segment) => clipFlowSegment(segment, bounds)); const marks = result.tiles.map((tile) => freshness.get(tile) || {}); const times = marks .map((mark) => mark.fetchedAt) diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index a6eecc5b5..ce62f5e1f 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -112,6 +112,7 @@ function pairedSampleShare(segment, index) { } 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) { @@ -126,8 +127,10 @@ function pairedSampleShare(segment, index) { xs[i - 1] + dx * t, ys[i - 1] + dy * t, reverse, - // A line that doubles back beside itself is not its own pair. - (piece) => (piece.flow === segment ? 0 : PAIRED_SIDE_RADIUS_M), + // 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++; diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index 406dc934b..228a9609c 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -1,10 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; import { flowSegmentsToRoads, resolveRoadMode, selectTrafficRoads, } from './roadModes.js'; +import { createFlowTileSource } from './flowSource.js'; import { createTrafficSource } from './source.js'; import { createTrafficLayer } from './index.js'; import { @@ -14,6 +16,7 @@ import { serializeStoredLayerState, parseStoredLayerState, } from '../../data/layerState.js'; +import { tileToBBox } from '../../data/tomtomTiles.js'; const coordinates = [ [-97.744, 30.267], @@ -171,6 +174,78 @@ test('a TomTom side that doubles back beside itself is not its own pair', () => }; 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', () => { const parallel = { ...main,