From c1c64860b32ff1a514a26283a21b8faa72e5e66c Mon Sep 17 00:00:00 2001 From: limina1 Date: Fri, 23 May 2025 13:44:37 -0400 Subject: [PATCH] Add star layout, tag-anchor visualization --- src/lib/navigator/EventNetwork/Legend.svelte | 185 +++++-- .../navigator/EventNetwork/NodeTooltip.svelte | 92 ++-- .../navigator/EventNetwork/Settings.svelte | 117 ++++- .../navigator/EventNetwork/TagTable.svelte | 82 +++ src/lib/navigator/EventNetwork/index.svelte | 482 +++++++++++++----- src/lib/navigator/EventNetwork/types.ts | 8 +- .../EventNetwork/utils/forceSimulation.ts | 20 +- .../EventNetwork/utils/starForceSimulation.ts | 265 ++++++++++ .../EventNetwork/utils/starNetworkBuilder.ts | 338 ++++++++++++ .../EventNetwork/utils/tagNetworkBuilder.ts | 284 +++++++++++ src/styles/visualize.css | 270 ++++++---- 11 files changed, 1808 insertions(+), 335 deletions(-) create mode 100644 src/lib/navigator/EventNetwork/TagTable.svelte create mode 100644 src/lib/navigator/EventNetwork/utils/starForceSimulation.ts create mode 100644 src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts create mode 100644 src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index 024037f..a7f253f 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -1,12 +1,24 @@ -
+

Settings

