Skip to content

Commit 5ab60fa

Browse files
kevinelliottclaude
andcommitted
Scope station bearing sectors to time window with sliding windows
Station directional coverage (bearing sectors) was accumulating forever regardless of the selected time window. Now uses StationSlidingWindow — the same circular buffer approach as H3 cells. Each time slice stores per-station sector data (max distance, message count, signal levels). When queried, slices are aggregated to produce window-scoped coverage. Switching from 1h to 5m now shows only the bearing sectors from the last 5 minutes. Also: clicking a polygon or station now enlarges the associated station marker (radius 20, bright emerald, thicker stroke). Both direct station click and polygon click highlight the related station. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 38c3cc2 commit 5ab60fa

File tree

4 files changed

+241
-87
lines changed

4 files changed

+241
-87
lines changed

map/src/components/map/CoverageMap.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ export default function CoverageMap() {
171171
// ── Deck.gl layers ──
172172

173173
const selectedPolygonStationId = selectedPolygon?.stationId ?? null;
174+
// Highlight the station marker for either direct station click or polygon click
175+
const highlightedStationId = selectedStationId ?? selectedPolygonStationId;
174176

175177
const layers = useMemo(() => {
176178
const result = [];
@@ -179,9 +181,9 @@ export default function CoverageMap() {
179181
} else {
180182
result.push(createPolygonLayer(polygonData, selectedPolygonStationId));
181183
}
182-
result.push(createStationMarkersLayer(stations, selectedStationId));
184+
result.push(createStationMarkersLayer(stations, highlightedStationId));
183185
return result;
184-
}, [mode, hexData, polygonData, stations, selectedStationId, selectedPolygonStationId]);
186+
}, [mode, hexData, polygonData, stations, highlightedStationId, selectedPolygonStationId]);
185187

