Skip to content

Commit 663c09d

Browse files
Yerazeclaude
andcommitted
feat(map): node-to-node LOS distance measurement tool on all maps (#3636)
Adds a reusable "Measure Distance" tool to every interactive map view — Unified/Map Analysis, the per-source Dashboard map, the MeshCore map, and the per-source Meshtastic Nodes-tab map. Toggling it on lets the user click near two nodes; each click snaps to the nearest node and a dashed line is drawn with the straight-line distance labeled at the midpoint (honoring the km/mi setting). A third click restarts; Escape or toggling off clears it. - New `MeasureDistanceController` react-leaflet child renders the picks, line, and label; snapping keeps it map-agnostic so one component serves all maps. - New pure `measureDistance.ts` helpers (nearestPoint/measureLabel) reuse the existing Haversine `calculateDistance`/`formatDistance` utils. - Per-map wiring: a Features-panel toggle (Dashboard/MeshCore/NodesTab) or a toolbar button + transient context flag (Map Analysis), plus a points array built from each map's existing positioned-node memo. - Controller is only mounted while active, so existing partial react-leaflet test mocks are unaffected. Frontend-only; no backend or DB changes. Unit + component tests cover snap, km/mi formatting, restart, Escape, and the crosshair affordance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub
1 parent 6b6b039 commit 663c09d

11 files changed

Lines changed: 527 additions & 1 deletion

src/components/Dashboard/DashboardMap.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import GeoJsonOverlay from '../GeoJsonOverlay';
2727
import PolarGridOverlay from '../PolarGridOverlay';
2828
import { TilesetSelector } from '../TilesetSelector';
2929
import MapLegend from '../MapLegend';
30+
import MeasureDistanceController from '../MeasureDistanceController';
31+
import type { MeasurePoint } from '../../utils/measureDistance';
3032
import type { GeoJsonLayer } from '../../server/services/geojsonService.js';
3133
import { useMapContext } from '../../contexts/MapContext';
3234
import { useSettings } from '../../contexts/SettingsContext';
@@ -266,6 +268,9 @@ export default function DashboardMap({
266268
localStorage.setItem('isMapControlsCollapsed', String(isMapControlsCollapsed));
267269
}, [isMapControlsCollapsed]);
268270

271+
// #3636: node-to-node LOS distance measurement tool.
272+
const [measureActive, setMeasureActive] = useState(false);
273+
269274
const {
270275
showPaths,
271276
setShowPaths,
@@ -343,6 +348,14 @@ export default function DashboardMap({
343348

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

351+
// #3636: measurement endpoints — nearest-node snapping picks from these.
352+
const measurePoints: MeasurePoint[] = nodesWithPosition.map(({ node, pos }) => ({
353+
id: String(node.nodeId ?? node.user?.id ?? node.nodeNum),
354+
lat: pos.lat,
355+
lng: pos.lng,
356+
label: node.shortName ?? node.user?.shortName,
357+
}));
358+
346359
// Own-node position per source for the polar grid. Resolved from the raw
347360
// `nodes` prop (not the age/transport-filtered marker list) so the grid center
348361
// survives even when the local node is stale or filtered off the map.
@@ -501,6 +514,14 @@ export default function DashboardMap({
501514

502515
<SpiderfierController ref={spiderfierRef} />
503516

517+
{measureActive && (
518+
<MeasureDistanceController
519+
active={measureActive}
520+
points={measurePoints}
521+
onExit={() => setMeasureActive(false)}
522+
/>
523+
)}
524+
504525
<MapBoundsUpdater positions={nodePositions} sourceId={sourceId} />
505526

506527
{showLegend && <MapLegend />}
@@ -734,6 +755,17 @@ export default function DashboardMap({
734755
</div>
735756
{!isMapControlsCollapsed && (
736757
<>
758+
{/* #3636: node-to-node LOS distance measurement toggle. Needs at least
759+
two positioned nodes to be meaningful. */}
760+
<label className="map-control-item" title="Measure straight-line distance between two nodes">
761+
<input
762+
type="checkbox"
763+
checked={measureActive}
764+
disabled={measurePoints.length < 2}
765+
onChange={(e) => setMeasureActive(e.target.checked)}
766+
/>
767+
<span>Measure Distance</span>
768+
</label>
737769
{/* Map Features age slider (#3322): hides node markers, traceroutes,
738770
and route segments older than the chosen age. Ranges 1h–maxNodeAge. */}
739771
{(() => {

src/components/MapAnalysis/MapAnalysisCanvas.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { useMemo } from 'react';
12
import { MapContainer, TileLayer, Pane } from 'react-leaflet';
23
import 'leaflet/dist/leaflet.css';
34
import { useSettings } from '../../contexts/SettingsContext';
45
import { useMapAnalysisCtx } from './MapAnalysisContext';
6+
import { useAnalysisNodes } from './useAnalysisNodes';
7+
import MeasureDistanceController from '../MeasureDistanceController';
8+
import type { MeasurePoint } from '../../utils/measureDistance';
59
import { getTilesetById } from '../../config/tilesets';
610
import { TilesetSelector } from '../TilesetSelector';
711
import NodeMarkersLayer from './layers/NodeMarkersLayer';
@@ -30,7 +34,20 @@ export default function MapAnalysisCanvas() {
3034
customTilesets,
3135
setMapTileset,
3236
} = useSettings();
33-
const { config } = useMapAnalysisCtx();
37+
const { config, measureMode, setMeasureMode } = useMapAnalysisCtx();
38+
39+
// #3636: measurement endpoints, from the same visible+positioned node list
40+
// the markers layer uses so the two never disagree.
41+
const analysisNodes = useAnalysisNodes();
42+
const measurePoints: MeasurePoint[] = useMemo(
43+
() => analysisNodes.map((a) => ({
44+
id: a.key,
45+
lat: a.latLng[0],
46+
lng: a.latLng[1],
47+
label: a.node.shortName ?? undefined,
48+
})),
49+
[analysisNodes],
50+
);
3451

3552
const center: [number, number] = [
3653
defaultMapCenterLat ?? FALLBACK_CENTER[0],
@@ -49,6 +66,13 @@ export default function MapAnalysisCanvas() {
4966
maxZoom={tileset.maxZoom}
5067
/>
5168
<FollowController />
69+
{measureMode && (
70+
<MeasureDistanceController
71+
active={measureMode}
72+
points={measurePoints}
73+
onExit={() => setMeasureMode(false)}
74+
/>
75+
)}
5276
<Pane name="waypoints" style={{ zIndex: 650 }}>
5377
{config.layers.waypoints.enabled && <WaypointsLayer />}
5478
</Pane>

src/components/MapAnalysis/MapAnalysisContext.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ type CtxShape = ReturnType<typeof useMapAnalysisConfig> & {
3535
/** Follow/Auto-zoom paused by a manual pan/zoom; cleared by Resume or retargeting (issue #3788 P2). */
3636
followPaused: boolean;
3737
setFollowPaused: (p: boolean) => void;
38+
/** Node-to-node LOS distance measurement tool active (issue #3636); transient, not persisted. */
39+
measureMode: boolean;
40+
setMeasureMode: (m: boolean) => void;
3841
};
3942

4043
const Ctx = createContext<CtxShape | null>(null);
@@ -44,6 +47,7 @@ export function MapAnalysisProvider({ children }: { children: ReactNode }) {
4447
const [selected, setSelected] = useState<SelectedTarget | null>(null);
4548
const [nodeFilter, setNodeFilter] = useState('');
4649
const [followPaused, setFollowPaused] = useState(false);
50+
const [measureMode, setMeasureMode] = useState(false);
4751
return (
4852
<Ctx.Provider
4953
value={{
@@ -54,6 +58,8 @@ export function MapAnalysisProvider({ children }: { children: ReactNode }) {
5458
setNodeFilter,
5559
followPaused,
5660
setFollowPaused,
61+
measureMode,
62+
setMeasureMode,
5763
}}
5864
>
5965
{children}

src/components/MapAnalysis/MapAnalysisToolbar.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export default function MapAnalysisToolbar() {
4545
setTimeSlider,
4646
setFollowMode,
4747
setAutoZoom,
48+
measureMode,
49+
setMeasureMode,
4850
reset,
4951
} = useMapAnalysisCtx();
5052
const { data: sources = [] } = useDashboardSources();
@@ -162,6 +164,15 @@ export default function MapAnalysisToolbar() {
162164
>
163165
Time Slider
164166
</button>
167+
{/* #3636: node-to-node LOS distance measurement tool. */}
168+
<button
169+
type="button"
170+
className={`map-analysis-layer-btn ${measureMode ? 'active' : ''}`}
171+
onClick={() => setMeasureMode(!measureMode)}
172+
title="Measure straight-line distance between two nodes"
173+
>
174+
Measure
175+
</button>
165176
{UNTIMED_LAYERS.map(({ key, label }) => (
166177
<LayerToggleButton
167178
key={key}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/* #3636: distance label for the node-to-node LOS measurement tool.
2+
Applied to the permanent Leaflet tooltip anchored at the measurement line's
3+
midpoint. Styled as a high-contrast pill so it reads over any tile layer. */
4+
.leaflet-tooltip.measure-distance-label {
5+
background: #0f172a;
6+
color: #e2e8f0;
7+
border: 1px solid #38bdf8;
8+
border-radius: 10px;
9+
padding: 2px 8px;
10+
font-weight: 600;
11+
font-size: 0.8rem;
12+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
13+
white-space: nowrap;
14+
}
15+
16+
/* Center-direction tooltips have no callout arrow; hide any pseudo just in case. */
17+
.leaflet-tooltip.measure-distance-label::before {
18+
display: none;
19+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
import { render, screen, act } from '@testing-library/react';
6+
import type { MeasurePoint } from '../utils/measureDistance';
7+
8+
// Capture the click handler registered via useMapEvents so the test can fire
9+
// synthetic map clicks. Stub the leaflet primitives to plain DOM so we can
10+
// assert what the controller renders without a real map.
11+
let clickHandler: ((e: { latlng: { lat: number; lng: number } }) => void) | null = null;
12+
const containerStyle: { cursor: string } = { cursor: '' };
13+
14+
vi.mock('react-leaflet', () => ({
15+
useMap: () => ({ getContainer: () => ({ style: containerStyle }) }),
16+
useMapEvents: (handlers: { click: (e: { latlng: { lat: number; lng: number } }) => void }) => {
17+
clickHandler = handlers.click;
18+
return {};
19+
},
20+
CircleMarker: ({ center }: { center: [number, number] }) => (
21+
<div data-testid="measure-ring" data-lat={center[0]} data-lng={center[1]} />
22+
),
23+
Polyline: ({ positions, children }: { positions: [number, number][]; children?: React.ReactNode }) => (
24+
<div data-testid="measure-line" data-positions={JSON.stringify(positions)}>{children}</div>
25+
),
26+
Tooltip: ({ children }: { children?: React.ReactNode }) => (
27+
<div data-testid="measure-label">{children}</div>
28+
),
29+
}));
30+
31+
let unit: 'km' | 'mi' = 'km';
32+
vi.mock('../contexts/SettingsContext', () => ({
33+
useSettings: () => ({ distanceUnit: unit }),
34+
}));
35+
36+
import MeasureDistanceController from './MeasureDistanceController';
37+
38+
const POINTS: MeasurePoint[] = [
39+
{ id: 'a', lat: 40.0, lng: -105.0, label: 'A' },
40+
{ id: 'b', lat: 40.5, lng: -105.0, label: 'B' },
41+
{ id: 'c', lat: 41.0, lng: -105.0, label: 'C' },
42+
];
43+
44+
function click(lat: number, lng: number) {
45+
act(() => clickHandler?.({ latlng: { lat, lng } }));
46+
}
47+
48+
describe('MeasureDistanceController', () => {
49+
beforeEach(() => {
50+
clickHandler = null;
51+
containerStyle.cursor = '';
52+
unit = 'km';
53+
});
54+
55+
it('renders nothing when inactive', () => {
56+
render(<MeasureDistanceController active={false} points={POINTS} />);
57+
expect(screen.queryByTestId('measure-ring')).toBeNull();
58+
});
59+
60+
it('renders nothing with fewer than two points', () => {
61+
render(<MeasureDistanceController active points={[POINTS[0]]} />);
62+
expect(screen.queryByTestId('measure-ring')).toBeNull();
63+
});
64+
65+
it('snaps two clicks to nearest nodes and draws a labeled line', () => {
66+
render(<MeasureDistanceController active points={POINTS} />);
67+
// Click near A, then near C -> line A..C, ~111 km.
68+
click(39.9, -105.0);
69+
click(41.1, -105.0);
70+
71+
const line = screen.getByTestId('measure-line');
72+
expect(JSON.parse(line.getAttribute('data-positions')!)).toEqual([
73+
[40.0, -105.0],
74+
[41.0, -105.0],
75+
]);
76+
const label = screen.getByTestId('measure-label').textContent ?? '';
77+
expect(label).toMatch(/km$/);
78+
expect(parseFloat(label)).toBeCloseTo(111.2, 0);
79+
});
80+
81+
it('honors the miles preference', () => {
82+
unit = 'mi';
83+
render(<MeasureDistanceController active points={POINTS} />);
84+
click(39.9, -105.0);
85+
click(40.6, -105.0); // nearest to B
86+
expect(screen.getByTestId('measure-label').textContent).toMatch(/mi$/);
87+
});
88+
89+
it('a third click restarts the measurement from a new anchor', () => {
90+
render(<MeasureDistanceController active points={POINTS} />);
91+
click(39.9, -105.0); // A
92+
click(41.1, -105.0); // C -> completed pair
93+
expect(screen.queryByTestId('measure-line')).not.toBeNull();
94+
95+
click(40.6, -105.0); // restart with B as the new anchor A
96+
expect(screen.queryByTestId('measure-line')).toBeNull();
97+
// one ring for the new anchor
98+
expect(screen.getAllByTestId('measure-ring')).toHaveLength(1);
99+
});
100+
101+
it('sets a crosshair cursor while active and restores it on exit', () => {
102+
const { rerender } = render(<MeasureDistanceController active points={POINTS} />);
103+
expect(containerStyle.cursor).toBe('crosshair');
104+
rerender(<MeasureDistanceController active={false} points={POINTS} />);
105+
expect(containerStyle.cursor).toBe('');
106+
});
107+
108+
it('Escape clears the measurement and calls onExit', () => {
109+
const onExit = vi.fn();
110+
render(<MeasureDistanceController active points={POINTS} onExit={onExit} />);
111+
click(39.9, -105.0);
112+
click(41.1, -105.0);
113+
expect(screen.queryByTestId('measure-line')).not.toBeNull();
114+
115+
act(() => {
116+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
117+
});
118+
expect(onExit).toHaveBeenCalledTimes(1);
119+
expect(screen.queryByTestId('measure-line')).toBeNull();
120+
});
121+
});

0 commit comments

Comments
 (0)