@ -1,40 +1,84 @@
@@ -1,40 +1,84 @@
<!-- EventNetwork.svelte -->
<!--
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 { createSimulation , setupDragHandlers , applyGlobalLogGravity , applyConnectedGravity } from "./utils/forceSimulation";
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 { Button } from 'flowbite-svelte';
// 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 = [], onupdate } = $props< { events? : NDKEvent [], onupdate : () => void } >();
let { events = [] } = $props< { events? : NDKEvent [] } >();
// Error state
let errorMessage = $state< string | null > (null);
let hasError = $derived(!!errorMessage);
// DOM references
let svg: SVGSVGElement;
let isDarkMode = $state(false);
let container: HTMLDivElement;
// Use a string ID for comparisons instead of the node object
// 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);
const nodeRadius = 20;
const linkDistance = 10;
const arrowDistance = 10;
// 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);
let simulation: d3.Simulation< NetworkNode , NetworkLink > | null = null;
let svgGroup: d3.Selection< SVGGElement , unknown , null , undefined > ;
// D3 objects
let simulation: Simulation< NetworkNode , NetworkLink > | null = null;
let svgGroup: Selection;
let zoomBehavior: any;
let svgElement: Selection;
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400);
// Track current render level
let currentLevels = $derived(levelsToRender);
// Update dimensions when container changes
$effect(() => {
@ -44,32 +88,40 @@
@@ -44,32 +88,40 @@
}
});
// Track levelsToRender changes
let currentLevels = $derived(levelsToRender);
/**
* Initializes the SVG graph structure
* Sets up the SVG element, zoom behavior, and arrow marker
*/
function initializeGraph() {
if (!svg) return;
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
const zoom = d3
.zoom< SVGSVGElement , unknown > ()
.scaleExtent([0.1, 9])
.on("zoom", (event) => {
zoomBehavior = d3
.zoom()
.scaleExtent([0.1, 9]) // Min/max zoom levels
.on("zoom", (event: any ) => {
svgGroup.attr("transform", event.transform);
});
svgElement.call(zoom);
svgElement.call(zoomBehavior );
// Set up arrow marker
// Set up arrow marker for links
const defs = svgElement.append("defs");
defs
.append("marker")
@ -88,166 +140,248 @@
@@ -88,166 +140,248 @@
.attr("stroke-width", 1);
}
/**
* Updates the graph with new data
* Generates the graph from events, creates the simulation, and renders nodes and links
*/
function updateGraph() {
if (!svg || !events?.length || !svgGroup) return;
const { nodes , links } = generateGraph(events, Number(currentLevels));
if (!nodes.length) return;
// Stop any existing simulation
if (simulation) simulation.stop();
// Create new simulation
simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance));
const dragHandler = setupDragHandlers(simulation);
// Update links
const link = svgGroup
.selectAll< SVGPathElement , NetworkLink > ("path.link")
.data(links, d => `${ d . source . id } -${ d . target . id } `)
.join(
enter => enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
update => update,
exit => exit.remove()
);
// Update nodes
const node = svgGroup
.selectAll< SVGGElement , NetworkNode > ("g.node")
.data(nodes, d => d.id)
.join(
enter => {
const nodeEnter = enter
.append("g")
.attr("class", "node network-node-leather")
.call(dragHandler);
nodeEnter
.append("circle")
.attr("class", "drag-circle")
.attr("r", nodeRadius * 2.5)
.attr("fill", "transparent")
.attr("stroke", "transparent")
.style("cursor", "move");
nodeEnter
.append("circle")
.attr("class", "visual-circle")
.attr("r", nodeRadius)
.attr("stroke-width", 2);
nodeEnter
.append("text")
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "black")
.attr("font-size", "12px");
return nodeEnter;
},
update => update,
exit => exit.remove()
);
// Update node appearances
node.select("circle.visual-circle")
.attr("class", d => !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather"
)
.attr("fill", d => !d.isContainer
? isDarkMode ? "#FFFFFF" : "network-link-leather"
: getEventColor(d.id)
);
node.select("text")
.text(d => d.isContainer ? "I" : "C");
// Update node interactions
node
.on("mouseover", (event, d) => {
if (!selectedNodeId) {
tooltipVisible = true;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
}
})
.on("mousemove", (event, d) => {
if (!selectedNodeId) {
tooltipX = event.pageX;
tooltipY = event.pageY;
}
})
.on("mouseout", () => {
if (!selectedNodeId) {
tooltipVisible = false;
tooltipNode = null;
}
})
.on("click", (event, d) => {
event.stopPropagation();
if (selectedNodeId === d.id) {
selectedNodeId = null;
tooltipVisible = false;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
} else {
selectedNodeId = d.id;
tooltipVisible = true;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
}
debug("Updating graph");
errorMessage = null;
// Create variables to hold our selections
let link: any;
let node: any;
let dragHandler: any;
let nodes: NetworkNode[] = [];
let links: NetworkLink[] = [];
try {
// Validate required elements
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");
}
// Generate graph data from events
debug("Generating graph with events", {
eventCount: events.length,
currentLevels
});
// Handle simulation ticks
simulation.on("tick", () => {
nodes.forEach(node => {
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha());
applyConnectedGravity(node, links, simulation!.alpha());
const graphData = generateGraph(events, Number(currentLevels));
nodes = graphData.nodes;
links = graphData.links;
debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length
});
// Update positions
link.attr("d", d => {
const dx = d.target.x! - d.source.x!;
const dy = d.target.y! - d.source.y!;
const angle = Math.atan2(dy, dx);
const sourceGap = nodeRadius;
const targetGap = nodeRadius + arrowDistance;
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 } `;
if (!nodes.length) {
throw new Error("No nodes to render");
}
// Stop any existing simulation
if (simulation) {
debug("Stopping existing simulation");
simulation.stop();
}
// Create new simulation
debug("Creating new simulation");
simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE);
// Center the nodes when the simulation is done
simulation.on("end", () => {
centerGraph();
});
node.attr("transform", d => `translate(${ d . x } ,${ d . y } )`);
});
// Create drag handler
dragHandler = setupDragHandlers(simulation);
// Update links
debug("Updating links");
link = svgGroup
.selectAll("path.link")
.data(links, (d: NetworkLink) => `${ d . source . id } -${ d . target . id } `)
.join(
(enter: any) => enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
(update: any) => update,
(exit: any) => exit.remove()
);
// Update nodes
debug("Updating nodes");
node = svgGroup
.selectAll("g.node")
.data(nodes, (d: NetworkNode) => d.id)
.join(
(enter: 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");
// Visible circle
nodeEnter
.append("circle")
.attr("class", "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");
return nodeEnter;
},
(update: any) => update,
(exit: any) => exit.remove()
);
// Update node appearances
debug("Updating node appearances");
node.select("circle.visual-circle")
.attr("class", (d: NetworkNode) => !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather"
)
.attr("fill", (d: NetworkNode) => !d.isContainer
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
);
node.select("text")
.text((d: NetworkNode) => d.isContainer ? "I" : "C");
// Set up node interactions
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;
}
});
// Set up simulation tick handler
debug("Setting up simulation tick handler");
if (simulation) {
simulation.on("tick", () => {
// Apply custom forces to each node
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 sourceGap = NODE_RADIUS;
const targetGap = NODE_RADIUS + 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 } )`);
});
}
} catch (error) {
console.error("Error in updateGraph:", error);
errorMessage = `Error updating graph: ${ error instanceof Error ? error.message : String ( error )} `;
}
}
/**
* Component lifecycle setup
*/
onMount(() => {
isDarkMode = document.body.classList.contains("dark");
// Initialize the graph structure
initializeGraph();
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 )} `;
}
// Handle window resizing
// Set up window resize handler
const handleResize = () => {
windowHeight = window.innerHeight;
};
windowHeight = window.innerHeight;
window.addEventListener("resize", handleResize);
// Watch for theme changes
// Set up theme change observer
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
@ -256,10 +390,10 @@
@@ -256,10 +390,10 @@
isDarkMode = newIsDarkMode;
// Update node colors when theme changes
if (svgGroup) {
svgGroup.selectAll< SVGGElement , NetworkNode > ("g.node")
svgGroup.selectAll("g.node")
.select("circle.visual-circle")
.attr("fill", d => !d.isContainer
? newIsDarkMode ? "#FFFFFF" : "network-link-leather"
.attr("fill", ( d: NetworkNode) => !d.isContainer
? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
);
}
@ -268,6 +402,7 @@
@@ -268,6 +402,7 @@
});
});
// Set up container resize observer
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
@ -275,19 +410,21 @@
@@ -275,19 +410,21 @@
}
if (svg) {
d3.select(svg).attr("viewBox", `0 0 ${ width } ${ height } `);
// Trigger simulation to adjust to new dimensions
// 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();
@ -296,29 +433,150 @@
@@ -296,29 +433,150 @@
};
});
// Watch for changes that should trigger a graph update
/**
* Watch for changes that should trigger a graph update
*/
$effect(() => {
if (svg && events?.length) {
// Include currentLevels in the effect dependencies
const _ = currentLevels;
updateGraph();
debug("Effect triggered", {
hasSvg: !!svg,
eventCount: events?.length,
currentLevels
});
try {
if (svg && events?.length) {
// Include currentLevels in the effect dependencies
const _ = currentLevels;
updateGraph();
}
} catch (error) {
console.error("Error in effect:", error);
errorMessage = `Error updating graph: ${ error instanceof Error ? error.message : String ( error )} `;
}
});
/**
* 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="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4"
>
< div class = "h-[calc(100%-130px)] min-h-[300px]" bind:this = { container } >
< 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='' />
<!-- Settings Panel (shown when settings button is clicked) -->
< Settings count = { events . length } onupdate= { onupdate } />
<!-- svelte - ignore a11y_click_events_have_key_events -->
<!-- svelte - ignore a11y_no_static_element_interactions -->
< svg
bind:this={ svg }
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded"
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 }
@ -327,9 +585,8 @@
@@ -327,9 +585,8 @@
selected={ tooltipNode . id === selectedNodeId }
x={ tooltipX }
y={ tooltipY }
on: close={ handleTooltipClose }
onclose={ handleTooltipClose }
/>
{ /if }
< Legend / >
< / div >