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.
337 lines
9.3 KiB
337 lines
9.3 KiB
/** |
|
* Person Network Builder |
|
* |
|
* Creates person anchor nodes for event authors in the network visualization |
|
*/ |
|
|
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
import type { NetworkNode, NetworkLink } from "../types"; |
|
import { getDisplayNameSync } from "$lib/utils/profileCache"; |
|
import { SeededRandom, createDebugFunction } from "./common"; |
|
|
|
const PERSON_ANCHOR_RADIUS = 15; |
|
const PERSON_ANCHOR_PLACEMENT_RADIUS = 1000; |
|
const MAX_PERSON_NODES = 20; // Default limit for person nodes |
|
|
|
// Debug function |
|
const debug = createDebugFunction("PersonNetworkBuilder"); |
|
|
|
|
|
/** |
|
* 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; |
|
} |
|
return Math.abs(hash); |
|
} |
|
|
|
export interface PersonConnection { |
|
signedByEventIds: Set<string>; |
|
referencedInEventIds: Set<string>; |
|
isFromFollowList?: boolean; // Track if this person comes from follow lists |
|
} |
|
|
|
/** |
|
* Extracts unique persons (pubkeys) from events |
|
* Tracks both signed-by (event.pubkey) and referenced (["p", pubkey] tags) |
|
*/ |
|
export function extractUniquePersons( |
|
events: NDKEvent[], |
|
followListEvents?: NDKEvent[] |
|
): Map<string, PersonConnection> { |
|
// Map of pubkey -> PersonConnection |
|
const personMap = new Map<string, PersonConnection>(); |
|
|
|
debug("Extracting unique persons", { eventCount: events.length, followListCount: followListEvents?.length || 0 }); |
|
|
|
// First collect pubkeys from follow list events |
|
const followListPubkeys = new Set<string>(); |
|
if (followListEvents && followListEvents.length > 0) { |
|
followListEvents.forEach((event) => { |
|
// Follow list author |
|
if (event.pubkey) { |
|
followListPubkeys.add(event.pubkey); |
|
} |
|
// People in follow lists (p tags) |
|
if (event.tags) { |
|
event.tags |
|
.filter(tag => { |
|
tag[0] === 'p' |
|
}) |
|
.forEach(tag => { |
|
followListPubkeys.add(tag[1]); |
|
}); |
|
} |
|
}); |
|
} |
|
|
|
events.forEach((event) => { |
|
if (!event.id) return; |
|
|
|
// Track signed-by connections |
|
if (event.pubkey) { |
|
if (!personMap.has(event.pubkey)) { |
|
personMap.set(event.pubkey, { |
|
signedByEventIds: new Set(), |
|
referencedInEventIds: new Set(), |
|
isFromFollowList: followListPubkeys.has(event.pubkey) |
|
}); |
|
} |
|
personMap.get(event.pubkey)!.signedByEventIds.add(event.id); |
|
} |
|
|
|
// Track referenced connections from "p" tags |
|
if (event.tags) { |
|
event.tags.forEach(tag => { |
|
if (tag[0] === "p" && tag[1]) { |
|
const referencedPubkey = tag[1]; |
|
if (!personMap.has(referencedPubkey)) { |
|
personMap.set(referencedPubkey, { |
|
signedByEventIds: new Set(), |
|
referencedInEventIds: new Set(), |
|
isFromFollowList: followListPubkeys.has(referencedPubkey) |
|
}); |
|
} |
|
personMap.get(referencedPubkey)!.referencedInEventIds.add(event.id); |
|
} |
|
}); |
|
} |
|
}); |
|
|
|
debug("Extracted persons", { personCount: personMap.size }); |
|
|
|
return personMap; |
|
} |
|
|
|
/** |
|
* Helper to build eligible person info for anchor nodes. |
|
*/ |
|
function buildEligiblePerson( |
|
pubkey: string, |
|
connection: PersonConnection, |
|
showSignedBy: boolean, |
|
showReferenced: boolean |
|
): { |
|
pubkey: string; |
|
connection: PersonConnection; |
|
connectedEventIds: Set<string>; |
|
totalConnections: number; |
|
} | null { |
|
const connectedEventIds = new Set<string>(); |
|
|
|
if (showSignedBy) { |
|
connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); |
|
} |
|
|
|
if (showReferenced) { |
|
connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); |
|
} |
|
|
|
if (connectedEventIds.size === 0) { |
|
return null; |
|
} |
|
|
|
return { |
|
pubkey, |
|
connection, |
|
connectedEventIds, |
|
totalConnections: connectedEventIds.size |
|
}; |
|
} |
|
|
|
type EligiblePerson = { |
|
pubkey: string; |
|
connection: PersonConnection; |
|
totalConnections: number; |
|
connectedEventIds: Set<string>; |
|
}; |
|
|
|
function getEligiblePersons( |
|
personMap: Map<string, PersonConnection>, |
|
showSignedBy: boolean, |
|
showReferenced: boolean, |
|
limit: number |
|
): EligiblePerson[] { |
|
// Build eligible persons and keep only top N using a min-heap or partial sort |
|
const eligible: EligiblePerson[] = []; |
|
|
|
for (const [pubkey, connection] of personMap) { |
|
let totalConnections = 0; |
|
if (showSignedBy) totalConnections += connection.signedByEventIds.size; |
|
if (showReferenced) totalConnections += connection.referencedInEventIds.size; |
|
if (totalConnections === 0) continue; |
|
|
|
// Only build the set if this person is eligible |
|
const connectedEventIds = new Set<string>(); |
|
if (showSignedBy) { |
|
connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); |
|
} |
|
if (showReferenced) { |
|
connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); |
|
} |
|
|
|
eligible.push({ pubkey, connection, totalConnections, connectedEventIds }); |
|
} |
|
|
|
// Partial sort: get top N by totalConnections |
|
eligible.sort((a, b) => b.totalConnections - a.totalConnections); |
|
return eligible.slice(0, limit); |
|
} |
|
|
|
/** |
|
* Creates person anchor nodes |
|
*/ |
|
export function createPersonAnchorNodes( |
|
personMap: Map<string, PersonConnection>, |
|
width: number, |
|
height: number, |
|
showSignedBy: boolean, |
|
showReferenced: boolean, |
|
limit: number = MAX_PERSON_NODES |
|
): { nodes: NetworkNode[], totalCount: number } { |
|
const anchorNodes: NetworkNode[] = []; |
|
|
|
const centerX = width / 2; |
|
const centerY = height / 2; |
|
|
|
// Calculate eligible persons and their connection counts |
|
const eligiblePersons = getEligiblePersons(personMap, showSignedBy, showReferenced, limit); |
|
|
|
// Create nodes for the limited set |
|
debug("Creating person anchor nodes", { |
|
eligibleCount: eligiblePersons.length, |
|
limitedCount: eligiblePersons.length, |
|
showSignedBy, |
|
showReferenced |
|
}); |
|
|
|
eligiblePersons.forEach(({ pubkey, connection, connectedEventIds }) => { |
|
// Create seeded random generator for consistent positioning |
|
const rng = new SeededRandom(createSeed(pubkey)); |
|
|
|
// Generate deterministic position |
|
const angle = rng.next() * 2 * Math.PI; |
|
const distance = rng.next() * PERSON_ANCHOR_PLACEMENT_RADIUS; |
|
const x = centerX + distance * Math.cos(angle); |
|
const y = centerY + distance * Math.sin(angle); |
|
|
|
// Get display name |
|
const displayName = getDisplayNameSync(pubkey); |
|
|
|
const anchorNode: NetworkNode = { |
|
id: `person-anchor-${pubkey}`, |
|
title: displayName, |
|
content: `${connection.signedByEventIds.size} signed, ${connection.referencedInEventIds.size} referenced`, |
|
author: "", |
|
kind: 0, // Special kind for anchors |
|
type: "PersonAnchor", |
|
level: -1, |
|
isPersonAnchor: true, |
|
pubkey, |
|
displayName, |
|
connectedNodes: Array.from(connectedEventIds), |
|
isFromFollowList: connection.isFromFollowList, |
|
x, |
|
y, |
|
fx: x, // Fix position |
|
fy: y, |
|
}; |
|
|
|
anchorNodes.push(anchorNode); |
|
}); |
|
|
|
debug("Created person anchor nodes", { count: anchorNodes.length, totalEligible: eligiblePersons.length }); |
|
|
|
return { |
|
nodes: anchorNodes, |
|
totalCount: eligiblePersons.length |
|
}; |
|
} |
|
|
|
export interface PersonLink extends NetworkLink { |
|
connectionType?: "signed-by" | "referenced"; |
|
} |
|
|
|
/** |
|
* Creates links between person anchors and their events |
|
* Adds connection type for coloring |
|
*/ |
|
export function createPersonLinks( |
|
personAnchors: NetworkNode[], |
|
nodes: NetworkNode[], |
|
personMap: Map<string, PersonConnection> |
|
): PersonLink[] { |
|
debug("Creating person links", { anchorCount: personAnchors.length, nodeCount: nodes.length }); |
|
|
|
const nodeMap = new Map(nodes.map((n) => [n.id, n])); |
|
|
|
const links: PersonLink[] = personAnchors.flatMap((anchor) => { |
|
if (!anchor.connectedNodes || !anchor.pubkey) { |
|
return []; |
|
} |
|
|
|
const connection = personMap.get(anchor.pubkey); |
|
if (!connection) { |
|
return []; |
|
} |
|
|
|
return anchor.connectedNodes.map((nodeId) => { |
|
const node = nodeMap.get(nodeId); |
|
if (!node) { |
|
return undefined; |
|
} |
|
|
|
let connectionType: 'signed-by' | 'referenced' | undefined; |
|
if (connection.signedByEventIds.has(nodeId)) { |
|
connectionType = 'signed-by'; |
|
} else if (connection.referencedInEventIds.has(nodeId)) { |
|
connectionType = 'referenced'; |
|
} |
|
|
|
return { |
|
source: anchor, |
|
target: node, |
|
isSequential: false, |
|
connectionType, |
|
}; |
|
}).filter(Boolean); // Remove undefineds |
|
}); |
|
|
|
debug("Created person links", { linkCount: links.length }); |
|
return links; |
|
} |
|
|
|
/** |
|
* Formats person anchor info for display in Legend |
|
*/ |
|
export interface PersonAnchorInfo { |
|
pubkey: string; |
|
displayName: string; |
|
signedByCount: number; |
|
referencedCount: number; |
|
isFromFollowList: boolean; |
|
} |
|
|
|
/** |
|
* Extracts person info for Legend display |
|
*/ |
|
export function extractPersonAnchorInfo( |
|
personAnchors: NetworkNode[], |
|
personMap: Map<string, PersonConnection> |
|
): PersonAnchorInfo[] { |
|
return personAnchors.map(anchor => { |
|
const connection = personMap.get(anchor.pubkey || ""); |
|
return { |
|
pubkey: anchor.pubkey || "", |
|
displayName: anchor.displayName || "", |
|
signedByCount: connection?.signedByEventIds.size || 0, |
|
referencedCount: connection?.referencedInEventIds.size || 0, |
|
isFromFollowList: connection?.isFromFollowList || false, |
|
}; |
|
}); |
|
} |