fix: measure traffic inside the requested circle and report missing tiles

Traffic figures for a radius area counted roads anywhere in its bounding
box, and a tile that failed to load silently changed them. Roads are now cut
to the circle, and the flow source reports per request whether any tile
failed so the answer can say its figures are partial.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent 302a1c1995
commit 9e4cc9095b
3 files changed
+155 -23

No files matched your search

+78 -9
View File
@@ -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),
+53
View File
@@ -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/);
});