FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
LinkVisualizer.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4import { Box, Tooltip, Typography } from '@mui/material';
5import { Html, Line } from '@react-three/drei';
6import { useFrame } from '@react-three/fiber';
7import { invoke } from '@tauri-apps/api/core';
8import { useEffect, useMemo, useRef, useState } from 'react';
9import * as THREE from 'three';
10import {
11 calculateInterpolatedPosition,
12 Platform,
13 useScenarioStore,
14} from '@/stores/scenarioStore';
15import { useSimulationProgressStore } from '@/stores/simulationProgressStore';
16import { fersColors } from '@/theme';
17
18const TYPE_MAP = ['monostatic', 'illuminator', 'scattered', 'direct'] as const;
19const QUALITY_MAP = ['strong', 'weak'] as const;
20const COLORS = {
21 monostatic: {
22 // Strong monostatic return (received power above noise floor)
23 strong: fersColors.link.monostatic.strong,
24 // Weak monostatic return (received power below noise floor - rendered as transparent "ghost" line)
25 weak: fersColors.link.monostatic.weak,
26 },
27 // Shows power density at the target in dBW/m²
28 illuminator: fersColors.link.illuminator,
29 // Shows received power in dBm (Rendered as transparent "ghost" line if signal is below noise floor)
30 scattered: fersColors.link.scattered,
31 // Shows direct interference/leakage power in dBm
32 direct: fersColors.link.direct,
33};
34
35// Metadata only - no Vector3 positions here.
36// Positions are derived at 60FPS during render.
37interface LinkMetadata {
38 link_type: 'monostatic' | 'illuminator' | 'scattered' | 'direct';
39 quality: 'strong' | 'weak';
40 label: string;
41 source_id: string;
42 dest_id: string;
43 origin_id: string;
44}
45
46// Derived object used for actual rendering
47interface RenderableLink extends LinkMetadata {
48 start: THREE.Vector3;
49 end: THREE.Vector3;
50 distance: number;
51}
52
53// Define the shape coming from Rust
54interface RustVisualLink {
55 link_type: number;
56 quality: number;
57 label: string;
58 source_id: string;
59 dest_id: string;
60 origin_id: string;
61}
62
63// Helper to determine color based on link type and quality
64const getLinkColor = (link: LinkMetadata) => {
65 if (link.link_type === 'monostatic') {
66 return link.quality === 'strong'
67 ? COLORS.monostatic.strong
68 : COLORS.monostatic.weak;
69 } else if (link.link_type === 'illuminator') {
70 return COLORS.illuminator;
71 } else if (link.link_type === 'scattered') {
72 return COLORS.scattered;
73 } else if (link.link_type === 'direct') {
74 return COLORS.direct;
75 }
76 return '#ffffff';
77};
78
79function LinkLine({ link }: { link: RenderableLink }) {
80 const points = useMemo(
81 () => [link.start, link.end] as [THREE.Vector3, THREE.Vector3],
82 [link.start, link.end]
83 );
84
85 const color = getLinkColor(link);
86
87 // Ghost Line Logic:
88 // If the link is geometrically valid but radiometrically weak (Sub-Noise),
89 // render it transparently to indicate "possibility" without "detection".
90 const isGhost =
91 (link.link_type === 'monostatic' || link.link_type === 'scattered') &&
92 link.quality === 'weak';
93
94 const opacity = isGhost ? 0.1 : 1.0;
95
96 return (
97 <Line
98 points={points}
99 color={color}
100 lineWidth={1.5}
101 transparent
102 opacity={opacity}
103 />
104 );
105}
106
107function LabelItem({ link, color }: { link: RenderableLink; color: string }) {
108 return (
109 <Tooltip
110 arrow
111 placement="top"
112 title={
113 <Box sx={{ p: 0.5 }}>
114 <Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
115 Link Details
116 </Typography>
117 <Typography variant="caption" display="block">
118 <b>Path Segment:</b> {link.source_id} → {link.dest_id}
119 </Typography>
120 {link.link_type === 'scattered' && (
121 <Typography variant="caption" display="block">
122 <b>Illuminator:</b> {link.origin_id}
123 </Typography>
124 )}
125 <Typography variant="caption" display="block">
126 <b>Type:</b> {link.link_type}
127 </Typography>
128 <Typography variant="caption" display="block">
129 <b>Distance:</b> {(link.distance / 1000).toFixed(2)} km
130 </Typography>
131 <Typography variant="caption" display="block">
132 <b>Value:</b> {link.label}
133 </Typography>
134 </Box>
135 }
136 >
137 <div
138 style={{
139 background: fersColors.background.overlay,
140 color: color,
141 padding: '2px 6px',
142 borderRadius: '4px',
143 fontSize: '10px',
144 fontFamily: 'monospace',
145 whiteSpace: 'nowrap',
146 border: `1px solid ${color}`,
147 boxShadow: `0 0 4px ${color}20`,
148 userSelect: 'none',
149 cursor: 'help',
150 pointerEvents: 'auto',
151 }}
152 >
153 {link.label}
154 </div>
155 </Tooltip>
156 );
157}
158
159// Component responsible for rendering a stack of labels at a specific 3D position
160function LabelCluster({
161 position,
162 links,
163}: {
164 position: THREE.Vector3;
165 links: RenderableLink[];
166}) {
167 return (
168 <Html position={position} center zIndexRange={[100, 0]}>
169 <div
170 style={{
171 display: 'flex',
172 flexDirection: 'column',
173 gap: '2px',
174 alignItems: 'center',
175 pointerEvents: 'none',
176 }}
177 >
178 {links.map((link, i) => {
179 const color = getLinkColor(link);
180 return <LabelItem key={i} link={link} color={color} />;
181 })}
182 </div>
183 </Html>
184 );
185}
186
187/**
188 * Renders radio frequency links between platforms.
189 *
190 * Performance Design:
191 * 1. Metadata Fetching: Throttled to ~10 FPS. Fetches connectivity status and text labels.
192 * 2. Geometry Rendering: Runs at 60 FPS (driven by store.currentTime).
193 *
194 * This ensures the lines "stick" to platforms smoothly while avoiding FFI/Text updates
195 * overload on every frame.
196 */
197export default function LinkVisualizer() {
198 const currentTime = useScenarioStore((state) => state.currentTime);
199 const platforms = useScenarioStore((state) => state.platforms);
200 const visibility = useScenarioStore((state) => state.visibility);
201 const isSimulating = useSimulationProgressStore(
202 (state) => state.isSimulating
203 );
204 const isGeneratingKml = useSimulationProgressStore(
205 (state) => state.isGeneratingKml
206 );
207 const {
208 showLinkLabels,
209 showLinkMonostatic,
210 showLinkIlluminator,
211 showLinkScattered,
212 showLinkDirect,
213 } = visibility;
214
215 // Store only the metadata (connectivity/labels), not positions
216 const [linkMetadata, setLinkMetadata] = useState<LinkMetadata[]>([]);
217
218 // Throttle control
219 const lastFetchTimeRef = useRef<number>(0);
220 const isFetchingRef = useRef<boolean>(false);
221 const isMountedRef = useRef<boolean>(true);
222 // Updated to 16ms to target 60FPS updates during playback
223 const FETCH_INTERVAL_MS = 16;
224
225 // Track component mount status to prevent setting state on unmounted component
226 useEffect(() => {
227 isMountedRef.current = true;
228 return () => {
229 isMountedRef.current = false;
230 };
231 }, []);
232
233 // Clear stale links when a backend operation holds the mutex
234 useEffect(() => {
235 if (isSimulating || isGeneratingKml) {
236 setLinkMetadata([]);
237 }
238 }, [isSimulating, isGeneratingKml]);
239
240 // Build a lookup map: Component Name -> Parent Platform
241 const componentToPlatform = useMemo(() => {
242 const map = new Map<string, Platform>();
243 platforms.forEach((p) => {
244 // Map platform components (Tx, Rx, Tgt) to the platform
245 p.components.forEach((c) => {
246 map.set(c.id, p);
247 if (c.type === 'monostatic') {
248 map.set(c.txId, p);
249 map.set(c.rxId, p);
250 }
251 });
252 });
253 return map;
254 }, [platforms]);
255
256 // Reset throttle when the scenario structure changes
257 useEffect(() => {
258 lastFetchTimeRef.current = 0;
259 }, [componentToPlatform]);
260
261 // REPLACED: useFrame loop handles fetching instead of useEffect.
262 // This prevents the cleanup-cancellation race condition where rapid
263 // timeline updates would cancel the fetch request before it returned.
264 useFrame(() => {
265 // Access store state imperatively to avoid dependency staleness
266 const state = useScenarioStore.getState();
267 const simState = useSimulationProgressStore.getState();
268
269 // 1. Concurrency & Sync Checks
270 if (
271 simState.isSimulating ||
272 simState.isGeneratingKml ||
273 state.isBackendSyncing ||
274 isFetchingRef.current
275 ) {
276 return;
277 }
278
279 const now = Date.now();
280
281 // 2. Throttle Check
282 if (
283 lastFetchTimeRef.current > 0 &&
284 now - lastFetchTimeRef.current < FETCH_INTERVAL_MS
285 ) {
286 return;
287 }
288
289 // 3. Perform Fetch
290 const fetchLinks = async () => {
291 try {
292 isFetchingRef.current = true;
293 lastFetchTimeRef.current = Date.now();
294
295 // Use the imperative time from the store for the most recent value
296 const links = await invoke<RustVisualLink[]>(
297 'get_preview_links',
298 {
299 time: state.currentTime,
300 }
301 );
302
303 // Only update state if component is still mounted
304 if (isMountedRef.current) {
305 const reconstructedLinks: LinkMetadata[] = links.map(
306 (l) => ({
307 link_type: TYPE_MAP[l.link_type],
308 quality: QUALITY_MAP[l.quality],
309 label: l.label,
310 source_id: l.source_id,
311 dest_id: l.dest_id,
312 origin_id: l.origin_id,
313 })
314 );
315 setLinkMetadata(reconstructedLinks);
316 }
317 } catch (e) {
318 console.error('Link preview error:', e);
319 } finally {
320 // Release lock
321 if (isMountedRef.current) {
322 isFetchingRef.current = false;
323 }
324 }
325 };
326
327 void fetchLinks();
328 });
329
330 // 2. High-Frequency Geometry Calculation (Runs every render/frame)
331 const { clusters, flatLinks } = useMemo(() => {
332 const calculatedLinks: RenderableLink[] = [];
333 const clusterMap = new Map<
334 string,
335 { position: THREE.Vector3; links: RenderableLink[] }
336 >();
337
338 linkMetadata.forEach((meta) => {
339 // Filter by type
340 if (meta.link_type === 'monostatic' && !showLinkMonostatic) return;
341 if (meta.link_type === 'illuminator' && !showLinkIlluminator)
342 return;
343 if (meta.link_type === 'scattered' && !showLinkScattered) return;
344 if (meta.link_type === 'direct' && !showLinkDirect) return;
345
346 const sourcePlat = componentToPlatform.get(meta.source_id);
347 const destPlat = componentToPlatform.get(meta.dest_id);
348
349 if (sourcePlat && destPlat) {
350 const startPos = calculateInterpolatedPosition(
351 sourcePlat,
352 currentTime
353 );
354 const endPos = calculateInterpolatedPosition(
355 destPlat,
356 currentTime
357 );
358 const dist = startPos.distanceTo(endPos);
359
360 const renderLink: RenderableLink = {
361 ...meta,
362 start: startPos,
363 end: endPos,
364 distance: dist,
365 };
366
367 calculatedLinks.push(renderLink);
368
369 // Clustering logic for labels - only if labels are enabled
370 if (showLinkLabels) {
371 const mid = startPos.clone().lerp(endPos, 0.5);
372 // Round keys to cluster nearby labels
373 const key = `${mid.x.toFixed(1)}_${mid.y.toFixed(1)}_${mid.z.toFixed(1)}`;
374
375 if (!clusterMap.has(key)) {
376 clusterMap.set(key, { position: mid, links: [] });
377 }
378 clusterMap.get(key)!.links.push(renderLink);
379 }
380 }
381 });
382
383 return {
384 clusters: Array.from(clusterMap.values()),
385 flatLinks: calculatedLinks,
386 };
387 }, [
388 linkMetadata,
389 currentTime,
390 componentToPlatform,
391 showLinkLabels,
392 showLinkMonostatic,
393 showLinkIlluminator,
394 showLinkScattered,
395 showLinkDirect,
396 ]);
397
398 return (
399 <group>
400 {/* Render all lines individually for geometry */}
401 {flatLinks.map((link, i) => (
402 <LinkLine key={`line-${i}`} link={link} />
403 ))}
404
405 {/* Render aggregated label clusters */}
406 {clusters.map((cluster, i) => (
407 <LabelCluster
408 key={`cluster-${i}`}
409 position={cluster.position}
410 links={cluster.links}
411 />
412 ))}
413 </group>
414 );
415}