Skip to content

Commit a83f355

Browse files
kevinelliottclaude
andcommitted
Fix window consistency, transport filter for polygons, add URL state + coverage area
Fixes: - Longer windows now always include stations from shorter windows. getStationMessageCounts scans all windows up to the requested one, ensuring a station visible in 5m also appears in 1h/1mo. - Polygon mode now respects the transport filter (All/Aircraft/Marine). Marine source types (aiscatcher, ais) are filtered out in Aircraft mode and vice versa. New features: - URL state sync: view state, mode, time window, and transport filter are encoded in the URL hash. Share links like /#mode=hexgrid&window=1h &lat=51.50&lon=-0.12&zoom=6.0 - Coverage area estimation: stats bar shows approximate total coverage area in km² (derived from H3 res-3 cell count × 12,400 km²/cell). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 376b466 commit a83f355

6 files changed

Lines changed: 123 additions & 15 deletions

File tree

map/src/components/controls/StatsBar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export default function StatsBar() {
6464
{/* Main stats */}
6565
<div className="flex items-center justify-between">
6666
<Stat label="Stations" value={stations.length} />
67-
<Stat label="H3 Cells" value={hexData.length} />
67+
<Stat label="Coverage" value={formatArea(stats?.coverageAreaKm2 ?? 0)} />
6868
<Stat label="Messages" value={formatCount(totalMessages)} />
6969
<Stat label="msg/s" value={stats?.messagesPerSecond ?? 0} decimals={1} />
7070
</div>
@@ -146,3 +146,9 @@ function formatCount(n: number): string {
146146
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
147147
return String(n);
148148
}
149+
150+
function formatArea(km2: number): string {
151+
if (km2 >= 1_000_000) return `${(km2 / 1_000_000).toFixed(1)}M km²`;
152+
if (km2 >= 1_000) return `${(km2 / 1_000).toFixed(0)}k km²`;
153+
return `${km2} km²`;
154+
}

map/src/components/map/CoverageMap.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import ConnectionBanner from '@/components/controls/ConnectionBanner';
1515
import StationPopup from '@/components/station/StationPopup';
1616
import HexPopup from '@/components/map/HexPopup';
1717
import PolygonPopup from '@/components/map/PolygonPopup';
18+
import { useUrlState } from '@/lib/hooks/use-url-state';
1819
import type { CoverageHex, CoveragePolygon } from '@/lib/types/coverage';
1920

2021
const POLL_INTERVAL = 10_000;
@@ -30,6 +31,8 @@ function DeckGLOverlay(props: any) {
3031
}
3132

3233
export default function CoverageMap() {
34+
useUrlState();
35+
3336
const [viewState, setLocalViewState] = useState({
3437
longitude: MAP_CONFIG.initialViewState.longitude as number,
3538
latitude: MAP_CONFIG.initialViewState.latitude as number,
@@ -105,9 +108,16 @@ export default function CoverageMap() {
105108
}, [timeWindow, h3Resolution, transportFilter, setHexData, setRefreshing]);
106109

107110
const fetchPolygons = useCallback(async () => {
108-
const withPosition = stations.filter(
109-
(s) => s.latitude !== 0 && s.longitude !== 0 && s.messagesWithPosition > 0
110-
).slice(0, 30);
111+
const MARINE_TYPES = new Set(['aiscatcher', 'ais']);
112+
113+
const withPosition = stations.filter((s) => {
114+
if (s.latitude === 0 && s.longitude === 0) return false;
115+
if (s.messagesWithPosition <= 0) return false;
116+
// Transport filter
117+
if (transportFilter === 'aircraft' && MARINE_TYPES.has(s.sourceType)) return false;
118+
if (transportFilter === 'marine' && !MARINE_TYPES.has(s.sourceType)) return false;
119+
return true;
120+
}).slice(0, 30);
111121

112122
const polygons: CoveragePolygon[] = [];
113123

@@ -135,7 +145,7 @@ export default function CoverageMap() {
135145
);
136146

137147
setPolygonData(polygons);
138-
}, [stations, timeWindow, setPolygonData]);
148+
}, [stations, timeWindow, transportFilter, setPolygonData]);
139149

140150
const fetchStations = useCallback(async () => {
141151
try {

map/src/lib/hooks/use-url-state.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client';
2+
3+
import { useEffect, useRef } from 'react';
4+
import { useUIStore } from '../stores/ui-store';
5+
import { TIME_WINDOWS, type TimeWindowKey } from '../constants/time-windows';
6+
7+
/**
8+
* Sync UI state to/from URL hash parameters.
9+
* Enables sharing links like: /map#mode=hexgrid&window=1h&lat=51.5&lon=-0.1&zoom=8
10+
*/
11+
export function useUrlState() {
12+
const mode = useUIStore((s) => s.mode);
13+
const timeWindow = useUIStore((s) => s.timeWindow);
14+
const transportFilter = useUIStore((s) => s.transportFilter);
15+
const viewState = useUIStore((s) => s.viewState);
16+
const setMode = useUIStore((s) => s.setMode);
17+
const setTimeWindow = useUIStore((s) => s.setTimeWindow);
18+
const setTransportFilter = useUIStore((s) => s.setTransportFilter);
19+
const flyTo = useUIStore((s) => s.flyTo);
20+
21+
const initialized = useRef(false);
22+
23+
// On mount: read from URL hash
24+
useEffect(() => {
25+
if (initialized.current) return;
26+
initialized.current = true;
27+
28+
const hash = window.location.hash.slice(1);
29+
if (!hash) return;
30+
31+
const params = new URLSearchParams(hash);
32+
33+
const m = params.get('mode');
34+
if (m === 'hexgrid' || m === 'polygon') setMode(m);
35+
36+
const w = params.get('window');
37+
if (w && TIME_WINDOWS.some((tw) => tw.key === w)) setTimeWindow(w as TimeWindowKey);
38+
39+
const t = params.get('transport');
40+
if (t === 'all' || t === 'aircraft' || t === 'marine') setTransportFilter(t);
41+
42+
const lat = params.get('lat');
43+
const lon = params.get('lon');
44+
const zoom = params.get('zoom');
45+
if (lat && lon) {
46+
flyTo({
47+
latitude: parseFloat(lat),
48+
longitude: parseFloat(lon),
49+
zoom: zoom ? parseFloat(zoom) : 6,
50+
});
51+
}
52+
}, [setMode, setTimeWindow, setTransportFilter, flyTo]);
53+
54+
// Debounced write to URL hash
55+
const writeTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
56+
57+
useEffect(() => {
58+
clearTimeout(writeTimer.current);
59+
writeTimer.current = setTimeout(() => {
60+
const params = new URLSearchParams();
61+
params.set('mode', mode);
62+
params.set('window', timeWindow);
63+
if (transportFilter !== 'all') params.set('transport', transportFilter);
64+
params.set('lat', viewState.latitude.toFixed(2));
65+
params.set('lon', viewState.longitude.toFixed(2));
66+
params.set('zoom', viewState.zoom.toFixed(1));
67+
68+
const newHash = params.toString();
69+
if (window.location.hash.slice(1) !== newHash) {
70+
history.replaceState(null, '', `#${newHash}`);
71+
}
72+
}, 1000);
73+
}, [mode, timeWindow, transportFilter, viewState]);
74+
}

map/src/lib/types/coverage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface CoverageStats {
4747
messagesPerSecond: number;
4848
activeStations: number;
4949
totalCells: Record<string, number>;
50+
coverageAreaKm2: number;
5051
natsConnected: boolean;
5152
}
5253

service/src/aggregation/coverage-store.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -280,18 +280,31 @@ export class CoverageStore {
280280
*/
281281
getStationMessageCounts(windowName: string): Map<number, { messageCount: number; maxDistance: number }> {
282282
const stationCounts = new Map<number, { messageCount: number; maxDistance: number }>();
283+
const wi = WINDOW_CONFIGS.findIndex(c => c.name === windowName);
284+
if (wi === -1) return stationCounts;
283285

284-
// Primary source: H3 sliding windows (tracks station IDs per cell)
285286
const ri = H3_RESOLUTION_CONFIGS.findIndex(c => c.resolution === 3);
286-
const wi = WINDOW_CONFIGS.findIndex(c => c.name === windowName);
287-
if (ri !== -1 && wi !== -1) {
288-
const window = this.windows[ri][wi];
287+
288+
// Scan the requested window AND all shorter windows.
289+
// A station visible in a shorter window must appear in all longer windows.
290+
// Use the requested window's counts as the primary, but ensure stations
291+
// from shorter windows are included.
292+
const windowsToCheck = ri !== -1
293+
? Array.from({ length: wi + 1 }, (_, i) => i)
294+
: [];
295+
296+
for (const checkWi of windowsToCheck) {
297+
if (ri === -1) continue;
298+
const window = this.windows[ri][checkWi];
289299
const aggregated = window.getAggregated();
290300
for (const [, cell] of aggregated) {
291301
for (const stationId of cell.stationIds) {
292302
const existing = stationCounts.get(stationId);
293303
if (existing) {
294-
existing.messageCount += cell.messageCount;
304+
// Use the max values (longer window should have more data)
305+
if (checkWi === wi) {
306+
existing.messageCount += cell.messageCount;
307+
}
295308
existing.maxDistance = Math.max(existing.maxDistance, cell.maxDistance);
296309
} else {
297310
stationCounts.set(stationId, {
@@ -303,12 +316,11 @@ export class CoverageStore {
303316
}
304317
}
305318

306-
// Fallback: station sliding windows (for data loaded from persistence
307-
// where H3 cells may lack station IDs)
308-
if (wi !== -1 && wi < this.stationWindows.length) {
309-
const sw = this.stationWindows[wi];
319+
// Also check station sliding windows (for persistence-loaded data)
320+
for (let swi = 0; swi <= wi && swi < this.stationWindows.length; swi++) {
321+
const sw = this.stationWindows[swi];
310322
for (const stationId of sw.activeStationIds()) {
311-
if (stationCounts.has(stationId)) continue; // Already counted from H3
323+
if (stationCounts.has(stationId)) continue;
312324
const cov = sw.getStationCoverage(stationId);
313325
if (cov && cov.totalMessages > 0) {
314326
stationCounts.set(stationId, {

service/src/api/routes/health.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,15 @@ healthRoutes.get('/stats', (c) => {
4343
const uptime = process.uptime();
4444
const mps = uptime > 0 ? Math.round(stats.eventsProcessed / uptime * 10) / 10 : 0;
4545

46+
// Estimate coverage area from H3 res-3 cells (each ~12,400 km²)
47+
const res3Cells = stats.activeCells[3] ?? 0;
48+
const coverageAreaKm2 = Math.round(res3Cells * 12400);
49+
4650
return c.json({
4751
messagesPerSecond: mps,
4852
activeStations: stats.stationsTracked,
4953
totalCells: stats.activeCells,
54+
coverageAreaKm2,
5055
natsConnected: nats.isConnected(),
5156
});
5257
});

0 commit comments

Comments
 (0)