Skip to content

Commit 2e75e35

Browse files
Yerazeclaude
andcommitted
feat(map): add polar grid overlay to Map Analysis and Unified maps (#3971)
Extend the polar grid overlay (range rings + azimuth sectors centered on the own-node position, #2307) from NodesTab to two more map surfaces, reusing the existing `PolarGridOverlay` component and `getPolarGridRings()` utility. Map Analysis: - New `PolarGridLayer` draws a theme-colored grid centered on each active source's own-node position (resolved per `sourceId`). - Toggle added to the toolbar, disabled when no active source has a known own-node position. Unified / Dashboard map: - Renders one grid per source that has an own-node position, each colored to match that source's per-source color (via `resolveSourceColor`), with a legend so overlapping grids stay distinguishable. - Toggle reuses the existing `MapContext.showPolarGrid` (unified across map surfaces), disabled when no source has a position. Own-node positions are resolved from each source's local `nodeNum` (surfaced by `GET /api/sources/:id/status`) paired with that node's position in the shared node list — see new `getOwnNodePositions` util and `useOwnNodePositions` hook. MeshCore sources (no meshtastic nodeNum) naturally yield no grid. `PolarGridOverlay` gains an optional `color` prop; when set it recolors the whole grid at reduced opacity (theme palette otherwise, unchanged for NodesTab). Tests: new ownNodePositions unit tests, PolarGridOverlay color-override test, and a Map Analysis toolbar disabled-state test. Full suite green (0 failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub
1 parent 4e677ba commit 2e75e35

16 files changed

Lines changed: 485 additions & 5 deletions

src/components/Dashboard/DashboardMap.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,21 @@ vi.mock('../../contexts/MapContext', () => ({
4343
setShowNeighborInfo: vi.fn(),
4444
showWaypoints: false,
4545
setShowWaypoints: vi.fn(),
46+
showPolarGrid: false,
47+
setShowPolarGrid: vi.fn(),
4648
}),
4749
}));
4850

