From 37babea3ae7c99301693fed880024fde40dcf604 Mon Sep 17 00:00:00 2001 From: limina1 Date: Tue, 17 Jun 2025 14:13:23 -0400 Subject: [PATCH] Implement person visualizer with diamond nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added PersonAnchor type to NetworkNode types - Created personNetworkBuilder utility for extracting and creating person nodes - Added person visualizer section to Legend component with collapsible UI - Implemented diamond shape rendering for person nodes in D3 visualization - Added state management for show/hide person nodes and individual toggles - Auto-disable all person nodes by default to prevent graph overload - Integrated person node filtering into the graph update logic - Added green color (#10B981) for person anchor nodes - Fixed duplicate handleTagToggle function declaration Person nodes now display as diamond shapes and can be individually toggled for visibility. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/navigator/EventNetwork/Legend.svelte | 92 +++++++++ src/lib/navigator/EventNetwork/index.svelte | 190 +++++++++++++++--- src/lib/navigator/EventNetwork/types.ts | 7 +- .../utils/personNetworkBuilder.ts | 167 +++++++++++++++ 4 files changed, 427 insertions(+), 29 deletions(-) create mode 100644 src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index a7c0c97..e24014d 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -20,6 +20,11 @@ tagExpansionDepth = $bindable(0), requirePublications = $bindable(true), onTagSettingsChange = () => {}, + showPersonNodes = $bindable(false), + personAnchors = [], + disabledPersons = new Set(), + onPersonToggle = (pubkey: string) => {}, + onPersonSettingsChange = () => {}, } = $props<{ collapsedOnInteraction: boolean; className: string; @@ -35,12 +40,18 @@ tagExpansionDepth?: number; requirePublications?: boolean; onTagSettingsChange?: () => void; + showPersonNodes?: boolean; + personAnchors?: any[]; + disabledPersons?: Set; + onPersonToggle?: (pubkey: string) => void; + onPersonSettingsChange?: () => void; }>(); let expanded = $state(true); let nodeTypesExpanded = $state(true); let tagAnchorsExpanded = $state(true); let tagControlsExpanded = $state(true); + let personVisualizerExpanded = $state(true); $effect(() => { if (collapsedOnInteraction) { @@ -303,6 +314,79 @@ {/if} {/if} + + +
+
personVisualizerExpanded = !personVisualizerExpanded}> +

Person Visualizer

