Skip to content

Commit fcfc81f

Browse files
kevinelliottclaude
andcommitted
Transport filter for polygons, stations, stats, and bearing sectors
Full transport filter support across the entire stack: - Backend: station sliding windows now key data by stationId+transportType. getStationCoverage and getAllStationCoverage accept transportFilter to return only bearing sectors from matching transport types. - Backend: /stations endpoint filters by transport type based on station source type (aiscatcher/ais → marine, rest → aircraft). - Backend: /coverage/stations/:id accepts transport query param to filter polygon bearing sector data by transport type. - Frontend: stations API call passes transport filter, so station list, stats, and coverage area all update with the filter. - Frontend: polygon fetch passes transport filter to both station list filtering and polygon detail API calls. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a83f355 commit fcfc81f

5 files changed

Lines changed: 90 additions & 60 deletions

File tree

map/src/components/map/CoverageMap.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@ export default function CoverageMap() {
124124
await Promise.allSettled(
125125
withPosition.map(async (station) => {
126126
try {
127+
const p = new URLSearchParams({ window: timeWindow });
128+
if (transportFilter !== 'all') p.set('transport', transportFilter);
127129
const res = await fetch(
128-
`${API_URL}/api/v1/coverage/stations/${station.id}?window=${timeWindow}`
130+
`${API_URL}/api/v1/coverage/stations/${station.id}?${p}`
129131
);
130132
if (res.ok) {
131133
const data = await res.json();
@@ -149,13 +151,15 @@ export default function CoverageMap() {
149151

150152
const fetchStations = useCallback(async () => {
151153
try {
152-
const res = await fetch(`${API_URL}/api/v1/stations?window=${timeWindow}`);
154+
const params = new URLSearchParams({ window: timeWindow });
155+
if (transportFilter !== 'all') params.set('transport', transportFilter);
156+
const res = await fetch(`${API_URL}/api/v1/stations?${params}`);
153157
if (res.ok) {
154158
const data = await res.json();
155159
setStations(data.stations ?? []);
156160
}
157161
} catch { /* retry next interval */ }
158-
}, [timeWindow, setStations]);
162+
}, [timeWindow, transportFilter, setStations]);
159163

160164
const fetchStats = useCallback(async () => {
161165
try {

service/src/aggregation/coverage-store.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export class CoverageStore {
198198
event.signalLevel,
199199
hasValidTarget,
200200
hasError,
201+
event.transportType,
201202
);
202203
}
203204
}
@@ -254,20 +255,20 @@ export class CoverageStore {
254255
}
255256

256257
/** Get station coverage data for a specific station and window (sliding window scoped) */
257-
getStationCoverage(stationId: number, windowName: string): StationCoverage | null {
258+
getStationCoverage(stationId: number, windowName: string, transportFilter?: TransportType): StationCoverage | null {
258259
const wi = WINDOW_CONFIGS.findIndex(c => c.name === windowName);
259260
if (wi === -1) return null;
260-
return this.stationWindows[wi].getStationCoverage(stationId);
261+
return this.stationWindows[wi].getStationCoverage(stationId, transportFilter);
261262
}
262263

263264
/** Get all stations with coverage data for a given window */
264-
getAllStationCoverage(windowName: string): StationCoverage[] {
265+
getAllStationCoverage(windowName: string, transportFilter?: TransportType): StationCoverage[] {
265266
const wi = WINDOW_CONFIGS.findIndex(c => c.name === windowName);
266267
if (wi === -1) return [];
267268
const sw = this.stationWindows[wi];
268269
const results: StationCoverage[] = [];
269-
for (const id of sw.activeStationIds()) {
270-
const cov = sw.getStationCoverage(id);
270+
for (const id of sw.activeStationIds(transportFilter)) {
271+
const cov = sw.getStationCoverage(id, transportFilter);
271272
if (cov && cov.totalMessages > 0) results.push(cov);
272273
}
273274
return results;
@@ -398,20 +399,24 @@ export class CoverageStore {
398399
const sw = this.stationWindows[wi];
399400
for (const s of stationData) {
400401
if (!s.stationId || !s.bearingSectors) continue;
402+
// Determine transport type from station metadata
403+
const meta = this.stationMap.get(s.stationId);
404+
const tt: TransportType = (meta?.sourceType === 'aiscatcher' || meta?.sourceType === 'ais')
405+
? 'marine' : 'aircraft';
401406
// Record each sector's data into the current slice
402407
for (let i = 0; i < 36; i++) {
403408
const dist = s.bearingSectors[i] ?? 0;
404409
const count = s.sectorMessageCounts?.[i] ?? 0;
405410
if (dist > 0 || count > 0) {
406411
for (let j = 0; j < count; j++) {
407-
sw.record(s.stationId, i, dist, s.sectorAvgLevels?.[i] ?? null, true, false);
412+
sw.record(s.stationId, i, dist, s.sectorAvgLevels?.[i] ?? null, true, false, tt);
408413
}
409414
}
410415
}
411416
// Record messages without position
412417
const noPos = (s.totalMessages ?? 0) - (s.messagesWithPosition ?? 0);
413418
for (let j = 0; j < noPos; j++) {
414-
sw.record(s.stationId, -1, 0, s.avgLevel ?? null, false, false);
419+
sw.record(s.stationId, -1, 0, s.avgLevel ?? null, false, false, tt);
415420
}
416421
}
417422
}

service/src/aggregation/station-window.ts

Lines changed: 50 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,20 @@
11
import type { WindowConfig, StationCoverage } from '../types/coverage.js';
2+
import type { TransportType } from '../types/events.js';
23

3-
/** Per-station sector data stored in one time slice */
4+
/** Per-station+transport sector data stored in one time slice */
45
interface StationSliceData {
5-
/** Max distance per 10-degree sector (36 sectors) */
66
sectorMaxDist: Float64Array;
7-
/** Message count per sector */
87
sectorMsgCount: Uint32Array;
9-
/** Sum of signal levels per sector (for averaging) */
108
sectorLevelSum: Float64Array;
11-
/** Total messages (with and without position) */
129
totalMessages: number;
13-
/** Messages that had target position */
1410
messagesWithPosition: number;
15-
/** Sum of signal levels for overall average */
1611
totalLevelSum: number;
17-
/** Count of messages with signal level data */
1812
levelCount: number;
19-
/** Count of messages with errors */
2013
errorCount: number;
14+
transportType: TransportType;
2115
}
2216

23-
function createEmptySliceData(): StationSliceData {
17+
function createEmptySliceData(transportType: TransportType): StationSliceData {
2418
return {
2519
sectorMaxDist: new Float64Array(36),
2620
sectorMsgCount: new Uint32Array(36),
@@ -30,15 +24,21 @@ function createEmptySliceData(): StationSliceData {
3024
totalLevelSum: 0,
3125
levelCount: 0,
3226
errorCount: 0,
27+
transportType,
3328
};
3429
}
3530

36-
type Slice = Map<number, StationSliceData>; // stationId -> data
31+
/** Composite key: stationId + transportType */
32+
function sliceKey(stationId: number, transportType: TransportType): string {
33+
return `${stationId}:${transportType}`;
34+
}
35+
36+
type Slice = Map<string, StationSliceData>;
3737

3838
/**
3939
* Sliding window for per-station bearing sector data.
40-
* Each slice stores sector data for all stations active in that period.
41-
* Aggregation across slices computes the window-scoped StationCoverage.
40+
* Data is keyed by stationId+transportType so coverage can be
41+
* filtered by transport type at query time.
4242
*/
4343
export class StationSlidingWindow {
4444
readonly config: WindowConfig;
@@ -61,20 +61,22 @@ export class StationSlidingWindow {
6161
this.slices[this.currentIndex].clear();
6262
}
6363

64-
/** Record a reception event for a station in the current slice */
64+
/** Record a reception event */
6565
record(
6666
stationId: number,
6767
sector: number,
6868
distance: number,
6969
signalLevel: number | null,
7070
hasPosition: boolean,
7171
hasError: boolean,
72+
transportType: TransportType = 'aircraft',
7273
): void {
74+
const key = sliceKey(stationId, transportType);
7375
const slice = this.slices[this.currentIndex];
74-
let data = slice.get(stationId);
76+
let data = slice.get(key);
7577
if (!data) {
76-
data = createEmptySliceData();
77-
slice.set(stationId, data);
78+
data = createEmptySliceData(transportType);
79+
slice.set(key, data);
7880
}
7981

8082
data.totalMessages++;
@@ -100,8 +102,11 @@ export class StationSlidingWindow {
100102
}
101103
}
102104

103-
/** Aggregate all slices into per-station coverage for the full window */
104-
getStationCoverage(stationId: number): StationCoverage | null {
105+
/**
106+
* Aggregate all slices into per-station coverage.
107+
* @param transportFilter - if set, only include data from matching transport types
108+
*/
109+
getStationCoverage(stationId: number, transportFilter?: TransportType): StationCoverage | null {
105110
const result: StationCoverage = {
106111
stationId,
107112
bearingSectors: new Float64Array(36),
@@ -122,32 +127,32 @@ export class StationSlidingWindow {
122127
const sectorLevelSums = new Float64Array(36);
123128

124129
for (const slice of this.slices) {
125-
const data = slice.get(stationId);
126-
if (!data) continue;
127-
128-
result.totalMessages += data.totalMessages;
129-
result.messagesWithPosition += data.messagesWithPosition;
130-
totalLevelSum += data.totalLevelSum;
131-
levelCount += data.levelCount;
132-
errorCount += data.errorCount;
133-
134-
for (let i = 0; i < 36; i++) {
135-
// Max distance across all slices per sector
136-
if (data.sectorMaxDist[i] > result.bearingSectors[i]) {
137-
result.bearingSectors[i] = data.sectorMaxDist[i];
138-
}
139-
result.sectorMessageCounts[i] += data.sectorMsgCount[i];
140-
sectorLevelSums[i] += data.sectorLevelSum[i];
141-
142-
if (data.sectorMaxDist[i] > result.maxDistance) {
143-
result.maxDistance = data.sectorMaxDist[i];
130+
// Check all matching keys for this station
131+
for (const [key, data] of slice) {
132+
if (!key.startsWith(`${stationId}:`)) continue;
133+
if (transportFilter && data.transportType !== transportFilter) continue;
134+
135+
result.totalMessages += data.totalMessages;
136+
result.messagesWithPosition += data.messagesWithPosition;
137+
totalLevelSum += data.totalLevelSum;
138+
levelCount += data.levelCount;
139+
errorCount += data.errorCount;
140+
141+
for (let i = 0; i < 36; i++) {
142+
if (data.sectorMaxDist[i] > result.bearingSectors[i]) {
143+
result.bearingSectors[i] = data.sectorMaxDist[i];
144+
}
145+
result.sectorMessageCounts[i] += data.sectorMsgCount[i];
146+
sectorLevelSums[i] += data.sectorLevelSum[i];
147+
if (data.sectorMaxDist[i] > result.maxDistance) {
148+
result.maxDistance = data.sectorMaxDist[i];
149+
}
144150
}
145151
}
146152
}
147153

148154
if (result.totalMessages === 0) return null;
149155

150-
// Compute averages
151156
result.avgLevel = levelCount > 0 ? totalLevelSum / levelCount : 0;
152157
result.errorRate = result.totalMessages > 0 ? errorCount / result.totalMessages : 0;
153158

@@ -161,12 +166,14 @@ export class StationSlidingWindow {
161166
return result;
162167
}
163168

164-
/** Get all station IDs that have data in this window */
165-
activeStationIds(): Set<number> {
169+
/** Get all unique station IDs, optionally filtered by transport type */
170+
activeStationIds(transportFilter?: TransportType): Set<number> {
166171
const ids = new Set<number>();
167172
for (const slice of this.slices) {
168-
for (const id of slice.keys()) {
169-
ids.add(id);
173+
for (const [key, data] of slice) {
174+
if (transportFilter && data.transportType !== transportFilter) continue;
175+
const stationId = parseInt(key.split(':')[0], 10);
176+
ids.add(stationId);
170177
}
171178
}
172179
return ids;

service/src/api/routes/coverage.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ coverageRoutes.get('/stations/:id', (c) => {
4545
const store = c.get('store');
4646
const stationId = parseInt(c.req.param('id'), 10);
4747
const windowName = c.req.query('window') ?? '1h';
48+
const transportRaw = c.req.query('transport');
49+
const validTransports = ['aircraft', 'marine', 'sonde', 'space'];
50+
const transportFilter = transportRaw && validTransports.includes(transportRaw)
51+
? transportRaw as TransportType
52+
: undefined;
4853

4954
if (!Number.isInteger(stationId) || stationId < 1) {
5055
return c.json({ error: 'Invalid station ID' }, 400);
@@ -55,16 +60,16 @@ coverageRoutes.get('/stations/:id', (c) => {
5560
return c.json({ error: 'Station not found' }, 404);
5661
}
5762

58-
// Window-scoped message counts from H3 sliding windows (properly expires)
63+
// Window-scoped message counts from H3 sliding windows
5964
const windowCounts = store.getStationMessageCounts(windowName);
6065
const windowData = windowCounts.get(stationId);
6166

6267
if (!windowData || windowData.messageCount === 0) {
6368
return c.json({ error: 'No coverage data for this station in this window' }, 404);
6469
}
6570

66-
// Bearing sector data (cumulative best-observed range per direction)
67-
const coverage = store.getStationCoverage(stationId, windowName);
71+
// Bearing sector data filtered by transport type
72+
const coverage = store.getStationCoverage(stationId, windowName, transportFilter);
6873

6974
const polygon = coverage
7075
? buildCoveragePolygon(station.latitude, station.longitude, coverage)

service/src/api/routes/stations.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
import { Hono } from 'hono';
22
import type { AppContext } from '../server.js';
3+
import type { TransportType } from '../../types/events.js';
34

45
export const stationRoutes = new Hono<AppContext>();
56

7+
const MARINE_SOURCE_TYPES = new Set(['aiscatcher', 'ais']);
8+
69
/** GET /api/v1/stations — All stations with window-scoped coverage summaries */
710
stationRoutes.get('/', (c) => {
811
const store = c.get('store');
912
const windowName = c.req.query('window') ?? '1h';
1013
const activeOnly = c.req.query('active') !== 'false';
14+
const transportRaw = c.req.query('transport');
15+
const validTransports = ['aircraft', 'marine', 'sonde', 'space'];
16+
const transportFilter = transportRaw && validTransports.includes(transportRaw)
17+
? transportRaw as TransportType
18+
: undefined;
1119

12-
// Get window-scoped message counts from H3 sliding windows (properly expires old data)
1320
const windowCounts = store.getStationMessageCounts(windowName);
14-
15-
// Get full coverage data (for bearing sectors, signal levels, etc.)
16-
const allCoverage = store.getAllStationCoverage(windowName);
21+
const allCoverage = store.getAllStationCoverage(windowName, transportFilter);
1722
const coverageByStation = new Map(allCoverage.map((cov) => [cov.stationId, cov]));
1823

1924
const stations = [];
@@ -24,6 +29,10 @@ stationRoutes.get('/', (c) => {
2429
const meta = store.stationMap.get(stationId);
2530
if (!meta) continue;
2631

32+
// Filter stations by transport type based on their source type
33+
if (transportFilter === 'aircraft' && MARINE_SOURCE_TYPES.has(meta.sourceType)) continue;
34+
if (transportFilter === 'marine' && !MARINE_SOURCE_TYPES.has(meta.sourceType)) continue;
35+
2736
const cov = coverageByStation.get(stationId);
2837

2938
stations.push({

0 commit comments

Comments
 (0)