Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions frontend/src/features/map/MapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
trackFeatureCollection,
} from "../../map/layers/recordLayers";
import { darkStyle } from "../../map/style/darkStyle";
import { visibleTracks } from "../../state/selectors";
import { isLayerVisible, useStore } from "../../state/store";

const TRACK_SOURCE = "aether-tracks";
Expand All @@ -31,8 +32,14 @@ export function MapView() {
const tracks = useStore((s) => s.live.tracks);
const features = useStore((s) => s.live.features);
const layerVisible = useStore((s) => s.layerVisible);

const trackFc = useMemo(() => trackFeatureCollection(tracks), [tracks]);
const provenanceFilter = useStore((s) => s.provenanceFilter);

// Provenance-filtered tracks leave the GeoJSON source entirely when hidden, so
// "collapse to local-only" removes them from the map (PRD §16.5). Display only.
const trackFc = useMemo(
() => trackFeatureCollection(visibleTracks(tracks, provenanceFilter)),
[tracks, provenanceFilter],
);
const featureFc = useMemo(() => featureFeatureCollection(features), [features]);

// Initialize the map once.
Expand Down
32 changes: 31 additions & 1 deletion frontend/src/features/sources/LayerControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ import {
featurePresentation,
trackPresentation,
} from "../../map/presentationRegistry";
import { useStore } from "../../state/store";
import { useStore, type ProvenanceFilter } from "../../state/store";

// The flagship "collapse to local-only" control (PRD §8.2, §16.5). Display only —
// it filters which tracks render, never what the backend ingests or fuses.
const PROVENANCE_OPTIONS: { value: ProvenanceFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "local", label: "Local RF" },
{ value: "network", label: "Network" },
];

export function LayerControl() {
// Counts only depend on tracks + features; key the memo on those so a status
Expand Down Expand Up @@ -39,10 +47,32 @@ export function LayerControl() {
}, [tracks, features]);

const visibility = useStore((s) => s.layerVisible);
const provenanceFilter = useStore((s) => s.provenanceFilter);
const setProvenanceFilter = useStore((s) => s.setProvenanceFilter);

return (
<section className="panel-section" aria-label="Layers and filters">
<h2>Layers</h2>

<div
className="provenance-filter"
role="radiogroup"
aria-label="Provenance filter"
>
{PROVENANCE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={provenanceFilter === opt.value}
className={provenanceFilter === opt.value ? "active" : ""}
onClick={() => setProvenanceFilter(opt.value)}
>
{opt.label}
</button>
))}
</div>

