FERS 0.1.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, useThree } from '@react-three/fiber';
7import { invoke } from '@tauri-apps/api/core';
8import React, { 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 rcs: number; // RCS in m^2; negative if not applicable
45 actual_power_dbm: number; // Received power with actual RCS; -999 if not applicable
46 display_value: number; // Numeric value represented by label, in the label's unit
47}
48
49interface LinkRange {
50 powerMin: number;
51 powerMax: number;
52 rcsMin: number;
53 rcsMax: number;
54}
55
56// Derived object used for actual rendering
57interface RenderableLink extends LinkMetadata {
58 start: THREE.Vector3;
59 end: THREE.Vector3;
60 distance: number;
61 source_name: string;
62 dest_name: string;
63 origin_name: string;
64 fmcwDutyCycle: number | null;
65 range: LinkRange | null;
66}
67
68// Define the shape coming from Rust
69interface RustVisualLink {
70 link_type: number;
71 quality: number;
72 label: string;
73 source_id: string;
74 dest_id: string;
75 origin_id: string;
76 rcs: number; // RCS in m^2; negative if not applicable
77 actual_power_dbm: number; // Received power with actual RCS; -999 if not applicable
78 display_value: number; // Numeric value represented by label, in the label's unit
79}
80
81// Helper to determine color based on link type and quality
82const getLinkColor = (link: LinkMetadata) => {
83 if (link.link_type === 'monostatic') {
84 return link.quality === 'strong'
85 ? COLORS.monostatic.strong
86 : COLORS.monostatic.weak;
87 } else if (link.link_type === 'illuminator') {
88 return COLORS.illuminator;
89 } else if (link.link_type === 'scattered') {
90 return COLORS.scattered;
91 } else if (link.link_type === 'direct') {
92 return COLORS.direct;
93 }
94 return '#ffffff';
95};
96
97const getLinkValueUnit = (link: LinkMetadata) =>
98 link.link_type === 'illuminator' ? 'dBW/m²' : 'dBm';
99
100function LinkLine({ link }: { link: RenderableLink }) {
101 const points = useMemo(
102 () => [link.start, link.end] as [THREE.Vector3, THREE.Vector3],
103 [link.start, link.end]
104 );
105
106 const color = getLinkColor(link);
107
108 // Ghost Line Logic:
109 // If the link is geometrically valid but radiometrically weak (Sub-Noise),
110 // render it transparently to indicate "possibility" without "detection".
111 const isGhost =
112 (link.link_type === 'monostatic' || link.link_type === 'scattered') &&
113 link.quality === 'weak';
114
115 const opacity = isGhost ? 0.1 : 1.0;
116
117 return (
118 <Line
119 points={points}
120 color={color}
121 lineWidth={1.5}
122 transparent
123 opacity={opacity}
124 />
125 );
126}
127
128function LabelItem({ link, color }: { link: RenderableLink; color: string }) {
129 const dutyCycle = link.fmcwDutyCycle ?? 1.0;
130 const hasDutyCycledValue =
131 link.fmcwDutyCycle !== null && dutyCycle > 0 && dutyCycle < 0.9995;
132 const dutyCycleOffsetDb = hasDutyCycledValue
133 ? -10.0 * Math.log10(dutyCycle)
134 : 0.0;
135 const peakDisplayValue =
136 hasDutyCycledValue && link.display_value > -990
137 ? link.display_value + dutyCycleOffsetDb
138 : null;
139 const actualPeakPowerDbm =
140 hasDutyCycledValue && link.actual_power_dbm > -990
141 ? link.actual_power_dbm + dutyCycleOffsetDb
142 : null;
143
144 return (
145 <Tooltip
146 arrow
147 placement="top"
148 title={
149 <Box sx={{ p: 0.5 }}>
150 <Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
151 Link Details
152 </Typography>
153 <Typography variant="caption" display="block">
154 <b>Path Segment:</b> {link.source_name} →{' '}
155 {link.dest_name}
156 </Typography>
157 {link.link_type === 'scattered' && (
158 <Typography variant="caption" display="block">
159 <b>Illuminator:</b> {link.origin_name}
160 </Typography>
161 )}
162 <Typography variant="caption" display="block">
163 <b>Type:</b> {link.link_type}
164 </Typography>
165 <Typography variant="caption" display="block">
166 <b>Distance:</b> {(link.distance / 1000).toFixed(2)} km
167 </Typography>
168 <Typography variant="caption" display="block">
169 <b>
170 {hasDutyCycledValue ? 'Average Value:' : 'Value:'}
171 </b>{' '}
172 {link.label}
173 </Typography>
174 {hasDutyCycledValue && (
175 <>
176 <Typography variant="caption" display="block">
177 <b>FMCW Duty Cycle:</b>{' '}
178 {(dutyCycle * 100).toFixed(1)}%
179 </Typography>
180 {peakDisplayValue !== null && (
181 <Typography
182 variant="caption"
183 display="block"
184 sx={{ pl: 1.5, opacity: 0.7 }}
185 >
186 peak equivalent:{' '}
187 {peakDisplayValue.toFixed(1)}{' '}
188 {getLinkValueUnit(link)}
189 </Typography>
190 )}
191 </>
192 )}
193 {link.actual_power_dbm > -990 && (
194 <>
195 <Typography variant="caption" display="block">
196 <b>
197 {hasDutyCycledValue
198 ? 'Actual Average Power:'
199 : 'Actual Power:'}
200 </b>{' '}
201 {link.actual_power_dbm.toFixed(1)} dBm
202 </Typography>
203 {actualPeakPowerDbm !== null && (
204 <Typography
205 variant="caption"
206 display="block"
207 sx={{ pl: 1.5, opacity: 0.7 }}
208 >
209 actual peak: {actualPeakPowerDbm.toFixed(1)}{' '}
210 dBm
211 </Typography>
212 )}
213 {link.range &&
214 link.range.powerMin < link.range.powerMax && (
215 <Typography
216 variant="caption"
217 display="block"
218 sx={{ pl: 1.5, opacity: 0.7 }}
219 >
220 range: {link.range.powerMin.toFixed(1)}{' '}
221 to {link.range.powerMax.toFixed(1)} dBm
222 </Typography>
223 )}
224 </>
225 )}
226 {link.rcs >= 0 && (
227 <>
228 <Typography variant="caption" display="block">
229 <b>RCS:</b> {link.rcs.toFixed(2)} m^2
230 </Typography>
231 {link.range &&
232 link.range.rcsMin < link.range.rcsMax && (
233 <Typography
234 variant="caption"
235 display="block"
236 sx={{ pl: 1.5, opacity: 0.7 }}
237 >
238 range: {link.range.rcsMin.toFixed(2)} to{' '}
239 {link.range.rcsMax.toFixed(2)} m^2
240 </Typography>
241 )}
242 </>
243 )}
244 </Box>
245 }
246 >
247 <div
248 style={{
249 background: fersColors.background.overlay,
250 color: color,
251 padding: '2px 6px',
252 borderRadius: '4px',
253 fontSize: '10px',
254 fontFamily: 'monospace',
255 whiteSpace: 'nowrap',
256 border: `1px solid ${color}`,
257 boxShadow: `0 0 4px ${color}20`,
258 userSelect: 'none',
259 cursor: 'help',
260 pointerEvents: 'auto',
261 }}
262 >
263 {link.label}
264 </div>
265 </Tooltip>
266 );
267}
268
269// Component responsible for rendering a stack of labels at a specific 3D position
270function LabelCluster({
271 position,
272 links,
273 divRef,
274}: {
275 position: THREE.Vector3;
276 links: RenderableLink[];
277 divRef: React.Ref<HTMLDivElement>;
278}) {
279 return (
280 <Html position={position} center zIndexRange={[100, 0]}>
281 <div
282 ref={divRef}
283 style={{
284 display: 'flex',
285 flexDirection: 'column',
286 gap: '2px',
287 alignItems: 'center',
288 pointerEvents: 'none',
289 }}
290 >
291 {links.map((link, i) => {
292 const color = getLinkColor(link);
293 return <LabelItem key={i} link={link} color={color} />;
294 })}
295 </div>
296 </Html>
297 );
298}
299
300// Minimum screen-space separation (px) enforced between label cluster centers.
301const LABEL_OVERLAP_PX = 60;
302
303/**
304 * Renders radio frequency links between platforms.
305 *
306 * Performance Design:
307 * 1. Metadata Fetching: 60 FPS. Labels and quality update at full rate.
308 * 2. Range Tracking: Per-link min/max accumulated in a ref (no re-render cost); read in useMemo.
309 * 3. Geometry Rendering: 60 FPS (driven by store.currentTime). Lines always stick to platforms.
310 */
311export default function LinkVisualizer() {
312 const currentTime = useScenarioStore((state) => state.currentTime);
313 const platforms = useScenarioStore((state) => state.platforms);
314 const waveforms = useScenarioStore((state) => state.waveforms);
315 const visibility = useScenarioStore((state) => state.visibility);
316 const isSimulating = useSimulationProgressStore(
317 (state) => state.isSimulating
318 );
319 const isGeneratingKml = useSimulationProgressStore(
320 (state) => state.isGeneratingKml
321 );
322 const {
323 showLinkLabels,
324 showLinkMonostatic,
325 showLinkIlluminator,
326 showLinkScattered,
327 showLinkDirect,
328 } = visibility;
329
330 const { camera, gl } = useThree();
331
332 // Store only the metadata (connectivity/labels), not positions
333 const [linkMetadata, setLinkMetadata] = useState<LinkMetadata[]>([]);
334
335 // Accumulates observed min/max per link key — never causes re-renders, read in useMemo
336 const linkRangesRef = useRef<Map<string, LinkRange>>(new Map());
337
338 // Throttle control
339 const lastFetchTimeRef = useRef<number>(0);
340 const isFetchingRef = useRef<boolean>(false);
341 const isMountedRef = useRef<boolean>(true);
342 const FETCH_INTERVAL_MS = 16;
343
344 // Track component mount status to prevent setting state on unmounted component
345 useEffect(() => {
346 isMountedRef.current = true;
347 return () => {
348 isMountedRef.current = false;
349 };
350 }, []);
351
352 // Clear stale links when a backend operation holds the mutex
353 useEffect(() => {
354 if (isSimulating || isGeneratingKml) {
355 setLinkMetadata([]);
356 linkRangesRef.current.clear();
357 }
358 }, [isSimulating, isGeneratingKml]);
359
360 // Build a lookup map: Component Name -> Parent Platform
361 const componentToPlatform = useMemo(() => {
362 const map = new Map<string, Platform>();
363 platforms.forEach((p) => {
364 // Map platform components (Tx, Rx, Tgt) to the platform
365 p.components.forEach((c) => {
366 map.set(c.id, p);
367 if (c.type === 'monostatic') {
368 map.set(c.txId, p);
369 map.set(c.rxId, p);
370 }
371 });
372 });
373 return map;
374 }, [platforms]);
375
376 // Maps component/sub-component IDs to display names.
377 // Monostatic txId/rxId are internal sub-IDs — map them to the parent component name.
378 const componentToName = useMemo(() => {
379 const map = new Map<string, string>();
380 platforms.forEach((p) => {
381 p.components.forEach((c) => {
382 map.set(c.id, c.name);
383 if (c.type === 'monostatic') {
384 map.set(c.txId, c.name);
385 map.set(c.rxId, c.name);
386 }
387 });
388 });
389 return map;
390 }, [platforms]);
391
392 const fmcwDutyCycleByTransmitter = useMemo(() => {
393 const waveformById = new Map(waveforms.map((w) => [w.id, w]));
394 const dutyCycleByTx = new Map<string, number>();
395
396 platforms.forEach((p) => {
397 p.components.forEach((c) => {
398 if (c.type !== 'transmitter' && c.type !== 'monostatic') {
399 return;
400 }
401
402 const waveform = c.waveformId
403 ? waveformById.get(c.waveformId)
404 : undefined;
405 if (!waveform) {
406 return;
407 }
408
409 const dutyCycle =
410 waveform.waveformType === 'fmcw_triangle'
411 ? 1
412 : waveform.waveformType === 'fmcw_linear_chirp' &&
413 waveform.chirp_period > 0
414 ? Math.min(
415 1,
416 Math.max(
417 0,
418 waveform.chirp_duration /
419 waveform.chirp_period
420 )
421 )
422 : null;
423 if (dutyCycle === null) {
424 return;
425 }
426 const txId = c.type === 'monostatic' ? c.txId : c.id;
427 dutyCycleByTx.set(txId, dutyCycle);
428 });
429 });
430
431 return dutyCycleByTx;
432 }, [platforms, waveforms]);
433
434 // Reset throttle and accumulated ranges when the scenario structure changes
435 useEffect(() => {
436 lastFetchTimeRef.current = 0;
437 linkRangesRef.current.clear();
438 }, [componentToPlatform]);
439
440 // REPLACED: useFrame loop handles fetching instead of useEffect.
441 // This prevents the cleanup-cancellation race condition where rapid
442 // timeline updates would cancel the fetch request before it returned.
443 useFrame(() => {
444 // Access store state imperatively to avoid dependency staleness
445 const state = useScenarioStore.getState();
446 const simState = useSimulationProgressStore.getState();
447
448 // 1. Concurrency & Sync Checks
449 if (
450 simState.isSimulating ||
451 simState.isGeneratingKml ||
452 state.isBackendSyncing ||
453 isFetchingRef.current
454 ) {
455 return;
456 }
457
458 const now = Date.now();
459
460 // 2. Throttle Check
461 if (
462 lastFetchTimeRef.current > 0 &&
463 now - lastFetchTimeRef.current < FETCH_INTERVAL_MS
464 ) {
465 return;
466 }
467
468 // 3. Perform Fetch
469 const fetchLinks = async () => {
470 try {
471 isFetchingRef.current = true;
472 lastFetchTimeRef.current = Date.now();
473
474 // Use the imperative time from the store for the most recent value
475 const links = await invoke<RustVisualLink[]>(
476 'get_preview_links',
477 {
478 time: state.currentTime,
479 }
480 );
481
482 // Only update state if component is still mounted
483 if (isMountedRef.current) {
484 const reconstructedLinks: LinkMetadata[] = links.map(
485 (l) => ({
486 link_type: TYPE_MAP[l.link_type],
487 quality: QUALITY_MAP[l.quality],
488 label: l.label,
489 source_id: l.source_id,
490 dest_id: l.dest_id,
491 origin_id: l.origin_id,
492 rcs: l.rcs,
493 actual_power_dbm: l.actual_power_dbm,
494 display_value: l.display_value,
495 })
496 );
497 setLinkMetadata(reconstructedLinks);
498
499 // Accumulate observed min/max per link (ranges only ever expand)
500 reconstructedLinks.forEach((m) => {
501 const key = `${m.link_type}_${m.source_id}_${m.dest_id}_${m.origin_id}`;
502 const existing = linkRangesRef.current.get(key);
503 if (existing) {
504 if (m.actual_power_dbm > -990) {
505 existing.powerMin = Math.min(
506 existing.powerMin,
507 m.actual_power_dbm
508 );
509 existing.powerMax = Math.max(
510 existing.powerMax,
511 m.actual_power_dbm
512 );
513 }
514 if (m.rcs >= 0) {
515 existing.rcsMin = Math.min(
516 existing.rcsMin,
517 m.rcs
518 );
519 existing.rcsMax = Math.max(
520 existing.rcsMax,
521 m.rcs
522 );
523 }
524 } else {
525 linkRangesRef.current.set(key, {
526 powerMin:
527 m.actual_power_dbm > -990
528 ? m.actual_power_dbm
529 : Infinity,
530 powerMax:
531 m.actual_power_dbm > -990
532 ? m.actual_power_dbm
533 : -Infinity,
534 rcsMin: m.rcs >= 0 ? m.rcs : Infinity,
535 rcsMax: m.rcs >= 0 ? m.rcs : -Infinity,
536 });
537 }
538 });
539 }
540 } catch (e) {
541 console.error('Link preview error:', e);
542 } finally {
543 // Release lock
544 if (isMountedRef.current) {
545 isFetchingRef.current = false;
546 }
547 }
548 };
549
550 void fetchLinks();
551 });
552
553 // Refs to each rendered LabelCluster's inner div for direct DOM transform updates
554 const clusterDivRefs = useRef<(HTMLDivElement | null)[]>([]);
555 // Latest clusters held in a ref so useFrame always reads the current value
556 const clustersRef = useRef<
557 Array<{ position: THREE.Vector3; links: RenderableLink[] }>
558 >([]);
559 // Scratch vector reused each frame to avoid per-frame allocation
560 const ndcScratch = useMemo(() => new THREE.Vector3(), []);
561
562 // Screen-space label placement: runs every frame, nudges overlapping cluster
563 // divs apart via CSS transform so all labels remain visible.
564 useFrame(() => {
565 const clusterCount = clustersRef.current.length;
566 if (clusterCount === 0) return;
567
568 const w = gl.domElement.clientWidth;
569 const h = gl.domElement.clientHeight;
570
571 // Project all cluster anchor positions to screen space
572 const sx = new Float32Array(clusterCount);
573 const sy = new Float32Array(clusterCount);
574 for (let i = 0; i < clusterCount; i++) {
575 ndcScratch.copy(clustersRef.current[i].position).project(camera);
576 sx[i] = (ndcScratch.x * 0.5 + 0.5) * w;
577 sy[i] = (-ndcScratch.y * 0.5 + 0.5) * h;
578 }
579
580 // Iterative force-push: separate overlapping label centers
581 const ox = new Float32Array(clusterCount); // x offsets (pixels)
582 const oy = new Float32Array(clusterCount); // y offsets (pixels)
583 for (let iter = 0; iter < 8; iter++) {
584 for (let i = 0; i < clusterCount; i++) {
585 for (let j = i + 1; j < clusterCount; j++) {
586 const dx = sx[j] + ox[j] - (sx[i] + ox[i]);
587 const dy = sy[j] + oy[j] - (sy[i] + oy[i]);
588 const distSq = dx * dx + dy * dy;
589 if (distSq >= LABEL_OVERLAP_PX * LABEL_OVERLAP_PX) continue;
590
591 const dist = Math.sqrt(distSq);
592 const push = (LABEL_OVERLAP_PX - dist) * 0.5;
593 // Push apart along the separation vector; use (1, 0) as fallback
594 const nx = dist > 0.5 ? dx / dist : 1;
595 const ny = dist > 0.5 ? dy / dist : 0;
596 ox[i] -= nx * push;
597 oy[i] -= ny * push;
598 ox[j] += nx * push;
599 oy[j] += ny * push;
600 }
601 }
602 }
603
604 // Apply computed offsets as CSS transforms on each cluster div
605 for (let i = 0; i < clusterCount; i++) {
606 const div = clusterDivRefs.current[i];
607 if (!div) continue;
608 div.style.transform = `translate(${ox[i].toFixed(1)}px, ${oy[i].toFixed(1)}px)`;
609 }
610 });
611
612 // 2. High-Frequency Geometry Calculation (Runs every render/frame)
613 const { clusters, flatLinks } = useMemo(() => {
614 const calculatedLinks: RenderableLink[] = [];
615 const clusterMap = new Map<
616 string,
617 { position: THREE.Vector3; links: RenderableLink[] }
618 >();
619
620 linkMetadata.forEach((meta) => {
621 // Filter by type
622 if (meta.link_type === 'monostatic' && !showLinkMonostatic) return;
623 if (meta.link_type === 'illuminator' && !showLinkIlluminator)
624 return;
625 if (meta.link_type === 'scattered' && !showLinkScattered) return;
626 if (meta.link_type === 'direct' && !showLinkDirect) return;
627
628 const sourcePlat = componentToPlatform.get(meta.source_id);
629 const destPlat = componentToPlatform.get(meta.dest_id);
630
631 if (sourcePlat && destPlat) {
632 const startPos = calculateInterpolatedPosition(
633 sourcePlat,
634 currentTime
635 );
636 const endPos = calculateInterpolatedPosition(
637 destPlat,
638 currentTime
639 );
640 const dist = startPos.distanceTo(endPos);
641
642 const rangeKey = `${meta.link_type}_${meta.source_id}_${meta.dest_id}_${meta.origin_id}`;
643 const range = linkRangesRef.current.get(rangeKey) ?? null;
644
645 const renderLink: RenderableLink = {
646 ...meta,
647 start: startPos,
648 end: endPos,
649 distance: dist,
650 source_name:
651 componentToName.get(meta.source_id) ?? meta.source_id,
652 dest_name:
653 componentToName.get(meta.dest_id) ?? meta.dest_id,
654 origin_name:
655 componentToName.get(meta.origin_id) ?? meta.origin_id,
656 fmcwDutyCycle:
657 fmcwDutyCycleByTransmitter.get(meta.origin_id) ?? null,
658 range,
659 };
660
661 calculatedLinks.push(renderLink);
662
663 // Clustering logic for labels - only if labels are enabled
664 if (showLinkLabels) {
665 const mid = startPos.clone().lerp(endPos, 0.5);
666 // Round keys to cluster nearby labels
667 const key = `${mid.x.toFixed(1)}_${mid.y.toFixed(1)}_${mid.z.toFixed(1)}`;
668
669 if (!clusterMap.has(key)) {
670 clusterMap.set(key, { position: mid, links: [] });
671 }
672 clusterMap.get(key)!.links.push(renderLink);
673 }
674 }
675 });
676
677 const computedClusters = Array.from(clusterMap.values());
678 clustersRef.current = computedClusters;
679 return {
680 clusters: computedClusters,
681 flatLinks: calculatedLinks,
682 };
683 }, [
684 linkMetadata,
685 currentTime,
686 componentToPlatform,
687 componentToName,
688 fmcwDutyCycleByTransmitter,
689 showLinkLabels,
690 showLinkMonostatic,
691 showLinkIlluminator,
692 showLinkScattered,
693 showLinkDirect,
694 ]);
695
696 return (
697 <group>
698 {/* Render all lines individually for geometry */}
699 {flatLinks.map((link, i) => (
700 <LinkLine key={`line-${i}`} link={link} />
701 ))}
702
703 {/* Render aggregated label clusters */}
704 {clusters.map((cluster, i) => (
705 <LabelCluster
706 key={`cluster-${i}`}
707 position={cluster.position}
708 links={cluster.links}
709 divRef={(el) => {
710 clusterDivRefs.current[i] = el;
711 }}
712 />
713 ))}
714 </group>
715 );
716}