You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1382 lines
40 KiB
1382 lines
40 KiB
<!-- |
|
EventNetwork Component |
|
|
|
A force-directed graph visualization of Nostr events, showing the relationships |
|
between index events and their content. This component handles the D3 force |
|
simulation, SVG rendering, and user interactions. |
|
--> |
|
<script lang="ts"> |
|
import { onMount } from "svelte"; |
|
import * as d3 from "d3"; |
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
import { levelsToRender } from "$lib/state"; |
|
import { generateGraph, getEventColor } from "./utils/networkBuilder"; |
|
import { getEventKindColor } from "$lib/utils/eventColors"; |
|
import { |
|
generateStarGraph, |
|
applyStarLayout, |
|
} from "./utils/starNetworkBuilder"; |
|
import { |
|
createStarSimulation, |
|
applyInitialStarPositions, |
|
createStarDragHandler, |
|
} from "./utils/starForceSimulation"; |
|
import { |
|
createSimulation, |
|
setupDragHandlers, |
|
applyGlobalLogGravity, |
|
applyConnectedGravity, |
|
type Simulation, |
|
} from "./utils/forceSimulation"; |
|
import Legend from "./Legend.svelte"; |
|
import NodeTooltip from "./NodeTooltip.svelte"; |
|
import type { NetworkNode, NetworkLink } from "./types"; |
|
import Settings from "./Settings.svelte"; |
|
import { |
|
enhanceGraphWithTags, |
|
getTagAnchorColor, |
|
} from "./utils/tagNetworkBuilder"; |
|
import { |
|
extractUniquePersons, |
|
createPersonAnchorNodes, |
|
createPersonLinks, |
|
extractPersonAnchorInfo, |
|
} from "./utils/personNetworkBuilder"; |
|
import { Button } from "flowbite-svelte"; |
|
import { visualizationConfig } from "$lib/stores/visualizationConfig"; |
|
import { get } from "svelte/store"; |
|
import type { EventCounts } from "$lib/types"; |
|
|
|
// Type alias for D3 selections |
|
type Selection = any; |
|
|
|
// Configuration |
|
const DEBUG = false; // Set to true to enable debug logging |
|
const NODE_RADIUS = 20; |
|
const LINK_DISTANCE = 10; |
|
const ARROW_DISTANCE = 10; |
|
const CONTENT_COLOR_LIGHT = "#d6c1a8"; |
|
const CONTENT_COLOR_DARK = "#FFFFFF"; |
|
|
|
/** |
|
* Debug logging function that only logs when DEBUG is true |
|
*/ |
|
function debug(...args: any[]) { |
|
if (DEBUG) { |
|
console.log("[EventNetwork]", ...args); |
|
} |
|
} |
|
|
|
// Component props |
|
let { |
|
events = [], |
|
followListEvents = [], |
|
totalCount = 0, |
|
onupdate, |
|
onclear = () => {}, |
|
onTagExpansionChange, |
|
profileStats = { totalFetched: 0, displayLimit: 50 }, |
|
allEventCounts = {} |
|
} = $props<{ |
|
events?: NDKEvent[]; |
|
followListEvents?: NDKEvent[]; |
|
totalCount?: number; |
|
onupdate: () => void; |
|
onclear?: () => void; |
|
onTagExpansionChange?: (tags: string[]) => void; |
|
profileStats?: { totalFetched: number; displayLimit: number }; |
|
allEventCounts?: EventCounts; |
|
}>(); |
|
|
|
// Error state |
|
let errorMessage = $state<string | null>(null); |
|
let hasError = $derived(!!errorMessage); |
|
|
|
// DOM references |
|
let svg: SVGSVGElement; |
|
let container: HTMLDivElement; |
|
|
|
// Theme state |
|
let isDarkMode = $state(false); |
|
|
|
// Tooltip state |
|
let selectedNodeId = $state<string | null>(null); |
|
let tooltipVisible = $state(false); |
|
let tooltipX = $state(0); |
|
let tooltipY = $state(0); |
|
let tooltipNode = $state<NetworkNode | null>(null); |
|
|
|
// Dimensions |
|
let width = $state(1000); |
|
let height = $state(600); |
|
let windowHeight = $state<number | undefined>(undefined); |
|
let graphHeight = $derived( |
|
windowHeight ? Math.max(windowHeight * 0.2, 400) : 400, |
|
); |
|
|
|
// D3 objects |
|
let simulation: Simulation<NetworkNode, NetworkLink> | null = null; |
|
let svgGroup: Selection; |
|
let zoomBehavior: any; |
|
let svgElement: Selection; |
|
|
|
// Position cache to preserve node positions across updates |
|
let nodePositions = new Map<string, { x: number; y: number; vx?: number; vy?: number }>(); |
|
|
|
// Track current render level |
|
let currentLevels = $derived(levelsToRender); |
|
|
|
// Star visualization state (default to true) |
|
let starVisualization = $state(true); |
|
|
|
// Tag anchors state |
|
let showTagAnchors = $state(false); |
|
let selectedTagType = $state("t"); // Default to hashtags |
|
let tagAnchorInfo = $state<any[]>([]); |
|
|
|
// Store initial state to detect if component is being recreated |
|
let componentId = Math.random(); |
|
debug("Component created with ID:", componentId); |
|
|
|
// Event counts by kind - derived from events |
|
let eventCounts = $derived.by(() => { |
|
const counts: { [kind: number]: number } = {}; |
|
events.forEach((event: NDKEvent) => { |
|
if (event.kind !== undefined) { |
|
counts[event.kind] = (counts[event.kind] || 0) + 1; |
|
} |
|
}); |
|
return counts; |
|
}); |
|
|
|
// Disabled tags state for interactive legend |
|
let disabledTags = $state(new Set<string>()); |
|
|
|
// Track if we've auto-disabled tags |
|
let autoDisabledTags = $state(false); |
|
|
|
// Maximum number of tag anchors before auto-disabling |
|
const MAX_TAG_ANCHORS = 20; |
|
|
|
// Person nodes state |
|
let showPersonNodes = $state(false); |
|
let personAnchorInfo = $state<any[]>([]); |
|
let disabledPersons = $state(new Set<string>()); |
|
let showSignedBy = $state(true); |
|
let showReferenced = $state(true); |
|
let personMap = $state<Map<string, any>>(new Map()); |
|
let totalPersonCount = $state(0); |
|
let displayedPersonCount = $state(0); |
|
let hasInitializedPersons = $state(false); |
|
|
|
|
|
// Update dimensions when container changes |
|
$effect(() => { |
|
if (container) { |
|
width = container.clientWidth || width; |
|
height = container.clientHeight || height; |
|
} |
|
}); |
|
|
|
/** |
|
* Initializes the SVG graph structure |
|
* Sets up the SVG element, zoom behavior, and arrow marker |
|
*/ |
|
function initializeGraph() { |
|
debug("Initializing graph"); |
|
if (!svg) { |
|
debug("SVG element not found"); |
|
return; |
|
} |
|
|
|
debug("SVG dimensions", { width, height }); |
|
const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`); |
|
|
|
// Clear existing content |
|
svgElement.selectAll("*").remove(); |
|
debug("Cleared SVG content"); |
|
|
|
// Create main group for zoom |
|
svgGroup = svgElement.append("g"); |
|
debug("Created SVG group"); |
|
|
|
// Set up zoom behavior |
|
zoomBehavior = d3 |
|
.zoom() |
|
.scaleExtent([0.1, 9]) // Min/max zoom levels |
|
.on("zoom", (event: any) => { |
|
svgGroup.attr("transform", event.transform); |
|
}); |
|
|
|
svgElement.call(zoomBehavior); |
|
|
|
// Set up arrow marker for links |
|
const defs = svgElement.append("defs"); |
|
defs |
|
.append("marker") |
|
.attr("id", "arrowhead") |
|
.attr("markerUnits", "strokeWidth") |
|
.attr("viewBox", "-10 -5 10 10") |
|
.attr("refX", 0) |
|
.attr("refY", 0) |
|
.attr("markerWidth", 5) |
|
.attr("markerHeight", 5) |
|
.attr("orient", "auto") |
|
.append("path") |
|
.attr("d", "M -10 -5 L 0 0 L -10 5 z") |
|
.attr("class", "network-link-leather") |
|
.attr("fill", "none") |
|
.attr("stroke-width", 1); |
|
} |
|
|
|
|
|
/** |
|
* Validates that required elements are available for graph rendering |
|
*/ |
|
function validateGraphElements() { |
|
if (!svg) { |
|
throw new Error("SVG element not found"); |
|
} |
|
|
|
if (!events?.length) { |
|
throw new Error("No events to render"); |
|
} |
|
|
|
if (!svgGroup) { |
|
throw new Error("SVG group not found"); |
|
} |
|
} |
|
|
|
/** |
|
* Generates graph data from events, including tag and person anchors |
|
*/ |
|
function generateGraphData() { |
|
debug("Generating graph with events", { |
|
eventCount: events.length, |
|
currentLevels, |
|
starVisualization, |
|
showTagAnchors, |
|
}); |
|
|
|
let graphData = starVisualization |
|
? generateStarGraph(events, Number(currentLevels)) |
|
: generateGraph(events, Number(currentLevels)); |
|
|
|
// Enhance with tag anchors if enabled |
|
if (showTagAnchors) { |
|
debug("Enhancing graph with tags", { |
|
selectedTagType, |
|
eventCount: events.length, |
|
width, |
|
height |
|
}); |
|
|
|
// Get the display limit based on tag type |
|
let displayLimit: number | undefined; |
|
|
|
graphData = enhanceGraphWithTags( |
|
graphData, |
|
events, |
|
selectedTagType, |
|
width, |
|
height, |
|
displayLimit, |
|
); |
|
|
|
// Extract tag anchor info for legend |
|
const tagAnchors = graphData.nodes.filter((n) => n.isTagAnchor); |
|
|
|
debug("Tag anchors created", { |
|
count: tagAnchors.length, |
|
anchors: tagAnchors |
|
}); |
|
|
|
tagAnchorInfo = tagAnchors.map((n) => ({ |
|
type: n.tagType, |
|
label: n.title, |
|
count: n.connectedNodes?.length || 0, |
|
color: getTagAnchorColor(n.tagType || ""), |
|
})); |
|
} else { |
|
tagAnchorInfo = []; |
|
} |
|
|
|
// Add person nodes if enabled |
|
if (showPersonNodes) { |
|
debug("Creating person anchor nodes"); |
|
|
|
// Extract unique persons from events and follow lists |
|
personMap = extractUniquePersons(events, followListEvents); |
|
|
|
// Create person anchor nodes based on filters |
|
const personResult = createPersonAnchorNodes( |
|
personMap, |
|
width, |
|
height, |
|
showSignedBy, |
|
showReferenced |
|
); |
|
|
|
const personAnchors = personResult.nodes; |
|
totalPersonCount = personResult.totalCount; |
|
displayedPersonCount = personAnchors.length; |
|
|
|
// Create links between person anchors and their events |
|
const personLinks = createPersonLinks(personAnchors, graphData.nodes, personMap); |
|
|
|
// Add person anchors to the graph |
|
graphData.nodes = [...graphData.nodes, ...personAnchors]; |
|
graphData.links = [...graphData.links, ...personLinks]; |
|
|
|
// Extract person info for legend |
|
personAnchorInfo = extractPersonAnchorInfo(personAnchors, personMap); |
|
|
|
// Auto-disable all person nodes by default (only on first time showing) |
|
if (!hasInitializedPersons && personAnchors.length > 0) { |
|
personAnchors.forEach(anchor => { |
|
if (anchor.pubkey) { |
|
disabledPersons.add(anchor.pubkey); |
|
} |
|
}); |
|
hasInitializedPersons = true; |
|
} |
|
|
|
debug("Person anchors created", { |
|
count: personAnchors.length, |
|
disabled: disabledPersons.size, |
|
showSignedBy, |
|
showReferenced |
|
}); |
|
} else { |
|
personAnchorInfo = []; |
|
// Reset initialization flag when person nodes are hidden |
|
if (hasInitializedPersons && personAnchorInfo.length === 0) { |
|
hasInitializedPersons = false; |
|
disabledPersons.clear(); |
|
} |
|
} |
|
|
|
return graphData; |
|
} |
|
|
|
/** |
|
* Filters nodes and links based on disabled tags and persons |
|
*/ |
|
function filterNodesAndLinks(graphData: { nodes: NetworkNode[]; links: NetworkLink[] }) { |
|
let nodes = graphData.nodes; |
|
let links = graphData.links; |
|
|
|
// Filter out disabled tag anchors and person nodes from nodes and links |
|
if ((showTagAnchors && disabledTags.size > 0) || (showPersonNodes && disabledPersons.size > 0)) { |
|
// Filter out disabled nodes |
|
nodes = nodes.filter((node: NetworkNode) => { |
|
if (node.isTagAnchor) { |
|
const tagId = `${node.tagType}-${node.title}`; |
|
return !disabledTags.has(tagId); |
|
} |
|
if (node.isPersonAnchor && node.pubkey) { |
|
return !disabledPersons.has(node.pubkey); |
|
} |
|
return true; |
|
}); |
|
|
|
// Filter out links to disabled nodes |
|
links = links.filter((link: NetworkLink) => { |
|
const source = link.source as NetworkNode; |
|
const target = link.target as NetworkNode; |
|
|
|
// Check if either node is disabled |
|
if (source.isTagAnchor) { |
|
const tagId = `${source.tagType}-${source.title}`; |
|
if (disabledTags.has(tagId)) return false; |
|
} |
|
if (target.isTagAnchor) { |
|
const tagId = `${target.tagType}-${target.title}`; |
|
if (disabledTags.has(tagId)) return false; |
|
} |
|
if (source.isPersonAnchor && source.pubkey) { |
|
if (disabledPersons.has(source.pubkey)) return false; |
|
} |
|
if (target.isPersonAnchor && target.pubkey) { |
|
if (disabledPersons.has(target.pubkey)) return false; |
|
} |
|
|
|
return true; |
|
}); |
|
|
|
debug("Filtered links for disabled tags", { |
|
originalCount: graphData.links.length, |
|
filteredCount: links.length, |
|
disabledTags: Array.from(disabledTags) |
|
}); |
|
} |
|
|
|
return { nodes, links }; |
|
} |
|
|
|
/** |
|
* Saves current node positions to preserve them across updates |
|
*/ |
|
function saveNodePositions(nodes: NetworkNode[]) { |
|
if (simulation && nodes.length > 0) { |
|
nodes.forEach(node => { |
|
if (node.x != null && node.y != null) { |
|
nodePositions.set(node.id, { |
|
x: node.x, |
|
y: node.y, |
|
vx: node.vx, |
|
vy: node.vy |
|
}); |
|
} |
|
}); |
|
debug("Saved positions for", nodePositions.size, "nodes"); |
|
} |
|
} |
|
|
|
/** |
|
* Restores node positions from cache and initializes new nodes |
|
*/ |
|
function restoreNodePositions(nodes: NetworkNode[]): number { |
|
let restoredCount = 0; |
|
nodes.forEach(node => { |
|
const savedPos = nodePositions.get(node.id); |
|
if (savedPos && !node.isTagAnchor) { // Don't restore tag anchor positions as they're fixed |
|
node.x = savedPos.x; |
|
node.y = savedPos.y; |
|
node.vx = savedPos.vx || 0; |
|
node.vy = savedPos.vy || 0; |
|
restoredCount++; |
|
} else if (!node.x && !node.y && !node.isTagAnchor && !node.isPersonAnchor) { |
|
// Give disconnected nodes (like kind 0) random initial positions |
|
node.x = width / 2 + (Math.random() - 0.5) * width * 0.5; |
|
node.y = height / 2 + (Math.random() - 0.5) * height * 0.5; |
|
node.vx = 0; |
|
node.vy = 0; |
|
} |
|
}); |
|
return restoredCount; |
|
} |
|
|
|
/** |
|
* Sets up the D3 force simulation and drag handlers |
|
*/ |
|
function setupSimulation(nodes: NetworkNode[], links: NetworkLink[], restoredCount: number) { |
|
// Stop any existing simulation |
|
if (simulation) { |
|
debug("Stopping existing simulation"); |
|
simulation.stop(); |
|
} |
|
|
|
// Create new simulation |
|
debug("Creating new simulation"); |
|
const hasRestoredPositions = restoredCount > 0; |
|
let newSimulation: Simulation<NetworkNode, NetworkLink>; |
|
|
|
if (starVisualization) { |
|
// Use star-specific simulation |
|
newSimulation = createStarSimulation(nodes, links, width, height); |
|
// Apply initial star positioning only if we don't have restored positions |
|
if (!hasRestoredPositions) { |
|
applyInitialStarPositions(nodes, links, width, height); |
|
} |
|
} else { |
|
// Use regular simulation |
|
newSimulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE); |
|
|
|
// Add center force for disconnected nodes (like kind 0) |
|
newSimulation.force("center", d3.forceCenter(width / 2, height / 2).strength(0.05)); |
|
|
|
// Add radial force to keep disconnected nodes in view |
|
newSimulation.force("radial", d3.forceRadial(Math.min(width, height) / 3, width / 2, height / 2) |
|
.strength((d: NetworkNode) => { |
|
// Apply radial force only to nodes without links (disconnected nodes) |
|
const hasLinks = links.some(l => |
|
(l.source as NetworkNode).id === d.id || |
|
(l.target as NetworkNode).id === d.id |
|
); |
|
return hasLinks ? 0 : 0.1; |
|
})); |
|
} |
|
|
|
// Use gentler alpha for updates with restored positions |
|
if (hasRestoredPositions) { |
|
newSimulation.alpha(0.3); // Gentler restart |
|
} |
|
|
|
// Center the nodes when the simulation is done |
|
newSimulation.on("end", () => { |
|
if (!starVisualization) { |
|
centerGraph(); |
|
} |
|
}); |
|
|
|
// Create drag handler |
|
const dragHandler = starVisualization |
|
? createStarDragHandler(newSimulation) |
|
: setupDragHandlers(newSimulation); |
|
|
|
return { simulation: newSimulation, dragHandler }; |
|
} |
|
|
|
/** |
|
* Renders links in the SVG |
|
*/ |
|
function renderLinks(links: NetworkLink[]) { |
|
debug("Updating links"); |
|
return svgGroup |
|
.selectAll("path.link") |
|
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`) |
|
.join( |
|
(enter: any) => |
|
enter |
|
.append("path") |
|
.attr("class", (d: any) => { |
|
let classes = "link network-link-leather"; |
|
if (d.connectionType === "signed-by") { |
|
classes += " person-link-signed"; |
|
} else if (d.connectionType === "referenced") { |
|
classes += " person-link-referenced"; |
|
} |
|
return classes; |
|
}) |
|
.attr("stroke-width", 2) |
|
.attr("marker-end", "url(#arrowhead)"), |
|
(update: any) => update.attr("class", (d: any) => { |
|
let classes = "link network-link-leather"; |
|
if (d.connectionType === "signed-by") { |
|
classes += " person-link-signed"; |
|
} else if (d.connectionType === "referenced") { |
|
classes += " person-link-referenced"; |
|
} |
|
return classes; |
|
}), |
|
(exit: any) => exit.remove(), |
|
); |
|
} |
|
|
|
/** |
|
* Creates the node group and attaches drag handlers |
|
*/ |
|
function createNodeGroup(enter: any, dragHandler: any) { |
|
const nodeEnter = enter |
|
.append('g') |
|
.attr('class', 'node network-node-leather') |
|
.call(dragHandler); |
|
|
|
// Larger transparent circle for better drag handling |
|
nodeEnter |
|
.append('circle') |
|
.attr('class', 'drag-circle') |
|
.attr('r', NODE_RADIUS * 2.5) |
|
.attr('fill', 'transparent') |
|
.attr('stroke', 'transparent') |
|
.style('cursor', 'move'); |
|
|
|
// Add shape based on node type |
|
nodeEnter.each(function (this: SVGGElement, d: NetworkNode) { |
|
const g = d3.select(this); |
|
if (d.isPersonAnchor) { |
|
// Diamond shape for person anchors |
|
g.append('rect') |
|
.attr('class', 'visual-shape visual-diamond') |
|
.attr('width', NODE_RADIUS * 1.5) |
|
.attr('height', NODE_RADIUS * 1.5) |
|
.attr('x', -NODE_RADIUS * 0.75) |
|
.attr('y', -NODE_RADIUS * 0.75) |
|
.attr('transform', 'rotate(45)') |
|
.attr('stroke-width', 2); |
|
} else { |
|
// Circle for other nodes |
|
g.append('circle') |
|
.attr('class', 'visual-shape visual-circle') |
|
.attr('r', NODE_RADIUS) |
|
.attr('stroke-width', 2); |
|
} |
|
}); |
|
|
|
// Node label |
|
nodeEnter |
|
.append('text') |
|
.attr('dy', '0.35em') |
|
.attr('text-anchor', 'middle') |
|
.attr('fill', 'black') |
|
.attr('font-size', '12px') |
|
.attr('stroke', 'none') |
|
.attr('font-weight', 'bold') |
|
.style('pointer-events', 'none'); |
|
|
|
return nodeEnter; |
|
} |
|
|
|
/** |
|
* Updates visual properties for all nodes |
|
*/ |
|
function updateNodeAppearance(node: any) { |
|
node |
|
.select('.visual-shape') |
|
.attr('class', (d: NetworkNode) => { |
|
const shapeClass = d.isPersonAnchor ? 'visual-diamond' : 'visual-circle'; |
|
const baseClasses = `visual-shape ${shapeClass} network-node-leather`; |
|
if (d.isPersonAnchor) { |
|
return `${baseClasses} person-anchor-node`; |
|
} |
|
if (d.isTagAnchor) { |
|
return `${baseClasses} tag-anchor-node`; |
|
} |
|
if (!d.isContainer) { |
|
return `${baseClasses} network-node-content`; |
|
} |
|
if (starVisualization && d.kind === 30040) { |
|
return `${baseClasses} star-center-node`; |
|
} |
|
return baseClasses; |
|
}) |
|
.style('fill', (d: NetworkNode) => { |
|
if (d.isPersonAnchor) { |
|
if (d.isFromFollowList) { |
|
return getEventKindColor(3); |
|
} |
|
return '#10B981'; |
|
} |
|
if (d.isTagAnchor) { |
|
return getTagAnchorColor(d.tagType || ''); |
|
} |
|
const color = getEventKindColor(d.kind); |
|
return color; |
|
}) |
|
.attr('opacity', 1) |
|
.attr('r', (d: NetworkNode) => { |
|
if (d.isPersonAnchor) return null; |
|
if (d.isTagAnchor) { |
|
return NODE_RADIUS * 0.75; |
|
} |
|
if (starVisualization && d.isContainer && d.kind === 30040) { |
|
return NODE_RADIUS * 1.5; |
|
} |
|
return NODE_RADIUS; |
|
}) |
|
.attr('width', (d: NetworkNode) => { |
|
if (!d.isPersonAnchor) return null; |
|
return NODE_RADIUS * 1.5; |
|
}) |
|
.attr('height', (d: NetworkNode) => { |
|
if (!d.isPersonAnchor) return null; |
|
return NODE_RADIUS * 1.5; |
|
}) |
|
.attr('x', (d: NetworkNode) => { |
|
if (!d.isPersonAnchor) return null; |
|
return -NODE_RADIUS * 0.75; |
|
}) |
|
.attr('y', (d: NetworkNode) => { |
|
if (!d.isPersonAnchor) return null; |
|
return -NODE_RADIUS * 0.75; |
|
}) |
|
.attr('stroke-width', (d: NetworkNode) => { |
|
if (d.isPersonAnchor) { |
|
return 3; |
|
} |
|
if (d.isTagAnchor) { |
|
return 3; |
|
} |
|
return 2; |
|
}); |
|
} |
|
|
|
/** |
|
* Updates the text label for all nodes |
|
*/ |
|
function updateNodeLabels(node: any) { |
|
node |
|
.select('text') |
|
.text((d: NetworkNode) => { |
|
if (d.isTagAnchor) { |
|
return d.tagType === 't' ? '#' : 'T'; |
|
} |
|
return ''; |
|
}) |
|
.attr('font-size', (d: NetworkNode) => { |
|
if (d.isTagAnchor) { |
|
return '10px'; |
|
} |
|
if (starVisualization && d.isContainer && d.kind === 30040) { |
|
return '14px'; |
|
} |
|
return '12px'; |
|
}) |
|
.attr('fill', (d: NetworkNode) => { |
|
if (d.isTagAnchor) { |
|
return 'white'; |
|
} |
|
return 'black'; |
|
}) |
|
.style('fill', (d: NetworkNode) => { |
|
if (d.isTagAnchor) { |
|
return 'white'; |
|
} |
|
return null; |
|
}) |
|
.attr('stroke', 'none') |
|
.style('stroke', 'none'); |
|
} |
|
|
|
/** |
|
* Renders nodes in the SVG (refactored for clarity) |
|
*/ |
|
function renderNodes(nodes: NetworkNode[], dragHandler: any) { |
|
debug('Updating nodes'); |
|
const node = svgGroup |
|
.selectAll('g.node') |
|
.data(nodes, (d: NetworkNode) => d.id) |
|
.join( |
|
(enter: any) => createNodeGroup(enter, dragHandler), |
|
(update: any) => { |
|
update.call(dragHandler); |
|
return update; |
|
}, |
|
(exit: any) => exit.remove(), |
|
); |
|
|
|
updateNodeAppearance(node); |
|
updateNodeLabels(node); |
|
|
|
return node; |
|
} |
|
|
|
/** |
|
* Sets up mouse interactions for nodes (hover and click) |
|
*/ |
|
function setupNodeInteractions(node: any) { |
|
debug("Setting up node interactions"); |
|
node |
|
.on("mouseover", (event: any, d: NetworkNode) => { |
|
if (!selectedNodeId) { |
|
tooltipVisible = true; |
|
tooltipNode = d; |
|
tooltipX = event.pageX; |
|
tooltipY = event.pageY; |
|
} |
|
}) |
|
.on("mousemove", (event: any) => { |
|
if (!selectedNodeId) { |
|
tooltipX = event.pageX; |
|
tooltipY = event.pageY; |
|
} |
|
}) |
|
.on("mouseout", () => { |
|
if (!selectedNodeId) { |
|
tooltipVisible = false; |
|
tooltipNode = null; |
|
} |
|
}) |
|
.on("click", (event: any, d: NetworkNode) => { |
|
event.stopPropagation(); |
|
if (selectedNodeId === d.id) { |
|
// Clicking the selected node again deselects it |
|
selectedNodeId = null; |
|
tooltipVisible = false; |
|
} else { |
|
// Select the node and show its tooltip |
|
selectedNodeId = d.id; |
|
tooltipVisible = true; |
|
tooltipNode = d; |
|
tooltipX = event.pageX; |
|
tooltipY = event.pageY; |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Sets up the simulation tick handler for animation |
|
*/ |
|
function setupSimulationTickHandler( |
|
simulation: Simulation<NetworkNode, NetworkLink> | null, |
|
nodes: NetworkNode[], |
|
links: NetworkLink[], |
|
link: any, |
|
node: any |
|
) { |
|
debug("Setting up simulation tick handler"); |
|
if (simulation) { |
|
simulation.on("tick", () => { |
|
// Apply custom forces to each node |
|
if (!starVisualization) { |
|
nodes.forEach((node) => { |
|
// Pull nodes toward the center |
|
applyGlobalLogGravity( |
|
node, |
|
width / 2, |
|
height / 2, |
|
simulation!.alpha(), |
|
); |
|
// Pull connected nodes toward each other |
|
applyConnectedGravity(node, links, simulation!.alpha()); |
|
}); |
|
} |
|
|
|
// Update link positions |
|
link.attr("d", (d: NetworkLink) => { |
|
// Calculate angle between source and target |
|
const dx = d.target.x! - d.source.x!; |
|
const dy = d.target.y! - d.source.y!; |
|
const angle = Math.atan2(dy, dx); |
|
|
|
// Calculate start and end points with offsets for node radius |
|
const sourceRadius = |
|
starVisualization && |
|
d.source.isContainer && |
|
d.source.kind === 30040 |
|
? NODE_RADIUS * 1.5 |
|
: NODE_RADIUS; |
|
const targetRadius = |
|
starVisualization && |
|
d.target.isContainer && |
|
d.target.kind === 30040 |
|
? NODE_RADIUS * 1.5 |
|
: NODE_RADIUS; |
|
|
|
const sourceGap = sourceRadius; |
|
const targetGap = targetRadius + ARROW_DISTANCE; |
|
|
|
const startX = d.source.x! + sourceGap * Math.cos(angle); |
|
const startY = d.source.y! + sourceGap * Math.sin(angle); |
|
const endX = d.target.x! - targetGap * Math.cos(angle); |
|
const endY = d.target.y! - targetGap * Math.sin(angle); |
|
|
|
return `M${startX},${startY}L${endX},${endY}`; |
|
}); |
|
|
|
// Update node positions |
|
node.attr( |
|
"transform", |
|
(d: NetworkNode) => `translate(${d.x},${d.y})`, |
|
); |
|
}); |
|
} |
|
} |
|
|
|
/** |
|
* Handles errors that occur during graph updates |
|
*/ |
|
function handleGraphError(error: unknown) { |
|
console.error("Error in updateGraph:", error); |
|
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; |
|
} |
|
|
|
/** |
|
* Updates the graph with new data |
|
* Generates the graph from events, creates the simulation, and renders nodes and links |
|
*/ |
|
function updateGraph() { |
|
debug("updateGraph called", { |
|
eventCount: events?.length, |
|
starVisualization, |
|
showTagAnchors, |
|
selectedTagType, |
|
disabledTagsCount: disabledTags.size |
|
}); |
|
errorMessage = null; |
|
|
|
try { |
|
validateGraphElements(); |
|
const graphData = generateGraphData(); |
|
|
|
// Save current positions before filtering |
|
saveNodePositions(graphData.nodes); |
|
|
|
const { nodes, links } = filterNodesAndLinks(graphData); |
|
const restoredCount = restoreNodePositions(nodes); |
|
|
|
if (!nodes.length) { |
|
throw new Error("No nodes to render"); |
|
} |
|
|
|
const { simulation: newSimulation, dragHandler } = setupSimulation(nodes, links, restoredCount); |
|
simulation = newSimulation; |
|
|
|
const link = renderLinks(links); |
|
const node = renderNodes(nodes, dragHandler); |
|
|
|
setupNodeInteractions(node); |
|
setupSimulationTickHandler(simulation, nodes, links, link, node); |
|
} catch (error) { |
|
handleGraphError(error); |
|
} |
|
} |
|
|
|
/** |
|
* Component lifecycle setup |
|
*/ |
|
onMount(() => { |
|
debug("Component mounted"); |
|
try { |
|
// Detect initial theme |
|
isDarkMode = document.body.classList.contains("dark"); |
|
|
|
// Initialize the graph structure |
|
initializeGraph(); |
|
} catch (error) { |
|
console.error("Error in onMount:", error); |
|
errorMessage = `Error initializing graph: ${error instanceof Error ? error.message : String(error)}`; |
|
} |
|
|
|
// Set up window resize handler |
|
const handleResize = () => { |
|
windowHeight = window.innerHeight; |
|
}; |
|
windowHeight = window.innerHeight; |
|
window.addEventListener("resize", handleResize); |
|
|
|
// Set up theme change observer |
|
const themeObserver = new MutationObserver((mutations) => { |
|
mutations.forEach((mutation) => { |
|
if (mutation.attributeName === "class") { |
|
const newIsDarkMode = document.body.classList.contains("dark"); |
|
if (newIsDarkMode !== isDarkMode) { |
|
isDarkMode = newIsDarkMode; |
|
// Update node colors when theme changes |
|
if (svgGroup) { |
|
svgGroup |
|
.selectAll("g.node") |
|
.select(".visual-shape") |
|
.style("fill", (d: NetworkNode) => { |
|
// Person anchors - color based on source |
|
if (d.isPersonAnchor) { |
|
// If from follow list, use kind 3 color |
|
if (d.isFromFollowList) { |
|
return getEventKindColor(3); |
|
} |
|
// Otherwise green for event authors |
|
return "#10B981"; |
|
} |
|
if (d.isTagAnchor) { |
|
return getTagAnchorColor(d.tagType || ""); |
|
} |
|
return getEventKindColor(d.kind); |
|
}); |
|
} |
|
} |
|
} |
|
}); |
|
}); |
|
|
|
// Set up container resize observer |
|
const resizeObserver = new ResizeObserver((entries) => { |
|
for (const entry of entries) { |
|
width = entry.contentRect.width; |
|
height = graphHeight; |
|
} |
|
if (svg) { |
|
d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`); |
|
// Restart simulation with new dimensions |
|
if (simulation) { |
|
simulation.alpha(0.3).restart(); |
|
} |
|
} |
|
}); |
|
|
|
// Start observers |
|
themeObserver.observe(document.documentElement, { |
|
attributes: true, |
|
attributeFilter: ["class"], |
|
}); |
|
resizeObserver.observe(container); |
|
|
|
// Clean up on component destruction |
|
return () => { |
|
themeObserver.disconnect(); |
|
resizeObserver.disconnect(); |
|
window.removeEventListener("resize", handleResize); |
|
if (simulation) simulation.stop(); |
|
}; |
|
}); |
|
|
|
/** |
|
* Watch for changes that should trigger a graph update |
|
*/ |
|
let isUpdating = false; |
|
let updateTimer: ReturnType<typeof setTimeout> | null = null; |
|
|
|
// Create a derived state that combines all dependencies |
|
const graphDependencies = $derived({ |
|
levels: currentLevels, |
|
star: starVisualization, |
|
tags: showTagAnchors, |
|
tagType: selectedTagType, |
|
disabled: disabledTags.size, |
|
persons: showPersonNodes, |
|
disabledPersons: disabledPersons.size, |
|
showSignedBy, |
|
showReferenced, |
|
eventsLength: events?.length || 0 |
|
}); |
|
|
|
// Debounced update function |
|
function scheduleGraphUpdate() { |
|
if (updateTimer) { |
|
clearTimeout(updateTimer); |
|
} |
|
|
|
updateTimer = setTimeout(() => { |
|
if (!isUpdating && svg && events?.length > 0) { |
|
debug("Scheduled graph update executing", graphDependencies); |
|
isUpdating = true; |
|
try { |
|
updateGraph(); |
|
} catch (error) { |
|
console.error("Error updating graph:", error); |
|
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; |
|
} finally { |
|
isUpdating = false; |
|
updateTimer = null; |
|
} |
|
} |
|
}, 100); // 100ms debounce |
|
} |
|
|
|
$effect(() => { |
|
// Just track the dependencies and schedule update |
|
const deps = graphDependencies; |
|
|
|
if (svg && events?.length > 0) { |
|
scheduleGraphUpdate(); |
|
} |
|
}); |
|
|
|
// Track previous values to avoid unnecessary calls |
|
let previousTagType = $state<string | undefined>(undefined); |
|
let isInitialized = $state(false); |
|
|
|
// Mark as initialized after first render |
|
$effect(() => { |
|
if (!isInitialized && svg) { |
|
isInitialized = true; |
|
} |
|
}); |
|
|
|
/** |
|
* Watch for tag expansion changes |
|
*/ |
|
$effect(() => { |
|
// Skip if not initialized or no callback |
|
if (!isInitialized || !onTagExpansionChange) return; |
|
|
|
// Check if we need to trigger expansion |
|
const tagTypeChanged = selectedTagType !== previousTagType; |
|
const shouldExpand = showTagAnchors && tagTypeChanged; |
|
|
|
if (shouldExpand) { |
|
previousTagType = selectedTagType; |
|
|
|
// Extract unique tags from current events |
|
const tags = new Set<string>(); |
|
events.forEach((event: NDKEvent) => { |
|
const eventTags = event.getMatchingTags(selectedTagType); |
|
eventTags.forEach((tag: string[]) => { |
|
if (tag[1]) tags.add(tag[1]); |
|
}); |
|
}); |
|
|
|
debug("Tag expansion requested", { |
|
tagType: selectedTagType, |
|
tags: Array.from(tags), |
|
tagTypeChanged |
|
}); |
|
|
|
onTagExpansionChange(Array.from(tags)); |
|
} |
|
}); |
|
|
|
/** |
|
* Watch for tag anchor count and auto-disable if exceeds threshold |
|
*/ |
|
let autoDisableTimer: ReturnType<typeof setTimeout> | null = null; |
|
|
|
$effect(() => { |
|
// Clear any pending timer |
|
if (autoDisableTimer) { |
|
clearTimeout(autoDisableTimer); |
|
autoDisableTimer = null; |
|
} |
|
|
|
// Only check when tag anchors are shown and we have tags |
|
if (showTagAnchors && tagAnchorInfo.length > 0) { |
|
|
|
// If we have more than MAX_TAG_ANCHORS and haven't auto-disabled yet |
|
if (tagAnchorInfo.length > MAX_TAG_ANCHORS && !autoDisabledTags) { |
|
// Defer the state update to break the sync cycle |
|
autoDisableTimer = setTimeout(() => { |
|
debug(`Auto-disabling tags: ${tagAnchorInfo.length} exceeds maximum of ${MAX_TAG_ANCHORS}`); |
|
|
|
// Disable all tags |
|
const newDisabledTags = new Set<string>(); |
|
tagAnchorInfo.forEach(anchor => { |
|
const tagId = `${anchor.type}-${anchor.label}`; |
|
newDisabledTags.add(tagId); |
|
}); |
|
|
|
disabledTags = newDisabledTags; |
|
autoDisabledTags = true; |
|
|
|
// Optional: Show a notification to the user |
|
console.info(`[EventNetwork] Auto-disabled ${tagAnchorInfo.length} tag anchors to prevent graph overload. Click individual tags in the legend to enable them.`); |
|
}, 0); |
|
} |
|
|
|
// Reset auto-disabled flag if tag count goes back down |
|
if (tagAnchorInfo.length <= MAX_TAG_ANCHORS && autoDisabledTags) { |
|
autoDisableTimer = setTimeout(() => { |
|
autoDisabledTags = false; |
|
}, 0); |
|
} |
|
} |
|
|
|
// Reset when tag anchors are hidden |
|
if (!showTagAnchors && autoDisabledTags) { |
|
autoDisableTimer = setTimeout(() => { |
|
autoDisabledTags = false; |
|
}, 0); |
|
} |
|
}); |
|
|
|
/** |
|
* Handles toggling tag visibility |
|
*/ |
|
function handleTagToggle(tagId: string) { |
|
if (disabledTags.has(tagId)) { |
|
const newDisabledTags = new Set(disabledTags); |
|
newDisabledTags.delete(tagId); |
|
disabledTags = newDisabledTags; |
|
} else { |
|
const newDisabledTags = new Set(disabledTags); |
|
newDisabledTags.add(tagId); |
|
disabledTags = newDisabledTags; |
|
} |
|
// Update graph will be triggered by the effect |
|
} |
|
|
|
/** |
|
* Handles toggling person node visibility |
|
*/ |
|
function handlePersonToggle(pubkey: string) { |
|
if (disabledPersons.has(pubkey)) { |
|
const newDisabledPersons = new Set(disabledPersons); |
|
newDisabledPersons.delete(pubkey); |
|
disabledPersons = newDisabledPersons; |
|
} else { |
|
const newDisabledPersons = new Set(disabledPersons); |
|
newDisabledPersons.add(pubkey); |
|
disabledPersons = newDisabledPersons; |
|
} |
|
// Update graph will be triggered by the effect |
|
} |
|
|
|
/** |
|
* Handles tooltip close event |
|
*/ |
|
function handleTooltipClose() { |
|
tooltipVisible = false; |
|
selectedNodeId = null; |
|
} |
|
|
|
/** |
|
* Centers the graph in the viewport |
|
*/ |
|
function centerGraph() { |
|
if (svg && svgGroup && zoomBehavior) { |
|
const svgWidth = svg.clientWidth || width; |
|
const svgHeight = svg.clientHeight || height; |
|
|
|
// Reset zoom and center |
|
d3.select(svg) |
|
.transition() |
|
.duration(750) |
|
.call( |
|
zoomBehavior.transform, |
|
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8), |
|
); |
|
} |
|
} |
|
|
|
/** |
|
* Zooms in the graph |
|
*/ |
|
function zoomIn() { |
|
if (svg && zoomBehavior) { |
|
d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 1.3); |
|
} |
|
} |
|
|
|
/** |
|
* Zooms out the graph |
|
*/ |
|
function zoomOut() { |
|
if (svg && zoomBehavior) { |
|
d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 0.7); |
|
} |
|
} |
|
|
|
/** |
|
* Legend interactions |
|
*/ |
|
let graphInteracted = $state(false); |
|
|
|
function handleGraphClick() { |
|
if (!graphInteracted) { |
|
graphInteracted = true; |
|
} |
|
} |
|
|
|
</script> |
|
|
|
<div class="network-container"> |
|
{#if hasError} |
|
<div class="network-error"> |
|
<h3 class="network-error-title">Error</h3> |
|
<p>{errorMessage}</p> |
|
<button |
|
class="network-error-retry" |
|
onclick={() => { |
|
errorMessage = null; |
|
updateGraph(); |
|
}} |
|
> |
|
Retry |
|
</button> |
|
</div> |
|
{/if} |
|
|
|
<div class="network-svg-container" bind:this={container} role="figure"> |
|
<Legend |
|
collapsedOnInteraction={graphInteracted} |
|
className="" |
|
starMode={starVisualization} |
|
showTags={showTagAnchors} |
|
tagAnchors={tagAnchorInfo} |
|
eventCounts={eventCounts} |
|
{disabledTags} |
|
onTagToggle={handleTagToggle} |
|
{autoDisabledTags} |
|
bind:showTagAnchors |
|
bind:selectedTagType |
|
onTagSettingsChange={() => { |
|
// Trigger graph update when tag settings change |
|
if (svg && events?.length) { |
|
updateGraph(); |
|
} |
|
}} |
|
bind:showPersonNodes |
|
personAnchors={personAnchorInfo} |
|
{disabledPersons} |
|
onPersonToggle={handlePersonToggle} |
|
onPersonSettingsChange={() => { |
|
// Trigger graph update when person settings change |
|
if (svg && events?.length) { |
|
updateGraph(); |
|
} |
|
}} |
|
bind:showSignedBy |
|
bind:showReferenced |
|
{totalPersonCount} |
|
{displayedPersonCount} |
|
/> |
|
|
|
<!-- Settings Panel (shown when settings button is clicked) --> |
|
<Settings |
|
count={events.length} |
|
{totalCount} |
|
{onupdate} |
|
{onclear} |
|
bind:starVisualization |
|
eventCounts={allEventCounts} |
|
{profileStats} |
|
/> |
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_static_element_interactions --> |
|
<svg bind:this={svg} class="network-svg" onclick={handleGraphClick} /> |
|
|
|
<!-- Zoom controls --> |
|
<div class="network-controls"> |
|
<Button |
|
outline |
|
size="lg" |
|
class="network-control-button btn-leather rounded-lg p-2" |
|
onclick={zoomIn} |
|
aria-label="Zoom in" |
|
> |
|
<svg |
|
xmlns="http://www.w3.org/2000/svg" |
|
width="24" |
|
height="24" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
stroke-linecap="round" |
|
stroke-linejoin="round" |
|
> |
|
<circle cx="11" cy="11" r="8"></circle> |
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> |
|
<line x1="11" y1="8" x2="11" y2="14"></line> |
|
<line x1="8" y1="11" x2="14" y2="11"></line> |
|
</svg> |
|
</Button> |
|
<Button |
|
outline |
|
size="lg" |
|
class="network-control-button btn-leather rounded-lg p-2" |
|
onclick={zoomOut} |
|
aria-label="Zoom out" |
|
> |
|
<svg |
|
xmlns="http://www.w3.org/2000/svg" |
|
width="24" |
|
height="24" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
stroke-linecap="round" |
|
stroke-linejoin="round" |
|
> |
|
<circle cx="11" cy="11" r="8"></circle> |
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> |
|
<line x1="8" y1="11" x2="14" y2="11"></line> |
|
</svg> |
|
</Button> |
|
<Button |
|
outline |
|
size="lg" |
|
class="network-control-button btn-leather rounded-lg p-2" |
|
onclick={centerGraph} |
|
aria-label="Center graph" |
|
> |
|
<svg |
|
xmlns="http://www.w3.org/2000/svg" |
|
width="24" |
|
height="24" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
stroke-linecap="round" |
|
stroke-linejoin="round" |
|
> |
|
<circle cx="12" cy="12" r="10"></circle> |
|
<circle cx="12" cy="12" r="3"></circle> |
|
</svg> |
|
</Button> |
|
</div> |
|
</div> |
|
|
|
{#if tooltipVisible && tooltipNode} |
|
<NodeTooltip |
|
node={tooltipNode} |
|
selected={tooltipNode.id === selectedNodeId} |
|
x={tooltipX} |
|
y={tooltipY} |
|
onclose={handleTooltipClose} |
|
starMode={starVisualization} |
|
/> |
|
{/if} |
|
</div>
|
|
|