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 waveform.waveformType === 'fmcw_from_file'
412 ? 1
413 : waveform.waveformType === 'fmcw_linear_chirp' &&
414 waveform.chirp_period > 0
415 ? Math.min(
416 1,
417 Math.max(
418 0,
419 waveform.chirp_duration /
420 waveform.chirp_period
421 )
422 )
423 : waveform.waveformType === 'stepped_frequency' &&
424 waveform.step_period > 0
425 ? Math.min(
426 1,
427 Math.max(
428 0,
429 waveform.dwell_time / waveform.step_period
430 )
431 )
432 : null;
433 if (dutyCycle === null) {
434 return;
435 }
436 const txId = c.type === 'monostatic' ? c.txId : c.id;
437 dutyCycleByTx.set(txId, dutyCycle);
438 });
439 });
440
441 return dutyCycleByTx;
442 }, [platforms, waveforms]);
443
444 // Reset throttle and accumulated ranges when the scenario structure changes
445 useEffect(() => {
446 lastFetchTimeRef.current = 0;
447 linkRangesRef.current.clear();
448 }, [componentToPlatform]);
449
450 // REPLACED: useFrame loop handles fetching instead of useEffect.
451 // This prevents the cleanup-cancellation race condition where rapid
452 // timeline updates would cancel the fetch request before it returned.
453 useFrame(() => {
454 // Access store state imperatively to avoid dependency staleness
455 const state = useScenarioStore.getState();
456 const simState = useSimulationProgressStore.getState();
457
458 // 1. Concurrency & Sync Checks
459 if (
460 simState.isSimulating ||
461 simState.isGeneratingKml ||
462 state.isBackendSyncing ||
463 isFetchingRef.current
464 ) {
465 return;
466 }
467
468 const now = Date.now();
469
470 // 2. Throttle Check
471 if (
472 lastFetchTimeRef.current > 0 &&
473 now - lastFetchTimeRef.current < FETCH_INTERVAL_MS
474 ) {
475 return;
476 }
477
478 // 3. Perform Fetch
479 const fetchLinks = async () => {
480 try {
481 isFetchingRef.current = true;
482 lastFetchTimeRef.current = Date.now();
483
484 // Use the imperative time from the store for the most recent value
485 const links = await invoke<RustVisualLink[]>(
486 'get_preview_links',
487 {
488 time: state.currentTime,
489 }
490 );
491
492 // Only update state if component is still mounted
493 if (isMountedRef.current) {
494 const reconstructedLinks: LinkMetadata[] = links.map(
495 (l) => ({
496 link_type: TYPE_MAP[l.link_type],
497 quality: QUALITY_MAP[l.quality],
498 label: l.label,
499 source_id: l.source_id,
500 dest_id: l.dest_id,
501 origin_id: l.origin_id,
502 rcs: l.rcs,
503 actual_power_dbm: l.actual_power_dbm,
504 display_value: l.display_value,
505 })
506 );
507 setLinkMetadata(reconstructedLinks);
508
509 // Accumulate observed min/max per link (ranges only ever expand)
510 reconstructedLinks.forEach((m) => {
511 const key = `${m.link_type}_${m.source_id}_${m.dest_id}_${m.origin_id}`;
512 const existing = linkRangesRef.current.get(key);
513 if (existing) {
514 if (m.actual_power_dbm > -990) {
515 existing.powerMin = Math.min(
516 existing.powerMin,
517 m.actual_power_dbm
518 );
519 existing.powerMax = Math.max(
520 existing.powerMax,
521 m.actual_power_dbm
522 );
523 }
524 if (m.rcs >= 0) {
525 existing.rcsMin = Math.min(
526 existing.rcsMin,
527 m.rcs
528 );
529 existing.rcsMax = Math.max(
530 existing.rcsMax,
531 m.rcs
532 );
533 }
534 } else {
535 linkRangesRef.current.set(key, {
536 powerMin:
537 m.actual_power_dbm > -990
538 ? m.actual_power_dbm
539 : Infinity,
540 powerMax:
541 m.actual_power_dbm > -990
542 ? m.actual_power_dbm
543 : -Infinity,
544 rcsMin: m.rcs >= 0 ? m.rcs : Infinity,
545 rcsMax: m.rcs >= 0 ? m.rcs : -Infinity,
546 });
547 }
548 });
549 }
550 } catch (e) {
551 console.error('Link preview error:', e);
552 } finally {
553 // Release lock
554 if (isMountedRef.current) {
555 isFetchingRef.current = false;
556 }
557 }
558 };
559
560 void fetchLinks();
561 });
562
563 // Refs to each rendered LabelCluster's inner div for direct DOM transform updates
564 const clusterDivRefs = useRef<(HTMLDivElement | null)[]>([]);
565 // Latest clusters held in a ref so useFrame always reads the current value
566 const clustersRef = useRef<
567 Array<{ position: THREE.Vector3; links: RenderableLink[] }>
568 >([]);
569 // Scratch vector reused each frame to avoid per-frame allocation
570 const ndcScratch = useMemo(() => new THREE.Vector3(), []);
571
572 // Screen-space label placement: runs every frame, nudges overlapping cluster
573 // divs apart via CSS transform so all labels remain visible.
574 useFrame(() => {
575 const clusterCount = clustersRef.current.length;
576 if (clusterCount === 0) return;
577
578 const w = gl.domElement.clientWidth;
579 const h = gl.domElement.clientHeight;
580
581 // Project all cluster anchor positions to screen space
582 const sx = new Float32Array(clusterCount);
583 const sy = new Float32Array(clusterCount);
584 for (let i = 0; i < clusterCount; i++) {
585 ndcScratch.copy(clustersRef.current[i].position).project(camera);
586 sx[i] = (ndcScratch.x * 0.5 + 0.5) * w;
587 sy[i] = (-ndcScratch.y * 0.5 + 0.5) * h;
588 }
589
590 // Iterative force-push: separate overlapping label centers
591 const ox = new Float32Array(clusterCount); // x offsets (pixels)
592 const oy = new Float32Array(clusterCount); // y offsets (pixels)
593 for (let iter = 0; iter < 8; iter++) {
594 for (let i = 0; i < clusterCount; i++) {
595 for (let j = i + 1; j < clusterCount; j++) {
596 const dx = sx[j] + ox[j] - (sx[i] + ox[i]);
597 const dy = sy[j] + oy[j] - (sy[i] + oy[i]);
598 const distSq = dx * dx + dy * dy;
599 if (distSq >= LABEL_OVERLAP_PX * LABEL_OVERLAP_PX) continue;
600
601 const dist = Math.sqrt(distSq);
602 const push = (LABEL_OVERLAP_PX - dist) * 0.5;
603 // Push apart along the separation vector; use (1, 0) as fallback
604 const nx = dist > 0.5 ? dx / dist : 1;
605 const ny = dist > 0.5 ? dy / dist : 0;
606 ox[i] -= nx * push;
607 oy[i] -= ny * push;
608 ox[j] += nx * push;
609 oy[j] += ny * push;
610 }
611 }
612 }
613
614 // Apply computed offsets as CSS transforms on each cluster div
615 for (let i = 0; i < clusterCount; i++) {
616 const div = clusterDivRefs.current[i];
617 if (!div) continue;
618 div.style.transform = `translate(${ox[i].toFixed(1)}px, ${oy[i].toFixed(1)}px)`;
619 }
620 });
621
622 // 2. High-Frequency Geometry Calculation (Runs every render/frame)
623 const { clusters, flatLinks } = useMemo(() => {
624 const calculatedLinks: RenderableLink[] = [];
625 const clusterMap = new Map<
626 string,
627 { position: THREE.Vector3; links: RenderableLink[] }
628 >();
629
630 linkMetadata.forEach((meta) => {
631 // Filter by type
632 if (meta.link_type === 'monostatic' && !showLinkMonostatic) return;
633 if (meta.link_type === 'illuminator' && !showLinkIlluminator)
634 return;
635 if (meta.link_type === 'scattered' && !showLinkScattered) return;
636 if (meta.link_type === 'direct' && !showLinkDirect) return;
637
638 const sourcePlat = componentToPlatform.get(meta.source_id);
639 const destPlat = componentToPlatform.get(meta.dest_id);
640
641 if (sourcePlat && destPlat) {
642 const startPos = calculateInterpolatedPosition(
643 sourcePlat,
644 currentTime
645 );
646 const endPos = calculateInterpolatedPosition(
647 destPlat,
648 currentTime
649 );
650 const dist = startPos.distanceTo(endPos);
651
652 const rangeKey = `${meta.link_type}_${meta.source_id}_${meta.dest_id}_${meta.origin_id}`;
653 const range = linkRangesRef.current.get(rangeKey) ?? null;
654
655 const renderLink: RenderableLink = {
656 ...meta,
657 start: startPos,
658 end: endPos,
659 distance: dist,
660 source_name:
661 componentToName.get(meta.source_id) ?? meta.source_id,
662 dest_name:
663 componentToName.get(meta.dest_id) ?? meta.dest_id,
664 origin_name:
665 componentToName.get(meta.origin_id) ?? meta.origin_id,
666 fmcwDutyCycle:
667 fmcwDutyCycleByTransmitter.get(meta.origin_id) ?? null,
668 range,
669 };
670
671 calculatedLinks.push(renderLink);
672
673 // Clustering logic for labels - only if labels are enabled
674 if (showLinkLabels) {
675 const mid = startPos.clone().lerp(endPos, 0.5);
676 // Round keys to cluster nearby labels
677 const key = `${mid.x.toFixed(1)}_${mid.y.toFixed(1)}_${mid.z.toFixed(1)}`;
678
679 if (!clusterMap.has(key)) {
680 clusterMap.set(key, { position: mid, links: [] });
681 }
682 clusterMap.get(key)!.links.push(renderLink);
683 }
684 }
685 });
686
687 const computedClusters = Array.from(clusterMap.values());
688 clustersRef.current = computedClusters;
689 return {
690 clusters: computedClusters,
691 flatLinks: calculatedLinks,
692 };
693 }, [
694 linkMetadata,
695 currentTime,
696 componentToPlatform,
697 componentToName,
698 fmcwDutyCycleByTransmitter,
699 showLinkLabels,
700 showLinkMonostatic,
701 showLinkIlluminator,
702 showLinkScattered,
703 showLinkDirect,
704 ]);
705
706 return (
707 <group>
708 {/* Render all lines individually for geometry */}
709 {flatLinks.map((link, i) => (
710 <LinkLine key={`line-${i}`} link={link} />
711 ))}
712
713 {/* Render aggregated label clusters */}
714 {clusters.map((cluster, i) => (
715 <LabelCluster
716 key={`cluster-${i}`}
717 position={cluster.position}
718 links={cluster.links}
719 divRef={(el) => {
720 clusterDivRefs.current[i] = el;
721 }}
722 />
723 ))}
724 </group>
725 );
726}