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(/