mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule internal updated: 0e89906f85...81f3494cff
@@ -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 (
|
||||
<CircleMarker
|
||||
key={loc.locationKey}
|
||||
|
||||
@@ -1,24 +1,62 @@
|
||||
import { MAP_LEGEND_NOTE, type MapColorMode } from "./mapColorModes";
|
||||
import {
|
||||
MAP_LEGEND_NOTE,
|
||||
NO_BUCKET_FILTER,
|
||||
bucketFilterAllows,
|
||||
toggleBucketFilter,
|
||||
type BucketFilter,
|
||||
type MapColorMode,
|
||||
} from "./mapColorModes";
|
||||
|
||||
interface MapLegendProps {
|
||||
mode: MapColorMode;
|
||||
/** Buckets currently shown on the map; empty = all. */
|
||||
filter: BucketFilter;
|
||||
onFilterChange: (next: BucketFilter) => 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 (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-600">
|
||||
<span className="font-medium text-gray-700">{mode.label}</span>
|
||||
{mode.buckets.map((b) => (
|
||||
<span key={b.label} className="inline-flex items-center">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full mr-1.5"
|
||||
// Markers draw at fillOpacity 0.7; a fully opaque swatch would read darker than the map.
|
||||
style={{ backgroundColor: b.hex, opacity: 0.8 }}
|
||||
/>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
{mode.buckets.map((b) => {
|
||||
const shown = bucketFilterAllows(filter, b);
|
||||
return (
|
||||
<button
|
||||
key={b.label}
|
||||
type="button"
|
||||
aria-pressed={filtered && shown}
|
||||
title={shown && !filtered ? "Show only this bucket" : shown ? "Hide this bucket" : "Show this bucket"}
|
||||
onClick={() => onFilterChange(toggleBucketFilter(filter, b))}
|
||||
className={`inline-flex items-center rounded px-1 py-0.5 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500 ${
|
||||
shown ? "" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full mr-1.5"
|
||||
// Markers draw at fillOpacity 0.7; a fully opaque swatch would read darker than the map.
|
||||
style={{ backgroundColor: b.hex, opacity: 0.8 }}
|
||||
/>
|
||||
{b.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFilterChange(NO_BUCKET_FILTER)}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
<span className="text-gray-400">{MAP_LEGEND_NOTE}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
+30
-3
@@ -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, "<").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(/<button /g)?.length).toBe(mode.buckets.length);
|
||||
expect(html).not.toContain('aria-pressed="true"');
|
||||
expect(html).not.toContain("opacity-40");
|
||||
expect(html).not.toContain("Show all");
|
||||
});
|
||||
|
||||
it("with a filter, presses the selected bucket, dims the others and offers Show all", () => {
|
||||
const [mode] = MAP_COLOR_MODES;
|
||||
const html = render(mode, toggleBucketFilter(NO_BUCKET_FILTER, mode.buckets[1]));
|
||||
expect(html.match(/aria-pressed="true"/g)?.length).toBe(1);
|
||||
expect(html.match(/opacity-40/g)?.length).toBe(mode.buckets.length - 1);
|
||||
expect(html).toContain("Show all");
|
||||
// The pressed button is the one carrying the selected bucket's label.
|
||||
const pressed = html.split("<button ").find((chunk) => chunk.includes('aria-pressed="true"'));
|
||||
expect(pressed).toContain(mode.buckets[1].label);
|
||||
expect(pressed).not.toContain("opacity-40");
|
||||
});
|
||||
});
|
||||
|
||||
+39
@@ -5,7 +5,10 @@ import {
|
||||
MAP_COLOR_MODES,
|
||||
MAP_COLOR_MODE_BY_ID,
|
||||
MAP_LEGEND_NOTE,
|
||||
NO_BUCKET_FILTER,
|
||||
bucketFilterAllows,
|
||||
isMapColorModeId,
|
||||
toggleBucketFilter,
|
||||
} from "../mapColorModes";
|
||||
|
||||
// Typed against the wire types: a manifest regen that renames a field fails tsc here, not at runtime.
|
||||
@@ -243,3 +246,39 @@ describe("badge class parity", () => {
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucket filter", () => {
|
||||
const { buckets } = MAP_COLOR_MODE_BY_ID.latency;
|
||||
|
||||
it("empty filter allows every bucket", () => {
|
||||
for (const b of buckets) expect(bucketFilterAllows(NO_BUCKET_FILTER, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("toggling in narrows to exactly the selected buckets", () => {
|
||||
const one = toggleBucketFilter(NO_BUCKET_FILTER, buckets[0]);
|
||||
expect(bucketFilterAllows(one, buckets[0])).toBe(true);
|
||||
expect(bucketFilterAllows(one, buckets[1])).toBe(false);
|
||||
|
||||
const two = toggleBucketFilter(one, buckets[3]);
|
||||
expect([...buckets].map((b) => bucketFilterAllows(two, b))).toEqual([true, false, false, true, false]);
|
||||
});
|
||||
|
||||
it("toggling the last selected bucket out returns to show-all", () => {
|
||||
const one = toggleBucketFilter(NO_BUCKET_FILTER, buckets[2]);
|
||||
const none = toggleBucketFilter(one, buckets[2]);
|
||||
expect(none.size).toBe(0);
|
||||
for (const b of buckets) expect(bucketFilterAllows(none, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mutate the input set", () => {
|
||||
const one = toggleBucketFilter(NO_BUCKET_FILTER, buckets[0]);
|
||||
toggleBucketFilter(one, buckets[1]);
|
||||
expect(NO_BUCKET_FILTER.size).toBe(0);
|
||||
expect(one.size).toBe(1);
|
||||
});
|
||||
|
||||
it("matches buckets by identity, so another mode's bucket is never allowed", () => {
|
||||
const one = toggleBucketFilter(NO_BUCKET_FILTER, buckets[0]);
|
||||
expect(bucketFilterAllows(one, MAP_COLOR_MODE_BY_ID.success.buckets[0])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,4 +185,30 @@ export function isMapColorModeId(value: string): value is MapColorModeId {
|
||||
return Object.prototype.hasOwnProperty.call(MAP_COLOR_MODE_BY_ID, value);
|
||||
}
|
||||
|
||||
export const MAP_LEGEND_NOTE = "Marker size = sessions in range · Blue ring = selected location";
|
||||
export const MAP_LEGEND_NOTE =
|
||||
"Marker size = sessions in range · Blue ring = selected location · Click a bucket to filter";
|
||||
|
||||
// ---- Legend filter --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Which buckets the map currently shows. Empty = no filter, every marker is drawn. Buckets are
|
||||
* compared by identity, so a set built from one mode's buckets never matches another mode's;
|
||||
* the page resets the filter on a mode switch rather than relying on that.
|
||||
*/
|
||||
export type BucketFilter = ReadonlySet<ColorBucket>;
|
||||
|
||||
export const NO_BUCKET_FILTER: BucketFilter = new Set();
|
||||
|
||||
/** Toggle one bucket. Removing the last selected bucket returns to "show all" (empty set). */
|
||||
export function toggleBucketFilter(filter: BucketFilter, bucket: ColorBucket): BucketFilter {
|
||||
const next = new Set(filter);
|
||||
if (next.has(bucket)) next.delete(bucket);
|
||||
else next.add(bucket);
|
||||
return next.size === 0 ? NO_BUCKET_FILTER : next;
|
||||
}
|
||||
|
||||
/** Legend dimming and marker visibility both go through here so they cannot disagree. */
|
||||
export function bucketFilterAllows(filter: BucketFilter, bucket: ColorBucket): boolean {
|
||||
return filter.size === 0 || filter.has(bucket);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
DEFAULT_MAP_COLOR_MODE,
|
||||
MAP_COLOR_MODES,
|
||||
MAP_COLOR_MODE_BY_ID,
|
||||
NO_BUCKET_FILTER,
|
||||
isMapColorModeId,
|
||||
type BucketFilter,
|
||||
type MapColorModeId,
|
||||
} from "./mapColorModes";
|
||||
import { MapLegend } from "./MapLegend";
|
||||
@@ -65,6 +67,8 @@ export default function GeographicPerformancePage() {
|
||||
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
|
||||
const [colorModeId, setColorModeId] = useState<MapColorModeId>(DEFAULT_MAP_COLOR_MODE);
|
||||
const colorMode = MAP_COLOR_MODE_BY_ID[colorModeId];
|
||||
// Legend selection; buckets belong to a mode, so a mode switch resets it (see the select).
|
||||
const [bucketFilter, setBucketFilter] = useState<BucketFilter>(NO_BUCKET_FILTER);
|
||||
|
||||
const isTimeRangeMount = useRef(true);
|
||||
|
||||
@@ -290,7 +294,9 @@ export default function GeographicPerformancePage() {
|
||||
<select
|
||||
value={colorModeId}
|
||||
onChange={(e) => {
|
||||
if (isMapColorModeId(e.target.value)) setColorModeId(e.target.value);
|
||||
if (!isMapColorModeId(e.target.value)) return;
|
||||
setColorModeId(e.target.value);
|
||||
setBucketFilter(NO_BUCKET_FILTER);
|
||||
}}
|
||||
className="border border-gray-300 rounded-md px-2 py-1 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
>
|
||||
@@ -305,12 +311,13 @@ export default function GeographicPerformancePage() {
|
||||
locations={geoMetrics.locations}
|
||||
globalAverages={geoMetrics.globalAverages}
|
||||
colorMode={colorMode}
|
||||
bucketFilter={bucketFilter}
|
||||
selectedLocation={selectedLocation}
|
||||
onLocationSelect={setSelectedLocation}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-3">
|
||||
<MapLegend mode={colorMode} />
|
||||
<MapLegend mode={colorMode} filter={bucketFilter} onFilterChange={setBucketFilter} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user