mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Visualizer overhaul
This commit is contained in:
@@ -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({
|
||||
|
||||
<VisualizerTooltip
|
||||
activeNodeId={tooltipNodeId}
|
||||
nodes={data.nodes}
|
||||
neighborIds={hoveredNeighborIds}
|
||||
canonicalNodes={data.canonicalNodes}
|
||||
canonicalNeighborIds={data.canonicalNeighborIds}
|
||||
renderedNodeIds={data.renderedNodeIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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<string, GraphNode>;
|
||||
neighborIds: string[];
|
||||
canonicalNodes: Map<string, PacketNetworkNode>;
|
||||
canonicalNeighborIds: Map<string, string[]>;
|
||||
renderedNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
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<typeof neighbor> => neighbor !== null);
|
||||
|
||||
@@ -56,6 +68,7 @@ export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: Visualiz
|
||||
{neighbors.map((neighbor) => (
|
||||
<li key={neighbor.id}>
|
||||
{neighbor.name}
|
||||
{neighbor.hidden && <span className="text-muted-foreground/60"> (hidden)</span>}
|
||||
{neighbor.ambiguousNames && neighbor.ambiguousNames.length > 0 && (
|
||||
<span className="text-muted-foreground/60">
|
||||
{' '}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface GraphLink extends SimulationLinkDatum<GraphNode> {
|
||||
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<GraphNode, 'id' | 'name' | 'type' | 'isAmbiguous'>) {
|
||||
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();
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, GraphNode>;
|
||||
links: Map<string, GraphLink>;
|
||||
canonicalNodes: Map<string, PacketNetworkNode>;
|
||||
canonicalNeighborIds: Map<string, string[]>;
|
||||
renderedNodeIds: Set<string>;
|
||||
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<Map<string, GraphNode>>(new Map());
|
||||
const linksRef = useRef<Map<string, GraphLink>>(new Map());
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
@@ -90,47 +118,23 @@ export function useVisualizerData3D({
|
||||
const processedRef = useRef<Set<string>>(new Set());
|
||||
const pendingRef = useRef<Map<string, PendingPacket>>(new Map());
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const trafficPatternsRef = useRef<Map<string, RepeaterTrafficData>>(new Map());
|
||||
const speedMultiplierRef = useRef(particleSpeedMultiplier);
|
||||
const observationWindowRef = useRef(observationWindowSec * 1000);
|
||||
const stretchRafRef = useRef<number | null>(null);
|
||||
const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 });
|
||||
const [, setProjectionVersion] = useState(0);
|
||||
|
||||
const contactIndex = useMemo(() => {
|
||||
const byPrefix12 = new Map<string, Contact>();
|
||||
const byName = new Map<string, Contact>();
|
||||
const byPrefix = new Map<string, Contact[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const prefix12 = contact.public_key.slice(0, 12).toLowerCase();
|
||||
byPrefix12.set(prefix12, contact);
|
||||
|
||||
if (contact.name && !byName.has(contact.name)) {
|
||||
byName.set(contact.name, contact);
|
||||
}
|
||||
|
||||
for (let len = 1; len <= 12; len++) {
|
||||
const prefix = prefix12.slice(0, len);
|
||||
const matches = byPrefix.get(prefix);
|
||||
if (matches) {
|
||||
matches.push(contact);
|
||||
} else {
|
||||
byPrefix.set(prefix, [contact]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byPrefix12, byName, byPrefix };
|
||||
}, [contacts]);
|
||||
|
||||
const advertPathIndex = useMemo(() => {
|
||||
const byRepeater = new Map<string, ContactAdvertPathSummary['paths']>();
|
||||
for (const summary of repeaterAdvertPaths) {
|
||||
const key = summary.public_key.slice(0, 12).toLowerCase();
|
||||
byRepeater.set(key, summary.paths);
|
||||
}
|
||||
return { byRepeater };
|
||||
}, [repeaterAdvertPaths]);
|
||||
const packetNetworkContext = useMemo(
|
||||
() =>
|
||||
buildPacketNetworkContext({
|
||||
contacts,
|
||||
config,
|
||||
repeaterAdvertPaths,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
}),
|
||||
[contacts, config, repeaterAdvertPaths, splitAmbiguousByTraffic, useAdvertPathHints]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
speedMultiplierRef.current = particleSpeedMultiplier;
|
||||
@@ -216,115 +220,98 @@ export function useVisualizerData3D({
|
||||
? prev
|
||||
: { ...prev, nodes: nodes.length, links: links.length }
|
||||
);
|
||||
setProjectionVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodesRef.current.has('self')) {
|
||||
nodesRef.current.set('self', {
|
||||
id: 'self',
|
||||
name: config?.name || 'Me',
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
});
|
||||
syncSimulation();
|
||||
const upsertRenderNode = useCallback(
|
||||
(node: PacketNetworkNode, existing?: GraphNode): GraphNode => {
|
||||
if (!existing) {
|
||||
return buildInitialRenderNode(node);
|
||||
}
|
||||
|
||||
existing.name = node.name;
|
||||
existing.type = node.type;
|
||||
existing.isAmbiguous = node.isAmbiguous;
|
||||
existing.lastActivity = node.lastActivity;
|
||||
existing.lastActivityReason = node.lastActivityReason;
|
||||
existing.lastSeen = node.lastSeen;
|
||||
existing.probableIdentity = node.probableIdentity;
|
||||
existing.ambiguousNames = node.ambiguousNames;
|
||||
|
||||
if (node.id === 'self') {
|
||||
existing.x = 0;
|
||||
existing.y = 0;
|
||||
existing.z = 0;
|
||||
existing.vx = 0;
|
||||
existing.vy = 0;
|
||||
existing.vz = 0;
|
||||
}
|
||||
|
||||
return existing;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const rebuildRenderProjection = useCallback(() => {
|
||||
const projection = projectPacketNetwork(networkStateRef.current, {
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
});
|
||||
const previousNodes = nodesRef.current;
|
||||
const nextNodes = new Map<string, GraphNode>();
|
||||
|
||||
for (const [nodeId, node] of projection.nodes) {
|
||||
nextNodes.set(nodeId, upsertRenderNode(node, previousNodes.get(nodeId)));
|
||||
}
|
||||
}, [config, syncSimulation]);
|
||||
|
||||
const nextLinks = new Map<string, GraphLink>();
|
||||
for (const [key, link] of projection.links) {
|
||||
nextLinks.set(key, {
|
||||
source: link.sourceId,
|
||||
target: link.targetId,
|
||||
lastActivity: link.lastActivity,
|
||||
hasDirectObservation: link.hasDirectObservation,
|
||||
hasHiddenIntermediate: link.hasHiddenIntermediate,
|
||||
hiddenHopLabels: [...link.hiddenHopLabels],
|
||||
});
|
||||
}
|
||||
|
||||
nodesRef.current = nextNodes;
|
||||
linksRef.current = nextLinks;
|
||||
syncSimulation();
|
||||
}, [showAmbiguousNodes, showAmbiguousPaths, syncSimulation, upsertRenderNode]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureSelfNode(networkStateRef.current, config?.name || 'Me');
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode, nodesRef.current.get('self')));
|
||||
}
|
||||
syncSimulation();
|
||||
}, [config?.name, syncSimulation, upsertRenderNode]);
|
||||
|
||||
useEffect(() => {
|
||||
processedRef.current.clear();
|
||||
const selfNode = nodesRef.current.get('self');
|
||||
clearPacketNetworkState(networkStateRef.current, { selfName: config?.name || 'Me' });
|
||||
nodesRef.current.clear();
|
||||
if (selfNode) nodesRef.current.set('self', selfNode);
|
||||
linksRef.current.clear();
|
||||
particlesRef.current = [];
|
||||
pendingRef.current.clear();
|
||||
timersRef.current.forEach((t) => clearTimeout(t));
|
||||
timersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
timersRef.current.clear();
|
||||
trafficPatternsRef.current.clear();
|
||||
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode));
|
||||
}
|
||||
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
syncSimulation();
|
||||
}, [
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
splitAmbiguousByTraffic,
|
||||
syncSimulation,
|
||||
]);
|
||||
}, [config?.name, splitAmbiguousByTraffic, syncSimulation, upsertRenderNode, useAdvertPathHints]);
|
||||
|
||||
const addNode = useCallback(
|
||||
(
|
||||
id: string,
|
||||
name: string | null,
|
||||
type: GraphNode['type'],
|
||||
isAmbiguous: boolean,
|
||||
probableIdentity?: string | null,
|
||||
ambiguousNames?: string[],
|
||||
lastSeen?: number | null,
|
||||
activityAtMs?: number
|
||||
) => {
|
||||
const activityAt = activityAtMs ?? Date.now();
|
||||
const existing = nodesRef.current.get(id);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
|
||||
if (name) existing.name = name;
|
||||
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
|
||||
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
||||
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
||||
} else {
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = 80 + Math.random() * 100;
|
||||
nodesRef.current.set(id, {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
isAmbiguous,
|
||||
lastActivity: activityAt,
|
||||
probableIdentity,
|
||||
lastSeen,
|
||||
ambiguousNames,
|
||||
x: r * Math.sin(phi) * Math.cos(theta),
|
||||
y: r * Math.sin(phi) * Math.sin(theta),
|
||||
z: r * Math.cos(phi),
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addLink = useCallback(
|
||||
(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
activityAtMs?: number,
|
||||
hiddenIntermediate: boolean = false
|
||||
) => {
|
||||
const activityAt = activityAtMs ?? Date.now();
|
||||
const key = buildLinkKey(sourceId, targetId);
|
||||
const existing = linksRef.current.get(key);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
|
||||
if (hiddenIntermediate) {
|
||||
existing.hasHiddenIntermediate = true;
|
||||
} else {
|
||||
existing.hasDirectObservation = true;
|
||||
}
|
||||
} else {
|
||||
linksRef.current.set(key, {
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
lastActivity: activityAt,
|
||||
hasDirectObservation: !hiddenIntermediate,
|
||||
hasHiddenIntermediate: hiddenIntermediate,
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
rebuildRenderProjection();
|
||||
}, [rebuildRenderProjection]);
|
||||
|
||||
const publishPacket = useCallback((packetKey: string) => {
|
||||
const pending = pendingRef.current.get(packetKey);
|
||||
@@ -353,342 +340,10 @@ export function useVisualizerData3D({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pickLikelyRepeaterByAdvertPath = useCallback(
|
||||
(candidates: Contact[], nextPrefix: string | null) => {
|
||||
const nextHop = nextPrefix?.toLowerCase() ?? null;
|
||||
const scored = candidates
|
||||
.map((candidate) => {
|
||||
const prefix12 = candidate.public_key.slice(0, 12).toLowerCase();
|
||||
const paths = advertPathIndex.byRepeater.get(prefix12) ?? [];
|
||||
let matchScore = 0;
|
||||
let totalScore = 0;
|
||||
|
||||
for (const path of paths) {
|
||||
totalScore += path.heard_count;
|
||||
const pathNextHop = path.next_hop?.toLowerCase() ?? null;
|
||||
if (pathNextHop === nextHop) {
|
||||
matchScore += path.heard_count;
|
||||
}
|
||||
}
|
||||
|
||||
return { candidate, matchScore, totalScore };
|
||||
})
|
||||
.filter((entry) => entry.totalScore > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.matchScore - a.matchScore ||
|
||||
b.totalScore - a.totalScore ||
|
||||
a.candidate.public_key.localeCompare(b.candidate.public_key)
|
||||
);
|
||||
|
||||
if (scored.length === 0) return null;
|
||||
|
||||
const top = scored[0];
|
||||
const second = scored[1] ?? null;
|
||||
|
||||
if (top.matchScore < 2) return null;
|
||||
if (second && top.matchScore < second.matchScore * 2) return null;
|
||||
|
||||
return top.candidate;
|
||||
},
|
||||
[advertPathIndex]
|
||||
);
|
||||
|
||||
const resolveNode = useCallback(
|
||||
(
|
||||
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
||||
isRepeater: boolean,
|
||||
showAmbiguous: boolean,
|
||||
myPrefix: string | null,
|
||||
activityAtMs: number,
|
||||
trafficContext?: { packetSource: string | null; nextPrefix: string | null }
|
||||
): string | null => {
|
||||
if (source.type === 'pubkey') {
|
||||
if (source.value.length < 12) return null;
|
||||
const nodeId = source.value.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
const contact = contactIndex.byPrefix12.get(nodeId);
|
||||
addNode(
|
||||
nodeId,
|
||||
contact?.name || null,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact?.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (source.type === 'name') {
|
||||
const contact = contactIndex.byName.get(source.value) ?? null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
addNode(
|
||||
nodeId,
|
||||
contact.name,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
const nodeId = `name:${source.value}`;
|
||||
addNode(
|
||||
nodeId,
|
||||
source.value,
|
||||
'client',
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const lookupValue = source.value.toLowerCase();
|
||||
const matches = contactIndex.byPrefix.get(lookupValue) ?? [];
|
||||
const contact = matches.length === 1 ? matches[0] : null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
addNode(
|
||||
nodeId,
|
||||
contact.name,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (showAmbiguous) {
|
||||
const filtered = isRepeater
|
||||
? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER)
|
||||
: matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER);
|
||||
|
||||
if (filtered.length === 1) {
|
||||
const c = filtered[0];
|
||||
const nodeId = c.public_key.slice(0, 12).toLowerCase();
|
||||
addNode(
|
||||
nodeId,
|
||||
c.name,
|
||||
getNodeType(c),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
c.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (filtered.length > 1 || (filtered.length === 0 && isRepeater)) {
|
||||
const names = filtered.map((c) => c.name || c.public_key.slice(0, 8));
|
||||
const lastSeen = filtered.reduce(
|
||||
(max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max),
|
||||
null as number | null
|
||||
);
|
||||
|
||||
let nodeId = buildAmbiguousRepeaterNodeId(lookupValue);
|
||||
let displayName = buildAmbiguousRepeaterLabel(lookupValue);
|
||||
let probableIdentity: string | null = null;
|
||||
let ambiguousNames = names.length > 0 ? names : undefined;
|
||||
|
||||
if (useAdvertPathHints && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
const likely = pickLikelyRepeaterByAdvertPath(filtered, normalizedNext);
|
||||
if (likely) {
|
||||
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
|
||||
probableIdentity = likelyName;
|
||||
displayName = likelyName;
|
||||
ambiguousNames = filtered
|
||||
.filter((c) => c.public_key !== likely.public_key)
|
||||
.map((c) => c.name || c.public_key.slice(0, 8));
|
||||
}
|
||||
}
|
||||
|
||||
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
|
||||
if (trafficContext.packetSource) {
|
||||
recordTrafficObservation(
|
||||
trafficPatternsRef.current,
|
||||
lookupValue,
|
||||
trafficContext.packetSource,
|
||||
normalizedNext
|
||||
);
|
||||
}
|
||||
|
||||
const trafficData = trafficPatternsRef.current.get(lookupValue);
|
||||
if (trafficData) {
|
||||
const analysis = analyzeRepeaterTraffic(trafficData);
|
||||
if (analysis.shouldSplit && normalizedNext) {
|
||||
nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext);
|
||||
if (!probableIdentity) {
|
||||
displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addNode(
|
||||
nodeId,
|
||||
displayName,
|
||||
isRepeater ? 'repeater' : 'client',
|
||||
true,
|
||||
probableIdentity,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[
|
||||
contactIndex,
|
||||
addNode,
|
||||
useAdvertPathHints,
|
||||
pickLikelyRepeaterByAdvertPath,
|
||||
splitAmbiguousByTraffic,
|
||||
]
|
||||
);
|
||||
|
||||
const buildPath = useCallback(
|
||||
(
|
||||
parsed: ReturnType<typeof parsePacket>,
|
||||
packet: RawPacket,
|
||||
myPrefix: string | null,
|
||||
activityAtMs: number
|
||||
): { nodes: string[]; dashedLinkKeys: Set<string> } => {
|
||||
if (!parsed) return { nodes: [], dashedLinkKeys: new Set() };
|
||||
const steps: PathStep[] = [];
|
||||
let packetSource: string | null = null;
|
||||
const isDm = parsed.payloadType === PayloadType.TextMessage;
|
||||
const isOutgoingDm = isDm && !!myPrefix && parsed.srcHash?.toLowerCase() === myPrefix;
|
||||
|
||||
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'pubkey', value: parsed.advertPubkey },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
steps.push({ nodeId });
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'pubkey', value: parsed.anonRequestPubkey },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
steps.push({ nodeId });
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) {
|
||||
if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) {
|
||||
steps.push({ nodeId: 'self' });
|
||||
packetSource = 'self';
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: parsed.srcHash },
|
||||
false,
|
||||
showAmbiguousNodes,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
steps.push({ nodeId });
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.GroupText) {
|
||||
const senderName = parsed.groupTextSender || packet.decrypted_info?.sender;
|
||||
if (senderName) {
|
||||
const resolved = resolveNode(
|
||||
{ type: 'name', value: senderName },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (resolved) {
|
||||
steps.push({ nodeId: resolved });
|
||||
packetSource = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < parsed.pathBytes.length; i++) {
|
||||
const hexPrefix = parsed.pathBytes[i];
|
||||
const nextPrefix = parsed.pathBytes[i + 1] || null;
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: hexPrefix },
|
||||
true,
|
||||
showAmbiguousPaths,
|
||||
myPrefix,
|
||||
activityAtMs,
|
||||
{ packetSource, nextPrefix }
|
||||
);
|
||||
steps.push({ nodeId, markHiddenLinkWhenOmitted: true });
|
||||
}
|
||||
|
||||
if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) {
|
||||
if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) {
|
||||
steps.push({ nodeId: 'self' });
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: parsed.dstHash },
|
||||
false,
|
||||
showAmbiguousNodes,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
steps.push({ nodeId });
|
||||
} else if (!isOutgoingDm) {
|
||||
steps.push({ nodeId: 'self' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const hasVisibleNode = steps.some((step) => step.nodeId !== null);
|
||||
if (hasVisibleNode) {
|
||||
steps.push({ nodeId: 'self' });
|
||||
}
|
||||
}
|
||||
|
||||
const compacted = compactPathSteps(steps);
|
||||
return {
|
||||
nodes: dedupeConsecutive(compacted.nodes),
|
||||
dashedLinkKeys: compacted.dashedLinkKeys,
|
||||
};
|
||||
},
|
||||
[resolveNode, showAmbiguousPaths, showAmbiguousNodes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let newProcessed = 0;
|
||||
let newAnimated = 0;
|
||||
let needsUpdate = false;
|
||||
const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null;
|
||||
let needsProjectionRebuild = false;
|
||||
|
||||
for (const packet of packets) {
|
||||
const observationKey = getRawPacketObservationKey(packet);
|
||||
@@ -700,40 +355,30 @@ export function useVisualizerData3D({
|
||||
processedRef.current = new Set(Array.from(processedRef.current).slice(-500));
|
||||
}
|
||||
|
||||
const parsed = parsePacket(packet.data);
|
||||
if (!parsed) continue;
|
||||
const ingested = ingestPacketIntoPacketNetwork(
|
||||
networkStateRef.current,
|
||||
packetNetworkContext,
|
||||
packet
|
||||
);
|
||||
if (!ingested) continue;
|
||||
needsProjectionRebuild = true;
|
||||
|
||||
const packetActivityAt = normalizePacketTimestampMs(packet.timestamp);
|
||||
const builtPath = buildPath(parsed, packet, myPrefix, packetActivityAt);
|
||||
if (builtPath.nodes.length < 2) continue;
|
||||
const projectedPath = projectCanonicalPath(networkStateRef.current, ingested.canonicalPath, {
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
});
|
||||
if (projectedPath.nodes.length < 2) continue;
|
||||
|
||||
const label = getPacketLabel(parsed.payloadType);
|
||||
for (let i = 0; i < builtPath.nodes.length; i++) {
|
||||
const n = nodesRef.current.get(builtPath.nodes[i]);
|
||||
if (n && n.id !== 'self') {
|
||||
n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < builtPath.nodes.length - 1; i++) {
|
||||
if (builtPath.nodes[i] !== builtPath.nodes[i + 1]) {
|
||||
const linkKey = buildLinkKey(builtPath.nodes[i], builtPath.nodes[i + 1]);
|
||||
addLink(
|
||||
builtPath.nodes[i],
|
||||
builtPath.nodes[i + 1],
|
||||
packetActivityAt,
|
||||
builtPath.dashedLinkKeys.has(linkKey)
|
||||
);
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
const packetKey = generatePacketKey(parsed, packet);
|
||||
const packetKey = generatePacketKey(ingested.parsed, packet);
|
||||
const now = Date.now();
|
||||
const existing = pendingRef.current.get(packetKey);
|
||||
|
||||
if (existing && now < existing.expiresAt) {
|
||||
existing.paths.push({ nodes: builtPath.nodes, snr: packet.snr ?? null, timestamp: now });
|
||||
existing.paths.push({
|
||||
nodes: projectedPath.nodes,
|
||||
snr: packet.snr ?? null,
|
||||
timestamp: now,
|
||||
});
|
||||
} else {
|
||||
const existingTimer = timersRef.current.get(packetKey);
|
||||
if (existingTimer) {
|
||||
@@ -742,8 +387,8 @@ export function useVisualizerData3D({
|
||||
const windowMs = observationWindowRef.current;
|
||||
pendingRef.current.set(packetKey, {
|
||||
key: packetKey,
|
||||
label: getPacketLabel(parsed.payloadType),
|
||||
paths: [{ nodes: builtPath.nodes, snr: packet.snr ?? null, timestamp: now }],
|
||||
label: ingested.label,
|
||||
paths: [{ nodes: projectedPath.nodes, snr: packet.snr ?? null, timestamp: now }],
|
||||
firstSeen: now,
|
||||
expiresAt: now + windowMs,
|
||||
});
|
||||
@@ -770,7 +415,9 @@ export function useVisualizerData3D({
|
||||
newAnimated++;
|
||||
}
|
||||
|
||||
if (needsUpdate) syncSimulation();
|
||||
if (needsProjectionRebuild) {
|
||||
rebuildRenderProjection();
|
||||
}
|
||||
if (newProcessed > 0) {
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
@@ -778,7 +425,14 @@ export function useVisualizerData3D({
|
||||
animated: prev.animated + newAnimated,
|
||||
}));
|
||||
}
|
||||
}, [packets, config, buildPath, addLink, syncSimulation, publishPacket]);
|
||||
}, [
|
||||
packets,
|
||||
packetNetworkContext,
|
||||
publishPacket,
|
||||
rebuildRenderProjection,
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
]);
|
||||
|
||||
const expandContract = useCallback(() => {
|
||||
const sim = simulationRef.current;
|
||||
@@ -867,21 +521,14 @@ export function useVisualizerData3D({
|
||||
timersRef.current.clear();
|
||||
pendingRef.current.clear();
|
||||
processedRef.current.clear();
|
||||
trafficPatternsRef.current.clear();
|
||||
particlesRef.current.length = 0;
|
||||
linksRef.current.clear();
|
||||
clearPacketNetworkState(networkStateRef.current, { selfName: config?.name || 'Me' });
|
||||
|
||||
const selfNode = nodesRef.current.get('self');
|
||||
linksRef.current.clear();
|
||||
nodesRef.current.clear();
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
selfNode.x = 0;
|
||||
selfNode.y = 0;
|
||||
selfNode.z = 0;
|
||||
selfNode.vx = 0;
|
||||
selfNode.vy = 0;
|
||||
selfNode.vz = 0;
|
||||
selfNode.lastActivity = Date.now();
|
||||
nodesRef.current.set('self', selfNode);
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode));
|
||||
}
|
||||
|
||||
const sim = simulationRef.current;
|
||||
@@ -892,8 +539,8 @@ export function useVisualizerData3D({
|
||||
sim.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
setStats({ processed: 0, animated: 0, nodes: 1, links: 0 });
|
||||
}, []);
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
}, [config?.name, upsertRenderNode]);
|
||||
|
||||
useEffect(() => {
|
||||
const stretchRaf = stretchRafRef;
|
||||
@@ -919,40 +566,23 @@ export function useVisualizerData3D({
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const cutoff = Date.now() - staleMs;
|
||||
let pruned = false;
|
||||
|
||||
for (const [id, node] of nodesRef.current) {
|
||||
if (id === 'self') continue;
|
||||
if (node.lastActivity < cutoff) {
|
||||
nodesRef.current.delete(id);
|
||||
pruned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned) {
|
||||
for (const [key, link] of linksRef.current) {
|
||||
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
if (!nodesRef.current.has(sourceId) || !nodesRef.current.has(targetId)) {
|
||||
linksRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
syncSimulation();
|
||||
if (prunePacketNetworkState(networkStateRef.current, cutoff)) {
|
||||
rebuildRenderProjection();
|
||||
}
|
||||
}, pruneIntervalMs);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]);
|
||||
}, [pruneStaleMinutes, pruneStaleNodes, rebuildRenderProjection]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
nodes: nodesRef.current,
|
||||
links: linksRef.current,
|
||||
particles: particlesRef.current,
|
||||
stats,
|
||||
expandContract,
|
||||
clearAndReset,
|
||||
}),
|
||||
[stats, expandContract, clearAndReset]
|
||||
);
|
||||
return {
|
||||
nodes: nodesRef.current,
|
||||
links: linksRef.current,
|
||||
canonicalNodes: networkStateRef.current.nodes,
|
||||
canonicalNeighborIds: snapshotNeighborIds(networkStateRef.current),
|
||||
renderedNodeIds: new Set(nodesRef.current.keys()),
|
||||
particles: particlesRef.current,
|
||||
stats,
|
||||
expandContract,
|
||||
clearAndReset,
|
||||
};
|
||||
}
|
||||
|
||||
774
frontend/src/networkGraph/packetNetworkGraph.ts
Normal file
774
frontend/src/networkGraph/packetNetworkGraph.ts
Normal file
@@ -0,0 +1,774 @@
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type ContactAdvertPathSummary,
|
||||
type RadioConfig,
|
||||
type RawPacket,
|
||||
} from '../types';
|
||||
import {
|
||||
analyzeRepeaterTraffic,
|
||||
buildAmbiguousRepeaterLabel,
|
||||
buildAmbiguousRepeaterNodeId,
|
||||
buildLinkKey,
|
||||
compactPathSteps,
|
||||
dedupeConsecutive,
|
||||
getNodeType,
|
||||
getPacketLabel,
|
||||
parsePacket,
|
||||
recordTrafficObservation,
|
||||
type NodeType,
|
||||
type ParsedPacket,
|
||||
type RepeaterTrafficData,
|
||||
} from '../utils/visualizerUtils';
|
||||
import { normalizePacketTimestampMs } from '../components/visualizer/shared';
|
||||
|
||||
interface ContactIndex {
|
||||
byPrefix12: Map<string, Contact>;
|
||||
byName: Map<string, Contact>;
|
||||
byPrefix: Map<string, Contact[]>;
|
||||
}
|
||||
|
||||
interface AdvertPathIndex {
|
||||
byRepeater: Map<string, ContactAdvertPathSummary['paths']>;
|
||||
}
|
||||
|
||||
export interface PacketNetworkContext {
|
||||
advertPathIndex: AdvertPathIndex;
|
||||
contactIndex: ContactIndex;
|
||||
myPrefix: string | null;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
}
|
||||
|
||||
export interface PacketNetworkVisibilityOptions {
|
||||
showAmbiguousNodes: boolean;
|
||||
showAmbiguousPaths: boolean;
|
||||
}
|
||||
|
||||
export interface PacketNetworkNode {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: NodeType;
|
||||
isAmbiguous: boolean;
|
||||
lastActivity: number;
|
||||
lastActivityReason?: string;
|
||||
lastSeen?: number | null;
|
||||
probableIdentity?: string | null;
|
||||
ambiguousNames?: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkLink {
|
||||
lastActivity: number;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export interface ProjectedPacketNetworkLink extends PacketNetworkLink {
|
||||
hasDirectObservation: boolean;
|
||||
hasHiddenIntermediate: boolean;
|
||||
hiddenHopLabels: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkObservation {
|
||||
activityAtMs: number;
|
||||
nodes: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkState {
|
||||
links: Map<string, PacketNetworkLink>;
|
||||
neighborIds: Map<string, Set<string>>;
|
||||
nodes: Map<string, PacketNetworkNode>;
|
||||
observations: PacketNetworkObservation[];
|
||||
trafficPatterns: Map<string, RepeaterTrafficData>;
|
||||
}
|
||||
|
||||
export interface PacketNetworkIngestResult {
|
||||
activityAtMs: number;
|
||||
canonicalPath: string[];
|
||||
label: ReturnType<typeof getPacketLabel>;
|
||||
parsed: ParsedPacket;
|
||||
}
|
||||
|
||||
export interface ProjectedPacketNetworkPath {
|
||||
dashedLinkDetails: Map<string, string[]>;
|
||||
nodes: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkProjection {
|
||||
links: Map<string, ProjectedPacketNetworkLink>;
|
||||
nodes: Map<string, PacketNetworkNode>;
|
||||
renderedNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export function buildPacketNetworkContext({
|
||||
config,
|
||||
contacts,
|
||||
repeaterAdvertPaths,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
}: {
|
||||
config: RadioConfig | null;
|
||||
contacts: Contact[];
|
||||
repeaterAdvertPaths: ContactAdvertPathSummary[];
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
}): PacketNetworkContext {
|
||||
const byPrefix12 = new Map<string, Contact>();
|
||||
const byName = new Map<string, Contact>();
|
||||
const byPrefix = new Map<string, Contact[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const prefix12 = contact.public_key.slice(0, 12).toLowerCase();
|
||||
byPrefix12.set(prefix12, contact);
|
||||
|
||||
if (contact.name && !byName.has(contact.name)) {
|
||||
byName.set(contact.name, contact);
|
||||
}
|
||||
|
||||
for (let len = 1; len <= 12; len++) {
|
||||
const prefix = prefix12.slice(0, len);
|
||||
const matches = byPrefix.get(prefix);
|
||||
if (matches) {
|
||||
matches.push(contact);
|
||||
} else {
|
||||
byPrefix.set(prefix, [contact]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byRepeater = new Map<string, ContactAdvertPathSummary['paths']>();
|
||||
for (const summary of repeaterAdvertPaths) {
|
||||
const key = summary.public_key.slice(0, 12).toLowerCase();
|
||||
byRepeater.set(key, summary.paths);
|
||||
}
|
||||
|
||||
return {
|
||||
contactIndex: { byPrefix12, byName, byPrefix },
|
||||
advertPathIndex: { byRepeater },
|
||||
myPrefix: config?.public_key?.slice(0, 12).toLowerCase() || null,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPacketNetworkState(selfName: string = 'Me'): PacketNetworkState {
|
||||
const now = Date.now();
|
||||
return {
|
||||
nodes: new Map([
|
||||
[
|
||||
'self',
|
||||
{
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: now,
|
||||
},
|
||||
],
|
||||
]),
|
||||
links: new Map(),
|
||||
neighborIds: new Map(),
|
||||
observations: [],
|
||||
trafficPatterns: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureSelfNode(state: PacketNetworkState, selfName: string = 'Me'): void {
|
||||
const existing = state.nodes.get('self');
|
||||
if (existing) {
|
||||
existing.name = selfName;
|
||||
return;
|
||||
}
|
||||
state.nodes.set('self', {
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearPacketNetworkState(
|
||||
state: PacketNetworkState,
|
||||
{ selfName = 'Me' }: { selfName?: string } = {}
|
||||
): void {
|
||||
state.links.clear();
|
||||
state.neighborIds.clear();
|
||||
state.observations = [];
|
||||
state.trafficPatterns.clear();
|
||||
|
||||
const selfNode = state.nodes.get('self');
|
||||
state.nodes.clear();
|
||||
state.nodes.set('self', {
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
lastActivityReason: undefined,
|
||||
lastSeen: null,
|
||||
probableIdentity: undefined,
|
||||
ambiguousNames: undefined,
|
||||
});
|
||||
|
||||
if (selfNode?.name && selfNode.name !== selfName) {
|
||||
state.nodes.get('self')!.name = selfName;
|
||||
}
|
||||
}
|
||||
|
||||
function addOrUpdateNode(
|
||||
state: PacketNetworkState,
|
||||
{
|
||||
activityAtMs,
|
||||
ambiguousNames,
|
||||
id,
|
||||
isAmbiguous,
|
||||
lastSeen,
|
||||
name,
|
||||
probableIdentity,
|
||||
type,
|
||||
}: {
|
||||
activityAtMs: number;
|
||||
ambiguousNames?: string[];
|
||||
id: string;
|
||||
isAmbiguous: boolean;
|
||||
lastSeen?: number | null;
|
||||
name: string | null;
|
||||
probableIdentity?: string | null;
|
||||
type: NodeType;
|
||||
}
|
||||
): void {
|
||||
const existing = state.nodes.get(id);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAtMs);
|
||||
if (name) existing.name = name;
|
||||
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
|
||||
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
||||
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
||||
return;
|
||||
}
|
||||
|
||||
state.nodes.set(id, {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
isAmbiguous,
|
||||
lastActivity: activityAtMs,
|
||||
probableIdentity,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
});
|
||||
}
|
||||
|
||||
function addCanonicalLink(
|
||||
state: PacketNetworkState,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
activityAtMs: number
|
||||
): void {
|
||||
const key = buildLinkKey(sourceId, targetId);
|
||||
const existing = state.links.get(key);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAtMs);
|
||||
} else {
|
||||
state.links.set(key, { sourceId, targetId, lastActivity: activityAtMs });
|
||||
}
|
||||
}
|
||||
|
||||
function upsertNeighbor(state: PacketNetworkState, sourceId: string, targetId: string): void {
|
||||
const ensureSet = (id: string) => {
|
||||
const existing = state.neighborIds.get(id);
|
||||
if (existing) return existing;
|
||||
const created = new Set<string>();
|
||||
state.neighborIds.set(id, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
ensureSet(sourceId).add(targetId);
|
||||
ensureSet(targetId).add(sourceId);
|
||||
}
|
||||
|
||||
function pickLikelyRepeaterByAdvertPath(
|
||||
context: PacketNetworkContext,
|
||||
candidates: Contact[],
|
||||
nextPrefix: string | null
|
||||
): Contact | null {
|
||||
const nextHop = nextPrefix?.toLowerCase() ?? null;
|
||||
const scored = candidates
|
||||
.map((candidate) => {
|
||||
const prefix12 = candidate.public_key.slice(0, 12).toLowerCase();
|
||||
const paths = context.advertPathIndex.byRepeater.get(prefix12) ?? [];
|
||||
let matchScore = 0;
|
||||
let totalScore = 0;
|
||||
|
||||
for (const path of paths) {
|
||||
totalScore += path.heard_count;
|
||||
const pathNextHop = path.next_hop?.toLowerCase() ?? null;
|
||||
if (pathNextHop === nextHop) {
|
||||
matchScore += path.heard_count;
|
||||
}
|
||||
}
|
||||
|
||||
return { candidate, matchScore, totalScore };
|
||||
})
|
||||
.filter((entry) => entry.totalScore > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.matchScore - a.matchScore ||
|
||||
b.totalScore - a.totalScore ||
|
||||
a.candidate.public_key.localeCompare(b.candidate.public_key)
|
||||
);
|
||||
|
||||
if (scored.length === 0) return null;
|
||||
|
||||
const top = scored[0];
|
||||
const second = scored[1] ?? null;
|
||||
|
||||
if (top.matchScore < 2) return null;
|
||||
if (second && top.matchScore < second.matchScore * 2) return null;
|
||||
|
||||
return top.candidate;
|
||||
}
|
||||
|
||||
function resolveNode(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
||||
isRepeater: boolean,
|
||||
showAmbiguous: boolean,
|
||||
activityAtMs: number,
|
||||
trafficContext?: { packetSource: string | null; nextPrefix: string | null }
|
||||
): string | null {
|
||||
if (source.type === 'pubkey') {
|
||||
if (source.value.length < 12) return null;
|
||||
const nodeId = source.value.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
const contact = context.contactIndex.byPrefix12.get(nodeId);
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact?.name || null,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact?.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (source.type === 'name') {
|
||||
const contact = context.contactIndex.byName.get(source.value) ?? null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact.name,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const nodeId = `name:${source.value}`;
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: source.value,
|
||||
type: 'client',
|
||||
isAmbiguous: false,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const lookupValue = source.value.toLowerCase();
|
||||
const matches = context.contactIndex.byPrefix.get(lookupValue) ?? [];
|
||||
const contact = matches.length === 1 ? matches[0] : null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact.name,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (!showAmbiguous) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filtered = isRepeater
|
||||
? matches.filter((candidate) => candidate.type === CONTACT_TYPE_REPEATER)
|
||||
: matches.filter((candidate) => candidate.type !== CONTACT_TYPE_REPEATER);
|
||||
|
||||
if (filtered.length === 1) {
|
||||
const only = filtered[0];
|
||||
const nodeId = only.public_key.slice(0, 12).toLowerCase();
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: only.name,
|
||||
type: getNodeType(only),
|
||||
isAmbiguous: false,
|
||||
lastSeen: only.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (filtered.length === 0 && !isRepeater) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const names = filtered.map((candidate) => candidate.name || candidate.public_key.slice(0, 8));
|
||||
const lastSeen = filtered.reduce(
|
||||
(max, candidate) =>
|
||||
candidate.last_seen && (!max || candidate.last_seen > max) ? candidate.last_seen : max,
|
||||
null as number | null
|
||||
);
|
||||
|
||||
let nodeId = buildAmbiguousRepeaterNodeId(lookupValue);
|
||||
let displayName = buildAmbiguousRepeaterLabel(lookupValue);
|
||||
let probableIdentity: string | null = null;
|
||||
let ambiguousNames = names.length > 0 ? names : undefined;
|
||||
|
||||
if (context.useAdvertPathHints && isRepeater && trafficContext) {
|
||||
const likely = pickLikelyRepeaterByAdvertPath(context, filtered, trafficContext.nextPrefix);
|
||||
if (likely) {
|
||||
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
|
||||
probableIdentity = likelyName;
|
||||
displayName = likelyName;
|
||||
ambiguousNames = filtered
|
||||
.filter((candidate) => candidate.public_key !== likely.public_key)
|
||||
.map((candidate) => candidate.name || candidate.public_key.slice(0, 8));
|
||||
}
|
||||
}
|
||||
|
||||
if (context.splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
|
||||
if (trafficContext.packetSource) {
|
||||
recordTrafficObservation(
|
||||
state.trafficPatterns,
|
||||
lookupValue,
|
||||
trafficContext.packetSource,
|
||||
normalizedNext
|
||||
);
|
||||
}
|
||||
|
||||
const trafficData = state.trafficPatterns.get(lookupValue);
|
||||
if (trafficData) {
|
||||
const analysis = analyzeRepeaterTraffic(trafficData);
|
||||
if (analysis.shouldSplit && normalizedNext) {
|
||||
nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext);
|
||||
if (!probableIdentity) {
|
||||
displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: displayName,
|
||||
type: isRepeater ? 'repeater' : 'client',
|
||||
isAmbiguous: true,
|
||||
probableIdentity,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
export function buildCanonicalPathForPacket(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
parsed: ParsedPacket,
|
||||
packet: RawPacket,
|
||||
activityAtMs: number
|
||||
): string[] {
|
||||
const path: string[] = [];
|
||||
let packetSource: string | null = null;
|
||||
const isDm = parsed.payloadType === PayloadType.TextMessage;
|
||||
const isOutgoingDm =
|
||||
isDm && !!context.myPrefix && parsed.srcHash?.toLowerCase() === context.myPrefix;
|
||||
|
||||
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'pubkey', value: parsed.advertPubkey },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'pubkey', value: parsed.anonRequestPubkey },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) {
|
||||
if (context.myPrefix && parsed.srcHash.toLowerCase() === context.myPrefix) {
|
||||
path.push('self');
|
||||
packetSource = 'self';
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'prefix', value: parsed.srcHash },
|
||||
false,
|
||||
true,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.GroupText) {
|
||||
const senderName = parsed.groupTextSender || packet.decrypted_info?.sender;
|
||||
if (senderName) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'name', value: senderName },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < parsed.pathBytes.length; i++) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'prefix', value: parsed.pathBytes[i] },
|
||||
true,
|
||||
true,
|
||||
activityAtMs,
|
||||
{ packetSource, nextPrefix: parsed.pathBytes[i + 1] || null }
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) {
|
||||
if (context.myPrefix && parsed.dstHash.toLowerCase() === context.myPrefix) {
|
||||
path.push('self');
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'prefix', value: parsed.dstHash },
|
||||
false,
|
||||
true,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
} else if (!isOutgoingDm) {
|
||||
path.push('self');
|
||||
}
|
||||
}
|
||||
} else if (path.length > 0) {
|
||||
path.push('self');
|
||||
}
|
||||
|
||||
return dedupeConsecutive(path);
|
||||
}
|
||||
|
||||
export function ingestPacketIntoPacketNetwork(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
packet: RawPacket
|
||||
): PacketNetworkIngestResult | null {
|
||||
const parsed = parsePacket(packet.data);
|
||||
if (!parsed) return null;
|
||||
|
||||
const activityAtMs = normalizePacketTimestampMs(packet.timestamp);
|
||||
const canonicalPath = buildCanonicalPathForPacket(state, context, parsed, packet, activityAtMs);
|
||||
if (canonicalPath.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = getPacketLabel(parsed.payloadType);
|
||||
for (let i = 0; i < canonicalPath.length; i++) {
|
||||
const node = state.nodes.get(canonicalPath[i]);
|
||||
if (node && node.id !== 'self') {
|
||||
node.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`;
|
||||
}
|
||||
}
|
||||
|
||||
state.observations.push({ nodes: canonicalPath, activityAtMs });
|
||||
|
||||
for (let i = 0; i < canonicalPath.length - 1; i++) {
|
||||
if (canonicalPath[i] !== canonicalPath[i + 1]) {
|
||||
addCanonicalLink(state, canonicalPath[i], canonicalPath[i + 1], activityAtMs);
|
||||
upsertNeighbor(state, canonicalPath[i], canonicalPath[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { parsed, label, canonicalPath, activityAtMs };
|
||||
}
|
||||
|
||||
export function isPacketNetworkNodeVisible(
|
||||
node: PacketNetworkNode | undefined,
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): boolean {
|
||||
if (!node) return false;
|
||||
if (node.id === 'self') return true;
|
||||
if (!node.isAmbiguous) return true;
|
||||
return node.type === 'repeater' ? visibility.showAmbiguousPaths : visibility.showAmbiguousNodes;
|
||||
}
|
||||
|
||||
export function projectCanonicalPath(
|
||||
state: PacketNetworkState,
|
||||
canonicalPath: string[],
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): ProjectedPacketNetworkPath {
|
||||
const projected = compactPathSteps(
|
||||
canonicalPath.map((nodeId) => ({
|
||||
nodeId: isPacketNetworkNodeVisible(state.nodes.get(nodeId), visibility) ? nodeId : null,
|
||||
markHiddenLinkWhenOmitted: true,
|
||||
hiddenLabel: null,
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: dedupeConsecutive(projected.nodes),
|
||||
dashedLinkDetails: projected.dashedLinkDetails,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectPacketNetwork(
|
||||
state: PacketNetworkState,
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): PacketNetworkProjection {
|
||||
const nodes = new Map<string, PacketNetworkNode>();
|
||||
const selfNode = state.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodes.set('self', selfNode);
|
||||
}
|
||||
|
||||
const links = new Map<string, ProjectedPacketNetworkLink>();
|
||||
|
||||
for (const observation of state.observations) {
|
||||
const projected = projectCanonicalPath(state, observation.nodes, visibility);
|
||||
if (projected.nodes.length < 2) continue;
|
||||
|
||||
for (const nodeId of projected.nodes) {
|
||||
const node = state.nodes.get(nodeId);
|
||||
if (node) {
|
||||
nodes.set(nodeId, node);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < projected.nodes.length - 1; i++) {
|
||||
const sourceId = projected.nodes[i];
|
||||
const targetId = projected.nodes[i + 1];
|
||||
if (sourceId === targetId) continue;
|
||||
|
||||
const key = buildLinkKey(sourceId, targetId);
|
||||
const hiddenIntermediate = projected.dashedLinkDetails.has(key);
|
||||
const existing = links.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, observation.activityAtMs);
|
||||
if (hiddenIntermediate) {
|
||||
existing.hasHiddenIntermediate = true;
|
||||
for (const label of projected.dashedLinkDetails.get(key) ?? []) {
|
||||
if (!existing.hiddenHopLabels.includes(label)) {
|
||||
existing.hiddenHopLabels.push(label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
existing.hasDirectObservation = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
links.set(key, {
|
||||
sourceId,
|
||||
targetId,
|
||||
lastActivity: observation.activityAtMs,
|
||||
hasDirectObservation: !hiddenIntermediate,
|
||||
hasHiddenIntermediate: hiddenIntermediate,
|
||||
hiddenHopLabels: [...(projected.dashedLinkDetails.get(key) ?? [])],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
links,
|
||||
renderedNodeIds: new Set(nodes.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
export function prunePacketNetworkState(state: PacketNetworkState, cutoff: number): boolean {
|
||||
let pruned = false;
|
||||
|
||||
for (const [id, node] of state.nodes) {
|
||||
if (id === 'self') continue;
|
||||
if (node.lastActivity < cutoff) {
|
||||
state.nodes.delete(id);
|
||||
pruned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pruned) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [key, link] of state.links) {
|
||||
if (!state.nodes.has(link.sourceId) || !state.nodes.has(link.targetId)) {
|
||||
state.links.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
state.observations = state.observations.filter((observation) =>
|
||||
observation.nodes.every((nodeId) => state.nodes.has(nodeId))
|
||||
);
|
||||
|
||||
state.neighborIds.clear();
|
||||
for (const link of state.links.values()) {
|
||||
upsertNeighbor(state, link.sourceId, link.targetId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function snapshotNeighborIds(state: PacketNetworkState): Map<string, string[]> {
|
||||
return new Map(
|
||||
Array.from(state.neighborIds.entries()).map(([nodeId, neighborIds]) => [
|
||||
nodeId,
|
||||
Array.from(neighborIds).sort(),
|
||||
])
|
||||
);
|
||||
}
|
||||
209
frontend/src/test/packetNetworkGraph.test.ts
Normal file
209
frontend/src/test/packetNetworkGraph.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import {
|
||||
buildPacketNetworkContext,
|
||||
createPacketNetworkState,
|
||||
ingestPacketIntoPacketNetwork,
|
||||
projectCanonicalPath,
|
||||
projectPacketNetwork,
|
||||
snapshotNeighborIds,
|
||||
} from '../networkGraph/packetNetworkGraph';
|
||||
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
const { packetFixtures } = vi.hoisted(() => ({
|
||||
packetFixtures: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/visualizerUtils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/visualizerUtils')>(
|
||||
'../utils/visualizerUtils'
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
parsePacket: vi.fn(
|
||||
(hexData: string) => packetFixtures.get(hexData) ?? actual.parsePacket(hexData)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
function createConfig(publicKey: string): RadioConfig {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name: 'Me',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
tx_power: 0,
|
||||
max_tx_power: 0,
|
||||
radio: { freq: 0, bw: 0, sf: 0, cr: 0 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: true,
|
||||
advert_location_source: 'off',
|
||||
};
|
||||
}
|
||||
|
||||
function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createPacket(data: string): RawPacket {
|
||||
return {
|
||||
id: 1,
|
||||
observation_id: 1,
|
||||
timestamp: 1_700_000_000,
|
||||
data,
|
||||
payload_type: 'TEXT',
|
||||
snr: null,
|
||||
rssi: null,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('packetNetworkGraph', () => {
|
||||
it('preserves canonical adjacency while projection hides ambiguous repeaters', () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
packetFixtures.set('dm-semantic-hide', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-semantic-hide',
|
||||
pathBytes: ['32'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [createContact(aliceKey, 'Alice')],
|
||||
config: createConfig(selfKey),
|
||||
repeaterAdvertPaths: [],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: false,
|
||||
});
|
||||
|
||||
ingestPacketIntoPacketNetwork(state, context, createPacket('dm-semantic-hide'));
|
||||
|
||||
const hiddenProjection = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: false,
|
||||
});
|
||||
const shownProjection = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: true,
|
||||
});
|
||||
|
||||
expect(snapshotNeighborIds(state)).toEqual(
|
||||
new Map([
|
||||
['?32', ['aaaaaaaaaaaa', 'self']],
|
||||
['aaaaaaaaaaaa', ['?32']],
|
||||
['self', ['?32']],
|
||||
])
|
||||
);
|
||||
expect(hiddenProjection.links.has('aaaaaaaaaaaa->self')).toBe(true);
|
||||
expect(shownProjection.links.has('?32->aaaaaaaaaaaa')).toBe(true);
|
||||
expect(shownProjection.links.has('?32->self')).toBe(true);
|
||||
});
|
||||
|
||||
it('projects hidden ambiguous runs as dashed bridges but keeps later known repeaters visible', () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-hidden-chain', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-hidden-chain',
|
||||
pathBytes: ['32', '565656565656'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [
|
||||
createContact(aliceKey, 'Alice'),
|
||||
createContact(repeaterKey, 'Relay B', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
repeaterAdvertPaths: [],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: false,
|
||||
});
|
||||
|
||||
const ingested = ingestPacketIntoPacketNetwork(state, context, createPacket('dm-hidden-chain'));
|
||||
|
||||
expect(ingested?.canonicalPath).toEqual(['aaaaaaaaaaaa', '?32', '565656565656', 'self']);
|
||||
|
||||
const projectedPath = projectCanonicalPath(state, ingested!.canonicalPath, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: false,
|
||||
});
|
||||
const projection = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: false,
|
||||
});
|
||||
|
||||
expect(projectedPath.nodes).toEqual(['aaaaaaaaaaaa', '565656565656', 'self']);
|
||||
expect(Array.from(projectedPath.dashedLinkDetails.keys())).toEqual([
|
||||
'565656565656->aaaaaaaaaaaa',
|
||||
]);
|
||||
expect(projection.links.get('565656565656->aaaaaaaaaaaa')?.hasHiddenIntermediate).toBe(true);
|
||||
expect(projection.links.get('565656565656->self')?.hasDirectObservation).toBe(true);
|
||||
});
|
||||
|
||||
it('replays real advert packets through the semantic layer', () => {
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [],
|
||||
config: createConfig('ffffffffffff0000000000000000000000000000000000000000000000000000'),
|
||||
repeaterAdvertPaths: [],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: false,
|
||||
});
|
||||
|
||||
const packet = createPacket(
|
||||
'1106538B1CD273868576DC7F679B493F9AB5AC316173E1A56D3388BC3BA75F583F63AB0D1BA2A8ABD0BC6669DBF719E67E4C8517BA4E0D6F8C96A323E9D13A77F2630DED965A5C17C3EC6ED1601EEFE857749DA24E9F39CBEACD722C3708F433DB5FA9BAF0BAF9BC5B1241069290FEEB029A839EF843616E204F204D657368203220F09FA5AB'
|
||||
);
|
||||
packet.payload_type = 'ADVERT';
|
||||
|
||||
const ingested = ingestPacketIntoPacketNetwork(state, context, packet);
|
||||
|
||||
expect(ingested?.canonicalPath).toEqual([
|
||||
'8576dc7f679b',
|
||||
'?53',
|
||||
'?8b',
|
||||
'?1c',
|
||||
'?d2',
|
||||
'?73',
|
||||
'?86',
|
||||
'self',
|
||||
]);
|
||||
expect(snapshotNeighborIds(state).get('?73')).toEqual(['?86', '?d2']);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,51 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('useVisualizerData3D', () => {
|
||||
it('keeps canonical adjacency stable when ambiguous repeaters are shown or hidden', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
packetFixtures.set('dm-canonical-stable', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-canonical-stable',
|
||||
pathBytes: ['32'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const packets = [createPacket('dm-canonical-stable')];
|
||||
const contacts = [createContact(aliceKey, 'Alice')];
|
||||
|
||||
const hidden = renderVisualizerData({
|
||||
packets,
|
||||
contacts,
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: false,
|
||||
});
|
||||
const shown = renderVisualizerData({
|
||||
packets,
|
||||
contacts,
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: true,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hidden.result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32'])
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(shown.result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32'])
|
||||
);
|
||||
|
||||
expect(hidden.result.current.canonicalNeighborIds).toEqual(
|
||||
shown.result.current.canonicalNeighborIds
|
||||
);
|
||||
expect(hidden.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', 'self'))).toBe(true);
|
||||
expect(hidden.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(false);
|
||||
expect(shown.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks compressed hidden-repeater routes as dashed links instead of direct solid links', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
@@ -140,6 +185,9 @@ describe('useVisualizerData3D', () => {
|
||||
expect(link).toBeDefined();
|
||||
expect(link?.hasHiddenIntermediate).toBe(true);
|
||||
expect(link?.hasDirectObservation).toBe(false);
|
||||
expect(result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32']);
|
||||
expect(result.current.canonicalNeighborIds.get('self')).toEqual(['?32']);
|
||||
expect(result.current.renderedNodeIds.has('?32')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not append self after a resolved outgoing DM destination', async () => {
|
||||
@@ -175,6 +223,44 @@ describe('useVisualizerData3D', () => {
|
||||
expect(result.current.links.has(buildLinkKey('self', 'bbbbbbbbbbbb'))).toBe(false);
|
||||
});
|
||||
|
||||
it('picks back up with known repeaters after hiding ambiguous repeater segments', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-hidden-then-known', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-hidden-then-known',
|
||||
pathBytes: ['32', '565656565656'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const { result } = renderVisualizerData({
|
||||
packets: [createPacket('dm-hidden-then-known')],
|
||||
contacts: [
|
||||
createContact(aliceKey, 'Alice'),
|
||||
createContact(repeaterKey, 'Relay B', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.links.size).toBe(2));
|
||||
|
||||
expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '565656565656'))).toBe(true);
|
||||
expect(result.current.links.has(buildLinkKey('565656565656', 'self'))).toBe(true);
|
||||
expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', 'self'))).toBe(false);
|
||||
expect(result.current.renderedNodeIds.has('565656565656')).toBe(true);
|
||||
expect(result.current.renderedNodeIds.has('?32')).toBe(false);
|
||||
expect(result.current.canonicalNeighborIds.get('?32')).toEqual(
|
||||
expect.arrayContaining(['aaaaaaaaaaaa', '565656565656'])
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create a fake self edge for an unresolved outgoing direct DM', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
|
||||
|
||||
27
frontend/src/test/visualizerShared.test.ts
Normal file
27
frontend/src/test/visualizerShared.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSceneNodeLabel } from '../components/visualizer/shared';
|
||||
|
||||
describe('visualizer shared label helpers', () => {
|
||||
it('adds an ambiguity suffix to in-graph labels for ambiguous nodes', () => {
|
||||
expect(
|
||||
getSceneNodeLabel({
|
||||
id: '?32',
|
||||
name: 'Likely Relay',
|
||||
type: 'repeater',
|
||||
isAmbiguous: true,
|
||||
})
|
||||
).toBe('Likely Relay (?)');
|
||||
});
|
||||
|
||||
it('does not add an ambiguity suffix to unambiguous nodes', () => {
|
||||
expect(
|
||||
getSceneNodeLabel({
|
||||
id: 'aaaaaaaaaaaa',
|
||||
name: 'Alice',
|
||||
type: 'client',
|
||||
isAmbiguous: false,
|
||||
})
|
||||
).toBe('Alice');
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,17 @@ import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VisualizerTooltip } from '../components/visualizer/VisualizerTooltip';
|
||||
import type { GraphNode } from '../components/visualizer/shared';
|
||||
import type { PacketNetworkNode } from '../networkGraph/packetNetworkGraph';
|
||||
|
||||
function createNode(overrides: Partial<GraphNode> & Pick<GraphNode, 'id' | 'type'>): GraphNode {
|
||||
function createNode(
|
||||
overrides: Partial<PacketNetworkNode> & Pick<PacketNetworkNode, 'id' | 'type'>
|
||||
): PacketNetworkNode {
|
||||
return {
|
||||
id: overrides.id,
|
||||
type: overrides.type,
|
||||
name: overrides.name ?? null,
|
||||
isAmbiguous: overrides.isAmbiguous ?? false,
|
||||
lastActivity: overrides.lastActivity ?? Date.now(),
|
||||
x: overrides.x ?? 0,
|
||||
y: overrides.y ?? 0,
|
||||
z: overrides.z ?? 0,
|
||||
probableIdentity: overrides.probableIdentity,
|
||||
ambiguousNames: overrides.ambiguousNames,
|
||||
lastActivityReason: overrides.lastActivityReason,
|
||||
@@ -23,7 +22,12 @@ function createNode(overrides: Partial<GraphNode> & Pick<GraphNode, 'id' | 'type
|
||||
describe('VisualizerTooltip', () => {
|
||||
it('renders nothing without an active node', () => {
|
||||
const { container } = render(
|
||||
<VisualizerTooltip activeNodeId={null} nodes={new Map()} neighborIds={[]} />
|
||||
<VisualizerTooltip
|
||||
activeNodeId={null}
|
||||
canonicalNodes={new Map()}
|
||||
canonicalNeighborIds={new Map()}
|
||||
renderedNodeIds={new Set()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
@@ -49,17 +53,25 @@ describe('VisualizerTooltip', () => {
|
||||
name: 'Neighbor Node',
|
||||
ambiguousNames: ['Alt Neighbor'],
|
||||
});
|
||||
const hiddenRepeater = createNode({
|
||||
id: '?44',
|
||||
type: 'repeater',
|
||||
name: '44',
|
||||
isAmbiguous: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<VisualizerTooltip
|
||||
activeNodeId={node.id}
|
||||
nodes={
|
||||
canonicalNodes={
|
||||
new Map([
|
||||
[node.id, node],
|
||||
[neighbor.id, neighbor],
|
||||
[hiddenRepeater.id, hiddenRepeater],
|
||||
])
|
||||
}
|
||||
neighborIds={[neighbor.id]}
|
||||
canonicalNeighborIds={new Map([[node.id, [neighbor.id, hiddenRepeater.id]]])}
|
||||
renderedNodeIds={new Set([node.id, neighbor.id])}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -72,6 +84,8 @@ describe('VisualizerTooltip', () => {
|
||||
expect(screen.getByText('Reason: Relayed GT')).toBeInTheDocument();
|
||||
expect(screen.getByText('Neighbor Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('(Alt Neighbor)')).toBeInTheDocument();
|
||||
expect(screen.getByText('44')).toBeInTheDocument();
|
||||
expect(screen.getByText('(hidden)')).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface PendingPacket {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface ParsedPacket {
|
||||
export interface ParsedPacket {
|
||||
payloadType: number;
|
||||
messageHash: string | null;
|
||||
pathBytes: string[];
|
||||
@@ -113,6 +113,7 @@ export const PACKET_LEGEND_ITEMS = [
|
||||
export interface PathStep {
|
||||
nodeId: string | null;
|
||||
markHiddenLinkWhenOmitted?: boolean;
|
||||
hiddenLabel?: string | null;
|
||||
}
|
||||
|
||||
export function normalizeHopToken(hop: string | null | undefined): string | null {
|
||||
@@ -253,31 +254,43 @@ export function dedupeConsecutive<T>(arr: T[]): T[] {
|
||||
|
||||
export function compactPathSteps(steps: PathStep[]): {
|
||||
nodes: string[];
|
||||
dashedLinkKeys: Set<string>;
|
||||
dashedLinkDetails: Map<string, string[]>;
|
||||
} {
|
||||
const nodes: string[] = [];
|
||||
const dashedLinkKeys = new Set<string>();
|
||||
const dashedLinkDetails = new Map<string, string[]>();
|
||||
let pendingHiddenLink = false;
|
||||
let pendingHiddenLabels: string[] = [];
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.nodeId) {
|
||||
const previousNodeId = nodes.length > 0 ? nodes[nodes.length - 1] : null;
|
||||
if (previousNodeId && pendingHiddenLink && previousNodeId !== step.nodeId) {
|
||||
dashedLinkKeys.add(buildLinkKey(previousNodeId, step.nodeId));
|
||||
const key = buildLinkKey(previousNodeId, step.nodeId);
|
||||
const existing = dashedLinkDetails.get(key) ?? [];
|
||||
for (const label of pendingHiddenLabels) {
|
||||
if (!existing.includes(label)) {
|
||||
existing.push(label);
|
||||
}
|
||||
}
|
||||
dashedLinkDetails.set(key, existing);
|
||||
}
|
||||
if (previousNodeId !== step.nodeId) {
|
||||
nodes.push(step.nodeId);
|
||||
}
|
||||
pendingHiddenLink = false;
|
||||
pendingHiddenLabels = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.markHiddenLinkWhenOmitted && nodes.length > 0) {
|
||||
pendingHiddenLink = true;
|
||||
if (step.hiddenLabel && !pendingHiddenLabels.includes(step.hiddenLabel)) {
|
||||
pendingHiddenLabels.push(step.hiddenLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, dashedLinkKeys };
|
||||
return { nodes, dashedLinkDetails };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user