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,
});
@@ -0,0 +1,203 @@
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PayloadType } from '@michaelhart/meshcore-decoder';
import type { Contact, RadioConfig, RawPacket } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { buildLinkKey } from '../utils/visualizerUtils';
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) ?? null),
};
});
import { useVisualizerData3D } from '../components/visualizer/useVisualizerData3D';
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,
};
}
function renderVisualizerData({
packets,
contacts,
config,
showAmbiguousPaths = false,
showAmbiguousNodes = false,
}: {
packets: RawPacket[];
contacts: Contact[];
config: RadioConfig;
showAmbiguousPaths?: boolean;
showAmbiguousNodes?: boolean;
}) {
return renderHook(() =>
useVisualizerData3D({
packets,
contacts,
config,
repeaterAdvertPaths: [],
showAmbiguousPaths,
showAmbiguousNodes,
useAdvertPathHints: false,
splitAmbiguousByTraffic: false,
chargeStrength: -200,
letEmDrift: false,
particleSpeedMultiplier: 1,
observationWindowSec: 15,
pruneStaleNodes: false,
pruneStaleMinutes: 5,
})
);
}
afterEach(() => {
packetFixtures.clear();
});
describe('useVisualizerData3D', () => {
it('marks compressed hidden-repeater routes as dashed links instead of direct solid links', async () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
packetFixtures.set('dm-hidden-hop', {
payloadType: PayloadType.TextMessage,
messageHash: 'dm-hidden-hop',
pathBytes: ['32'],
srcHash: 'aaaaaaaaaaaa',
dstHash: 'ffffffffffff',
advertPubkey: null,
groupTextSender: null,
anonRequestPubkey: null,
});
const { result } = renderVisualizerData({
packets: [createPacket('dm-hidden-hop')],
contacts: [createContact(aliceKey, 'Alice')],
config: createConfig(selfKey),
});
await waitFor(() => expect(result.current.links.size).toBe(1));
const link = result.current.links.get(buildLinkKey('aaaaaaaaaaaa', 'self'));
expect(link).toBeDefined();
expect(link?.hasHiddenIntermediate).toBe(true);
expect(link?.hasDirectObservation).toBe(false);
});
it('does not append self after a resolved outgoing DM destination', async () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
const bobKey = 'bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000';
const repeaterKey = '3232323232320000000000000000000000000000000000000000000000000000';
packetFixtures.set('dm-outgoing-known-dst', {
payloadType: PayloadType.TextMessage,
messageHash: 'dm-outgoing-known-dst',
pathBytes: ['323232323232'],
srcHash: 'ffffffffffff',
dstHash: 'bbbbbbbbbbbb',
advertPubkey: null,
groupTextSender: null,
anonRequestPubkey: null,
});
const { result } = renderVisualizerData({
packets: [createPacket('dm-outgoing-known-dst')],
contacts: [
createContact(bobKey, 'Bob'),
createContact(repeaterKey, 'Relay', CONTACT_TYPE_REPEATER),
],
config: createConfig(selfKey),
showAmbiguousPaths: true,
});
await waitFor(() => expect(result.current.links.size).toBe(2));
expect(result.current.links.has(buildLinkKey('self', '323232323232'))).toBe(true);
expect(result.current.links.has(buildLinkKey('323232323232', 'bbbbbbbbbbbb'))).toBe(true);
expect(result.current.links.has(buildLinkKey('self', 'bbbbbbbbbbbb'))).toBe(false);
});
it('does not create a fake self edge for an unresolved outgoing direct DM', async () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
packetFixtures.set('dm-outgoing-unknown-dst', {
payloadType: PayloadType.TextMessage,
messageHash: 'dm-outgoing-unknown-dst',
pathBytes: [],
srcHash: 'ffffffffffff',
dstHash: 'cccccccccccc',
advertPubkey: null,
groupTextSender: null,
anonRequestPubkey: null,
});
const { result } = renderVisualizerData({
packets: [createPacket('dm-outgoing-unknown-dst')],
contacts: [],
config: createConfig(selfKey),
});
await waitFor(() => expect(result.current.stats.processed).toBe(1));
expect(result.current.links.size).toBe(0);
expect(Array.from(result.current.nodes.keys())).toEqual(['self']);
});
});
+38
View File
@@ -110,6 +110,11 @@ export const PACKET_LEGEND_ITEMS = [
{ label: '?', color: COLORS.particleUnknown, description: 'Other' },
] as const;
export interface PathStep {
nodeId: string | null;
markHiddenLinkWhenOmitted?: boolean;
}
export function normalizeHopToken(hop: string | null | undefined): string | null {
const normalized = hop?.trim().toLowerCase() ?? '';
return normalized.length > 0 ? normalized : null;
@@ -234,6 +239,10 @@ export function getLinkId<
};
}
export function buildLinkKey(sourceId: string, targetId: string): string {
return [sourceId, targetId].sort().join('->');
}
export function getNodeType(contact: Contact | null | undefined): NodeType {
return contact?.type === CONTACT_TYPE_REPEATER ? 'repeater' : 'client';
}
@@ -242,6 +251,35 @@ export function dedupeConsecutive<T>(arr: T[]): T[] {
return arr.filter((item, i) => i === 0 || item !== arr[i - 1]);
}
export function compactPathSteps(steps: PathStep[]): {
nodes: string[];
dashedLinkKeys: Set<string>;
} {
const nodes: string[] = [];
const dashedLinkKeys = new Set<string>();
let pendingHiddenLink = false;
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));
}
if (previousNodeId !== step.nodeId) {
nodes.push(step.nodeId);
}
pendingHiddenLink = false;
continue;
}
if (step.markHiddenLinkWhenOmitted && nodes.length > 0) {
pendingHiddenLink = true;
}
}
return { nodes, dashedLinkKeys };
}
/**
* Analyze traffic patterns for an ambiguous repeater prefix to determine if it
* should be split into multiple nodes.