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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 78e072695..006334930 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 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/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/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 05b7ba4f7..ce62f5e1f 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,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 * 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 +167,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..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], @@ -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[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', () => { const parallel = { ...main,