clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

343 lines
9.4 KiB

/**
* Network Builder Utilities
*
* This module provides utilities for building a network graph from Nostr events.
* It handles the creation of nodes and links, and the processing of event relationships.
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools";
import { standardRelays } from "$lib/consts";
import { getMatchingTags } from "$lib/utils/nostrUtils";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KIND = 30041;
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[NetworkBuilder]", ...args);
}
}
/**
* Creates a NetworkNode from an NDKEvent
*
* Extracts relevant information from the event and creates a node representation
* for the visualization.
*
* @param event - The Nostr event to convert to a node
* @param level - The hierarchy level of the node (default: 0)
* @returns A NetworkNode object representing the event
*/
export function createNetworkNode(
event: NDKEvent,
level: number = 0,
): NetworkNode {
debug("Creating network node", {
eventId: event.id,
kind: event.kind,
level,
});
const isContainer = event.kind === INDEX_EVENT_KIND;
const nodeType = isContainer ? "Index" : "Content";
// Create the base node with essential properties
const node: NetworkNode = {
id: event.id,
event,
isContainer,
level,
title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled",
content: event.content || "",
author: event.pubkey || "",
kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined
type: nodeType,
};
// Add NIP-19 identifiers if possible
if (event.kind && event.pubkey) {
try {
const dTag = event.getMatchingTags("d")?.[0]?.[1] || "";
// Create naddr (NIP-19 address) for the event
node.naddr = nip19.naddrEncode({
pubkey: event.pubkey,
identifier: dTag,
kind: event.kind,
relays: standardRelays,
});
// Create nevent (NIP-19 event reference) for the event
node.nevent = nip19.neventEncode({
id: event.id,
relays: standardRelays,
kind: event.kind,
});
} catch (error) {
console.warn("Failed to generate identifiers for node:", error);
}
}
return node;
}
/**
* Creates a map of event IDs to events for quick lookup
*
* @param events - Array of Nostr events
* @returns Map of event IDs to events
*/
export function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> {
debug("Creating event map", { eventCount: events.length });
const eventMap = new Map<string, NDKEvent>();
events.forEach((event) => {
if (event.id) {
eventMap.set(event.id, event);
}
});
debug("Event map created", { mapSize: eventMap.size });
return eventMap;
}
/**
* Extracts an event ID from an 'a' tag
*
* @param tag - The tag array from a Nostr event
* @returns The event ID or null if not found
*/
export function extractEventIdFromATag(tag: string[]): string | null {
return tag[3] || null;
}
/**
* Generates a deterministic color for an event based on its ID
*
* This creates visually distinct colors for different index events
* while ensuring the same event always gets the same color.
*
* @param eventId - The event ID to generate a color for
* @returns An HSL color string
*/
export function getEventColor(eventId: string): string {
// Use first 4 characters of event ID as a hex number
const num = parseInt(eventId.slice(0, 4), 16);
// Convert to a hue value (0-359)
const hue = num % 360;
// Use fixed saturation and lightness for pastel colors
const saturation = 70;
const lightness = 75;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
/**
* Initializes the graph state from a set of events
*
* Creates nodes for all events and identifies referenced events.
*
* @param events - Array of Nostr events
* @returns Initial graph state
*/
export function initializeGraphState(events: NDKEvent[]): GraphState {
debug("Initializing graph state", { eventCount: events.length });
const nodeMap = new Map<string, NetworkNode>();
const eventMap = createEventMap(events);
// Create initial nodes for all events
events.forEach((event) => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
});
debug("Node map created", { nodeCount: nodeMap.size });
// Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>();
events.forEach((event) => {
const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", {
eventId: event.id,
aTagCount: aTags.length,
});
aTags.forEach((tag) => {
const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id);
});
});
debug("Referenced IDs set created", { referencedCount: referencedIds.size });
return {
nodeMap,
links: [],
eventMap,
referencedIds,
};
}
/**
* Processes a sequence of nodes referenced by an index event
*
* Creates links between the index and its content, and between sequential content nodes.
* Also processes nested indices recursively up to the maximum level.
*
* @param sequence - Array of nodes in the sequence
* @param indexEvent - The index event referencing the sequence
* @param level - Current hierarchy level
* @param state - Current graph state
* @param maxLevel - Maximum hierarchy level to process
*/
export function processSequence(
sequence: NetworkNode[],
indexEvent: NDKEvent,
level: number,
state: GraphState,
maxLevel: number,
): void {
// Stop if we've reached max level or have no nodes
if (level >= maxLevel || sequence.length === 0) return;
// Set levels for all nodes in the sequence
sequence.forEach((node) => {
node.level = level + 1;
});
// Create link from index to first content node
const indexNode = state.nodeMap.get(indexEvent.id);
if (indexNode && sequence[0]) {
state.links.push({
source: indexNode,
target: sequence[0],
isSequential: true,
});
}
// Create sequential links between content nodes
for (let i = 0; i < sequence.length - 1; i++) {
const currentNode = sequence[i];
const nextNode = sequence[i + 1];
state.links.push({
source: currentNode,
target: nextNode,
isSequential: true,
});
// Process nested indices recursively
if (currentNode.isContainer) {
processNestedIndex(currentNode, level + 1, state, maxLevel);
}
}
// Process the last node if it's an index
const lastNode = sequence[sequence.length - 1];
if (lastNode?.isContainer) {
processNestedIndex(lastNode, level + 1, state, maxLevel);
}
}
/**
* Processes a nested index node
*
* @param node - The index node to process
* @param level - Current hierarchy level
* @param state - Current graph state
* @param maxLevel - Maximum hierarchy level to process
*/
export function processNestedIndex(
node: NetworkNode,
level: number,
state: GraphState,
maxLevel: number,
): void {
if (!node.isContainer || level >= maxLevel) return;
const nestedEvent = state.eventMap.get(node.id);
if (nestedEvent) {
processIndexEvent(nestedEvent, level, state, maxLevel);
}
}
/**
* Processes an index event and its referenced content
*
* @param indexEvent - The index event to process
* @param level - Current hierarchy level
* @param state - Current graph state
* @param maxLevel - Maximum hierarchy level to process
*/
export function processIndexEvent(
indexEvent: NDKEvent,
level: number,
state: GraphState,
maxLevel: number,
): void {
if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
const sequence = getMatchingTags(indexEvent, "a")
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))
.filter((node): node is NetworkNode => node !== undefined);
processSequence(sequence, indexEvent, level, state, maxLevel);
}
/**
* Generates a complete graph from a set of events
*
* This is the main entry point for building the network visualization.
*
* @param events - Array of Nostr events
* @param maxLevel - Maximum hierarchy level to process
* @returns Complete graph data for visualization
*/
export function generateGraph(events: NDKEvent[], maxLevel: number): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel });
// Initialize the graph state
const state = initializeGraphState(events);
// Find root index events (those not referenced by other events)
const rootIndices = events.filter(
(e) =>
e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id),
);
debug("Found root indices", {
rootCount: rootIndices.length,
rootIds: rootIndices.map((e) => e.id),
});
// Process each root index
rootIndices.forEach((rootIndex) => {
debug("Processing root index", {
rootId: rootIndex.id,
aTags: getMatchingTags(rootIndex, "a").length,
});
processIndexEvent(rootIndex, 0, state, maxLevel);
});
// Create the final graph data
const result = {
nodes: Array.from(state.nodeMap.values()),
links: state.links,
};
debug("Graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length,
});
return result;
}