{rows.length === 0 && <p className="muted">No layers active yet.</p>}
<ul className="layer-list">
{rows.map((r) => {
Expand Down
118 changes: 118 additions & 0 deletions frontend/src/features/tracks/TrackList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TrackList } from "./TrackList";
import { emptyState } from "../../state/liveState";
import { useStore, type ProvenanceFilter } from "../../state/store";
import type { TrackRecord } from "../../types/records";

// jsdom client render: zustand's server snapshot is memoized at store creation,
// so renderToStaticMarkup would read stale empty state. The test environment is
// jsdom, so we render on a real DOM node and read innerHTML before unmounting.
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;

const NOW = "2026-06-15T00:00:00Z";

function track(
id: string,
locally_received: boolean,
over: Partial<TrackRecord> = {},
): TrackRecord {
return {
schema_version: 2,
kind: "track",
id,
source: "demo",
observed_at: NOW,
received_at: NOW,
published_at: NOW,
correlation_key: id,
provenance: [],
tags: [],
attributes: {},
track_type: "aircraft",
label: id,
geometry: { type: "Point", coordinates: [-95, 40] },
altitude_m: null,
speed_mps: null,
heading_deg: null,
vertical_rate_mps: null,
locally_received,
classification: null,
valid_until: null,
predicted: false,
...over,
};
}

function setTracks(tracks: TrackRecord[], filter: ProvenanceFilter = "all") {
const live = emptyState();
live.tracks = new Map(tracks.map((t) => [t.id, t]));
useStore.setState({ live, provenanceFilter: filter });
}

/** Render TrackList against current store state and return its HTML. */
function render(): string {
const el = document.createElement("div");
const root = createRoot(el);
act(() => {
root.render(<TrackList />);
});
const html = el.innerHTML;
act(() => {
root.unmount();
});
return html;
}

const FUSED = track("aircraft:icao:demo01", true, {
label: "DEMO-FUSE",
attributes: {
fusion: {
active_source: "local_adsb",
contributors: [
{ source: "demo", local_rf: true, observed_at: NOW, freshness: "live" },
{ source: "demo-net", local_rf: false, observed_at: NOW, freshness: "live" },
],
field_sources: { geometry: "demo", speed_mps: "demo-net" },
field_freshness: { geometry: "live", speed_mps: "live" },
last_local_rf_at: NOW,
fused_count: 2,
},
},
});

describe("TrackList", () => {
beforeEach(() => {
useStore.setState({ live: emptyState(), provenanceFilter: "all" });
});
afterEach(() => {
useStore.setState({ live: emptyState(), provenanceFilter: "all" });
});

it("shows a contributor badge and LOCAL provenance for a fused track", () => {
setTracks([FUSED]);
const html = render();
expect(html).toContain("×2"); // contributor badge for fused_count > 1
expect(html).toContain("LOCAL"); // locally_received drives the prov badge
expect(html).toContain("DEMO-FUSE");
});

it("changes the N of M count when the provenance filter changes", () => {
const tracks = [track("a", true), track("b", false)];
setTracks(tracks, "all");
expect(render()).toContain("Tracks (2)");

setTracks(tracks, "local");
const localHtml = render();
expect(localHtml).toContain("Tracks (1 of 2)");
});

it("renders a track with no fusion attributes without crashing", () => {
setTracks([track("plain", false)]);
const html = render();
expect(html).toContain("NET");
expect(html).not.toContain("×"); // no contributor badge
});
});
39 changes: 32 additions & 7 deletions frontend/src/features/tracks/TrackList.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,51 @@
// Track list (condensed form of PRD §24.4). Shows current tracks with provenance
// badge (local RF vs network) and type. Full detail panel comes with selection
// in a later slice; this proves mixed track types render from live state.
// badge (local RF vs network) and type. When the provenance filter is active the
// header reads "Tracks N of M" so the operator sees what's hidden. A fused track
// (more than one contributing source) gets a ×N contributor badge whose tooltip
// names the sources, the active source, and when the operator's own antenna last
// heard it (PRD §8.1, §11.4). Full detail panel comes with selection later.

import { useMemo } from "react";
import { trackPresentation } from "../../map/presentationRegistry";
import { visibleTracks } from "../../state/selectors";
import { useStore } from "../../state/store";
import { fusionMeta, type TrackRecord } from "../../types/records";

function fusionTooltip(track: TrackRecord): string | undefined {
const meta = fusionMeta(track);
if (!meta) return undefined;
const sources = meta.contributors.map((c) => c.source).join(", ");
const lastLocal = meta.last_local_rf_at ?? "never";
return `Sources: ${sources}\nActive: ${meta.active_source}\nLast local RF: ${lastLocal}`;
}
Comment on lines +14 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a defensive check for meta.contributors being null or undefined. If a malformed track record is received where contributors is missing, calling .map() directly will throw a TypeError and crash the React render tree.

Suggested change
function fusionTooltip(track: TrackRecord): string | undefined {
const meta = fusionMeta(track);
if (!meta) return undefined;
const sources = meta.contributors.map((c) => c.source).join(", ");
const lastLocal = meta.last_local_rf_at ?? "never";
return `Sources: ${sources}\nActive: ${meta.active_source}\nLast local RF: ${lastLocal}`;
}
function fusionTooltip(track: TrackRecord): string | undefined {
const meta = fusionMeta(track);
if (!meta) return undefined;
const sources = (meta.contributors ?? []).map((c) => c.source).join(", ");
const lastLocal = meta.last_local_rf_at ?? "never";
return `Sources: ${sources}\nActive: ${meta.active_source}\nLast local RF: ${lastLocal}`;
}


export function TrackList() {
// Key on the tracks Map, not the whole live object, so the list only re-sorts
// when tracks change — not on every alert/event/status frame.
// Key on the tracks Map + filter, not the whole live object, so the list only
// re-sorts when tracks change — not on every alert/event/status frame.
const trackMap = useStore((s) => s.live.tracks);
const filter = useStore((s) => s.provenanceFilter);

const total = trackMap.size;
const tracks = useMemo(
() =>
[...trackMap.values()].sort((a, b) =>
visibleTracks(trackMap, filter).sort((a, b) =>
(a.label ?? a.id).localeCompare(b.label ?? b.id),
),
[trackMap],
[trackMap, filter],
);

const heading =
filter === "all" ? `Tracks (${total})` : `Tracks (${tracks.length} of ${total})`;

return (
<section className="panel-section" aria-label="Tracks">
<h2>Tracks ({tracks.length})</h2>
<h2>{heading}</h2>
{tracks.length === 0 && <p className="muted">No tracks yet.</p>}
<ul className="track-list">
{tracks.map((t) => {
const p = trackPresentation(t);
const meta = fusionMeta(t);
const fused = meta !== undefined && meta.fused_count > 1;
return (
<li key={t.id} className="track-row">
<span className="swatch" style={{ background: p.color }} aria-hidden />
Expand All @@ -37,6 +57,11 @@ export function TrackList() {
>
{t.locally_received ? "LOCAL" : "NET"}
</span>
{fused && (
<span className="fused" title={fusionTooltip(t)}>
×{meta.fused_count}
</span>
)}
{t.predicted && <span className="predicted">pred</span>}
</li>
);
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,37 @@ ul {
font-size: 10px;
color: #c79bff;
}
.fused {
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
color: var(--text);
background: #33414f;
cursor: help;
}

/* Provenance filter: the flagship collapse-to-local-only control (PRD §8.2). */
.provenance-filter {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.provenance-filter button {
flex: 1;
font-size: 11px;
padding: 3px 6px;
border: 1px solid #33414f;
border-radius: 3px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.provenance-filter button.active {
color: var(--bg);
background: var(--text);
border-color: var(--text);
}

.feed {
display: flex;
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/map/layers/recordLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
featurePresentation,
trackPresentation,
} from "../presentationRegistry";
import { fusionMeta } from "../../types/records";
import type {
GeoFeatureRecord,
GeoJSONGeometry,
Expand All @@ -26,6 +27,10 @@ export interface MapFeatureProps {
locallyReceived: boolean;
predicted: boolean;
subtype: string;
/** Fusion headline source (PRD §8.1); empty when the track isn't fused. */
activeSource: string;
/** Number of contributing sources (1 when not fused). */
fusedCount: number;
}

export interface MapFeature {
Expand All @@ -39,14 +44,21 @@ export interface FeatureCollection {
features: MapFeature[];
}

/** Point features for all tracks that currently have a position. */
/** Point features for tracks that currently have a position.
*
* Accepts any iterable of tracks, so a caller can pass a provenance-filtered list
* (see `visibleTracks`) — a hidden track simply isn't in the iterable, so it
* leaves the GeoJSON source entirely (PRD §16.5). Display only; never changes
* ingestion.
*/
export function trackFeatureCollection(
tracks: Map<string, TrackRecord>,
tracks: Iterable<TrackRecord>,
): FeatureCollection {
const features: MapFeature[] = [];
for (const track of tracks.values()) {
for (const track of tracks) {
if (!track.geometry) continue;
const p = trackPresentation(track);
const meta = fusionMeta(track);
features.push({
type: "Feature",
geometry: track.geometry,
Expand All @@ -62,6 +74,8 @@ export function trackFeatureCollection(
locallyReceived: track.locally_received,
predicted: track.predicted,
subtype: track.track_type,
activeSource: meta?.active_source ?? "",
fusedCount: meta?.fused_count ?? 1,
},
});
}
Expand Down Expand Up @@ -90,6 +104,8 @@ export function featureFeatureCollection(
locallyReceived: false,
predicted: false,
subtype: feat.feature_type,
activeSource: "",
fusedCount: 1,
},
});
}
Expand Down
Loading
Loading