Move visualizer to 3D

This commit is contained in:
Jack Kingsman
2026-02-16 15:36:44 -08:00
parent 0d03945b81
commit 89d311e4ae
8 changed files with 2329 additions and 1832 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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>;
}

View 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);
}
}