|
|
|
|
@ -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); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let { events = [] } = $props<{ events?: NDKEvent[] }>(); |
|
|
|
|
// 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 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,58 +140,116 @@
@@ -88,58 +140,116 @@
|
|
|
|
|
.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; |
|
|
|
|
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"); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const { nodes, links } = generateGraph(events, Number(currentLevels)); |
|
|
|
|
if (!nodes.length) return; |
|
|
|
|
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) simulation.stop(); |
|
|
|
|
if (simulation) { |
|
|
|
|
debug("Stopping existing simulation"); |
|
|
|
|
simulation.stop(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Create new simulation |
|
|
|
|
simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance)); |
|
|
|
|
const dragHandler = setupDragHandlers(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 |
|
|
|
|
const link = svgGroup |
|
|
|
|
.selectAll<SVGPathElement, NetworkLink>("path.link") |
|
|
|
|
.data(links, d => `${d.source.id}-${d.target.id}`) |
|
|
|
|
debug("Updating links"); |
|
|
|
|
link = svgGroup |
|
|
|
|
.selectAll("path.link") |
|
|
|
|
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`) |
|
|
|
|
.join( |
|
|
|
|
enter => enter |
|
|
|
|
(enter: any) => enter |
|
|
|
|
.append("path") |
|
|
|
|
.attr("class", "link network-link-leather") |
|
|
|
|
.attr("stroke-width", 2) |
|
|
|
|
.attr("marker-end", "url(#arrowhead)"), |
|
|
|
|
update => update, |
|
|
|
|
exit => exit.remove() |
|
|
|
|
(update: any) => update, |
|
|
|
|
(exit: any) => exit.remove() |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
// Update nodes |
|
|
|
|
const node = svgGroup |
|
|
|
|
.selectAll<SVGGElement, NetworkNode>("g.node") |
|
|
|
|
.data(nodes, d => d.id) |
|
|
|
|
debug("Updating nodes"); |
|
|
|
|
node = svgGroup |
|
|
|
|
.selectAll("g.node") |
|
|
|
|
.data(nodes, (d: NetworkNode) => d.id) |
|
|
|
|
.join( |
|
|
|
|
enter => { |
|
|
|
|
(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", nodeRadius * 2.5) |
|
|
|
|
.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", nodeRadius) |
|
|
|
|
.attr("r", NODE_RADIUS) |
|
|
|
|
.attr("stroke-width", 2); |
|
|
|
|
|
|
|
|
|
// Node label |
|
|
|
|
nodeEnter |
|
|
|
|
.append("text") |
|
|
|
|
.attr("dy", "0.35em") |
|
|
|
|
@ -149,27 +259,29 @@
@@ -149,27 +259,29 @@
|
|
|
|
|
|
|
|
|
|
return nodeEnter; |
|
|
|
|
}, |
|
|
|
|
update => update, |
|
|
|
|
exit => exit.remove() |
|
|
|
|
(update: any) => update, |
|
|
|
|
(exit: any) => exit.remove() |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
// Update node appearances |
|
|
|
|
debug("Updating node appearances"); |
|
|
|
|
node.select("circle.visual-circle") |
|
|
|
|
.attr("class", d => !d.isContainer |
|
|
|
|
.attr("class", (d: NetworkNode) => !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" |
|
|
|
|
.attr("fill", (d: NetworkNode) => !d.isContainer |
|
|
|
|
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT |
|
|
|
|
: getEventColor(d.id) |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
node.select("text") |
|
|
|
|
.text(d => d.isContainer ? "I" : "C"); |
|
|
|
|
.text((d: NetworkNode) => d.isContainer ? "I" : "C"); |
|
|
|
|
|
|
|
|
|
// Update node interactions |
|
|
|
|
// Set up node interactions |
|
|
|
|
debug("Setting up node interactions"); |
|
|
|
|
node |
|
|
|
|
.on("mouseover", (event, d) => { |
|
|
|
|
.on("mouseover", (event: any, d: NetworkNode) => { |
|
|
|
|
if (!selectedNodeId) { |
|
|
|
|
tooltipVisible = true; |
|
|
|
|
tooltipNode = d; |
|
|
|
|
@ -177,7 +289,7 @@
@@ -177,7 +289,7 @@
|
|
|
|
|
tooltipY = event.pageY; |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
.on("mousemove", (event, d) => { |
|
|
|
|
.on("mousemove", (event: any) => { |
|
|
|
|
if (!selectedNodeId) { |
|
|
|
|
tooltipX = event.pageX; |
|
|
|
|
tooltipY = event.pageY; |
|
|
|
|
@ -189,15 +301,14 @@
@@ -189,15 +301,14 @@
|
|
|
|
|
tooltipNode = null; |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
.on("click", (event, d) => { |
|
|
|
|
.on("click", (event: any, d: NetworkNode) => { |
|
|
|
|
event.stopPropagation(); |
|
|
|
|
if (selectedNodeId === d.id) { |
|
|
|
|
// Clicking the selected node again deselects it |
|
|
|
|
selectedNodeId = null; |
|
|
|
|
tooltipVisible = false; |
|
|
|
|
tooltipNode = d; |
|
|
|
|
tooltipX = event.pageX; |
|
|
|
|
tooltipY = event.pageY; |
|
|
|
|
} else { |
|
|
|
|
// Select the node and show its tooltip |
|
|
|
|
selectedNodeId = d.id; |
|
|
|
|
tooltipVisible = true; |
|
|
|
|
tooltipNode = d; |
|
|
|
|
@ -206,21 +317,28 @@
@@ -206,21 +317,28 @@
|
|
|
|
|
} |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
// Handle simulation ticks |
|
|
|
|
// 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 positions |
|
|
|
|
link.attr("d", d => { |
|
|
|
|
// 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); |
|
|
|
|
|
|
|
|
|
const sourceGap = nodeRadius; |
|
|
|
|
const targetGap = nodeRadius + arrowDistance; |
|
|
|
|
// 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); |
|
|
|
|
@ -230,24 +348,40 @@
@@ -230,24 +348,40 @@
|
|
|
|
|
return `M${startX},${startY}L${endX},${endY}`; |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
node.attr("transform", d => `translate(${d.x},${d.y})`); |
|
|
|
|
// 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)}`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 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(() => { |
|
|
|
|
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="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(); }} |
|
|
|
|
> |
|
|
|
|
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}> |
|
|
|
|
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> |
|
|
|
|
|