diff --git a/frontend/src/components/PacketVisualizer3D.tsx b/frontend/src/components/PacketVisualizer3D.tsx
index aa712a0..520d1c8 100644
--- a/frontend/src/components/PacketVisualizer3D.tsx
+++ b/frontend/src/components/PacketVisualizer3D.tsx
@@ -117,7 +117,7 @@ export function PacketVisualizer3D({
pruneStaleMinutes,
});
- const { hoveredNodeId, hoveredNeighborIds, pinnedNodeId } = useVisualizer3DScene({
+ const { hoveredNodeId, pinnedNodeId } = useVisualizer3DScene({
containerRef,
data,
autoOrbit,
@@ -167,8 +167,9 @@ export function PacketVisualizer3D({
);
diff --git a/frontend/src/components/visualizer/AGENTS_packet_visualizer.md b/frontend/src/components/visualizer/AGENTS_packet_visualizer.md
index d37d507..31dbf93 100644
--- a/frontend/src/components/visualizer/AGENTS_packet_visualizer.md
+++ b/frontend/src/components/visualizer/AGENTS_packet_visualizer.md
@@ -12,24 +12,41 @@ The visualizer displays:
## Architecture
-### Data Layer (`components/visualizer/useVisualizerData3D.ts`)
+### Semantic Data Layer (`networkGraph/packetNetworkGraph.ts`)
-The custom hook manages all graph state and simulation logic:
+The packet-network module owns the canonical mesh representation and the visibility-aware projection logic:
```
-Packets → Parse → Aggregate by key → Observation window → Publish → Animate
+Packets → Parse → Canonical observations/adjacency → Projection by settings
```
**Key responsibilities:**
-- Maintains node and link maps (`nodesRef`, `linksRef`)
+- Resolves packet source / repeater / destination nodes into a canonical path
+- Maintains canonical node, link, observation, and neighbor state independent of UI toggles
+- Applies ambiguous repeater heuristics and advert-path hints while building canonical data
+- Projects canonical paths into rendered links, including dashed bridges over hidden ambiguous runs
+- Exposes a reusable semantic surface for other consumers besides the 3D visualizer
+
+### Visualizer Data Hook (`components/visualizer/useVisualizerData3D.ts`)
+
+The hook manages render-specific state and animation timing on top of the shared packet-network data layer:
+
+```
+Canonical projection → Aggregate by key → Observation window → Publish → Animate
+```
+
+**Key responsibilities:**
+
+- Adapts semantic packet-network nodes/links into `GraphNode` / `GraphLink` render objects
- Runs `d3-force-3d` simulation for 3D layout (`.numDimensions(3)`)
-- Processes incoming packets with deduplication
-- Aggregates packet repeats across multiple paths
+- Processes incoming packets with deduplication and feeds them into the semantic layer
+- Aggregates packet repeats across multiple projected paths
- Manages particle queue and animation timing
**State:**
+- `networkStateRef`: Canonical packet-network state (nodes, links, observations, neighbors)
- `nodesRef`: Map of node ID → GraphNode
- `linksRef`: Map of link key → GraphLink
- `particlesRef`: Array of active Particle objects
@@ -50,6 +67,8 @@ Scene creation, render-loop updates, raycasting hover, and click-to-pin interact
### Shared Utilities
+- `networkGraph/packetNetworkGraph.ts`
+ - Canonical packet-network types and replay/projection logic
- `components/visualizer/shared.ts`
- Graph-specific types: `GraphNode`, `GraphLink`, `NodeMeshData`
- Shared rendering helpers: node colors, relative-time formatting, typed-array growth helpers
@@ -308,7 +327,7 @@ function buildPath(parsed, packet, myPrefix): string[] {
| Pan (right-drag) | Pan the camera |
| Scroll wheel | Zoom in/out |
-**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.
+**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" using canonical packet-network adjacency, not rendered-link adjacency, so hidden repeaters still appear truthfully as hidden neighbors.
## Configuration Options
@@ -333,15 +352,18 @@ function buildPath(parsed, packet, myPrefix): string[] {
```
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)
+├── SEMANTIC DATA LAYER (networkGraph/packetNetworkGraph.ts)
+│ ├── Contact/advert indexes
+│ ├── Canonical node/link/neighbor/observation state
+│ ├── Path building (resolveNode, buildCanonicalPathForPacket)
│ ├── Traffic pattern analysis (for repeater disambiguation)
-│ └── Packet processing & publishing
+│ └── Projection (projectCanonicalPath, projectPacketNetwork)
+├── DATA HOOK (useVisualizerData3D)
+│ ├── Refs (network state, render nodes, links, particles, simulation, pending, timers, stretchRaf)
+│ ├── d3-force-3d simulation initialization (.numDimensions(3))
+│ ├── Semantic→render adaptation
+│ ├── Observation-window packet aggregation
+│ └── Particle publishing
└── MAIN COMPONENT (PacketVisualizer3D)
├── Three.js scene setup (WebGLRenderer, CSS2DRenderer, OrbitControls)
├── Node mesh management (SphereGeometry + CSS2DObject labels)
@@ -356,6 +378,13 @@ utils/visualizerUtils.ts
├── Constants (COLORS, PARTICLE_COLOR_MAP, PARTICLE_SPEED, PACKET_LEGEND_ITEMS)
└── Functions (parsePacket, generatePacketKey, analyzeRepeaterTraffic, etc.)
+networkGraph/packetNetworkGraph.ts
+├── Types (PacketNetworkNode, PacketNetworkLink, PacketNetworkObservation, projection types)
+├── Context builders (contact and advert-path indexes)
+├── Canonical replay (ingestPacketIntoPacketNetwork)
+├── Projection helpers (projectCanonicalPath, projectPacketNetwork)
+└── State maintenance (clear, prune, neighbor snapshots)
+
types/d3-force-3d.d.ts
└── Type declarations for d3-force-3d (SimulationNodeDatum3D, Simulation3D, forces)
```
diff --git a/frontend/src/components/visualizer/VisualizerTooltip.tsx b/frontend/src/components/visualizer/VisualizerTooltip.tsx
index 0482a4a..94df1af 100644
--- a/frontend/src/components/visualizer/VisualizerTooltip.tsx
+++ b/frontend/src/components/visualizer/VisualizerTooltip.tsx
@@ -1,25 +1,37 @@
-import type { GraphNode } from './shared';
+import type { PacketNetworkNode } from '../../networkGraph/packetNetworkGraph';
import { formatRelativeTime } from './shared';
interface VisualizerTooltipProps {
activeNodeId: string | null;
- nodes: Map;
- neighborIds: string[];
+ canonicalNodes: Map;
+ canonicalNeighborIds: Map;
+ renderedNodeIds: Set;
}
-export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: VisualizerTooltipProps) {
+export function VisualizerTooltip({
+ activeNodeId,
+ canonicalNodes,
+ canonicalNeighborIds,
+ renderedNodeIds,
+}: VisualizerTooltipProps) {
if (!activeNodeId) return null;
- const node = nodes.get(activeNodeId);
+ const node = canonicalNodes.get(activeNodeId);
if (!node) return null;
+ const neighborIds = canonicalNeighborIds.get(activeNodeId) ?? [];
const neighbors = neighborIds
.map((nid) => {
- const neighbor = nodes.get(nid);
+ const neighbor = canonicalNodes.get(nid);
if (!neighbor) return null;
const displayName =
neighbor.name || (neighbor.type === 'self' ? 'Me' : neighbor.id.slice(0, 8));
- return { id: nid, name: displayName, ambiguousNames: neighbor.ambiguousNames };
+ return {
+ id: nid,
+ name: displayName,
+ ambiguousNames: neighbor.ambiguousNames,
+ hidden: !renderedNodeIds.has(nid),
+ };
})
.filter((neighbor): neighbor is NonNullable => neighbor !== null);
@@ -56,6 +68,7 @@ export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: Visualiz
{neighbors.map((neighbor) => (
{neighbor.name}
+ {neighbor.hidden && (hidden)}
{neighbor.ambiguousNames && neighbor.ambiguousNames.length > 0 && (
{' '}
diff --git a/frontend/src/components/visualizer/shared.ts b/frontend/src/components/visualizer/shared.ts
index 0f24c47..5163cb9 100644
--- a/frontend/src/components/visualizer/shared.ts
+++ b/frontend/src/components/visualizer/shared.ts
@@ -23,6 +23,7 @@ export interface GraphLink extends SimulationLinkDatum {
lastActivity: number;
hasDirectObservation: boolean;
hasHiddenIntermediate: boolean;
+ hiddenHopLabels: string[];
}
export interface NodeMeshData {
@@ -76,6 +77,11 @@ export function formatRelativeTime(timestamp: number): string {
return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`;
}
+export function getSceneNodeLabel(node: Pick) {
+ const baseLabel = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
+ return node.isAmbiguous ? `${baseLabel} (?)` : baseLabel;
+}
+
export function normalizePacketTimestampMs(timestamp: number | null | undefined): number {
if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) {
return Date.now();
diff --git a/frontend/src/components/visualizer/useVisualizer3DScene.ts b/frontend/src/components/visualizer/useVisualizer3DScene.ts
index 72bea0b..fb52a4b 100644
--- a/frontend/src/components/visualizer/useVisualizer3DScene.ts
+++ b/frontend/src/components/visualizer/useVisualizer3DScene.ts
@@ -5,7 +5,13 @@ import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRe
import { COLORS, getLinkId } from '../../utils/visualizerUtils';
import type { VisualizerData3D } from './useVisualizerData3D';
-import { arraysEqual, getBaseNodeColor, growFloat32Buffer, type NodeMeshData } from './shared';
+import {
+ arraysEqual,
+ getBaseNodeColor,
+ getSceneNodeLabel,
+ growFloat32Buffer,
+ type NodeMeshData,
+} from './shared';
interface UseVisualizer3DSceneArgs {
containerRef: RefObject;
@@ -362,7 +368,7 @@ export function useVisualizer3DScene({
if (nd.labelDiv.style.color !== labelColor) {
nd.labelDiv.style.color = labelColor;
}
- const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
+ const labelText = getSceneNodeLabel(node);
if (nd.labelDiv.textContent !== labelText) {
nd.labelDiv.textContent = labelText;
}
diff --git a/frontend/src/components/visualizer/useVisualizerData3D.ts b/frontend/src/components/visualizer/useVisualizerData3D.ts
index bd3f53f..c1e3080 100644
--- a/frontend/src/components/visualizer/useVisualizerData3D.ts
+++ b/frontend/src/components/visualizer/useVisualizerData3D.ts
@@ -10,10 +10,20 @@ import {
type ForceLink3D,
type Simulation3D,
} from 'd3-force-3d';
-import { PayloadType } from '@michaelhart/meshcore-decoder';
+import type { PacketNetworkNode } from '../../networkGraph/packetNetworkGraph';
+import {
+ buildPacketNetworkContext,
+ clearPacketNetworkState,
+ createPacketNetworkState,
+ ensureSelfNode,
+ ingestPacketIntoPacketNetwork,
+ projectCanonicalPath,
+ projectPacketNetwork,
+ prunePacketNetworkState,
+ snapshotNeighborIds,
+} from '../../networkGraph/packetNetworkGraph';
import {
- CONTACT_TYPE_REPEATER,
type Contact,
type ContactAdvertPathSummary,
type RadioConfig,
@@ -22,24 +32,14 @@ import {
import { getRawPacketObservationKey } from '../../utils/rawPacketIdentity';
import {
buildLinkKey,
- type Particle,
- type PathStep,
- type PendingPacket,
- type RepeaterTrafficData,
- PARTICLE_COLOR_MAP,
- PARTICLE_SPEED,
- analyzeRepeaterTraffic,
- buildAmbiguousRepeaterLabel,
- buildAmbiguousRepeaterNodeId,
- compactPathSteps,
dedupeConsecutive,
generatePacketKey,
- getNodeType,
- getPacketLabel,
- parsePacket,
- recordTrafficObservation,
+ type Particle,
+ PARTICLE_COLOR_MAP,
+ PARTICLE_SPEED,
+ type PendingPacket,
} from '../../utils/visualizerUtils';
-import { type GraphLink, type GraphNode, normalizePacketTimestampMs } from './shared';
+import { type GraphLink, type GraphNode } from './shared';
export interface UseVisualizerData3DOptions {
packets: RawPacket[];
@@ -61,12 +61,39 @@ export interface UseVisualizerData3DOptions {
export interface VisualizerData3D {
nodes: Map;
links: Map;
+ canonicalNodes: Map;
+ canonicalNeighborIds: Map;
+ renderedNodeIds: Set;
particles: Particle[];
stats: { processed: number; animated: number; nodes: number; links: number };
expandContract: () => void;
clearAndReset: () => void;
}
+function buildInitialRenderNode(node: PacketNetworkNode): GraphNode {
+ if (node.id === 'self') {
+ return {
+ ...node,
+ x: 0,
+ y: 0,
+ z: 0,
+ vx: 0,
+ vy: 0,
+ vz: 0,
+ };
+ }
+
+ const theta = Math.random() * Math.PI * 2;
+ const phi = Math.acos(2 * Math.random() - 1);
+ const r = 80 + Math.random() * 100;
+ return {
+ ...node,
+ x: r * Math.sin(phi) * Math.cos(theta),
+ y: r * Math.sin(phi) * Math.sin(theta),
+ z: r * Math.cos(phi),
+ };
+}
+
export function useVisualizerData3D({
packets,
contacts,
@@ -83,6 +110,7 @@ export function useVisualizerData3D({
pruneStaleNodes,
pruneStaleMinutes,
}: UseVisualizerData3DOptions): VisualizerData3D {
+ const networkStateRef = useRef(createPacketNetworkState(config?.name || 'Me'));
const nodesRef = useRef