186188
const getTooltip = useCallback(({ object }: any) => {
187189
if (!object) return null;
Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
11
import { ScatterplotLayer } from '@deck.gl/layers';
22
import type { Station } from '@/lib/types/coverage';
33

4-
/** Source type to RGBA color for station dots */
54
const SOURCE_COLORS: Record<string, [number, number, number]> = {
6-
acars: [52, 211, 153], // emerald
7-
vdl: [96, 165, 250], // blue
8-
hfdl: [129, 140, 248], // indigo
9-
satcom: [167, 139, 250], // purple
10-
aiscatcher:[251, 146, 60], // orange
11-
ais: [251, 146, 60], // orange
5+
acars: [52, 211, 153],
6+
vdl: [96, 165, 250],
7+
hfdl: [129, 140, 248],
8+
satcom: [167, 139, 250],
9+
aiscatcher:[251, 146, 60],
10+
ais: [251, 146, 60],
1211
};
1312

1413
const DEFAULT_COLOR: [number, number, number] = [200, 200, 200];
1514
const SELECTED_COLOR: [number, number, number, number] = [99, 251, 215, 255];
1615

16+
/**
17+
* @param highlightedId - station to highlight (from direct click OR polygon selection)
18+
*/
1719
export function createStationMarkersLayer(
1820
data: Station[],
19-
selectedId: number | null,
21+
highlightedId: number | null,
2022
) {
21-
// Filter out stations without coordinates
2223
const visibleData = data.filter(
2324
(d) => d.latitude !== 0 || d.longitude !== 0
2425
);
@@ -29,25 +30,30 @@ export function createStationMarkersLayer(
2930
pickable: true,
3031
getPosition: (d) => [d.longitude, d.latitude],
3132
getFillColor: (d) => {
32-
if (d.id === selectedId) return SELECTED_COLOR;
33+
if (d.id === highlightedId) return SELECTED_COLOR;
3334
const rgb = SOURCE_COLORS[d.sourceType] ?? DEFAULT_COLOR;
3435
return [rgb[0], rgb[1], rgb[2], 200];
3536
},
36-
getRadius: (d) => Math.max(4, Math.log2((d.messageCount || 1) + 1) * 1.8),
37+
getRadius: (d) => {
38+
if (d.id === highlightedId) return 20;
39+
return Math.max(4, Math.log2((d.messageCount || 1) + 1) * 1.8);
40+
},
3741
radiusMinPixels: 3,
38-
radiusMaxPixels: 12,
42+
radiusMaxPixels: 20,
3943
stroked: true,
4044
getLineColor: (d) =>
41-
d.id === selectedId
42-
? [99, 251, 215, 100]
45+
d.id === highlightedId
46+
? [99, 251, 215, 150]
4347
: [0, 0, 0, 100],
48+
getLineWidth: (d) =>
49+
d.id === highlightedId ? 3 : 1,
4450
lineWidthMinPixels: 1,
45-
transitions: {
46-
getRadius: { duration: 300 },
47-
getFillColor: { duration: 200 },
48-
},
51+
lineWidthMaxPixels: 4,
4952
updateTriggers: {
50-
getFillColor: [selectedId, data],
53+
getFillColor: [highlightedId],
54+
getRadius: [highlightedId],
55+
getLineColor: [highlightedId],
56+
getLineWidth: [highlightedId],
5157
},
5258
});
5359
}

service/src/aggregation/coverage-store.ts

Lines changed: 34 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { SlidingWindow, createEmptyCell, mergeIntoCell } from './sliding-window.js';
2+
import { StationSlidingWindow } from './station-window.js';
23
import { indexPoint, shouldMaintainResolution } from '../compute/h3-indexer.js';
34
import { haversineDistance, bearing, bearingToSector } from '../compute/distance.js';
45
import { computeConfidence } from '../compute/signal-analysis.js';
@@ -18,8 +19,8 @@ import type { CoverageEvent, StationMeta, TransportType } from '../types/events.
1819
export class CoverageStore {
1920
/** windows[resolutionIdx][windowIdx] = SlidingWindow */
2021
private windows: SlidingWindow[][] = [];
21-
/** Per-station directional coverage: stationId -> windowName -> StationCoverage */
22-
private stationCoverage = new Map<number, Map<string, StationCoverage>>();
22+
/** Per-station directional coverage with sliding windows: one per window config */
23+
private stationWindows: StationSlidingWindow[] = [];
2324
/** Synced station metadata from main API */
2425
public stationMap = new Map<number, StationMeta>();
2526
/** Rate limiter */
@@ -45,6 +46,11 @@ export class CoverageStore {
4546
}
4647
this.windows.push(resWindows);
4748
}
49+
50+
// Initialize station sliding windows (one per time window config)
51+
for (const wc of WINDOW_CONFIGS) {
52+
this.stationWindows.push(new StationSlidingWindow(wc));
53+
}
4854
}
4955

