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 } from '@react-three/fiber';
7import { invoke } from '@tauri-apps/api/core';
8import { 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';
46// Derived object used for actual rendering
47interface RenderableLink extends LinkMetadata {
53// Define the shape coming from Rust
54interface RustVisualLink {
63// Helper to determine color based on link type and quality
64const getLinkColor = (link: LinkMetadata) => {
65 if (link.link_type === 'monostatic') {
66 return link.quality === 'strong'
67 ? COLORS.monostatic.strong
68 : COLORS.monostatic.weak;
69 } else if (link.link_type === 'illuminator') {
70 return COLORS.illuminator;
71 } else if (link.link_type === 'scattered') {
72 return COLORS.scattered;
73 } else if (link.link_type === 'direct') {
79function LinkLine({ link }: { link: RenderableLink }) {
80 const points = useMemo(
81 () => [link.start, link.end] as [THREE.Vector3, THREE.Vector3],
82 [link.start, link.end]
85 const color = getLinkColor(link);
88 // If the link is geometrically valid but radiometrically weak (Sub-Noise),
89 // render it transparently to indicate "possibility" without "detection".
91 (link.link_type === 'monostatic' || link.link_type === 'scattered') &&
92 link.quality === 'weak';
94 const opacity = isGhost ? 0.1 : 1.0;
107function LabelItem({ link, color }: { link: RenderableLink; color: string }) {
113 <Box sx={{ p: 0.5 }}>
114 <Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
117 <Typography variant="caption" display="block">
118 <b>Path Segment:</b> {link.source_id} → {link.dest_id}
120 {link.link_type === 'scattered' && (
121 <Typography variant="caption" display="block">
122 <b>Illuminator:</b> {link.origin_id}
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);
201 const isSimulating = useSimulationProgressStore(
202 (state) => state.isSimulating
204 const isGeneratingKml = useSimulationProgressStore(
205 (state) => state.isGeneratingKml
215 // Store only the metadata (connectivity/labels), not positions
216 const [linkMetadata, setLinkMetadata] = useState<LinkMetadata[]>([]);
219 const lastFetchTimeRef = useRef<number>(0);
220 const isFetchingRef = useRef<boolean>(false);
221 const isMountedRef = useRef<boolean>(true);
222 // Updated to 16ms to target 60FPS updates during playback
223 const FETCH_INTERVAL_MS = 16;
225 // Track component mount status to prevent setting state on unmounted component
227 isMountedRef.current = true;
229 isMountedRef.current = false;
233 // Clear stale links when a backend operation holds the mutex
235 if (isSimulating || isGeneratingKml) {
238 }, [isSimulating, isGeneratingKml]);
240 // Build a lookup map: Component Name -> Parent Platform
241 const componentToPlatform = useMemo(() => {
242 const map = new Map<string, Platform>();
243 platforms.forEach((p) => {
244 // Map platform components (Tx, Rx, Tgt) to the platform
245 p.components.forEach((c) => {
247 if (c.type === 'monostatic') {
256 // Reset throttle when the scenario structure changes
258 lastFetchTimeRef.current = 0;
259 }, [componentToPlatform]);
261 // REPLACED: useFrame loop handles fetching instead of useEffect.
262 // This prevents the cleanup-cancellation race condition where rapid
263 // timeline updates would cancel the fetch request before it returned.
265 // Access store state imperatively to avoid dependency staleness
266 const state = useScenarioStore.getState();
267 const simState = useSimulationProgressStore.getState();
269 // 1. Concurrency & Sync Checks
271 simState.isSimulating ||
272 simState.isGeneratingKml ||
273 state.isBackendSyncing ||
274 isFetchingRef.current
279 const now = Date.now();
283 lastFetchTimeRef.current > 0 &&
284 now - lastFetchTimeRef.current < FETCH_INTERVAL_MS
290 const fetchLinks = async () => {
292 isFetchingRef.current = true;
293 lastFetchTimeRef.current = Date.now();
295 // Use the imperative time from the store for the most recent value
296 const links = await invoke<RustVisualLink[]>(
299 time: state.currentTime,
303 // Only update state if component is still mounted
304 if (isMountedRef.current) {
305 const reconstructedLinks: LinkMetadata[] = links.map(
307 link_type: TYPE_MAP[l.link_type],
308 quality: QUALITY_MAP[l.quality],
310 source_id: l.source_id,
312 origin_id: l.origin_id,
315 setLinkMetadata(reconstructedLinks);
318 console.error('Link preview error:', e);
321 if (isMountedRef.current) {
322 isFetchingRef.current = false;
330 // 2. High-Frequency Geometry Calculation (Runs every render/frame)
331 const { clusters, flatLinks } = useMemo(() => {
332 const calculatedLinks: RenderableLink[] = [];
333 const clusterMap = new Map<
335 { position: THREE.Vector3; links: RenderableLink[] }
338 linkMetadata.forEach((meta) => {
340 if (meta.link_type === 'monostatic' && !showLinkMonostatic) return;
341 if (meta.link_type === 'illuminator' && !showLinkIlluminator)
343 if (meta.link_type === 'scattered' && !showLinkScattered) return;
344 if (meta.link_type === 'direct' && !showLinkDirect) return;
346 const sourcePlat = componentToPlatform.get(meta.source_id);
347 const destPlat = componentToPlatform.get(meta.dest_id);
349 if (sourcePlat && destPlat) {
350 const startPos = calculateInterpolatedPosition(
354 const endPos = calculateInterpolatedPosition(
358 const dist = startPos.distanceTo(endPos);
360 const renderLink: RenderableLink = {
367 calculatedLinks.push(renderLink);
369 // Clustering logic for labels - only if labels are enabled
370 if (showLinkLabels) {
371 const mid = startPos.clone().lerp(endPos, 0.5);
372 // Round keys to cluster nearby labels
373 const key = `${mid.x.toFixed(1)}_${mid.y.toFixed(1)}_${mid.z.toFixed(1)}`;
375 if (!clusterMap.has(key)) {
376 clusterMap.set(key, { position: mid, links: [] });
378 clusterMap.get(key)!.links.push(renderLink);
384 clusters: Array.from(clusterMap.values()),
385 flatLinks: calculatedLinks,
400 {/* Render all lines individually for geometry */}
401 {flatLinks.map((link, i) => (
402 <LinkLine key={`line-${i}`} link={link} />
405 {/* Render aggregated label clusters */}
406 {clusters.map((cluster, i) => (
409 position={cluster.position}
410 links={cluster.links}