diff --git a/src/lib/components/cards/BlogHeader.svelte b/src/lib/components/cards/BlogHeader.svelte
index a91d0a4..b80b3cf 100644
--- a/src/lib/components/cards/BlogHeader.svelte
+++ b/src/lib/components/cards/BlogHeader.svelte
@@ -6,6 +6,7 @@
import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte";
+ import { getMatchingTags } from '$lib/utils/nostrUtils';
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>();
diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte
index a7f253f..46c5acc 100644
--- a/src/lib/navigator/EventNetwork/Legend.svelte
+++ b/src/lib/navigator/EventNetwork/Legend.svelte
@@ -12,12 +12,14 @@
starMode = false,
showTags = false,
tagAnchors = [],
+ eventCounts = {},
} = $props<{
collapsedOnInteraction: boolean;
className: string;
starMode?: boolean;
showTags?: boolean;
tagAnchors?: any[];
+ eventCounts?: { [kind: number]: number };
}>();
let expanded = $state(true);
@@ -65,7 +67,7 @@
Index events (kind 30040) - Star centers with unique colors{eventCounts[30040] || 0} Index events (kind 30040) - Star centers with unique colors
@@ -77,7 +79,7 @@
Content nodes (kind 30041) - Arranged around star centers{eventCounts[30041] || 0} Content nodes (kind 30041) - Arranged around star centers
@@ -107,7 +109,7 @@
Index events (kind 30040) - Each with a unique pastel color{eventCounts[30040] || 0} Index events (kind 30040) - Each with a unique pastel color
@@ -119,7 +121,7 @@
Content events (kinds 30041, 30818) - Publication sections{(eventCounts[30041] || 0) + (eventCounts[30818] || 0)} Content events (kinds 30041, 30818) - Publication sections
diff --git a/src/lib/navigator/EventNetwork/Settings.svelte b/src/lib/navigator/EventNetwork/Settings.svelte
index 00e294e..cc31dc3 100644
--- a/src/lib/navigator/EventNetwork/Settings.svelte
+++ b/src/lib/navigator/EventNetwork/Settings.svelte
@@ -17,12 +17,14 @@
starVisualization = $bindable(true),
showTagAnchors = $bindable(false),
selectedTagType = $bindable("t"),
+ tagExpansionDepth = $bindable(0),
} = $props<{
count: number;
onupdate: () => void;
starVisualization?: boolean;
showTagAnchors?: boolean;
selectedTagType?: string;
+ tagExpansionDepth?: number;
}>();
let expanded = $state(false);
@@ -36,6 +38,17 @@
function handleLimitUpdate() {
onupdate();
}
+
+ function handleDepthInput(event: Event) {
+ const input = event.target as HTMLInputElement;
+ const value = parseInt(input.value);
+ // Ensure value is between 0 and 10
+ if (!isNaN(value) && value >= 0 && value <= 10) {
+ tagExpansionDepth = value;
+ } else if (input.value === "") {
+ tagExpansionDepth = 0;
+ }
+ }
@@ -87,25 +100,53 @@
{#if showTagAnchors}
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ (0-10)
+
+
+
+ Fetch publications sharing tags
+
+
{/if}
diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte
index 58ffffa..b09cc25 100644
--- a/src/lib/navigator/EventNetwork/index.svelte
+++ b/src/lib/navigator/EventNetwork/index.svelte
@@ -41,7 +41,7 @@
type Selection = any;
// Configuration
- const DEBUG = false; // Set to true to enable debug logging
+ const DEBUG = true; // Set to true to enable debug logging
const NODE_RADIUS = 20;
const LINK_DISTANCE = 10;
const ARROW_DISTANCE = 10;
@@ -58,9 +58,10 @@
}
// Component props
- let { events = [], onupdate } = $props<{
+ let { events = [], onupdate, onTagExpansionChange } = $props<{
events?: NDKEvent[];
onupdate: () => void;
+ onTagExpansionChange?: (depth: number, tags: string[]) => void;
}>();
// Error state
@@ -94,6 +95,9 @@
let svgGroup: Selection;
let zoomBehavior: any;
let svgElement: Selection;
+
+ // Position cache to preserve node positions across updates
+ let nodePositions = new Map
();
// Track current render level
let currentLevels = $derived(levelsToRender);
@@ -105,6 +109,14 @@
let showTagAnchors = $state(false);
let selectedTagType = $state("t"); // Default to hashtags
let tagAnchorInfo = $state([]);
+ let tagExpansionDepth = $state(0); // Default to no expansion
+
+ // Store initial state to detect if component is being recreated
+ let componentId = Math.random();
+ debug("Component created with ID:", componentId);
+
+ // Event counts by kind
+ let eventCounts = $state<{ [kind: number]: number }>({});
// Debug function - call from browser console: window.debugTagAnchors()
if (typeof window !== "undefined") {
@@ -224,6 +236,13 @@
// Enhance with tag anchors if enabled
if (showTagAnchors) {
+ debug("Enhancing graph with tags", {
+ selectedTagType,
+ eventCount: events.length,
+ width,
+ height
+ });
+
graphData = enhanceGraphWithTags(
graphData,
events,
@@ -233,9 +252,14 @@
);
// Extract tag anchor info for legend
- tagAnchorInfo = graphData.nodes
- .filter((n) => n.isTagAnchor)
- .map((n) => ({
+ const tagAnchors = graphData.nodes.filter((n) => n.isTagAnchor);
+
+ debug("Tag anchors created", {
+ count: tagAnchors.length,
+ anchors: tagAnchors
+ });
+
+ tagAnchorInfo = tagAnchors.map((n) => ({
type: n.tagType,
label: n.title,
count: n.connectedNodes?.length || 0,
@@ -245,12 +269,48 @@
tagAnchorInfo = [];
}
+ // Save current node positions before updating
+ if (simulation && nodes.length > 0) {
+ nodes.forEach(node => {
+ if (node.x != null && node.y != null) {
+ nodePositions.set(node.id, {
+ x: node.x,
+ y: node.y,
+ vx: node.vx,
+ vy: node.vy
+ });
+ }
+ });
+ debug("Saved positions for", nodePositions.size, "nodes");
+ }
+
nodes = graphData.nodes;
links = graphData.links;
+
+ // Count events by kind
+ const counts: { [kind: number]: number } = {};
+ events.forEach(event => {
+ counts[event.kind] = (counts[event.kind] || 0) + 1;
+ });
+ eventCounts = counts;
+
+ // Restore positions for existing nodes
+ let restoredCount = 0;
+ nodes.forEach(node => {
+ const savedPos = nodePositions.get(node.id);
+ if (savedPos && !node.isTagAnchor) { // Don't restore tag anchor positions as they're fixed
+ node.x = savedPos.x;
+ node.y = savedPos.y;
+ node.vx = savedPos.vx || 0;
+ node.vy = savedPos.vy || 0;
+ restoredCount++;
+ }
+ });
debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length,
+ restoredPositions: restoredCount
});
if (!nodes.length) {
@@ -265,15 +325,24 @@
// Create new simulation
debug("Creating new simulation");
+ const hasRestoredPositions = restoredCount > 0;
+
if (starVisualization) {
// Use star-specific simulation
simulation = createStarSimulation(nodes, links, width, height);
- // Apply initial star positioning
- applyInitialStarPositions(nodes, links, width, height);
+ // Apply initial star positioning only if we don't have restored positions
+ if (!hasRestoredPositions) {
+ applyInitialStarPositions(nodes, links, width, height);
+ }
} else {
// Use regular simulation
simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE);
}
+
+ // Use gentler alpha for updates with restored positions
+ if (hasRestoredPositions) {
+ simulation.alpha(0.3); // Gentler restart
+ }
// Center the nodes when the simulation is done
if (simulation) {
@@ -643,6 +712,55 @@
}
});
+ // Track previous values to avoid unnecessary calls
+ let previousDepth = $state(0);
+ let previousTagType = $state(selectedTagType);
+ let isInitialized = $state(false);
+
+ // Mark as initialized after first render
+ $effect(() => {
+ if (!isInitialized && svg) {
+ isInitialized = true;
+ }
+ });
+
+ /**
+ * Watch for tag expansion depth changes
+ */
+ $effect(() => {
+ // Skip if not initialized or no callback
+ if (!isInitialized || !onTagExpansionChange) return;
+
+ // Check if we need to trigger expansion
+ const depthChanged = tagExpansionDepth !== previousDepth;
+ const tagTypeChanged = selectedTagType !== previousTagType;
+ const shouldExpand = showTagAnchors && (depthChanged || tagTypeChanged);
+
+ if (shouldExpand) {
+ previousDepth = tagExpansionDepth;
+ previousTagType = selectedTagType;
+
+ // Extract unique tags from current events
+ const tags = new Set();
+ events.forEach(event => {
+ const eventTags = event.getMatchingTags(selectedTagType);
+ eventTags.forEach(tag => {
+ if (tag[1]) tags.add(tag[1]);
+ });
+ });
+
+ debug("Tag expansion requested", {
+ depth: tagExpansionDepth,
+ tagType: selectedTagType,
+ tags: Array.from(tags),
+ depthChanged,
+ tagTypeChanged
+ });
+
+ onTagExpansionChange(tagExpansionDepth, Array.from(tags));
+ }
+ });
+
/**
* Handles tooltip close event
*/
@@ -724,6 +842,7 @@
starMode={starVisualization}
showTags={showTagAnchors}
tagAnchors={tagAnchorInfo}
+ eventCounts={eventCounts}
/>
@@ -733,6 +852,7 @@
bind:starVisualization
bind:showTagAnchors
bind:selectedTagType
+ bind:tagExpansionDepth
/>
diff --git a/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts b/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts
index f41b575..903f2bd 100644
--- a/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts
+++ b/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts
@@ -73,6 +73,8 @@ export function extractUniqueTagsForType(
): Map> {
// Map of tagValue -> Set of event IDs
const tagMap = new Map>();
+
+ console.log(`[TagBuilder] Extracting tags of type: ${tagType} from ${events.length} events`);
events.forEach((event) => {
if (!event.tags || !event.id) return;
@@ -92,6 +94,8 @@ export function extractUniqueTagsForType(
tagMap.get(tagValue)!.add(event.id);
});
});
+
+ console.log(`[TagBuilder] Found ${tagMap.size} unique tags of type ${tagType}:`, Array.from(tagMap.keys()));
return tagMap;
}
@@ -108,8 +112,10 @@ export function createTagAnchorNodes(
const anchorNodes: NetworkNode[] = [];
// Calculate positions for tag anchors randomly within radius
+ // For single publication view, show all tags. For network view, only show tags with 2+ events
+ const minEventCount = tagMap.size <= 10 ? 1 : 2;
const validTags = Array.from(tagMap.entries()).filter(
- ([_, eventIds]) => eventIds.size >= 2,
+ ([_, eventIds]) => eventIds.size >= minEventCount,
);
if (validTags.length === 0) return [];
diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte
index 629199f..fc32d4e 100644
--- a/src/routes/visualize/+page.svelte
+++ b/src/routes/visualize/+page.svelte
@@ -13,7 +13,7 @@
import { networkFetchLimit } from "$lib/state";
// Configuration
- const DEBUG = false; // Set to true to enable debug logging
+ const DEBUG = true; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KINDS = [30041, 30818];
@@ -31,6 +31,8 @@
let loading = true;
let error: string | null = null;
let showSettings = false;
+ let tagExpansionDepth = 0;
+ let baseEvents: NDKEvent[] = []; // Store original events before expansion
/**
* Fetches events from the Nostr network
@@ -95,6 +97,7 @@
// Step 5: Combine both sets of events
events = [...Array.from(validIndexEvents), ...Array.from(contentEvents)];
+ baseEvents = [...events]; // Store base events for tag expansion
debug("Total events for visualization:", events.length);
} catch (e) {
console.error("Error fetching events:", e);
@@ -105,6 +108,85 @@
}
+ /**
+ * Handles tag expansion to fetch related publications
+ */
+ async function handleTagExpansion(depth: number, tags: string[]) {
+ debug("Handling tag expansion", { depth, tags });
+
+ if (depth === 0 || tags.length === 0) {
+ // Reset to base events only
+ events = [...baseEvents];
+ return;
+ }
+
+ try {
+ // Don't show loading spinner for incremental updates
+ error = null;
+
+ // Keep track of existing event IDs to avoid duplicates
+ const existingEventIds = new Set(baseEvents.map(e => e.id));
+
+ // Fetch publications that have any of the specified tags
+ const taggedPublications = await $ndkInstance.fetchEvents({
+ kinds: [INDEX_EVENT_KIND],
+ "#t": tags, // Match any of these tags
+ limit: 30 * depth // Reasonable limit based on depth
+ });
+
+ debug("Found tagged publications:", taggedPublications.size);
+
+ // Filter to avoid duplicates
+ const newPublications = Array.from(taggedPublications).filter(
+ event => !existingEventIds.has(event.id)
+ );
+
+ // Extract content event IDs from new publications
+ const contentEventIds = new Set();
+ const existingContentIds = new Set(
+ baseEvents.filter(e => CONTENT_EVENT_KINDS.includes(e.kind)).map(e => e.id)
+ );
+
+ newPublications.forEach((event) => {
+ const aTags = event.getMatchingTags("a");
+ aTags.forEach((tag) => {
+ const eventId = tag[3];
+ if (eventId && !existingContentIds.has(eventId)) {
+ contentEventIds.add(eventId);
+ }
+ });
+ });
+
+ // Fetch the content events
+ let newContentEvents: NDKEvent[] = [];
+ if (contentEventIds.size > 0) {
+ const contentEventsSet = await $ndkInstance.fetchEvents({
+ kinds: CONTENT_EVENT_KINDS,
+ ids: Array.from(contentEventIds),
+ });
+ newContentEvents = Array.from(contentEventsSet);
+ }
+
+ // Combine all events: base events + new publications + new content
+ events = [
+ ...baseEvents,
+ ...newPublications,
+ ...newContentEvents
+ ];
+
+ debug("Events after expansion:", {
+ base: baseEvents.length,
+ newPubs: newPublications.length,
+ newContent: newContentEvents.length,
+ total: events.length
+ });
+
+ } catch (e) {
+ console.error("Error expanding tags:", e);
+ error = e instanceof Error ? e.message : String(e);
+ }
+ }
+
// Fetch events when component mounts
onMount(() => {
debug("Component mounted");
@@ -159,6 +241,6 @@
{:else}
-
+
{/if}