5056
/**
@@ -175,66 +181,24 @@ export class CoverageStore {
175181

176182
private updateStationCoverage(
177183
event: CoverageEvent,
178-
stationLat: number,
179-
stationLon: number,
184+
_stationLat: number,
185+
_stationLon: number,
180186
distance: number,
181187
brng: number,
182188
hasValidTarget: boolean,
183189
): void {
184-
let stationWindows = this.stationCoverage.get(event.stationId);
185-
if (!stationWindows) {
186-
stationWindows = new Map();
187-
this.stationCoverage.set(event.stationId, stationWindows);
188-
}
189-
190-
for (const wc of WINDOW_CONFIGS) {
191-
let cov = stationWindows.get(wc.name);
192-
if (!cov) {
193-
cov = {
194-
stationId: event.stationId,
195-
bearingSectors: new Float64Array(36),
196-
sectorMessageCounts: new Uint32Array(36),
197-
sectorAvgLevels: new Float64Array(36),
198-
totalMessages: 0,
199-
messagesWithPosition: 0,
200-
maxDistance: 0,
201-
avgLevel: 0,
202-
errorRate: 0,
203-
confidence: 0,
204-
lastUpdated: Date.now(),
205-
};
206-
stationWindows.set(wc.name, cov);
207-
}
208-
209-
cov.totalMessages++;
210-
cov.lastUpdated = Date.now();
211-
212-
if (hasValidTarget) {
213-
cov.messagesWithPosition++;
214-
if (distance > cov.maxDistance) cov.maxDistance = distance;
215-
216-
const sector = bearingToSector(brng);
217-
if (distance > cov.bearingSectors[sector]) {
218-
cov.bearingSectors[sector] = distance;
219-
}
220-
cov.sectorMessageCounts[sector]++;
221-
222-
if (event.signalLevel !== null) {
223-
// Running average
224-
const prevAvg = cov.sectorAvgLevels[sector];
225-
const count = cov.sectorMessageCounts[sector];
226-
cov.sectorAvgLevels[sector] = prevAvg + (event.signalLevel - prevAvg) / count;
227-
}
228-
}
229-
230-
if (event.signalLevel !== null) {
231-
const prevAvg = cov.avgLevel;
232-
cov.avgLevel = prevAvg + (event.signalLevel - prevAvg) / cov.totalMessages;
233-
}
234-
235-
if (event.errorCount !== null && event.errorCount > 0) {
236-
cov.errorRate = (cov.errorRate * (cov.totalMessages - 1) + 1) / cov.totalMessages;
237-
}
190+
const sector = hasValidTarget ? bearingToSector(brng) : -1;
191+
const hasError = event.errorCount !== null && event.errorCount > 0;
192+
193+
for (const sw of this.stationWindows) {
194+
sw.record(
195+
event.stationId,
196+
sector,
197+
distance,
198+
event.signalLevel,
199+
hasValidTarget,
200+
hasError,
201+
);
238202
}
239203
}
240204

@@ -288,19 +252,22 @@ export class CoverageStore {
288252
return results;
289253
}
290254

291-
/** Get station coverage data for a specific station and window */
255+
/** Get station coverage data for a specific station and window (sliding window scoped) */
292256
getStationCoverage(stationId: number, windowName: string): StationCoverage | null {
293-
return this.stationCoverage.get(stationId)?.get(windowName) ?? null;
257+
const wi = WINDOW_CONFIGS.findIndex(c => c.name === windowName);
258+
if (wi === -1) return null;
259+
return this.stationWindows[wi].getStationCoverage(stationId);
294260
}
295261

296262
/** Get all stations with coverage data for a given window */
297263
getAllStationCoverage(windowName: string): StationCoverage[] {
264+
const wi = WINDOW_CONFIGS.findIndex(c => c.name === windowName);
265+
if (wi === -1) return [];
266+
const sw = this.stationWindows[wi];
298267
const results: StationCoverage[] = [];
299-
for (const [, windows] of this.stationCoverage) {
300-
const cov = windows.get(windowName);
301-
if (cov && cov.totalMessages > 0) {
302-
results.push(cov);
303-
}
268+
for (const id of sw.activeStationIds()) {
269+
const cov = sw.getStationCoverage(id);
270+
if (cov && cov.totalMessages > 0) results.push(cov);
304271
}
305272
return results;
306273
}
@@ -360,7 +327,7 @@ export class CoverageStore {
360327
eventsRejected: this.eventsRejected,
361328
eventsWithPosition: this.eventsWithPosition,
362329
eventsWithoutPosition: this.eventsWithoutPosition,
363-
stationsTracked: this.stationCoverage.size,
330+
stationsTracked: this.stationWindows.length > 0 ? this.stationWindows[2].activeStationIds().size : 0,
364331
activeCells,
365332
};
366333
}
@@ -371,6 +338,7 @@ export class CoverageStore {
371338
window.destroy();
372339
}
373340
}
341+
for (const sw of this.stationWindows) sw.destroy();
374342
this.rateLimiter.destroy();
375343
}
376344
}

0 commit comments

Comments
 (0)