-
diff --git a/src/lib/navigator/EventNetwork/TagTable.svelte b/src/lib/navigator/EventNetwork/TagTable.svelte new file mode 100644 index 0000000..fa02295 --- /dev/null +++ b/src/lib/navigator/EventNetwork/TagTable.svelte @@ -0,0 +1,82 @@ + + + +{#if uniqueTags.length > 0} +
+

+ {tagTypeLabels[selectedTagType] || 'Tags'} +

+ + + + + + + + + {#each uniqueTags as tag} + + + + + {/each} + +
TagCount
{tag.value}{tag.count}
+
+{:else} +
+ No {tagTypeLabels[selectedTagType]?.toLowerCase() || 'tags'} found +
+{/if} + + \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index 7742475..58ffffa 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -11,19 +11,32 @@ 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 + 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 {Button} from 'flowbite-svelte'; - + import { + enhanceGraphWithTags, + getTagAnchorColor, + } from "./utils/tagNetworkBuilder"; + import { Button } from "flowbite-svelte"; + // Type alias for D3 selections type Selection = any; @@ -34,7 +47,7 @@ const ARROW_DISTANCE = 10; const CONTENT_COLOR_LIGHT = "#d6c1a8"; const CONTENT_COLOR_DARK = "#FFFFFF"; - + /** * Debug logging function that only logs when DEBUG is true */ @@ -43,9 +56,12 @@ console.log("[EventNetwork]", ...args); } } - + // Component props - let { events = [], onupdate } = $props<{ events?: NDKEvent[], onupdate: () => void }>(); + let { events = [], onupdate } = $props<{ + events?: NDKEvent[]; + onupdate: () => void; + }>(); // Error state let errorMessage = $state(null); @@ -54,10 +70,10 @@ // DOM references let svg: SVGSVGElement; let container: HTMLDivElement; - + // Theme state let isDarkMode = $state(false); - + // Tooltip state let selectedNodeId = $state(null); let tooltipVisible = $state(false); @@ -69,8 +85,10 @@ let width = $state(1000); let height = $state(600); let windowHeight = $state(undefined); - let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400); - + let graphHeight = $derived( + windowHeight ? Math.max(windowHeight * 0.2, 400) : 400, + ); + // D3 objects let simulation: Simulation | null = null; let svgGroup: Selection; @@ -80,6 +98,30 @@ // 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([]); + + // 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) { @@ -100,8 +142,7 @@ } debug("SVG dimensions", { width, height }); - const svgElement = d3.select(svg) - .attr("viewBox", `0 0 ${width} ${height}`); + const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`); // Clear existing content svgElement.selectAll("*").remove(); @@ -147,80 +188,125 @@ 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 + debug("Generating graph with events", { + eventCount: events.length, + currentLevels, + starVisualization, + showTagAnchors, }); - - const graphData = generateGraph(events, Number(currentLevels)); + + let graphData = starVisualization + ? generateStarGraph(events, Number(currentLevels)) + : generateGraph(events, Number(currentLevels)); + + // Enhance with tag anchors if enabled + if (showTagAnchors) { + graphData = enhanceGraphWithTags( + graphData, + events, + selectedTagType, + width, + height, + ); + + // Extract tag anchor info for legend + tagAnchorInfo = graphData.nodes + .filter((n) => n.isTagAnchor) + .map((n) => ({ + type: n.tagType, + label: n.title, + count: n.connectedNodes?.length || 0, + color: getTagAnchorColor(n.tagType || ""), + })); + } else { + tagAnchorInfo = []; + } + nodes = graphData.nodes; links = graphData.links; - - debug("Generated graph data", { - nodeCount: nodes.length, - linkCount: links.length + + 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); - + if (starVisualization) { + // Use star-specific simulation + simulation = createStarSimulation(nodes, links, width, height); + // Apply initial star positioning + applyInitialStarPositions(nodes, links, width, height); + } else { + // Use regular simulation + simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE); + } + // Center the nodes when the simulation is done - simulation.on("end", () => { - centerGraph(); - }); - + if (simulation) { + simulation.on("end", () => { + if (!starVisualization) { + centerGraph(); + } + }); + } + // Create drag handler - dragHandler = setupDragHandlers(simulation); - + 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)"), + (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() + (exit: any) => exit.remove(), ); - + // Update nodes debug("Updating nodes"); node = svgGroup @@ -255,29 +341,101 @@ .attr("dy", "0.35em") .attr("text-anchor", "middle") .attr("fill", "black") - .attr("font-size", "12px"); + .attr("font-size", "12px") + .attr("stroke", "none") + .attr("font-weight", "bold") + .style("pointer-events", "none"); return nodeEnter; }, (update: any) => update, - (exit: any) => exit.remove() + (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("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; + }) + .attr("fill", (d: NetworkNode) => { + // Tag anchors get their specific colors + if (d.isTagAnchor) { + return getTagAnchorColor(d.tagType || ""); + } + // Use consistent colors for both modes + if (!d.isContainer) { + return isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT; + } + // Index nodes get unique pastel colors in both modes + return getEventColor(d.id); + }) + .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"; + } + // Always use I for index and C for content + return d.isContainer ? "I" : "C"; + }) + .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"); - node.select("text") - .text((d: NetworkNode) => d.isContainer ? "I" : "C"); - // Set up node interactions debug("Setting up node interactions"); node @@ -316,18 +474,25 @@ 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()); - }); + 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) => { @@ -337,8 +502,21 @@ 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 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); @@ -349,7 +527,10 @@ }); // Update node positions - node.attr("transform", (d: NetworkNode) => `translate(${d.x},${d.y})`); + node.attr( + "transform", + (d: NetworkNode) => `translate(${d.x},${d.y})`, + ); }); } } catch (error) { @@ -366,14 +547,14 @@ 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; @@ -390,11 +571,15 @@ isDarkMode = newIsDarkMode; // Update node colors when theme changes if (svgGroup) { - svgGroup.selectAll("g.node") + svgGroup + .selectAll("g.node") .select("circle.visual-circle") - .attr("fill", (d: NetworkNode) => !d.isContainer - ? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT - : getEventColor(d.id) + .attr("fill", (d: NetworkNode) => + !d.isContainer + ? newIsDarkMode + ? CONTENT_COLOR_DARK + : CONTENT_COLOR_LIGHT + : getEventColor(d.id), ); } } @@ -423,7 +608,7 @@ attributeFilter: ["class"], }); resizeObserver.observe(container); - + // Clean up on component destruction return () => { themeObserver.disconnect(); @@ -437,16 +622,19 @@ * Watch for changes that should trigger a graph update */ $effect(() => { - debug("Effect triggered", { - hasSvg: !!svg, - eventCount: events?.length, - currentLevels + debug("Effect triggered", { + hasSvg: !!svg, + eventCount: events?.length, + currentLevels, }); - + try { if (svg && events?.length) { - // Include currentLevels in the effect dependencies + // Include all relevant state in the effect dependencies const _ = currentLevels; + const __ = starVisualization; + const ___ = showTagAnchors; + const ____ = selectedTagType; updateGraph(); } } catch (error) { @@ -454,7 +642,7 @@ errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; } }); - + /** * Handles tooltip close event */ @@ -462,7 +650,7 @@ tooltipVisible = false; selectedNodeId = null; } - + /** * Centers the graph in the viewport */ @@ -470,40 +658,39 @@ 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) - ); + 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 - ); + 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 - ); + d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 0.7); } } /** * Legend interactions - */ + */ let graphInteracted = $state(false); function handleGraphClick() { @@ -518,9 +705,12 @@

Error

{errorMessage}

- @@ -528,50 +718,94 @@ {/if}
- + - - + + - - + +
- - -
diff --git a/src/lib/navigator/EventNetwork/types.ts b/src/lib/navigator/EventNetwork/types.ts index db2d46b..ddfef98 100644 --- a/src/lib/navigator/EventNetwork/types.ts +++ b/src/lib/navigator/EventNetwork/types.ts @@ -43,10 +43,16 @@ export interface NetworkNode extends SimulationNodeDatum { title: string; // Event title content: string; // Event content author: string; // Author's public key - type: "Index" | "Content"; // Node type classification + type: "Index" | "Content" | "TagAnchor"; // Node type classification naddr?: string; // NIP-19 naddr identifier nevent?: string; // NIP-19 nevent identifier isContainer?: boolean; // Whether this node is a container (index) + + // Tag anchor specific fields + isTagAnchor?: boolean; // Whether this is a tag anchor node + tagType?: string; // Type of tag (t, p, e, etc.) + tagValue?: string; // The tag value + connectedNodes?: string[]; // IDs of nodes that have this tag } /** diff --git a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts index 34731b3..50fc672 100644 --- a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts +++ b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts @@ -102,6 +102,9 @@ export function applyGlobalLogGravity( centerY: number, alpha: number, ) { + // Tag anchors should not be affected by gravity + if (node.isTagAnchor) return; + const dx = (node.x ?? 0) - centerX; const dy = (node.y ?? 0) - centerY; const distance = Math.sqrt(dx * dx + dy * dy); @@ -127,10 +130,14 @@ export function applyConnectedGravity( links: NetworkLink[], alpha: number, ) { - // Find all nodes connected to this node + // Tag anchors should not be affected by connected gravity + if (node.isTagAnchor) return; + + // Find all nodes connected to this node (excluding tag anchors) const connectedNodes = links .filter(link => link.source.id === node.id || link.target.id === node.id) - .map(link => link.source.id === node.id ? link.target : link.source); + .map(link => link.source.id === node.id ? link.target : link.source) + .filter(n => !n.isTagAnchor); if (connectedNodes.length === 0) return; @@ -168,6 +175,9 @@ export function setupDragHandlers( return d3 .drag() .on("start", (event: D3DragEvent, d: NetworkNode) => { + // Tag anchors should never be draggable + if (d.isTagAnchor) return; + // Warm up simulation if it's cooled down if (!event.active) { simulation.alphaTarget(warmupClickEnergy).restart(); @@ -177,11 +187,17 @@ export function setupDragHandlers( d.fy = d.y; }) .on("drag", (event: D3DragEvent, d: NetworkNode) => { + // Tag anchors should never be draggable + if (d.isTagAnchor) return; + // Update fixed position to mouse position d.fx = event.x; d.fy = event.y; }) .on("end", (event: D3DragEvent, d: NetworkNode) => { + // Tag anchors should never be draggable + if (d.isTagAnchor) return; + // Cool down simulation when drag ends if (!event.active) { simulation.alphaTarget(0); diff --git a/src/lib/navigator/EventNetwork/utils/starForceSimulation.ts b/src/lib/navigator/EventNetwork/utils/starForceSimulation.ts new file mode 100644 index 0000000..a20cb07 --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/starForceSimulation.ts @@ -0,0 +1,265 @@ +/** + * Star Network Force Simulation + * + * Custom force simulation optimized for star network layouts. + * Provides stronger connections between star centers and their content nodes, + * with specialized forces to maintain hierarchical structure. + */ + +import * as d3 from "d3"; +import type { NetworkNode, NetworkLink } from "../types"; +import type { Simulation } from "./forceSimulation"; +import { createTagGravityForce } from "./tagNetworkBuilder"; + +// Configuration for star network forces +const STAR_CENTER_CHARGE = -300; // Stronger repulsion between star centers +const CONTENT_NODE_CHARGE = -50; // Weaker repulsion for content nodes +const STAR_LINK_STRENGTH = 0.5; // Moderate connection to star center +const INTER_STAR_LINK_STRENGTH = 0.2; // Weaker connection between stars +const STAR_LINK_DISTANCE = 80; // Fixed distance from center to content +const INTER_STAR_DISTANCE = 200; // Distance between star centers +const CENTER_GRAVITY = 0.02; // Gentle pull toward canvas center +const STAR_CENTER_WEIGHT = 10; // Weight multiplier for star centers + +/** + * Creates a custom force simulation for star networks + */ +export function createStarSimulation( + nodes: NetworkNode[], + links: NetworkLink[], + width: number, + height: number +): Simulation { + // Create the simulation + const simulation = d3.forceSimulation(nodes) as any + simulation + .force("center", d3.forceCenter(width / 2, height / 2).strength(CENTER_GRAVITY)) + .velocityDecay(0.2) // Lower decay for more responsive simulation + .alphaDecay(0.0001) // Much slower alpha decay to prevent freezing + .alphaMin(0.001); // Keep minimum energy to prevent complete freeze + + // Custom charge force that varies by node type + const chargeForce = d3.forceManyBody() + .strength((d: NetworkNode) => { + // Tag anchors don't repel + if (d.isTagAnchor) { + return 0; + } + // Star centers repel each other strongly + if (d.isContainer && d.kind === 30040) { + return STAR_CENTER_CHARGE; + } + // Content nodes have minimal repulsion + return CONTENT_NODE_CHARGE; + }) + .distanceMax(300); // Limit charge force range + + // Custom link force with variable strength and distance + const linkForce = d3.forceLink(links) + .id((d: NetworkNode) => d.id) + .strength((link: any) => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + // Strong connection from star center to its content + if (source.kind === 30040 && target.kind === 30041) { + return STAR_LINK_STRENGTH; + } + // Weaker connection between star centers + if (source.kind === 30040 && target.kind === 30040) { + return INTER_STAR_LINK_STRENGTH; + } + return 0.5; // Default strength + }) + .distance((link: any) => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + // Fixed distance for star-to-content links + if (source.kind === 30040 && target.kind === 30041) { + return STAR_LINK_DISTANCE; + } + // Longer distance between star centers + if (source.kind === 30040 && target.kind === 30040) { + return INTER_STAR_DISTANCE; + } + return 100; // Default distance + }); + + // Apply forces to simulation + simulation + .force("charge", chargeForce) + .force("link", linkForce); + + // Custom radial force to keep content nodes around their star center + simulation.force("radial", createRadialForce(nodes, links)); + + // Add tag gravity force if there are tag anchors + const hasTagAnchors = nodes.some(n => n.isTagAnchor); + if (hasTagAnchors) { + simulation.force("tagGravity", createTagGravityForce(nodes, links)); + } + + // Periodic reheat to prevent freezing + let tickCount = 0; + simulation.on("tick", () => { + tickCount++; + // Every 300 ticks, give a small energy boost to prevent freezing + if (tickCount % 300 === 0 && simulation.alpha() < 0.01) { + simulation.alpha(0.02); + } + }); + + return simulation; +} + +/** + * Creates a custom radial force that keeps content nodes in orbit around their star center + */ +function createRadialForce(nodes: NetworkNode[], links: NetworkLink[]): any { + // Build a map of content nodes to their star centers + const nodeToCenter = new Map(); + + links.forEach(link => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + if (source.kind === 30040 && target.kind === 30041) { + nodeToCenter.set(target.id, source); + } + }); + + // Custom force function + function force(alpha: number) { + nodes.forEach(node => { + if (node.kind === 30041) { + const center = nodeToCenter.get(node.id); + if (center && center.x != null && center.y != null && node.x != null && node.y != null) { + // Calculate desired position + const dx = node.x - center.x; + const dy = node.y - center.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0) { + // Normalize and apply force + const targetDistance = STAR_LINK_DISTANCE; + const force = (distance - targetDistance) * alpha * 0.3; // Reduced force + node.vx = (node.vx || 0) - (dx / distance) * force; + node.vy = (node.vy || 0) - (dy / distance) * force; + } + } + } + }); + } + + force.initialize = function(_: NetworkNode[]) { + nodes = _; + }; + + return force; +} + +/** + * Applies initial positioning for star networks + */ +export function applyInitialStarPositions( + nodes: NetworkNode[], + links: NetworkLink[], + width: number, + height: number +): void { + // Group nodes by their star centers + const starGroups = new Map(); + const starCenters: NetworkNode[] = []; + + // Identify star centers + nodes.forEach(node => { + if (node.isContainer && node.kind === 30040) { + starCenters.push(node); + starGroups.set(node.id, []); + } + }); + + // Assign content nodes to their star centers + links.forEach(link => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + if (source.kind === 30040 && target.kind === 30041) { + const group = starGroups.get(source.id); + if (group) { + group.push(target); + } + } + }); + + // Position star centers in a grid or circle + if (starCenters.length === 1) { + // Single star - center it + const center = starCenters[0]; + center.x = width / 2; + center.y = height / 2; + // Don't fix position initially - let simulation run naturally + } else if (starCenters.length > 1) { + // Multiple stars - arrange in a circle + const centerX = width / 2; + const centerY = height / 2; + const radius = Math.min(width, height) * 0.3; + const angleStep = (2 * Math.PI) / starCenters.length; + + starCenters.forEach((center, i) => { + const angle = i * angleStep; + center.x = centerX + radius * Math.cos(angle); + center.y = centerY + radius * Math.sin(angle); + // Don't fix position initially - let simulation adjust + }); + } + + // Position content nodes around their star centers + starGroups.forEach((contentNodes, centerId) => { + const center = nodes.find(n => n.id === centerId); + if (!center) return; + + const angleStep = (2 * Math.PI) / Math.max(contentNodes.length, 1); + contentNodes.forEach((node, i) => { + const angle = i * angleStep; + node.x = (center.x || 0) + STAR_LINK_DISTANCE * Math.cos(angle); + node.y = (center.y || 0) + STAR_LINK_DISTANCE * Math.sin(angle); + }); + }); +} + +/** + * Custom drag handler for star networks + */ +export function createStarDragHandler( + simulation: Simulation +): any { + function dragstarted(event: any, d: NetworkNode) { + if (!event.active) simulation.alphaTarget(0.1).restart(); // Lower target for smoother dragging + + // For all nodes, set their fixed position at start + d.fx = d.x; + d.fy = d.y; + } + + function dragged(event: any, d: NetworkNode) { + d.fx = event.x; + d.fy = event.y; + } + + function dragended(event: any, d: NetworkNode) { + if (!event.active) simulation.alphaTarget(0); + + // Tag anchors and star centers stay fixed after dragging + if (d.isTagAnchor || d.kind === 30040) { + d.fx = event.x; + d.fy = event.y; + } else { + // Let content nodes float + d.fx = null; + d.fy = null; + } + } + + return d3.drag() + .on("start", dragstarted) + .on("drag", dragged) + .on("end", dragended); +} \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts b/src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts new file mode 100644 index 0000000..ea1bda8 --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts @@ -0,0 +1,338 @@ +/** + * Star Network Builder for NKBIP-01 Events + * + * This module provides utilities for building star network visualizations specifically + * for NKBIP-01 events (kinds 30040 and 30041). Unlike the sequential network builder, + * this creates star formations where index events (30040) are central nodes with + * content events (30041) arranged around them. + */ + +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; +import { getMatchingTags } from '$lib/utils/nostrUtils'; +import { createNetworkNode, createEventMap, extractEventIdFromATag, getEventColor } from './networkBuilder'; + +// Configuration +const DEBUG = false; +const INDEX_EVENT_KIND = 30040; +const CONTENT_EVENT_KIND = 30041; + +/** + * Debug logging function + */ +function debug(...args: any[]) { + if (DEBUG) { + console.log("[StarNetworkBuilder]", ...args); + } +} + +/** + * Represents a star network with a central index node and peripheral content nodes + */ +export interface StarNetwork { + center: NetworkNode; // Central index node (30040) + peripheralNodes: NetworkNode[]; // Content nodes (30041) and connected indices (30040) + links: NetworkLink[]; // Links within this star +} + +/** + * Creates a star network from an index event and its references + * + * @param indexEvent - The central index event (30040) + * @param state - Current graph state + * @param level - Hierarchy level for this star + * @returns A star network structure + */ +export function createStarNetwork( + indexEvent: NDKEvent, + state: GraphState, + level: number = 0 +): StarNetwork | null { + debug("Creating star network", { indexId: indexEvent.id, level }); + + const centerNode = state.nodeMap.get(indexEvent.id); + if (!centerNode) { + debug("Center node not found for index event", indexEvent.id); + return null; + } + + // Set the center node level + centerNode.level = level; + + // Extract referenced event IDs from 'a' tags + const referencedIds = getMatchingTags(indexEvent, "a") + .map(tag => extractEventIdFromATag(tag)) + .filter((id): id is string => id !== null); + + debug("Found referenced IDs", { count: referencedIds.length, ids: referencedIds }); + + // Get peripheral nodes (both content and nested indices) + const peripheralNodes: NetworkNode[] = []; + const links: NetworkLink[] = []; + + referencedIds.forEach(id => { + const node = state.nodeMap.get(id); + if (node) { + // Set the peripheral node level + node.level = level + 1; + peripheralNodes.push(node); + + // Create link from center to peripheral node + links.push({ + source: centerNode, + target: node, + isSequential: false // Star links are not sequential + }); + + debug("Added peripheral node", { nodeId: id, nodeType: node.type }); + } + }); + + return { + center: centerNode, + peripheralNodes, + links + }; +} + +/** + * Processes all index events to create star networks + * + * @param events - Array of all events + * @param maxLevel - Maximum nesting level to process + * @returns Array of star networks + */ +export function createStarNetworks( + events: NDKEvent[], + maxLevel: number, + existingNodeMap?: Map +): StarNetwork[] { + debug("Creating star networks", { eventCount: events.length, maxLevel }); + + // Use existing node map or create new one + const nodeMap = existingNodeMap || new Map(); + const eventMap = createEventMap(events); + + // Create nodes for all events if not using existing map + if (!existingNodeMap) { + events.forEach(event => { + if (!event.id) return; + const node = createNetworkNode(event); + nodeMap.set(event.id, node); + }); + } + + const state: GraphState = { + nodeMap, + links: [], + eventMap, + referencedIds: new Set() + }; + + // Find all index events + const indexEvents = events.filter(event => event.kind === INDEX_EVENT_KIND); + debug("Found index events", { count: indexEvents.length }); + + const starNetworks: StarNetwork[] = []; + const processedIndices = new Set(); + + // Process all index events regardless of level + indexEvents.forEach(indexEvent => { + if (!indexEvent.id || processedIndices.has(indexEvent.id)) return; + + const star = createStarNetwork(indexEvent, state, 0); + if (star && star.peripheralNodes.length > 0) { + starNetworks.push(star); + processedIndices.add(indexEvent.id); + debug("Created star network", { + centerId: star.center.id, + peripheralCount: star.peripheralNodes.length + }); + } + }); + + return starNetworks; +} + +/** + * Creates inter-star connections between star networks + * + * @param starNetworks - Array of star networks + * @returns Additional links connecting different star networks + */ +export function createInterStarConnections(starNetworks: StarNetwork[]): NetworkLink[] { + debug("Creating inter-star connections", { starCount: starNetworks.length }); + + const interStarLinks: NetworkLink[] = []; + + // Create a map of center nodes for quick lookup + const centerNodeMap = new Map(); + starNetworks.forEach(star => { + centerNodeMap.set(star.center.id, star.center); + }); + + // For each star, check if any of its peripheral nodes are centers of other stars + starNetworks.forEach(star => { + star.peripheralNodes.forEach(peripheralNode => { + // If this peripheral node is the center of another star, create an inter-star link + if (peripheralNode.isContainer && centerNodeMap.has(peripheralNode.id)) { + const targetStar = starNetworks.find(s => s.center.id === peripheralNode.id); + if (targetStar) { + interStarLinks.push({ + source: star.center, + target: targetStar.center, + isSequential: false + }); + debug("Created inter-star connection", { + from: star.center.id, + to: targetStar.center.id + }); + } + } + }); + }); + + return interStarLinks; +} + +/** + * Applies star-specific positioning to nodes using a radial layout + * + * @param starNetworks - Array of star networks + * @param width - Canvas width + * @param height - Canvas height + */ +export function applyStarLayout( + starNetworks: StarNetwork[], + width: number, + height: number +): void { + debug("Applying star layout", { + starCount: starNetworks.length, + dimensions: { width, height } + }); + + const centerX = width / 2; + const centerY = height / 2; + + // If only one star, center it + if (starNetworks.length === 1) { + const star = starNetworks[0]; + + // Position center node + star.center.x = centerX; + star.center.y = centerY; + star.center.fx = centerX; // Fix center position + star.center.fy = centerY; + + // Position peripheral nodes in a circle around center + const radius = Math.min(width, height) * 0.25; + const angleStep = (2 * Math.PI) / star.peripheralNodes.length; + + star.peripheralNodes.forEach((node, index) => { + const angle = index * angleStep; + node.x = centerX + radius * Math.cos(angle); + node.y = centerY + radius * Math.sin(angle); + }); + + return; + } + + // For multiple stars, arrange them in a grid or circle + const starsPerRow = Math.ceil(Math.sqrt(starNetworks.length)); + const starSpacingX = width / (starsPerRow + 1); + const starSpacingY = height / (Math.ceil(starNetworks.length / starsPerRow) + 1); + + starNetworks.forEach((star, index) => { + const row = Math.floor(index / starsPerRow); + const col = index % starsPerRow; + + const starCenterX = (col + 1) * starSpacingX; + const starCenterY = (row + 1) * starSpacingY; + + // Position center node + star.center.x = starCenterX; + star.center.y = starCenterY; + star.center.fx = starCenterX; // Fix center position + star.center.fy = starCenterY; + + // Position peripheral nodes around this star's center + const radius = Math.min(starSpacingX, starSpacingY) * 0.3; + const angleStep = (2 * Math.PI) / Math.max(star.peripheralNodes.length, 1); + + star.peripheralNodes.forEach((node, nodeIndex) => { + const angle = nodeIndex * angleStep; + node.x = starCenterX + radius * Math.cos(angle); + node.y = starCenterY + radius * Math.sin(angle); + }); + }); +} + +/** + * Generates a complete star network graph from events + * + * @param events - Array of Nostr events + * @param maxLevel - Maximum hierarchy level to process + * @returns Complete graph data with star network layout + */ +export function generateStarGraph( + events: NDKEvent[], + maxLevel: number +): GraphData { + debug("Generating star graph", { eventCount: events.length, maxLevel }); + + // Guard against empty events + if (!events || events.length === 0) { + return { nodes: [], links: [] }; + } + + // Initialize all nodes first + const nodeMap = new Map(); + events.forEach(event => { + if (!event.id) return; + const node = createNetworkNode(event); + nodeMap.set(event.id, node); + }); + + // Create star networks with the existing node map + const starNetworks = createStarNetworks(events, maxLevel, nodeMap); + + // Create inter-star connections + const interStarLinks = createInterStarConnections(starNetworks); + + // Collect nodes that are part of stars + const nodesInStars = new Set(); + const allLinks: NetworkLink[] = []; + + // Add nodes and links from all stars + starNetworks.forEach(star => { + nodesInStars.add(star.center.id); + star.peripheralNodes.forEach(node => { + nodesInStars.add(node.id); + }); + allLinks.push(...star.links); + }); + + // Add inter-star links + allLinks.push(...interStarLinks); + + // Include orphaned nodes (those not in any star) + const allNodes: NetworkNode[] = []; + nodeMap.forEach((node, id) => { + allNodes.push(node); + }); + + const result = { + nodes: allNodes, + links: allLinks + }; + + debug("Star graph generation complete", { + nodeCount: result.nodes.length, + linkCount: result.links.length, + starCount: starNetworks.length, + orphanedNodes: allNodes.length - nodesInStars.size + }); + + return result; +} \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts b/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts new file mode 100644 index 0000000..762a079 --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts @@ -0,0 +1,284 @@ +/** + * Tag Network Builder + * + * Enhances network visualizations with tag anchor nodes that act as gravity points + * for nodes sharing the same tags. + */ + +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import type { NetworkNode, NetworkLink, GraphData } from "../types"; + +// Configuration +const TAG_ANCHOR_RADIUS = 15; +// TODO: Move this to settings panel for user control +const TAG_ANCHOR_PLACEMENT_RADIUS = 2500; // Radius from center within which to randomly place tag anchors + +/** + * Simple seeded random number generator (using a Linear Congruential Generator) + * This ensures consistent positioning for the same tag values across sessions + */ +class SeededRandom { + private seed: number; + + constructor(seed: number) { + this.seed = seed; + } + + // Generate next random number between 0 and 1 + next(): number { + this.seed = (this.seed * 9301 + 49297) % 233280; + return this.seed / 233280; + } +} + +/** + * Creates a deterministic seed from a string + */ +function createSeed(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +/** + * Color mapping for tag anchor nodes + */ +export function getTagAnchorColor(tagType: string): string { + switch (tagType) { + case "t": + return "#3B82F6"; // Blue for hashtags + case "p": + return "#10B981"; // Green for people + case "author": + return "#8B5CF6"; // Purple for authors + case "e": + return "#F59E0B"; // Yellow for events + case "a": + return "#EF4444"; // Red for articles + default: + return "#6B7280"; // Gray for others + } +} + +/** + * Extracts unique tags from events for a specific tag type + */ +export function extractUniqueTagsForType( + events: NDKEvent[], + tagType: string, +): Map> { + // Map of tagValue -> Set of event IDs + const tagMap = new Map>(); + + events.forEach((event) => { + if (!event.tags || !event.id) return; + + event.tags.forEach((tag) => { + if (tag.length < 2) return; + + if (tag[0] !== tagType) return; + const tagValue = tag[1]; + + if (!tagValue) return; + + if (!tagMap.has(tagValue)) { + tagMap.set(tagValue, new Set()); + } + + tagMap.get(tagValue)!.add(event.id); + }); + }); + + return tagMap; +} + +/** + * Creates tag anchor nodes from extracted tags of a specific type + */ +export function createTagAnchorNodes( + tagMap: Map>, + tagType: string, + width: number, + height: number, +): NetworkNode[] { + const anchorNodes: NetworkNode[] = []; + + // Calculate positions for tag anchors randomly within radius + const validTags = Array.from(tagMap.entries()).filter( + ([_, eventIds]) => eventIds.size >= 2, + ); + + if (validTags.length === 0) return []; + + validTags.forEach(([tagValue, eventIds]) => { + // Position anchors randomly within a radius from the center + const centerX = width / 2; + const centerY = height / 2; + + // Create seeded random generator based on tag type and value for consistent positioning + const seedString = `${tagType}-${tagValue}`; + const rng = new SeededRandom(createSeed(seedString)); + + // Generate deterministic position within the defined radius + const angle = rng.next() * 2 * Math.PI; + const distance = rng.next() * TAG_ANCHOR_PLACEMENT_RADIUS; + const x = centerX + distance * Math.cos(angle); + const y = centerY + distance * Math.sin(angle); + + // Format the display title based on tag type + let displayTitle = tagValue; + if (tagType === "t") { + displayTitle = tagValue.startsWith("#") ? tagValue : `#${tagValue}`; + } else if (tagType === "author") { + displayTitle = tagValue; + } else if (tagType === "p") { + // Truncate pubkey for display + displayTitle = `${tagValue.substring(0, 8)}...`; + } + + const anchorNode: NetworkNode = { + id: `tag-anchor-${tagType}-${tagValue}`, + title: displayTitle, + content: `${eventIds.size} events`, + author: "", + kind: 0, // Special kind for tag anchors + type: "TagAnchor", + level: -1, // Tag anchors are outside the hierarchy + isTagAnchor: true, + tagType, + tagValue, + connectedNodes: Array.from(eventIds), + x, + y, + fx: x, // Fix position + fy: y, + }; + + anchorNodes.push(anchorNode); + }); + + return anchorNodes; +} + +/** + * Creates invisible links between tag anchors and nodes that have those tags + */ +export function createTagLinks( + tagAnchors: NetworkNode[], + nodes: NetworkNode[], +): NetworkLink[] { + const links: NetworkLink[] = []; + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + tagAnchors.forEach((anchor) => { + if (!anchor.connectedNodes) return; + + anchor.connectedNodes.forEach((nodeId) => { + const node = nodeMap.get(nodeId); + if (node) { + links.push({ + source: anchor, + target: node, + isSequential: false, + }); + } + }); + }); + + return links; +} + +/** + * Enhances a graph with tag anchor nodes for a specific tag type + */ +export function enhanceGraphWithTags( + graphData: GraphData, + events: NDKEvent[], + tagType: string, + width: number, + height: number, +): GraphData { + // Extract unique tags for the specified type + const tagMap = extractUniqueTagsForType(events, tagType); + + // Create tag anchor nodes + const tagAnchors = createTagAnchorNodes(tagMap, tagType, width, height); + + // Create links between anchors and nodes + const tagLinks = createTagLinks(tagAnchors, graphData.nodes); + + // Return enhanced graph + return { + nodes: [...graphData.nodes, ...tagAnchors], + links: [...graphData.links, ...tagLinks], + }; +} + +/** + * Custom force for tag anchor gravity + */ +export function createTagGravityForce( + nodes: NetworkNode[], + links: NetworkLink[], +): any { + // Build a map of nodes to their tag anchors + const nodeToAnchors = new Map(); + + links.forEach((link) => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + + if (source.isTagAnchor && !target.isTagAnchor) { + if (!nodeToAnchors.has(target.id)) { + nodeToAnchors.set(target.id, []); + } + nodeToAnchors.get(target.id)!.push(source); + } else if (target.isTagAnchor && !source.isTagAnchor) { + if (!nodeToAnchors.has(source.id)) { + nodeToAnchors.set(source.id, []); + } + nodeToAnchors.get(source.id)!.push(target); + } + }); + + // Custom force function + function force(alpha: number) { + nodes.forEach((node) => { + if (node.isTagAnchor) return; // Tag anchors don't move + + const anchors = nodeToAnchors.get(node.id); + if (!anchors || anchors.length === 0) return; + + // Apply gentle pull toward each tag anchor + anchors.forEach((anchor) => { + if ( + anchor.x != null && + anchor.y != null && + node.x != null && + node.y != null + ) { + const dx = anchor.x - node.x; + const dy = anchor.y - node.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0) { + // Gentle force that decreases with distance + const strength = (0.02 * alpha) / anchors.length; + node.vx = (node.vx || 0) + (dx / distance) * strength * distance; + node.vy = (node.vy || 0) + (dy / distance) * strength * distance; + } + } + }); + }); + } + + force.initialize = function (_: NetworkNode[]) { + nodes = _; + }; + + return force; +} diff --git a/src/styles/visualize.css b/src/styles/visualize.css index 1ff732d..d0631b5 100644 --- a/src/styles/visualize.css +++ b/src/styles/visualize.css @@ -1,112 +1,178 @@ @layer components { - /* Legend styles - specific to visualization */ - .legend-list { - @apply list-disc mt-2 space-y-2 text-gray-800 dark:text-gray-300; - } - - .legend-item { - @apply flex items-center; - } - - .legend-icon { - @apply relative w-6 h-6 mr-2; - } - - .legend-circle { - @apply absolute inset-0 rounded-full border-2 border-black; - } - - .legend-circle.content { - @apply bg-gray-700 dark:bg-gray-300; - background-color: #d6c1a8; - } - - .legend-circle.content { - background-color: var(--content-color, #d6c1a8); - } - - :global(.dark) .legend-circle.content { - background-color: var(--content-color-dark, #FFFFFF); - } - - .legend-letter { - @apply absolute inset-0 flex items-center justify-center text-black text-xs font-bold; - } - - .legend-text { - @apply text-sm; - } - - /* Network visualization styles - specific to visualization */ - .network-container { - @apply flex flex-col w-full h-[calc(100vh-138px)] min-h-[400px] max-h-[900px]; - } - - .network-svg-container { - @apply relative sm:h-[100%]; - } - - .network-svg { - @apply w-full sm:h-[100%] border; - @apply border border-primary-200 has-[:hover]:border-primary-700 dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500 rounded; - } - - .network-error { - @apply w-full p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded-lg mb-4; - } - - .network-error-title { - @apply font-bold text-lg; - } - - .network-error-retry { - @apply mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700; - } - - .network-debug { - @apply mt-4 text-sm text-gray-500; - } - - /* Zoom controls */ - .network-controls { - @apply absolute bottom-4 right-4 flex flex-col gap-2 z-10; - } - - .network-control-button { - @apply bg-white; - } - - /* Tooltip styles - specific to visualization tooltips */ - .tooltip-close-btn { - @apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 + /* Legend styles - specific to visualization */ + .legend-list { + @apply list-disc mt-2 space-y-2 text-gray-800 dark:text-gray-300; + } + + .legend-item { + @apply flex items-center; + } + + .legend-icon { + @apply relative w-6 h-6 mr-2; + } + + .legend-circle { + @apply absolute inset-0 rounded-full border-2 border-black; + } + + .legend-circle.content { + @apply bg-gray-700 dark:bg-gray-300; + background-color: #d6c1a8; + } + + .legend-circle.content { + background-color: var(--content-color, #d6c1a8); + } + + :global(.dark) .legend-circle.content { + background-color: var(--content-color-dark, #ffffff); + } + + .legend-letter { + @apply absolute inset-0 flex items-center justify-center text-black text-xs font-bold; + } + + .legend-text { + @apply text-sm; + } + + /* Network visualization styles - specific to visualization */ + .network-container { + @apply flex flex-col w-full h-[calc(100vh-138px)] min-h-[400px] max-h-[900px]; + } + + .network-svg-container { + @apply relative sm:h-[100%]; + } + + .network-svg { + @apply w-full sm:h-[100%] border; + @apply border border-primary-200 has-[:hover]:border-primary-700 dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500 rounded; + } + + .network-error { + @apply w-full p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded-lg mb-4; + } + + .network-error-title { + @apply font-bold text-lg; + } + + .network-error-retry { + @apply mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700; + } + + .network-debug { + @apply mt-4 text-sm text-gray-500; + } + + /* Zoom controls */ + .network-controls { + @apply absolute bottom-4 right-4 flex flex-col gap-2 z-10; + } + + .network-control-button { + @apply bg-white; + } + + /* Tooltip styles - specific to visualization tooltips */ + .tooltip-close-btn { + @apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200; - } + } + + .tooltip-content { + @apply space-y-2 pr-6; + } + + .tooltip-title { + @apply font-bold text-base; + } + + .tooltip-title-link { + @apply text-gray-800 hover:text-blue-600 dark:text-gray-200 dark:hover:text-blue-400; + } + + .tooltip-metadata { + @apply text-gray-600 dark:text-gray-400 text-sm; + } + + .tooltip-summary { + @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; + } + + .tooltip-content-preview { + @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; + } + + .tooltip-help-text { + @apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic; + } + + /* Star network visualization styles */ + .star-center-node { + @apply transition-all duration-300; + stroke-width: 3px; + filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.2)); + } + + .star-center-node:hover { + filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.3)); + transform: scale(1.05); + } + + .network-node-content { + @apply transition-all duration-200; + } + + .network-node-content:hover { + @apply transform scale-110; + filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.3)); + } - .tooltip-content { - @apply space-y-2 pr-6; - } + /* Tag anchor nodes */ + .tag-anchor-node { + @apply transition-all duration-300; + stroke: rgba(255, 255, 255, 0.8); + filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.4)); + cursor: default !important; + } - .tooltip-title { - @apply font-bold text-base; - } + .tag-anchor-node:hover { + filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.6)); + transform: scale(1.1); + } - .tooltip-title-link { - @apply text-gray-800 hover:text-blue-600 dark:text-gray-200 dark:hover:text-blue-400; - } + /* Ensure drag cursor doesn't appear on tag anchors */ + .node.drag-circle { + cursor: move; + } - .tooltip-metadata { - @apply text-gray-600 dark:text-gray-400 text-sm; - } + .node.tag-anchor-node .drag-circle { + cursor: default !important; + } - .tooltip-summary { - @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; - } + /* Tag grid layout in legend */ + .tag-grid { + display: grid; + gap: 0.5rem; /* Change this value to adjust spacing between columns and rows */ + column-gap: 1.75rem; /* Use this to set column spacing independently */ + row-gap: 0.5rem; /* Use this to set row spacing independently */ + width: 100%; + } - .tooltip-content-preview { - @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; - } + .tag-grid-item { + display: flex; + align-items: center; + gap: 0.25rem; + overflow: hidden; + } - .tooltip-help-text { - @apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic; - } + .tag-grid-item .legend-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + } }