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'}
+
+
+
+
+ | Tag |
+ Count |
+
+
+
+ {#each uniqueTags as tag}
+
+ | {tag.value} |
+ {tag.count} |
+
+ {/each}
+
+
+
+{: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}
-
{ errorMessage = null; updateGraph(); }}
+ onclick={() => {
+ errorMessage = null;
+ updateGraph();
+ }}
>
Retry
@@ -528,50 +718,94 @@
{/if}
-
+
-
-
+
+
-
-
+
+
-
-
-
-
+
-
-
+
@@ -586,7 +820,7 @@
x={tooltipX}
y={tooltipY}
onclose={handleTooltipClose}
+ starMode={starVisualization}
/>
{/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;
+ }
}