diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index cb153a2..8c7b99b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -48,7 +48,7 @@ frontend/src/ │ ├── RawPacketList.tsx │ ├── MapView.tsx │ ├── VisualizerView.tsx -│ ├── PacketVisualizer.tsx +│ ├── PacketVisualizer3D.tsx │ ├── PathModal.tsx │ ├── CrackerPanel.tsx │ ├── BotCodeEditor.tsx @@ -98,6 +98,12 @@ Specialized logic is delegated to hooks: - ACK/repeat updates arrive as `message_acked` events. - Outgoing channel messages show a 30-second resend control; resend calls `POST /api/messages/channel/{message_id}/resend`. +### Visualizer behavior + +- `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs). +- `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame. +- Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets. + ## WebSocket (`useWebSocket.ts`) - Auto reconnect (3s) with cleanup guard on unmount. diff --git a/frontend/package.json b/frontend/package.json index 6aa0c79..d6e2780 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,10 +24,12 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@types/three": "^0.182.0", "@uiw/react-codemirror": "^4.25.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3-force": "^3.0.0", + "d3-force-3d": "^3.0.6", "leaflet": "^1.9.4", "lucide-react": "^0.562.0", "meshcore-hashtag-cracker": "^1.7.0", @@ -37,7 +39,8 @@ "react-leaflet": "^4.2.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "three": "^0.182.0" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/frontend/src/components/AGENTS.md b/frontend/src/components/AGENTS.md index e042688..e060430 100644 --- a/frontend/src/components/AGENTS.md +++ b/frontend/src/components/AGENTS.md @@ -1,20 +1,18 @@ -# PacketVisualizer Architecture +# PacketVisualizer3D Architecture -This document explains the architecture and design of the PacketVisualizer component, which renders a real-time force-directed graph visualization of mesh network packet traffic. +This document explains the architecture and design of the `PacketVisualizer3D` component, which renders a real-time 3D force-directed graph visualization of mesh network packet traffic using Three.js and `d3-force-3d`. ## Overview -The PacketVisualizer displays: +The visualizer displays: -- **Nodes**: Network participants (self, repeaters, clients) +- **Nodes**: Network participants (self, repeaters, clients) as colored spheres - **Links**: Connections between nodes based on observed packet paths -- **Particles**: Animated dots traveling along links representing packets in transit +- **Particles**: Animated colored dots traveling along links representing packets in transit -## Architecture: Data Layer vs Rendering Layer +## Architecture -The component is split into two distinct layers to enable future rendering engine swaps (e.g., WebGL, Three.js): - -### Data Layer (`useVisualizerData` hook) +### Data Layer (`useVisualizerData3D` hook) The custom hook manages all graph state and simulation logic: @@ -25,7 +23,7 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An **Key responsibilities:** - Maintains node and link maps (`nodesRef`, `linksRef`) -- Runs D3 force simulation for layout +- Runs `d3-force-3d` simulation for 3D layout (`.numDimensions(3)`) - Processes incoming packets with deduplication - Aggregates packet repeats across multiple paths - Manages particle queue and animation timing @@ -35,19 +33,32 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An - `nodesRef`: Map of node ID → GraphNode - `linksRef`: Map of link key → GraphLink - `particlesRef`: Array of active Particle objects -- `simulationRef`: D3 force simulation instance +- `simulationRef`: d3-force-3d simulation instance - `pendingRef`: Packets in observation window awaiting animation - `timersRef`: Per-packet publish timers -### Rendering Layer (canvas drawing functions) +### Rendering Layer (Three.js) -Separate pure functions handle all canvas rendering: +- `THREE.WebGLRenderer` + `CSS2DRenderer` (text labels overlaid on 3D scene) +- `OrbitControls` for camera interaction (orbit, pan, zoom) +- `THREE.Mesh` with `SphereGeometry` per node + `CSS2DObject` labels +- `THREE.LineSegments` for links (persistent geometries; position buffers updated in-place each frame) +- `THREE.Points` with vertex colors for particles (persistent geometry + circular sprite texture) +- `THREE.Raycaster` for hover/click detection on node spheres -- `renderLinks()`: Draws connections between nodes -- `renderParticles()`: Draws animated packets with labels -- `renderNodes()`: Draws node circles with emojis/text +### Shared Utilities (`utils/visualizerUtils.ts`) -The main component orchestrates rendering via `requestAnimationFrame`. +Types, constants, and pure functions shared across the codebase: + +- Types: `NodeType`, `PacketLabel`, `Particle`, `ObservedPath`, `PendingPacket`, `ParsedPacket`, `TrafficObservation`, `RepeaterTrafficData`, `RepeaterSplitAnalysis` +- Constants: `COLORS`, `PARTICLE_COLOR_MAP`, `PARTICLE_SPEED`, `DEFAULT_OBSERVATION_WINDOW_SEC`, traffic thresholds, `PACKET_LEGEND_ITEMS` +- Functions: `simpleHash`, `parsePacket`, `getPacketLabel`, `generatePacketKey`, `getLinkId`, `getNodeType`, `dedupeConsecutive`, `analyzeRepeaterTraffic`, `recordTrafficObservation` + +`GraphNode` and `GraphLink` are defined locally in the component — they extend `SimulationNodeDatum3D` and `SimulationLinkDatum` from `d3-force-3d`. + +### Type Declarations (`types/d3-force-3d.d.ts`) + +Minimal `.d.ts` file for `d3-force-3d` which has no bundled TypeScript types. Declares `SimulationNodeDatum3D` extending `SimulationNodeDatum` with `z`/`vz`/`fz` properties. ## Packet Processing Pipeline @@ -70,12 +81,14 @@ packets.forEach((packet) => { Packets are grouped by a unique key to aggregate repeats: -| Packet Type | Key Format | -| -------------- | ----------------------------------------- | -| Advertisement | `ad:{pubkey_prefix_12}` | -| Group Text | `gt:{channel}:{sender}:{content_hash}` | -| Direct Message | `dm:{src_hash}:{dst_hash}:{content_hash}` | -| Other | `other:{data_hash}` | +| Packet Type | Key Format | +| -------------- | ------------------------------------------------------ | +| Advertisement | `ad:{pubkey_prefix_12}` | +| Group Text | `gt:{channel}:{sender}:{message_hash_or_data_hash}` | +| Direct Message | `dm:{src_hash}:{dst_hash}:{message_hash_or_data_hash}` | +| Other | `other:{message_hash_or_data_hash}` | + +`parsePacket()` exposes decoder `messageHash` (path-insensitive). `generatePacketKey()` prefers that hash, falling back to a local data hash for malformed/unsupported packets. ### 3. Observation Window @@ -86,7 +99,7 @@ if (existing && now < existing.expiresAt) { // Append path to existing entry existing.paths.push({ nodes: path, snr: packet.snr, timestamp: now }); } else { - // Create new pending entry with 2-second observation window + // Create new pending entry with observation window pendingPacketsRef.current.set(key, { key, label, @@ -120,25 +133,28 @@ function publishPacket(pending: PendingPacket) { **Key insight:** Particles start with negative progress. This creates smooth flow through multi-hop paths without pausing at intermediate nodes. -## D3 Force Simulation +## D3 Force Simulation (3D) -The layout uses D3's force simulation with these forces: +The layout uses `d3-force-3d` with `.numDimensions(3)`: -| Force | Purpose | -| ------------- | ---------------------------------------------------- | -| `link` | Pulls connected nodes together | -| `charge` | Repels nodes from each other (self node 6x stronger) | -| `center` | Gently pulls graph toward center | -| `collide` | Prevents node overlap | -| `selfX/selfY` | Anchors self node near center | +| Force | Purpose | +| ------------------- | ---------------------------------------------- | +| `link` | Pulls connected nodes together (distance: 120) | +| `charge` | Repels nodes (self node 6x stronger, max: 800) | +| `center` | Gently pulls graph toward origin (0, 0, 0) | +| `selfX/selfY/selfZ` | Anchors self node near origin | ### Shuffle Layout -The "Shuffle layout" button randomizes all node positions (except self, which stays centered) and reheats the simulation to alpha=1. This lets users try different random starting configurations to find a cleaner layout. +The "Shuffle layout" button randomizes all node positions in a 3D sphere (radius 200, except self stays at origin) and reheats the simulation to alpha=1. ### Continuous Drift -When "Let 'em drift" is enabled, `alphaTarget(0.05)` keeps the simulation running indefinitely, allowing the graph to continuously reorganize into better layouts. +When "Let 'em drift" is enabled, `alphaTarget(0.05)` keeps the simulation running indefinitely, allowing the graph to continuously reorganize. + +### Expand/Contract ("Oooh Big Stretch!") + +Temporarily increases repulsion to push nodes apart, then relaxes back. Useful for untangling dense graphs. ## Node Resolution @@ -147,7 +163,7 @@ Nodes are resolved from various sources: ```typescript function resolveNode(source, isRepeater, showAmbiguous): string | null { // source.type can be: 'pubkey', 'prefix', or 'name' - // Try to find matching contact + // Use precomputed contact indexes (by 12-char prefix, by name, by shorter prefixes) // If found: use full 12-char prefix as node ID // If not found and showAmbiguous: create "?prefix" node // Otherwise: return null (path terminates) @@ -156,7 +172,7 @@ function resolveNode(source, isRepeater, showAmbiguous): string | null { ### Ambiguous Nodes -When only a 1-byte prefix is known (from packet path bytes), the node is marked ambiguous and shown with a `?` prefix and gray styling. +When only a 1-byte prefix is known (from packet path bytes), the node is marked ambiguous and shown with a `?` prefix and gray styling. However, if the node is identified as a repeater (via advert or path hop), it shows blue regardless of ambiguity. ### Traffic Pattern Splitting (Experimental) @@ -198,7 +214,6 @@ Here source `ae` routes through `32` to BOTH `ba` and `60`. This proves `32` is - Source sets are completely disjoint (no source appears in multiple groups) - Each group has at least 20 unique sources (conservative threshold) 4. **Final repeaters** (no next hop, connects directly to self): Never split - - Rationale: The last repeater before you is clearly a single physical node **Node ID format:** @@ -206,12 +221,6 @@ Here source `ae` routes through `32` to BOTH `ba` and `60`. This proves `32` is - With splitting (after evidence threshold met): `?XX:>YY` (e.g., `?32:>ba`) - Final repeater: `?XX` (unchanged, no suffix) -**Implementation Notes:** - -- Observations are stored with timestamps and pruned after 30 minutes -- Maximum 200 observations per prefix to limit memory -- Once split, nodes cannot be un-split (be conservative before splitting) - ## Path Building Paths are constructed from packet data: @@ -243,8 +252,6 @@ function buildPath(parsed, packet, myPrefix): string[] { ### Sender Extraction by Packet Type -Different packet types provide different levels of sender identification: - | Packet Type | Sender Info Available | Resolution | | -------------- | ------------------------------ | ------------------------------ | | Advertisement | Full 32-byte public key | Exact contact match | @@ -254,106 +261,85 @@ Different packet types provide different levels of sender identification: | Request | 1-byte source hash | Ambiguous | | Other | None | Path bytes only | -**AnonRequest packets** are particularly useful because they include the sender's full public key (unlike regular Request packets which only have a 1-byte hash). This allows exact identification of who is making the request. +## Node Colors -## Canvas Rendering - -### Coordinate Transformation - -Pan and zoom are applied via transform matrix: - -```typescript -ctx.setTransform(dpr * scale, 0, 0, dpr * scale, dpr * (x + panX), dpr * (y + panY)); -``` - -### Render Order - -1. Clear canvas with background -2. Draw links (gray lines) -3. Draw particles (colored dots with labels) -4. Draw nodes (circles with emojis) -5. Draw hover tooltip if applicable +| Color | Hex | Meaning | +| ------- | --------- | ---------------------------------------------- | +| Green | `#22c55e` | Self node (larger sphere) | +| Blue | `#3b82f6` | Repeater | +| White | `#ffffff` | Client | +| Gray | `#9ca3af` | Unknown/ambiguous (not identified as repeater) | +| Gold | `#ffd700` | Active (hovered or pinned) node | +| Lt Gold | `#fff0b3` | Neighbors of active node | ## Mouse Interactions -| Action | Behavior | -| -------------------------- | ------------------------------------------------ | -| Click + drag on node | Move node to new position (temporarily fixes it) | -| Release dragged node | Node returns to force-directed layout | -| Click + drag on empty area | Pan the canvas | -| Scroll wheel | Zoom in/out | -| Hover over node | Shows node details, cursor changes to pointer | +| Action | Behavior | +| -------------------------- | ------------------------------------------- | +| Left-click on node | Pin: highlight node + links + neighbors | +| Left-click pinned node | Unpin: remove highlights | +| Left-click empty space | Unpin any pinned node | +| Hover over node | Shows tooltip with node details + neighbors | +| Orbit (left-drag on space) | Rotate camera around scene | +| Pan (right-drag) | Pan the camera | +| Scroll wheel | Zoom in/out | -**Node Dragging Implementation:** - -- On mouse down over a node, sets `fx`/`fy` (D3 fixed position) to lock it -- On mouse move, updates the fixed position to follow cursor -- On mouse up, clears `fx`/`fy` so node rejoins the simulation -- Simulation is slightly reheated during drag for responsive feedback +**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" listing all connected neighbors with their possible names. ## Configuration Options | Option | Default | Description | | -------------------------- | ------- | --------------------------------------------------------- | -| Ambiguous path repeaters | On | Show nodes when only partial prefix known | +| Ambiguous repeaters | On | Show nodes when only partial prefix known | | Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders | | Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) | -| Hide repeaters >48hrs | Off | Filter out old repeaters | | Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) | | Let 'em drift | On | Continuous layout optimization | | Repulsion | 200 | Force strength (50-2500) | | Packet speed | 2x | Particle animation speed multiplier (1x-5x) | | Shuffle layout | - | Button to randomize node positions and reheat sim | | Oooh Big Stretch! | - | Button to temporarily increase repulsion then relax | +| Clear & Reset | - | Button to clear all nodes, links, and packets | | Hide UI | Off | Hide legends and most controls for cleaner view | | Full screen | Off | Hide the packet feed panel (desktop only) | ## File Structure ``` -PacketVisualizer.tsx -├── TYPES (GraphNode, GraphLink, Particle, etc.) -├── CONSTANTS (colors, timing, legend items) -├── UTILITY FUNCTIONS -│ ├── simpleHash() -│ ├── parsePacket() -│ ├── getPacketLabel() -│ ├── generatePacketKey() -│ ├── findContactBy*() -│ ├── dedupeConsecutive() -│ ├── analyzeRepeaterTraffic() -│ └── recordTrafficObservation() -├── DATA LAYER HOOK (useVisualizerData) -│ ├── Refs (nodes, links, particles, simulation, pending, timers, trafficPatterns) -│ ├── Simulation initialization +PacketVisualizer3D.tsx +├── TYPES (GraphNode extends SimulationNodeDatum3D, GraphLink) +├── CONSTANTS (NODE_COLORS, NODE_LEGEND_ITEMS) +├── DATA LAYER HOOK (useVisualizerData3D) +│ ├── Refs (nodes, links, particles, simulation, pending, timers, trafficPatterns, stretchRaf) +│ ├── d3-force-3d simulation initialization (.numDimensions(3)) +│ ├── Contact indexing (byPrefix12 / byName / byPrefix) │ ├── Node/link management (addNode, addLink, syncSimulation) │ ├── Path building (resolveNode, buildPath) │ ├── Traffic pattern analysis (for repeater disambiguation) │ └── Packet processing & publishing -├── RENDERING FUNCTIONS -│ ├── renderLinks() -│ ├── renderParticles() -│ └── renderNodes() -└── MAIN COMPONENT (PacketVisualizer) - ├── State (dimensions, options, transform, hover) - ├── Event handlers (mouse, wheel) - ├── Animation loop - └── JSX (canvas, legend, settings panel) +└── MAIN COMPONENT (PacketVisualizer3D) + ├── Three.js scene setup (WebGLRenderer, CSS2DRenderer, OrbitControls) + ├── Node mesh management (SphereGeometry + CSS2DObject labels) + ├── Link rendering (persistent LineSegments + dynamic position buffer updates) + ├── Particle rendering (persistent Points + dynamic position/color buffer updates) + ├── Raycasting (hover detection, click-to-pin) + ├── State (options, pinned/hovered node, neighbors; change-detected UI updates) + └── JSX (container, legend overlay, options panel, tooltip) + +utils/visualizerUtils.ts +├── Types (NodeType, PacketLabel, Particle, PendingPacket, ParsedPacket, etc.) +├── Constants (COLORS, PARTICLE_COLOR_MAP, PARTICLE_SPEED, PACKET_LEGEND_ITEMS) +└── Functions (parsePacket, generatePacketKey, analyzeRepeaterTraffic, etc.) + +types/d3-force-3d.d.ts +└── Type declarations for d3-force-3d (SimulationNodeDatum3D, Simulation3D, forces) ``` ## Performance Considerations -- **Observation window**: 2 seconds balances latency vs. path aggregation -- **Max links**: Capped at 100 to prevent graph explosion +- **Observation window**: Configurable (default 15s) to balance latency vs. path aggregation +- **Persistent geometries**: Links/highlights/particles are created once; buffers are updated per frame to reduce GC/GPU churn - **Particle culling**: Particles removed when progress > 1 -- **Node filtering**: Old repeaters can be hidden to reduce clutter +- **Change-detected React updates**: Hover + neighbor UI state updates only when values change - **requestAnimationFrame**: Render loop tied to display refresh rate - -## Future Improvements - -The data/rendering split enables: - -- WebGL rendering for larger graphs -- 3D visualization -- Different layout algorithms -- Export to other formats +- **CSS2DRenderer z-index**: Set to 1 so UI overlays (z-10) render above node labels diff --git a/frontend/src/components/PacketVisualizer.tsx b/frontend/src/components/PacketVisualizer.tsx deleted file mode 100644 index 12e86bb..0000000 --- a/frontend/src/components/PacketVisualizer.tsx +++ /dev/null @@ -1,1712 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import { - forceSimulation, - forceLink, - forceManyBody, - forceCenter, - forceCollide, - forceX, - forceY, - type Simulation, - type SimulationNodeDatum, - type SimulationLinkDatum, -} from 'd3-force'; -import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder'; -import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket, type RadioConfig } from '../types'; -import { Checkbox } from './ui/checkbox'; - -// ============================================================================= -// TYPES -// ============================================================================= - -type NodeType = 'self' | 'repeater' | 'client'; -type PacketLabel = 'AD' | 'GT' | 'DM' | 'ACK' | 'TR' | 'RQ' | 'RS' | '?'; - -interface GraphNode extends SimulationNodeDatum { - id: string; - name: string | null; - type: NodeType; - isAmbiguous: boolean; - lastActivity: number; - lastSeen?: number | null; - ambiguousNames?: string[]; - x?: number; - y?: number; - vx?: number; - vy?: number; - fx?: number | null; - fy?: number | null; -} - -interface GraphLink extends SimulationLinkDatum { - source: string | GraphNode; - target: string | GraphNode; - lastActivity: number; -} - -interface Particle { - linkKey: string; - progress: number; - speed: number; - color: string; - label: PacketLabel; - fromNodeId: string; - toNodeId: string; -} - -interface ObservedPath { - nodes: string[]; - snr: number | null; - timestamp: number; -} - -interface PendingPacket { - key: string; - label: PacketLabel; - paths: ObservedPath[]; - firstSeen: number; - expiresAt: number; -} - -interface ParsedPacket { - payloadType: number; - pathBytes: string[]; - srcHash: string | null; - dstHash: string | null; - advertPubkey: string | null; - groupTextSender: string | null; - anonRequestPubkey: string | null; -} - -// Traffic pattern tracking for smarter repeater disambiguation -interface TrafficObservation { - source: string; // Node that originated traffic (could be resolved node ID or ambiguous) - nextHop: string | null; // Next hop after this repeater (null if final hop before self) - timestamp: number; -} - -interface RepeaterTrafficData { - prefix: string; // The 1-byte hex prefix (e.g., "32") - observations: TrafficObservation[]; -} - -// Analysis result for whether to split an ambiguous repeater -interface RepeaterSplitAnalysis { - shouldSplit: boolean; - // If shouldSplit, maps nextHop -> the sources that exclusively route through it - disjointGroups: Map> | null; -} - -// ============================================================================= -// CONSTANTS -// ============================================================================= - -const COLORS = { - background: '#0a0a0a', - link: '#4b5563', - ambiguous: '#9ca3af', - particleAD: '#f59e0b', // amber - advertisements - particleGT: '#06b6d4', // cyan - group text - particleDM: '#8b5cf6', // purple - direct messages - particleACK: '#22c55e', // green - acknowledgments - particleTR: '#f97316', // orange - trace packets - particleRQ: '#ec4899', // pink - requests - particleRS: '#14b8a6', // teal - responses - particleUnknown: '#6b7280', // gray - unknown -} as const; - -const PARTICLE_COLOR_MAP: Record = { - AD: COLORS.particleAD, - GT: COLORS.particleGT, - DM: COLORS.particleDM, - ACK: COLORS.particleACK, - TR: COLORS.particleTR, - RQ: COLORS.particleRQ, - RS: COLORS.particleRS, - '?': COLORS.particleUnknown, -}; - -const PARTICLE_SPEED = 0.008; -const DEFAULT_OBSERVATION_WINDOW_SEC = 15; -const FORTY_EIGHT_HOURS_MS = 48 * 60 * 60 * 1000; - -// Traffic pattern analysis thresholds -// Be conservative - once split, we can't unsplit, so require strong evidence -const MIN_OBSERVATIONS_TO_SPLIT = 20; // Need at least this many unique sources per next-hop group -const MAX_TRAFFIC_OBSERVATIONS = 200; // Per ambiguous prefix, to limit memory -const TRAFFIC_OBSERVATION_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes - old observations are pruned - -const LEGEND_ITEMS = [ - { emoji: '🟢', label: 'You', size: 'text-xl' }, - { emoji: '📡', label: 'Repeater', size: 'text-base' }, - { emoji: '👤', label: 'Node', size: 'text-base' }, - { emoji: '❓', label: 'Unknown', size: 'text-base' }, -] as const; - -const PACKET_LEGEND_ITEMS = [ - { label: 'AD', color: COLORS.particleAD, description: 'Advertisement' }, - { label: 'GT', color: COLORS.particleGT, description: 'Group Text' }, - { label: 'DM', color: COLORS.particleDM, description: 'Direct Message' }, - { label: 'ACK', color: COLORS.particleACK, description: 'Acknowledgment' }, - { label: 'TR', color: COLORS.particleTR, description: 'Trace' }, - { label: 'RQ', color: COLORS.particleRQ, description: 'Request' }, - { label: 'RS', color: COLORS.particleRS, description: 'Response' }, - { label: '?', color: COLORS.particleUnknown, description: 'Other' }, -] as const; - -// ============================================================================= -// UTILITY FUNCTIONS (Data Layer) -// ============================================================================= - -function simpleHash(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = (hash << 5) - hash + str.charCodeAt(i); - hash = hash & hash; - } - return Math.abs(hash).toString(16).padStart(8, '0'); -} - -function parsePacket(hexData: string): ParsedPacket | null { - try { - const decoded = MeshCoreDecoder.decode(hexData); - if (!decoded.isValid) return null; - - const result: ParsedPacket = { - payloadType: decoded.payloadType, - pathBytes: decoded.path || [], - srcHash: null, - dstHash: null, - advertPubkey: null, - groupTextSender: null, - anonRequestPubkey: null, - }; - - if (decoded.payloadType === PayloadType.TextMessage && decoded.payload.decoded) { - const payload = decoded.payload.decoded as { sourceHash?: string; destinationHash?: string }; - result.srcHash = payload.sourceHash || null; - result.dstHash = payload.destinationHash || null; - } else if (decoded.payloadType === PayloadType.Advert && decoded.payload.decoded) { - result.advertPubkey = (decoded.payload.decoded as { publicKey?: string }).publicKey || null; - } else if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) { - const payload = decoded.payload.decoded as { decrypted?: { sender?: string } }; - result.groupTextSender = payload.decrypted?.sender || null; - } else if (decoded.payloadType === PayloadType.AnonRequest && decoded.payload.decoded) { - const payload = decoded.payload.decoded as { senderPublicKey?: string }; - result.anonRequestPubkey = payload.senderPublicKey || null; - } - - return result; - } catch { - return null; - } -} - -function getPacketLabel(payloadType: number): PacketLabel { - switch (payloadType) { - case PayloadType.Advert: - return 'AD'; - case PayloadType.GroupText: - return 'GT'; - case PayloadType.TextMessage: - return 'DM'; - case PayloadType.Ack: - return 'ACK'; - case PayloadType.Trace: - return 'TR'; - case PayloadType.Request: - case PayloadType.AnonRequest: - return 'RQ'; - case PayloadType.Response: - return 'RS'; - default: - return '?'; - } -} - -function generatePacketKey(parsed: ParsedPacket, rawPacket: RawPacket): string { - const contentHash = simpleHash(rawPacket.data).slice(0, 8); - - if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { - return `ad:${parsed.advertPubkey.slice(0, 12)}`; - } - if (parsed.payloadType === PayloadType.GroupText) { - const sender = parsed.groupTextSender || rawPacket.decrypted_info?.sender || '?'; - const channel = rawPacket.decrypted_info?.channel_name || '?'; - return `gt:${channel}:${sender}:${contentHash}`; - } - if (parsed.payloadType === PayloadType.TextMessage) { - return `dm:${parsed.srcHash || '?'}:${parsed.dstHash || '?'}:${contentHash}`; - } - if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) { - return `rq:${parsed.anonRequestPubkey.slice(0, 12)}:${contentHash}`; - } - return `other:${contentHash}`; -} - -function getLinkId(link: GraphLink): { sourceId: string; targetId: string } { - return { - sourceId: typeof link.source === 'string' ? link.source : link.source.id, - targetId: typeof link.target === 'string' ? link.target : link.target.id, - }; -} - -function findContactByPrefix(prefix: string, contacts: Contact[]): Contact | null { - const normalized = prefix.toLowerCase(); - const matches = contacts.filter((c) => c.public_key.toLowerCase().startsWith(normalized)); - return matches.length === 1 ? matches[0] : null; -} - -function findContactsByPrefix(prefix: string, contacts: Contact[]): Contact[] { - const normalized = prefix.toLowerCase(); - return contacts.filter((c) => c.public_key.toLowerCase().startsWith(normalized)); -} - -function findContactByName(name: string, contacts: Contact[]): Contact | null { - return contacts.find((c) => c.name === name) || null; -} - -function getNodeType(contact: Contact | null | undefined): NodeType { - return contact?.type === CONTACT_TYPE_REPEATER ? 'repeater' : 'client'; -} - -function dedupeConsecutive(arr: T[]): T[] { - return arr.filter((item, i) => i === 0 || item !== arr[i - 1]); -} - -/** - * Analyze traffic patterns for an ambiguous repeater prefix to determine if it - * should be split into multiple nodes. - * - * Logic: - * - Group observations by nextHop - * - For each nextHop group, collect the set of sources - * - If any source appears in multiple nextHop groups → same physical node (hub), don't split - * - If source sets are completely disjoint → likely different physical nodes, split - * - * Returns shouldSplit=true only when we have enough evidence of disjoint routing. - */ -function analyzeRepeaterTraffic(data: RepeaterTrafficData): RepeaterSplitAnalysis { - const now = Date.now(); - - // Filter out old observations - const recentObservations = data.observations.filter( - (obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS - ); - - // Group by nextHop (use "self" for null nextHop - final repeater) - const byNextHop = new Map>(); - for (const obs of recentObservations) { - const hopKey = obs.nextHop ?? 'self'; - if (!byNextHop.has(hopKey)) { - byNextHop.set(hopKey, new Set()); - } - byNextHop.get(hopKey)!.add(obs.source); - } - - // If only one nextHop group, no need to split - if (byNextHop.size <= 1) { - return { shouldSplit: false, disjointGroups: null }; - } - - // Check if any source appears in multiple groups (evidence of hub behavior) - const allSources = new Map(); // source -> list of nextHops it uses - for (const [nextHop, sources] of byNextHop) { - for (const source of sources) { - if (!allSources.has(source)) { - allSources.set(source, []); - } - allSources.get(source)!.push(nextHop); - } - } - - // If any source routes to multiple nextHops, this is a hub - don't split - for (const [, nextHops] of allSources) { - if (nextHops.length > 1) { - return { shouldSplit: false, disjointGroups: null }; - } - } - - // Check if we have enough observations in each group to be confident - for (const [, sources] of byNextHop) { - if (sources.size < MIN_OBSERVATIONS_TO_SPLIT) { - // Not enough evidence yet - be conservative, don't split - return { shouldSplit: false, disjointGroups: null }; - } - } - - // Source sets are disjoint and we have enough data - split! - return { shouldSplit: true, disjointGroups: byNextHop }; -} - -/** - * Record a traffic observation for an ambiguous repeater prefix. - * Prunes old observations and limits total count. - */ -function recordTrafficObservation( - trafficData: Map, - prefix: string, - source: string, - nextHop: string | null -): void { - const normalizedPrefix = prefix.toLowerCase(); - const now = Date.now(); - - if (!trafficData.has(normalizedPrefix)) { - trafficData.set(normalizedPrefix, { prefix: normalizedPrefix, observations: [] }); - } - - const data = trafficData.get(normalizedPrefix)!; - - // Add new observation - data.observations.push({ source, nextHop, timestamp: now }); - - // Prune old observations - data.observations = data.observations.filter( - (obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS - ); - - // Limit total count - if (data.observations.length > MAX_TRAFFIC_OBSERVATIONS) { - data.observations = data.observations.slice(-MAX_TRAFFIC_OBSERVATIONS); - } -} - -// ============================================================================= -// DATA LAYER HOOK -// ============================================================================= - -interface UseVisualizerDataOptions { - packets: RawPacket[]; - contacts: Contact[]; - config: RadioConfig | null; - showAmbiguousPaths: boolean; - showAmbiguousNodes: boolean; - splitAmbiguousByTraffic: boolean; - chargeStrength: number; - letEmDrift: boolean; - particleSpeedMultiplier: number; - observationWindowSec: number; - dimensions: { width: number; height: number }; -} - -interface VisualizerData { - nodes: Map; - links: Map; - particles: Particle[]; - simulation: Simulation | null; - stats: { processed: number; animated: number; nodes: number; links: number }; - randomizePositions: () => void; - expandContract: () => void; - clearAndReset: () => void; -} - -function useVisualizerData({ - packets, - contacts, - config, - showAmbiguousPaths, - showAmbiguousNodes, - splitAmbiguousByTraffic, - chargeStrength, - letEmDrift, - particleSpeedMultiplier, - observationWindowSec, - dimensions, -}: UseVisualizerDataOptions): VisualizerData { - const nodesRef = useRef>(new Map()); - const linksRef = useRef>(new Map()); - const particlesRef = useRef([]); - const simulationRef = useRef | null>(null); - const processedRef = useRef>(new Set()); - const pendingRef = useRef>(new Map()); - const timersRef = useRef>>(new Map()); - const trafficPatternsRef = useRef>(new Map()); - const speedMultiplierRef = useRef(particleSpeedMultiplier); - const observationWindowRef = useRef(observationWindowSec * 1000); - const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 }); - - // Keep refs in sync with props - useEffect(() => { - speedMultiplierRef.current = particleSpeedMultiplier; - }, [particleSpeedMultiplier]); - - useEffect(() => { - observationWindowRef.current = observationWindowSec * 1000; - }, [observationWindowSec]); - - // Initialize simulation - useEffect(() => { - const sim = forceSimulation([]) - .force( - 'link', - forceLink([]) - .id((d) => d.id) - .distance(80) - .strength(0.3) - ) - .force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? -1200 : -200)) - .distanceMax(500) - ) - .force('center', forceCenter(dimensions.width / 2, dimensions.height / 2)) - .force('collide', forceCollide(40)) - .force( - 'selfX', - forceX(dimensions.width / 2).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ) - .force( - 'selfY', - forceY(dimensions.height / 2).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ) - .alphaDecay(0.02) - .velocityDecay(0.5) - .alphaTarget(0.03); - - simulationRef.current = sim; - return () => { - sim.stop(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- one-time init; dimensions/charge handled by the effect below - }, []); - - // Update simulation forces when dimensions/charge change - useEffect(() => { - const sim = simulationRef.current; - if (!sim) return; - - sim.force('center', forceCenter(dimensions.width / 2, dimensions.height / 2)); - sim.force( - 'selfX', - forceX(dimensions.width / 2).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ); - sim.force( - 'selfY', - forceY(dimensions.height / 2).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ); - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength)) - .distanceMax(500) - ); - sim.alpha(0.3).restart(); - }, [dimensions, chargeStrength]); - - // Update alphaTarget when drift preference changes - useEffect(() => { - const sim = simulationRef.current; - if (!sim) return; - sim.alphaTarget(letEmDrift ? 0.05 : 0); - }, [letEmDrift]); - - // Ensure self node exists - useEffect(() => { - if (!nodesRef.current.has('self')) { - nodesRef.current.set('self', { - id: 'self', - name: config?.name || 'Me', - type: 'self', - isAmbiguous: false, - lastActivity: Date.now(), - x: dimensions.width / 2, - y: dimensions.height / 2, - }); - syncSimulation(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- syncSimulation is stable (no deps), defined below - }, [config, dimensions]); - - // Reset on option changes - useEffect(() => { - processedRef.current.clear(); - const selfNode = nodesRef.current.get('self'); - nodesRef.current.clear(); - if (selfNode) nodesRef.current.set('self', selfNode); - linksRef.current.clear(); - particlesRef.current = []; - pendingRef.current.clear(); - timersRef.current.forEach((t) => clearTimeout(t)); - timersRef.current.clear(); - trafficPatternsRef.current.clear(); - setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); - }, [showAmbiguousPaths, showAmbiguousNodes, splitAmbiguousByTraffic]); - - const syncSimulation = useCallback(() => { - const sim = simulationRef.current; - if (!sim) return; - - const nodes = Array.from(nodesRef.current.values()); - const links = Array.from(linksRef.current.values()); - - sim.nodes(nodes); - const linkForce = sim.force('link') as ReturnType>; - linkForce?.links(links); - - sim.alpha(0.15).restart(); - - setStats((prev) => ({ ...prev, nodes: nodes.length, links: links.length })); - }, []); - - const addNode = useCallback( - ( - id: string, - name: string | null, - type: NodeType, - isAmbiguous: boolean, - ambiguousNames?: string[], - lastSeen?: number | null - ) => { - const existing = nodesRef.current.get(id); - if (existing) { - existing.lastActivity = Date.now(); - if (name && !existing.name) existing.name = name; - if (ambiguousNames) existing.ambiguousNames = ambiguousNames; - if (lastSeen !== undefined) existing.lastSeen = lastSeen; - } else { - const selfNode = nodesRef.current.get('self'); - nodesRef.current.set(id, { - id, - name, - type, - isAmbiguous, - lastActivity: Date.now(), - lastSeen, - ambiguousNames, - x: (selfNode?.x ?? 400) + (Math.random() - 0.5) * 100, - y: (selfNode?.y ?? 300) + (Math.random() - 0.5) * 100, - }); - } - }, - [] - ); - - const addLink = useCallback((sourceId: string, targetId: string) => { - const key = [sourceId, targetId].sort().join('->'); - const existing = linksRef.current.get(key); - if (existing) { - existing.lastActivity = Date.now(); - } else { - linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: Date.now() }); - } - }, []); - - const publishPacket = useCallback((packetKey: string) => { - const pending = pendingRef.current.get(packetKey); - if (!pending) return; - - pendingRef.current.delete(packetKey); - timersRef.current.delete(packetKey); - - for (const path of pending.paths) { - const dedupedPath = dedupeConsecutive(path.nodes); - if (dedupedPath.length < 2) continue; - - for (let i = 0; i < dedupedPath.length - 1; i++) { - particlesRef.current.push({ - linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'), - progress: -i, - speed: PARTICLE_SPEED * speedMultiplierRef.current, - color: PARTICLE_COLOR_MAP[pending.label], - label: pending.label, - fromNodeId: dedupedPath[i], - toNodeId: dedupedPath[i + 1], - }); - } - } - }, []); - - // Resolve a node from various sources and add to graph - // trafficContext is used when splitAmbiguousByTraffic is enabled to create - // separate nodes for ambiguous repeaters based on their position in traffic flow - // myPrefix is the user's own 12-char pubkey prefix - if a node matches, return 'self' - // trafficContext.packetSource is the original source of the packet (for traffic analysis) - // trafficContext.nextPrefix is the next hop after this repeater - const resolveNode = useCallback( - ( - source: { type: 'prefix' | 'pubkey' | 'name'; value: string }, - isRepeater: boolean, - showAmbiguous: boolean, - myPrefix: string | null, - trafficContext?: { packetSource: string | null; nextPrefix: string | null } - ): string | null => { - if (source.type === 'pubkey') { - if (source.value.length < 12) return null; - const nodeId = source.value.slice(0, 12).toLowerCase(); - // Check if this is our own identity - return 'self' instead of creating duplicate node - if (myPrefix && nodeId === myPrefix) { - return 'self'; - } - const contact = contacts.find((c) => c.public_key.toLowerCase().startsWith(nodeId)); - addNode( - nodeId, - contact?.name || null, - getNodeType(contact), - false, - undefined, - contact?.last_seen - ); - return nodeId; - } - - if (source.type === 'name') { - const contact = findContactByName(source.value, contacts); - if (contact) { - const nodeId = contact.public_key.slice(0, 12).toLowerCase(); - // Check if this is our own identity - if (myPrefix && nodeId === myPrefix) { - return 'self'; - } - addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen); - return nodeId; - } - const nodeId = `name:${source.value}`; - addNode(nodeId, source.value, 'client', false); - return nodeId; - } - - // type === 'prefix' - const contact = findContactByPrefix(source.value, contacts); - if (contact) { - const nodeId = contact.public_key.slice(0, 12).toLowerCase(); - // Check if this is our own identity - if (myPrefix && nodeId === myPrefix) { - return 'self'; - } - addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen); - return nodeId; - } - - if (showAmbiguous) { - const matches = findContactsByPrefix(source.value, contacts); - const filtered = isRepeater - ? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER) - : matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER); - - // If exactly one match after filtering, use it directly (not ambiguous) - if (filtered.length === 1) { - const contact = filtered[0]; - const nodeId = contact.public_key.slice(0, 12).toLowerCase(); - addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen); - return nodeId; - } - - // Multiple matches or no matches - create ambiguous node - // When splitAmbiguousByTraffic is enabled for repeaters, use traffic pattern analysis - if (filtered.length > 1 || (filtered.length === 0 && isRepeater)) { - const names = filtered.map((c) => c.name || c.public_key.slice(0, 8)); - const lastSeen = filtered.reduce( - (max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max), - null as number | null - ); - - // Default: simple ambiguous node ID - let nodeId = `?${source.value.toLowerCase()}`; - let displayName = source.value.toUpperCase(); - - // When splitAmbiguousByTraffic is enabled, use traffic pattern analysis - if (splitAmbiguousByTraffic && isRepeater && trafficContext) { - const prefix = source.value.toLowerCase(); - - // Record observation for traffic analysis (only if we have a packet source) - if (trafficContext.packetSource) { - recordTrafficObservation( - trafficPatternsRef.current, - prefix, - trafficContext.packetSource, - trafficContext.nextPrefix - ); - } - - // Analyze traffic patterns to decide if we should split - const trafficData = trafficPatternsRef.current.get(prefix); - if (trafficData) { - const analysis = analyzeRepeaterTraffic(trafficData); - - if (analysis.shouldSplit && trafficContext.nextPrefix) { - // Strong evidence of disjoint routing - split by next hop - const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase(); - nodeId = `?${prefix}:>${nextShort}`; - displayName = `${source.value.toUpperCase()}:>${nextShort}`; - } - // If analysis says don't split, or this is the final repeater (nextPrefix=null), - // keep the simple ?XX ID - } - } - - addNode( - nodeId, - displayName, - isRepeater ? 'repeater' : 'client', - true, - names.length > 0 ? names : undefined, - lastSeen - ); - return nodeId; - } - } - - return null; - }, - [contacts, addNode, splitAmbiguousByTraffic] - ); - - // Build path from parsed packet - const buildPath = useCallback( - (parsed: ParsedPacket, packet: RawPacket, myPrefix: string | null): string[] => { - const path: string[] = []; - let packetSource: string | null = null; - - // Add source - and track it for traffic pattern analysis - if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { - const nodeId = resolveNode( - { type: 'pubkey', value: parsed.advertPubkey }, - false, - false, - myPrefix - ); - if (nodeId) { - path.push(nodeId); - packetSource = nodeId; - } - } else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) { - // AnonRequest packets contain the full sender public key - const nodeId = resolveNode( - { type: 'pubkey', value: parsed.anonRequestPubkey }, - false, - false, - myPrefix - ); - if (nodeId) { - path.push(nodeId); - packetSource = nodeId; - } - } else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) { - if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) { - path.push('self'); - packetSource = 'self'; - } else { - const nodeId = resolveNode( - { type: 'prefix', value: parsed.srcHash }, - false, - showAmbiguousNodes, - myPrefix - ); - if (nodeId) { - path.push(nodeId); - packetSource = nodeId; - } - } - } else if (parsed.payloadType === PayloadType.GroupText) { - const senderName = parsed.groupTextSender || packet.decrypted_info?.sender; - if (senderName) { - const nodeId = resolveNode({ type: 'name', value: senderName }, false, false, myPrefix); - if (nodeId) { - path.push(nodeId); - packetSource = nodeId; - } - } - } - - // Add path bytes (repeaters) - // Pass packetSource for traffic pattern analysis (used to track which sources route through which repeaters) - for (let i = 0; i < parsed.pathBytes.length; i++) { - const hexPrefix = parsed.pathBytes[i]; - const nextPrefix = parsed.pathBytes[i + 1] || null; - - const nodeId = resolveNode( - { type: 'prefix', value: hexPrefix }, - true, - showAmbiguousPaths, - myPrefix, - { - packetSource, - nextPrefix, - } - ); - if (nodeId) path.push(nodeId); - } - - // Add destination - if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) { - if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) { - path.push('self'); - } else { - const nodeId = resolveNode( - { type: 'prefix', value: parsed.dstHash }, - false, - showAmbiguousNodes, - myPrefix - ); - if (nodeId) path.push(nodeId); - else path.push('self'); - } - } else if (path.length > 0) { - path.push('self'); - } - - // Ensure ends with self - if (path.length > 0 && path[path.length - 1] !== 'self') { - path.push('self'); - } - - return dedupeConsecutive(path); - }, - [resolveNode, showAmbiguousPaths, showAmbiguousNodes] - ); - - // Process packets - useEffect(() => { - let newProcessed = 0; - let newAnimated = 0; - let needsUpdate = false; - const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null; - - for (const packet of packets) { - if (processedRef.current.has(packet.id)) continue; - processedRef.current.add(packet.id); - newProcessed++; - - // Limit processed set size - if (processedRef.current.size > 1000) { - processedRef.current = new Set(Array.from(processedRef.current).slice(-500)); - } - - const parsed = parsePacket(packet.data); - if (!parsed) continue; - - const path = buildPath(parsed, packet, myPrefix); - if (path.length < 2) continue; - - // Create links - for (let i = 0; i < path.length - 1; i++) { - if (path[i] !== path[i + 1]) { - addLink(path[i], path[i + 1]); - needsUpdate = true; - } - } - - // Queue for animation - const packetKey = generatePacketKey(parsed, packet); - const now = Date.now(); - const existing = pendingRef.current.get(packetKey); - - if (existing && now < existing.expiresAt) { - existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now }); - } else { - if (timersRef.current.has(packetKey)) { - clearTimeout(timersRef.current.get(packetKey)); - } - const windowMs = observationWindowRef.current; - pendingRef.current.set(packetKey, { - key: packetKey, - label: getPacketLabel(parsed.payloadType), - paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }], - firstSeen: now, - expiresAt: now + windowMs, - }); - timersRef.current.set( - packetKey, - setTimeout(() => publishPacket(packetKey), windowMs) - ); - } - - // Limit pending size - if (pendingRef.current.size > 100) { - const entries = Array.from(pendingRef.current.entries()) - .sort((a, b) => a[1].firstSeen - b[1].firstSeen) - .slice(0, 50); - for (const [key] of entries) { - clearTimeout(timersRef.current.get(key)); - timersRef.current.delete(key); - pendingRef.current.delete(key); - } - } - - newAnimated++; - } - - if (needsUpdate) syncSimulation(); - if (newProcessed > 0) { - setStats((prev) => ({ - ...prev, - processed: prev.processed + newProcessed, - animated: prev.animated + newAnimated, - })); - } - }, [packets, config, buildPath, addLink, syncSimulation, publishPacket]); - - // Randomize all node positions (except self) and reheat simulation - const randomizePositions = useCallback(() => { - const sim = simulationRef.current; - if (!sim) return; - - const centerX = dimensions.width / 2; - const centerY = dimensions.height / 2; - const radius = Math.min(dimensions.width, dimensions.height) * 0.4; - - for (const node of nodesRef.current.values()) { - if (node.id === 'self') { - // Keep self at center - node.x = centerX; - node.y = centerY; - } else { - // Randomize position in a circle around center - const angle = Math.random() * 2 * Math.PI; - const r = Math.random() * radius; - node.x = centerX + r * Math.cos(angle); - node.y = centerY + r * Math.sin(angle); - } - // Clear velocities - node.vx = 0; - node.vy = 0; - } - - // Reheat simulation strongly - sim.alpha(1).restart(); - }, [dimensions]); - - // Expand to high repulsion, hold, then contract back - // Also weakens link force during expansion so nodes can actually separate - const expandContract = useCallback(() => { - const sim = simulationRef.current; - if (!sim) return; - - const startChargeStrength = chargeStrength; - const peakChargeStrength = -5000; - const startLinkStrength = 0.3; - const minLinkStrength = 0.02; // Nearly disable links during expansion - const expandDuration = 1000; - const holdDuration = 2000; - const contractDuration = 1000; - const startTime = performance.now(); - - const animate = (now: number) => { - const elapsed = now - startTime; - let currentChargeStrength: number; - let currentLinkStrength: number; - - if (elapsed < expandDuration) { - // Expanding: ramp up repulsion, weaken links - const t = elapsed / expandDuration; - currentChargeStrength = - startChargeStrength + (peakChargeStrength - startChargeStrength) * t; - currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t; - } else if (elapsed < expandDuration + holdDuration) { - // Hold: stay at peak repulsion, links weak - currentChargeStrength = peakChargeStrength; - currentLinkStrength = minLinkStrength; - } else if (elapsed < expandDuration + holdDuration + contractDuration) { - // Contracting: restore both forces - const t = (elapsed - expandDuration - holdDuration) / contractDuration; - currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t; - currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t; - } else { - // Done - restore originals - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength)) - .distanceMax(500) - ); - sim.force( - 'link', - forceLink(Array.from(linksRef.current.values())) - .id((d) => d.id) - .distance(80) - .strength(startLinkStrength) - ); - sim.alpha(0.3).restart(); - return; - } - - // Apply current strengths - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength)) - .distanceMax(500) - ); - sim.force( - 'link', - forceLink(Array.from(linksRef.current.values())) - .id((d) => d.id) - .distance(80) - .strength(currentLinkStrength) - ); - sim.alpha(0.5).restart(); - - requestAnimationFrame(animate); - }; - - requestAnimationFrame(animate); - }, [chargeStrength]); - - // Clear all state and reset to initial (keeps self node only) - const clearAndReset = useCallback(() => { - // Clear all pending timers - for (const timer of timersRef.current.values()) { - clearTimeout(timer); - } - timersRef.current.clear(); - - // Clear pending packets - pendingRef.current.clear(); - - // Clear processed packet IDs so they can be re-processed if needed - processedRef.current.clear(); - - // Clear traffic patterns - trafficPatternsRef.current.clear(); - - // Clear particles - particlesRef.current.length = 0; - - // Clear links - linksRef.current.clear(); - - // Clear nodes except self, then reset self position - const selfNode = nodesRef.current.get('self'); - nodesRef.current.clear(); - if (selfNode) { - selfNode.x = dimensions.width / 2; - selfNode.y = dimensions.height / 2; - selfNode.vx = 0; - selfNode.vy = 0; - selfNode.lastActivity = Date.now(); - nodesRef.current.set('self', selfNode); - } - - // Reset simulation with just self node - const sim = simulationRef.current; - if (sim) { - sim.nodes(Array.from(nodesRef.current.values())); - sim.force( - 'link', - forceLink([]) - .id((d) => d.id) - .distance(80) - .strength(0.3) - ); - sim.alpha(0.3).restart(); - } - - // Reset stats - setStats({ processed: 0, animated: 0, nodes: 1, links: 0 }); - }, [dimensions]); - - return { - nodes: nodesRef.current, - links: linksRef.current, - particles: particlesRef.current, - simulation: simulationRef.current, - stats, - randomizePositions, - expandContract, - clearAndReset, - }; -} - -// ============================================================================= -// RENDERING FUNCTIONS -// ============================================================================= - -function renderLinks( - ctx: CanvasRenderingContext2D, - links: GraphLink[], - nodes: Map -) { - ctx.strokeStyle = COLORS.link; - ctx.lineWidth = 2; - - for (const link of links) { - const { sourceId, targetId } = getLinkId(link); - const source = nodes.get(sourceId); - const target = nodes.get(targetId); - - if (source?.x != null && source?.y != null && target?.x != null && target?.y != null) { - ctx.beginPath(); - ctx.moveTo(source.x, source.y); - ctx.lineTo(target.x, target.y); - ctx.stroke(); - } - } -} - -function renderParticles( - ctx: CanvasRenderingContext2D, - particles: Particle[], - nodes: Map, - visibleNodeIds: Set -): Particle[] { - const active: Particle[] = []; - - for (const particle of particles) { - const fromNode = nodes.get(particle.fromNodeId); - const toNode = nodes.get(particle.toNodeId); - const isVisible = - visibleNodeIds.has(particle.fromNodeId) && visibleNodeIds.has(particle.toNodeId); - - particle.progress += particle.speed; - - if (particle.progress > 1) continue; - active.push(particle); - - if (!isVisible || !fromNode?.x || !toNode?.x || fromNode.y == null || toNode.y == null) - continue; - if (particle.progress < 0) continue; - - const t = particle.progress; - const x = fromNode.x + (toNode.x - fromNode.x) * t; - const y = fromNode.y + (toNode.y - fromNode.y) * t; - - // Glow - ctx.fillStyle = particle.color + '40'; - ctx.beginPath(); - ctx.arc(x, y, 14, 0, Math.PI * 2); - ctx.fill(); - - // Circle - ctx.fillStyle = particle.color; - ctx.beginPath(); - ctx.arc(x, y, 10, 0, Math.PI * 2); - ctx.fill(); - - // Label - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 8px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(particle.label, x, y); - } - - return active; -} - -function renderNodes( - ctx: CanvasRenderingContext2D, - nodes: GraphNode[], - hoveredNodeId: string | null -) { - for (const node of nodes) { - if (node.x == null || node.y == null) continue; - - // Emoji - const emoji = - node.type === 'self' - ? '🟢' - : node.type === 'repeater' - ? '📡' - : node.isAmbiguous - ? '❓' - : '👤'; - const size = node.type === 'self' ? 36 : 18; - - ctx.font = `${size}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(emoji, node.x, node.y); - - // Label - const label = node.isAmbiguous - ? node.id - : node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); - ctx.font = '11px sans-serif'; - ctx.textBaseline = 'top'; - ctx.fillStyle = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; - ctx.fillText(label, node.x, node.y + size / 2 + 4); - - // Ambiguous names - if (node.isAmbiguous && node.ambiguousNames?.length) { - ctx.font = '9px sans-serif'; - ctx.fillStyle = '#6b7280'; - let yOffset = node.y + size / 2 + 18; - - if (hoveredNodeId === node.id) { - for (const name of node.ambiguousNames) { - ctx.fillText(name, node.x, yOffset); - yOffset += 11; - } - } else if (node.ambiguousNames.length === 1) { - ctx.fillText(node.ambiguousNames[0], node.x, yOffset); - } else { - ctx.fillText( - `${node.ambiguousNames[0]} +${node.ambiguousNames.length - 1} more`, - node.x, - yOffset - ); - } - } - } -} - -// ============================================================================= -// MAIN COMPONENT -// ============================================================================= - -interface PacketVisualizerProps { - packets: RawPacket[]; - contacts: Contact[]; - config: RadioConfig | null; - fullScreen?: boolean; - onFullScreenChange?: (fullScreen: boolean) => void; - onClearPackets?: () => void; -} - -export function PacketVisualizer({ - packets, - contacts, - config, - fullScreen, - onFullScreenChange, - onClearPackets, -}: PacketVisualizerProps) { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); - - // Options - const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true); - const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false); - const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(false); - const [chargeStrength, setChargeStrength] = useState(-200); - const [filterOldRepeaters, setFilterOldRepeaters] = useState(false); - const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC); - const [letEmDrift, setLetEmDrift] = useState(true); - const [particleSpeedMultiplier, setParticleSpeedMultiplier] = useState(2); - const [hideUI, setHideUI] = useState(false); - - // Pan/zoom - const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); - const isDraggingRef = useRef(false); - const lastMouseRef = useRef({ x: 0, y: 0 }); - const draggedNodeRef = useRef(null); - - // Hover - const [hoveredNodeId, setHoveredNodeId] = useState(null); - - // Data layer - const data = useVisualizerData({ - packets, - contacts, - config, - showAmbiguousPaths, - showAmbiguousNodes, - splitAmbiguousByTraffic, - chargeStrength, - letEmDrift, - particleSpeedMultiplier, - observationWindowSec, - dimensions, - }); - - // Track dimensions - useEffect(() => { - const update = () => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - setDimensions({ width: rect.width, height: rect.height }); - } - }; - update(); - const observer = new ResizeObserver(update); - if (containerRef.current) observer.observe(containerRef.current); - return () => observer.disconnect(); - }, []); - - // Render - const render = useCallback(() => { - const canvas = canvasRef.current; - const ctx = canvas?.getContext('2d'); - if (!canvas || !ctx) return; - - const { width, height } = dimensions; - const dpr = window.devicePixelRatio || 1; - - canvas.width = width * dpr; - canvas.height = height * dpr; - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - ctx.scale(dpr, dpr); - - ctx.fillStyle = COLORS.background; - ctx.fillRect(0, 0, width, height); - - ctx.save(); - ctx.translate(width / 2, height / 2); - ctx.scale(transform.scale, transform.scale); - ctx.translate(transform.x - width / 2, transform.y - height / 2); - - const now = Date.now(); - const allNodes = Array.from(data.nodes.values()); - const visibleNodeIds = new Set(); - - // Filter nodes - const visibleNodes = allNodes.filter((node) => { - if (node.type === 'self' || node.type === 'client') { - visibleNodeIds.add(node.id); - return true; - } - if (filterOldRepeaters && node.type === 'repeater') { - const lastTime = node.lastSeen ? node.lastSeen * 1000 : node.lastActivity; - if (now - lastTime > FORTY_EIGHT_HOURS_MS) return false; - } - visibleNodeIds.add(node.id); - return true; - }); - - // Filter links - const allLinks = Array.from(data.links.values()); - const visibleLinks = allLinks.filter((link) => { - const { sourceId, targetId } = getLinkId(link); - return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId); - }); - - renderLinks(ctx, visibleLinks, data.nodes); - data.particles.splice( - 0, - data.particles.length, - ...renderParticles(ctx, data.particles, data.nodes, visibleNodeIds) - ); - renderNodes(ctx, visibleNodes, hoveredNodeId); - - ctx.restore(); - }, [dimensions, transform, data, hoveredNodeId, filterOldRepeaters]); - - // Animation loop - useEffect(() => { - let running = true; - const animate = () => { - if (!running) return; - render(); - requestAnimationFrame(animate); - }; - animate(); - return () => { - running = false; - }; - }, [render]); - - // Mouse handlers - const screenToGraph = useCallback( - (screenX: number, screenY: number) => { - const { width, height } = dimensions; - const cx = (screenX - width / 2) / transform.scale - transform.x + width / 2; - const cy = (screenY - height / 2) / transform.scale - transform.y + height / 2; - return { x: cx, y: cy }; - }, - [dimensions, transform] - ); - - const findNodeAt = useCallback( - (gx: number, gy: number) => { - for (const node of data.nodes.values()) { - if (node.x == null || node.y == null) continue; - if (Math.hypot(gx - node.x, gy - node.y) < 20) return node; - } - return null; - }, - [data.nodes] - ); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - - const rect = canvas.getBoundingClientRect(); - const pos = screenToGraph(e.clientX - rect.left, e.clientY - rect.top); - const node = findNodeAt(pos.x, pos.y); - - if (node) { - // Start dragging this node - draggedNodeRef.current = node; - // Fix the node's position while dragging - node.fx = node.x; - node.fy = node.y; - // Reheat simulation slightly for responsive feedback - data.simulation?.alpha(0.3).restart(); - } else { - // Start panning - isDraggingRef.current = true; - } - lastMouseRef.current = { x: e.clientX, y: e.clientY }; - }, - [screenToGraph, findNodeAt, data.simulation] - ); - - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - - const rect = canvas.getBoundingClientRect(); - const pos = screenToGraph(e.clientX - rect.left, e.clientY - rect.top); - - // Update hover state - setHoveredNodeId(findNodeAt(pos.x, pos.y)?.id || null); - - // Handle node dragging - if (draggedNodeRef.current) { - draggedNodeRef.current.fx = pos.x; - draggedNodeRef.current.fy = pos.y; - return; - } - - // Handle canvas panning - if (!isDraggingRef.current) return; - const dx = e.clientX - lastMouseRef.current.x; - const dy = e.clientY - lastMouseRef.current.y; - lastMouseRef.current = { x: e.clientX, y: e.clientY }; - setTransform((t) => ({ ...t, x: t.x + dx / t.scale, y: t.y + dy / t.scale })); - }, - [screenToGraph, findNodeAt] - ); - - const handleMouseUp = useCallback(() => { - if (draggedNodeRef.current) { - // Release the node - clear fixed position so it can move freely again - draggedNodeRef.current.fx = null; - draggedNodeRef.current.fy = null; - draggedNodeRef.current = null; - } - isDraggingRef.current = false; - }, []); - - const handleMouseLeave = useCallback(() => { - if (draggedNodeRef.current) { - draggedNodeRef.current.fx = null; - draggedNodeRef.current.fy = null; - draggedNodeRef.current = null; - } - isDraggingRef.current = false; - setHoveredNodeId(null); - }, []); - - const handleWheel = useCallback((e: WheelEvent) => { - e.preventDefault(); - const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1; - setTransform((t) => ({ ...t, scale: Math.min(Math.max(t.scale * factor, 0.1), 5) })); - }, []); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - canvas.addEventListener('wheel', handleWheel, { passive: false }); - return () => canvas.removeEventListener('wheel', handleWheel); - }, [handleWheel]); - - // Determine cursor based on state - const getCursor = () => { - if (draggedNodeRef.current) return 'grabbing'; - if (hoveredNodeId) return 'pointer'; - return 'grab'; - }; - - return ( -
- - - {/* Legend */} - {!hideUI && ( -
-
-
-
Packets
- {PACKET_LEGEND_ITEMS.map((item) => ( -
-
- {item.label} -
- {item.description} -
- ))} -
-
-
Nodes
- {LEGEND_ITEMS.map((item) => ( -
- {item.emoji} - {item.label} -
- ))} -
-
-
- )} - - {/* Options */} -
-
- {!hideUI && ( - <> -
Nodes: {data.stats.nodes}
-
Links: {data.stats.links}
-
- - - - -
- - - setObservationWindowSec( - Math.max(1, Math.min(60, parseInt(e.target.value) || 1)) - ) - } - className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center" - /> - sec -
-
- -
- - setChargeStrength(-parseInt(e.target.value))} - className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" - /> -
-
- - setParticleSpeedMultiplier(parseFloat(e.target.value))} - className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" - /> -
-
- - - -
- - )} -
- - {onFullScreenChange && ( - - )} -
-
-
-
- ); -} diff --git a/frontend/src/components/PacketVisualizer3D.tsx b/frontend/src/components/PacketVisualizer3D.tsx new file mode 100644 index 0000000..1b2362e --- /dev/null +++ b/frontend/src/components/PacketVisualizer3D.tsx @@ -0,0 +1,1692 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; +import { + forceSimulation, + forceLink, + forceManyBody, + forceCenter, + forceX, + forceY, + forceZ, + type Simulation3D, + type SimulationNodeDatum3D, + type ForceLink3D, +} from 'd3-force-3d'; +import type { SimulationLinkDatum } from 'd3-force'; +import { PayloadType } from '@michaelhart/meshcore-decoder'; +import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket, type RadioConfig } from '../types'; +import { Checkbox } from './ui/checkbox'; +import { + type NodeType, + type Particle, + type PendingPacket, + type RepeaterTrafficData, + COLORS, + PARTICLE_COLOR_MAP, + PARTICLE_SPEED, + DEFAULT_OBSERVATION_WINDOW_SEC, + PACKET_LEGEND_ITEMS, + parsePacket, + getPacketLabel, + generatePacketKey, + getLinkId, + getNodeType, + dedupeConsecutive, + analyzeRepeaterTraffic, + recordTrafficObservation, +} from '../utils/visualizerUtils'; + +// ============================================================================= +// TYPES (local — extend d3-force-3d simulation datum types) +// ============================================================================= + +interface GraphNode extends SimulationNodeDatum3D { + id: string; + name: string | null; + type: NodeType; + isAmbiguous: boolean; + lastActivity: number; + lastSeen?: number | null; + ambiguousNames?: string[]; +} + +interface GraphLink extends SimulationLinkDatum { + source: string | GraphNode; + target: string | GraphNode; + lastActivity: number; +} + +// ============================================================================= +// 3D NODE COLORS +// ============================================================================= + +const NODE_COLORS = { + self: 0x22c55e, // green + repeater: 0x3b82f6, // blue + client: 0xffffff, // white + ambiguous: 0x9ca3af, // gray +} as const; + +const NODE_LEGEND_ITEMS = [ + { color: '#22c55e', label: 'You', size: 14 }, + { color: '#3b82f6', label: 'Repeater', size: 10 }, + { color: '#ffffff', label: 'Node', size: 10 }, + { color: '#9ca3af', label: 'Ambiguous', size: 10 }, +] as const; + +function getBaseNodeColor(node: Pick): number { + if (node.type === 'self') return NODE_COLORS.self; + if (node.type === 'repeater') return NODE_COLORS.repeater; + return node.isAmbiguous ? NODE_COLORS.ambiguous : NODE_COLORS.client; +} + +function growFloat32Buffer( + current: Float32Array, + requiredLength: number +): Float32Array { + let nextLength = Math.max(12, current.length); + while (nextLength < requiredLength) { + nextLength *= 2; + } + return new Float32Array(nextLength); +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +// ============================================================================= +// DATA LAYER HOOK (3D variant) +// ============================================================================= + +interface UseVisualizerData3DOptions { + packets: RawPacket[]; + contacts: Contact[]; + config: RadioConfig | null; + showAmbiguousPaths: boolean; + showAmbiguousNodes: boolean; + splitAmbiguousByTraffic: boolean; + chargeStrength: number; + letEmDrift: boolean; + particleSpeedMultiplier: number; + observationWindowSec: number; +} + +interface VisualizerData3D { + nodes: Map; + links: Map; + particles: Particle[]; + stats: { processed: number; animated: number; nodes: number; links: number }; + randomizePositions: () => void; + expandContract: () => void; + clearAndReset: () => void; +} + +function useVisualizerData3D({ + packets, + contacts, + config, + showAmbiguousPaths, + showAmbiguousNodes, + splitAmbiguousByTraffic, + chargeStrength, + letEmDrift, + particleSpeedMultiplier, + observationWindowSec, +}: UseVisualizerData3DOptions): VisualizerData3D { + const nodesRef = useRef>(new Map()); + const linksRef = useRef>(new Map()); + const particlesRef = useRef([]); + const simulationRef = useRef | null>(null); + const processedRef = useRef>(new Set()); + const pendingRef = useRef>(new Map()); + const timersRef = useRef>>(new Map()); + const trafficPatternsRef = useRef>(new Map()); + const speedMultiplierRef = useRef(particleSpeedMultiplier); + const observationWindowRef = useRef(observationWindowSec * 1000); + const stretchRafRef = useRef(null); + const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 }); + + const contactIndex = useMemo(() => { + const byPrefix12 = new Map(); + const byName = new Map(); + const byPrefix = new Map(); + + for (const contact of contacts) { + const prefix12 = contact.public_key.slice(0, 12).toLowerCase(); + byPrefix12.set(prefix12, contact); + + if (contact.name && !byName.has(contact.name)) { + byName.set(contact.name, contact); + } + + for (let len = 1; len <= 12; len++) { + const prefix = prefix12.slice(0, len); + const matches = byPrefix.get(prefix); + if (matches) { + matches.push(contact); + } else { + byPrefix.set(prefix, [contact]); + } + } + } + + return { byPrefix12, byName, byPrefix }; + }, [contacts]); + + // Keep refs in sync with props + useEffect(() => { + speedMultiplierRef.current = particleSpeedMultiplier; + }, [particleSpeedMultiplier]); + + useEffect(() => { + observationWindowRef.current = observationWindowSec * 1000; + }, [observationWindowSec]); + + // Initialize simulation (3D — centered at origin) + useEffect(() => { + const sim = forceSimulation([]) + .numDimensions(3) + .force( + 'link', + forceLink([]) + .id((d) => d.id) + .distance(120) + .strength(0.3) + ) + .force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? -1200 : -200)) + .distanceMax(800) + ) + .force('center', forceCenter(0, 0, 0)) + .force( + 'selfX', + forceX(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .force( + 'selfY', + forceY(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .force( + 'selfZ', + forceZ(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .alphaDecay(0.02) + .velocityDecay(0.5) + .alphaTarget(0.03); + + simulationRef.current = sim; + return () => { + sim.stop(); + }; + }, []); + + // Update simulation forces when charge changes + useEffect(() => { + const sim = simulationRef.current; + if (!sim) return; + + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength)) + .distanceMax(800) + ); + sim.alpha(0.3).restart(); + }, [chargeStrength]); + + // Update alphaTarget when drift preference changes + useEffect(() => { + const sim = simulationRef.current; + if (!sim) return; + sim.alphaTarget(letEmDrift ? 0.05 : 0); + }, [letEmDrift]); + + // Ensure self node exists + useEffect(() => { + if (!nodesRef.current.has('self')) { + nodesRef.current.set('self', { + id: 'self', + name: config?.name || 'Me', + type: 'self', + isAmbiguous: false, + lastActivity: Date.now(), + x: 0, + y: 0, + z: 0, + }); + syncSimulation(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- syncSimulation is stable + }, [config]); + + const syncSimulation = useCallback(() => { + const sim = simulationRef.current; + if (!sim) return; + + const nodes = Array.from(nodesRef.current.values()); + const links = Array.from(linksRef.current.values()); + + sim.nodes(nodes); + const linkForce = sim.force('link') as ForceLink3D | undefined; + linkForce?.links(links); + + sim.alpha(0.15).restart(); + + setStats((prev) => + prev.nodes === nodes.length && prev.links === links.length + ? prev + : { ...prev, nodes: nodes.length, links: links.length } + ); + }, []); + + // Reset on option changes + useEffect(() => { + processedRef.current.clear(); + const selfNode = nodesRef.current.get('self'); + nodesRef.current.clear(); + if (selfNode) nodesRef.current.set('self', selfNode); + linksRef.current.clear(); + particlesRef.current = []; + pendingRef.current.clear(); + timersRef.current.forEach((t) => clearTimeout(t)); + timersRef.current.clear(); + trafficPatternsRef.current.clear(); + setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); + syncSimulation(); + }, [showAmbiguousPaths, showAmbiguousNodes, splitAmbiguousByTraffic, syncSimulation]); + + const addNode = useCallback( + ( + id: string, + name: string | null, + type: NodeType, + isAmbiguous: boolean, + ambiguousNames?: string[], + lastSeen?: number | null + ) => { + const existing = nodesRef.current.get(id); + if (existing) { + existing.lastActivity = Date.now(); + if (name && !existing.name) existing.name = name; + if (ambiguousNames) existing.ambiguousNames = ambiguousNames; + if (lastSeen !== undefined) existing.lastSeen = lastSeen; + } else { + // Initialize in 3D sphere around origin + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const r = 80 + Math.random() * 100; + nodesRef.current.set(id, { + id, + name, + type, + isAmbiguous, + lastActivity: Date.now(), + lastSeen, + ambiguousNames, + x: r * Math.sin(phi) * Math.cos(theta), + y: r * Math.sin(phi) * Math.sin(theta), + z: r * Math.cos(phi), + }); + } + }, + [] + ); + + const addLink = useCallback((sourceId: string, targetId: string) => { + const key = [sourceId, targetId].sort().join('->'); + const existing = linksRef.current.get(key); + if (existing) { + existing.lastActivity = Date.now(); + } else { + linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: Date.now() }); + } + }, []); + + const publishPacket = useCallback((packetKey: string) => { + const pending = pendingRef.current.get(packetKey); + if (!pending) return; + + pendingRef.current.delete(packetKey); + timersRef.current.delete(packetKey); + + for (const path of pending.paths) { + const dedupedPath = dedupeConsecutive(path.nodes); + if (dedupedPath.length < 2) continue; + + for (let i = 0; i < dedupedPath.length - 1; i++) { + particlesRef.current.push({ + linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'), + progress: -i, + speed: PARTICLE_SPEED * speedMultiplierRef.current, + color: PARTICLE_COLOR_MAP[pending.label], + label: pending.label, + fromNodeId: dedupedPath[i], + toNodeId: dedupedPath[i + 1], + }); + } + } + }, []); + + const resolveNode = useCallback( + ( + source: { type: 'prefix' | 'pubkey' | 'name'; value: string }, + isRepeater: boolean, + showAmbiguous: boolean, + myPrefix: string | null, + trafficContext?: { packetSource: string | null; nextPrefix: string | null } + ): string | null => { + if (source.type === 'pubkey') { + if (source.value.length < 12) return null; + const nodeId = source.value.slice(0, 12).toLowerCase(); + if (myPrefix && nodeId === myPrefix) return 'self'; + const contact = contactIndex.byPrefix12.get(nodeId); + addNode( + nodeId, + contact?.name || null, + getNodeType(contact), + false, + undefined, + contact?.last_seen + ); + return nodeId; + } + + if (source.type === 'name') { + const contact = contactIndex.byName.get(source.value) ?? null; + if (contact) { + const nodeId = contact.public_key.slice(0, 12).toLowerCase(); + if (myPrefix && nodeId === myPrefix) return 'self'; + addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen); + return nodeId; + } + const nodeId = `name:${source.value}`; + addNode(nodeId, source.value, 'client', false); + return nodeId; + } + + // type === 'prefix' + const matches = contactIndex.byPrefix.get(source.value.toLowerCase()) ?? []; + const contact = matches.length === 1 ? matches[0] : null; + if (contact) { + const nodeId = contact.public_key.slice(0, 12).toLowerCase(); + if (myPrefix && nodeId === myPrefix) return 'self'; + addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen); + return nodeId; + } + + if (showAmbiguous) { + const filtered = isRepeater + ? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER) + : matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER); + + if (filtered.length === 1) { + const c = filtered[0]; + const nodeId = c.public_key.slice(0, 12).toLowerCase(); + addNode(nodeId, c.name, getNodeType(c), false, undefined, c.last_seen); + return nodeId; + } + + if (filtered.length > 1 || (filtered.length === 0 && isRepeater)) { + const names = filtered.map((c) => c.name || c.public_key.slice(0, 8)); + const lastSeen = filtered.reduce( + (max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max), + null as number | null + ); + + let nodeId = `?${source.value.toLowerCase()}`; + let displayName = source.value.toUpperCase(); + + if (splitAmbiguousByTraffic && isRepeater && trafficContext) { + const prefix = source.value.toLowerCase(); + + if (trafficContext.packetSource) { + recordTrafficObservation( + trafficPatternsRef.current, + prefix, + trafficContext.packetSource, + trafficContext.nextPrefix + ); + } + + const trafficData = trafficPatternsRef.current.get(prefix); + if (trafficData) { + const analysis = analyzeRepeaterTraffic(trafficData); + if (analysis.shouldSplit && trafficContext.nextPrefix) { + const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase(); + nodeId = `?${prefix}:>${nextShort}`; + displayName = `${source.value.toUpperCase()}:>${nextShort}`; + } + } + } + + addNode( + nodeId, + displayName, + isRepeater ? 'repeater' : 'client', + true, + names.length > 0 ? names : undefined, + lastSeen + ); + return nodeId; + } + } + + return null; + }, + [contactIndex, addNode, splitAmbiguousByTraffic] + ); + + const buildPath = useCallback( + ( + parsed: ReturnType, + packet: RawPacket, + myPrefix: string | null + ): string[] => { + if (!parsed) return []; + const path: string[] = []; + let packetSource: string | null = null; + + if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { + const nodeId = resolveNode( + { type: 'pubkey', value: parsed.advertPubkey }, + false, + false, + myPrefix + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) { + const nodeId = resolveNode( + { type: 'pubkey', value: parsed.anonRequestPubkey }, + false, + false, + myPrefix + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) { + if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) { + path.push('self'); + packetSource = 'self'; + } else { + const nodeId = resolveNode( + { type: 'prefix', value: parsed.srcHash }, + false, + showAmbiguousNodes, + myPrefix + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } + } else if (parsed.payloadType === PayloadType.GroupText) { + const senderName = parsed.groupTextSender || packet.decrypted_info?.sender; + if (senderName) { + const nodeId = resolveNode({ type: 'name', value: senderName }, false, false, myPrefix); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } + } + + for (let i = 0; i < parsed.pathBytes.length; i++) { + const hexPrefix = parsed.pathBytes[i]; + const nextPrefix = parsed.pathBytes[i + 1] || null; + const nodeId = resolveNode( + { type: 'prefix', value: hexPrefix }, + true, + showAmbiguousPaths, + myPrefix, + { packetSource, nextPrefix } + ); + if (nodeId) path.push(nodeId); + } + + if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) { + if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) { + path.push('self'); + } else { + const nodeId = resolveNode( + { type: 'prefix', value: parsed.dstHash }, + false, + showAmbiguousNodes, + myPrefix + ); + if (nodeId) path.push(nodeId); + else path.push('self'); + } + } else if (path.length > 0) { + path.push('self'); + } + + if (path.length > 0 && path[path.length - 1] !== 'self') { + path.push('self'); + } + + return dedupeConsecutive(path); + }, + [resolveNode, showAmbiguousPaths, showAmbiguousNodes] + ); + + // Process packets + useEffect(() => { + let newProcessed = 0; + let newAnimated = 0; + let needsUpdate = false; + const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null; + + for (const packet of packets) { + if (processedRef.current.has(packet.id)) continue; + processedRef.current.add(packet.id); + newProcessed++; + + if (processedRef.current.size > 1000) { + processedRef.current = new Set(Array.from(processedRef.current).slice(-500)); + } + + const parsed = parsePacket(packet.data); + if (!parsed) continue; + + const path = buildPath(parsed, packet, myPrefix); + if (path.length < 2) continue; + + for (let i = 0; i < path.length - 1; i++) { + if (path[i] !== path[i + 1]) { + addLink(path[i], path[i + 1]); + needsUpdate = true; + } + } + + const packetKey = generatePacketKey(parsed, packet); + const now = Date.now(); + const existing = pendingRef.current.get(packetKey); + + if (existing && now < existing.expiresAt) { + existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now }); + } else { + if (timersRef.current.has(packetKey)) { + clearTimeout(timersRef.current.get(packetKey)); + } + const windowMs = observationWindowRef.current; + pendingRef.current.set(packetKey, { + key: packetKey, + label: getPacketLabel(parsed.payloadType), + paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }], + firstSeen: now, + expiresAt: now + windowMs, + }); + timersRef.current.set( + packetKey, + setTimeout(() => publishPacket(packetKey), windowMs) + ); + } + + if (pendingRef.current.size > 100) { + const entries = Array.from(pendingRef.current.entries()) + .sort((a, b) => a[1].firstSeen - b[1].firstSeen) + .slice(0, 50); + for (const [key] of entries) { + clearTimeout(timersRef.current.get(key)); + timersRef.current.delete(key); + pendingRef.current.delete(key); + } + } + + newAnimated++; + } + + if (needsUpdate) syncSimulation(); + if (newProcessed > 0) { + setStats((prev) => ({ + ...prev, + processed: prev.processed + newProcessed, + animated: prev.animated + newAnimated, + })); + } + }, [packets, config, buildPath, addLink, syncSimulation, publishPacket]); + + const randomizePositions = useCallback(() => { + const sim = simulationRef.current; + if (!sim) return; + + const radius = 200; + for (const node of nodesRef.current.values()) { + if (node.id === 'self') { + node.x = 0; + node.y = 0; + node.z = 0; + } else { + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const r = Math.random() * radius; + node.x = r * Math.sin(phi) * Math.cos(theta); + node.y = r * Math.sin(phi) * Math.sin(theta); + node.z = r * Math.cos(phi); + } + node.vx = 0; + node.vy = 0; + node.vz = 0; + } + + sim.alpha(1).restart(); + }, []); + + const expandContract = useCallback(() => { + const sim = simulationRef.current; + if (!sim) return; + + if (stretchRafRef.current !== null) { + cancelAnimationFrame(stretchRafRef.current); + stretchRafRef.current = null; + } + + const startChargeStrength = chargeStrength; + const peakChargeStrength = -5000; + const startLinkStrength = 0.3; + const minLinkStrength = 0.02; + const expandDuration = 1000; + const holdDuration = 2000; + const contractDuration = 1000; + const startTime = performance.now(); + + const animate = (now: number) => { + const elapsed = now - startTime; + let currentChargeStrength: number; + let currentLinkStrength: number; + + if (elapsed < expandDuration) { + const t = elapsed / expandDuration; + currentChargeStrength = + startChargeStrength + (peakChargeStrength - startChargeStrength) * t; + currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t; + } else if (elapsed < expandDuration + holdDuration) { + currentChargeStrength = peakChargeStrength; + currentLinkStrength = minLinkStrength; + } else if (elapsed < expandDuration + holdDuration + contractDuration) { + const t = (elapsed - expandDuration - holdDuration) / contractDuration; + currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t; + currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t; + } else { + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength)) + .distanceMax(800) + ); + sim.force( + 'link', + forceLink(Array.from(linksRef.current.values())) + .id((d) => d.id) + .distance(120) + .strength(startLinkStrength) + ); + sim.alpha(0.3).restart(); + stretchRafRef.current = null; + return; + } + + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength)) + .distanceMax(800) + ); + sim.force( + 'link', + forceLink(Array.from(linksRef.current.values())) + .id((d) => d.id) + .distance(120) + .strength(currentLinkStrength) + ); + sim.alpha(0.5).restart(); + + stretchRafRef.current = requestAnimationFrame(animate); + }; + + stretchRafRef.current = requestAnimationFrame(animate); + }, [chargeStrength]); + + const clearAndReset = useCallback(() => { + if (stretchRafRef.current !== null) { + cancelAnimationFrame(stretchRafRef.current); + stretchRafRef.current = null; + } + + for (const timer of timersRef.current.values()) { + clearTimeout(timer); + } + timersRef.current.clear(); + pendingRef.current.clear(); + processedRef.current.clear(); + trafficPatternsRef.current.clear(); + particlesRef.current.length = 0; + linksRef.current.clear(); + + const selfNode = nodesRef.current.get('self'); + nodesRef.current.clear(); + if (selfNode) { + selfNode.x = 0; + selfNode.y = 0; + selfNode.z = 0; + selfNode.vx = 0; + selfNode.vy = 0; + selfNode.vz = 0; + selfNode.lastActivity = Date.now(); + nodesRef.current.set('self', selfNode); + } + + const sim = simulationRef.current; + if (sim) { + sim.nodes(Array.from(nodesRef.current.values())); + const linkForce = sim.force('link') as ForceLink3D | undefined; + linkForce?.links([]); + sim.alpha(0.3).restart(); + } + + setStats({ processed: 0, animated: 0, nodes: 1, links: 0 }); + }, []); + + useEffect(() => { + return () => { + if (stretchRafRef.current !== null) { + cancelAnimationFrame(stretchRafRef.current); + } + for (const timer of timersRef.current.values()) { + clearTimeout(timer); + } + timersRef.current.clear(); + pendingRef.current.clear(); + }; + }, []); + + return useMemo( + () => ({ + nodes: nodesRef.current, + links: linksRef.current, + particles: particlesRef.current, + stats, + randomizePositions, + expandContract, + clearAndReset, + }), + [stats, randomizePositions, expandContract, clearAndReset] + ); +} + +// ============================================================================= +// THREE.JS SCENE MANAGEMENT +// ============================================================================= + +interface NodeMeshData { + mesh: THREE.Mesh; + label: CSS2DObject; + labelDiv: HTMLDivElement; +} + +// ============================================================================= +// MAIN COMPONENT +// ============================================================================= + +interface PacketVisualizer3DProps { + packets: RawPacket[]; + contacts: Contact[]; + config: RadioConfig | null; + fullScreen?: boolean; + onFullScreenChange?: (fullScreen: boolean) => void; + onClearPackets?: () => void; +} + +export function PacketVisualizer3D({ + packets, + contacts, + config, + fullScreen, + onFullScreenChange, + onClearPackets, +}: PacketVisualizer3DProps) { + const containerRef = useRef(null); + const rendererRef = useRef(null); + const cssRendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const nodeMeshesRef = useRef>(new Map()); + const raycastTargetsRef = useRef([]); + const linkLineRef = useRef(null); + const highlightLineRef = useRef(null); + const particlePointsRef = useRef(null); + const particleTextureRef = useRef(null); + const linkPositionBufferRef = useRef>(new Float32Array(0)); + const highlightPositionBufferRef = useRef>(new Float32Array(0)); + const particlePositionBufferRef = useRef>(new Float32Array(0)); + const particleColorBufferRef = useRef>(new Float32Array(0)); + const raycasterRef = useRef(new THREE.Raycaster()); + const mouseRef = useRef(new THREE.Vector2()); + + // Options + const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true); + const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false); + const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(false); + const [chargeStrength, setChargeStrength] = useState(-200); + const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC); + const [letEmDrift, setLetEmDrift] = useState(true); + const [particleSpeedMultiplier, setParticleSpeedMultiplier] = useState(2); + const [hideUI, setHideUI] = useState(false); + + // Hover & click-to-pin + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const hoveredNodeIdRef = useRef(null); + const [hoveredNeighborIds, setHoveredNeighborIds] = useState([]); + const hoveredNeighborIdsRef = useRef([]); + const pinnedNodeIdRef = useRef(null); + const [pinnedNodeId, setPinnedNodeId] = useState(null); + + // Data layer + const data = useVisualizerData3D({ + packets, + contacts, + config, + showAmbiguousPaths, + showAmbiguousNodes, + splitAmbiguousByTraffic, + chargeStrength, + letEmDrift, + particleSpeedMultiplier, + observationWindowSec, + }); + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + // Initialize Three.js scene + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Scene + const scene = new THREE.Scene(); + scene.background = new THREE.Color(COLORS.background); + sceneRef.current = scene; + + // Camera + const camera = new THREE.PerspectiveCamera(60, 1, 1, 5000); + camera.position.set(0, 0, 400); + cameraRef.current = camera; + + // WebGL renderer + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + container.appendChild(renderer.domElement); + rendererRef.current = renderer; + + // Circular particle sprite texture (so particles render as circles, not squares) + const texSize = 64; + const texCanvas = document.createElement('canvas'); + texCanvas.width = texSize; + texCanvas.height = texSize; + const texCtx = texCanvas.getContext('2d')!; + const gradient = texCtx.createRadialGradient( + texSize / 2, + texSize / 2, + 0, + texSize / 2, + texSize / 2, + texSize / 2 + ); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(0.5, 'rgba(255,255,255,0.8)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + texCtx.fillStyle = gradient; + texCtx.fillRect(0, 0, texSize, texSize); + const particleTexture = new THREE.CanvasTexture(texCanvas); + particleTextureRef.current = particleTexture; + + // CSS2D renderer for text labels + const cssRenderer = new CSS2DRenderer(); + cssRenderer.domElement.style.position = 'absolute'; + cssRenderer.domElement.style.top = '0'; + cssRenderer.domElement.style.left = '0'; + cssRenderer.domElement.style.pointerEvents = 'none'; + cssRenderer.domElement.style.zIndex = '1'; + container.appendChild(cssRenderer.domElement); + cssRendererRef.current = cssRenderer; + + // OrbitControls + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.1; + controls.minDistance = 50; + controls.maxDistance = 2000; + controlsRef.current = controls; + + // Persistent line meshes (their buffers are updated in-place each frame) + const linkGeometry = new THREE.BufferGeometry(); + const linkMaterial = new THREE.LineBasicMaterial({ + color: COLORS.link, + transparent: true, + opacity: 0.6, + }); + const linkSegments = new THREE.LineSegments(linkGeometry, linkMaterial); + linkSegments.visible = false; + scene.add(linkSegments); + linkLineRef.current = linkSegments; + + const highlightGeometry = new THREE.BufferGeometry(); + const highlightMaterial = new THREE.LineBasicMaterial({ + color: 0xffd700, + transparent: true, + opacity: 1.0, + linewidth: 2, + }); + const highlightSegments = new THREE.LineSegments(highlightGeometry, highlightMaterial); + highlightSegments.visible = false; + scene.add(highlightSegments); + highlightLineRef.current = highlightSegments; + + const particleGeometry = new THREE.BufferGeometry(); + const particleMaterial = new THREE.PointsMaterial({ + size: 20, + map: particleTexture, + vertexColors: true, + sizeAttenuation: true, + transparent: true, + opacity: 0.9, + depthWrite: false, + }); + const particlePoints = new THREE.Points(particleGeometry, particleMaterial); + particlePoints.visible = false; + scene.add(particlePoints); + particlePointsRef.current = particlePoints; + + // Initial sizing + const rect = container.getBoundingClientRect(); + renderer.setSize(rect.width, rect.height); + cssRenderer.setSize(rect.width, rect.height); + camera.aspect = rect.width / rect.height; + camera.updateProjectionMatrix(); + + // Resize observer + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width === 0 || height === 0) continue; + renderer.setSize(width, height); + cssRenderer.setSize(width, height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + } + }); + observer.observe(container); + + return () => { + observer.disconnect(); + controls.dispose(); + renderer.dispose(); + // Remove renderer DOM elements + if (renderer.domElement.parentNode) { + renderer.domElement.parentNode.removeChild(renderer.domElement); + } + if (cssRenderer.domElement.parentNode) { + cssRenderer.domElement.parentNode.removeChild(cssRenderer.domElement); + } + // Clean up node meshes + for (const nd of nodeMeshesRef.current.values()) { + scene.remove(nd.mesh); + scene.remove(nd.label); + nd.mesh.geometry.dispose(); + (nd.mesh.material as THREE.Material).dispose(); + } + nodeMeshesRef.current.clear(); + raycastTargetsRef.current = []; + + if (linkLineRef.current) { + scene.remove(linkLineRef.current); + linkLineRef.current.geometry.dispose(); + (linkLineRef.current.material as THREE.Material).dispose(); + linkLineRef.current = null; + } + if (highlightLineRef.current) { + scene.remove(highlightLineRef.current); + highlightLineRef.current.geometry.dispose(); + (highlightLineRef.current.material as THREE.Material).dispose(); + highlightLineRef.current = null; + } + if (particlePointsRef.current) { + scene.remove(particlePointsRef.current); + particlePointsRef.current.geometry.dispose(); + (particlePointsRef.current.material as THREE.Material).dispose(); + particlePointsRef.current = null; + } + particleTexture.dispose(); + particleTextureRef.current = null; + linkPositionBufferRef.current = new Float32Array(0); + highlightPositionBufferRef.current = new Float32Array(0); + particlePositionBufferRef.current = new Float32Array(0); + particleColorBufferRef.current = new Float32Array(0); + sceneRef.current = null; + cameraRef.current = null; + rendererRef.current = null; + cssRendererRef.current = null; + controlsRef.current = null; + }; + }, []); + + // Mouse handlers for raycasting and click-to-pin + useEffect(() => { + const renderer = rendererRef.current; + const camera = cameraRef.current; + if (!renderer || !camera) return; + + const onMouseMove = (event: MouseEvent) => { + const rect = renderer.domElement.getBoundingClientRect(); + mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + }; + + let mouseDownPos = { x: 0, y: 0 }; + + const onMouseDown = (event: MouseEvent) => { + mouseDownPos = { x: event.clientX, y: event.clientY }; + }; + + const onMouseUp = (event: MouseEvent) => { + // Only count as click if mouse didn't move much (not a drag/orbit) + const dx = event.clientX - mouseDownPos.x; + const dy = event.clientY - mouseDownPos.y; + if (dx * dx + dy * dy > 25) return; + + const rect = renderer.domElement.getBoundingClientRect(); + const clickMouse = new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); + + const raycaster = raycasterRef.current; + raycaster.setFromCamera(clickMouse, camera); + const intersects = raycaster.intersectObjects(raycastTargetsRef.current, false); + const clickedObject = intersects[0]?.object as THREE.Mesh | undefined; + const clickedId = (clickedObject?.userData?.nodeId as string | undefined) ?? null; + + if (clickedId === pinnedNodeIdRef.current) { + // Unpin + pinnedNodeIdRef.current = null; + setPinnedNodeId(null); + } else if (clickedId) { + // Pin this node + pinnedNodeIdRef.current = clickedId; + setPinnedNodeId(clickedId); + } else { + // Clicked empty space — unpin + pinnedNodeIdRef.current = null; + setPinnedNodeId(null); + } + }; + + renderer.domElement.addEventListener('mousemove', onMouseMove); + renderer.domElement.addEventListener('mousedown', onMouseDown); + renderer.domElement.addEventListener('mouseup', onMouseUp); + return () => { + renderer.domElement.removeEventListener('mousemove', onMouseMove); + renderer.domElement.removeEventListener('mousedown', onMouseDown); + renderer.domElement.removeEventListener('mouseup', onMouseUp); + }; + }, []); + + // Animation loop + useEffect(() => { + const scene = sceneRef.current; + const camera = cameraRef.current; + const renderer = rendererRef.current; + const cssRenderer = cssRendererRef.current; + const controls = controlsRef.current; + if (!scene || !camera || !renderer || !cssRenderer || !controls) return; + + let running = true; + + const animate = () => { + if (!running) return; + requestAnimationFrame(animate); + + controls.update(); + + const { nodes, links, particles } = dataRef.current; + + // --- Sync node meshes --- + const currentNodeIds = new Set(); + + for (const node of nodes.values()) { + currentNodeIds.add(node.id); + + let nd = nodeMeshesRef.current.get(node.id); + if (!nd) { + const isSelf = node.type === 'self'; + const radius = isSelf ? 8 : 4; + const geometry = new THREE.SphereGeometry(radius, 16, 12); + const material = new THREE.MeshBasicMaterial({ color: getBaseNodeColor(node) }); + const mesh = new THREE.Mesh(geometry, material); + mesh.userData.nodeId = node.id; + scene.add(mesh); + + const labelDiv = document.createElement('div'); + labelDiv.style.color = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; + labelDiv.style.fontSize = '11px'; + labelDiv.style.fontFamily = 'sans-serif'; + labelDiv.style.textAlign = 'center'; + labelDiv.style.whiteSpace = 'nowrap'; + labelDiv.style.textShadow = '0 0 4px #000, 0 0 2px #000'; + const label = new CSS2DObject(labelDiv); + label.position.set(0, -(radius + 6), 0); + mesh.add(label); + + nd = { mesh, label, labelDiv }; + nodeMeshesRef.current.set(node.id, nd); + raycastTargetsRef.current.push(mesh); + } + + nd.mesh.position.set(node.x ?? 0, node.y ?? 0, node.z ?? 0); + const labelColor = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; + if (nd.labelDiv.style.color !== labelColor) { + nd.labelDiv.style.color = labelColor; + } + const labelText = node.isAmbiguous + ? node.id + : node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); + if (nd.labelDiv.textContent !== labelText) { + nd.labelDiv.textContent = labelText; + } + } + + for (const [id, nd] of nodeMeshesRef.current) { + if (!currentNodeIds.has(id)) { + scene.remove(nd.mesh); + nd.mesh.geometry.dispose(); + (nd.mesh.material as THREE.Material).dispose(); + const meshIdx = raycastTargetsRef.current.indexOf(nd.mesh); + if (meshIdx >= 0) raycastTargetsRef.current.splice(meshIdx, 1); + nodeMeshesRef.current.delete(id); + } + } + + // --- Raycasting for hover --- + raycasterRef.current.setFromCamera(mouseRef.current, camera); + const intersects = raycasterRef.current.intersectObjects(raycastTargetsRef.current, false); + const hitObject = intersects[0]?.object as THREE.Mesh | undefined; + const hitId = (hitObject?.userData?.nodeId as string | undefined) ?? null; + if (hitId !== hoveredNodeIdRef.current) { + hoveredNodeIdRef.current = hitId; + setHoveredNodeId(hitId); + } + const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current; + + // --- Sync links (buffers updated in-place) --- + const visibleLinks: GraphLink[] = []; + for (const link of links.values()) { + const { sourceId, targetId } = getLinkId(link); + if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) { + visibleLinks.push(link); + } + } + + const connectedIds = activeId ? new Set([activeId]) : null; + + const linkLine = linkLineRef.current; + if (linkLine) { + const geometry = linkLine.geometry as THREE.BufferGeometry; + const requiredLength = visibleLinks.length * 6; + if (linkPositionBufferRef.current.length < requiredLength) { + linkPositionBufferRef.current = growFloat32Buffer( + linkPositionBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(linkPositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const highlightLine = highlightLineRef.current; + if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) { + highlightPositionBufferRef.current = growFloat32Buffer( + highlightPositionBufferRef.current, + requiredLength + ); + (highlightLine.geometry as THREE.BufferGeometry).setAttribute( + 'position', + new THREE.BufferAttribute(highlightPositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const positions = linkPositionBufferRef.current; + const hlPositions = highlightPositionBufferRef.current; + let idx = 0; + let hlIdx = 0; + + for (const link of visibleLinks) { + const { sourceId, targetId } = getLinkId(link); + const sNode = nodes.get(sourceId); + const tNode = nodes.get(targetId); + if (!sNode || !tNode) continue; + + const sx = sNode.x ?? 0; + const sy = sNode.y ?? 0; + const sz = sNode.z ?? 0; + const tx = tNode.x ?? 0; + const ty = tNode.y ?? 0; + const tz = tNode.z ?? 0; + + positions[idx++] = sx; + positions[idx++] = sy; + positions[idx++] = sz; + positions[idx++] = tx; + positions[idx++] = ty; + positions[idx++] = tz; + + if (activeId && (sourceId === activeId || targetId === activeId)) { + connectedIds?.add(sourceId === activeId ? targetId : sourceId); + hlPositions[hlIdx++] = sx; + hlPositions[hlIdx++] = sy; + hlPositions[hlIdx++] = sz; + hlPositions[hlIdx++] = tx; + hlPositions[hlIdx++] = ty; + hlPositions[hlIdx++] = tz; + } + } + + const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; + if (positionAttr) { + positionAttr.needsUpdate = true; + } + geometry.setDrawRange(0, idx / 3); + linkLine.visible = idx > 0; + + if (highlightLine) { + const hlGeometry = highlightLine.geometry as THREE.BufferGeometry; + const hlAttr = hlGeometry.getAttribute('position') as THREE.BufferAttribute | undefined; + if (hlAttr) { + hlAttr.needsUpdate = true; + } + hlGeometry.setDrawRange(0, hlIdx / 3); + highlightLine.visible = hlIdx > 0; + } + } + + // --- Sync particles (buffers updated in-place) --- + let writeIdx = 0; + for (let readIdx = 0; readIdx < particles.length; readIdx++) { + const particle = particles[readIdx]; + particle.progress += particle.speed; + if (particle.progress <= 1) { + particles[writeIdx++] = particle; + } + } + particles.length = writeIdx; + + const particlePoints = particlePointsRef.current; + if (particlePoints) { + const geometry = particlePoints.geometry as THREE.BufferGeometry; + const requiredLength = particles.length * 3; + + if (particlePositionBufferRef.current.length < requiredLength) { + particlePositionBufferRef.current = growFloat32Buffer( + particlePositionBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(particlePositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + if (particleColorBufferRef.current.length < requiredLength) { + particleColorBufferRef.current = growFloat32Buffer( + particleColorBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'color', + new THREE.BufferAttribute(particleColorBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const pPositions = particlePositionBufferRef.current; + const pColors = particleColorBufferRef.current; + const color = new THREE.Color(); + let visibleCount = 0; + + for (const p of particles) { + if (p.progress < 0) continue; + if (!currentNodeIds.has(p.fromNodeId) || !currentNodeIds.has(p.toNodeId)) continue; + + const fromNode = nodes.get(p.fromNodeId); + const toNode = nodes.get(p.toNodeId); + if (!fromNode || !toNode) continue; + + const t = p.progress; + const x = (fromNode.x ?? 0) + ((toNode.x ?? 0) - (fromNode.x ?? 0)) * t; + const y = (fromNode.y ?? 0) + ((toNode.y ?? 0) - (fromNode.y ?? 0)) * t; + const z = (fromNode.z ?? 0) + ((toNode.z ?? 0) - (fromNode.z ?? 0)) * t; + + pPositions[visibleCount * 3] = x; + pPositions[visibleCount * 3 + 1] = y; + pPositions[visibleCount * 3 + 2] = z; + + color.set(p.color); + pColors[visibleCount * 3] = color.r; + pColors[visibleCount * 3 + 1] = color.g; + pColors[visibleCount * 3 + 2] = color.b; + visibleCount++; + } + + const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; + const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute | undefined; + if (posAttr) posAttr.needsUpdate = true; + if (colorAttr) colorAttr.needsUpdate = true; + geometry.setDrawRange(0, visibleCount); + particlePoints.visible = visibleCount > 0; + } + + // Sync neighbor info only when changed to avoid re-rendering every frame. + const nextNeighbors = connectedIds + ? Array.from(connectedIds) + .filter((id) => id !== activeId) + .sort() + : []; + if (!arraysEqual(hoveredNeighborIdsRef.current, nextNeighbors)) { + hoveredNeighborIdsRef.current = nextNeighbors; + setHoveredNeighborIds(nextNeighbors); + } + + // Highlight active node and neighbors + for (const [id, nd] of nodeMeshesRef.current) { + const node = nodes.get(id); + if (!node) continue; + const mat = nd.mesh.material as THREE.MeshBasicMaterial; + if (id === activeId) { + mat.color.set(0xffd700); + } else if (connectedIds?.has(id)) { + mat.color.set(0xfff0b3); + } else { + mat.color.set(getBaseNodeColor(node)); + } + } + + renderer.render(scene, camera); + cssRenderer.render(scene, camera); + }; + + animate(); + return () => { + running = false; + }; + }, []); + + return ( +
+ {/* Legend */} + {!hideUI && ( +
+
+
+
Packets
+ {PACKET_LEGEND_ITEMS.map((item) => ( +
+
+ {item.label} +
+ {item.description} +
+ ))} +
+
+
Nodes
+ {NODE_LEGEND_ITEMS.map((item) => ( +
+
+ {item.label} +
+ ))} +
+
+
+ )} + + {/* Options */} +
+
+ {!hideUI && ( + <> +
Nodes: {data.stats.nodes}
+
Links: {data.stats.links}
+
+ + + +
+ + + setObservationWindowSec( + Math.max(1, Math.min(60, parseInt(e.target.value) || 1)) + ) + } + className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center" + /> + sec +
+
+ +
+ + setChargeStrength(-parseInt(e.target.value))} + className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+
+ + setParticleSpeedMultiplier(parseFloat(e.target.value))} + className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+
+ + + +
+ + )} +
+ + {onFullScreenChange && ( + + )} +
+
+
+ + {/* Hovered/pinned node tooltip */} + {(pinnedNodeId ?? hoveredNodeId) && !hideUI && ( +
+ {(() => { + const tooltipNodeId = pinnedNodeId ?? hoveredNodeId; + const node = tooltipNodeId ? data.nodes.get(tooltipNodeId) : null; + if (!node) return null; + const neighbors = hoveredNeighborIds + .map((nid) => { + const n = data.nodes.get(nid); + if (!n) return null; + const displayName = n.name || (n.type === 'self' ? 'Me' : n.id.slice(0, 8)); + return { id: nid, name: displayName, ambiguousNames: n.ambiguousNames }; + }) + .filter(Boolean); + return ( +
+
+ {node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8))} +
+
ID: {node.id}
+
+ Type: {node.type} + {node.isAmbiguous ? ' (ambiguous)' : ''} +
+ {node.ambiguousNames && node.ambiguousNames.length > 0 && ( +
+ Possible: {node.ambiguousNames.join(', ')} +
+ )} + {neighbors.length > 0 && ( +
+
Traffic exchanged with:
+
    + {neighbors.map((nb) => ( +
  • + {nb!.name} + {nb!.ambiguousNames && nb!.ambiguousNames.length > 0 && ( + + {' '} + ({nb!.ambiguousNames.join(', ')}) + + )} +
  • + ))} +
+
+ )} +
+ ); + })()} +
+ )} +
+ ); +} diff --git a/frontend/src/components/VisualizerView.tsx b/frontend/src/components/VisualizerView.tsx index ce052a6..ef79562 100644 --- a/frontend/src/components/VisualizerView.tsx +++ b/frontend/src/components/VisualizerView.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import type { Contact, RawPacket, RadioConfig } from '../types'; -import { PacketVisualizer } from './PacketVisualizer'; +import { PacketVisualizer3D } from './PacketVisualizer3D'; import { RawPacketList } from './RawPacketList'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { cn } from '@/lib/utils'; @@ -30,7 +30,7 @@ export function VisualizerView({ packets, contacts, config, onClearPackets }: Vi Packet Feed - - = SimulationLinkDatum, + > { + restart(): this; + stop(): this; + tick(iterations?: number): this; + nodes(): NodeDatum[]; + nodes(nodes: NodeDatum[]): this; + alpha(): number; + alpha(alpha: number): this; + alphaMin(): number; + alphaMin(min: number): this; + alphaDecay(): number; + alphaDecay(decay: number): this; + alphaTarget(): number; + alphaTarget(target: number): this; + velocityDecay(): number; + velocityDecay(decay: number): this; + force(name: string): Force3D | undefined; + force(name: string, force: Force3D | null): this; + find(x: number, y: number, z?: number, radius?: number): NodeDatum | undefined; + randomSource(): () => number; + randomSource(source: () => number): this; + on(typenames: string): ((...args: unknown[]) => void) | undefined; + on(typenames: string, listener: ((...args: unknown[]) => void) | null): this; + numDimensions(): number; + numDimensions(nDim: 1 | 2 | 3): this; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + export interface Force3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + _LinkDatum extends SimulationLinkDatum = SimulationLinkDatum, + > {} + + export function forceSimulation< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + LinkDatum extends SimulationLinkDatum = SimulationLinkDatum, + >(nodes?: NodeDatum[]): Simulation3D; + + // --------------------------------------------------------------------------- + // Forces + // --------------------------------------------------------------------------- + + export interface ForceLink3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + LinkDatum extends SimulationLinkDatum = SimulationLinkDatum, + > extends Force3D { + links(): LinkDatum[]; + links(links: LinkDatum[]): this; + id(): (node: NodeDatum, i: number, nodesData: NodeDatum[]) => string | number; + id(id: (node: NodeDatum, i: number, nodesData: NodeDatum[]) => string | number): this; + distance(): (link: LinkDatum, i: number, links: LinkDatum[]) => number; + distance(distance: number | ((link: LinkDatum, i: number, links: LinkDatum[]) => number)): this; + strength(): (link: LinkDatum, i: number, links: LinkDatum[]) => number; + strength(strength: number | ((link: LinkDatum, i: number, links: LinkDatum[]) => number)): this; + iterations(): number; + iterations(iterations: number): this; + } + + export function forceLink< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + LinkDatum extends SimulationLinkDatum = SimulationLinkDatum, + >(links?: LinkDatum[]): ForceLink3D; + + export interface ForceManyBody3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + > extends Force3D { + strength(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + strength(strength: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + theta(): number; + theta(theta: number): this; + distanceMin(): number; + distanceMin(distance: number): this; + distanceMax(): number; + distanceMax(distance: number): this; + } + + export function forceManyBody< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + >(): ForceManyBody3D; + + export interface ForceCenter3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + > extends Force3D { + x(): number; + x(x: number): this; + y(): number; + y(y: number): this; + z(): number; + z(z: number): this; + strength(): number; + strength(strength: number): this; + } + + export function forceCenter( + x?: number, + y?: number, + z?: number + ): ForceCenter3D; + + export interface ForceCollide3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + > extends Force3D { + radius(): (node: NodeDatum, i: number, nodes: NodeDatum[]) => number; + radius(radius: number | ((node: NodeDatum, i: number, nodes: NodeDatum[]) => number)): this; + strength(): number; + strength(strength: number): this; + iterations(): number; + iterations(iterations: number): this; + } + + export function forceCollide( + radius?: number | ((node: NodeDatum, i: number, nodes: NodeDatum[]) => number) + ): ForceCollide3D; + + export interface ForceX3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + > extends Force3D { + x(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + x(x: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + strength(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + strength(strength: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + } + + export function forceX( + x?: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number) + ): ForceX3D; + + export interface ForceY3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + > extends Force3D { + y(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + y(y: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + strength(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + strength(strength: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + } + + export function forceY( + y?: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number) + ): ForceY3D; + + export interface ForceZ3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + > extends Force3D { + z(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + z(z: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + strength(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + strength(strength: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + } + + export function forceZ( + z?: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number) + ): ForceZ3D; + + export interface ForceRadial3D< + NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D, + > extends Force3D { + radius(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + radius(radius: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + x(): number; + x(x: number): this; + y(): number; + y(y: number): this; + z(): number; + z(z: number): this; + strength(): (d: NodeDatum, i: number, data: NodeDatum[]) => number; + strength(strength: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)): this; + } + + export function forceRadial( + radius?: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number), + x?: number, + y?: number, + z?: number + ): ForceRadial3D; +} diff --git a/frontend/src/utils/visualizerUtils.ts b/frontend/src/utils/visualizerUtils.ts new file mode 100644 index 0000000..a171f44 --- /dev/null +++ b/frontend/src/utils/visualizerUtils.ts @@ -0,0 +1,324 @@ +import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder'; +import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket } from '../types'; + +// ============================================================================= +// TYPES +// ============================================================================= + +export type NodeType = 'self' | 'repeater' | 'client'; +export type PacketLabel = 'AD' | 'GT' | 'DM' | 'ACK' | 'TR' | 'RQ' | 'RS' | '?'; + +export interface Particle { + linkKey: string; + progress: number; + speed: number; + color: string; + label: PacketLabel; + fromNodeId: string; + toNodeId: string; +} + +export interface ObservedPath { + nodes: string[]; + snr: number | null; + timestamp: number; +} + +export interface PendingPacket { + key: string; + label: PacketLabel; + paths: ObservedPath[]; + firstSeen: number; + expiresAt: number; +} + +export interface ParsedPacket { + payloadType: number; + messageHash: string | null; + pathBytes: string[]; + srcHash: string | null; + dstHash: string | null; + advertPubkey: string | null; + groupTextSender: string | null; + anonRequestPubkey: string | null; +} + +// Traffic pattern tracking for smarter repeater disambiguation +export interface TrafficObservation { + source: string; // Node that originated traffic (could be resolved node ID or ambiguous) + nextHop: string | null; // Next hop after this repeater (null if final hop before self) + timestamp: number; +} + +export interface RepeaterTrafficData { + prefix: string; // The 1-byte hex prefix (e.g., "32") + observations: TrafficObservation[]; +} + +// Analysis result for whether to split an ambiguous repeater +export interface RepeaterSplitAnalysis { + shouldSplit: boolean; + // If shouldSplit, maps nextHop -> the sources that exclusively route through it + disjointGroups: Map> | null; +} + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +export const COLORS = { + background: '#0a0a0a', + link: '#4b5563', + ambiguous: '#9ca3af', + particleAD: '#f59e0b', // amber - advertisements + particleGT: '#06b6d4', // cyan - group text + particleDM: '#8b5cf6', // purple - direct messages + particleACK: '#22c55e', // green - acknowledgments + particleTR: '#f97316', // orange - trace packets + particleRQ: '#ec4899', // pink - requests + particleRS: '#14b8a6', // teal - responses + particleUnknown: '#6b7280', // gray - unknown +} as const; + +export const PARTICLE_COLOR_MAP: Record = { + AD: COLORS.particleAD, + GT: COLORS.particleGT, + DM: COLORS.particleDM, + ACK: COLORS.particleACK, + TR: COLORS.particleTR, + RQ: COLORS.particleRQ, + RS: COLORS.particleRS, + '?': COLORS.particleUnknown, +}; + +export const PARTICLE_SPEED = 0.008; +export const DEFAULT_OBSERVATION_WINDOW_SEC = 15; +// Traffic pattern analysis thresholds +// Be conservative - once split, we can't unsplit, so require strong evidence +export const MIN_OBSERVATIONS_TO_SPLIT = 20; // Need at least this many unique sources per next-hop group +export const MAX_TRAFFIC_OBSERVATIONS = 200; // Per ambiguous prefix, to limit memory +export const TRAFFIC_OBSERVATION_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes - old observations are pruned + +export const PACKET_LEGEND_ITEMS = [ + { label: 'AD', color: COLORS.particleAD, description: 'Advertisement' }, + { label: 'GT', color: COLORS.particleGT, description: 'Group Text' }, + { label: 'DM', color: COLORS.particleDM, description: 'Direct Message' }, + { label: 'ACK', color: COLORS.particleACK, description: 'Acknowledgment' }, + { label: 'TR', color: COLORS.particleTR, description: 'Trace' }, + { label: 'RQ', color: COLORS.particleRQ, description: 'Request' }, + { label: 'RS', color: COLORS.particleRS, description: 'Response' }, + { label: '?', color: COLORS.particleUnknown, description: 'Other' }, +] as const; + +// ============================================================================= +// UTILITY FUNCTIONS (Data Layer) +// ============================================================================= + +export function simpleHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash = hash & hash; + } + return Math.abs(hash).toString(16).padStart(8, '0'); +} + +export function parsePacket(hexData: string): ParsedPacket | null { + try { + const decoded = MeshCoreDecoder.decode(hexData); + if (!decoded.isValid) return null; + + const result: ParsedPacket = { + payloadType: decoded.payloadType, + messageHash: decoded.messageHash || null, + pathBytes: decoded.path || [], + srcHash: null, + dstHash: null, + advertPubkey: null, + groupTextSender: null, + anonRequestPubkey: null, + }; + + if (decoded.payloadType === PayloadType.TextMessage && decoded.payload.decoded) { + const payload = decoded.payload.decoded as { sourceHash?: string; destinationHash?: string }; + result.srcHash = payload.sourceHash || null; + result.dstHash = payload.destinationHash || null; + } else if (decoded.payloadType === PayloadType.Advert && decoded.payload.decoded) { + result.advertPubkey = (decoded.payload.decoded as { publicKey?: string }).publicKey || null; + } else if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) { + const payload = decoded.payload.decoded as { decrypted?: { sender?: string } }; + result.groupTextSender = payload.decrypted?.sender || null; + } else if (decoded.payloadType === PayloadType.AnonRequest && decoded.payload.decoded) { + const payload = decoded.payload.decoded as { senderPublicKey?: string }; + result.anonRequestPubkey = payload.senderPublicKey || null; + } + + return result; + } catch { + return null; + } +} + +export function getPacketLabel(payloadType: number): PacketLabel { + switch (payloadType) { + case PayloadType.Advert: + return 'AD'; + case PayloadType.GroupText: + return 'GT'; + case PayloadType.TextMessage: + return 'DM'; + case PayloadType.Ack: + return 'ACK'; + case PayloadType.Trace: + return 'TR'; + case PayloadType.Request: + case PayloadType.AnonRequest: + return 'RQ'; + case PayloadType.Response: + return 'RS'; + default: + return '?'; + } +} + +export function generatePacketKey(parsed: ParsedPacket, rawPacket: RawPacket): string { + const contentHash = (parsed.messageHash || simpleHash(rawPacket.data)).slice(0, 8); + + if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { + return `ad:${parsed.advertPubkey.slice(0, 12)}`; + } + if (parsed.payloadType === PayloadType.GroupText) { + const sender = parsed.groupTextSender || rawPacket.decrypted_info?.sender || '?'; + const channel = rawPacket.decrypted_info?.channel_name || '?'; + return `gt:${channel}:${sender}:${contentHash}`; + } + if (parsed.payloadType === PayloadType.TextMessage) { + return `dm:${parsed.srcHash || '?'}:${parsed.dstHash || '?'}:${contentHash}`; + } + if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) { + return `rq:${parsed.anonRequestPubkey.slice(0, 12)}:${contentHash}`; + } + return `other:${contentHash}`; +} + +export function getLinkId< + T extends { source: string | { id: string }; target: string | { id: string } }, +>(link: T): { sourceId: string; targetId: string } { + return { + sourceId: typeof link.source === 'string' ? link.source : link.source.id, + targetId: typeof link.target === 'string' ? link.target : link.target.id, + }; +} + +export function findContactByPrefix(prefix: string, contacts: Contact[]): Contact | null { + const normalized = prefix.toLowerCase(); + const matches = contacts.filter((c) => c.public_key.toLowerCase().startsWith(normalized)); + return matches.length === 1 ? matches[0] : null; +} + +export function findContactsByPrefix(prefix: string, contacts: Contact[]): Contact[] { + const normalized = prefix.toLowerCase(); + return contacts.filter((c) => c.public_key.toLowerCase().startsWith(normalized)); +} + +export function findContactByName(name: string, contacts: Contact[]): Contact | null { + return contacts.find((c) => c.name === name) || null; +} + +export function getNodeType(contact: Contact | null | undefined): NodeType { + return contact?.type === CONTACT_TYPE_REPEATER ? 'repeater' : 'client'; +} + +export function dedupeConsecutive(arr: T[]): T[] { + return arr.filter((item, i) => i === 0 || item !== arr[i - 1]); +} + +/** + * Analyze traffic patterns for an ambiguous repeater prefix to determine if it + * should be split into multiple nodes. + */ +export function analyzeRepeaterTraffic(data: RepeaterTrafficData): RepeaterSplitAnalysis { + const now = Date.now(); + + // Filter out old observations + const recentObservations = data.observations.filter( + (obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS + ); + + // Group by nextHop (use "self" for null nextHop - final repeater) + const byNextHop = new Map>(); + for (const obs of recentObservations) { + const hopKey = obs.nextHop ?? 'self'; + if (!byNextHop.has(hopKey)) { + byNextHop.set(hopKey, new Set()); + } + byNextHop.get(hopKey)!.add(obs.source); + } + + // If only one nextHop group, no need to split + if (byNextHop.size <= 1) { + return { shouldSplit: false, disjointGroups: null }; + } + + // Check if any source appears in multiple groups (evidence of hub behavior) + const allSources = new Map(); // source -> list of nextHops it uses + for (const [nextHop, sources] of byNextHop) { + for (const source of sources) { + if (!allSources.has(source)) { + allSources.set(source, []); + } + allSources.get(source)!.push(nextHop); + } + } + + // If any source routes to multiple nextHops, this is a hub - don't split + for (const [, nextHops] of allSources) { + if (nextHops.length > 1) { + return { shouldSplit: false, disjointGroups: null }; + } + } + + // Check if we have enough observations in each group to be confident + for (const [, sources] of byNextHop) { + if (sources.size < MIN_OBSERVATIONS_TO_SPLIT) { + // Not enough evidence yet - be conservative, don't split + return { shouldSplit: false, disjointGroups: null }; + } + } + + // Source sets are disjoint and we have enough data - split! + return { shouldSplit: true, disjointGroups: byNextHop }; +} + +/** + * Record a traffic observation for an ambiguous repeater prefix. + * Prunes old observations and limits total count. + */ +export function recordTrafficObservation( + trafficData: Map, + prefix: string, + source: string, + nextHop: string | null +): void { + const normalizedPrefix = prefix.toLowerCase(); + const now = Date.now(); + + if (!trafficData.has(normalizedPrefix)) { + trafficData.set(normalizedPrefix, { prefix: normalizedPrefix, observations: [] }); + } + + const data = trafficData.get(normalizedPrefix)!; + + // Add new observation + data.observations.push({ source, nextHop, timestamp: now }); + + // Prune old observations + data.observations = data.observations.filter( + (obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS + ); + + // Limit total count + if (data.observations.length > MAX_TRAFFIC_OBSERVATIONS) { + data.observations = data.observations.slice(-MAX_TRAFFIC_OBSERVATIONS); + } +}