diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 18d0b9863..49567a9ee 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -25,21 +25,31 @@ export function createFlowTileSource({ maxTiles: 16, }); let partial = false; + /** + * Flow segments for bounds, with whether any covering tile failed to load + * for this request. + */ + async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) { + const result = await tiles.fetchBounds(bounds, { + zoom, + signal, + tiles: bounds.coverage?.coarse, + }); + partial = result.partial; + const segments = bounds.coverage + ? result.tiles.flat() + : result.tiles.flat().flatMap((segment) => + clipTileLine(segment.coords, bounds).map((coords) => ({ + ...segment, + coords, + })), + ); + return { segments, partial: result.partial === true }; + } return { - async fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) { - const result = await tiles.fetchBounds(bounds, { - zoom, - signal, - tiles: bounds.coverage?.coarse, - }); - partial = result.partial; - if (bounds.coverage) return result.tiles.flat(); - return result.tiles.flat().flatMap((segment) => - clipTileLine(segment.coords, bounds).map((coords) => ({ - ...segment, - coords, - })), - ); + fetchFlowDetail, + async fetchFlowForBounds(bounds, options) { + return (await fetchFlowDetail(bounds, options)).segments; }, getFlowSessionStats: () => ({ ...tiles.getStats(), partial }), resetFlowTileCache: () => tiles.clear(), diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index b4743e81f..2b5c735e2 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -310,6 +310,55 @@ function lineKm(coords) { return total; } +/** + * The parts of a `[lon, lat]` line inside an area. Box areas keep the whole + * line (tiles are already clipped to the box); radius areas cut each segment + * where it crosses the circle, by bisection. + */ +function insideParts(coords, area) { + if (!area.center) return [coords]; + const inside = ([lon, lat]) => areaContains(area, { lat, lon }); + // Where the segment from-to crosses the circle, on its inside, found by + // bisection. One end must be inside and the other outside. + const edge = (from, to) => { + let a = from; + let b = to; + for (let step = 0; step < 20; step += 1) { + const middle = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + if (inside(middle) === inside(from)) a = middle; + else b = middle; + } + return inside(from) ? a : b; + }; + const parts = []; + let current = inside(coords[0]) ? [coords[0]] : null; + for (let index = 1; index < coords.length; index += 1) { + const from = coords[index - 1]; + const to = coords[index]; + const fromIn = inside(from); + const toIn = inside(to); + if (fromIn && toIn) current.push(to); + else if (fromIn) { + current.push(edge(from, to)); + parts.push(current); + current = null; + } else if (toIn) current = [edge(from, to), to]; + else { + // Both ends outside: the segment may still cut across the circle. + const middle = Array.from({ length: 15 }, (_, step) => { + const t = (step + 1) / 16; + return [ + from[0] + (to[0] - from[0]) * t, + from[1] + (to[1] - from[1]) * t, + ]; + }).find(inside); + if (middle) parts.push([edge(middle, from), edge(middle, to)]); + } + } + if (current) parts.push(current); + return parts.filter((part) => part.length > 1); +} + async function readFlow(traffic, area, signal) { const box = { west: area.west, @@ -319,7 +368,12 @@ async function readFlow(traffic, area, signal) { }; for (const zoom of FLOW_ZOOMS) { try { - return await traffic.fetchFlowForBounds(box, { zoom, signal }); + if (traffic.fetchFlowDetail) + return await traffic.fetchFlowDetail(box, { zoom, signal }); + return { + segments: await traffic.fetchFlowForBounds(box, { zoom, signal }), + partial: false, + }; } catch (error) { if (error?.code !== 'TILE_VIEW_TOO_WIDE') throw error; } @@ -357,9 +411,19 @@ export const getTrafficFlow = defineTool({ 'unavailable', 'Live traffic needs a TomTom key configured for this server', ); - const segments = (await readFlow(services.traffic, area, signal)).filter( - (segment) => Array.isArray(segment.coords) && segment.coords.length > 1, - ); + const flow = await readFlow(services.traffic, area, signal); + // Tiles cover the area's box; a radius area keeps only the parts of each + // road inside its circle. + const segments = flow.segments + .filter( + (segment) => Array.isArray(segment.coords) && segment.coords.length > 1, + ) + .flatMap((segment) => + insideParts(segment.coords, area).map((coords) => ({ + ...segment, + coords, + })), + ); const categories = new Map(); let measuredKm = 0; let weighted = 0; @@ -398,13 +462,18 @@ export const getTrafficFlow = defineTool({ })) .sort((a, b) => b.road_km - a.road_km); return { - summary: segments.length - ? `Traffic in ${area.label}: ${speedPct ?? 'unknown'}% of free-flow speed on average; ` + - `${round(congestedKm, 1)} km congested of ${round(measuredKm, 1)} km measured` + - (closedKm > 0 ? `; ${round(closedKm, 1)} km closed.` : '.') - : `No live traffic is reported in ${area.label}.`, + summary: + (segments.length + ? `Traffic in ${area.label}: ${speedPct ?? 'unknown'}% of free-flow speed on average; ` + + `${round(congestedKm, 1)} km congested of ${round(measuredKm, 1)} km measured` + + (closedKm > 0 ? `; ${round(closedKm, 1)} km closed.` : '.') + : `No live traffic is reported in ${area.label}.`) + + (flow.partial + ? ' Some map tiles did not load, so these figures are partial.' + : ''), data: { area: area.label, + partial: flow.partial, speed_pct_of_free_flow: speedPct, measured_km: round(measuredKm, 1), congested_km: round(congestedKm, 1), diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 7b41e7085..1d3212ff7 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -428,3 +428,56 @@ test('bike share names the systems it did not search', async () => { /more not searched: .*; use a smaller area\)\.$/, ); }); + +test('traffic counts only roads inside a radius area and reports missing tiles', async () => { + const flow = (segments, partial = false) => ({ + getStatus: async () => ({ hasKey: true }), + fetchFlowDetail: async () => ({ segments, partial }), + }); + const call = (traffic, area) => + composeCatalog({ tools: coreTools, services: { traffic } }).call( + 'get_traffic_flow', + { area }, + ); + const circle = { lat: 0, lon: 0, radius_km: 10 }; + // In the circle's bounding box, but about 12.6 km from its center. + const corner = [ + { + coords: [ + [0.08, 0.08], + [0.085, 0.085], + ], + closure: true, + trafficLevel: 1, + }, + ]; + const outside = await call(flow(corner), circle); + assert.equal(outside.data.closed_km, 0); + assert.equal(outside.data.measured_km, 0); + // A 20 km road through the center keeps the 20 km inside the circle. + const across = [ + { + coords: [ + [-0.2, 0], + [0.2, 0], + ], + trafficLevel: 1, + }, + ]; + const crossing = await call(flow(across), circle); + assert.ok(Math.abs(crossing.data.measured_km - 20) < 0.2); + // A road cutting the circle with both ends outside it. + const chord = [ + { + coords: [ + [-0.2, 0.05], + [0.2, 0.05], + ], + trafficLevel: 1, + }, + ]; + assert.ok((await call(flow(chord), circle)).data.measured_km > 10); + const partial = await call(flow(across, true), circle); + assert.equal(partial.data.partial, true); + assert.match(partial.summary, /Some map tiles did not load/); +});