round out the review fixes: chunk reload, spiderfy reset, search debounce

- a failed lazy chunk now offers 'reload' (React.lazy caches the
  rejection, so remounting could never recover Map/Stats)
- an open spiderfy fan closes on region/type-filter change instead of
  showing leaves from the previous dataset
- map settings persistence moved out of the setState updater
- observer search debounces 250ms and shows 'Search failed' instead of
  'No matches' on errors
- packet-detail staleTime drops to 30s so a reopened analyzer picks up
  new observations
- leaving Stats clears its statsTab/observerId/range URL params
- ws types now match the wire (batteryMv omitted not null, scopes can
  be null, lastObservationId isn't sent today); drop the unused
  microToDeg shim now that nothing imports it
This commit is contained in:
MrAlders0n committed 2026-06-10 07:08:13 -04:00
1 parent ad4e68f817
commit 4d6d76e451
8 files changed
+54 -17

No files matched your search

+10 -2
View File
@@ -135,18 +135,20 @@ function AppInner() {
// packet analyzer shown as a modal over the node panel (clicking a node's observation row)
const [overlayPacketHash, setOverlayPacketHash] = useState<string | null>(null);
// short staleTime: observations keep accruing, so reopening the analyzer should show them
// instead of a snapshot frozen at first open
const { data: analyzerDetail, isLoading: analyzerLoading } = useQuery({
queryKey: ["packet-detail", analyzerHash],
queryFn: () => getPacketDetail(analyzerHash!),
enabled: !!analyzerHash,
staleTime: Infinity,
staleTime: 30_000,
});
const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = useQuery({
queryKey: ["packet-detail", overlayPacketHash],
queryFn: () => getPacketDetail(overlayPacketHash!),
enabled: !!overlayPacketHash,
staleTime: Infinity,
staleTime: 30_000,
});
const handleAnalyze = useCallback((hash: string | null) => {
@@ -168,6 +170,12 @@ function AppInner() {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", tab);
// stats sub-state shouldn't haunt the URL on other tabs
if (tab !== "Stats") {
next.delete("statsTab");
next.delete("observerId");
next.delete("range");
}
return next;
});
};
+1 -1
View File
@@ -176,7 +176,7 @@ export class WsManager {
this.sendSubscribe();
if (isReconnect) {
// we were dark during the outage — synthesize a lag notice so live views heal the gap
const notice: WsLagged = { v: 1, type: "lagged", droppedCount: 0, since: this.lastEventTimestamp, lastObservationId: 0 };
const notice: WsLagged = { v: 1, type: "lagged", droppedCount: 0, since: this.lastEventTimestamp };
for (const handler of this.laggedHandlers) {
handler(notice);
}
+5 -2
View File
@@ -19,15 +19,18 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.error) {
if (this.props.fallback) return this.props.fallback;
// React.lazy caches a rejected import, so remounting can't recover a failed chunk load
// (e.g. a deploy swapped the hashed assets out from under us) — only a real reload can
const chunkFailure = /dynamically imported module|Loading chunk|error loading/i.test(this.state.error.message);
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-8 text-text-dim">
<p className="text-sm font-mono">Something went wrong rendering this view.</p>
<button
type="button"
className="text-xs font-mono underline cursor-pointer text-primary"
onClick={() => this.setState({ error: null })}
onClick={() => (chunkFailure ? window.location.reload() : this.setState({ error: null }))}
>
try again
{chunkFailure ? "reload" : "try again"}
</button>
</div>
);
+8 -5
View File
@@ -38,12 +38,15 @@ export function MapSettingsPanel({
return stored === null ? !isMobile : stored === "true";
});
const toggle = () =>
setOpen((v) => {
const next = !v;
const toggle = () => {
const next = !open;
setOpen(next);
try {
localStorage.setItem(OPEN_STORAGE_KEY, String(next));
return next;
});
} catch {
// private mode / quota — the toggle still works, just not persisted
}
};
return (
<div className="absolute top-3 left-3 z-10 w-60 max-w-[calc(100vw-1.5rem)] bg-bg-raised border border-border rounded-md shadow-lg overflow-hidden font-mono">
+1 -1
View File
@@ -96,7 +96,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError);
const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome
useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId);
useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, `${regionKey}:${typeFilter}`);
return (
<div className="relative flex flex-1 min-h-0">
+15
View File
@@ -98,8 +98,12 @@ export function useMapNodes(
clustered: boolean,
onSelectNode: (id: string) => void,
selectedNodeId: string | null,
// identity of the dataset (region + type filter); an open spiderfy fan closes when it changes,
// since its leaves were drawn from the previous dataset
resetKey = "",
) {
const geojsonRef = useRef(geojson);
const spiderRef = useRef<Spiderfy | null>(null);
const onSelectNodeRef = useRef(onSelectNode);
const selectedNodeIdRef = useRef(selectedNodeId);
const appliedClusteredRef = useRef(clustered);
@@ -338,6 +342,7 @@ export function useMapNodes(
spiderLeavesLayout: SPIDER_LEAVES_LAYOUT,
});
spider.applyTo(NODES_CLUSTER_LAYER_ID);
spiderRef.current = spider;
// @nazka/map-gl-js-spiderfy registers its cluster-click handler inside a one-shot map.once("idle").
// With 3D terrain that idle often doesn't fire before this effect re-runs, so the handler never
// attaches (clusters look unclickable) or attaches late as an orphan after cleanup. Run that
@@ -384,6 +389,7 @@ export function useMapNodes(
map.off("mouseenter", layer, setPointer);
map.off("mouseleave", layer, clearPointer);
}
spiderRef.current = null;
try {
spider.unspiderfyAll();
} catch {
@@ -392,4 +398,13 @@ export function useMapNodes(
};
// themeKey is a dep so spiderfy rebuilds and its legs + leaf icons pick up the new palette
}, [mapRef, isReady, clustered, themeKey]);
// close any open fan when the dataset identity changes — its leaves no longer exist
useEffect(() => {
try {
spiderRef.current?.unspiderfyAll();
} catch {
/* map may already be removed */
}
}, [resetKey]);
}
+10 -2
View File
@@ -29,7 +29,12 @@ function ObserverList({
}) {
const { iatas, regionKey } = useRegion();
const [query, setQuery] = useState("");
const q = query.trim();
// debounce so the server-side lookup fires once per pause, not once per keystroke
const [q, setQ] = useState("");
useEffect(() => {
const id = setTimeout(() => setQ(query.trim()), 250);
return () => clearTimeout(id);
}, [query]);
const searching = q.length > 0;
// default: top observers by activity (with count + activity bar). Searching swaps to a server-side
@@ -63,7 +68,10 @@ function ObserverList({
/>
<div className="flex flex-col gap-0.5">
{loading && <div className="py-6 text-center font-mono text-[11px] text-text-dim">{searching ? "Searching…" : "Loading…"}</div>}
{!loading && rows.length === 0 && (
{searching && search.isError && (
<div className="py-6 text-center font-mono text-[11px] text-danger">Search failed</div>
)}
{!loading && !(searching && search.isError) && rows.length === 0 && (
<div className="py-6 text-center font-mono text-[11px] text-text-dim">{searching ? "No matches" : "No observers"}</div>
)}
{rows.map((r) => {
+4 -4
View File
@@ -67,9 +67,9 @@ export interface WsObserverStatus {
iata: string;
online: boolean;
radio?: string; // compact "freq,bw,sf" string
scopes: string[];
batteryMv: number | null;
uptimeSeconds: number | null;
scopes: string[] | null; // a nil slice marshals to null
batteryMv?: number; // omitempty — absent when unknown (0)
uptimeSeconds: number;
lastStatusAt: number;
};
}
@@ -107,7 +107,7 @@ export interface WsLagged {
type: "lagged";
droppedCount: number;
since: number;
lastObservationId: number;
lastObservationId?: number; // declared in the protocol but not sent today
}
export interface WsError {