diff --git a/frontend/src/components/visualizer/shared.ts b/frontend/src/components/visualizer/shared.ts index 39d153b..0f24c47 100644 --- a/frontend/src/components/visualizer/shared.ts +++ b/frontend/src/components/visualizer/shared.ts @@ -21,6 +21,8 @@ export interface GraphLink extends SimulationLinkDatum { source: string | GraphNode; target: string | GraphNode; lastActivity: number; + hasDirectObservation: boolean; + hasHiddenIntermediate: boolean; } export interface NodeMeshData { diff --git a/frontend/src/components/visualizer/useVisualizer3DScene.ts b/frontend/src/components/visualizer/useVisualizer3DScene.ts index 6bcab6e..72bea0b 100644 --- a/frontend/src/components/visualizer/useVisualizer3DScene.ts +++ b/frontend/src/components/visualizer/useVisualizer3DScene.ts @@ -32,10 +32,12 @@ export function useVisualizer3DScene({ const nodeMeshesRef = useRef>(new Map()); const raycastTargetsRef = useRef([]); const linkLineRef = useRef(null); + const dashedLinkLineRef = useRef(null); const highlightLineRef = useRef(null); const particlePointsRef = useRef(null); const particleTextureRef = useRef(null); const linkPositionBufferRef = useRef(new Float32Array(0)); + const dashedLinkPositionBufferRef = useRef(new Float32Array(0)); const highlightPositionBufferRef = useRef(new Float32Array(0)); const particlePositionBufferRef = useRef(new Float32Array(0)); const particleColorBufferRef = useRef(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]; diff --git a/frontend/src/components/visualizer/useVisualizerData3D.ts b/frontend/src/components/visualizer/useVisualizerData3D.ts index 8f415e2..bd3f53f 100644 --- a/frontend/src/components/visualizer/useVisualizerData3D.ts +++ b/frontend/src/components/visualizer/useVisualizerData3D.ts @@ -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 } => { + 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, }); diff --git a/frontend/src/test/useVisualizerData3D.test.ts b/frontend/src/test/useVisualizerData3D.test.ts new file mode 100644 index 0000000..45f2e7f --- /dev/null +++ b/frontend/src/test/useVisualizerData3D.test.ts @@ -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(), +})); + +vi.mock('../utils/visualizerUtils', async () => { + const actual = await vi.importActual( + '../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']); + }); +}); diff --git a/frontend/src/utils/visualizerUtils.ts b/frontend/src/utils/visualizerUtils.ts index cbc69de..2a9bdc9 100644 --- a/frontend/src/utils/visualizerUtils.ts +++ b/frontend/src/utils/visualizerUtils.ts @@ -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(arr: T[]): T[] { return arr.filter((item, i) => i === 0 || item !== arr[i - 1]); } +export function compactPathSteps(steps: PathStep[]): { + nodes: string[]; + dashedLinkKeys: Set; +} { + const nodes: string[] = []; + const dashedLinkKeys = new Set(); + 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.