mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
1711 lines
57 KiB
TypeScript
1711 lines
57 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import {
|
|
forceSimulation,
|
|
forceLink,
|
|
forceManyBody,
|
|
forceCenter,
|
|
forceCollide,
|
|
forceX,
|
|
forceY,
|
|
type Simulation,
|
|
type SimulationNodeDatum,
|
|
type SimulationLinkDatum,
|
|
} from 'd3-force';
|
|
import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder';
|
|
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket, type RadioConfig } from '../types';
|
|
import { Checkbox } from './ui/checkbox';
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
type NodeType = 'self' | 'repeater' | 'client';
|
|
type PacketLabel = 'AD' | 'GT' | 'DM' | 'ACK' | 'TR' | 'RQ' | 'RS' | '?';
|
|
|
|
interface GraphNode extends SimulationNodeDatum {
|
|
id: string;
|
|
name: string | null;
|
|
type: NodeType;
|
|
isAmbiguous: boolean;
|
|
lastActivity: number;
|
|
lastSeen?: number | null;
|
|
ambiguousNames?: string[];
|
|
x?: number;
|
|
y?: number;
|
|
vx?: number;
|
|
vy?: number;
|
|
fx?: number | null;
|
|
fy?: number | null;
|
|
}
|
|
|
|
interface GraphLink extends SimulationLinkDatum<GraphNode> {
|
|
source: string | GraphNode;
|
|
target: string | GraphNode;
|
|
lastActivity: number;
|
|
}
|
|
|
|
interface Particle {
|
|
linkKey: string;
|
|
progress: number;
|
|
speed: number;
|
|
color: string;
|
|
label: PacketLabel;
|
|
fromNodeId: string;
|
|
toNodeId: string;
|
|
}
|
|
|
|
interface ObservedPath {
|
|
nodes: string[];
|
|
snr: number | null;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface PendingPacket {
|
|
key: string;
|
|
label: PacketLabel;
|
|
paths: ObservedPath[];
|
|
firstSeen: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface ParsedPacket {
|
|
payloadType: number;
|
|
pathBytes: string[];
|
|
srcHash: string | null;
|
|
dstHash: string | null;
|
|
advertPubkey: string | null;
|
|
groupTextSender: string | null;
|
|
anonRequestPubkey: string | null;
|
|
}
|
|
|
|
// Traffic pattern tracking for smarter repeater disambiguation
|
|
interface TrafficObservation {
|
|
source: string; // Node that originated traffic (could be resolved node ID or ambiguous)
|
|
nextHop: string | null; // Next hop after this repeater (null if final hop before self)
|
|
timestamp: number;
|
|
}
|
|
|
|
interface RepeaterTrafficData {
|
|
prefix: string; // The 1-byte hex prefix (e.g., "32")
|
|
observations: TrafficObservation[];
|
|
}
|
|
|
|
// Analysis result for whether to split an ambiguous repeater
|
|
interface RepeaterSplitAnalysis {
|
|
shouldSplit: boolean;
|
|
// If shouldSplit, maps nextHop -> the sources that exclusively route through it
|
|
disjointGroups: Map<string, Set<string>> | null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// CONSTANTS
|
|
// =============================================================================
|
|
|
|
const COLORS = {
|
|
background: '#0a0a0a',
|
|
link: '#4b5563',
|
|
ambiguous: '#9ca3af',
|
|
particleAD: '#f59e0b', // amber - advertisements
|
|
particleGT: '#06b6d4', // cyan - group text
|
|
particleDM: '#8b5cf6', // purple - direct messages
|
|
particleACK: '#22c55e', // green - acknowledgments
|
|
particleTR: '#f97316', // orange - trace packets
|
|
particleRQ: '#ec4899', // pink - requests
|
|
particleRS: '#14b8a6', // teal - responses
|
|
particleUnknown: '#6b7280', // gray - unknown
|
|
} as const;
|
|
|
|
const PARTICLE_COLOR_MAP: Record<PacketLabel, string> = {
|
|
AD: COLORS.particleAD,
|
|
GT: COLORS.particleGT,
|
|
DM: COLORS.particleDM,
|
|
ACK: COLORS.particleACK,
|
|
TR: COLORS.particleTR,
|
|
RQ: COLORS.particleRQ,
|
|
RS: COLORS.particleRS,
|
|
'?': COLORS.particleUnknown,
|
|
};
|
|
|
|
const PARTICLE_SPEED = 0.008;
|
|
const DEFAULT_OBSERVATION_WINDOW_SEC = 15;
|
|
const FORTY_EIGHT_HOURS_MS = 48 * 60 * 60 * 1000;
|
|
|
|
// Traffic pattern analysis thresholds
|
|
// Be conservative - once split, we can't unsplit, so require strong evidence
|
|
const MIN_OBSERVATIONS_TO_SPLIT = 20; // Need at least this many unique sources per next-hop group
|
|
const MAX_TRAFFIC_OBSERVATIONS = 200; // Per ambiguous prefix, to limit memory
|
|
const TRAFFIC_OBSERVATION_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes - old observations are pruned
|
|
|
|
const LEGEND_ITEMS = [
|
|
{ emoji: '🟢', label: 'You', size: 'text-xl' },
|
|
{ emoji: '📡', label: 'Repeater', size: 'text-base' },
|
|
{ emoji: '👤', label: 'Node', size: 'text-base' },
|
|
{ emoji: '❓', label: 'Unknown', size: 'text-base' },
|
|
] as const;
|
|
|
|
const PACKET_LEGEND_ITEMS = [
|
|
{ label: 'AD', color: COLORS.particleAD, description: 'Advertisement' },
|
|
{ label: 'GT', color: COLORS.particleGT, description: 'Group Text' },
|
|
{ label: 'DM', color: COLORS.particleDM, description: 'Direct Message' },
|
|
{ label: 'ACK', color: COLORS.particleACK, description: 'Acknowledgment' },
|
|
{ label: 'TR', color: COLORS.particleTR, description: 'Trace' },
|
|
{ label: 'RQ', color: COLORS.particleRQ, description: 'Request' },
|
|
{ label: 'RS', color: COLORS.particleRS, description: 'Response' },
|
|
{ label: '?', color: COLORS.particleUnknown, description: 'Other' },
|
|
] as const;
|
|
|
|
// =============================================================================
|
|
// UTILITY FUNCTIONS (Data Layer)
|
|
// =============================================================================
|
|
|
|
function simpleHash(str: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = (hash << 5) - hash + str.charCodeAt(i);
|
|
hash = hash & hash;
|
|
}
|
|
return Math.abs(hash).toString(16).padStart(8, '0');
|
|
}
|
|
|
|
function parsePacket(hexData: string): ParsedPacket | null {
|
|
try {
|
|
const decoded = MeshCoreDecoder.decode(hexData);
|
|
if (!decoded.isValid) return null;
|
|
|
|
const result: ParsedPacket = {
|
|
payloadType: decoded.payloadType,
|
|
pathBytes: decoded.path || [],
|
|
srcHash: null,
|
|
dstHash: null,
|
|
advertPubkey: null,
|
|
groupTextSender: null,
|
|
anonRequestPubkey: null,
|
|
};
|
|
|
|
if (decoded.payloadType === PayloadType.TextMessage && decoded.payload.decoded) {
|
|
const payload = decoded.payload.decoded as { sourceHash?: string; destinationHash?: string };
|
|
result.srcHash = payload.sourceHash || null;
|
|
result.dstHash = payload.destinationHash || null;
|
|
} else if (decoded.payloadType === PayloadType.Advert && decoded.payload.decoded) {
|
|
result.advertPubkey = (decoded.payload.decoded as { publicKey?: string }).publicKey || null;
|
|
} else if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) {
|
|
const payload = decoded.payload.decoded as { decrypted?: { sender?: string } };
|
|
result.groupTextSender = payload.decrypted?.sender || null;
|
|
} else if (decoded.payloadType === PayloadType.AnonRequest && decoded.payload.decoded) {
|
|
const payload = decoded.payload.decoded as { senderPublicKey?: string };
|
|
result.anonRequestPubkey = payload.senderPublicKey || null;
|
|
}
|
|
|
|
return result;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getPacketLabel(payloadType: number): PacketLabel {
|
|
switch (payloadType) {
|
|
case PayloadType.Advert:
|
|
return 'AD';
|
|
case PayloadType.GroupText:
|
|
return 'GT';
|
|
case PayloadType.TextMessage:
|
|
return 'DM';
|
|
case PayloadType.Ack:
|
|
return 'ACK';
|
|
case PayloadType.Trace:
|
|
return 'TR';
|
|
case PayloadType.Request:
|
|
case PayloadType.AnonRequest:
|
|
return 'RQ';
|
|
case PayloadType.Response:
|
|
return 'RS';
|
|
default:
|
|
return '?';
|
|
}
|
|
}
|
|
|
|
function generatePacketKey(parsed: ParsedPacket, rawPacket: RawPacket): string {
|
|
const contentHash = simpleHash(rawPacket.data).slice(0, 8);
|
|
|
|
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
|
return `ad:${parsed.advertPubkey.slice(0, 12)}`;
|
|
}
|
|
if (parsed.payloadType === PayloadType.GroupText) {
|
|
const sender = parsed.groupTextSender || rawPacket.decrypted_info?.sender || '?';
|
|
const channel = rawPacket.decrypted_info?.channel_name || '?';
|
|
return `gt:${channel}:${sender}:${contentHash}`;
|
|
}
|
|
if (parsed.payloadType === PayloadType.TextMessage) {
|
|
return `dm:${parsed.srcHash || '?'}:${parsed.dstHash || '?'}:${contentHash}`;
|
|
}
|
|
if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
|
return `rq:${parsed.anonRequestPubkey.slice(0, 12)}:${contentHash}`;
|
|
}
|
|
return `other:${contentHash}`;
|
|
}
|
|
|
|
function getLinkId(link: GraphLink): { sourceId: string; targetId: string } {
|
|
return {
|
|
sourceId: typeof link.source === 'string' ? link.source : link.source.id,
|
|
targetId: typeof link.target === 'string' ? link.target : link.target.id,
|
|
};
|
|
}
|
|
|
|
function findContactByPrefix(prefix: string, contacts: Contact[]): Contact | null {
|
|
const normalized = prefix.toLowerCase();
|
|
const matches = contacts.filter((c) => c.public_key.toLowerCase().startsWith(normalized));
|
|
return matches.length === 1 ? matches[0] : null;
|
|
}
|
|
|
|
function findContactsByPrefix(prefix: string, contacts: Contact[]): Contact[] {
|
|
const normalized = prefix.toLowerCase();
|
|
return contacts.filter((c) => c.public_key.toLowerCase().startsWith(normalized));
|
|
}
|
|
|
|
function findContactByName(name: string, contacts: Contact[]): Contact | null {
|
|
return contacts.find((c) => c.name === name) || null;
|
|
}
|
|
|
|
function getNodeType(contact: Contact | null | undefined): NodeType {
|
|
return contact?.type === CONTACT_TYPE_REPEATER ? 'repeater' : 'client';
|
|
}
|
|
|
|
function dedupeConsecutive<T>(arr: T[]): T[] {
|
|
return arr.filter((item, i) => i === 0 || item !== arr[i - 1]);
|
|
}
|
|
|
|
/**
|
|
* Analyze traffic patterns for an ambiguous repeater prefix to determine if it
|
|
* should be split into multiple nodes.
|
|
*
|
|
* Logic:
|
|
* - Group observations by nextHop
|
|
* - For each nextHop group, collect the set of sources
|
|
* - If any source appears in multiple nextHop groups → same physical node (hub), don't split
|
|
* - If source sets are completely disjoint → likely different physical nodes, split
|
|
*
|
|
* Returns shouldSplit=true only when we have enough evidence of disjoint routing.
|
|
*/
|
|
function analyzeRepeaterTraffic(data: RepeaterTrafficData): RepeaterSplitAnalysis {
|
|
const now = Date.now();
|
|
|
|
// Filter out old observations
|
|
const recentObservations = data.observations.filter(
|
|
(obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS
|
|
);
|
|
|
|
// Group by nextHop (use "self" for null nextHop - final repeater)
|
|
const byNextHop = new Map<string, Set<string>>();
|
|
for (const obs of recentObservations) {
|
|
const hopKey = obs.nextHop ?? 'self';
|
|
if (!byNextHop.has(hopKey)) {
|
|
byNextHop.set(hopKey, new Set());
|
|
}
|
|
byNextHop.get(hopKey)!.add(obs.source);
|
|
}
|
|
|
|
// If only one nextHop group, no need to split
|
|
if (byNextHop.size <= 1) {
|
|
return { shouldSplit: false, disjointGroups: null };
|
|
}
|
|
|
|
// Check if any source appears in multiple groups (evidence of hub behavior)
|
|
const allSources = new Map<string, string[]>(); // source -> list of nextHops it uses
|
|
for (const [nextHop, sources] of byNextHop) {
|
|
for (const source of sources) {
|
|
if (!allSources.has(source)) {
|
|
allSources.set(source, []);
|
|
}
|
|
allSources.get(source)!.push(nextHop);
|
|
}
|
|
}
|
|
|
|
// If any source routes to multiple nextHops, this is a hub - don't split
|
|
for (const [, nextHops] of allSources) {
|
|
if (nextHops.length > 1) {
|
|
return { shouldSplit: false, disjointGroups: null };
|
|
}
|
|
}
|
|
|
|
// Check if we have enough observations in each group to be confident
|
|
for (const [, sources] of byNextHop) {
|
|
if (sources.size < MIN_OBSERVATIONS_TO_SPLIT) {
|
|
// Not enough evidence yet - be conservative, don't split
|
|
return { shouldSplit: false, disjointGroups: null };
|
|
}
|
|
}
|
|
|
|
// Source sets are disjoint and we have enough data - split!
|
|
return { shouldSplit: true, disjointGroups: byNextHop };
|
|
}
|
|
|
|
/**
|
|
* Record a traffic observation for an ambiguous repeater prefix.
|
|
* Prunes old observations and limits total count.
|
|
*/
|
|
function recordTrafficObservation(
|
|
trafficData: Map<string, RepeaterTrafficData>,
|
|
prefix: string,
|
|
source: string,
|
|
nextHop: string | null
|
|
): void {
|
|
const normalizedPrefix = prefix.toLowerCase();
|
|
const now = Date.now();
|
|
|
|
if (!trafficData.has(normalizedPrefix)) {
|
|
trafficData.set(normalizedPrefix, { prefix: normalizedPrefix, observations: [] });
|
|
}
|
|
|
|
const data = trafficData.get(normalizedPrefix)!;
|
|
|
|
// Add new observation
|
|
data.observations.push({ source, nextHop, timestamp: now });
|
|
|
|
// Prune old observations
|
|
data.observations = data.observations.filter(
|
|
(obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS
|
|
);
|
|
|
|
// Limit total count
|
|
if (data.observations.length > MAX_TRAFFIC_OBSERVATIONS) {
|
|
data.observations = data.observations.slice(-MAX_TRAFFIC_OBSERVATIONS);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// DATA LAYER HOOK
|
|
// =============================================================================
|
|
|
|
interface UseVisualizerDataOptions {
|
|
packets: RawPacket[];
|
|
contacts: Contact[];
|
|
config: RadioConfig | null;
|
|
showAmbiguousPaths: boolean;
|
|
showAmbiguousNodes: boolean;
|
|
splitAmbiguousByTraffic: boolean;
|
|
chargeStrength: number;
|
|
letEmDrift: boolean;
|
|
particleSpeedMultiplier: number;
|
|
observationWindowSec: number;
|
|
dimensions: { width: number; height: number };
|
|
}
|
|
|
|
interface VisualizerData {
|
|
nodes: Map<string, GraphNode>;
|
|
links: Map<string, GraphLink>;
|
|
particles: Particle[];
|
|
simulation: Simulation<GraphNode, GraphLink> | null;
|
|
stats: { processed: number; animated: number; nodes: number; links: number };
|
|
randomizePositions: () => void;
|
|
expandContract: () => void;
|
|
clearAndReset: () => void;
|
|
}
|
|
|
|
function useVisualizerData({
|
|
packets,
|
|
contacts,
|
|
config,
|
|
showAmbiguousPaths,
|
|
showAmbiguousNodes,
|
|
splitAmbiguousByTraffic,
|
|
chargeStrength,
|
|
letEmDrift,
|
|
particleSpeedMultiplier,
|
|
observationWindowSec,
|
|
dimensions,
|
|
}: UseVisualizerDataOptions): VisualizerData {
|
|
const nodesRef = useRef<Map<string, GraphNode>>(new Map());
|
|
const linksRef = useRef<Map<string, GraphLink>>(new Map());
|
|
const particlesRef = useRef<Particle[]>([]);
|
|
const simulationRef = useRef<Simulation<GraphNode, GraphLink> | null>(null);
|
|
const processedRef = useRef<Set<number>>(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 [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 });
|
|
|
|
// Keep refs in sync with props
|
|
useEffect(() => {
|
|
speedMultiplierRef.current = particleSpeedMultiplier;
|
|
}, [particleSpeedMultiplier]);
|
|
|
|
useEffect(() => {
|
|
observationWindowRef.current = observationWindowSec * 1000;
|
|
}, [observationWindowSec]);
|
|
|
|
// Initialize simulation
|
|
useEffect(() => {
|
|
const sim = forceSimulation<GraphNode, GraphLink>([])
|
|
.force(
|
|
'link',
|
|
forceLink<GraphNode, GraphLink>([])
|
|
.id((d) => d.id)
|
|
.distance(80)
|
|
.strength(0.3)
|
|
)
|
|
.force(
|
|
'charge',
|
|
forceManyBody<GraphNode>()
|
|
.strength((d) => (d.id === 'self' ? -1200 : -200))
|
|
.distanceMax(500)
|
|
)
|
|
.force('center', forceCenter(dimensions.width / 2, dimensions.height / 2))
|
|
.force('collide', forceCollide(40))
|
|
.force(
|
|
'selfX',
|
|
forceX<GraphNode>(dimensions.width / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))
|
|
)
|
|
.force(
|
|
'selfY',
|
|
forceY<GraphNode>(dimensions.height / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))
|
|
)
|
|
.alphaDecay(0.02)
|
|
.velocityDecay(0.5)
|
|
.alphaTarget(0.03);
|
|
|
|
simulationRef.current = sim;
|
|
return () => {
|
|
sim.stop();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-time init; dimensions/charge handled by the effect below
|
|
}, []);
|
|
|
|
// Update simulation forces when dimensions/charge change
|
|
useEffect(() => {
|
|
const sim = simulationRef.current;
|
|
if (!sim) return;
|
|
|
|
sim.force('center', forceCenter(dimensions.width / 2, dimensions.height / 2));
|
|
sim.force(
|
|
'selfX',
|
|
forceX<GraphNode>(dimensions.width / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))
|
|
);
|
|
sim.force(
|
|
'selfY',
|
|
forceY<GraphNode>(dimensions.height / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))
|
|
);
|
|
sim.force(
|
|
'charge',
|
|
forceManyBody<GraphNode>()
|
|
.strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength))
|
|
.distanceMax(500)
|
|
);
|
|
sim.alpha(0.3).restart();
|
|
}, [dimensions, chargeStrength]);
|
|
|
|
// Update alphaTarget when drift preference changes
|
|
useEffect(() => {
|
|
const sim = simulationRef.current;
|
|
if (!sim) return;
|
|
sim.alphaTarget(letEmDrift ? 0.05 : 0);
|
|
}, [letEmDrift]);
|
|
|
|
// Ensure self node exists
|
|
useEffect(() => {
|
|
if (!nodesRef.current.has('self')) {
|
|
nodesRef.current.set('self', {
|
|
id: 'self',
|
|
name: config?.name || 'Me',
|
|
type: 'self',
|
|
isAmbiguous: false,
|
|
lastActivity: Date.now(),
|
|
x: dimensions.width / 2,
|
|
y: dimensions.height / 2,
|
|
});
|
|
syncSimulation();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- syncSimulation is stable (no deps), defined below
|
|
}, [config, dimensions]);
|
|
|
|
// Reset on option changes
|
|
useEffect(() => {
|
|
processedRef.current.clear();
|
|
const selfNode = nodesRef.current.get('self');
|
|
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.clear();
|
|
trafficPatternsRef.current.clear();
|
|
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
|
}, [showAmbiguousPaths, showAmbiguousNodes, splitAmbiguousByTraffic]);
|
|
|
|
const syncSimulation = useCallback(() => {
|
|
const sim = simulationRef.current;
|
|
if (!sim) return;
|
|
|
|
const nodes = Array.from(nodesRef.current.values());
|
|
const links = Array.from(linksRef.current.values());
|
|
|
|
sim.nodes(nodes);
|
|
const linkForce = sim.force('link') as ReturnType<typeof forceLink<GraphNode, GraphLink>>;
|
|
linkForce?.links(links);
|
|
|
|
sim.alpha(0.15).restart();
|
|
|
|
setStats((prev) => ({ ...prev, nodes: nodes.length, links: links.length }));
|
|
}, []);
|
|
|
|
const addNode = useCallback(
|
|
(
|
|
id: string,
|
|
name: string | null,
|
|
type: NodeType,
|
|
isAmbiguous: boolean,
|
|
ambiguousNames?: string[],
|
|
lastSeen?: number | null
|
|
) => {
|
|
const existing = nodesRef.current.get(id);
|
|
if (existing) {
|
|
existing.lastActivity = Date.now();
|
|
if (name && !existing.name) existing.name = name;
|
|
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
|
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
|
} else {
|
|
const selfNode = nodesRef.current.get('self');
|
|
nodesRef.current.set(id, {
|
|
id,
|
|
name,
|
|
type,
|
|
isAmbiguous,
|
|
lastActivity: Date.now(),
|
|
lastSeen,
|
|
ambiguousNames,
|
|
x: (selfNode?.x ?? 400) + (Math.random() - 0.5) * 100,
|
|
y: (selfNode?.y ?? 300) + (Math.random() - 0.5) * 100,
|
|
});
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const addLink = useCallback((sourceId: string, targetId: string) => {
|
|
const key = [sourceId, targetId].sort().join('->');
|
|
const existing = linksRef.current.get(key);
|
|
if (existing) {
|
|
existing.lastActivity = Date.now();
|
|
} else {
|
|
linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: Date.now() });
|
|
}
|
|
}, []);
|
|
|
|
const publishPacket = useCallback((packetKey: string) => {
|
|
const pending = pendingRef.current.get(packetKey);
|
|
if (!pending) return;
|
|
|
|
pendingRef.current.delete(packetKey);
|
|
timersRef.current.delete(packetKey);
|
|
|
|
for (const path of pending.paths) {
|
|
const dedupedPath = dedupeConsecutive(path.nodes);
|
|
if (dedupedPath.length < 2) continue;
|
|
|
|
for (let i = 0; i < dedupedPath.length - 1; i++) {
|
|
particlesRef.current.push({
|
|
linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'),
|
|
progress: -i,
|
|
speed: PARTICLE_SPEED * speedMultiplierRef.current,
|
|
color: PARTICLE_COLOR_MAP[pending.label],
|
|
label: pending.label,
|
|
fromNodeId: dedupedPath[i],
|
|
toNodeId: dedupedPath[i + 1],
|
|
});
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Resolve a node from various sources and add to graph
|
|
// trafficContext is used when splitAmbiguousByTraffic is enabled to create
|
|
// separate nodes for ambiguous repeaters based on their position in traffic flow
|
|
// myPrefix is the user's own 12-char pubkey prefix - if a node matches, return 'self'
|
|
// trafficContext.packetSource is the original source of the packet (for traffic analysis)
|
|
// trafficContext.nextPrefix is the next hop after this repeater
|
|
const resolveNode = useCallback(
|
|
(
|
|
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
|
isRepeater: boolean,
|
|
showAmbiguous: boolean,
|
|
myPrefix: string | null,
|
|
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();
|
|
// Check if this is our own identity - return 'self' instead of creating duplicate node
|
|
if (myPrefix && nodeId === myPrefix) {
|
|
return 'self';
|
|
}
|
|
const contact = contacts.find((c) => c.public_key.toLowerCase().startsWith(nodeId));
|
|
addNode(
|
|
nodeId,
|
|
contact?.name || null,
|
|
getNodeType(contact),
|
|
false,
|
|
undefined,
|
|
contact?.last_seen
|
|
);
|
|
return nodeId;
|
|
}
|
|
|
|
if (source.type === 'name') {
|
|
const contact = findContactByName(source.value, contacts);
|
|
if (contact) {
|
|
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
|
// Check if this is our own identity
|
|
if (myPrefix && nodeId === myPrefix) {
|
|
return 'self';
|
|
}
|
|
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
|
|
return nodeId;
|
|
}
|
|
const nodeId = `name:${source.value}`;
|
|
addNode(nodeId, source.value, 'client', false);
|
|
return nodeId;
|
|
}
|
|
|
|
// type === 'prefix'
|
|
const contact = findContactByPrefix(source.value, contacts);
|
|
if (contact) {
|
|
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
|
// Check if this is our own identity
|
|
if (myPrefix && nodeId === myPrefix) {
|
|
return 'self';
|
|
}
|
|
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
|
|
return nodeId;
|
|
}
|
|
|
|
if (showAmbiguous) {
|
|
const matches = findContactsByPrefix(source.value, contacts);
|
|
const filtered = isRepeater
|
|
? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER)
|
|
: matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER);
|
|
|
|
// If exactly one match after filtering, use it directly (not ambiguous)
|
|
if (filtered.length === 1) {
|
|
const contact = filtered[0];
|
|
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
|
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
|
|
return nodeId;
|
|
}
|
|
|
|
// Multiple matches or no matches - create ambiguous node
|
|
// When splitAmbiguousByTraffic is enabled for repeaters, use traffic pattern analysis
|
|
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
|
|
);
|
|
|
|
// Default: simple ambiguous node ID
|
|
let nodeId = `?${source.value.toLowerCase()}`;
|
|
let displayName = source.value.toUpperCase();
|
|
|
|
// When splitAmbiguousByTraffic is enabled, use traffic pattern analysis
|
|
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
|
const prefix = source.value.toLowerCase();
|
|
|
|
// Record observation for traffic analysis (only if we have a packet source)
|
|
if (trafficContext.packetSource) {
|
|
recordTrafficObservation(
|
|
trafficPatternsRef.current,
|
|
prefix,
|
|
trafficContext.packetSource,
|
|
trafficContext.nextPrefix
|
|
);
|
|
}
|
|
|
|
// Analyze traffic patterns to decide if we should split
|
|
const trafficData = trafficPatternsRef.current.get(prefix);
|
|
if (trafficData) {
|
|
const analysis = analyzeRepeaterTraffic(trafficData);
|
|
|
|
if (analysis.shouldSplit && trafficContext.nextPrefix) {
|
|
// Strong evidence of disjoint routing - split by next hop
|
|
const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase();
|
|
nodeId = `?${prefix}:>${nextShort}`;
|
|
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
|
|
}
|
|
// If analysis says don't split, or this is the final repeater (nextPrefix=null),
|
|
// keep the simple ?XX ID
|
|
}
|
|
}
|
|
|
|
addNode(
|
|
nodeId,
|
|
displayName,
|
|
isRepeater ? 'repeater' : 'client',
|
|
true,
|
|
names.length > 0 ? names : undefined,
|
|
lastSeen
|
|
);
|
|
return nodeId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
[contacts, addNode, splitAmbiguousByTraffic]
|
|
);
|
|
|
|
// Build path from parsed packet
|
|
const buildPath = useCallback(
|
|
(parsed: ParsedPacket, packet: RawPacket, myPrefix: string | null): string[] => {
|
|
const path: string[] = [];
|
|
let packetSource: string | null = null;
|
|
|
|
// Add source - and track it for traffic pattern analysis
|
|
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
|
const nodeId = resolveNode(
|
|
{ type: 'pubkey', value: parsed.advertPubkey },
|
|
false,
|
|
false,
|
|
myPrefix
|
|
);
|
|
if (nodeId) {
|
|
path.push(nodeId);
|
|
packetSource = nodeId;
|
|
}
|
|
} else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
|
// AnonRequest packets contain the full sender public key
|
|
const nodeId = resolveNode(
|
|
{ type: 'pubkey', value: parsed.anonRequestPubkey },
|
|
false,
|
|
false,
|
|
myPrefix
|
|
);
|
|
if (nodeId) {
|
|
path.push(nodeId);
|
|
packetSource = nodeId;
|
|
}
|
|
} else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) {
|
|
if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) {
|
|
path.push('self');
|
|
packetSource = 'self';
|
|
} else {
|
|
const nodeId = resolveNode(
|
|
{ type: 'prefix', value: parsed.srcHash },
|
|
false,
|
|
showAmbiguousNodes,
|
|
myPrefix
|
|
);
|
|
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({ type: 'name', value: senderName }, false, false, myPrefix);
|
|
if (nodeId) {
|
|
path.push(nodeId);
|
|
packetSource = nodeId;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add path bytes (repeaters)
|
|
// Pass packetSource for traffic pattern analysis (used to track which sources route through which repeaters)
|
|
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,
|
|
{
|
|
packetSource,
|
|
nextPrefix,
|
|
}
|
|
);
|
|
if (nodeId) path.push(nodeId);
|
|
}
|
|
|
|
// Add destination
|
|
if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) {
|
|
if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) {
|
|
path.push('self');
|
|
} else {
|
|
const nodeId = resolveNode(
|
|
{ type: 'prefix', value: parsed.dstHash },
|
|
false,
|
|
showAmbiguousNodes,
|
|
myPrefix
|
|
);
|
|
if (nodeId) path.push(nodeId);
|
|
else path.push('self');
|
|
}
|
|
} else if (path.length > 0) {
|
|
path.push('self');
|
|
}
|
|
|
|
// Ensure ends with self
|
|
if (path.length > 0 && path[path.length - 1] !== 'self') {
|
|
path.push('self');
|
|
}
|
|
|
|
return dedupeConsecutive(path);
|
|
},
|
|
[resolveNode, showAmbiguousPaths, showAmbiguousNodes]
|
|
);
|
|
|
|
// Process packets
|
|
useEffect(() => {
|
|
let newProcessed = 0;
|
|
let newAnimated = 0;
|
|
let needsUpdate = false;
|
|
const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null;
|
|
|
|
for (const packet of packets) {
|
|
if (processedRef.current.has(packet.id)) continue;
|
|
processedRef.current.add(packet.id);
|
|
newProcessed++;
|
|
|
|
// Limit processed set size
|
|
if (processedRef.current.size > 1000) {
|
|
processedRef.current = new Set(Array.from(processedRef.current).slice(-500));
|
|
}
|
|
|
|
const parsed = parsePacket(packet.data);
|
|
if (!parsed) continue;
|
|
|
|
const path = buildPath(parsed, packet, myPrefix);
|
|
if (path.length < 2) continue;
|
|
|
|
// Create links
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
if (path[i] !== path[i + 1]) {
|
|
addLink(path[i], path[i + 1]);
|
|
needsUpdate = true;
|
|
}
|
|
}
|
|
|
|
// Queue for animation
|
|
const packetKey = generatePacketKey(parsed, packet);
|
|
const now = Date.now();
|
|
const existing = pendingRef.current.get(packetKey);
|
|
|
|
if (existing && now < existing.expiresAt) {
|
|
existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now });
|
|
} else {
|
|
if (timersRef.current.has(packetKey)) {
|
|
clearTimeout(timersRef.current.get(packetKey));
|
|
}
|
|
const windowMs = observationWindowRef.current;
|
|
pendingRef.current.set(packetKey, {
|
|
key: packetKey,
|
|
label: getPacketLabel(parsed.payloadType),
|
|
paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }],
|
|
firstSeen: now,
|
|
expiresAt: now + windowMs,
|
|
});
|
|
timersRef.current.set(
|
|
packetKey,
|
|
setTimeout(() => publishPacket(packetKey), windowMs)
|
|
);
|
|
}
|
|
|
|
// Limit pending size
|
|
if (pendingRef.current.size > 100) {
|
|
const entries = Array.from(pendingRef.current.entries())
|
|
.sort((a, b) => a[1].firstSeen - b[1].firstSeen)
|
|
.slice(0, 50);
|
|
for (const [key] of entries) {
|
|
clearTimeout(timersRef.current.get(key));
|
|
timersRef.current.delete(key);
|
|
pendingRef.current.delete(key);
|
|
}
|
|
}
|
|
|
|
newAnimated++;
|
|
}
|
|
|
|
if (needsUpdate) syncSimulation();
|
|
if (newProcessed > 0) {
|
|
setStats((prev) => ({
|
|
...prev,
|
|
processed: prev.processed + newProcessed,
|
|
animated: prev.animated + newAnimated,
|
|
}));
|
|
}
|
|
}, [packets, config, buildPath, addLink, syncSimulation, publishPacket]);
|
|
|
|
// Randomize all node positions (except self) and reheat simulation
|
|
const randomizePositions = useCallback(() => {
|
|
const sim = simulationRef.current;
|
|
if (!sim) return;
|
|
|
|
const centerX = dimensions.width / 2;
|
|
const centerY = dimensions.height / 2;
|
|
const radius = Math.min(dimensions.width, dimensions.height) * 0.4;
|
|
|
|
for (const node of nodesRef.current.values()) {
|
|
if (node.id === 'self') {
|
|
// Keep self at center
|
|
node.x = centerX;
|
|
node.y = centerY;
|
|
} else {
|
|
// Randomize position in a circle around center
|
|
const angle = Math.random() * 2 * Math.PI;
|
|
const r = Math.random() * radius;
|
|
node.x = centerX + r * Math.cos(angle);
|
|
node.y = centerY + r * Math.sin(angle);
|
|
}
|
|
// Clear velocities
|
|
node.vx = 0;
|
|
node.vy = 0;
|
|
}
|
|
|
|
// Reheat simulation strongly
|
|
sim.alpha(1).restart();
|
|
}, [dimensions]);
|
|
|
|
// Expand to high repulsion, hold, then contract back
|
|
// Also weakens link force during expansion so nodes can actually separate
|
|
const expandContract = useCallback(() => {
|
|
const sim = simulationRef.current;
|
|
if (!sim) return;
|
|
|
|
const startChargeStrength = chargeStrength;
|
|
const peakChargeStrength = -5000;
|
|
const startLinkStrength = 0.3;
|
|
const minLinkStrength = 0.02; // Nearly disable links during expansion
|
|
const expandDuration = 1000;
|
|
const holdDuration = 2000;
|
|
const contractDuration = 1000;
|
|
const startTime = performance.now();
|
|
|
|
const animate = (now: number) => {
|
|
const elapsed = now - startTime;
|
|
let currentChargeStrength: number;
|
|
let currentLinkStrength: number;
|
|
|
|
if (elapsed < expandDuration) {
|
|
// Expanding: ramp up repulsion, weaken links
|
|
const t = elapsed / expandDuration;
|
|
currentChargeStrength =
|
|
startChargeStrength + (peakChargeStrength - startChargeStrength) * t;
|
|
currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t;
|
|
} else if (elapsed < expandDuration + holdDuration) {
|
|
// Hold: stay at peak repulsion, links weak
|
|
currentChargeStrength = peakChargeStrength;
|
|
currentLinkStrength = minLinkStrength;
|
|
} else if (elapsed < expandDuration + holdDuration + contractDuration) {
|
|
// Contracting: restore both forces
|
|
const t = (elapsed - expandDuration - holdDuration) / contractDuration;
|
|
currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t;
|
|
currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t;
|
|
} else {
|
|
// Done - restore originals
|
|
sim.force(
|
|
'charge',
|
|
forceManyBody<GraphNode>()
|
|
.strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength))
|
|
.distanceMax(500)
|
|
);
|
|
sim.force(
|
|
'link',
|
|
forceLink<GraphNode, GraphLink>(Array.from(linksRef.current.values()))
|
|
.id((d) => d.id)
|
|
.distance(80)
|
|
.strength(startLinkStrength)
|
|
);
|
|
sim.alpha(0.3).restart();
|
|
return;
|
|
}
|
|
|
|
// Apply current strengths
|
|
sim.force(
|
|
'charge',
|
|
forceManyBody<GraphNode>()
|
|
.strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength))
|
|
.distanceMax(500)
|
|
);
|
|
sim.force(
|
|
'link',
|
|
forceLink<GraphNode, GraphLink>(Array.from(linksRef.current.values()))
|
|
.id((d) => d.id)
|
|
.distance(80)
|
|
.strength(currentLinkStrength)
|
|
);
|
|
sim.alpha(0.5).restart();
|
|
|
|
requestAnimationFrame(animate);
|
|
};
|
|
|
|
requestAnimationFrame(animate);
|
|
}, [chargeStrength]);
|
|
|
|
// Clear all state and reset to initial (keeps self node only)
|
|
const clearAndReset = useCallback(() => {
|
|
// Clear all pending timers
|
|
for (const timer of timersRef.current.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
timersRef.current.clear();
|
|
|
|
// Clear pending packets
|
|
pendingRef.current.clear();
|
|
|
|
// Clear processed packet IDs so they can be re-processed if needed
|
|
processedRef.current.clear();
|
|
|
|
// Clear traffic patterns
|
|
trafficPatternsRef.current.clear();
|
|
|
|
// Clear particles
|
|
particlesRef.current.length = 0;
|
|
|
|
// Clear links
|
|
linksRef.current.clear();
|
|
|
|
// Clear nodes except self, then reset self position
|
|
const selfNode = nodesRef.current.get('self');
|
|
nodesRef.current.clear();
|
|
if (selfNode) {
|
|
selfNode.x = dimensions.width / 2;
|
|
selfNode.y = dimensions.height / 2;
|
|
selfNode.vx = 0;
|
|
selfNode.vy = 0;
|
|
selfNode.lastActivity = Date.now();
|
|
nodesRef.current.set('self', selfNode);
|
|
}
|
|
|
|
// Reset simulation with just self node
|
|
const sim = simulationRef.current;
|
|
if (sim) {
|
|
sim.nodes(Array.from(nodesRef.current.values()));
|
|
sim.force(
|
|
'link',
|
|
forceLink<GraphNode, GraphLink>([])
|
|
.id((d) => d.id)
|
|
.distance(80)
|
|
.strength(0.3)
|
|
);
|
|
sim.alpha(0.3).restart();
|
|
}
|
|
|
|
// Reset stats
|
|
setStats({ processed: 0, animated: 0, nodes: 1, links: 0 });
|
|
}, [dimensions]);
|
|
|
|
return {
|
|
nodes: nodesRef.current,
|
|
links: linksRef.current,
|
|
particles: particlesRef.current,
|
|
simulation: simulationRef.current,
|
|
stats,
|
|
randomizePositions,
|
|
expandContract,
|
|
clearAndReset,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// RENDERING FUNCTIONS
|
|
// =============================================================================
|
|
|
|
function renderLinks(
|
|
ctx: CanvasRenderingContext2D,
|
|
links: GraphLink[],
|
|
nodes: Map<string, GraphNode>
|
|
) {
|
|
ctx.strokeStyle = COLORS.link;
|
|
ctx.lineWidth = 2;
|
|
|
|
for (const link of links) {
|
|
const { sourceId, targetId } = getLinkId(link);
|
|
const source = nodes.get(sourceId);
|
|
const target = nodes.get(targetId);
|
|
|
|
if (source?.x != null && source?.y != null && target?.x != null && target?.y != null) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(source.x, source.y);
|
|
ctx.lineTo(target.x, target.y);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderParticles(
|
|
ctx: CanvasRenderingContext2D,
|
|
particles: Particle[],
|
|
nodes: Map<string, GraphNode>,
|
|
visibleNodeIds: Set<string>
|
|
): Particle[] {
|
|
const active: Particle[] = [];
|
|
|
|
for (const particle of particles) {
|
|
const fromNode = nodes.get(particle.fromNodeId);
|
|
const toNode = nodes.get(particle.toNodeId);
|
|
const isVisible =
|
|
visibleNodeIds.has(particle.fromNodeId) && visibleNodeIds.has(particle.toNodeId);
|
|
|
|
particle.progress += particle.speed;
|
|
|
|
if (particle.progress > 1) continue;
|
|
active.push(particle);
|
|
|
|
if (!isVisible || !fromNode?.x || !toNode?.x || fromNode.y == null || toNode.y == null)
|
|
continue;
|
|
if (particle.progress < 0) continue;
|
|
|
|
const t = particle.progress;
|
|
const x = fromNode.x + (toNode.x - fromNode.x) * t;
|
|
const y = fromNode.y + (toNode.y - fromNode.y) * t;
|
|
|
|
// Glow
|
|
ctx.fillStyle = particle.color + '40';
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 14, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Circle
|
|
ctx.fillStyle = particle.color;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Label
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.font = 'bold 8px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(particle.label, x, y);
|
|
}
|
|
|
|
return active;
|
|
}
|
|
|
|
function renderNodes(
|
|
ctx: CanvasRenderingContext2D,
|
|
nodes: GraphNode[],
|
|
hoveredNodeId: string | null
|
|
) {
|
|
for (const node of nodes) {
|
|
if (node.x == null || node.y == null) continue;
|
|
|
|
// Emoji
|
|
const emoji =
|
|
node.type === 'self'
|
|
? '🟢'
|
|
: node.type === 'repeater'
|
|
? '📡'
|
|
: node.isAmbiguous
|
|
? '❓'
|
|
: '👤';
|
|
const size = node.type === 'self' ? 36 : 18;
|
|
|
|
ctx.font = `${size}px sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(emoji, node.x, node.y);
|
|
|
|
// Label
|
|
const label = node.isAmbiguous
|
|
? node.id
|
|
: node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
|
ctx.font = '11px sans-serif';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillStyle = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb';
|
|
ctx.fillText(label, node.x, node.y + size / 2 + 4);
|
|
|
|
// Ambiguous names
|
|
if (node.isAmbiguous && node.ambiguousNames?.length) {
|
|
ctx.font = '9px sans-serif';
|
|
ctx.fillStyle = '#6b7280';
|
|
let yOffset = node.y + size / 2 + 18;
|
|
|
|
if (hoveredNodeId === node.id) {
|
|
for (const name of node.ambiguousNames) {
|
|
ctx.fillText(name, node.x, yOffset);
|
|
yOffset += 11;
|
|
}
|
|
} else if (node.ambiguousNames.length === 1) {
|
|
ctx.fillText(node.ambiguousNames[0], node.x, yOffset);
|
|
} else {
|
|
ctx.fillText(
|
|
`${node.ambiguousNames[0]} +${node.ambiguousNames.length - 1} more`,
|
|
node.x,
|
|
yOffset
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN COMPONENT
|
|
// =============================================================================
|
|
|
|
interface PacketVisualizerProps {
|
|
packets: RawPacket[];
|
|
contacts: Contact[];
|
|
config: RadioConfig | null;
|
|
fullScreen?: boolean;
|
|
onFullScreenChange?: (fullScreen: boolean) => void;
|
|
onClearPackets?: () => void;
|
|
}
|
|
|
|
export function PacketVisualizer({
|
|
packets,
|
|
contacts,
|
|
config,
|
|
fullScreen,
|
|
onFullScreenChange,
|
|
onClearPackets,
|
|
}: PacketVisualizerProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
|
|
|
// Options
|
|
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true);
|
|
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false);
|
|
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(false);
|
|
const [chargeStrength, setChargeStrength] = useState(-200);
|
|
const [filterOldRepeaters, setFilterOldRepeaters] = useState(false);
|
|
const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC);
|
|
const [letEmDrift, setLetEmDrift] = useState(true);
|
|
const [particleSpeedMultiplier, setParticleSpeedMultiplier] = useState(2);
|
|
const [hideUI, setHideUI] = useState(false);
|
|
|
|
// Pan/zoom
|
|
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
|
const isDraggingRef = useRef(false);
|
|
const lastMouseRef = useRef({ x: 0, y: 0 });
|
|
const draggedNodeRef = useRef<GraphNode | null>(null);
|
|
|
|
// Hover
|
|
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
|
|
|
// Data layer
|
|
const data = useVisualizerData({
|
|
packets,
|
|
contacts,
|
|
config,
|
|
showAmbiguousPaths,
|
|
showAmbiguousNodes,
|
|
splitAmbiguousByTraffic,
|
|
chargeStrength,
|
|
letEmDrift,
|
|
particleSpeedMultiplier,
|
|
observationWindowSec,
|
|
dimensions,
|
|
});
|
|
|
|
// Track dimensions
|
|
useEffect(() => {
|
|
const update = () => {
|
|
if (containerRef.current) {
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
setDimensions({ width: rect.width, height: rect.height });
|
|
}
|
|
};
|
|
update();
|
|
const observer = new ResizeObserver(update);
|
|
if (containerRef.current) observer.observe(containerRef.current);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// Render
|
|
const render = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas?.getContext('2d');
|
|
if (!canvas || !ctx) return;
|
|
|
|
const { width, height } = dimensions;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
canvas.width = width * dpr;
|
|
canvas.height = height * dpr;
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
ctx.fillStyle = COLORS.background;
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
ctx.save();
|
|
ctx.translate(width / 2, height / 2);
|
|
ctx.scale(transform.scale, transform.scale);
|
|
ctx.translate(transform.x - width / 2, transform.y - height / 2);
|
|
|
|
const now = Date.now();
|
|
const allNodes = Array.from(data.nodes.values());
|
|
const visibleNodeIds = new Set<string>();
|
|
|
|
// Filter nodes
|
|
const visibleNodes = allNodes.filter((node) => {
|
|
if (node.type === 'self' || node.type === 'client') {
|
|
visibleNodeIds.add(node.id);
|
|
return true;
|
|
}
|
|
if (filterOldRepeaters && node.type === 'repeater') {
|
|
const lastTime = node.lastSeen ? node.lastSeen * 1000 : node.lastActivity;
|
|
if (now - lastTime > FORTY_EIGHT_HOURS_MS) return false;
|
|
}
|
|
visibleNodeIds.add(node.id);
|
|
return true;
|
|
});
|
|
|
|
// Filter links
|
|
const allLinks = Array.from(data.links.values());
|
|
const visibleLinks = allLinks.filter((link) => {
|
|
const { sourceId, targetId } = getLinkId(link);
|
|
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
|
|
});
|
|
|
|
renderLinks(ctx, visibleLinks, data.nodes);
|
|
data.particles.splice(
|
|
0,
|
|
data.particles.length,
|
|
...renderParticles(ctx, data.particles, data.nodes, visibleNodeIds)
|
|
);
|
|
renderNodes(ctx, visibleNodes, hoveredNodeId);
|
|
|
|
ctx.restore();
|
|
}, [dimensions, transform, data, hoveredNodeId, filterOldRepeaters]);
|
|
|
|
// Animation loop
|
|
useEffect(() => {
|
|
let running = true;
|
|
const animate = () => {
|
|
if (!running) return;
|
|
render();
|
|
requestAnimationFrame(animate);
|
|
};
|
|
animate();
|
|
return () => {
|
|
running = false;
|
|
};
|
|
}, [render]);
|
|
|
|
// Mouse handlers
|
|
const screenToGraph = useCallback(
|
|
(screenX: number, screenY: number) => {
|
|
const { width, height } = dimensions;
|
|
const cx = (screenX - width / 2) / transform.scale - transform.x + width / 2;
|
|
const cy = (screenY - height / 2) / transform.scale - transform.y + height / 2;
|
|
return { x: cx, y: cy };
|
|
},
|
|
[dimensions, transform]
|
|
);
|
|
|
|
const findNodeAt = useCallback(
|
|
(gx: number, gy: number) => {
|
|
for (const node of data.nodes.values()) {
|
|
if (node.x == null || node.y == null) continue;
|
|
if (Math.hypot(gx - node.x, gy - node.y) < 20) return node;
|
|
}
|
|
return null;
|
|
},
|
|
[data.nodes]
|
|
);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const pos = screenToGraph(e.clientX - rect.left, e.clientY - rect.top);
|
|
const node = findNodeAt(pos.x, pos.y);
|
|
|
|
if (node) {
|
|
// Start dragging this node
|
|
draggedNodeRef.current = node;
|
|
// Fix the node's position while dragging
|
|
node.fx = node.x;
|
|
node.fy = node.y;
|
|
// Reheat simulation slightly for responsive feedback
|
|
data.simulation?.alpha(0.3).restart();
|
|
} else {
|
|
// Start panning
|
|
isDraggingRef.current = true;
|
|
}
|
|
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
|
},
|
|
[screenToGraph, findNodeAt, data.simulation]
|
|
);
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const pos = screenToGraph(e.clientX - rect.left, e.clientY - rect.top);
|
|
|
|
// Update hover state
|
|
setHoveredNodeId(findNodeAt(pos.x, pos.y)?.id || null);
|
|
|
|
// Handle node dragging
|
|
if (draggedNodeRef.current) {
|
|
draggedNodeRef.current.fx = pos.x;
|
|
draggedNodeRef.current.fy = pos.y;
|
|
return;
|
|
}
|
|
|
|
// Handle canvas panning
|
|
if (!isDraggingRef.current) return;
|
|
const dx = e.clientX - lastMouseRef.current.x;
|
|
const dy = e.clientY - lastMouseRef.current.y;
|
|
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
|
setTransform((t) => ({ ...t, x: t.x + dx / t.scale, y: t.y + dy / t.scale }));
|
|
},
|
|
[screenToGraph, findNodeAt]
|
|
);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
if (draggedNodeRef.current) {
|
|
// Release the node - clear fixed position so it can move freely again
|
|
draggedNodeRef.current.fx = null;
|
|
draggedNodeRef.current.fy = null;
|
|
draggedNodeRef.current = null;
|
|
}
|
|
isDraggingRef.current = false;
|
|
}, []);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
if (draggedNodeRef.current) {
|
|
draggedNodeRef.current.fx = null;
|
|
draggedNodeRef.current.fy = null;
|
|
draggedNodeRef.current = null;
|
|
}
|
|
isDraggingRef.current = false;
|
|
setHoveredNodeId(null);
|
|
}, []);
|
|
|
|
const handleWheel = useCallback((e: WheelEvent) => {
|
|
e.preventDefault();
|
|
const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1;
|
|
setTransform((t) => ({ ...t, scale: Math.min(Math.max(t.scale * factor, 0.1), 5) }));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
|
return () => canvas.removeEventListener('wheel', handleWheel);
|
|
}, [handleWheel]);
|
|
|
|
// Determine cursor based on state
|
|
const getCursor = () => {
|
|
if (draggedNodeRef.current) return 'grabbing';
|
|
if (hoveredNodeId) return 'pointer';
|
|
return 'grab';
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="w-full h-full bg-background relative overflow-hidden">
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-full"
|
|
style={{ display: 'block', cursor: getCursor() }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
/>
|
|
|
|
{/* Legend */}
|
|
{!hideUI && (
|
|
<div className="absolute bottom-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border">
|
|
<div className="flex gap-6">
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="text-muted-foreground font-medium mb-1">Packets</div>
|
|
{PACKET_LEGEND_ITEMS.map((item) => (
|
|
<div key={item.label} className="flex items-center gap-2">
|
|
<div
|
|
className="w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold text-white"
|
|
style={{ backgroundColor: item.color }}
|
|
>
|
|
{item.label}
|
|
</div>
|
|
<span>{item.description}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="text-muted-foreground font-medium mb-1">Nodes</div>
|
|
{LEGEND_ITEMS.map((item) => (
|
|
<div key={item.label} className="flex items-center gap-2">
|
|
<span className={item.size}>{item.emoji}</span>
|
|
<span>{item.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Options */}
|
|
<div className="absolute top-4 right-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border">
|
|
<div className="flex flex-col gap-2">
|
|
{!hideUI && (
|
|
<>
|
|
<div>Nodes: {data.stats.nodes}</div>
|
|
<div>Links: {data.stats.links}</div>
|
|
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<Checkbox
|
|
checked={showAmbiguousPaths}
|
|
onCheckedChange={(c) => setShowAmbiguousPaths(c === true)}
|
|
/>
|
|
<span title="Show placeholder nodes for repeaters when the 1-byte prefix matches multiple contacts">
|
|
Ambiguous repeaters
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<Checkbox
|
|
checked={showAmbiguousNodes}
|
|
onCheckedChange={(c) => setShowAmbiguousNodes(c === true)}
|
|
/>
|
|
<span title="Show placeholder nodes for senders/recipients when only a 1-byte prefix is known">
|
|
Ambiguous sender/recipient
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<Checkbox
|
|
checked={splitAmbiguousByTraffic}
|
|
onCheckedChange={(c) => setSplitAmbiguousByTraffic(c === true)}
|
|
disabled={!showAmbiguousPaths}
|
|
/>
|
|
<span
|
|
title="Split ambiguous repeaters into separate nodes based on traffic patterns (prev→next). Helps identify colliding prefixes representing different physical nodes."
|
|
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
|
>
|
|
Hueristically group repeaters by traffic pattern
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<Checkbox
|
|
checked={filterOldRepeaters}
|
|
onCheckedChange={(c) => setFilterOldRepeaters(c === true)}
|
|
/>
|
|
<span title="Hide repeaters not heard within the last 48 hours">
|
|
Hide repeaters >48hrs heard
|
|
</span>
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<label
|
|
className="text-muted-foreground"
|
|
title="How long to wait for duplicate packets via different paths before animating"
|
|
>
|
|
Observation window:
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="60"
|
|
value={observationWindowSec}
|
|
onChange={(e) =>
|
|
setObservationWindowSec(
|
|
Math.max(1, Math.min(60, parseInt(e.target.value) || 1))
|
|
)
|
|
}
|
|
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
|
|
/>
|
|
<span className="text-muted-foreground">sec</span>
|
|
</div>
|
|
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<Checkbox
|
|
checked={letEmDrift}
|
|
onCheckedChange={(c) => setLetEmDrift(c === true)}
|
|
/>
|
|
<span title="When enabled, the graph continuously reorganizes itself into a better layout">
|
|
Let 'em drift
|
|
</span>
|
|
</label>
|
|
<div className="flex flex-col gap-1 mt-1">
|
|
<label
|
|
className="text-muted-foreground"
|
|
title="How strongly nodes repel each other. Higher values spread nodes out more."
|
|
>
|
|
Repulsion: {Math.abs(chargeStrength)}
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="50"
|
|
max="2500"
|
|
value={Math.abs(chargeStrength)}
|
|
onChange={(e) => setChargeStrength(-parseInt(e.target.value))}
|
|
className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1 mt-1">
|
|
<label
|
|
className="text-muted-foreground"
|
|
title="How fast particles travel along links. Higher values make packets move faster."
|
|
>
|
|
Packet speed: {particleSpeedMultiplier}x
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="5"
|
|
step="0.5"
|
|
value={particleSpeedMultiplier}
|
|
onChange={(e) => setParticleSpeedMultiplier(parseFloat(e.target.value))}
|
|
className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={data.randomizePositions}
|
|
className="mt-2 px-3 py-1.5 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors"
|
|
title="Randomize node positions and let the simulation settle into a new layout"
|
|
>
|
|
Shuffle layout
|
|
</button>
|
|
<button
|
|
onClick={data.expandContract}
|
|
className="mt-1 px-3 py-1.5 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors"
|
|
title="Expand nodes apart then contract back - can help untangle the graph"
|
|
>
|
|
Oooh Big Stretch!
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
data.clearAndReset();
|
|
onClearPackets?.();
|
|
}}
|
|
className="mt-1 px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-500 rounded text-xs transition-colors"
|
|
title="Clear all nodes, links, and packets - reset to initial state"
|
|
>
|
|
Clear & Reset
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className={hideUI ? '' : 'border-t border-border pt-2 mt-1 flex flex-col gap-2'}>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<Checkbox checked={hideUI} onCheckedChange={(c) => setHideUI(c === true)} />
|
|
<span title="Hide legends and controls for a cleaner view">Hide UI</span>
|
|
</label>
|
|
{onFullScreenChange && (
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<Checkbox
|
|
checked={fullScreen}
|
|
onCheckedChange={(c) => onFullScreenChange(c === true)}
|
|
/>
|
|
<span title="Hide the packet feed panel">Full screen</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|