Browse Source

Enhance person visualizer with connection types and limits

- Limited person nodes to 20 by default to prevent graph overload
- Added "Displaying X of Y people found" message in Legend
- Implemented signed-by vs referenced connection tracking
- Added checkboxes to filter by connection type (signed by / referenced)
- Different link colors: green for signed-by, blue for referenced
- Removed "People (from follow lists)" from tag types (now handled by person visualizer)
- Consolidated all person connections into single node per pubkey
- Display count shows (Xs/Yr) for signed-by/referenced counts
- Disabled person toggle clicks when Show Person Nodes is off
- Cleaned up unused requirePublications code

This makes the person visualizer more manageable and informative while preventing performance issues from too many nodes.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
master
limina1 9 months ago
parent
commit
46a5d8fe18
  1. 14
      src/app.css
  2. 99
      src/lib/navigator/EventNetwork/Legend.svelte
  3. 194
      src/lib/navigator/EventNetwork/index.svelte
  4. 145
      src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts

14
src/app.css

@ -210,6 +210,20 @@ @@ -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. */

99
src/lib/navigator/EventNetwork/Legend.svelte

@ -18,13 +18,16 @@ @@ -18,13 +18,16 @@
showTagAnchors = $bindable(false),
selectedTagType = $bindable("t"),
tagExpansionDepth = $bindable(0),
requirePublications = $bindable(true),
onTagSettingsChange = () => {},
showPersonNodes = $bindable(false),
personAnchors = [],
disabledPersons = new Set<string>(),
onPersonToggle = (pubkey: string) => {},
onPersonSettingsChange = () => {},
showSignedBy = $bindable(true),
showReferenced = $bindable(true),
totalPersonCount = 0,
displayedPersonCount = 0,
} = $props<{
collapsedOnInteraction: boolean;
className: string;
@ -38,13 +41,16 @@ @@ -38,13 +41,16 @@
showTagAnchors?: boolean;
selectedTagType?: string;
tagExpansionDepth?: number;
requirePublications?: boolean;
onTagSettingsChange?: () => void;
showPersonNodes?: boolean;
personAnchors?: any[];
disabledPersons?: Set<string>;
onPersonToggle?: (pubkey: string) => void;
onPersonSettingsChange?: () => void;
showSignedBy?: boolean;
showReferenced?: boolean;
totalPersonCount?: number;
displayedPersonCount?: number;
}>();
let expanded = $state(true);
@ -200,32 +206,11 @@ @@ -200,32 +206,11 @@
>
<option value="t">Hashtags</option>
<option value="author">Authors</option>
<option value="p">People (from follow lists)</option>
<option value="e">Event References</option>
<option value="title">Titles</option>
<option value="summary">Summaries</option>
</select>
{#if selectedTagType === "p" && (!eventCounts[3] || eventCounts[3] === 0)}
<p class="text-xs text-orange-500 mt-1">
No follow lists loaded. Enable kind 3 events to see people tag anchors.
</p>
{/if}
{#if selectedTagType === "p" && eventCounts[3] > 0}
<div class="flex items-center space-x-2 mt-2">
<button
onclick={() => {
requirePublications = !requirePublications;
onTagSettingsChange();
}}
class="toggle-button small {requirePublications ? 'active' : ''}"
>
{requirePublications ? 'ON' : 'OFF'}
</button>
<span class="text-xs text-gray-600 dark:text-gray-400">Only show people with publications</span>
</div>
{/if}
</div>
<!-- Expansion Depth -->
@ -336,22 +321,51 @@ @@ -336,22 +321,51 @@
{#if personVisualizerExpanded}
<div class="space-y-3">
<!-- Show Person Nodes Toggle -->
<div class="flex items-center space-x-2">
<button
onclick={() => {
showPersonNodes = !showPersonNodes;
onPersonSettingsChange();
}}
class="toggle-button {showPersonNodes ? 'active' : ''}"
>
{showPersonNodes ? 'ON' : 'OFF'}
</button>
<span class="text-sm">Show Person Nodes</span>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button
onclick={() => {
showPersonNodes = !showPersonNodes;
onPersonSettingsChange();
}}
class="toggle-button {showPersonNodes ? 'active' : ''}"
>
{showPersonNodes ? 'ON' : 'OFF'}
</button>
<span class="text-sm">Show Person Nodes</span>
</div>
{#if showPersonNodes}
<div class="flex items-center space-x-3 text-xs">
<label class="flex items-center space-x-1">
<input
type="checkbox"
bind:checked={showSignedBy}
onchange={onPersonSettingsChange}
class="w-3 h-3"
/>
<span>Signed by</span>
</label>
<label class="flex items-center space-x-1">
<input
type="checkbox"
bind:checked={showReferenced}
onchange={onPersonSettingsChange}
class="w-3 h-3"
/>
<span>Referenced</span>
</label>
</div>
{/if}
</div>
{#if showPersonNodes && personAnchors.length > 0}
<p class="text-xs text-gray-600 dark:text-gray-400">
{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}
</p>
<div
class="tag-grid {personAnchors.length > 20 ? 'scrollable' : ''}"
@ -361,8 +375,13 @@ @@ -361,8 +375,13 @@
{@const isDisabled = disabledPersons.has(person.pubkey)}
<button
class="tag-grid-item {isDisabled ? 'disabled' : ''}"
onclick={() => 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}`}
>
<div class="legend-icon">
<span
@ -372,8 +391,10 @@ @@ -372,8 +391,10 @@
</div>
<span class="legend-text text-xs" style="opacity: {isDisabled ? 0.5 : 1};">
{person.displayName || person.pubkey.slice(0, 8) + '...'}
{#if !isDisabled && person.eventCount}
<span class="text-gray-500">({person.eventCount})</span>
{#if !isDisabled}
<span class="text-gray-500">
({person.signedByCount || 0}s/{person.referencedCount || 0}r)
</span>
{/if}
</span>
</button>

194
src/lib/navigator/EventNetwork/index.svelte

@ -131,7 +131,6 @@ @@ -131,7 +131,6 @@
let selectedTagType = $state("t"); // Default to hashtags
let tagAnchorInfo = $state<any[]>([]);
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 @@ @@ -161,6 +160,11 @@
let showPersonNodes = $state(false);
let personAnchorInfo = $state<any[]>([]);
let disabledPersons = $state(new Set<string>());
let showSignedBy = $state(true);
let showReferenced = $state(true);
let personMap = $state<Map<string, any>>(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 @@ @@ -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<string>();
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<string, Set<string>>();
// Find all connections for followed pubkeys
followedPubkeys.forEach(pubkey => {
const connectedEventIds = new Set<string>();
// 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 @@ @@ -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 @@ @@ -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
const personAnchors = createPersonAnchorNodes(personMap, width, height);
// Create person anchor nodes based on filters
const personResult = createPersonAnchorNodes(
personMap,
width,
height,
showSignedBy,
showReferenced
);
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -1251,6 +1167,10 @@
updateGraph();
}
}}
bind:showSignedBy
bind:showReferenced
{totalPersonCount}
{displayedPersonCount}
/>
<!-- Settings Panel (shown when settings button is clicked) -->

145
src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts

@ -10,6 +10,7 @@ import { getDisplayNameSync } from "$lib/utils/profileCache"; @@ -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 { @@ -40,25 +41,52 @@ function createSeed(str: string): number {
return Math.abs(hash);
}
export interface PersonConnection {
signedByEventIds: Set<string>;
referencedInEventIds: Set<string>;
}
/**
* Extracts unique persons (pubkeys) from events
* Tracks both signed-by (event.pubkey) and referenced (["p", pubkey] tags)
*/
export function extractUniquePersons(
events: NDKEvent[]
): Map<string, Set<string>> {
// Map of pubkey -> Set of event IDs
const personMap = new Map<string, Set<string>>();
): Map<string, PersonConnection> {
// Map of pubkey -> PersonConnection
const personMap = new Map<string, PersonConnection>();
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( @@ -70,16 +98,56 @@ export function extractUniquePersons(
* Creates person anchor nodes
*/
export function createPersonAnchorNodes(
personMap: Map<string, Set<string>>,
personMap: Map<string, PersonConnection>,
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<string>;
totalConnections: number;
}> = [];
Array.from(personMap.entries()).forEach(([pubkey, connection]) => {
// Get all connected event IDs based on filters
const connectedEventIds = new Set<string>();
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( @@ -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( @@ -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( @@ -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<string, PersonConnection>
): 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( @@ -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<string, PersonConnection>
): 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,
};
});
}
Loading…
Cancel
Save