1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
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';
12 calculateInterpolatedPosition,
13} from '@/stores/scenarioStore';
14import { Tooltip, Box, Typography } from '@mui/material';
15import { fersColors } from '@/theme';
17const TYPE_MAP = ['monostatic', 'illuminator', 'scattered', 'direct'] as const;
18const QUALITY_MAP = ['strong', 'weak'] as const;
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,
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,
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';
45// Derived object used for actual rendering
46interface RenderableLink extends LinkMetadata {
52// Define the shape coming from Rust
53interface RustVisualLink {
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') {
78function LinkLine({ link }: { link: RenderableLink }) {
79 const points = useMemo(
80 () => [link.start, link.end] as [THREE.Vector3, THREE.Vector3],
81 [link.start, link.end]
84 const color = getLinkColor(link);
87 // If the link is geometrically valid but radiometrically weak (Sub-Noise),
88 // render it transparently to indicate "possibility" without "detection".
90 (link.link_type === 'monostatic' || link.link_type === 'scattered') &&
91 link.quality === 'weak';
93 const opacity = isGhost ? 0.1 : 1.0;
106function LabelItem({ link, color }: { link: RenderableLink; color: string }) {
112 <Box sx={{ p: 0.5 }}>
113 <Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
116 <Typography variant="caption" display="block">
117 <b>Path Segment:</b> {link.source_name} →{' '}
120 {link.link_type === 'scattered' && (
121 <Typography variant="caption" display="block">
122 <b>Illuminator:</b> {link.origin_name}
125 <Typography variant="caption" display="block">
126 <b>Type:</b> {link.link_type}
128 <Typography variant="caption" display="block">
129 <b>Distance:</b> {(link.distance / 1000).toFixed(2)} km
131 <Typography variant="caption" display="block">
132 <b>Value:</b> {link.label}
139 background: fersColors.background.overlay,
144 fontFamily: 'monospace',
145 whiteSpace: 'nowrap',
146 border: `1px solid ${color}`,
147 boxShadow: `0 0 4px ${color}20`,
150 pointerEvents: 'auto',
159// Component responsible for rendering a stack of labels at a specific 3D position
160function LabelCluster({
164 position: THREE.Vector3;
165 links: RenderableLink[];
168 <Html position={position} center zIndexRange={[100, 0]}>
172 flexDirection: 'column',
174 alignItems: 'center',
175 pointerEvents: 'none',
178 {links.map((link, i) => {
179 const color = getLinkColor(link);
180 return <LabelItem key={i} link={link} color={color} />;
188 * Renders radio frequency links between platforms.
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).
194 * This ensures the lines "stick" to platforms smoothly while avoiding FFI/Text updates
195 * overload on every frame.
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);
209 // Store only the metadata (connectivity/labels), not positions
210 const [linkMetadata, setLinkMetadata] = useState<LinkMetadata[]>([]);
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;
219 // Track component mount status to prevent setting state on unmounted component
221 isMountedRef.current = true;
223 isMountedRef.current = false;
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));
237 // Reset throttle when the scenario structure changes
239 lastFetchTimeRef.current = 0;
240 }, [componentToPlatform]);
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.
246 // Access store state imperatively to avoid dependency staleness
247 const state = useScenarioStore.getState();
249 // 1. Concurrency & Sync Checks
250 if (state.isBackendSyncing || isFetchingRef.current) {
254 const now = Date.now();
258 lastFetchTimeRef.current > 0 &&
259 now - lastFetchTimeRef.current < FETCH_INTERVAL_MS
265 const fetchLinks = async () => {
267 isFetchingRef.current = true;
268 lastFetchTimeRef.current = Date.now();
270 // Use the imperative time from the store for the most recent value
271 const links = await invoke<RustVisualLink[]>(
274 time: state.currentTime,
278 // Only update state if component is still mounted
279 if (isMountedRef.current) {
280 const reconstructedLinks: LinkMetadata[] = links.map(
282 link_type: TYPE_MAP[l.link_type],
283 quality: QUALITY_MAP[l.quality],
285 source_name: l.source_name,
286 dest_name: l.dest_name,
287 origin_name: l.origin_name,
290 setLinkMetadata(reconstructedLinks);
293 console.error('Link preview error:', e);
296 if (isMountedRef.current) {
297 isFetchingRef.current = false;
305 // 2. High-Frequency Geometry Calculation (Runs every render/frame)
306 const { clusters, flatLinks } = useMemo(() => {
307 const calculatedLinks: RenderableLink[] = [];
308 const clusterMap = new Map<
310 { position: THREE.Vector3; links: RenderableLink[] }
313 linkMetadata.forEach((meta) => {
315 if (meta.link_type === 'monostatic' && !showLinkMonostatic) return;
316 if (meta.link_type === 'illuminator' && !showLinkIlluminator)
318 if (meta.link_type === 'scattered' && !showLinkScattered) return;
319 if (meta.link_type === 'direct' && !showLinkDirect) return;
321 const sourcePlat = componentToPlatform.get(meta.source_name);
322 const destPlat = componentToPlatform.get(meta.dest_name);
324 if (sourcePlat && destPlat) {
325 const startPos = calculateInterpolatedPosition(
329 const endPos = calculateInterpolatedPosition(
333 const dist = startPos.distanceTo(endPos);
335 const renderLink: RenderableLink = {
342 calculatedLinks.push(renderLink);
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)}`;
350 if (!clusterMap.has(key)) {
351 clusterMap.set(key, { position: mid, links: [] });
353 clusterMap.get(key)!.links.push(renderLink);
359 clusters: Array.from(clusterMap.values()),
360 flatLinks: calculatedLinks,
375 {/* Render all lines individually for geometry */}
376 {flatLinks.map((link, i) => (
377 <LinkLine key={`line-${i}`} link={link} />
380 {/* Render aggregated label clusters */}
381 {clusters.map((cluster, i) => (
384 position={cluster.position}
385 links={cluster.links}