diff --git a/.changeset/traffic-evidence-presentation.md b/.changeset/traffic-evidence-presentation.md new file mode 100644 index 000000000..88fd6fdf4 --- /dev/null +++ b/.changeset/traffic-evidence-presentation.md @@ -0,0 +1,9 @@ +--- +"web": patch +"@openmapx/i18n": patch +"@openmapx/core": patch +"@openmapx/integration-overlay-traffic-flow": patch +"@openmapx/integration-overlay-traffic-tomtom": patch +--- + +Show parenthesized estimated traffic delays inline with the duration, align the info button above the distance, and color route travel times using the existing severity bands. Use normal text for unknown congestion coverage, explaining unavailable data in the optional info popover and accessible route description; allow a green low-delay ETA only with complete, fresh provider evidence. Offer plain-language route and map explanations on demand, retain request-bound road-update verification, and use a Fast–Slow traffic legend. diff --git a/apps/web/src/components/panels/directions/RouteCard.test.tsx b/apps/web/src/components/panels/directions/RouteCard.test.tsx index 341d797b7..747c90ce6 100644 --- a/apps/web/src/components/panels/directions/RouteCard.test.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.test.tsx @@ -131,7 +131,9 @@ describe("RouteCard arrival time", () => { renderArrival({ kind: "now", destinationTimeZone: "Europe/Berlin" }); const arrival = screen.getByTestId("route-arrival"); expect(arrival).toHaveTextContent(/Arrives.*1:30\s*PM/); - expect(screen.getByRole("radio").getAttribute("aria-describedby")).toBe(arrival.id); + expect(screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ")).toContain( + arrival.id, + ); }); it("resolves a chosen departure in the origin zone before displaying destination time", () => { @@ -354,39 +356,112 @@ describe("RouteCard keyboard actions", () => { }); }); -describe("RouteCard traffic delta", () => { - it("shows nothing when there is no baseline duration", () => { +describe("RouteCard compact traffic explanation", () => { + it("describes the caveat when keyboard users select a route", () => { renderCard(baseRoute); - expect(screen.queryByTestId("traffic-delay")).toBeNull(); + const ids = screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ") ?? []; + expect(ids.map((id) => document.getElementById(id)?.textContent).join(" ")).toContain( + "Traffic data unavailable", + ); }); - - it("shows nothing when the delay is under five minutes", () => { - // 4 min on a 40 min baseline: over 10% but under the absolute floor. - renderCard({ ...baseRoute, duration: 2640, baselineDuration: 2400 }); - expect(screen.queryByTestId("traffic-delay")).toBeNull(); + it("shows a compact delay and describes it to keyboard users", () => { + renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\(\+45 min\)$/); + expect(screen.queryByText(/baseline/i)).toBeNull(); + expect(screen.queryByText("Traffic data unavailable")).toBeNull(); + const ids = screen.getByRole("radio").getAttribute("aria-describedby")?.split(" ") ?? []; + expect(ids.map((id) => document.getElementById(id)?.textContent).join(" ")).toContain( + "+45 min", + ); }); - - it("shows nothing when the delay is under ten percent", () => { - // 6 min on a 90 min baseline: over the absolute floor but under the ratio. - renderCard({ ...baseRoute, duration: 5760, baselineDuration: 5400 }); + it.each([ + [3960, "light"], + [4500, "moderate"], + [5400, "heavy"], + [7200, "severe"], + ] as const)( + "colors the travel time for a %s-second route using the %s delay band", + (duration, band) => { + renderCard({ ...baseRoute, duration, baselineDuration: 3600 }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + `var(--omx-traffic-${band})`, + ); + }, + ); + it.each([ + [2640, 2400], // Relative threshold reached, less than five minutes extra. + [5760, 5400], // More than five minutes extra, below ten percent. + [3600, 3600], // Same estimate without current traffic speeds. + [3500, 3600], // Current conditions can be quicker than the comparison. + ])("does not advertise a significant delay for %s / %s seconds", (duration, baselineDuration) => { + renderCard({ ...baseRoute, duration, baselineDuration }); expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + expect(screen.getByRole("button", { name: "About traffic" })).toBeInTheDocument(); }); - - it("shows the delay once both thresholds are met", () => { - // 12 min on a 75 min baseline = 16% -> light band. - renderCard({ ...baseRoute, duration: 5220, baselineDuration: 4500 }); - const el = screen.getByTestId("traffic-delay"); - expect(el.textContent).toContain("12 min"); - expect(getComputedStyle(el).color).toBe("var(--omx-traffic-light)"); + it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "does not fabricate a delay without a usable comparison (%s)", + (baselineDuration) => { + renderCard({ ...baseRoute, baselineDuration }); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + }, + ); + it.each(["walking", "cycling"] as const)("does not show traffic delays for %s", (mode) => { + renderCard({ ...baseRoute, mode, duration: 6300, baselineDuration: 3600 }); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.queryByRole("button", { name: "About traffic" })).toBeNull(); }); - - it("escalates the colour with the delay", () => { - // 45 min on a 60 min baseline = 75% -> heavy band. - renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); - expect(getComputedStyle(screen.getByTestId("traffic-delay")).color).toBe( + it("keeps severity coloring on an unselected motorcycle alternative", () => { + render( + + {}} + onDetails={() => {}} + units="metric" + /> + , + ); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( "var(--omx-traffic-heavy)", ); + expect(screen.getByText("(+45 min)")).toBeInTheDocument(); }); + it("localizes the compact delay in German", () => { + renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }, "metric", "de"); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\(\+45 min\)$/); + }); + it.each(["route", "peek"] as const)( + "opens and closes traffic info without selecting the %s route", + (selectionKind) => { + const onSelect = vi.fn(); + const view = render( + + {}} + units="metric" + /> + , + ); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toBeInTheDocument(); + expect(onSelect).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onSelect).not.toHaveBeenCalled(); + expect(view.container.querySelector("button button, button input, label button")).toBeNull(); + }, + ); }); /** @@ -924,3 +999,201 @@ describe("RouteCard impact integration", () => { expect(screen.getByText("Fastest route")).toBeDefined(); }); }); + +describe("RouteCard current congestion coverage", () => { + const now = Date.parse("2026-10-07T12:00:00Z"); + const freshCoverage = { + complete: true, + evaluatedAt: "2026-10-07T11:59:59Z", + validUntil: "2026-10-07T12:00:30Z", + }; + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + afterEach(() => vi.useRealTimers()); + + it.each([3600, 3500, 3840, 3899])( + "uses green without a hint for a fresh %s-second comparison", + (duration) => { + renderCard({ + ...baseRoute, + duration, + baselineDuration: 3600, + trafficCoverage: freshCoverage, + }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "var(--omx-brand)", + ); + expect(screen.queryByTestId("route-traffic-status")).toBeNull(); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + const describedBy = screen.getByRole("radio").getAttribute("aria-describedby"); + expect(describedBy).toBeNull(); + }, + ); + + it.each([ + undefined, + { ...freshCoverage, complete: false }, + { ...freshCoverage, evaluatedAt: "2026-10-07T12:00:01Z" }, + { ...freshCoverage, validUntil: "2026-10-07T12:00:00Z" }, + { ...freshCoverage, validUntil: "invalid" }, + { ...freshCoverage, evaluatedAt: "invalid" }, + { ...freshCoverage, validUntil: "2026-10-07T12:05:00Z" }, + ])("does not turn a zero delay green with insufficient evidence (%j)", (trafficCoverage) => { + renderCard({ ...baseRoute, duration: 3600, baselineDuration: 3600, trafficCoverage }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + }); + + it("expires green and updates both the duration and its accessible note", () => { + renderCard({ + ...baseRoute, + duration: 3600, + baselineDuration: 3600, + trafficCoverage: freshCoverage, + }); + act(() => vi.advanceTimersByTime(30000)); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + const id = screen.getByRole("radio").getAttribute("aria-describedby"); + expect(id && document.getElementById(id)).toHaveTextContent("Traffic data unavailable"); + }); + + it("rearms freshness expiration when the wall clock moves backwards", () => { + renderCard({ + ...baseRoute, + duration: 3600, + baselineDuration: 3600, + trafficCoverage: freshCoverage, + }); + vi.setSystemTime(now - 10000); + act(() => vi.advanceTimersByTime(30000)); + expect(screen.queryByText("Traffic data unavailable")).toBeNull(); + act(() => vi.advanceTimersByTime(10000)); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + }); + + it("still requires a usable comparison even with complete fresh coverage", () => { + renderCard({ ...baseRoute, trafficCoverage: freshCoverage }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + }); + + it("keeps significant delays colored even with complete coverage", () => { + renderCard({ + ...baseRoute, + duration: 6300, + baselineDuration: 3600, + trafficCoverage: freshCoverage, + }); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "var(--omx-traffic-heavy)", + ); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\(\+45 min\)$/); + expect(screen.queryByText("Traffic data unavailable")).toBeNull(); + }); +}); + +describe("RouteCard expired congestion estimates", () => { + const now = Date.parse("2026-10-07T12:00:00Z"); + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + afterEach(() => vi.useRealTimers()); + it("drops the delay color and caption when supplied congestion evidence expires", () => { + renderCard({ + ...baseRoute, + duration: 6300, + baselineDuration: 3600, + trafficCoverage: { + complete: true, + evaluatedAt: "2026-10-07T11:59:59Z", + validUntil: "2026-10-07T12:00:30Z", + }, + }); + expect(screen.getByTestId("traffic-delay")).toHaveTextContent(/^\(\+45 min\)$/); + act(() => vi.advanceTimersByTime(30000)); + expect(screen.queryByTestId("traffic-delay")).toBeNull(); + expect(screen.getByText("Traffic data unavailable")).toBeInTheDocument(); + expect(getComputedStyle(screen.getByRole("heading", { level: 6 })).color).toBe( + "rgba(0, 0, 0, 0.87)", + ); + }); +}); + +describe("RouteCard compact traffic header", () => { + it("shows the parenthesized delay directly in the duration heading", () => { + renderCard({ ...baseRoute, duration: 6300, baselineDuration: 3600 }); + expect(screen.getByRole("heading", { level: 6 })).toHaveTextContent("1 h 45 min (+45 min)"); + expect( + screen.getByRole("heading", { level: 6 }).contains(screen.getByTestId("traffic-delay")), + ).toBe(true); + expect(screen.getAllByTestId("traffic-delay")).toHaveLength(1); + }); + + it.each(["route", "peek"] as const)( + "keeps the info button outside the %s selection control", + (selectionKind) => { + const onSelect = vi.fn(); + render( + + {}} + units="metric" + /> + , + ); + const button = screen.getByRole("button", { name: "About traffic" }); + expect(button.closest("label")).toBeNull(); + expect(button.parentElement?.closest("button")).toBeNull(); + fireEvent.click(button); + expect(screen.getByRole("dialog", { name: "About traffic" })).toBeInTheDocument(); + expect(onSelect).not.toHaveBeenCalled(); + }, + ); +}); + +it.each(["route", "peek"] as const)( + "keeps unknown traffic available to assistive technology without a visible row (%s)", + (selectionKind) => { + render( + + {}} + onDetails={() => {}} + units="metric" + /> + , + ); + const selection = + selectionKind === "route" + ? screen.getByRole("radio") + : screen.getByRole("button", { name: /via A46/ }); + const id = selection.getAttribute("aria-describedby"); + const description = id ? document.getElementById(id) : null; + expect(description).toHaveTextContent("Traffic data unavailable"); + expect(description && getComputedStyle(description).position).toBe("absolute"); + expect(description && getComputedStyle(description).width).toBe("1px"); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Traffic data unavailable", + ); + }, +); diff --git a/apps/web/src/components/panels/directions/RouteCard.tsx b/apps/web/src/components/panels/directions/RouteCard.tsx index 120f43526..2cc83c844 100644 --- a/apps/web/src/components/panels/directions/RouteCard.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.tsx @@ -17,7 +17,6 @@ import type { RouteImpactUnavailableReason, } from "@openmapx/core"; import { - bandForDelayRatio, buildElevationProfile, estimateDrivingCo2Grams, formatDistance, @@ -43,13 +42,12 @@ import { useStartNavigation } from "@/lib/mobile/useStartNavigation"; import { primeSpeechSynthesis } from "@/lib/navigation/useNavigationVoice"; import { requestHeadingPermission } from "@/lib/useHeading"; import { useNow } from "@/lib/useNow"; +import { useRouteTrafficPresentation } from "@/lib/useRouteTrafficPresentation"; +import { RouteTrafficStatus } from "./RouteTrafficStatus"; import { type RouteArrivalContext, resolveRouteArrival } from "./routeArrival"; const GROUND_MODES = new Set(["driving", "walking", "cycling", "motorcycle"]); -/** Absolute floor for showing a traffic delay, in seconds. */ -const MIN_TRAFFIC_DELAY_SECONDS = 300; - function RouteArrivalCaption({ context, durationSeconds, @@ -135,6 +133,7 @@ export function RouteCard({ const t = useTranslations("directions"); const tc = useTranslations("common"); const tNav = useTranslations("navigation"); + const tTraffic = useTranslations("trafficStatus"); const locale = useLocale(); const { startGround } = useStartNavigation(); const waypoints = useDirectionsStore((s) => s.waypoints); @@ -148,6 +147,17 @@ export function RouteCard({ const [startError, setStartError] = useState(null); const [impactDetailsOpen, setImpactDetailsOpen] = useState(false); const arrivalCaptionId = useId(); + const trafficCaptionId = useId(); + const traffic = useRouteTrafficPresentation(route); + const selectionDescription = + [ + route.mode === "driving" && arrivalContext ? arrivalCaptionId : null, + (route.mode === "driving" || route.mode === "motorcycle") && traffic.kind !== "clear" + ? trafficCaptionId + : null, + ] + .filter(Boolean) + .join(" ") || undefined; const roadConditionNotice = roadConditionRouteNotice(roadConditionImpact); const handleStart = async () => { @@ -230,28 +240,46 @@ export function RouteCard({ ); - // Only worth surfacing when it clears both an absolute floor and a relative - // one: a 90-second delta on a two-hour drive tells the user nothing, and a - // large ratio on a very short hop is mostly snapping noise. - const trafficDelay = (() => { - const baseline = route.baselineDuration; - if (baseline === undefined || baseline <= 0) return null; - const delaySeconds = route.duration - baseline; - if (delaySeconds < MIN_TRAFFIC_DELAY_SECONDS) return null; - const band = bandForDelayRatio(delaySeconds / baseline); - if (!band) return null; - return { band, delaySeconds, baseline }; - })(); - const selectionLabel = `${route.summary ?? t("bestRoute")}, ${formatDuration(route.duration)}, ${dist}${ascentLabel ? `, ${ascentLabel}` : ""}`; const summaryContent = ( <> - {formatDuration(route.duration)} + {formatDuration(route.duration)} + {traffic.kind === "delay" && ( + + {" "} + ({tTraffic("delay", { delay: formatDuration(traffic.seconds) })}) + + )} @@ -277,17 +305,6 @@ export function RouteCard({ {ascentLabel} )} - {trafficDelay && ( - - {t("trafficDelay", { delay: formatDuration(trafficDelay.delaySeconds) })} - {" · "} - {t("trafficDelayNormally", { baseline: formatDuration(trafficDelay.baseline) })} - - )} {roadConditionNotice && ( - {modeIcon} + + {modeIcon} + - {selectionKind === "peek" ? ( - - {summaryContent} - - ) : ( - + + {selectionKind === "peek" ? ( { - if (active) onSelect(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") onSelect(); - }} - sx={{ position: "absolute", opacity: 0, width: "1px", height: "1px", m: 0 }} - /> - {summaryContent} + aria-describedby={selectionDescription} + onClick={onSelect} + sx={selectionSx} + > + {summaryContent} + + ) : ( + + { + if (active) onSelect(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") onSelect(); + }} + sx={{ position: "absolute", opacity: 0, width: "1px", height: "1px", m: 0 }} + /> + {summaryContent} + + )} + + - )} + + {(route.mode === "driving" || route.mode === "motorcycle") && + traffic.kind === "unavailable" && ( + + {tTraffic("summary")} + + )} {impact ? ( ( + + + +); +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); +it("expires held verification at the lease and never claims congestion causality", () => { + render(view()); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Available road updates were used for this route.", + ); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "This travel time may not include current traffic delays.", + ); + act(() => { + vi.advanceTimersByTime(30000); + }); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Road updates need to be checked again. Refresh the route.", + ); + expect(screen.getByRole("dialog", { name: "About traffic" })).not.toHaveTextContent( + "Available road updates were used", + ); +}); +it("replaces old status on a new provider/route without proof", () => { + const rendered = render(view()); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + rendered.rerender(view({ ...route, trafficProof: undefined }, "routing-osrm")); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent("OSRM"); + expect(screen.getByRole("dialog", { name: "About traffic" })).not.toHaveTextContent( + "Available road updates were used", + ); +}); +it("removes a prior successful claim on failed assessment", () => { + const rendered = render(view()); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + rendered.rerender(view(route, "routing-valhalla", { ...impact, availability: "unavailable" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Road updates couldn’t be checked.", + ); +}); + +it("rearms expiry when a clock correction makes the first callback early", () => { + render(view()); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + vi.setSystemTime(now - 1000); + act(() => { + vi.advanceTimersByTime(30000); + }); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Available road updates were used", + ); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Road updates need to be checked again. Refresh the route.", + ); + expect(screen.getByRole("dialog", { name: "About traffic" })).not.toHaveTextContent( + "Available road updates were used", + ); +}); + +it("keeps provider and application details out of the default summary", () => { + render(view()); + expect(screen.queryByText(/Self-hosted Valhalla/)).toBeNull(); + expect(screen.queryByText("Available road updates were used for this route.")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Available road updates were used for this route.", + ); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +it("updates the explanation when a new route has no estimate", () => { + const r = { ...route, duration: 6300, baselineDuration: 3600 }; + const rendered = render(view(r)); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "The extra time is estimated by comparing this route with and without current traffic speeds.", + ); + expect(screen.getByRole("dialog", { name: "About traffic" })).not.toHaveTextContent( + "This travel time may not include current traffic delays.", + ); + rendered.rerender(view(route)); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "This travel time may not include current traffic delays.", + ); +}); + +it("states that traffic data is unavailable inside the optional explanation", () => { + render(view()); + expect(screen.queryByText("Traffic data unavailable")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "About traffic" })); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Traffic data unavailable", + ); +}); diff --git a/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx new file mode 100644 index 000000000..5d1cfd481 --- /dev/null +++ b/apps/web/src/components/panels/directions/RouteTrafficStatus.tsx @@ -0,0 +1,63 @@ +"use client"; + +import Typography from "@mui/material/Typography"; +import type { RoadConditionRouteImpact, Route } from "@openmapx/core"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { TrafficInfo } from "@/integration-api/components/TrafficInfo"; +import { routeTrafficStatus } from "@/lib/route-traffic-status"; +import { useRouteTrafficPresentation } from "@/lib/useRouteTrafficPresentation"; + +export function RouteTrafficStatus({ + route, + impact, + provider, +}: { + route: Route; + impact?: RoadConditionRouteImpact; + provider?: string; +}) { + const t = useTranslations("trafficStatus"); + const [revision, setRevision] = useState(0); + void revision; + const status = routeTrafficStatus(route, impact, provider, Date.now()); + const deadline = status?.deadline; + useEffect(() => { + if (deadline == null) return; + let timer: ReturnType | undefined; + const expire = () => { + const remaining = deadline - Date.now(); + // A wall-clock correction can make a monotonic timeout fire early. + if (remaining > 0) timer = setTimeout(expire, remaining); + else setRevision((v) => v + 1); + }; + expire(); + return () => clearTimeout(timer); + }, [deadline]); + const traffic = useRouteTrafficPresentation(route); + if (!status) return null; + return ( + + {traffic.kind === "unavailable" && ( + + {t("summary")} + + )} + + {t( + traffic.kind === "delay" + ? "estimateExplanation" + : traffic.kind === "clear" + ? "coverageExplanation" + : "congestionUnverified", + )} + + + {t(`application.${status.application}`)} + + + {t("provider", { source: t(`source.${status.source}`) })} + + + ); +} diff --git a/apps/web/src/integration-api/components/TrafficInfo.test.tsx b/apps/web/src/integration-api/components/TrafficInfo.test.tsx new file mode 100644 index 000000000..658edf3d1 --- /dev/null +++ b/apps/web/src/integration-api/components/TrafficInfo.test.tsx @@ -0,0 +1,36 @@ +import { en } from "@openmapx/i18n"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, expect, it } from "vitest"; +import { cleanup, render, screen, userEvent, waitFor } from "@/test"; +import { TrafficInfo } from "./TrafficInfo"; + +afterEach(cleanup); + +it.each(["Escape", "Close", "outside click"] as const)( + "dismisses traffic info with %s and returns focus to its trigger", + async (dismissal) => { + const user = userEvent.setup(); + render( + + Traffic explanation + , + ); + const trigger = screen.getByRole("button", { name: "About traffic" }); + await user.click(trigger); + expect(screen.getByRole("dialog", { name: "About traffic" })).toHaveTextContent( + "Traffic explanation", + ); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + if (dismissal === "Escape") await user.keyboard("{Escape}"); + else if (dismissal === "Close") await user.click(screen.getByRole("button", { name: "Close" })); + else { + // The modal backdrop intentionally has no accessible control role. + const backdrop = document.querySelector(".MuiBackdrop-root"); + expect(backdrop).not.toBeNull(); + await user.click(backdrop as HTMLElement); + } + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(trigger).toHaveFocus(); + }, +); diff --git a/apps/web/src/integration-api/components/TrafficInfo.tsx b/apps/web/src/integration-api/components/TrafficInfo.tsx new file mode 100644 index 000000000..1e1b50d57 --- /dev/null +++ b/apps/web/src/integration-api/components/TrafficInfo.tsx @@ -0,0 +1,59 @@ +"use client"; + +import CloseIcon from "@mui/icons-material/Close"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import Box from "@mui/material/Box"; +import IconButton from "@mui/material/IconButton"; +import Popover from "@mui/material/Popover"; +import Typography from "@mui/material/Typography"; +import { useTranslations } from "next-intl"; +import { type ReactNode, useId, useState } from "react"; + +/** Optional traffic explanation; never nested inside route selection controls. */ +export function TrafficInfo({ children }: { children: ReactNode }) { + const t = useTranslations("trafficStatus"); + const tc = useTranslations("common"); + const [anchor, setAnchor] = useState(null); + const id = useId(); + return ( + <> + { + event.stopPropagation(); + setAnchor(event.currentTarget); + }} + > + + + setAnchor(null)} + onClick={(event) => event.stopPropagation()} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + slotProps={{ + paper: { + id, + role: "dialog", + "aria-label": t("about"), + sx: { p: 2, width: 300, maxWidth: "calc(100vw - 32px)", overflowWrap: "anywhere" }, + }, + }} + > + + {t("about")} + setAnchor(null)}> + + + + {children} + + + ); +} diff --git a/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx b/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx new file mode 100644 index 000000000..0edf77cc7 --- /dev/null +++ b/apps/web/src/integration-api/overlay/TrafficOverlayContext.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useTranslations } from "next-intl"; +import type { ReactNode } from "react"; +import { TrafficInfo } from "../components/TrafficInfo"; + +/** These tile formats expose no upstream timestamp or extent completeness. */ +export function TrafficOverlayContext({ + hosted, + visible, + children, +}: { + hosted: boolean; + visible: boolean; + children?: ReactNode; +}) { + const t = useTranslations("trafficStatus.overlay"); + return ( + + {!visible && ( + + {t("hidden")} + + )} + + + {t(hosted ? "hostedSource" : "ownedSource")} + + + {t("ageUnknown")} {t("coverageUnknown")} + + {!hosted && ( + + {t("estimates")} + + )} + {t("mapOnly")} + {children} + + + ); +} diff --git a/apps/web/src/integration-api/runtime/theme.ts b/apps/web/src/integration-api/runtime/theme.ts index 7d697713e..9f8d762f1 100644 --- a/apps/web/src/integration-api/runtime/theme.ts +++ b/apps/web/src/integration-api/runtime/theme.ts @@ -17,9 +17,8 @@ export const BRAND_HEX = "#207E23"; /** * Traffic-delay text colours, keyed by the shared `TrafficBand` names. CSS * variables rather than hexes so light/dark switching stays in CSS, matching - * BRAND above. `freeFlow` is absent by design: a delay below the display - * threshold is not shown at all, and a green ETA would imply we had verified - * the route is clear. + * BRAND above. Green ETAs use BRAND separately and require complete, fresh + * congestion coverage; a small recosting difference alone is insufficient. */ export const TRAFFIC_TEXT_COLOR: Record = { light: "var(--omx-traffic-light)", diff --git a/apps/web/src/lib/route-traffic-delay.ts b/apps/web/src/lib/route-traffic-delay.ts new file mode 100644 index 000000000..7d474859c --- /dev/null +++ b/apps/web/src/lib/route-traffic-delay.ts @@ -0,0 +1,54 @@ +import { bandForDelayRatio, type DelayBand, type Route } from "@openmapx/core"; + +/** Same-path estimate against the engine recosting that excludes current speeds. */ +export function routeTrafficDelay( + route: Route, +): { seconds: number; band: DelayBand | null } | null { + const baseline = route.baselineDuration; + if ( + (route.mode !== "driving" && route.mode !== "motorcycle") || + typeof baseline !== "number" || + !Number.isFinite(baseline) || + baseline <= 0 || + !Number.isFinite(route.duration) || + route.duration < 0 + ) + return null; + const seconds = route.duration - baseline; + return { + seconds, + band: seconds >= 300 ? bandForDelayRatio(seconds / baseline) : null, + }; +} + +export type RouteTrafficPresentation = + | { kind: "unavailable"; deadline: null } + | { kind: "clear"; deadline: number } + | { kind: "delay"; seconds: number; band: DelayBand; deadline: number | null }; + +/** A zero recosting difference cannot establish fresh congestion coverage. */ +export function routeTrafficPresentation(route: Route, now: number): RouteTrafficPresentation { + const delay = routeTrafficDelay(route); + const coverage = route.trafficCoverage; + const evaluatedAt = + typeof coverage?.evaluatedAt === "string" ? Date.parse(coverage.evaluatedAt) : NaN; + const validUntil = + typeof coverage?.validUntil === "string" ? Date.parse(coverage.validUntil) : NaN; + const fresh = + [now, evaluatedAt, validUntil].every(Number.isFinite) && + evaluatedAt <= now && + now < validUntil && + validUntil - evaluatedAt <= 120_000; + // Supplied evidence must not keep an expired or malformed estimate alive. + if (coverage !== undefined && !fresh) return { kind: "unavailable", deadline: null }; + // A positive estimate remains useful even without route-wide coverage. + if (delay?.band) + return { + kind: "delay", + seconds: delay.seconds, + band: delay.band, + deadline: fresh ? validUntil : null, + }; + if (delay && coverage?.complete === true && fresh) return { kind: "clear", deadline: validUntil }; + return { kind: "unavailable", deadline: null }; +} diff --git a/apps/web/src/lib/route-traffic-status.test.ts b/apps/web/src/lib/route-traffic-status.test.ts new file mode 100644 index 000000000..22472250f --- /dev/null +++ b/apps/web/src/lib/route-traffic-status.test.ts @@ -0,0 +1,115 @@ +import type { RoadConditionRouteImpact, Route, RoutingTrafficProof } from "@openmapx/core"; +import { describe, expect, it } from "vitest"; +import { routeTrafficStatus } from "./route-traffic-status"; + +const now = Date.parse("2026-10-07T02:00:00Z"); +export const proof: RoutingTrafficProof = { + schemaVersion: 1, + requestId: "request-one", + writeId: "write-one", + graphGeneration: "graph-one", + engineBootId: "boot-one", + endpoint: "route", + costing: "auto", + evaluatedAt: new Date(now - 1000).toISOString(), + validUntil: new Date(now + 60000).toISOString(), +}; +export const route: Route = { + mode: "driving", + duration: 2000, + distance: 10000, + geometry: [], + legs: [], + steps: [], + trafficProof: proof, +}; +export const impact: RoadConditionRouteImpact = { + availability: "current", + reasons: [], + evaluatedAt: new Date(now).toISOString(), + validUntil: new Date(now + 30000).toISOString(), +}; + +describe("route traffic evidence", () => { + it("requires the matching server assessment as well as proof", () => { + expect(routeTrafficStatus(route, impact, "routing-valhalla", now)?.application).toBe( + "verified", + ); + expect(routeTrafficStatus(route, undefined, "routing-valhalla", now)?.application).toBe( + "unverified", + ); + expect( + routeTrafficStatus({ ...route, trafficProof: undefined }, impact, "routing-valhalla", now) + ?.application, + ).toBe("unverified"); + }); + it.each([ + { schemaVersion: 2 }, + { requestId: "" }, + { writeId: " " }, + { graphGeneration: "x".repeat(257) }, + { engineBootId: "" }, + { endpoint: "matrix" }, + { costing: "motorcycle" }, + { evaluatedAt: new Date(now + 1).toISOString() }, + { evaluatedAt: "invalid" }, + { validUntil: "invalid" }, + ])("rejects invalid proof %j", (invalid) => { + const trafficProof = { ...proof, ...invalid } as RoutingTrafficProof; + expect( + routeTrafficStatus({ ...route, trafficProof }, impact, "routing-valhalla", now)?.application, + ).toBe("unverified"); + }); + it("expires either lease at its exact deadline", () => { + expect(routeTrafficStatus(route, impact, "routing-valhalla", now + 30000)?.application).toBe( + "expired", + ); + expect( + routeTrafficStatus( + { ...route, trafficProof: { ...proof, validUntil: new Date(now).toISOString() } }, + impact, + "routing-valhalla", + now, + )?.application, + ).toBe("expired"); + }); + it.each([ + { ...impact, evaluatedAt: new Date(now - 2000).toISOString() }, + { ...impact, evaluatedAt: new Date(now + 1).toISOString() }, + { ...impact, validUntil: new Date(now + 61000).toISOString() }, + { ...impact, reasons: ["graph_mismatch"] }, + ])("rejects contradictory assessment %j", (assessment) => { + expect(routeTrafficStatus(route, assessment, "routing-valhalla", now)?.application).toBe( + "unverified", + ); + }); + it("does not carry verification to another provider or hosted route", () => { + expect(routeTrafficStatus(route, impact, "routing-osrm", now)?.application).toBe("unverified"); + expect( + routeTrafficStatus({ ...route, sourceIds: ["stadia-maps"] }, impact, "routing-valhalla", now), + ).toMatchObject({ application: "unverified", source: "hostedValhalla" }); + }); + it("retains limited and failed assessment without verified claim", () => { + expect( + routeTrafficStatus(route, { ...impact, availability: "limited" }, "routing-valhalla", now) + ?.application, + ).toBe("limited"); + expect( + routeTrafficStatus(route, { ...impact, availability: "unavailable" }, "routing-valhalla", now) + ?.application, + ).toBe("unavailable"); + }); + it("only labels motorized routes and matches motorcycle costing", () => { + expect( + routeTrafficStatus({ ...route, mode: "walking" }, impact, "routing-valhalla", now), + ).toBeNull(); + expect( + routeTrafficStatus( + { ...route, mode: "motorcycle", trafficProof: { ...proof, costing: "motorcycle" } }, + impact, + "routing-valhalla", + now, + )?.application, + ).toBe("verified"); + }); +}); diff --git a/apps/web/src/lib/route-traffic-status.ts b/apps/web/src/lib/route-traffic-status.ts new file mode 100644 index 000000000..3ef608576 --- /dev/null +++ b/apps/web/src/lib/route-traffic-status.ts @@ -0,0 +1,69 @@ +import type { RoadConditionRouteImpact, Route } from "@openmapx/core"; + +export interface RouteTrafficStatusValue { + application: "verified" | "expired" | "limited" | "unavailable" | "unverified"; + source: "localValhalla" | "hostedValhalla" | "valhalla" | "osrm" | "unknown"; + deadline: number | null; +} + +/** Consumer guard, not an independent check of the engine's active graph. + * The server assessment verifies graph/write/boot identity for this response. + * Neither that assessment nor its proof establishes congestion's effect on ETA. + */ +export function routeTrafficStatus( + route: Route, + impact: RoadConditionRouteImpact | undefined, + provider: string | undefined, + nowMs: number, +): RouteTrafficStatusValue | null { + if (route.mode !== "driving" && route.mode !== "motorcycle") return null; + const hosted = route.sourceIds?.includes("stadia-maps") === true; + const source = + provider === "routing-valhalla" + ? hosted + ? "hostedValhalla" + : "valhalla" + : provider === "routing-osrm" + ? "osrm" + : "unknown"; + const result: RouteTrafficStatusValue = { application: "unverified", source, deadline: null }; + if (impact?.availability === "expired") return { ...result, application: "expired" }; + if (impact?.availability === "limited") return { ...result, application: "limited" }; + if (impact?.availability === "unavailable" || impact?.availability === "unsupported") + return { ...result, application: "unavailable" }; + const p = route.trafficProof; + if (provider !== "routing-valhalla" || hosted || !p || impact?.availability !== "current") + return result; + if ( + p.schemaVersion !== 1 || + (p.endpoint !== "route" && p.endpoint !== "optimized_route") || + p.costing !== (route.mode === "driving" ? "auto" : "motorcycle") || + ![p.requestId, p.writeId, p.graphGeneration, p.engineBootId, p.evaluatedAt, p.validUntil].every( + (value) => typeof value === "string" && value.trim().length > 0 && value.length <= 256, + ) + ) + return result; + const evaluatedAt = Date.parse(p.evaluatedAt); + const proofDeadline = Date.parse(p.validUntil); + const assessmentTime = Date.parse(impact.evaluatedAt); + const assessmentDeadline = impact.validUntil ? Date.parse(impact.validUntil) : NaN; + if ( + ![nowMs, evaluatedAt, proofDeadline, assessmentTime, assessmentDeadline].every(Number.isFinite) + ) + return result; + if ( + evaluatedAt > nowMs || + assessmentTime > nowMs || + assessmentTime < evaluatedAt || + impact.reasons.length > 0 + ) + return result; + if (proofDeadline <= nowMs || assessmentDeadline <= nowMs) + return { ...result, application: "expired" }; + if (assessmentDeadline > proofDeadline) return result; + return { + application: "verified", + source: "localValhalla", + deadline: Math.min(proofDeadline, assessmentDeadline), + }; +} diff --git a/apps/web/src/lib/useRouteTrafficPresentation.ts b/apps/web/src/lib/useRouteTrafficPresentation.ts new file mode 100644 index 000000000..23cfa1726 --- /dev/null +++ b/apps/web/src/lib/useRouteTrafficPresentation.ts @@ -0,0 +1,25 @@ +"use client"; + +import type { Route } from "@openmapx/core"; +import { useEffect, useState } from "react"; +import { routeTrafficPresentation } from "./route-traffic-delay"; + +/** Re-evaluate a held route when its congestion evidence expires. */ +export function useRouteTrafficPresentation(route: Route) { + const [, refresh] = useState(0); + const presentation = routeTrafficPresentation(route, Date.now()); + const deadline = presentation.deadline; + useEffect(() => { + if (deadline === null) return; + let timer: ReturnType | undefined; + const expire = () => { + const remaining = deadline - Date.now(); + // A backwards clock correction must not expire the lease early. + if (remaining > 0) timer = setTimeout(expire, remaining); + else refresh((revision) => revision + 1); + }; + expire(); + return () => clearTimeout(timer); + }, [deadline]); + return presentation; +} diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index 39d72b08a..9458eb5f1 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -122,6 +122,51 @@ selected travel time. The system evaluates these in the closure's local timezone fine-grained recurring schedule windows). This prevents routing detours around future closures that haven't started yet or nightly closures during daytime trips. +### Reading traffic information + +Driving and motorcycle cards color the travel time by the estimated traffic +delay and show a short caption such as **(+12 min)** directly after the duration. The estimate compares +the same route with and without current traffic speeds. It appears when the +extra time is at least five minutes and at least 10% of the comparison duration; +the existing severity bands distinguish light, moderate, heavy and severe +delays. The text supplies the amount so color is not the only signal. + +When there is no usable delay estimate or congestion data is missing, +incomplete or stale, the duration uses the normal text color without an extra +visible note. **Traffic data unavailable** remains in **About traffic** and the +route's screen-reader description. A route with fresh, complete congestion coverage +and a comparison below the display thresholds has a **green duration** and no +additional caption. Significant estimated delays retain their severity color +and the inline **(+12 min)** amount. The **About traffic** button is aligned +with the duration, above the distance, for all traffic states. + +A zero delay comparison alone cannot establish fresh congestion coverage. +The route contract's optional `trafficCoverage` evidence must confirm that +current congestion speeds cover the exact route and were used in its duration; +its lease expires within two minutes of evaluation. Current routing providers +**do not yet emit this evidence**, so low-delay comparisons currently use the +unavailable state rather than green. Adding trustworthy engine-side congestion +coverage remains part of the traffic follow-up; closure proofs and map-layer +colors cannot substitute for it. Positive delay estimates remain useful even +without complete coverage of every road. + +Open **About traffic** with the info button for a plain-language explanation of +the estimate, road updates and the route provider. These details do not interrupt +route selection or Start. + +Road updates are described as used only when this route has current, matching +engine and server evidence. That explanation expires automatically; missing, +failed or conflicting checks never carry a previous success forward. This +road-update verification is separate from the engine's estimated delay. The +estimate reflects the selected path at calculation time, can include applied +speed restrictions, and does not establish a fresh congestion measurement for +every road or the extra time versus a different traffic-free route. + +Map colors are separate from travel-time estimates. The +[traffic legends](./map-layers.md#transportation) explain their sources and +limitations on demand. This presentation applies to directions cards, not every +navigation/EV surface. + ## Stop times and dwell Any stop on a trip can carry a time of its own. The clock button on a waypoint @@ -172,8 +217,8 @@ The directions panel exposes the tuning knobs that map onto the routing API: engine's live-traffic request when one is available. The result is graph- and request-dependent: the engine may return only the primary route when no distinct alternative satisfies its cost and safety filters. Baseline durations - are shown for comparison when supplied; a baseline can legitimately be slower - than the live route. + supply the same-path estimated traffic-delay comparison shown on route cards; + they can legitimately be slower than the live route. - **Stops and optimization** — add intermediate waypoints, and ask the engine to reorder them into the shortest trip while keeping the first and last fixed. diff --git a/docs/docs/features/map-layers.md b/docs/docs/features/map-layers.md index bed74774c..ed943e5c3 100644 --- a/docs/docs/features/map-layers.md +++ b/docs/docs/features/map-layers.md @@ -124,6 +124,29 @@ than inventing a speed. These overlays complement the dedicated [public transit](./public-transit.md) and [directions](./directions.md) features rather than replacing them. +The **Traffic flow** legend uses a compact **Fast–Slow** color scale. Open +**About traffic** with the info button in either traffic legend for its source +and limitations. The owned legend also explains measured, estimated and typical +readings there, using the same opacity as the renderer. A hidden layer stays +explicitly labelled hidden. + +The tile formats expose no usable upstream/publication time or completeness +metadata, so the details say the update time is unknown and some roads may be +missing. Downloading a tile does not establish fresh measurements. Road colors +do not establish whether traffic delays are included in a route's travel time. + +Traffic publication/check evidence exists in authenticated admin coverage, but +is not exposed by these public tile contracts. The legends do not fetch admin +diagnostics or borrow check/request times as observation age. A public, bounded +publication metadata contract remains follow-up work. + +**Map colors do not verify traffic influence on a route.** The routing engine can +differ from the displayed overlay: hosted TomTom traffic over a self-hosted +Valhalla route is still a map visualization. Directions cards show estimated +traffic delays when comparison data is available and explain road-update +verification separately; see +[Reading traffic information](./directions.md#reading-traffic-information). + ### Context-aware layers Useful layers follow the task without becoming permanent preferences. Opening a diff --git a/integrations/overlay-traffic-flow/__tests__/legend.test.tsx b/integrations/overlay-traffic-flow/__tests__/legend.test.tsx index 106c68ba9..22aee23e4 100644 --- a/integrations/overlay-traffic-flow/__tests__/legend.test.tsx +++ b/integrations/overlay-traffic-flow/__tests__/legend.test.tsx @@ -1,11 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@/test"; import { useTrafficFlowStore } from "../store"; vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); import { TrafficFlowLegend } from "../legend"; +afterEach(cleanup); + describe("TrafficFlowLegend", () => { beforeEach(() => { useTrafficFlowStore.setState({ panelOpen: true, layerVisible: true }); @@ -13,9 +15,38 @@ describe("TrafficFlowLegend", () => { it("uses the renderer opacity for the typical confidence swatch", () => { render(); + fireEvent.click(screen.getByRole("button", { name: "trafficStatus.about" })); expect(getComputedStyle(screen.getByTestId("traffic-flow-confidence-typical")).opacity).toBe( "0.6", ); }); }); + +it("keeps source age and coverage unknown and distinguishes map from routing", () => { + useTrafficFlowStore.setState({ panelOpen: true, layerVisible: true }); + const view = render(); + expect(screen.queryByText("trafficStatus.overlay.ownedSource")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "trafficStatus.about" })); + expect(screen.getByText("trafficStatus.overlay.ownedSource")).toBeDefined(); + expect(screen.getByRole("dialog")).toHaveTextContent("trafficStatus.overlay.ageUnknown"); + expect(screen.getByRole("dialog")).toHaveTextContent("trafficStatus.overlay.coverageUnknown"); + expect(screen.getByText("trafficStatus.overlay.mapOnly")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.estimates")).toBeDefined(); + expect(view.container.querySelector("button button")).toBeNull(); +}); +it("explains hidden layer instead of implying map display", () => { + useTrafficFlowStore.setState({ panelOpen: true, layerVisible: false }); + render(); + expect(screen.getByText("trafficStatus.overlay.hidden")).toBeDefined(); +}); + +it("shows a compact speed scale while confidence details remain optional", () => { + useTrafficFlowStore.setState({ panelOpen: true, layerVisible: true }); + render(); + expect(screen.getByText("trafficStatus.overlay.fast")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.slow")).toBeDefined(); + expect(screen.queryByTestId("traffic-flow-confidence-typical")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "trafficStatus.about" })); + expect(screen.getByTestId("traffic-flow-confidence-typical")).toBeDefined(); +}); diff --git a/integrations/overlay-traffic-flow/legend.tsx b/integrations/overlay-traffic-flow/legend.tsx index 0db0fa425..b7209bc49 100644 --- a/integrations/overlay-traffic-flow/legend.tsx +++ b/integrations/overlay-traffic-flow/legend.tsx @@ -5,6 +5,7 @@ import Typography from "@mui/material/Typography"; import { TRAFFIC_BAND_COLORS, type TrafficBand, useOverlayVisibilitySetter } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { OverlayLegend } from "@/integration-api/overlay/OverlayLegend"; +import { TrafficOverlayContext } from "@/integration-api/overlay/TrafficOverlayContext"; import { useTrafficFlowStore } from "./store"; import { TRAFFIC_FLOW_CONFIDENCE_STEPS } from "./visual-style"; @@ -15,6 +16,7 @@ export const BANDS: { color: string; key: TrafficBand }[] = ( export function TrafficFlowLegend() { const t = useTranslations("trafficFlow"); + const ts = useTranslations("trafficStatus.overlay"); const panelOpen = useTrafficFlowStore((s) => s.panelOpen); const layerVisible = useTrafficFlowStore((s) => s.layerVisible); const setLayerVisible = useOverlayVisibilitySetter("traffic-flow"); @@ -35,42 +37,42 @@ export function TrafficFlowLegend() { ))} - - {BANDS.map((band) => ( - - {t(`band.${band.key}`)} - - ))} + + + {ts("fast")} + + + {ts("slow")} + - - - {t("confidence.label")} - - - {TRAFFIC_FLOW_CONFIDENCE_STEPS.map((step) => ( - - - - {t(`confidence.${step.key}`)} - - - ))} + + + + {t("confidence.label")} + + + {TRAFFIC_FLOW_CONFIDENCE_STEPS.map((step) => ( + + + + {t(`confidence.${step.key}`)} + + + ))} + - + ); } diff --git a/integrations/overlay-traffic-tomtom/legend.test.tsx b/integrations/overlay-traffic-tomtom/legend.test.tsx new file mode 100644 index 000000000..6e0b7b205 --- /dev/null +++ b/integrations/overlay-traffic-tomtom/legend.test.tsx @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@/test"; +import { TrafficLegend } from "./legend"; +import { useTrafficStore } from "./store"; + +vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); +const setVisible = vi.hoisted(() => vi.fn()); +vi.mock("@openmapx/core", async (original) => ({ + ...(await original()), + useOverlayVisibilitySetter: () => setVisible, +})); +afterEach(cleanup); +beforeEach(() => { + useTrafficStore.setState({ panelOpen: true, layerVisible: true }); + setVisible.mockClear(); +}); +it("explains hosted map-only traffic without inferring age or coverage", async () => { + const view = render(); + expect(screen.getByText("layers.trafficTomtom")).toBeDefined(); + expect(screen.queryByText("trafficStatus.overlay.hostedSource")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "trafficStatus.about" })); + expect(screen.getByText("trafficStatus.overlay.hostedSource")).toBeDefined(); + expect(screen.getByRole("dialog")).toHaveTextContent("trafficStatus.overlay.ageUnknown"); + expect(screen.getByRole("dialog")).toHaveTextContent("trafficStatus.overlay.coverageUnknown"); + expect(screen.getByText("trafficStatus.overlay.mapOnly")).toBeDefined(); + expect(view.container.querySelector("button button")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "common.close" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + fireEvent.click(screen.getByRole("switch", { name: "trafficStatus.overlay.toggleHosted" })); + expect(setVisible).toHaveBeenCalledWith(false); +}); +it("renders hidden status only when panel is open", () => { + useTrafficStore.setState({ layerVisible: false }); + const view = render(); + expect(screen.getByText("layers.trafficTomtom")).toBeDefined(); + expect(screen.getByText("trafficStatus.overlay.hidden")).toBeDefined(); + useTrafficStore.setState({ panelOpen: false }); + view.rerender(); + expect(screen.queryByText("trafficStatus.overlay.hostedSource")).toBeNull(); +}); diff --git a/integrations/overlay-traffic-tomtom/legend.tsx b/integrations/overlay-traffic-tomtom/legend.tsx new file mode 100644 index 000000000..67a6087f2 --- /dev/null +++ b/integrations/overlay-traffic-tomtom/legend.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useOverlayVisibilitySetter } from "@openmapx/core"; +import { useTranslations } from "next-intl"; +import { OverlayLegend } from "@/integration-api/overlay/OverlayLegend"; +import { TrafficOverlayContext } from "@/integration-api/overlay/TrafficOverlayContext"; +import { useTrafficStore } from "./store"; + +export function TrafficLegend() { + const t = useTranslations("trafficStatus.overlay"); + const tm = useTranslations("layers"); + const panelOpen = useTrafficStore((s) => s.panelOpen); + const layerVisible = useTrafficStore((s) => s.layerVisible); + const setLayerVisible = useOverlayVisibilitySetter("traffic"); + return ( + + + + ); +} diff --git a/integrations/overlay-traffic-tomtom/manifest.json b/integrations/overlay-traffic-tomtom/manifest.json index 5403f3899..7817fd8cf 100644 --- a/integrations/overlay-traffic-tomtom/manifest.json +++ b/integrations/overlay-traffic-tomtom/manifest.json @@ -13,7 +13,7 @@ "domains": ["map-overlay"], "frontend": { "mapLayer": true, - "legend": false, + "legend": true, "panel": false, "layerSelector": { "group": "map-details", diff --git a/integrations/overlay-traffic-tomtom/package.json b/integrations/overlay-traffic-tomtom/package.json index 636041913..934b67478 100644 --- a/integrations/overlay-traffic-tomtom/package.json +++ b/integrations/overlay-traffic-tomtom/package.json @@ -6,5 +6,13 @@ "type": "module", "dependencies": { "@openmapx/core": "workspace:^" + }, + "peerDependencies": { + "react": ">=19" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "react": "19.3.0", + "vitest": "^5.0.1" } } diff --git a/packages/core/src/types/routing.ts b/packages/core/src/types/routing.ts index 10dcc39a9..644b8f77f 100644 --- a/packages/core/src/types/routing.ts +++ b/packages/core/src/types/routing.ts @@ -147,6 +147,18 @@ export interface Route { * delay attributable to current conditions. */ baselineDuration?: number; + /** + * Provider evidence that fresh congestion speeds cover this exact route and + * were used in its duration. A comparison duration or road-closure proof is + * insufficient. Only complete coverage permits a green, low-delay ETA. + * Leases must be at most two minutes from evaluatedAt. Current providers do + * not yet emit this evidence; absent/partial/expired evidence means unknown. + */ + trafficCoverage?: { + complete: boolean; + evaluatedAt: string; + validUntil: string; + }; /** * The route drives along a road closed to all but local access ("Anlieger * frei"): it starts or ends on one, or can be reached only through one. diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index f69dc0b44..3adc74d40 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -892,8 +892,6 @@ "checkFailed": "Straßenbedingungen für diese Route konnten nicht geprüft werden" }, "localAccessRoad": "Nutzt eine gesperrte Straße (Anlieger frei)", - "trafficDelay": "+{delay} Verkehr", - "trafficDelayNormally": "normalerweise {baseline}", "from": "von", "to": "nach", "origin": "Start", @@ -4291,5 +4289,40 @@ "confirmTitle": "OSM-Suchindex erstellen", "confirmMessage": "Den OSM-Code- und Aliasindex für „{region}“ erstellen und atomar veröffentlichen? Dies kann CPU, Datenträger und Datenbank stark beanspruchen.", "startBuild": "Erstellung starten" + }, + "trafficStatus": { + "source": { + "localValhalla": "Selbst gehostetes Valhalla", + "hostedValhalla": "Gehostetes Valhalla (Stadia Maps)", + "valhalla": "Valhalla", + "osrm": "OSRM", + "unknown": "Routing-Quelle unbekannt" + }, + "application": { + "verified": "Verfügbare Straßenmeldungen wurden für diese Route berücksichtigt.", + "expired": "Straßenmeldungen müssen erneut geprüft werden. Berechne die Route erneut.", + "limited": "Einige gemeldete Straßenänderungen fehlen möglicherweise.", + "unavailable": "Straßenmeldungen konnten nicht geprüft werden.", + "unverified": "Es ist unklar, welche Straßenmeldungen berücksichtigt wurden." + }, + "congestionUnverified": "Die Fahrzeit berücksichtigt möglicherweise keine aktuellen Verzögerungen durch Stau.", + "overlay": { + "ownedSource": "Verkehrsinformationen von diesem Server.", + "hostedSource": "Verkehrsinformationen von TomTom.", + "ageUnknown": "Der Aktualisierungszeitpunkt ist unbekannt.", + "coverageUnknown": "Einige Straßen fehlen möglicherweise.", + "mapOnly": "Straßenfarben zeigen nicht, ob Verzögerungen in der Fahrzeit berücksichtigt sind.", + "estimates": "Einige Farben basieren auf üblichen oder geschätzten Geschwindigkeiten statt aktuellen Messungen.", + "hidden": "Ausgeblendet", + "toggleHosted": "TomTom-Verkehr umschalten", + "fast": "Schnell", + "slow": "Langsam" + }, + "about": "Verkehrsinformationen", + "summary": "Keine Verkehrsdaten verfügbar", + "provider": "Routenanbieter: {source}", + "delay": "+{delay}", + "coverageExplanation": "Die Fahrzeit berücksichtigt den aktuellen Verkehr.", + "estimateExplanation": "Die zusätzliche Zeit wird geschätzt, indem diese Route mit und ohne aktuelle Verkehrsgeschwindigkeiten verglichen wird. Tatsächliche Verzögerungen können abweichen." } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 57563c968..6872d1060 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -892,8 +892,6 @@ "checkFailed": "Couldn't check road conditions for this route" }, "localAccessRoad": "Uses a road closed except for local access", - "trafficDelay": "+{delay} traffic", - "trafficDelayNormally": "normally {baseline}", "from": "from", "to": "to", "origin": "Origin", @@ -4291,5 +4289,40 @@ "confirmTitle": "Build OSM search index", "confirmMessage": "Build and atomically publish the OSM code and alias index for \"{region}\"? This can be CPU-, disk-, and database-intensive.", "startBuild": "Start build" + }, + "trafficStatus": { + "source": { + "localValhalla": "Self-hosted Valhalla", + "hostedValhalla": "Hosted Valhalla (Stadia Maps)", + "valhalla": "Valhalla", + "osrm": "OSRM", + "unknown": "Routing source unknown" + }, + "application": { + "verified": "Available road updates were used for this route.", + "expired": "Road updates need to be checked again. Refresh the route.", + "limited": "Some reported road changes may be missing.", + "unavailable": "Road updates couldn’t be checked.", + "unverified": "We couldn’t confirm which road updates were used." + }, + "congestionUnverified": "This travel time may not include current traffic delays.", + "overlay": { + "ownedSource": "Traffic information from this server.", + "hostedSource": "Traffic information from TomTom.", + "ageUnknown": "The update time is unknown.", + "coverageUnknown": "Some roads may be missing.", + "mapOnly": "Road colors don’t tell you whether delays are included in your travel time.", + "estimates": "Some colors use typical or estimated speeds rather than current measurements.", + "hidden": "Hidden", + "toggleHosted": "Toggle TomTom traffic", + "fast": "Fast", + "slow": "Slow" + }, + "about": "About traffic", + "summary": "Traffic data unavailable", + "provider": "Route provider: {source}", + "delay": "+{delay}", + "coverageExplanation": "Current traffic is included in this travel time.", + "estimateExplanation": "The extra time is estimated by comparing this route with and without current traffic speeds. Actual delays can vary." } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c21d37b78..e324d256e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1318,6 +1318,16 @@ importers: '@openmapx/core': specifier: workspace:^ version: link:../../packages/core + devDependencies: + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + react: + specifier: 19.3.0 + version: 19.3.0 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.1)(jsdom@30.1.1(@noble/hashes@2.4.0))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) integrations/overlay-transit: dependencies: