"use client"; import React, { useState, useMemo, useCallback } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; import Tree from 'react-d3-tree'; import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24/outline"; import PathDisplay from "./PathDisplay"; import { useMeshcoreSearches } from "@/hooks/useMeshcoreSearch"; import type { MeshcoreSearchResult } from "@/hooks/useMeshcoreSearch"; import { useConfigWithRegion } from "@/hooks/useConfigWithRegion"; export interface PathData { origin: string; pubkey: string; path: string; } interface PathGroup { path: string; pathSlices: string[]; indices: number[]; } interface TreeNode { name: string; children?: TreeNode[]; } interface PathVisualizationProps { paths: PathData[]; title?: string; className?: string; showDropdown?: boolean; initiatingNodeKey?: string; } export default function PathVisualization({ paths, title = "Paths", className = "", showDropdown = true, initiatingNodeKey }: PathVisualizationProps) { const [expanded, setExpanded] = useState(false); const [showGraph, setShowGraph] = useState(false); const [graphFullscreen, setGraphFullscreen] = useState(false); const { config } = useConfigWithRegion(); const pathsCount = paths.length; // Process data for tree visualization const treeData = useMemo(() => { if (!showGraph || pathsCount === 0) return null; // Group messages by path similarity const pathGroups: PathGroup[] = []; paths.forEach(({ origin, pubkey, path }, index) => { // Parse path into 2-character slices and include pubkey as final hop const pathSlices = path.match(/.{1,2}/g) || []; const pubkeyPrefix = pubkey.substring(0, 2); const fullPathSlices = [...pathSlices, pubkeyPrefix]; // Find existing group with same path structure const existingGroup = pathGroups.find(group => group.pathSlices.length === fullPathSlices.length && group.pathSlices.every((slice, i) => slice === fullPathSlices[i]) ); if (existingGroup) { existingGroup.indices.push(index); } else { pathGroups.push({ path: path + pubkeyPrefix, pathSlices: fullPathSlices, indices: [index] }); } }); // Build tree structure for react-d3-tree const buildTree = (): TreeNode => { const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??"; const root: TreeNode = { name: rootName, children: [] }; pathGroups.forEach(group => { let currentNode = root; group.pathSlices.forEach((slice, level) => { let child = currentNode.children?.find(c => c.name === slice); if (!child) { child = { name: slice, children: [] }; if (!currentNode.children) currentNode.children = []; currentNode.children.push(child); } currentNode = child; }); }); return root; }; return buildTree(); }, [showGraph, paths, pathsCount, initiatingNodeKey]); // Extract unique prefixes from tree data for name lookups const uniquePrefixes = useMemo(() => { if (!treeData) return []; const prefixes = new Set(); const extractPrefixes = (node: TreeNode) => { prefixes.add(node.name); node.children?.forEach(extractPrefixes); }; extractPrefixes(treeData); return Array.from(prefixes); }, [treeData]); // Use the new useMeshcoreSearches hook to handle multiple prefix searches // Filter out "??" prefix and only search for valid hex prefixes const searches = useMemo(() => uniquePrefixes .filter(prefix => prefix !== "??") // Don't search for placeholder prefix .map(prefix => ({ query: prefix, exact: false, limit: 20, is_repeater: true, // Filter for repeaters only lastSeen: 60*60*24*2, // 2 days region: config?.selectedRegion, enabled: showGraph && prefix.length > 0 })) , [uniquePrefixes, showGraph, config?.lastSeen, config?.selectedRegion]); const searchResults = useMeshcoreSearches({ searches }); // Create mapping from prefix to node data (name + public key) const prefixToNodes = useMemo(() => { const mapping = new Map>(); // Create searchable prefixes (excluding "??") const searchablePrefixes = uniquePrefixes.filter(prefix => prefix !== "??"); searchablePrefixes.forEach((prefix, index) => { const searchResult = searchResults[index]; if (searchResult?.data?.results) { const matchingNodes = searchResult.data.results .filter(result => result.public_key.toLowerCase().startsWith(prefix.toLowerCase()) && result.node_name) .map(result => ({ name: result.node_name, publicKey: result.public_key })) .filter(node => node.name.length > 0); if (matchingNodes.length > 0) { mapping.set(prefix, matchingNodes); } } }); return mapping; }, [searchResults, uniquePrefixes]); // Fixed node spacing optimized for ~3 lines of text const fixedNodeSize = useMemo(() => ({ x: 140, y: 100 }), []); const handleToggle = useCallback(() => { setExpanded(prev => !prev); }, []); const handleGraphToggle = useCallback(() => { setShowGraph(prev => !prev); }, []); const handleFullscreenToggle = useCallback(() => { setGraphFullscreen(prev => !prev); }, []); const PathsList = useCallback(() => (
{paths.map(({ origin, pubkey, path }, index) => (
{origin}
))}
), [paths]); // Memoize the render function to prevent unnecessary re-renders const renderCustomNodeElement = useCallback(({ nodeDatum, toggleNode }: any) => { const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??"; const isRoot = nodeDatum.name === rootName; // Check if this node represents an origin pubkey (final 2-char hex from pubkey) const isOriginPubkey = paths.some(({ pubkey }) => { const pubkeyPrefix = pubkey.substring(0, 2); return nodeDatum.name === pubkeyPrefix; }); // Get node data for this prefix const nodeData = prefixToNodes.get(nodeDatum.name) || []; const isResolved = nodeData.length > 0; return ( {/* Use same circle style for all nodes */} {/* Hex prefix inside circle */} {nodeDatum.name} {/* Show all node names below circle for resolved prefixes - clickable */} {isResolved && nodeData.map((node, index) => { // Calculate dynamic width based on text length (approximate 6px per character + padding) const estimatedWidth = Math.max(60, node.name.length * 8 + 20); return ( {node.name} ); })} ); }, [initiatingNodeKey, paths, prefixToNodes]); const GraphView = useCallback(() => { if (!showGraph || pathsCount === 0 || !treeData) return null; const renderTree = () => ( ); if (graphFullscreen && typeof window !== 'undefined') { return createPortal(

Path Visualization

{renderTree()}
, document.body ); } return (
Path Graph
{renderTree()}
); }, [showGraph, pathsCount, treeData, graphFullscreen, handleFullscreenToggle, renderCustomNodeElement, fixedNodeSize]); if (!showDropdown) { return (
{pathsCount} path{pathsCount !== 1 ? 's' : ''} {pathsCount > 0 && ( )}
{showGraph && }
); } return (
{pathsCount > 0 && ( )}
{expanded && pathsCount > 0 && ( )}
); }