From 09e68a614f78b154d70c309f7c5415277eeeb953 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 4 Sep 2026 16:32:45 +0200 Subject: [PATCH] web(geo): legend buckets under the Performance Map toggle which markers are shown Click a bucket to narrow the map to it, add more with further clicks, Show all or the last toggle returns to the full map; a Color-by switch resets it. Camera fit and marker radius scale stay on the full location list so filtering never moves or rescales the map. Customer docs updated in the docs repo. Co-Authored-By: Claude Fable 5.1 --- internal | 2 +- .../app/geographic-performance/GeoMap.tsx | 17 ++++- .../app/geographic-performance/MapLegend.tsx | 66 +++++++++++++++---- .../__tests__/MapLegend.test.ts | 33 +++++++++- .../__tests__/mapColorModes.test.ts | 39 +++++++++++ .../geographic-performance/mapColorModes.ts | 28 +++++++- .../app/geographic-performance/page.tsx | 11 +++- 7 files changed, 173 insertions(+), 23 deletions(-) diff --git a/internal b/internal index 0e89906f..81f3494c 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 0e89906f85f8585c556e321364250f514ddd3070 +Subproject commit 81f3494cff512044a709773c344bc09ab73832d0 diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/GeoMap.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/GeoMap.tsx index c831e7c8..cffd6025 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/GeoMap.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/GeoMap.tsx @@ -5,13 +5,15 @@ import { MapContainer, TileLayer, CircleMarker, Tooltip, useMap } from "react-le import "leaflet/dist/leaflet.css"; import { formatThroughput } from "@/lib/formatting"; import type { GlobalAverages, LocationMetrics } from "@/utils/wire-types.generated"; -import type { MapColorMode } from "./mapColorModes"; +import { bucketFilterAllows, type BucketFilter, type MapColorMode } from "./mapColorModes"; interface GeoMapProps { locations: LocationMetrics[]; globalAverages: GlobalAverages; /** Module-level object from MAP_COLOR_MODE_BY_ID; identity-stable across renders. */ colorMode: MapColorMode; + /** Legend selection; empty = every marker. Only marker rendering honours it (see below). */ + bucketFilter: BucketFilter; selectedLocation: string | null; onLocationSelect: (key: string | null) => void; } @@ -53,7 +55,14 @@ function FitBounds({ locations }: { locations: { coords: [number, number] }[] }) return null; } -export default function GeoMap({ locations, globalAverages, colorMode, selectedLocation, onLocationSelect }: GeoMapProps) { +export default function GeoMap({ + locations, + globalAverages, + colorMode, + bucketFilter, + selectedLocation, + onLocationSelect, +}: GeoMapProps) { // Memoize so the derived array keeps a stable identity across re-renders. FitBounds' effect // depends on this list; a fresh array every render would re-run it (and re-fire the camera // move) on every parent re-render, not just when the location data actually changes. @@ -76,6 +85,9 @@ export default function GeoMap({ locations, globalAverages, colorMode, selectedL ); } + // FitBounds and the radius scale stay on the full list on purpose: toggling a legend bucket + // must not move the camera or resize the remaining markers, otherwise the filter reads as a + // different map instead of the same map with some markers hidden. const maxSessions = Math.max(...mappableLocations.map((l) => l.sessionCount)); return ( @@ -93,6 +105,7 @@ export default function GeoMap({ locations, globalAverages, colorMode, selectedL {mappableLocations.map((loc) => { const isSelected = selectedLocation === loc.locationKey; const bucket = colorMode.resolve(loc, globalAverages); + if (!bucketFilterAllows(bucketFilter, bucket)) return null; return ( void; +} /** * Legend for the Performance Map: one swatch per bucket of the active colour mode plus the fixed * size/selection note. Swatches take their colour from the same `hex` Leaflet paints with, so the - * legend cannot drift from the markers. Module-level component (react-hooks/static-components). + * legend cannot drift from the markers. Every bucket is a toggle: clicking narrows the map to the + * selected buckets, clicking the last selected one (or "Show all") returns to the full map. + * Module-level component (react-hooks/static-components). */ -export function MapLegend({ mode }: { mode: MapColorMode }) { +export function MapLegend({ mode, filter, onFilterChange }: MapLegendProps) { + const filtered = filter.size > 0; return ( -
+
{mode.label} - {mode.buckets.map((b) => ( - - - {b.label} - - ))} + {mode.buckets.map((b) => { + const shown = bucketFilterAllows(filter, b); + return ( + + ); + })} + {filtered && ( + + )} {MAP_LEGEND_NOTE}
); diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/__tests__/MapLegend.test.ts b/src/Web/autopilot-monitor-web/app/geographic-performance/__tests__/MapLegend.test.ts index 69adc65d..83b94840 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/__tests__/MapLegend.test.ts +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/__tests__/MapLegend.test.ts @@ -2,12 +2,18 @@ import { describe, it, expect } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MapLegend } from "../MapLegend"; -import { MAP_COLOR_MODES, MAP_LEGEND_NOTE } from "../mapColorModes"; +import { MAP_COLOR_MODES, MAP_LEGEND_NOTE, NO_BUCKET_FILTER, toggleBucketFilter } from "../mapColorModes"; + +const noop = () => {}; + +function render(mode: (typeof MAP_COLOR_MODES)[number], filter = NO_BUCKET_FILTER) { + return renderToStaticMarkup(createElement(MapLegend, { mode, filter, onFilterChange: noop })); +} describe("MapLegend", () => { it("renders every bucket label and hex of the active mode plus the size note", () => { for (const mode of MAP_COLOR_MODES) { - const html = renderToStaticMarkup(createElement(MapLegend, { mode })); + const html = render(mode); expect(html).toContain(mode.label); for (const b of mode.buckets) { expect(html).toContain(b.label.replace(/&/g, "&").replace(//g, ">")); @@ -19,9 +25,30 @@ describe("MapLegend", () => { it("does not render buckets of another mode", () => { const [first, second] = MAP_COLOR_MODES; - const html = renderToStaticMarkup(createElement(MapLegend, { mode: first })); + const html = render(first); for (const b of second.buckets) { if (!first.buckets.some((fb) => fb.label === b.label)) expect(html).not.toContain(b.label); } }); + + it("renders every bucket as an unpressed toggle and no reset link without a filter", () => { + const [mode] = MAP_COLOR_MODES; + const html = render(mode); + expect(html.match(/