11 changed files with 1808 additions and 335 deletions
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
<!-- |
||||
TagTable Component |
||||
Displays a table of unique tags found in the event network |
||||
--> |
||||
<script lang="ts"> |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { Table } from "flowbite-svelte"; |
||||
|
||||
let { events = [], selectedTagType = "t" } = $props<{ |
||||
events: NDKEvent[]; |
||||
selectedTagType: string; |
||||
}>(); |
||||
|
||||
// Computed property for unique tags |
||||
let uniqueTags = $derived(() => { |
||||
const tagMap = new Map(); |
||||
|
||||
events.forEach(event => { |
||||
const tags = event.tags || []; |
||||
tags.forEach(tag => { |
||||
if (tag[0] === selectedTagType) { |
||||
const tagValue = tag[1]; |
||||
const count = tagMap.get(tagValue)?.count || 0; |
||||
tagMap.set(tagValue, { |
||||
value: tagValue, |
||||
count: count + 1, |
||||
// Store first event that references this tag |
||||
firstEvent: tagMap.get(tagValue)?.firstEvent || event.id |
||||
}); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
return Array.from(tagMap.values()) |
||||
.sort((a, b) => b.count - a.count); // Sort by frequency |
||||
}); |
||||
|
||||
// Tag type labels for display |
||||
const tagTypeLabels: Record<string, string> = { |
||||
't': 'Hashtags', |
||||
'author': 'Authors', |
||||
'p': 'People', |
||||
'e': 'Events', |
||||
'title': 'Titles', |
||||
'summary': 'Summaries' |
||||
}; |
||||
</script> |
||||
|
||||
{#if uniqueTags.length > 0} |
||||
<div class="tag-table-container p-4"> |
||||
<h3 class="text-lg font-semibold mb-2"> |
||||
{tagTypeLabels[selectedTagType] || 'Tags'} |
||||
</h3> |
||||
<Table hoverable> |
||||
<thead> |
||||
<tr> |
||||
<th>Tag</th> |
||||
<th>Count</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{#each uniqueTags as tag} |
||||
<tr> |
||||
<td>{tag.value}</td> |
||||
<td>{tag.count}</td> |
||||
</tr> |
||||
{/each} |
||||
</tbody> |
||||
</Table> |
||||
</div> |
||||
{:else} |
||||
<div class="p-4 text-gray-500"> |
||||
No {tagTypeLabels[selectedTagType]?.toLowerCase() || 'tags'} found |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.tag-table-container { |
||||
max-height: 300px; |
||||
overflow-y: auto; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,265 @@
@@ -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<NetworkNode, NetworkLink> { |
||||
// 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<string, NetworkNode>(); |
||||
|
||||
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<string, NetworkNode[]>(); |
||||
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<NetworkNode, NetworkLink> |
||||
): 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); |
||||
} |
||||
@ -0,0 +1,338 @@
@@ -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<string, NetworkNode> |
||||
): StarNetwork[] { |
||||
debug("Creating star networks", { eventCount: events.length, maxLevel }); |
||||
|
||||
// Use existing node map or create new one
|
||||
const nodeMap = existingNodeMap || new Map<string, NetworkNode>(); |
||||
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<string>() |
||||
}; |
||||
|
||||
// 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<string>(); |
||||
|
||||
// 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<string, NetworkNode>(); |
||||
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<string, NetworkNode>(); |
||||
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<string>(); |
||||
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; |
||||
} |
||||
@ -0,0 +1,284 @@
@@ -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<string, Set<string>> { |
||||
// Map of tagValue -> Set of event IDs
|
||||
const tagMap = new Map<string, Set<string>>(); |
||||
|
||||
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<string, Set<string>>, |
||||
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<string, NetworkNode[]>(); |
||||
|
||||
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; |
||||
} |
||||
@ -1,112 +1,178 @@
@@ -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; |
||||
} |
||||
} |
||||
|
||||
.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)); |
||||
} |
||||
|
||||
/* 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; |
||||
} |
||||
|
||||
.tag-anchor-node:hover { |
||||
filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.6)); |
||||
transform: scale(1.1); |
||||
} |
||||
|
||||
/* Ensure drag cursor doesn't appear on tag anchors */ |
||||
.node.drag-circle { |
||||
cursor: move; |
||||
} |
||||
|
||||
.node.tag-anchor-node .drag-circle { |
||||
cursor: default !important; |
||||
} |
||||
|
||||
/* 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%; |
||||
} |
||||
|
||||
.tag-grid-item { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.25rem; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.tag-grid-item .legend-text { |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
flex: 1; |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue