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,