clone of repo on github
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.
 
 
 
 

646 lines
18 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 {
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;
}>();
// 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;
// Track current render level
let currentLevels = $derived(levelsToRender);
// 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);
}
/**
* Updates the graph with new data
* Generates the graph from events, creates the simulation, and renders nodes and links
*/
function updateGraph() {
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,
});
const graphData = generateGraph(events, Number(currentLevels));
nodes = graphData.nodes;
links = graphData.links;
debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length,
});
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();
});
// 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(() => {
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("circle.visual-circle")
.attr("fill", (d: NetworkNode) =>
!d.isContainer
? newIsDarkMode
? CONTENT_COLOR_DARK
: CONTENT_COLOR_LIGHT
: getEventColor(d.id),
);
}
}
}
});
});
// 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
*/
$effect(() => {
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="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} />
<!-- 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}
/>
{/if}
</div>