+ +
+ + {#if personVisualizerExpanded} +
+ +
+ + Show Person Nodes +
+ + {#if showPersonNodes && personAnchors.length > 0} +

+ {personAnchors.length} people found. Click to toggle visibility: +

+
+ {#each personAnchors as person} + {@const isDisabled = disabledPersons.has(person.pubkey)} + + {/each} +
+ {:else if showPersonNodes} +

+ No people found in the current events. +

+ {/if} +
+ {/if} +
{/if} @@ -491,4 +575,12 @@ :global(.dark) .toggle-button.active:hover { background-color: #2563eb; } + + .legend-diamond { + display: inline-block; + width: 14px; + height: 14px; + transform: rotate(45deg); + border: 2px solid white; + } diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index 73c1e4c..d0e7902 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -36,6 +36,12 @@ enhanceGraphWithTags, getTagAnchorColor, } from "./utils/tagNetworkBuilder"; + import { + extractUniquePersons, + createPersonAnchorNodes, + createPersonLinks, + extractPersonAnchorInfo, + } from "./utils/personNetworkBuilder"; import { Button } from "flowbite-svelte"; import { visualizationConfig } from "$lib/stores/visualizationConfig"; import { get } from "svelte/store"; @@ -150,6 +156,11 @@ // Maximum number of tag anchors before auto-disabling const MAX_TAG_ANCHORS = 20; + + // Person nodes state + let showPersonNodes = $state(false); + let personAnchorInfo = $state([]); + let disabledPersons = $state(new Set()); // Debug function - call from browser console: window.debugTagAnchors() if (typeof window !== "undefined") { @@ -431,6 +442,41 @@ tagAnchorInfo = []; } + // Add person nodes if enabled + if (showPersonNodes) { + debug("Creating person anchor nodes"); + + // Extract unique persons from events + const personMap = extractUniquePersons(events); + + // Create person anchor nodes + const personAnchors = createPersonAnchorNodes(personMap, width, height); + + // Create links between person anchors and their events + const personLinks = createPersonLinks(personAnchors, graphData.nodes); + + // Add person anchors to the graph + graphData.nodes = [...graphData.nodes, ...personAnchors]; + graphData.links = [...graphData.links, ...personLinks]; + + // Extract person info for legend + personAnchorInfo = extractPersonAnchorInfo(personAnchors); + + // Auto-disable all person nodes by default + personAnchors.forEach(anchor => { + if (anchor.pubkey) { + disabledPersons.add(anchor.pubkey); + } + }); + + debug("Person anchors created", { + count: personAnchors.length, + disabled: disabledPersons.size + }); + } else { + personAnchorInfo = []; + } + // Save current node positions before updating if (simulation && nodes.length > 0) { nodes.forEach(node => { @@ -449,23 +495,26 @@ nodes = graphData.nodes; links = graphData.links; - // Filter out disabled tag anchors from nodes and links - if (showTagAnchors && disabledTags.size > 0) { - // Filter out disabled tag anchor nodes + // Filter out disabled tag anchors and person nodes from nodes and links + if ((showTagAnchors && disabledTags.size > 0) || (showPersonNodes && disabledPersons.size > 0)) { + // Filter out disabled nodes nodes = nodes.filter((node: NetworkNode) => { if (node.isTagAnchor) { const tagId = `${node.tagType}-${node.title}`; return !disabledTags.has(tagId); } + if (node.isPersonAnchor && node.pubkey) { + return !disabledPersons.has(node.pubkey); + } return true; }); - // Filter out links to disabled tag anchors + // Filter out links to disabled nodes links = links.filter((link: NetworkLink) => { const source = link.source as NetworkNode; const target = link.target as NetworkNode; - // Check if either node is a disabled tag anchor + // Check if either node is disabled if (source.isTagAnchor) { const tagId = `${source.tagType}-${source.title}`; if (disabledTags.has(tagId)) return false; @@ -474,6 +523,12 @@ const tagId = `${target.tagType}-${target.title}`; if (disabledTags.has(tagId)) return false; } + if (source.isPersonAnchor && source.pubkey) { + if (disabledPersons.has(source.pubkey)) return false; + } + if (target.isPersonAnchor && target.pubkey) { + if (disabledPersons.has(target.pubkey)) return false; + } return true; }); @@ -591,12 +646,27 @@ .attr("stroke", "transparent") .style("cursor", "move"); - // Visible circle - nodeEnter - .append("circle") - .attr("class", "visual-circle") - .attr("r", NODE_RADIUS) - .attr("stroke-width", 2); + // Add shape based on node type + nodeEnter.each(function(d: NetworkNode) { + const g = d3.select(this); + if (d.isPersonAnchor) { + // Diamond shape for person anchors + g.append("rect") + .attr("class", "visual-shape visual-diamond") + .attr("width", NODE_RADIUS * 1.5) + .attr("height", NODE_RADIUS * 1.5) + .attr("x", -NODE_RADIUS * 0.75) + .attr("y", -NODE_RADIUS * 0.75) + .attr("transform", "rotate(45)") + .attr("stroke-width", 2); + } else { + // Circle for other nodes + g.append("circle") + .attr("class", "visual-shape visual-circle") + .attr("r", NODE_RADIUS) + .attr("stroke-width", 2); + } + }); // Node label nodeEnter @@ -624,9 +694,13 @@ // Update visual properties for ALL nodes (both new and existing) node - .select("circle.visual-circle") + .select(".visual-shape") .attr("class", (d: NetworkNode) => { - const baseClasses = "visual-circle network-node-leather"; + const shapeClass = d.isPersonAnchor ? "visual-diamond" : "visual-circle"; + const baseClasses = `visual-shape ${shapeClass} network-node-leather`; + if (d.isPersonAnchor) { + return `${baseClasses} person-anchor-node`; + } if (d.isTagAnchor) { return `${baseClasses} tag-anchor-node`; } @@ -639,6 +713,10 @@ return baseClasses; }) .style("fill", (d: NetworkNode) => { + // Person anchors are green + if (d.isPersonAnchor) { + return "#10B981"; + } // Tag anchors get their specific colors if (d.isTagAnchor) { return getTagAnchorColor(d.tagType || ""); @@ -649,6 +727,8 @@ }) .attr("opacity", 1) .attr("r", (d: NetworkNode) => { + // Only set radius for circles + if (d.isPersonAnchor) return null; // Tag anchors are smaller if (d.isTagAnchor) { return NODE_RADIUS * 0.75; @@ -659,7 +739,31 @@ } return NODE_RADIUS; }) + .attr("width", (d: NetworkNode) => { + // Only set width/height for diamonds + if (!d.isPersonAnchor) return null; + return NODE_RADIUS * 1.5; + }) + .attr("height", (d: NetworkNode) => { + // Only set width/height for diamonds + if (!d.isPersonAnchor) return null; + return NODE_RADIUS * 1.5; + }) + .attr("x", (d: NetworkNode) => { + // Only set x/y for diamonds + if (!d.isPersonAnchor) return null; + return -NODE_RADIUS * 0.75; + }) + .attr("y", (d: NetworkNode) => { + // Only set x/y for diamonds + if (!d.isPersonAnchor) return null; + return -NODE_RADIUS * 0.75; + }) .attr("stroke-width", (d: NetworkNode) => { + // Person anchors have thicker stroke + if (d.isPersonAnchor) { + return 3; + } // Tag anchors have thicker stroke if (d.isTagAnchor) { return 3; @@ -907,7 +1011,9 @@ star: starVisualization, tags: showTagAnchors, tagType: selectedTagType, - disabled: disabledTags.size + disabled: disabledTags.size, + persons: showPersonNodes, + disabledPersons: disabledPersons.size }; isUpdating = true; @@ -1007,6 +1113,38 @@ } }); + /** + * Handles toggling tag visibility + */ + function handleTagToggle(tagId: string) { + if (disabledTags.has(tagId)) { + const newDisabledTags = new Set(disabledTags); + newDisabledTags.delete(tagId); + disabledTags = newDisabledTags; + } else { + const newDisabledTags = new Set(disabledTags); + newDisabledTags.add(tagId); + disabledTags = newDisabledTags; + } + // Update graph will be triggered by the effect + } + + /** + * Handles toggling person node visibility + */ + function handlePersonToggle(pubkey: string) { + if (disabledPersons.has(pubkey)) { + const newDisabledPersons = new Set(disabledPersons); + newDisabledPersons.delete(pubkey); + disabledPersons = newDisabledPersons; + } else { + const newDisabledPersons = new Set(disabledPersons); + newDisabledPersons.add(pubkey); + disabledPersons = newDisabledPersons; + } + // Update graph will be triggered by the effect + } + /** * Handles tooltip close event */ @@ -1063,20 +1201,6 @@ } } - /** - * Handles toggling tag visibility in the legend - */ - function handleTagToggle(tagId: string) { - const newDisabledTags = new Set(disabledTags); - if (newDisabledTags.has(tagId)) { - newDisabledTags.delete(tagId); - } else { - newDisabledTags.add(tagId); - } - disabledTags = newDisabledTags; - - // Don't call updateGraph() here - the effect will handle it - }
@@ -1117,6 +1241,16 @@ updateGraph(); } }} + bind:showPersonNodes + personAnchors={personAnchorInfo} + {disabledPersons} + onPersonToggle={handlePersonToggle} + onPersonSettingsChange={() => { + // Trigger graph update when person settings change + if (svg && events?.length) { + updateGraph(); + } + }} /> diff --git a/src/lib/navigator/EventNetwork/types.ts b/src/lib/navigator/EventNetwork/types.ts index 97be9b8..d0d579c 100644 --- a/src/lib/navigator/EventNetwork/types.ts +++ b/src/lib/navigator/EventNetwork/types.ts @@ -43,7 +43,7 @@ export interface NetworkNode extends SimulationNodeDatum { title: string; // Event title content: string; // Event content author: string; // Author's public key - type: "Index" | "Content" | "TagAnchor"; // Node type classification + type: "Index" | "Content" | "TagAnchor" | "PersonAnchor"; // Node type classification naddr?: string; // NIP-19 naddr identifier nevent?: string; // NIP-19 nevent identifier isContainer?: boolean; // Whether this node is a container (index) @@ -53,6 +53,11 @@ export interface NetworkNode extends SimulationNodeDatum { tagType?: string; // Type of tag (t, p, e, etc.) tagValue?: string; // The tag value connectedNodes?: string[]; // IDs of nodes that have this tag + + // Person anchor specific fields + isPersonAnchor?: boolean; // Whether this is a person anchor node + pubkey?: string; // The person's public key + displayName?: string; // The person's display name from kind 0 } /** diff --git a/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts b/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts new file mode 100644 index 0000000..8ac0f9b --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts @@ -0,0 +1,167 @@ +/** + * 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"; + +const PERSON_ANCHOR_RADIUS = 15; +const PERSON_ANCHOR_PLACEMENT_RADIUS = 1000; + +/** + * Simple seeded random number generator + */ +class SeededRandom { + private seed: number; + + constructor(seed: number) { + this.seed = seed; + } + + 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; + } + return Math.abs(hash); +} + +/** + * Extracts unique persons (pubkeys) from events + */ +export function extractUniquePersons( + events: NDKEvent[] +): Map> { + // Map of pubkey -> Set of event IDs + const personMap = new Map>(); + + console.log(`[PersonBuilder] Extracting persons from ${events.length} events`); + + events.forEach((event) => { + if (!event.pubkey || !event.id) return; + + if (!personMap.has(event.pubkey)) { + personMap.set(event.pubkey, new Set()); + } + + personMap.get(event.pubkey)!.add(event.id); + }); + + console.log(`[PersonBuilder] Found ${personMap.size} unique persons`); + + return personMap; +} + +/** + * Creates person anchor nodes + */ +export function createPersonAnchorNodes( + personMap: Map>, + width: number, + height: number +): NetworkNode[] { + const anchorNodes: NetworkNode[] = []; + + const centerX = width / 2; + const centerY = height / 2; + + Array.from(personMap.entries()).forEach(([pubkey, eventIds]) => { + // 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: `${eventIds.size} events`, + author: "", + kind: 0, // Special kind for anchors + type: "PersonAnchor", + level: -1, + isPersonAnchor: true, + pubkey, + displayName, + connectedNodes: Array.from(eventIds), + x, + y, + fx: x, // Fix position + fy: y, + }; + + anchorNodes.push(anchorNode); + }); + + return anchorNodes; +} + +/** + * Creates links between person anchors and their events + */ +export function createPersonLinks( + personAnchors: NetworkNode[], + nodes: NetworkNode[] +): NetworkLink[] { + const links: NetworkLink[] = []; + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + personAnchors.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; +} + +/** + * Formats person anchor info for display in Legend + */ +export interface PersonAnchorInfo { + pubkey: string; + displayName: string; + eventCount: number; +} + +/** + * Extracts person info for Legend display + */ +export function extractPersonAnchorInfo( + personAnchors: NetworkNode[] +): PersonAnchorInfo[] { + return personAnchors.map(anchor => ({ + pubkey: anchor.pubkey || "", + displayName: anchor.displayName || "", + eventCount: anchor.connectedNodes?.length || 0, + })); +} \ No newline at end of file