mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Move visualizer to 3D
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1692
frontend/src/components/PacketVisualizer3D.tsx
Normal file
1692
frontend/src/components/PacketVisualizer3D.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
<TabsTrigger value="packets">Packet Feed</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="visualizer" className="flex-1 m-0 overflow-hidden">
|
||||
<PacketVisualizer
|
||||
<PacketVisualizer3D
|
||||
packets={packets}
|
||||
contacts={contacts}
|
||||
config={config}
|
||||
@@ -52,7 +52,7 @@ export function VisualizerView({ packets, contacts, config, onClearPackets }: Vi
|
||||
fullScreen ? 'flex-1' : 'flex-1 border-r border-border'
|
||||
)}
|
||||
>
|
||||
<PacketVisualizer
|
||||
<PacketVisualizer3D
|
||||
packets={packets}
|
||||
contacts={contacts}
|
||||
config={config}
|
||||
|
||||
198
frontend/src/types/d3-force-3d.d.ts
vendored
Normal file
198
frontend/src/types/d3-force-3d.d.ts
vendored
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Minimal type declarations for d3-force-3d.
|
||||
*
|
||||
* d3-force-3d mirrors the d3-force API but adds z-axis support.
|
||||
* Nodes gain z/vz/fz properties; forceCenter accepts a third arg; forceZ is new.
|
||||
*/
|
||||
declare module 'd3-force-3d' {
|
||||
import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
|
||||
|
||||
// Re-export the base datum types with z-axis extensions
|
||||
export interface SimulationNodeDatum3D extends SimulationNodeDatum {
|
||||
z?: number | undefined;
|
||||
vz?: number | undefined;
|
||||
fz?: number | null | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Simulation3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
LinkDatum extends SimulationLinkDatum<NodeDatum> = SimulationLinkDatum<NodeDatum>,
|
||||
> {
|
||||
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<NodeDatum, LinkDatum> | undefined;
|
||||
force(name: string, force: Force3D<NodeDatum, LinkDatum> | 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<NodeDatum> = SimulationLinkDatum<NodeDatum>,
|
||||
> {}
|
||||
|
||||
export function forceSimulation<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
LinkDatum extends SimulationLinkDatum<NodeDatum> = SimulationLinkDatum<NodeDatum>,
|
||||
>(nodes?: NodeDatum[]): Simulation3D<NodeDatum, LinkDatum>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ForceLink3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
LinkDatum extends SimulationLinkDatum<NodeDatum> = SimulationLinkDatum<NodeDatum>,
|
||||
> extends Force3D<NodeDatum, LinkDatum> {
|
||||
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<NodeDatum> = SimulationLinkDatum<NodeDatum>,
|
||||
>(links?: LinkDatum[]): ForceLink3D<NodeDatum, LinkDatum>;
|
||||
|
||||
export interface ForceManyBody3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
> extends Force3D<NodeDatum> {
|
||||
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<NodeDatum>;
|
||||
|
||||
export interface ForceCenter3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
> extends Force3D<NodeDatum> {
|
||||
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<NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D>(
|
||||
x?: number,
|
||||
y?: number,
|
||||
z?: number
|
||||
): ForceCenter3D<NodeDatum>;
|
||||
|
||||
export interface ForceCollide3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
> extends Force3D<NodeDatum> {
|
||||
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<NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D>(
|
||||
radius?: number | ((node: NodeDatum, i: number, nodes: NodeDatum[]) => number)
|
||||
): ForceCollide3D<NodeDatum>;
|
||||
|
||||
export interface ForceX3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
> extends Force3D<NodeDatum> {
|
||||
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<NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D>(
|
||||
x?: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)
|
||||
): ForceX3D<NodeDatum>;
|
||||
|
||||
export interface ForceY3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
> extends Force3D<NodeDatum> {
|
||||
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<NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D>(
|
||||
y?: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)
|
||||
): ForceY3D<NodeDatum>;
|
||||
|
||||
export interface ForceZ3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
> extends Force3D<NodeDatum> {
|
||||
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<NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D>(
|
||||
z?: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number)
|
||||
): ForceZ3D<NodeDatum>;
|
||||
|
||||
export interface ForceRadial3D<
|
||||
NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D,
|
||||
> extends Force3D<NodeDatum> {
|
||||
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<NodeDatum extends SimulationNodeDatum3D = SimulationNodeDatum3D>(
|
||||
radius?: number | ((d: NodeDatum, i: number, data: NodeDatum[]) => number),
|
||||
x?: number,
|
||||
y?: number,
|
||||
z?: number
|
||||
): ForceRadial3D<NodeDatum>;
|
||||
}
|
||||
324
frontend/src/utils/visualizerUtils.ts
Normal file
324
frontend/src/utils/visualizerUtils.ts
Normal file
@@ -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<string, Set<string>> | 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<PacketLabel, string> = {
|
||||
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<T>(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<string, Set<string>>();
|
||||
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<string, string[]>(); // 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<string, RepeaterTrafficData>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user