1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
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';
11 calculateInterpolatedPosition,
14} from '@/stores/scenarioStore';
15import { useSimulationProgressStore } from '@/stores/simulationProgressStore';
16import { fersColors } from '@/theme';
18const TYPE_MAP = ['monostatic', 'illuminator', 'scattered', 'direct'] as const;
19const QUALITY_MAP = ['strong', 'weak'] as const;
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,
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,
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';
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
56// Derived object used for actual rendering
57interface RenderableLink extends LinkMetadata {
64 fmcwDutyCycle: number | null;
65 range: LinkRange | null;
68// Define the shape coming from Rust
69interface RustVisualLink {
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
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') {
97const getLinkValueUnit = (link: LinkMetadata) =>
98 link.link_type === 'illuminator' ? 'dBW/m²' : 'dBm';
100function LinkLine({ link }: { link: RenderableLink }) {
101 const points = useMemo(
102 () => [link.start, link.end] as [THREE.Vector3, THREE.Vector3],
103 [link.start, link.end]
106 const color = getLinkColor(link);
109 // If the link is geometrically valid but radiometrically weak (Sub-Noise),
110 // render it transparently to indicate "possibility" without "detection".
112 (link.link_type === 'monostatic' || link.link_type === 'scattered') &&
113 link.quality === 'weak';
115 const opacity = isGhost ? 0.1 : 1.0;
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)
135 const peakDisplayValue =
136 hasDutyCycledValue && link.display_value > -990
137 ? link.display_value + dutyCycleOffsetDb
139 const actualPeakPowerDbm =
140 hasDutyCycledValue && link.actual_power_dbm > -990
141 ? link.actual_power_dbm + dutyCycleOffsetDb
149 <Box sx={{ p: 0.5 }}>
150 <Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
153 <Typography variant="caption" display="block">
154 <b>Path Segment:</b> {link.source_name} →{' '}
157 {link.link_type === 'scattered' && (
158 <Typography variant="caption" display="block">
159 <b>Illuminator:</b> {link.origin_name}
162 <Typography variant="caption" display="block">
163 <b>Type:</b> {link.link_type}
165 <Typography variant="caption" display="block">
166 <b>Distance:</b> {(link.distance / 1000).toFixed(2)} km
168 <Typography variant="caption" display="block">
170 {hasDutyCycledValue ? 'Average Value:' : 'Value:'}
174 {hasDutyCycledValue && (
176 <Typography variant="caption" display="block">
177 <b>FMCW Duty Cycle:</b>{' '}
178 {(dutyCycle * 100).toFixed(1)}%
180 {peakDisplayValue !== null && (
184 sx={{ pl: 1.5, opacity: 0.7 }}
186 peak equivalent:{' '}
187 {peakDisplayValue.toFixed(1)}{' '}
188 {getLinkValueUnit(link)}
193 {link.actual_power_dbm > -990 && (
195 <Typography variant="caption" display="block">
198 ? 'Actual Average Power:'
201 {link.actual_power_dbm.toFixed(1)} dBm
203 {actualPeakPowerDbm !== null && (
207 sx={{ pl: 1.5, opacity: 0.7 }}
209 actual peak: {actualPeakPowerDbm.toFixed(1)}{' '}
214 link.range.powerMin < link.range.powerMax && (
218 sx={{ pl: 1.5, opacity: 0.7 }}
220 range: {link.range.powerMin.toFixed(1)}{' '}
221 to {link.range.powerMax.toFixed(1)} dBm
228 <Typography variant="caption" display="block">
229 <b>RCS:</b> {link.rcs.toFixed(2)} m^2
232 link.range.rcsMin < link.range.rcsMax && (
236 sx={{ pl: 1.5, opacity: 0.7 }}
238 range: {link.range.rcsMin.toFixed(2)} to{' '}
239 {link.range.rcsMax.toFixed(2)} m^2
249 background: fersColors.background.overlay,
254 fontFamily: 'monospace',
255 whiteSpace: 'nowrap',
256 border: `1px solid ${color}`,
257 boxShadow: `0 0 4px ${color}20`,
260 pointerEvents: 'auto',
269// Component responsible for rendering a stack of labels at a specific 3D position
270function LabelCluster({
275 position: THREE.Vector3;
276 links: RenderableLink[];
277 divRef: React.Ref<HTMLDivElement>;
280 <Html position={position} center zIndexRange={[100, 0]}>
285 flexDirection: 'column',
287 alignItems: 'center',
288 pointerEvents: 'none',
291 {links.map((link, i) => {
292 const color = getLinkColor(link);
293 return <LabelItem key={i} link={link} color={color} />;
300// Minimum screen-space separation (px) enforced between label cluster centers.
301const LABEL_OVERLAP_PX = 60;
304 * Renders radio frequency links between platforms.
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.
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
319 const isGeneratingKml = useSimulationProgressStore(
320 (state) => state.isGeneratingKml
330 const { camera, gl } = useThree();
332 // Store only the metadata (connectivity/labels), not positions
333 const [linkMetadata, setLinkMetadata] = useState<LinkMetadata[]>([]);
335 // Accumulates observed min/max per link key — never causes re-renders, read in useMemo
336 const linkRangesRef = useRef<Map<string, LinkRange>>(new Map());
339 const lastFetchTimeRef = useRef<number>(0);
340 const isFetchingRef = useRef<boolean>(false);
341 const isMountedRef = useRef<boolean>(true);
342 const FETCH_INTERVAL_MS = 16;
344 // Track component mount status to prevent setting state on unmounted component
346 isMountedRef.current = true;
348 isMountedRef.current = false;
352 // Clear stale links when a backend operation holds the mutex
354 if (isSimulating || isGeneratingKml) {
356 linkRangesRef.current.clear();
358 }, [isSimulating, isGeneratingKml]);
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) => {
367 if (c.type === 'monostatic') {
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);
392 const fmcwDutyCycleByTransmitter = useMemo(() => {
393 const waveformById = new Map(waveforms.map((w) => [w.id, w]));
394 const dutyCycleByTx = new Map<string, number>();
396 platforms.forEach((p) => {
397 p.components.forEach((c) => {
398 if (c.type !== 'transmitter' && c.type !== 'monostatic') {
402 const waveform = c.waveformId
403 ? waveformById.get(c.waveformId)
410 waveform.waveformType === 'fmcw_triangle'
412 : waveform.waveformType === 'fmcw_linear_chirp' &&
413 waveform.chirp_period > 0
418 waveform.chirp_duration /
419 waveform.chirp_period
423 if (dutyCycle === null) {
426 const txId = c.type === 'monostatic' ? c.txId : c.id;
427 dutyCycleByTx.set(txId, dutyCycle);
431 return dutyCycleByTx;
432 }, [platforms, waveforms]);
434 // Reset throttle and accumulated ranges when the scenario structure changes
436 lastFetchTimeRef.current = 0;
437 linkRangesRef.current.clear();
438 }, [componentToPlatform]);
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.
444 // Access store state imperatively to avoid dependency staleness
445 const state = useScenarioStore.getState();
446 const simState = useSimulationProgressStore.getState();
448 // 1. Concurrency & Sync Checks
450 simState.isSimulating ||
451 simState.isGeneratingKml ||
452 state.isBackendSyncing ||
453 isFetchingRef.current
458 const now = Date.now();
462 lastFetchTimeRef.current > 0 &&
463 now - lastFetchTimeRef.current < FETCH_INTERVAL_MS
469 const fetchLinks = async () => {
471 isFetchingRef.current = true;
472 lastFetchTimeRef.current = Date.now();
474 // Use the imperative time from the store for the most recent value
475 const links = await invoke<RustVisualLink[]>(
478 time: state.currentTime,
482 // Only update state if component is still mounted
483 if (isMountedRef.current) {
484 const reconstructedLinks: LinkMetadata[] = links.map(
486 link_type: TYPE_MAP[l.link_type],
487 quality: QUALITY_MAP[l.quality],
489 source_id: l.source_id,
491 origin_id: l.origin_id,
493 actual_power_dbm: l.actual_power_dbm,
494 display_value: l.display_value,
497 setLinkMetadata(reconstructedLinks);
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);
504 if (m.actual_power_dbm > -990) {
505 existing.powerMin = Math.min(
509 existing.powerMax = Math.max(
515 existing.rcsMin = Math.min(
519 existing.rcsMax = Math.max(
525 linkRangesRef.current.set(key, {
527 m.actual_power_dbm > -990
531 m.actual_power_dbm > -990
534 rcsMin: m.rcs >= 0 ? m.rcs : Infinity,
535 rcsMax: m.rcs >= 0 ? m.rcs : -Infinity,
541 console.error('Link preview error:', e);
544 if (isMountedRef.current) {
545 isFetchingRef.current = false;
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[] }>
559 // Scratch vector reused each frame to avoid per-frame allocation
560 const ndcScratch = useMemo(() => new THREE.Vector3(), []);
562 // Screen-space label placement: runs every frame, nudges overlapping cluster
563 // divs apart via CSS transform so all labels remain visible.
565 const clusterCount = clustersRef.current.length;
566 if (clusterCount === 0) return;
568 const w = gl.domElement.clientWidth;
569 const h = gl.domElement.clientHeight;
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;
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;
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;
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];
608 div.style.transform = `translate(${ox[i].toFixed(1)}px, ${oy[i].toFixed(1)}px)`;
612 // 2. High-Frequency Geometry Calculation (Runs every render/frame)
613 const { clusters, flatLinks } = useMemo(() => {
614 const calculatedLinks: RenderableLink[] = [];
615 const clusterMap = new Map<
617 { position: THREE.Vector3; links: RenderableLink[] }
620 linkMetadata.forEach((meta) => {
622 if (meta.link_type === 'monostatic' && !showLinkMonostatic) return;
623 if (meta.link_type === 'illuminator' && !showLinkIlluminator)
625 if (meta.link_type === 'scattered' && !showLinkScattered) return;
626 if (meta.link_type === 'direct' && !showLinkDirect) return;
628 const sourcePlat = componentToPlatform.get(meta.source_id);
629 const destPlat = componentToPlatform.get(meta.dest_id);
631 if (sourcePlat && destPlat) {
632 const startPos = calculateInterpolatedPosition(
636 const endPos = calculateInterpolatedPosition(
640 const dist = startPos.distanceTo(endPos);
642 const rangeKey = `${meta.link_type}_${meta.source_id}_${meta.dest_id}_${meta.origin_id}`;
643 const range = linkRangesRef.current.get(rangeKey) ?? null;
645 const renderLink: RenderableLink = {
651 componentToName.get(meta.source_id) ?? meta.source_id,
653 componentToName.get(meta.dest_id) ?? meta.dest_id,
655 componentToName.get(meta.origin_id) ?? meta.origin_id,
657 fmcwDutyCycleByTransmitter.get(meta.origin_id) ?? null,
661 calculatedLinks.push(renderLink);
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)}`;
669 if (!clusterMap.has(key)) {
670 clusterMap.set(key, { position: mid, links: [] });
672 clusterMap.get(key)!.links.push(renderLink);
677 const computedClusters = Array.from(clusterMap.values());
678 clustersRef.current = computedClusters;
680 clusters: computedClusters,
681 flatLinks: calculatedLinks,
688 fmcwDutyCycleByTransmitter,
698 {/* Render all lines individually for geometry */}
699 {flatLinks.map((link, i) => (
700 <LinkLine key={`line-${i}`} link={link} />
703 {/* Render aggregated label clusters */}
704 {clusters.map((cluster, i) => (
707 position={cluster.position}
708 links={cluster.links}
710 clusterDivRefs.current[i] = el;