mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-29 22:41:32 +02:00
Use dashed lines for collapsed ambiguous repeater paths. Closes #44.
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user