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,