Use dashed lines for collapsed ambiguous repeater paths. Closes #44.

This commit is contained in:
Jack Kingsman
2026-03-12 12:11:17 -07:00
parent 08e00373aa
commit 489950a2f7
5 changed files with 414 additions and 45 deletions
@@ -21,6 +21,8 @@ export interface GraphLink extends SimulationLinkDatum<GraphNode> {
source: string | GraphNode;
target: string | GraphNode;
lastActivity: number;
hasDirectObservation: boolean;
hasHiddenIntermediate: boolean;
}
export interface NodeMeshData {
@@ -32,10 +32,12 @@ export function useVisualizer3DScene({
const nodeMeshesRef = useRef<Map<string, NodeMeshData>>(new Map());
const raycastTargetsRef = useRef<THREE.Mesh[]>([]);
const linkLineRef = useRef<THREE.LineSegments | null>(null);
const dashedLinkLineRef = useRef<THREE.LineSegments | null>(null);
const highlightLineRef = useRef<THREE.LineSegments | null>(null);
const particlePointsRef = useRef<THREE.Points | null>(null);
const particleTextureRef = useRef<THREE.Texture | null>(null);
const linkPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
const dashedLinkPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
const highlightPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
const particlePositionBufferRef = useRef<Float32Array>(new Float32Array(0));
const particleColorBufferRef = useRef<Float32Array>(new Float32Array(0));
@@ -126,6 +128,19 @@ export function useVisualizer3DScene({
scene.add(linkSegments);
linkLineRef.current = linkSegments;
const dashedLinkGeometry = new THREE.BufferGeometry();
const dashedLinkMaterial = new THREE.LineDashedMaterial({
color: 0x94a3b8,
transparent: true,
opacity: 0.85,
dashSize: 16,
gapSize: 10,
});
const dashedLinkSegments = new THREE.LineSegments(dashedLinkGeometry, dashedLinkMaterial);
dashedLinkSegments.visible = false;
scene.add(dashedLinkSegments);
dashedLinkLineRef.current = dashedLinkSegments;
const highlightGeometry = new THREE.BufferGeometry();
const highlightMaterial = new THREE.LineBasicMaterial({
color: 0xffd700,
@@ -198,6 +213,12 @@ export function useVisualizer3DScene({
(linkLineRef.current.material as THREE.Material).dispose();
linkLineRef.current = null;
}
if (dashedLinkLineRef.current) {
scene.remove(dashedLinkLineRef.current);
dashedLinkLineRef.current.geometry.dispose();
(dashedLinkLineRef.current.material as THREE.Material).dispose();
dashedLinkLineRef.current = null;
}
if (highlightLineRef.current) {
scene.remove(highlightLineRef.current);
highlightLineRef.current.geometry.dispose();
@@ -213,6 +234,7 @@ export function useVisualizer3DScene({
particleTexture.dispose();
particleTextureRef.current = null;
linkPositionBufferRef.current = new Float32Array(0);
dashedLinkPositionBufferRef.current = new Float32Array(0);
highlightPositionBufferRef.current = new Float32Array(0);
particlePositionBufferRef.current = new Float32Array(0);
particleColorBufferRef.current = new Float32Array(0);
@@ -369,11 +391,16 @@ export function useVisualizer3DScene({
}
const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current;
const visibleLinks = [];
const solidLinks = [];
const dashedLinks = [];
for (const link of links.values()) {
const { sourceId, targetId } = getLinkId(link);
if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) {
visibleLinks.push(link);
if (link.hasDirectObservation || !link.hasHiddenIntermediate) {
solidLinks.push(link);
} else {
dashedLinks.push(link);
}
}
}
@@ -382,7 +409,8 @@ export function useVisualizer3DScene({
const linkLine = linkLineRef.current;
if (linkLine) {
const geometry = linkLine.geometry as THREE.BufferGeometry;
const requiredLength = visibleLinks.length * 6;
const requiredLength = solidLinks.length * 6;
const highlightRequiredLength = (solidLinks.length + dashedLinks.length) * 6;
if (linkPositionBufferRef.current.length < requiredLength) {
linkPositionBufferRef.current = growFloat32Buffer(
linkPositionBufferRef.current,
@@ -397,10 +425,10 @@ export function useVisualizer3DScene({
}
const highlightLine = highlightLineRef.current;
if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) {
if (highlightLine && highlightPositionBufferRef.current.length < highlightRequiredLength) {
highlightPositionBufferRef.current = growFloat32Buffer(
highlightPositionBufferRef.current,
requiredLength
highlightRequiredLength
);
(highlightLine.geometry as THREE.BufferGeometry).setAttribute(
'position',
@@ -415,7 +443,7 @@ export function useVisualizer3DScene({
let idx = 0;
let hlIdx = 0;
for (const link of visibleLinks) {
for (const link of solidLinks) {
const { sourceId, targetId } = getLinkId(link);
const sNode = nodes.get(sourceId);
const tNode = nodes.get(targetId);
@@ -446,6 +474,23 @@ export function useVisualizer3DScene({
}
}
for (const link of dashedLinks) {
const { sourceId, targetId } = getLinkId(link);
if (activeId && (sourceId === activeId || targetId === activeId)) {
const sNode = nodes.get(sourceId);
const tNode = nodes.get(targetId);
if (!sNode || !tNode) continue;
connectedIds?.add(sourceId === activeId ? targetId : sourceId);
hlPositions[hlIdx++] = sNode.x ?? 0;
hlPositions[hlIdx++] = sNode.y ?? 0;
hlPositions[hlIdx++] = sNode.z ?? 0;
hlPositions[hlIdx++] = tNode.x ?? 0;
hlPositions[hlIdx++] = tNode.y ?? 0;
hlPositions[hlIdx++] = tNode.z ?? 0;
}
}
const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
if (positionAttr) {
positionAttr.needsUpdate = true;
@@ -464,6 +509,51 @@ export function useVisualizer3DScene({
}
}
const dashedLinkLine = dashedLinkLineRef.current;
if (dashedLinkLine) {
const geometry = dashedLinkLine.geometry as THREE.BufferGeometry;
const requiredLength = dashedLinks.length * 6;
if (dashedLinkPositionBufferRef.current.length < requiredLength) {
dashedLinkPositionBufferRef.current = growFloat32Buffer(
dashedLinkPositionBufferRef.current,
requiredLength
);
geometry.setAttribute(
'position',
new THREE.BufferAttribute(dashedLinkPositionBufferRef.current, 3).setUsage(
THREE.DynamicDrawUsage
)
);
}
const positions = dashedLinkPositionBufferRef.current;
let idx = 0;
for (const link of dashedLinks) {
const { sourceId, targetId } = getLinkId(link);
const sNode = nodes.get(sourceId);
const tNode = nodes.get(targetId);
if (!sNode || !tNode) continue;
positions[idx++] = sNode.x ?? 0;
positions[idx++] = sNode.y ?? 0;
positions[idx++] = sNode.z ?? 0;
positions[idx++] = tNode.x ?? 0;
positions[idx++] = tNode.y ?? 0;
positions[idx++] = tNode.z ?? 0;
}
const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
if (positionAttr) {
positionAttr.needsUpdate = true;
}
geometry.setDrawRange(0, idx / 3);
dashedLinkLine.visible = idx > 0;
if (idx > 0 && positionAttr) {
dashedLinkLine.computeLineDistances();
}
}
let writeIdx = 0;
for (let readIdx = 0; readIdx < particles.length; readIdx++) {
const particle = particles[readIdx];
@@ -21,7 +21,9 @@ import {
} from '../../types';
import { getRawPacketObservationKey } from '../../utils/rawPacketIdentity';
import {
buildLinkKey,
type Particle,
type PathStep,
type PendingPacket,
type RepeaterTrafficData,
PARTICLE_COLOR_MAP,
@@ -29,6 +31,7 @@ import {
analyzeRepeaterTraffic,
buildAmbiguousRepeaterLabel,
buildAmbiguousRepeaterNodeId,
compactPathSteps,
dedupeConsecutive,
generatePacketKey,
getNodeType,
@@ -293,16 +296,35 @@ export function useVisualizerData3D({
[]
);
const addLink = useCallback((sourceId: string, targetId: string, activityAtMs?: number) => {
const activityAt = activityAtMs ?? Date.now();
const key = [sourceId, targetId].sort().join('->');
const existing = linksRef.current.get(key);
if (existing) {
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
} else {
linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: activityAt });
}
}, []);
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,
});
}
},
[]
);
const publishPacket = useCallback((packetKey: string) => {
const pending = pendingRef.current.get(packetKey);
@@ -319,7 +341,7 @@ export function useVisualizerData3D({
for (let i = 0; i < dedupedPath.length - 1; i++) {
particlesRef.current.push({
linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'),
linkKey: buildLinkKey(dedupedPath[i], dedupedPath[i + 1]),
progress: -i,
speed: PARTICLE_SPEED * speedMultiplierRef.current,
color: PARTICLE_COLOR_MAP[pending.label],
@@ -550,10 +572,12 @@ export function useVisualizerData3D({
packet: RawPacket,
myPrefix: string | null,
activityAtMs: number
): string[] => {
if (!parsed) return [];
const path: string[] = [];
): { 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(
@@ -564,7 +588,7 @@ export function useVisualizerData3D({
activityAtMs
);
if (nodeId) {
path.push(nodeId);
steps.push({ nodeId });
packetSource = nodeId;
}
} else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
@@ -576,12 +600,12 @@ export function useVisualizerData3D({
activityAtMs
);
if (nodeId) {
path.push(nodeId);
steps.push({ nodeId });
packetSource = nodeId;
}
} else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) {
if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) {
path.push('self');
steps.push({ nodeId: 'self' });
packetSource = 'self';
} else {
const nodeId = resolveNode(
@@ -592,7 +616,7 @@ export function useVisualizerData3D({
activityAtMs
);
if (nodeId) {
path.push(nodeId);
steps.push({ nodeId });
packetSource = nodeId;
}
}
@@ -607,7 +631,7 @@ export function useVisualizerData3D({
activityAtMs
);
if (resolved) {
path.push(resolved);
steps.push({ nodeId: resolved });
packetSource = resolved;
}
}
@@ -624,12 +648,12 @@ export function useVisualizerData3D({
activityAtMs,
{ packetSource, nextPrefix }
);
if (nodeId) path.push(nodeId);
steps.push({ nodeId, markHiddenLinkWhenOmitted: true });
}
if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) {
if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) {
path.push('self');
steps.push({ nodeId: 'self' });
} else {
const nodeId = resolveNode(
{ type: 'prefix', value: parsed.dstHash },
@@ -638,18 +662,24 @@ export function useVisualizerData3D({
myPrefix,
activityAtMs
);
if (nodeId) path.push(nodeId);
else path.push('self');
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' });
}
} else if (path.length > 0) {
path.push('self');
}
if (path.length > 0 && path[path.length - 1] !== 'self') {
path.push('self');
}
return dedupeConsecutive(path);
const compacted = compactPathSteps(steps);
return {
nodes: dedupeConsecutive(compacted.nodes),
dashedLinkKeys: compacted.dashedLinkKeys,
};
},
[resolveNode, showAmbiguousPaths, showAmbiguousNodes]
);
@@ -674,20 +704,26 @@ export function useVisualizerData3D({
if (!parsed) continue;
const packetActivityAt = normalizePacketTimestampMs(packet.timestamp);
const path = buildPath(parsed, packet, myPrefix, packetActivityAt);
if (path.length < 2) continue;
const builtPath = buildPath(parsed, packet, myPrefix, packetActivityAt);
if (builtPath.nodes.length < 2) continue;
const label = getPacketLabel(parsed.payloadType);
for (let i = 0; i < path.length; i++) {
const n = nodesRef.current.get(path[i]);
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 < path.length - 1; i++) {
if (path[i] !== path[i + 1]) {
addLink(path[i], path[i + 1], packetActivityAt);
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;
}
}
@@ -697,7 +733,7 @@ export function useVisualizerData3D({
const existing = pendingRef.current.get(packetKey);
if (existing && now < existing.expiresAt) {
existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now });
existing.paths.push({ nodes: builtPath.nodes, snr: packet.snr ?? null, timestamp: now });
} else {
const existingTimer = timersRef.current.get(packetKey);
if (existingTimer) {
@@ -707,7 +743,7 @@ export function useVisualizerData3D({
pendingRef.current.set(packetKey, {
key: packetKey,
label: getPacketLabel(parsed.payloadType),
paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }],
paths: [{ nodes: builtPath.nodes, snr: packet.snr ?? null, timestamp: now }],
firstSeen: now,
expiresAt: now + windowMs,
});