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.
1215 lines
36 KiB
1215 lines
36 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 { Button } from "flowbite-svelte"; |
|
import { visualizationConfig } from "$lib/stores/visualizationConfig"; |
|
import { get } from "svelte/store"; |
|
|
|
// 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, |
|
onFetchMissing = () => {} |
|
} = $props<{ |
|
events?: NDKEvent[]; |
|
followListEvents?: NDKEvent[]; |
|
totalCount?: number; |
|
onupdate: () => void; |
|
onclear?: () => void; |
|
onTagExpansionChange?: (depth: number, tags: string[]) => void; |
|
onFetchMissing?: (ids: string[]) => 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; |
|
|
|
// 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[]>([]); |
|
let tagExpansionDepth = $state(0); // Default to no expansion |
|
let requirePublications = $state(true); // Default to only showing people with publications |
|
|
|
// 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; |
|
|
|
// Debug function - call from browser console: window.debugTagAnchors() |
|
if (typeof window !== "undefined") { |
|
window.debugTagAnchors = () => { |
|
console.log("=== TAG ANCHOR DEBUG INFO ==="); |
|
console.log("Tag Anchor Info:", tagAnchorInfo); |
|
console.log("Show Tag Anchors:", showTagAnchors); |
|
console.log("Selected Tag Type:", selectedTagType); |
|
const tagNodes = nodes.filter((n) => n.isTagAnchor); |
|
console.log("Tag Anchor Nodes:", tagNodes); |
|
console.log("Tag Types Found:", [ |
|
...new Set(tagNodes.map((n) => n.tagType)), |
|
]); |
|
return tagAnchorInfo; |
|
}; |
|
} |
|
|
|
// 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("updateGraph called", { |
|
eventCount: events?.length, |
|
starVisualization, |
|
showTagAnchors, |
|
selectedTagType, |
|
disabledTagsCount: disabledTags.size |
|
}); |
|
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, |
|
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 |
|
}); |
|
|
|
// For "p" tags, we need to extract pubkeys from follow lists |
|
// but only show anchors for pubkeys that have events in the visualization |
|
let eventsForTags = events; |
|
|
|
if (selectedTagType === "p" && followListEvents.length > 0) { |
|
// Extract all pubkeys from follow lists |
|
const followedPubkeys = new Set<string>(); |
|
followListEvents.forEach(event => { |
|
event.tags.forEach(tag => { |
|
if (tag[0] === "p" && tag[1]) { |
|
followedPubkeys.add(tag[1]); |
|
} |
|
}); |
|
}); |
|
|
|
const syntheticEvents: NDKEvent[] = []; |
|
|
|
// Create a map to track which events each followed pubkey is connected to |
|
const pubkeyToEvents = new Map<string, Set<string>>(); |
|
|
|
// Find all connections for followed pubkeys |
|
followedPubkeys.forEach(pubkey => { |
|
const connectedEventIds = new Set<string>(); |
|
|
|
// Find events they authored |
|
events.forEach(event => { |
|
if (event.pubkey === pubkey && event.id) { |
|
connectedEventIds.add(event.id); |
|
} |
|
}); |
|
|
|
// Find events where they're tagged with "p" |
|
events.forEach(event => { |
|
if (event.id && event.tags) { |
|
event.tags.forEach(tag => { |
|
if (tag[0] === 'p' && tag[1] === pubkey) { |
|
connectedEventIds.add(event.id); |
|
} |
|
}); |
|
} |
|
}); |
|
|
|
if (connectedEventIds.size > 0) { |
|
pubkeyToEvents.set(pubkey, connectedEventIds); |
|
} |
|
}); |
|
|
|
if (requirePublications) { |
|
// Only show people who have connections to events |
|
pubkeyToEvents.forEach((eventIds, pubkey) => { |
|
// Create synthetic events for each connection |
|
eventIds.forEach(eventId => { |
|
const syntheticEvent = { |
|
id: eventId, // Use the actual event's ID so it connects properly |
|
tags: [["p", pubkey]], |
|
pubkey: "", |
|
created_at: 0, |
|
kind: 0, |
|
content: "", |
|
sig: "" |
|
} as NDKEvent; |
|
syntheticEvents.push(syntheticEvent); |
|
}); |
|
}); |
|
} else { |
|
// Show all people from follow lists |
|
let syntheticId = 0; |
|
|
|
// First, add people who have event connections |
|
pubkeyToEvents.forEach((eventIds, pubkey) => { |
|
eventIds.forEach(eventId => { |
|
const syntheticEvent = { |
|
id: eventId, // Use the actual event's ID so it connects properly |
|
tags: [["p", pubkey]], |
|
pubkey: "", |
|
created_at: 0, |
|
kind: 0, |
|
content: "", |
|
sig: "" |
|
} as NDKEvent; |
|
syntheticEvents.push(syntheticEvent); |
|
}); |
|
}); |
|
|
|
// Then, add remaining people without any connections |
|
followedPubkeys.forEach(pubkey => { |
|
if (!pubkeyToEvents.has(pubkey)) { |
|
const syntheticEvent = { |
|
id: `synthetic-p-${syntheticId++}`, // Create unique IDs for those without events |
|
tags: [["p", pubkey]], |
|
pubkey: "", |
|
created_at: 0, |
|
kind: 0, |
|
content: "", |
|
sig: "" |
|
} as NDKEvent; |
|
syntheticEvents.push(syntheticEvent); |
|
} |
|
}); |
|
} |
|
|
|
eventsForTags = syntheticEvents; |
|
debug("Created synthetic events for p tags", { |
|
followedPubkeys: followedPubkeys.size, |
|
requirePublications, |
|
syntheticEvents: syntheticEvents.length |
|
}); |
|
} |
|
|
|
// Get the display limit based on tag type |
|
let displayLimit: number | undefined; |
|
if (selectedTagType === "p") { |
|
// For people tags, use kind 0 (profiles) limit |
|
const kind0Config = get(visualizationConfig).eventConfigs.find(ec => ec.kind === 0); |
|
displayLimit = kind0Config?.limit || 50; |
|
} |
|
|
|
graphData = enhanceGraphWithTags( |
|
graphData, |
|
eventsForTags, |
|
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 || ""), |
|
})); |
|
|
|
// Add a message if People tag type is selected but no follow lists are loaded |
|
if (selectedTagType === "p" && followListEvents.length === 0 && tagAnchors.length === 0) { |
|
console.warn("[EventNetwork] No follow lists loaded. Enable kind 3 events with appropriate depth to see people tag anchors."); |
|
} |
|
} else { |
|
tagAnchorInfo = []; |
|
} |
|
|
|
// Save current node positions before updating |
|
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"); |
|
} |
|
|
|
nodes = graphData.nodes; |
|
links = graphData.links; |
|
|
|
// Filter out disabled tag anchors from nodes and links |
|
if (showTagAnchors && disabledTags.size > 0) { |
|
// Filter out disabled tag anchor nodes |
|
nodes = nodes.filter((node: NetworkNode) => { |
|
if (node.isTagAnchor) { |
|
const tagId = `${node.tagType}-${node.title}`; |
|
return !disabledTags.has(tagId); |
|
} |
|
return true; |
|
}); |
|
|
|
// Filter out links to disabled tag anchors |
|
links = links.filter((link: NetworkLink) => { |
|
const source = link.source as NetworkNode; |
|
const target = link.target as NetworkNode; |
|
|
|
// Check if either node is a disabled tag anchor |
|
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; |
|
} |
|
|
|
return true; |
|
}); |
|
|
|
debug("Filtered links for disabled tags", { |
|
originalCount: graphData.links.length, |
|
filteredCount: links.length, |
|
disabledTags: Array.from(disabledTags) |
|
}); |
|
} |
|
|
|
// Event counts are now derived, no need to set them here |
|
debug("Event counts by kind:", eventCounts); |
|
|
|
// Restore positions for existing nodes |
|
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++; |
|
} |
|
}); |
|
|
|
debug("Generated graph data", { |
|
nodeCount: nodes.length, |
|
linkCount: links.length, |
|
restoredPositions: restoredCount |
|
}); |
|
|
|
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"); |
|
const hasRestoredPositions = restoredCount > 0; |
|
|
|
if (starVisualization) { |
|
// Use star-specific simulation |
|
simulation = 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 |
|
simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE); |
|
} |
|
|
|
// Use gentler alpha for updates with restored positions |
|
if (hasRestoredPositions) { |
|
simulation.alpha(0.3); // Gentler restart |
|
} |
|
|
|
// Center the nodes when the simulation is done |
|
if (simulation) { |
|
simulation.on("end", () => { |
|
if (!starVisualization) { |
|
centerGraph(); |
|
} |
|
}); |
|
} |
|
|
|
// Create drag handler |
|
if (simulation) { |
|
dragHandler = starVisualization |
|
? createStarDragHandler(simulation) |
|
: 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") |
|
.attr("stroke", "none") |
|
.attr("font-weight", "bold") |
|
.style("pointer-events", "none"); |
|
|
|
return nodeEnter; |
|
}, |
|
(update: any) => { |
|
// Ensure drag handler is applied to updated nodes |
|
update.call(dragHandler); |
|
return update; |
|
}, |
|
(exit: any) => exit.remove(), |
|
); |
|
|
|
// Update node appearances |
|
debug("Updating node appearances"); |
|
|
|
// Update visual properties for ALL nodes (both new and existing) |
|
node |
|
.select("circle.visual-circle") |
|
.attr("class", (d: NetworkNode) => { |
|
const baseClasses = "visual-circle network-node-leather"; |
|
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) => { |
|
// Tag anchors get their specific colors |
|
if (d.isTagAnchor) { |
|
return getTagAnchorColor(d.tagType || ""); |
|
} |
|
// Use deterministic color based on event kind |
|
const color = getEventKindColor(d.kind); |
|
return color; |
|
}) |
|
.attr("opacity", 1) |
|
.attr("r", (d: NetworkNode) => { |
|
// Tag anchors are smaller |
|
if (d.isTagAnchor) { |
|
return NODE_RADIUS * 0.75; |
|
} |
|
// Make star center nodes larger |
|
if (starVisualization && d.isContainer && d.kind === 30040) { |
|
return NODE_RADIUS * 1.5; |
|
} |
|
return NODE_RADIUS; |
|
}) |
|
.attr("stroke-width", (d: NetworkNode) => { |
|
// Tag anchors have thicker stroke |
|
if (d.isTagAnchor) { |
|
return 3; |
|
} |
|
return 2; |
|
}); |
|
|
|
node |
|
.select("text") |
|
.text((d: NetworkNode) => { |
|
// Tag anchors show abbreviated type |
|
if (d.isTagAnchor) { |
|
return d.tagType === "t" ? "#" : "T"; |
|
} |
|
// No text for regular nodes - just show the colored circle |
|
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) => { |
|
// White text on tag anchors |
|
if (d.isTagAnchor) { |
|
return "white"; |
|
} |
|
return "black"; |
|
}) |
|
.style("fill", (d: NetworkNode) => { |
|
// Force fill style for tag anchors |
|
if (d.isTagAnchor) { |
|
return "white"; |
|
} |
|
return null; |
|
}) |
|
.attr("stroke", "none") |
|
.style("stroke", "none"); |
|
|
|
// 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 |
|
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})`, |
|
); |
|
}); |
|
} |
|
} 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") |
|
.style("fill", (d: NetworkNode) => { |
|
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; |
|
|
|
$effect(() => { |
|
// Prevent recursive updates |
|
if (isUpdating) return; |
|
|
|
debug("Effect triggered", { |
|
hasSvg: !!svg, |
|
eventCount: events?.length, |
|
currentLevels, |
|
}); |
|
|
|
try { |
|
if (svg && events?.length) { |
|
// Track dependencies |
|
const deps = { |
|
levels: currentLevels, |
|
star: starVisualization, |
|
tags: showTagAnchors, |
|
tagType: selectedTagType, |
|
disabled: disabledTags.size |
|
}; |
|
|
|
isUpdating = true; |
|
updateGraph(); |
|
isUpdating = false; |
|
} |
|
} catch (error) { |
|
console.error("Error in effect:", error); |
|
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; |
|
isUpdating = false; |
|
} |
|
}); |
|
|
|
// Track previous values to avoid unnecessary calls |
|
let previousDepth = $state(0); |
|
let previousTagType = $state(selectedTagType); |
|
let isInitialized = $state(false); |
|
|
|
// Mark as initialized after first render |
|
$effect(() => { |
|
if (!isInitialized && svg) { |
|
isInitialized = true; |
|
} |
|
}); |
|
|
|
/** |
|
* Watch for tag expansion depth changes |
|
*/ |
|
$effect(() => { |
|
// Skip if not initialized or no callback |
|
if (!isInitialized || !onTagExpansionChange) return; |
|
|
|
// Check if we need to trigger expansion |
|
const depthChanged = tagExpansionDepth !== previousDepth; |
|
const tagTypeChanged = selectedTagType !== previousTagType; |
|
const shouldExpand = showTagAnchors && (depthChanged || tagTypeChanged); |
|
|
|
if (shouldExpand) { |
|
previousDepth = tagExpansionDepth; |
|
previousTagType = selectedTagType; |
|
|
|
// Extract unique tags from current events |
|
const tags = new Set<string>(); |
|
events.forEach(event => { |
|
const eventTags = event.getMatchingTags(selectedTagType); |
|
eventTags.forEach(tag => { |
|
if (tag[1]) tags.add(tag[1]); |
|
}); |
|
}); |
|
|
|
debug("Tag expansion requested", { |
|
depth: tagExpansionDepth, |
|
tagType: selectedTagType, |
|
tags: Array.from(tags), |
|
depthChanged, |
|
tagTypeChanged |
|
}); |
|
|
|
onTagExpansionChange(tagExpansionDepth, Array.from(tags)); |
|
} |
|
}); |
|
|
|
/** |
|
* Watch for tag anchor count and auto-disable if exceeds threshold |
|
*/ |
|
$effect(() => { |
|
// 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) { |
|
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.`); |
|
} |
|
|
|
// Reset auto-disabled flag if tag count goes back down |
|
if (tagAnchorInfo.length <= MAX_TAG_ANCHORS && autoDisabledTags) { |
|
autoDisabledTags = false; |
|
} |
|
} |
|
|
|
// Reset when tag anchors are hidden |
|
if (!showTagAnchors && autoDisabledTags) { |
|
autoDisabledTags = false; |
|
} |
|
}); |
|
|
|
/** |
|
* 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; |
|
} |
|
} |
|
|
|
/** |
|
* Handles toggling tag visibility in the legend |
|
*/ |
|
function handleTagToggle(tagId: string) { |
|
const newDisabledTags = new Set(disabledTags); |
|
if (newDisabledTags.has(tagId)) { |
|
newDisabledTags.delete(tagId); |
|
} else { |
|
newDisabledTags.add(tagId); |
|
} |
|
disabledTags = newDisabledTags; |
|
|
|
// Don't call updateGraph() here - the effect will handle it |
|
} |
|
</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} |
|
/> |
|
|
|
<!-- Settings Panel (shown when settings button is clicked) --> |
|
<Settings |
|
count={events.length} |
|
{totalCount} |
|
{onupdate} |
|
{onclear} |
|
{onFetchMissing} |
|
bind:starVisualization |
|
bind:showTagAnchors |
|
bind:selectedTagType |
|
bind:tagExpansionDepth |
|
bind:requirePublications |
|
{eventCounts} |
|
/> |
|
|
|
<!-- 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>
|
|
|