11 changed files with 1808 additions and 335 deletions
@ -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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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; |
||||||
|
} |
||||||
Loading…
Reference in new issue