Browse Source

Implement person visualizer with diamond nodes

- 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 <noreply@anthropic.com>
master
limina1 9 months ago
parent
commit
37babea3ae
  1. 92
      src/lib/navigator/EventNetwork/Legend.svelte
  2. 190
      src/lib/navigator/EventNetwork/index.svelte
  3. 7
      src/lib/navigator/EventNetwork/types.ts
  4. 167
      src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts

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

@ -20,6 +20,11 @@ @@ -20,6 +20,11 @@
tagExpansionDepth = $bindable(0),
requirePublications = $bindable(true),
onTagSettingsChange = () => {},
showPersonNodes = $bindable(false),
personAnchors = [],
disabledPersons = new Set<string>(),
onPersonToggle = (pubkey: string) => {},
onPersonSettingsChange = () => {},
} = $props<{
collapsedOnInteraction: boolean;
className: string;
@ -35,12 +40,18 @@ @@ -35,12 +40,18 @@
tagExpansionDepth?: number;
requirePublications?: boolean;
onTagSettingsChange?: () => void;
showPersonNodes?: boolean;
personAnchors?: any[];
disabledPersons?: Set<string>;
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 @@ @@ -303,6 +314,79 @@
{/if}
</div>
{/if}
<!-- Person Visualizer Section -->
<div class="legend-section">
<div class="legend-section-header" onclick={() => personVisualizerExpanded = !personVisualizerExpanded}>
<h4 class="legend-section-title">Person Visualizer</h4>
<Button
color="none"
outline
size="xs"
class="rounded-full p-1"
>
{#if personVisualizerExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</Button>
</div>
{#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>
{#if showPersonNodes && personAnchors.length > 0}
<p class="text-xs text-gray-600 dark:text-gray-400">
{personAnchors.length} people found. Click to toggle visibility:
</p>
<div
class="tag-grid {personAnchors.length > 20 ? 'scrollable' : ''}"
style="grid-template-columns: repeat(2, 1fr);"
>
{#each personAnchors as person}
{@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}`}
>
<div class="legend-icon">
<span
class="legend-diamond"
style="background-color: #10B981; opacity: {isDisabled ? 0.3 : 1};"
/>
</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}
</span>
</button>
{/each}
</div>
{:else if showPersonNodes}
<p class="text-xs text-gray-500 dark:text-gray-400">
No people found in the current events.
</p>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
@ -491,4 +575,12 @@ @@ -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;
}
</style>

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

@ -36,6 +36,12 @@ @@ -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 @@ @@ -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<any[]>([]);
let disabledPersons = $state(new Set<string>());
// Debug function - call from browser console: window.debugTagAnchors()
if (typeof window !== "undefined") {
@ -431,6 +442,41 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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
}
</script>
<div class="network-container">
@ -1117,6 +1241,16 @@ @@ -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();
}
}}
/>
<!-- Settings Panel (shown when settings button is clicked) -->

7
src/lib/navigator/EventNetwork/types.ts

@ -43,7 +43,7 @@ export interface NetworkNode extends SimulationNodeDatum { @@ -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 { @@ -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
}
/**

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

@ -0,0 +1,167 @@ @@ -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<string, Set<string>> {
// Map of pubkey -> Set of event IDs
const personMap = new Map<string, Set<string>>();
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<string, Set<string>>,
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,
}));
}
Loading…
Cancel
Save