diff --git a/src/app.css b/src/app.css index 21e1a48..7a68ff9 100644 --- a/src/app.css +++ b/src/app.css @@ -210,6 +210,20 @@ .network-node-content { @apply fill-primary-100; } + + /* Person link colors */ + .person-link-signed { + @apply stroke-green-500; + } + + .person-link-referenced { + @apply stroke-blue-400; + } + + /* Person anchor node */ + .person-anchor-node { + @apply fill-green-400 stroke-green-600; + } } /* Utilities can be applied via the @apply directive. */ diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index e24014d..909cad8 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -18,13 +18,16 @@ showTagAnchors = $bindable(false), selectedTagType = $bindable("t"), tagExpansionDepth = $bindable(0), - requirePublications = $bindable(true), onTagSettingsChange = () => {}, showPersonNodes = $bindable(false), personAnchors = [], disabledPersons = new Set(), onPersonToggle = (pubkey: string) => {}, onPersonSettingsChange = () => {}, + showSignedBy = $bindable(true), + showReferenced = $bindable(true), + totalPersonCount = 0, + displayedPersonCount = 0, } = $props<{ collapsedOnInteraction: boolean; className: string; @@ -38,13 +41,16 @@ showTagAnchors?: boolean; selectedTagType?: string; tagExpansionDepth?: number; - requirePublications?: boolean; onTagSettingsChange?: () => void; showPersonNodes?: boolean; personAnchors?: any[]; disabledPersons?: Set; onPersonToggle?: (pubkey: string) => void; onPersonSettingsChange?: () => void; + showSignedBy?: boolean; + showReferenced?: boolean; + totalPersonCount?: number; + displayedPersonCount?: number; }>(); let expanded = $state(true); @@ -200,32 +206,11 @@ > - - {#if selectedTagType === "p" && (!eventCounts[3] || eventCounts[3] === 0)} -

- ⚠️ No follow lists loaded. Enable kind 3 events to see people tag anchors. -

- {/if} - - {#if selectedTagType === "p" && eventCounts[3] > 0} -
- - Only show people with publications -
- {/if} @@ -336,22 +321,51 @@ {#if personVisualizerExpanded}
-
- - Show Person Nodes +
+
+ + Show Person Nodes +
+ + {#if showPersonNodes} +
+ + +
+ {/if}
{#if showPersonNodes && personAnchors.length > 0}

- {personAnchors.length} people found. Click to toggle visibility: + {#if totalPersonCount > displayedPersonCount} + Displaying {displayedPersonCount} of {totalPersonCount} people found. Click to toggle visibility: + {:else} + {personAnchors.length} people found. Click to toggle visibility: + {/if}

onPersonToggle(person.pubkey)} - title={isDisabled ? `Click to show ${person.displayName || person.pubkey}` : `Click to hide ${person.displayName || person.pubkey}`} + onclick={() => { + if (showPersonNodes) { + onPersonToggle(person.pubkey); + } + }} + disabled={!showPersonNodes} + title={!showPersonNodes ? 'Enable "Show Person Nodes" first' : isDisabled ? `Click to show ${person.displayName || person.pubkey}` : `Click to hide ${person.displayName || person.pubkey}`} >
{person.displayName || person.pubkey.slice(0, 8) + '...'} - {#if !isDisabled && person.eventCount} - ({person.eventCount}) + {#if !isDisabled} + + ({person.signedByCount || 0}s/{person.referencedCount || 0}r) + {/if} diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index d0e7902..2928441 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -131,7 +131,6 @@ let selectedTagType = $state("t"); // Default to hashtags let tagAnchorInfo = $state([]); let tagExpansionDepth = $state(0); // Default to no expansion - let requirePublications = $state(true); // Default to only showing people with publications // Store initial state to detect if component is being recreated let componentId = Math.random(); @@ -161,6 +160,11 @@ let showPersonNodes = $state(false); let personAnchorInfo = $state([]); let disabledPersons = $state(new Set()); + let showSignedBy = $state(true); + let showReferenced = $state(true); + let personMap = $state>(new Map()); + let totalPersonCount = $state(0); + let displayedPersonCount = $state(0); // Debug function - call from browser console: window.debugTagAnchors() if (typeof window !== "undefined") { @@ -293,126 +297,12 @@ height }); - // For "p" tags, we need to extract pubkeys from follow lists - // but only show anchors for pubkeys that have events in the visualization - let eventsForTags = events; - - if (selectedTagType === "p" && followListEvents.length > 0) { - // Extract all pubkeys from follow lists - const followedPubkeys = new Set(); - followListEvents.forEach(event => { - event.tags.forEach(tag => { - if (tag[0] === "p" && tag[1]) { - followedPubkeys.add(tag[1]); - } - }); - }); - - const syntheticEvents: NDKEvent[] = []; - - // Create a map to track which events each followed pubkey is connected to - const pubkeyToEvents = new Map>(); - - // Find all connections for followed pubkeys - followedPubkeys.forEach(pubkey => { - const connectedEventIds = new Set(); - - // Find events they authored - events.forEach(event => { - if (event.pubkey === pubkey && event.id) { - connectedEventIds.add(event.id); - } - }); - - // Find events where they're tagged with "p" - events.forEach(event => { - if (event.id && event.tags) { - event.tags.forEach(tag => { - if (tag[0] === 'p' && tag[1] === pubkey) { - connectedEventIds.add(event.id); - } - }); - } - }); - - if (connectedEventIds.size > 0) { - pubkeyToEvents.set(pubkey, connectedEventIds); - } - }); - - if (requirePublications) { - // Only show people who have connections to events - pubkeyToEvents.forEach((eventIds, pubkey) => { - // Create synthetic events for each connection - eventIds.forEach(eventId => { - const syntheticEvent = { - id: eventId, // Use the actual event's ID so it connects properly - tags: [["p", pubkey]], - pubkey: "", - created_at: 0, - kind: 0, - content: "", - sig: "" - } as NDKEvent; - syntheticEvents.push(syntheticEvent); - }); - }); - } else { - // Show all people from follow lists - let syntheticId = 0; - - // First, add people who have event connections - pubkeyToEvents.forEach((eventIds, pubkey) => { - eventIds.forEach(eventId => { - const syntheticEvent = { - id: eventId, // Use the actual event's ID so it connects properly - tags: [["p", pubkey]], - pubkey: "", - created_at: 0, - kind: 0, - content: "", - sig: "" - } as NDKEvent; - syntheticEvents.push(syntheticEvent); - }); - }); - - // Then, add remaining people without any connections - followedPubkeys.forEach(pubkey => { - if (!pubkeyToEvents.has(pubkey)) { - const syntheticEvent = { - id: `synthetic-p-${syntheticId++}`, // Create unique IDs for those without events - tags: [["p", pubkey]], - pubkey: "", - created_at: 0, - kind: 0, - content: "", - sig: "" - } as NDKEvent; - syntheticEvents.push(syntheticEvent); - } - }); - } - - eventsForTags = syntheticEvents; - debug("Created synthetic events for p tags", { - followedPubkeys: followedPubkeys.size, - requirePublications, - syntheticEvents: syntheticEvents.length - }); - } - // Get the display limit based on tag type let displayLimit: number | undefined; - if (selectedTagType === "p") { - // For people tags, use kind 0 (profiles) limit - const kind0Config = get(visualizationConfig).eventConfigs.find(ec => ec.kind === 0); - displayLimit = kind0Config?.limit || 50; - } graphData = enhanceGraphWithTags( graphData, - eventsForTags, + events, selectedTagType, width, height, @@ -433,11 +323,6 @@ count: n.connectedNodes?.length || 0, color: getTagAnchorColor(n.tagType || ""), })); - - // Add a message if People tag type is selected but no follow lists are loaded - if (selectedTagType === "p" && followListEvents.length === 0 && tagAnchors.length === 0) { - console.warn("[EventNetwork] No follow lists loaded. Enable kind 3 events with appropriate depth to see people tag anchors."); - } } else { tagAnchorInfo = []; } @@ -447,31 +332,45 @@ debug("Creating person anchor nodes"); // Extract unique persons from events - const personMap = extractUniquePersons(events); + personMap = extractUniquePersons(events); + + // Create person anchor nodes based on filters + const personResult = createPersonAnchorNodes( + personMap, + width, + height, + showSignedBy, + showReferenced + ); - // Create person anchor nodes - const personAnchors = createPersonAnchorNodes(personMap, width, height); + const personAnchors = personResult.nodes; + totalPersonCount = personResult.totalCount; + displayedPersonCount = personAnchors.length; // Create links between person anchors and their events - const personLinks = createPersonLinks(personAnchors, graphData.nodes); + const personLinks = createPersonLinks(personAnchors, graphData.nodes, personMap); // Add person anchors to the graph graphData.nodes = [...graphData.nodes, ...personAnchors]; graphData.links = [...graphData.links, ...personLinks]; // Extract person info for legend - personAnchorInfo = extractPersonAnchorInfo(personAnchors); + personAnchorInfo = extractPersonAnchorInfo(personAnchors, personMap); - // Auto-disable all person nodes by default - personAnchors.forEach(anchor => { - if (anchor.pubkey) { - disabledPersons.add(anchor.pubkey); - } - }); + // Auto-disable all person nodes by default (only on first show) + if (disabledPersons.size === 0) { + personAnchors.forEach(anchor => { + if (anchor.pubkey) { + disabledPersons.add(anchor.pubkey); + } + }); + } debug("Person anchors created", { count: personAnchors.length, - disabled: disabledPersons.size + disabled: disabledPersons.size, + showSignedBy, + showReferenced }); } else { personAnchorInfo = []; @@ -618,10 +517,26 @@ (enter: any) => enter .append("path") - .attr("class", "link network-link-leather") + .attr("class", (d: any) => { + let classes = "link network-link-leather"; + if (d.connectionType === "signed-by") { + classes += " person-link-signed"; + } else if (d.connectionType === "referenced") { + classes += " person-link-referenced"; + } + return classes; + }) .attr("stroke-width", 2) .attr("marker-end", "url(#arrowhead)"), - (update: any) => update, + (update: any) => update.attr("class", (d: any) => { + let classes = "link network-link-leather"; + if (d.connectionType === "signed-by") { + classes += " person-link-signed"; + } else if (d.connectionType === "referenced") { + classes += " person-link-referenced"; + } + return classes; + }), (exit: any) => exit.remove(), ); @@ -1013,7 +928,9 @@ tagType: selectedTagType, disabled: disabledTags.size, persons: showPersonNodes, - disabledPersons: disabledPersons.size + disabledPersons: disabledPersons.size, + showSignedBy, + showReferenced }; isUpdating = true; @@ -1234,7 +1151,6 @@ bind:showTagAnchors bind:selectedTagType bind:tagExpansionDepth - bind:requirePublications onTagSettingsChange={() => { // Trigger graph update when tag settings change if (svg && events?.length) { @@ -1251,6 +1167,10 @@ updateGraph(); } }} + bind:showSignedBy + bind:showReferenced + {totalPersonCount} + {displayedPersonCount} /> diff --git a/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts b/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts index 8ac0f9b..38f7ed7 100644 --- a/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts +++ b/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts @@ -10,6 +10,7 @@ import { getDisplayNameSync } from "$lib/utils/profileCache"; const PERSON_ANCHOR_RADIUS = 15; const PERSON_ANCHOR_PLACEMENT_RADIUS = 1000; +const MAX_PERSON_NODES = 20; // Default limit for person nodes /** * Simple seeded random number generator @@ -40,25 +41,52 @@ function createSeed(str: string): number { return Math.abs(hash); } +export interface PersonConnection { + signedByEventIds: Set; + referencedInEventIds: Set; +} + /** * Extracts unique persons (pubkeys) from events + * Tracks both signed-by (event.pubkey) and referenced (["p", pubkey] tags) */ export function extractUniquePersons( events: NDKEvent[] -): Map> { - // Map of pubkey -> Set of event IDs - const personMap = new Map>(); +): Map { + // Map of pubkey -> PersonConnection + const personMap = new Map(); console.log(`[PersonBuilder] Extracting persons from ${events.length} events`); events.forEach((event) => { - if (!event.pubkey || !event.id) return; + if (!event.id) return; - if (!personMap.has(event.pubkey)) { - personMap.set(event.pubkey, new Set()); + // Track signed-by connections + if (event.pubkey) { + if (!personMap.has(event.pubkey)) { + personMap.set(event.pubkey, { + signedByEventIds: new Set(), + referencedInEventIds: new Set() + }); + } + personMap.get(event.pubkey)!.signedByEventIds.add(event.id); } - personMap.get(event.pubkey)!.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() + }); + } + personMap.get(referencedPubkey)!.referencedInEventIds.add(event.id); + } + }); + } }); console.log(`[PersonBuilder] Found ${personMap.size} unique persons`); @@ -70,16 +98,56 @@ export function extractUniquePersons( * Creates person anchor nodes */ export function createPersonAnchorNodes( - personMap: Map>, + personMap: Map, width: number, - height: number -): NetworkNode[] { + 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; - Array.from(personMap.entries()).forEach(([pubkey, eventIds]) => { + // Calculate eligible persons and their connection counts + const eligiblePersons: Array<{ + pubkey: string; + connection: PersonConnection; + connectedEventIds: Set; + totalConnections: number; + }> = []; + + Array.from(personMap.entries()).forEach(([pubkey, connection]) => { + // Get all connected event IDs based on filters + const connectedEventIds = new Set(); + + if (showSignedBy) { + connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); + } + + if (showReferenced) { + connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); + } + + // Skip if no connections match the filter + if (connectedEventIds.size === 0) return; + + eligiblePersons.push({ + pubkey, + connection, + connectedEventIds, + totalConnections: connectedEventIds.size + }); + }); + + // Sort by total connections (descending) and take only top N + eligiblePersons.sort((a, b) => b.totalConnections - a.totalConnections); + const limitedPersons = eligiblePersons.slice(0, limit); + + // Create nodes for the limited set + limitedPersons.forEach(({ pubkey, connection, connectedEventIds }) => { + // Create seeded random generator for consistent positioning const rng = new SeededRandom(createSeed(pubkey)); @@ -95,7 +163,7 @@ export function createPersonAnchorNodes( const anchorNode: NetworkNode = { id: `person-anchor-${pubkey}`, title: displayName, - content: `${eventIds.size} events`, + content: `${connection.signedByEventIds.size} signed, ${connection.referencedInEventIds.size} referenced`, author: "", kind: 0, // Special kind for anchors type: "PersonAnchor", @@ -103,7 +171,7 @@ export function createPersonAnchorNodes( isPersonAnchor: true, pubkey, displayName, - connectedNodes: Array.from(eventIds), + connectedNodes: Array.from(connectedEventIds), x, y, fx: x, // Fix position @@ -113,29 +181,50 @@ export function createPersonAnchorNodes( anchorNodes.push(anchorNode); }); - return anchorNodes; + 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[] -): NetworkLink[] { - const links: NetworkLink[] = []; + nodes: NetworkNode[], + personMap: Map +): PersonLink[] { + const links: PersonLink[] = []; const nodeMap = new Map(nodes.map((n) => [n.id, n])); personAnchors.forEach((anchor) => { - if (!anchor.connectedNodes) return; + if (!anchor.connectedNodes || !anchor.pubkey) return; + + const connection = personMap.get(anchor.pubkey); + if (!connection) return; anchor.connectedNodes.forEach((nodeId) => { const node = nodeMap.get(nodeId); if (node) { + // Determine connection type + let connectionType: "signed-by" | "referenced" | undefined; + if (connection.signedByEventIds.has(nodeId)) { + connectionType = "signed-by"; + } else if (connection.referencedInEventIds.has(nodeId)) { + connectionType = "referenced"; + } + links.push({ source: anchor, target: node, isSequential: false, + connectionType, }); } }); @@ -150,18 +239,24 @@ export function createPersonLinks( export interface PersonAnchorInfo { pubkey: string; displayName: string; - eventCount: number; + signedByCount: number; + referencedCount: number; } /** * Extracts person info for Legend display */ export function extractPersonAnchorInfo( - personAnchors: NetworkNode[] + personAnchors: NetworkNode[], + personMap: Map ): PersonAnchorInfo[] { - return personAnchors.map(anchor => ({ - pubkey: anchor.pubkey || "", - displayName: anchor.displayName || "", - eventCount: anchor.connectedNodes?.length || 0, - })); + 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, + }; + }); } \ No newline at end of file