51+
// Polar grid (#3971) pulls the source list + per-source status to resolve each
52+
// source's own-node position. Mock the data hooks so the bare component renders
53+
// without a QueryClient/AuthProvider; empty data ⇒ no grid, existing assertions
54+
// (marker/polyline counts) are unaffected.
55+
vi.mock('../../hooks/useDashboardData', () => ({
56+
useDashboardSources: () => ({ data: [] }),
57+
useSourceStatuses: () => new Map(),
58+
UNIFIED_SOURCE_ID: '__unified__',
59+
}));
60+
4961
// CSRF + api are provided by the app shell in production; mock them so the bare
5062
// component renders and the GeoJSON layer fetch is inert in tests.
5163
vi.mock('../../hooks/useCsrfFetch', () => ({

src/components/Dashboard/DashboardMap.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,20 @@ import DashboardWaypoints from './DashboardWaypoints';
2424
import DashboardNodePopup, { type NodeSourceRef } from './DashboardNodePopup';
2525
import DashboardNeighborPopup from './DashboardNeighborPopup';
2626
import GeoJsonOverlay from '../GeoJsonOverlay';
27+
import PolarGridOverlay from '../PolarGridOverlay';
2728
import { TilesetSelector } from '../TilesetSelector';
2829
import MapLegend from '../MapLegend';
2930
import type { GeoJsonLayer } from '../../server/services/geojsonService.js';
3031
import { useMapContext } from '../../contexts/MapContext';
3132
import { useSettings } from '../../contexts/SettingsContext';
33+
import {
34+
useDashboardSources,
35+
useSourceStatuses,
36+
UNIFIED_SOURCE_ID,
37+
type DashboardSource,
38+
} from '../../hooks/useDashboardData';
39+
import { getSourceColor, resolveSourceColor } from '../../utils/sourceColors';
40+
import { getOwnNodePositions } from '../../utils/ownNodePositions';
3241
import { nodePassesTransportFilter } from '../../utils/nodeTransport';
3342
import { isNullIsland } from '../../utils/nullIsland';
3443
import { effectiveMapMaxAgeHours } from '../../utils/mapAge';
@@ -161,6 +170,21 @@ export default function DashboardMap({
161170
const tileset = getTilesetById(tilesetId, customTilesets);
162171
const { mapPinStyle, setMapTileset } = useSettings();
163172

173+
// Polar grid (#3971): draw a grid centered on each source's own-node position.
174+
// On the Unified map (sourceId === UNIFIED_SOURCE_ID) that's every source, each
175+
// in its own color with a legend; on a single-source map it's just that source.
176+
const { data: allSources = [] } = useDashboardSources();
177+
const allSourceIds = allSources.map((s: DashboardSource) => s.id);
178+
// Stable, sorted id list drives per-source color assignment so a source keeps
179+
// the same color here as on the other Unified views.
180+
const colorSourceIds = [...allSourceIds].sort();
181+
const sourceNameById = new Map<string, string>(
182+
allSources.map((s: DashboardSource) => [s.id, s.name] as [string, string]),
183+
);
184+
const isUnified = sourceId === UNIFIED_SOURCE_ID;
185+
const polarSourceIds = isUnified ? allSourceIds : sourceId ? [sourceId] : [];
186+
const sourceStatuses = useSourceStatuses(polarSourceIds);
187+
164188
// Spiderfier: fan out co-located markers so each node (incl. estimated-position
165189
// nodes that collapse onto the same anchor) is individually selectable (#3612).
166190
// Reuses the SAME shared SpiderfierController + tuning as the per-source NodesTab
@@ -259,6 +283,8 @@ export default function DashboardMap({
259283
setShowNeighborInfo,
260284
showWaypoints,
261285
setShowWaypoints,
286+
showPolarGrid,
287+
setShowPolarGrid,
262288
mapMaxAgeHours,
263289
setMapMaxAgeHours,
264290
} = useMapContext();
@@ -317,6 +343,16 @@ export default function DashboardMap({
317343

318344
const nodePositions: [number, number][] = nodesWithPosition.map((e) => [e.pos.lat, e.pos.lng]);
319345

346+
// Own-node position per source for the polar grid. Resolved from the raw
347+
// `nodes` prop (not the age/transport-filtered marker list) so the grid center
348+
// survives even when the local node is stale or filtered off the map.
349+
const localNodeNumBySource = new Map<string, number | null | undefined>();
350+
for (const id of polarSourceIds) {
351+
localNodeNumBySource.set(id, sourceStatuses.get(id)?.nodeNum ?? null);
352+
}
353+
const ownNodePositions = getOwnNodePositions(nodes, localNodeNumBySource);
354+
const hasOwnNode = ownNodePositions.length > 0;
355+
320356
// Genuine removals (a node aged out / filtered away) are reconciled here
321357
// rather than from the ref `null` bounce (see getMarkerRef): drop any tracked
322358
// marker whose key is no longer rendered, and unregister it from the
@@ -471,6 +507,16 @@ export default function DashboardMap({
471507

472508
{geoJsonLayers.length > 0 && <GeoJsonOverlay layers={geoJsonLayers} />}
473509

510+
{/* Polar grid — one per source with a known own-node position, each in
511+
that source's color so overlapping grids stay distinguishable (#3971). */}
512+
{showPolarGrid && ownNodePositions.map((op) => (
513+
<PolarGridOverlay
514+
key={`polar-${op.sourceId}`}
515+
center={{ lat: op.lat, lng: op.lng }}
516+
color={resolveSourceColor(op.sourceId, colorSourceIds)}
517+
/>
518+
))}
519+
474520
{showWaypoints && <DashboardWaypoints sourceId={sourceId} />}
475521

476522
{nodesWithPosition.map(({ node, pos }) => {
@@ -653,6 +699,25 @@ export default function DashboardMap({
653699
/>
654700
)}
655701

702+
{/* Polar grid legend — names each source whose grid is drawn, with its
703+
color swatch, so overlapping grids on the Unified map aren't confused. */}
704+
{showPolarGrid && ownNodePositions.length > 0 && (
705+
<div className="dashboard-polar-grid-legend" role="note" aria-label="Polar grid sources">
706+
<div className="dashboard-polar-grid-legend__title">Polar Grid</div>
707+
{ownNodePositions.map((op) => (
708+
<div key={`legend-${op.sourceId}`} className="dashboard-polar-grid-legend__row">
709+
<span
710+
className="dashboard-polar-grid-legend__swatch"
711+
style={{ background: getSourceColor(op.sourceId, colorSourceIds) }}
712+
/>
713+
<span className="dashboard-polar-grid-legend__name">
714+
{sourceNameById.get(op.sourceId) ?? op.sourceId}
715+
</span>
716+
</div>
717+
))}
718+
</div>
719+
)}
720+
656721
{/* Map Features control panel — mirrors NodesTab's "Features" panel but
657722
trimmed to the toggles meaningful on a cross-source map. */}
658723
<div className={`map-controls dashboard-map-controls ${isMapControlsCollapsed ? 'collapsed' : ''}`}>
@@ -770,6 +835,17 @@ export default function DashboardMap({
770835
/>
771836
<span>Show Waypoints</span>
772837
</label>
838+
<label className="map-control-item">
839+
<input
840+
type="checkbox"
841+
checked={showPolarGrid && hasOwnNode}
842+
disabled={!hasOwnNode}
843+
onChange={(e) => setShowPolarGrid(e.target.checked)}
844+
/>
845+
<span title={!hasOwnNode ? 'No source has a known own-node position' : undefined}>
846+
Show Polar Grid
847+
</span>
848+
</label>
773849
<label className="map-control-item">
774850
<input
775851
type="checkbox"

src/components/MapAnalysis/LayerToggleButton.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export interface LayerToggleButtonProps {
99
onLookbackChange?: (h: number | null) => void;
1010
loading?: boolean;
1111
errored?: boolean;
12+
/** When true the toggle can't be clicked (e.g. no own-node position, #3971). */
13+
disabled?: boolean;
14+
/** Tooltip shown on the button — useful to explain a disabled state. */
15+
title?: string;
1216
}
1317

1418
export default function LayerToggleButton({
@@ -20,6 +24,8 @@ export default function LayerToggleButton({
2024
onLookbackChange,
2125
loading,
2226
errored,
27+
disabled,
28+
title,
2329
}: LayerToggleButtonProps) {
2430
const [popOpen, setPopOpen] = useState(false);
2531
const showChevron = !!lookbackOptions && !!onLookbackChange;
@@ -29,6 +35,8 @@ export default function LayerToggleButton({
2935
<button
3036
type="button"
3137
onClick={() => onToggle(!enabled)}
38+
disabled={disabled}
39+
title={title}
3240
className={`map-analysis-layer-btn ${enabled ? 'active' : ''}`}
3341
>
3442
{label}

src/components/MapAnalysis/MapAnalysisCanvas.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import PositionTrailsLayer from './layers/PositionTrailsLayer';
1212
import CoverageHeatmapLayer from './layers/CoverageHeatmapLayer';
1313
import SnrOverlayLayer from './layers/SnrOverlayLayer';
1414
import WaypointsLayer from './layers/WaypointsLayer';
15+
import PolarGridLayer from './layers/PolarGridLayer';
1516
import TimeSliderControl from './TimeSliderControl';
1617
import MapLegend from './MapLegend';
1718

@@ -67,6 +68,11 @@ export default function MapAnalysisCanvas() {
6768
<Pane name="heatmap" style={{ zIndex: 350 }}>
6869
{config.layers.heatmap.enabled && <CoverageHeatmapLayer />}
6970
</Pane>
71+
{/* Polar grid sits just below the node markers (z600) so its labels don't
72+
paint over them, but above the data layers so the range rings read. */}
73+
<Pane name="polarGrid" style={{ zIndex: 550 }}>
74+
{config.layers.polarGrid.enabled && <PolarGridLayer />}
75+
</Pane>
7076
</MapContainer>
7177
<TilesetSelector selectedTilesetId={mapTileset} onTilesetChange={setMapTileset} />
7278
<TimeSliderControl />

src/components/MapAnalysis/MapAnalysisToolbar.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { MapAnalysisProvider } from './MapAnalysisContext';
1010

1111
vi.mock('../../hooks/useDashboardData', () => ({
1212
useDashboardSources: () => ({ data: [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }] }),
13+
// The Polar Grid toggle resolves own-node positions via these hooks (#3971).
14+
useDashboardUnifiedData: () => ({ nodes: [] }),
15+
useSourceStatuses: () => new Map(),
1316
}));
1417

1518
const wrapper = ({ children }: { children: React.ReactNode }) => {
@@ -33,6 +36,13 @@ describe('MapAnalysisToolbar', () => {
3336
}
3437
});
3538

39+
it('renders the Polar Grid toggle disabled when no source has an own-node position (#3971)', () => {
40+
render(<MapAnalysisToolbar />, { wrapper });
41+
const btn = screen.getByRole('button', { name: /polar grid/i });
42+
expect(btn).toBeInTheDocument();
43+
expect(btn).toBeDisabled();
44+
});
45+
3646
it('toggles a layer and persists to localStorage', () => {
3747
render(<MapAnalysisToolbar />, { wrapper });
3848
fireEvent.click(screen.getByRole('button', { name: /traceroutes/i }));

src/components/MapAnalysis/MapAnalysisToolbar.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import NodeTypeFilterControl from './NodeTypeFilterControl';
66
import NodeSearchControl from './NodeSearchControl';
77
import TracerouteControls from './TracerouteControls';
88
import { useMapAnalysisCtx } from './MapAnalysisContext';
9+
import { useOwnNodePositions } from '../../hooks/useOwnNodePositions';
910
import { LayerKey } from '../../hooks/useMapAnalysisConfig';
1011
import {
1112
usePositions,
@@ -35,6 +36,11 @@ export default function MapAnalysisToolbar() {
3536
const { config, setLayerEnabled, setLayerLookback, setSources, setTimeSlider, reset } = useMapAnalysisCtx();
3637
const { data: sources = [] } = useDashboardSources();
3738

39+
// Polar grid (#3971): centered on each active source's own-node position.
40+
// Disable the toggle when no active source has a resolvable own node.
41+
const ownNodePositions = useOwnNodePositions(config.sources);
42+
const hasOwnNode = ownNodePositions.length > 0;
43+
3844
const sourceIds = config.sources.length === 0
3945
? sources.map((s: { id: string }) => s.id)
4046
: config.sources;
@@ -120,6 +126,13 @@ export default function MapAnalysisToolbar() {
120126
onToggle={(next) => setLayerEnabled(key, next)}
121127
/>
122128
))}
129+
<LayerToggleButton
130+
label="Polar Grid"
131+
enabled={config.layers.polarGrid.enabled && hasOwnNode}
132+
onToggle={(next) => setLayerEnabled('polarGrid', next)}
133+
disabled={!hasOwnNode}
134+
title={hasOwnNode ? undefined : 'No source has a known own-node position'}
135+
/>
123136
{TIMED_LAYERS.map(({ key, label, options }) => (
124137
<LayerToggleButton
125138
key={key}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* PolarGridLayer — renders the polar grid overlay (range rings + azimuth
3+
* sectors, #2307) on the Map Analysis canvas (#3971).
4+
*
5+
* Map Analysis is scoped per source, so one grid is drawn per active source
6+
* that has a resolvable own-node position, centered on that node. The theme-
7+
* aware `overlayColors.polarGrid` palette is used (matching NodesTab); the
8+
* per-source coloring is reserved for the Unified/Dashboard map where several
9+
* grids routinely overlap.
10+
*/
11+
import { useMapAnalysisCtx } from '../MapAnalysisContext';
12+
import { useOwnNodePositions } from '../../../hooks/useOwnNodePositions';
13+
import PolarGridOverlay from '../../PolarGridOverlay';
14+
15+
export default function PolarGridLayer() {
16+
const { config } = useMapAnalysisCtx();
17+
// config.sources empty = "all sources" (unified semantics), matching the
18+
// marker/source filters elsewhere in Map Analysis.
19+
const ownPositions = useOwnNodePositions(config.sources);
20+
21+
return (
22+
<>
23+
{ownPositions.map((op) => (
24+
<PolarGridOverlay key={op.sourceId} center={{ lat: op.lat, lng: op.lng }} />
25+
))}
26+
</>
27+
);
28+
}

src/components/PolarGridOverlay.test.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@ import React from 'react';
77
import { PolarGridOverlay } from './PolarGridOverlay';
88

99
vi.mock('react-leaflet', () => ({
10-
Circle: ({ center, radius }: any) => (
11-
<div data-testid="circle" data-radius={radius} data-lat={center[0]} data-lng={center[1]} />
10+
Circle: ({ center, radius, pathOptions }: any) => (
11+
<div
12+
data-testid="circle"
13+
data-radius={radius}
14+
data-lat={center[0]}
15+
data-lng={center[1]}
16+
data-color={pathOptions?.color}
17+
data-opacity={pathOptions?.opacity}
18+
/>
1219
),
1320
Polyline: ({ positions }: any) => <div data-testid="polyline" />,
1421
Marker: ({ position }: any) => (
@@ -95,4 +102,23 @@ describe('PolarGridOverlay', () => {
95102
expect(radii[i]).toBeGreaterThan(radii[i - 1]);
96103
}
97104
});
105+
106+
it('uses the theme ring color at full opacity by default', () => {
107+
render(<PolarGridOverlay center={CENTER} />);
108+
const circle = screen.getAllByTestId('circle')[0];
109+
expect(circle.getAttribute('data-color')).toBe('rgba(0,200,255,0.3)');
110+
// No explicit opacity override (undefined) — the rgba alpha carries the
111+
// transparency, so the attribute is absent.
112+
expect(circle.getAttribute('data-opacity')).toBeNull();
113+
});
114+
115+
it('overrides every ring with the per-source color at reduced opacity (#3971)', () => {
116+
render(<PolarGridOverlay center={CENTER} color="#89b4fa" />);
117+
const circles = screen.getAllByTestId('circle');
118+
circles.forEach((circle) => {
119+
expect(circle.getAttribute('data-color')).toBe('#89b4fa');
120+
expect(Number(circle.getAttribute('data-opacity'))).toBeLessThan(1);
121+
expect(Number(circle.getAttribute('data-opacity'))).toBeGreaterThan(0);
122+
});
123+
});
98124
});

src/components/PolarGridOverlay.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,23 @@ import { getPolarGridRings, getSectorEndpoint } from '../utils/polarGrid.js';
66

77
interface PolarGridOverlayProps {
88
center: { lat: number; lng: number };
9+
/**
10+
* Optional literal (hex/rgb) color override for the whole grid. When set,
11+
* rings/sectors/labels all render in this color at reduced opacity — used on
12+
* the Unified/Dashboard map (#3971) to draw one grid per source in that
13+
* source's color so overlapping grids stay distinguishable. When omitted the
14+
* grid uses the theme-aware `overlayColors.polarGrid` palette (NodesTab / Map
15+
* Analysis). Must be a resolved literal, not a `var(--…)` — Leaflet paints SVG
16+
* strokes via the presentation attribute, which does not evaluate CSS vars.
17+
*/
18+
color?: string;
919
}
1020

1121
const SECTOR_BEARINGS = Array.from({ length: 12 }, (_, i) => i * 30);
1222
const CARDINAL_BEARINGS = new Set([0, 90, 180, 270]);
1323
const DEGREE_LABELS = ['0', '30', '60', '90', '120', '150', '180', '210', '240', '270', '300', '330'];
1424

15-
export const PolarGridOverlay: React.FC<PolarGridOverlayProps> = ({ center }) => {
25+
export const PolarGridOverlay: React.FC<PolarGridOverlayProps> = ({ center, color }) => {
1626
const map = useMap();
1727
const { distanceUnit, overlayColors } = useSettings();
1828
const [zoom, setZoom] = useState(map.getZoom());
@@ -23,7 +33,16 @@ export const PolarGridOverlay: React.FC<PolarGridOverlayProps> = ({ center }) =>
2333
return () => { map.off('zoomend', onZoomEnd); };
2434
}, [map]);
2535

26-
const colors = overlayColors;
36+
// When a per-source color override is provided, use it for every grid element
37+
// (the theme palette bakes opacity into rgba() values; a solid override gets
38+
// opacity via pathOptions instead). Otherwise fall back to the theme palette.
39+
const gridColors = color
40+
? { rings: color, sectors: color, cardinalSectors: color, labels: color }
41+
: overlayColors.polarGrid;
42+
const ringOpacity = color ? 0.5 : undefined;
43+
const cardinalOpacity = color ? 0.55 : undefined;
44+
const sectorOpacity = color ? 0.35 : undefined;
45+
const colors = { polarGrid: gridColors };
2746
const centerLatLng: [number, number] = [center.lat, center.lng];
2847

2948
const rings = useMemo(
@@ -76,6 +95,7 @@ export const PolarGridOverlay: React.FC<PolarGridOverlayProps> = ({ center }) =>
7695
pathOptions={{
7796
color: colors.polarGrid.rings,
7897
weight: 2,
98+
opacity: ringOpacity,
7999
fill: false,
80100
interactive: false,
81101
}}
@@ -91,6 +111,7 @@ export const PolarGridOverlay: React.FC<PolarGridOverlayProps> = ({ center }) =>
91111
? colors.polarGrid.cardinalSectors
92112
: colors.polarGrid.sectors,
93113
weight: 2,
114+
opacity: sector.isCardinal ? cardinalOpacity : sectorOpacity,
94115
dashArray: sector.isCardinal ? undefined : '6 6',
95116
interactive: false,
96117
}}

0 commit comments

Comments
 (0)