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 { useState, useEffect, useMemo, useRef } from 'react';
5import { invoke } from '@tauri-apps/api/core';
6import { Line, Html } from '@react-three/drei';
7import { useFrame } from '@react-three/fiber';
8import * as THREE from 'three';
9import {
10 useScenarioStore,
11 Platform,
12 calculateInterpolatedPosition,
13} from '@/stores/scenarioStore';
14import { Tooltip, Box, Typography } from '@mui/material';
15import { fersColors } from '@/theme';
16
17const TYPE_MAP = ['monostatic', 'illuminator', 'scattered', 'direct'] as const;
18const QUALITY_MAP = ['strong', 'weak'] as const;
19const COLORS = {
20 monostatic: {
21 // Strong monostatic return (received power above noise floor)
22 strong: fersColors.link.monostatic.strong,
23 // Weak monostatic return (received power below noise floor - rendered as transparent "ghost" line)
24 weak: fersColors.link.monostatic.weak,
25 },
26 // Shows power density at the target in dBW/m²
27 illuminator: fersColors.link.illuminator,
28 // Shows received power in dBm (Rendered as transparent "ghost" line if signal is below noise floor)
29 scattered: fersColors.link.scattered,
30 // Shows direct interference/leakage power in dBm
31 direct: fersColors.link.direct,
32};
33
34// Metadata only - no Vector3 positions here.
35// Positions are derived at 60FPS during render.
36interface LinkMetadata {
37 link_type: 'monostatic' | 'illuminator' | 'scattered' | 'direct';
38 quality: 'strong' | 'weak';
39 label: string;
40 source_name: string;
41 dest_name: string;
42 origin_name: string;
43}
44
45// Derived object used for actual rendering
46interface RenderableLink extends LinkMetadata {
47 start: THREE.Vector3;
48 end: THREE.Vector3;
49 distance: number;
50}
51
52// Define the shape coming from Rust
53interface RustVisualLink {
54 link_type: number;
55 quality: number;
56 label: string;
57 source_name: string;
58 dest_name: string;
59 origin_name: string;
60}
61
62// Helper to determine color based on link type and quality
63const getLinkColor = (link: LinkMetadata) => {
64 if (link.link_type === 'monostatic') {
65 return link.quality === 'strong'
66 ? COLORS.monostatic.strong
67 : COLORS.monostatic.weak;
68 } else if (link.link_type === 'illuminator') {
69 return COLORS.illuminator;
70 } else if (link.link_type === 'scattered') {
71 return COLORS.scattered;
72 } else if (link.link_type === 'direct') {
73 return COLORS.direct;
74 }
75 return '#ffffff';
76};
77
78function LinkLine({ link }: { link: RenderableLink }) {
79 const points = useMemo(
80 () => [link.start, link.end] as [THREE.Vector3, THREE.Vector3],
81 [link.start, link.end]
82 );
83
84 const color = getLinkColor(link);
85
86 // Ghost Line Logic:
87 // If the link is geometrically valid but radiometrically weak (Sub-Noise),
88 // render it transparently to indicate "possibility" without "detection".
89 const isGhost =
90 (link.link_type === 'monostatic' || link.link_type === 'scattered') &&
91 link.quality === 'weak';
92
93 const opacity = isGhost ? 0.1 : 1.0;
94
95 return (
96 <Line
97 points={points}
98 color={color}
99 lineWidth={1.5}
100 transparent
101 opacity={opacity}
102 />
103 );
104}
105
106function LabelItem({ link, color }: { link: RenderableLink; color: string }) {
107 return (
108 <Tooltip
109 arrow
110 placement="top"
111 title={
112 <Box sx={{ p: 0.5 }}>
113 <Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
114 Link Details
115 </Typography>
116 <Typography variant="caption" display="block">
117 <b>Path Segment:</b> {link.source_name} →{' '}
118 {link.dest_name}
119 </Typography>
120 {link.link_type === 'scattered' && (
121 <Typography variant="caption" display="block">
122 <b>Illuminator:</b> {link.origin_name}
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 {
202 showLinkLabels,
203 showLinkMonostatic,
204 showLinkIlluminator,
205 showLinkScattered,
206 showLinkDirect,
207 } = visibility;
208
209 // Store only the metadata (connectivity/labels), not positions
210 const [linkMetadata, setLinkMetadata] = useState<LinkMetadata[]>([]);
211
212 // Throttle control
213 const lastFetchTimeRef = useRef<number>(0);
214 const isFetchingRef = useRef<boolean>(false);
215 const isMountedRef = useRef<boolean>(true);
216 // Updated to 16ms to target 60FPS updates during playback
217 const FETCH_INTERVAL_MS = 16;
218
219 // Track component mount status to prevent setting state on unmounted component
220 useEffect(() => {
221 isMountedRef.current = true;
222 return () => {
223 isMountedRef.current = false;
224 };
225 }, []);
226
227 // Build a lookup map: Component Name -> Parent Platform
228 const componentToPlatform = useMemo(() => {
229 const map = new Map<string, Platform>();
230 platforms.forEach((p) => {
231 // Map platform components (Tx, Rx, Tgt) to the platform
232 p.components.forEach((c) => map.set(c.name, p));
233 });
234 return map;
235 }, [platforms]);
236
237 // Reset throttle when the scenario structure changes
238 useEffect(() => {
239 lastFetchTimeRef.current = 0;
240 }, [componentToPlatform]);
241
242 // REPLACED: useFrame loop handles fetching instead of useEffect.
243 // This prevents the cleanup-cancellation race condition where rapid
244 // timeline updates would cancel the fetch request before it returned.
245 useFrame(() => {
246 // Access store state imperatively to avoid dependency staleness
247 const state = useScenarioStore.getState();
248
249 // 1. Concurrency & Sync Checks
250 if (state.isBackendSyncing || isFetchingRef.current) {
251 return;
252 }
253
254 const now = Date.now();
255
256 // 2. Throttle Check
257 if (
258 lastFetchTimeRef.current > 0 &&
259 now - lastFetchTimeRef.current < FETCH_INTERVAL_MS
260 ) {
261 return;
262 }
263
264 // 3. Perform Fetch
265 const fetchLinks = async () => {
266 try {
267 isFetchingRef.current = true;
268 lastFetchTimeRef.current = Date.now();
269
270 // Use the imperative time from the store for the most recent value
271 const links = await invoke<RustVisualLink[]>(
272 'get_preview_links',
273 {
274 time: state.currentTime,
275 }
276 );
277
278 // Only update state if component is still mounted
279 if (isMountedRef.current) {
280 const reconstructedLinks: LinkMetadata[] = links.map(
281 (l) => ({
282 link_type: TYPE_MAP[l.link_type],
283 quality: QUALITY_MAP[l.quality],
284 label: l.label,
285 source_name: l.source_name,
286 dest_name: l.dest_name,
287 origin_name: l.origin_name,
288 })
289 );
290 setLinkMetadata(reconstructedLinks);
291 }
292 } catch (e) {
293 console.error('Link preview error:', e);
294 } finally {
295 // Release lock
296 if (isMountedRef.current) {
297 isFetchingRef.current = false;
298 }
299 }
300 };
301
302 void fetchLinks();
303 });
304
305 // 2. High-Frequency Geometry Calculation (Runs every render/frame)
306 const { clusters, flatLinks } = useMemo(() => {
307 const calculatedLinks: RenderableLink[] = [];
308 const clusterMap = new Map<
309 string,
310 { position: THREE.Vector3; links: RenderableLink[] }
311 >();
312
313 linkMetadata.forEach((meta) => {
314 // Filter by type
315 if (meta.link_type === 'monostatic' && !showLinkMonostatic) return;
316 if (meta.link_type === 'illuminator' && !showLinkIlluminator)
317 return;
318 if (meta.link_type === 'scattered' && !showLinkScattered) return;
319 if (meta.link_type === 'direct' && !showLinkDirect) return;
320
321 const sourcePlat = componentToPlatform.get(meta.source_name);
322 const destPlat = componentToPlatform.get(meta.dest_name);
323
324 if (sourcePlat && destPlat) {
325 const startPos = calculateInterpolatedPosition(
326 sourcePlat,
327 currentTime
328 );
329 const endPos = calculateInterpolatedPosition(
330 destPlat,
331 currentTime
332 );
333 const dist = startPos.distanceTo(endPos);
334
335 const renderLink: RenderableLink = {
336 ...meta,
337 start: startPos,
338 end: endPos,
339 distance: dist,
340 };
341
342 calculatedLinks.push(renderLink);
343
344 // Clustering logic for labels - only if labels are enabled
345 if (showLinkLabels) {
346 const mid = startPos.clone().lerp(endPos, 0.5);
347 // Round keys to cluster nearby labels
348 const key = `${mid.x.toFixed(1)}_${mid.y.toFixed(1)}_${mid.z.toFixed(1)}`;
349
350 if (!clusterMap.has(key)) {
351 clusterMap.set(key, { position: mid, links: [] });
352 }
353 clusterMap.get(key)!.links.push(renderLink);
354 }
355 }
356 });
357
358 return {
359 clusters: Array.from(clusterMap.values()),
360 flatLinks: calculatedLinks,
361 };
362 }, [
363 linkMetadata,
364 currentTime,
365 componentToPlatform,
366 showLinkLabels,
367 showLinkMonostatic,
368 showLinkIlluminator,
369 showLinkScattered,
370 showLinkDirect,
371 ]);
372
373 return (
374 <group>
375 {/* Render all lines individually for geometry */}
376 {flatLinks.map((link, i) => (
377 <LinkLine key={`line-${i}`} link={link} />
378 ))}
379
380 {/* Render aggregated label clusters */}
381 {clusters.map((cluster, i) => (
382 <LabelCluster
383 key={`cluster-${i}`}
384 position={cluster.position}
385 links={cluster.links}
386 />
387 ))}
388 </group>
389 );
390}