From bd3bc4ef008e5048437c871443df32657cf4a553 Mon Sep 17 00:00:00 2001 From: limina1 Date: Wed, 11 Jun 2025 14:59:24 -0400 Subject: [PATCH] Template Legend & Settings panels Indicators of not testes/not functional --- src/lib/navigator/EventNetwork/Legend.svelte | 216 +++++++++-- .../navigator/EventNetwork/Settings.svelte | 246 +++++++++++-- src/lib/navigator/EventNetwork/index.svelte | 72 +++- src/lib/stores/visualizationConfig.ts | 70 ++++ src/routes/visualize/+page.svelte | 346 ++++++++++++++---- 5 files changed, 827 insertions(+), 123 deletions(-) create mode 100644 src/lib/stores/visualizationConfig.ts diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index 7540760..053c77b 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -11,6 +11,8 @@ showTags = false, tagAnchors = [], eventCounts = {}, + disabledTags = new Set(), + onTagToggle = (tagId: string) => {}, } = $props<{ collapsedOnInteraction: boolean; className: string; @@ -18,9 +20,13 @@ showTags?: boolean; tagAnchors?: any[]; eventCounts?: { [kind: number]: number }; + disabledTags?: Set; + onTagToggle?: (tagId: string) => void; }>(); let expanded = $state(true); + let nodeTypesExpanded = $state(true); + let tagAnchorsExpanded = $state(true); $effect(() => { if (collapsedOnInteraction) { @@ -31,6 +37,14 @@ function toggle() { expanded = !expanded; } + + function toggleNodeTypes() { + nodeTypesExpanded = !nodeTypesExpanded; + } + + function toggleTagAnchors() { + tagAnchorsExpanded = !tagAnchorsExpanded; + }
@@ -52,7 +66,27 @@
{#if expanded} -
    +
    + +
    +
    +

    Node Types

    + +
    + + {#if nodeTypesExpanded} +
      {#if starMode}
    • @@ -141,44 +175,158 @@ >
    • {/if} +
    + {/if} +
    {#if showTags && tagAnchors.length > 0} -
  • - Active Tag Anchors: {tagAnchors[0].type} -
  • -
  • -
    - {#each tagAnchors as anchor} -
    -
    - - - {anchor.type === "t" - ? "#" - : anchor.type === "author" - ? "A" - : anchor.type.charAt(0).toUpperCase()} +
    +
    +

    Active Tag Anchors: {tagAnchors[0].type}

    + +
    + + {#if tagAnchorsExpanded} +
    + {#each tagAnchors as anchor} + {@const tagId = `${anchor.type}-${anchor.label}`} + {@const isDisabled = disabledTags.has(tagId)} +
    - - {anchor.label} - ({anchor.count}) - -
    - {/each} -
    -
  • + + {/each} +
    + {/if} + {/if} -
+ {/if} + + diff --git a/src/lib/navigator/EventNetwork/Settings.svelte b/src/lib/navigator/EventNetwork/Settings.svelte index 4f5ea51..e14edea 100644 --- a/src/lib/navigator/EventNetwork/Settings.svelte +++ b/src/lib/navigator/EventNetwork/Settings.svelte @@ -5,8 +5,10 @@ import { quintOut } from "svelte/easing"; import EventLimitControl from "$lib/components/EventLimitControl.svelte"; import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte"; - import { networkFetchLimit } from "$lib/state"; + import EventKindFilter from "$lib/components/EventKindFilter.svelte"; + import { networkFetchLimit, levelsToRender } from "$lib/state"; import { displayLimits } from "$lib/stores/displayLimits"; + import { visualizationConfig } from "$lib/stores/visualizationConfig"; import { Toggle, Select } from "flowbite-svelte"; let { @@ -31,10 +33,35 @@ }>(); let expanded = $state(false); + let eventTypesExpanded = $state(true); + let initialLoadExpanded = $state(true); + let displayLimitsExpanded = $state(true); + let graphTraversalExpanded = $state(true); + let visualSettingsExpanded = $state(true); function toggle() { expanded = !expanded; } + + function toggleEventTypes() { + eventTypesExpanded = !eventTypesExpanded; + } + + function toggleInitialLoad() { + initialLoadExpanded = !initialLoadExpanded; + } + + function toggleDisplayLimits() { + displayLimitsExpanded = !displayLimitsExpanded; + } + + function toggleGraphTraversal() { + graphTraversalExpanded = !graphTraversalExpanded; + } + + function toggleVisualSettings() { + visualSettingsExpanded = !visualSettingsExpanded; + } /** * Handles updates to visualization settings */ @@ -108,44 +135,105 @@ Showing {count} of {totalCount} events + + +
+
+

Event Types (not tested)

+ +
+ {#if eventTypesExpanded} + + {/if} +
-
-

Initial Load

- - +
+
+

Initial Load

+ +
+ {#if initialLoadExpanded} +
+ + +
+ {/if}
-
-

Display Limits

+
+
+

Display Limits

+ +
+ {#if displayLimitsExpanded}
-
-
+ {/if} +
+ + +
+
+

Graph Traversal (not tested)

+ +
+ {#if graphTraversalExpanded} + + +

+ When enabled, graph expansion will only use events already loaded +

+ {/if}
-
-

Visual Settings

+
+
+

Visual Settings

+ +
+ {#if visualSettingsExpanded}

@@ -186,7 +330,14 @@

@@ -222,7 +373,7 @@ Expansion Depth: (not functional) {/if}

+ {/if}
{/if}
+ + diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index 7e6f60e..25d96da 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 = true; // Set to true to enable debug logging + const DEBUG = false; // Set to true to enable debug logging const NODE_RADIUS = 20; const LINK_DISTANCE = 10; const ARROW_DISTANCE = 10; @@ -125,6 +125,9 @@ // Event counts by kind let eventCounts = $state<{ [kind: number]: number }>({}); + + // Disabled tags state for interactive legend + let disabledTags = $state(new Set()); // Debug function - call from browser console: window.debugTagAnchors() if (typeof window !== "undefined") { @@ -206,7 +209,13 @@ * Generates the graph from events, creates the simulation, and renders nodes and links */ function updateGraph() { - debug("Updating graph"); + debug("updateGraph called", { + eventCount: events?.length, + starVisualization, + showTagAnchors, + selectedTagType, + disabledTagsCount: disabledTags.size + }); errorMessage = null; // Create variables to hold our selections @@ -295,10 +304,38 @@ nodes = graphData.nodes; links = graphData.links; + // Filter out links to disabled tag anchors + if (showTagAnchors && disabledTags.size > 0) { + 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 + if (source.isTagAnchor) { + const tagId = `${source.tagType}-${source.title}`; + if (disabledTags.has(tagId)) return false; + } + if (target.isTagAnchor) { + const tagId = `${target.tagType}-${target.title}`; + if (disabledTags.has(tagId)) return false; + } + + return true; + }); + + debug("Filtered links for disabled tags", { + originalCount: graphData.links.length, + filteredCount: links.length, + disabledTags: Array.from(disabledTags) + }); + } + // Count events by kind const counts: { [kind: number]: number } = {}; - events.forEach(event => { - counts[event.kind] = (counts[event.kind] || 0) + 1; + events.forEach((event: NDKEvent) => { + if (event.kind !== undefined) { + counts[event.kind] = (counts[event.kind] || 0) + 1; + } }); eventCounts = counts; @@ -458,6 +495,14 @@ // Index nodes get unique pastel colors in both modes return getEventColor(d.id); }) + .attr("opacity", (d: NetworkNode) => { + // Dim disabled tag anchors + if (d.isTagAnchor) { + const tagId = `${d.tagType}-${d.title}`; + return disabledTags.has(tagId) ? 0.3 : 1; + } + return 1; + }) .attr("r", (d: NetworkNode) => { // Tag anchors are smaller if (d.isTagAnchor) { @@ -712,6 +757,7 @@ const __ = starVisualization; const ___ = showTagAnchors; const ____ = selectedTagType; + const _____ = disabledTags.size; updateGraph(); } } catch (error) { @@ -824,6 +870,22 @@ graphInteracted = true; } } + + /** + * 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; + + // Trigger graph update to apply visibility changes + updateGraph(); + }
@@ -851,6 +913,8 @@ showTags={showTagAnchors} tagAnchors={tagAnchorInfo} eventCounts={eventCounts} + {disabledTags} + onTagToggle={handleTagToggle} /> diff --git a/src/lib/stores/visualizationConfig.ts b/src/lib/stores/visualizationConfig.ts new file mode 100644 index 0000000..4581203 --- /dev/null +++ b/src/lib/stores/visualizationConfig.ts @@ -0,0 +1,70 @@ +import { writable, derived } from 'svelte/store'; + +export interface VisualizationConfig { + // Event filtering + allowedKinds: number[]; // Using array for ordered display + allowFreeEvents: boolean; + + // Display limits (moving from displayLimits store) + maxPublicationIndices: number; // -1 unlimited + maxEventsPerIndex: number; // -1 unlimited + + // Graph traversal + searchThroughFetched: boolean; +} + +function createVisualizationConfig() { + const { subscribe, set, update } = writable({ + allowedKinds: [30040, 30041, 30818], + allowFreeEvents: false, + maxPublicationIndices: -1, + maxEventsPerIndex: -1, + searchThroughFetched: true + }); + + return { + subscribe, + update, + reset: () => set({ + allowedKinds: [30040, 30041, 30818], + allowFreeEvents: false, + maxPublicationIndices: -1, + maxEventsPerIndex: -1, + searchThroughFetched: true + }), + addKind: (kind: number) => update(config => { + if (!config.allowedKinds.includes(kind)) { + return { ...config, allowedKinds: [...config.allowedKinds, kind] }; + } + return config; + }), + removeKind: (kind: number) => update(config => ({ + ...config, + allowedKinds: config.allowedKinds.filter(k => k !== kind) + })), + toggleFreeEvents: () => update(config => ({ + ...config, + allowFreeEvents: !config.allowFreeEvents + })), + setMaxPublicationIndices: (max: number) => update(config => ({ + ...config, + maxPublicationIndices: max + })), + setMaxEventsPerIndex: (max: number) => update(config => ({ + ...config, + maxEventsPerIndex: max + })), + toggleSearchThroughFetched: () => update(config => ({ + ...config, + searchThroughFetched: !config.searchThroughFetched + })) + }; +} + +export const visualizationConfig = createVisualizationConfig(); + +// Helper to check if a kind is allowed +export const isKindAllowed = derived( + visualizationConfig, + $config => (kind: number) => $config.allowedKinds.includes(kind) +); \ No newline at end of file diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index ecb2bb6..5e47641 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -12,11 +12,12 @@ import { filterValidIndexEvents } from "$lib/utils"; import { networkFetchLimit } from "$lib/state"; import { displayLimits } from "$lib/stores/displayLimits"; + import { visualizationConfig } from "$lib/stores/visualizationConfig"; import { filterByDisplayLimits, detectMissingEvents } from "$lib/utils/displayLimits"; import type { PageData } from './$types'; // Configuration - const DEBUG = true; // Set to true to enable debug logging + const DEBUG = false; // Set to true to enable debug logging const INDEX_EVENT_KIND = 30040; const CONTENT_EVENT_KINDS = [30041, 30818]; @@ -92,38 +93,143 @@ debug("Valid index events after filtering:", validIndexEvents.size); } - // Step 3: Extract content event IDs from index events - const contentEventIds = new Set(); + // Step 3: Extract content event references from index events + const contentReferences = new Map(); validIndexEvents.forEach((event) => { const aTags = event.getMatchingTags("a"); debug(`Event ${event.id} has ${aTags.length} a-tags`); aTags.forEach((tag) => { - const eventId = tag[3]; - if (eventId) { - contentEventIds.add(eventId); + // Parse the 'a' tag identifier: kind:pubkey:d-tag + if (tag[1]) { + const parts = tag[1].split(':'); + if (parts.length >= 3) { + const kind = parseInt(parts[0]); + const pubkey = parts[1]; + const dTag = parts.slice(2).join(':'); // Handle d-tags with colons + + // Only add if it's a content event kind we're interested in + if (CONTENT_EVENT_KINDS.includes(kind)) { + const key = `${kind}:${pubkey}:${dTag}`; + contentReferences.set(key, { kind, pubkey, dTag }); + } + } } }); }); - debug("Content event IDs to fetch:", contentEventIds.size); + debug("Content references to fetch:", contentReferences.size); - // Step 4: Fetch the referenced content events + // Step 4: Fetch the referenced content events with author filter debug(`Fetching content events (kinds ${CONTENT_EVENT_KINDS.join(', ')})`); - const contentEvents = await $ndkInstance.fetchEvents( - { - kinds: CONTENT_EVENT_KINDS, - ids: Array.from(contentEventIds), - }, - { - groupable: true, - skipVerification: false, - skipValidation: false, - }, + + // Group by author to make more efficient queries + const referencesByAuthor = new Map>(); + contentReferences.forEach(({ kind, pubkey, dTag }) => { + if (!referencesByAuthor.has(pubkey)) { + referencesByAuthor.set(pubkey, []); + } + referencesByAuthor.get(pubkey)!.push({ kind, dTag }); + }); + + // Fetch events for each author + const contentEventPromises = Array.from(referencesByAuthor.entries()).map( + async ([author, refs]) => { + const dTags = [...new Set(refs.map(r => r.dTag))]; // Dedupe d-tags + return $ndkInstance.fetchEvents({ + kinds: CONTENT_EVENT_KINDS, + authors: [author], + "#d": dTags, + }); + } ); - debug("Fetched content events:", contentEvents.size); + + const contentEventSets = await Promise.all(contentEventPromises); + + // Deduplicate by keeping only the most recent version of each d-tag per author + const eventsByCoordinate = new Map(); + + contentEventSets.forEach((eventSet, idx) => { + eventSet.forEach(event => { + const dTag = event.tagValue("d"); + const author = event.pubkey; + const kind = event.kind; + + if (dTag && author && kind) { + const coordinate = `${kind}:${author}:${dTag}`; + const existing = eventsByCoordinate.get(coordinate); + + // Keep the most recent event (highest created_at) + if (!existing || (event.created_at && existing.created_at && event.created_at > existing.created_at)) { + eventsByCoordinate.set(coordinate, event); + debug(`Keeping newer version of ${coordinate}, created_at: ${event.created_at}`); + } else if (existing) { + debug(`Skipping older version of ${coordinate}, created_at: ${event.created_at} vs ${existing.created_at}`); + } + } + }); + }); + + const contentEvents = new Set(eventsByCoordinate.values()); + debug("Fetched content events after deduplication:", contentEvents.size); - // Step 5: Combine both sets of events - allEvents = [...Array.from(validIndexEvents), ...Array.from(contentEvents)]; + // Step 5: Combine both sets of events with coordinate-based deduplication + // First, build coordinate map for replaceable events + const coordinateMap = new Map(); + const allEventsToProcess = [...Array.from(validIndexEvents), ...Array.from(contentEvents)]; + + // First pass: identify the most recent version of each replaceable event + allEventsToProcess.forEach(event => { + if (!event.id) return; + + // For replaceable events (30000-39999), track by coordinate + if (event.kind && event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tagValue("d"); + const author = event.pubkey; + + if (dTag && author) { + const coordinate = `${event.kind}:${author}:${dTag}`; + const existing = coordinateMap.get(coordinate); + + // Keep the most recent version + if (!existing || (event.created_at && existing.created_at && event.created_at > existing.created_at)) { + coordinateMap.set(coordinate, event); + } + } + } + }); + + // Second pass: build final event map + const finalEventMap = new Map(); + const seenCoordinates = new Set(); + + allEventsToProcess.forEach(event => { + if (!event.id) return; + + // For replaceable events, only add if it's the chosen version + if (event.kind && event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tagValue("d"); + const author = event.pubkey; + + if (dTag && author) { + const coordinate = `${event.kind}:${author}:${dTag}`; + const chosenEvent = coordinateMap.get(coordinate); + + // Only add this event if it's the chosen one for this coordinate + if (chosenEvent && chosenEvent.id === event.id) { + if (!seenCoordinates.has(coordinate)) { + finalEventMap.set(event.id, event); + seenCoordinates.add(coordinate); + } + } + return; + } + } + + // Non-replaceable events are added directly + finalEventMap.set(event.id, event); + }); + + allEvents = Array.from(finalEventMap.values()); baseEvents = [...allEvents]; // Store base events for tag expansion // Step 6: Apply display limits @@ -154,7 +260,7 @@ * Handles tag expansion to fetch related publications */ async function handleTagExpansion(depth: number, tags: string[]) { - debug("Handling tag expansion", { depth, tags }); + debug("Handling tag expansion", { depth, tags, searchThroughFetched: $visualizationConfig.searchThroughFetched }); if (depth === 0 || tags.length === 0) { // Reset to base events only @@ -170,52 +276,167 @@ // 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); + let newPublications: NDKEvent[] = []; + let newContentEvents: NDKEvent[] = []; - // Filter to avoid duplicates - const newPublications = Array.from(taggedPublications).filter( - event => !existingEventIds.has(event.id) - ); + if ($visualizationConfig.searchThroughFetched) { + // Search through already fetched events only + debug("Searching through already fetched events for tags:", tags); + + // Find publications in allEvents that have the specified tags + const taggedPublications = allEvents.filter(event => { + if (event.kind !== INDEX_EVENT_KIND) return false; + if (existingEventIds.has(event.id)) return false; // Skip base events + + // Check if event has any of the specified tags + const eventTags = event.getMatchingTags("t").map(tag => tag[1]); + return tags.some(tag => eventTags.includes(tag)); + }); + + newPublications = taggedPublications; + debug("Found", newPublications.length, "publications in fetched events"); + + // For content events, also search in allEvents + const existingContentDTags = new Set( + baseEvents + .filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)) + .map(e => e.tagValue("d")) + .filter(d => d !== undefined) + ); + + const contentEventDTags = new Set(); + newPublications.forEach((event) => { + const aTags = event.getMatchingTags("a"); + aTags.forEach((tag) => { + // Parse the 'a' tag identifier: kind:pubkey:d-tag + if (tag[1]) { + const parts = tag[1].split(':'); + if (parts.length >= 3) { + const dTag = parts.slice(2).join(':'); // Handle d-tags with colons + if (!existingContentDTags.has(dTag)) { + contentEventDTags.add(dTag); + } + } + } + }); + }); + + // Find content events in allEvents + newContentEvents = allEvents.filter(event => { + if (!CONTENT_EVENT_KINDS.includes(event.kind || 0)) return false; + const dTag = event.tagValue("d"); + return dTag !== undefined && contentEventDTags.has(dTag); + }); + + } else { + // Fetch from relays as before + debug("Fetching from relays for tags:", tags); + + // 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 from relays:", taggedPublications.size); + + // Filter to avoid duplicates + newPublications = Array.from(taggedPublications).filter( + event => !existingEventIds.has(event.id) + ); + + // Extract content event d-tags from new publications + const contentEventDTags = new Set(); + const existingContentDTags = new Set( + baseEvents + .filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)) + .map(e => e.tagValue("d")) + .filter(d => d !== undefined) + ); + + newPublications.forEach((event) => { + const aTags = event.getMatchingTags("a"); + aTags.forEach((tag) => { + // Parse the 'a' tag identifier: kind:pubkey:d-tag + if (tag[1]) { + const parts = tag[1].split(':'); + if (parts.length >= 3) { + const dTag = parts.slice(2).join(':'); // Handle d-tags with colons + if (!existingContentDTags.has(dTag)) { + contentEventDTags.add(dTag); + } + } + } + }); + }); + + // Fetch the content events + if (contentEventDTags.size > 0) { + const contentEventsSet = await $ndkInstance.fetchEvents({ + kinds: CONTENT_EVENT_KINDS, + "#d": Array.from(contentEventDTags), // Use d-tag filter + }); + newContentEvents = Array.from(contentEventsSet); + } + } - // Extract content event IDs from new publications - const contentEventIds = new Set(); - const existingContentIds = new Set( - baseEvents.filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)).map(e => e.id) - ); + // Combine all events with coordinate-based deduplication + // First, build coordinate map for replaceable events + const coordinateMap = new Map(); + const allEventsToProcess = [...baseEvents, ...newPublications, ...newContentEvents]; - newPublications.forEach((event) => { - const aTags = event.getMatchingTags("a"); - aTags.forEach((tag) => { - const eventId = tag[3]; - if (eventId && !existingContentIds.has(eventId)) { - contentEventIds.add(eventId); + // First pass: identify the most recent version of each replaceable event + allEventsToProcess.forEach(event => { + if (!event.id) return; + + // For replaceable events (30000-39999), track by coordinate + if (event.kind && event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tagValue("d"); + const author = event.pubkey; + + if (dTag && author) { + const coordinate = `${event.kind}:${author}:${dTag}`; + const existing = coordinateMap.get(coordinate); + + // Keep the most recent version + if (!existing || (event.created_at && existing.created_at && event.created_at > existing.created_at)) { + coordinateMap.set(coordinate, event); + } } - }); + } }); - // 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); - } + // Second pass: build final event map + const finalEventMap = new Map(); + const seenCoordinates = new Set(); + + allEventsToProcess.forEach(event => { + if (!event.id) return; + + // For replaceable events, only add if it's the chosen version + if (event.kind && event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tagValue("d"); + const author = event.pubkey; + + if (dTag && author) { + const coordinate = `${event.kind}:${author}:${dTag}`; + const chosenEvent = coordinateMap.get(coordinate); + + // Only add this event if it's the chosen one for this coordinate + if (chosenEvent && chosenEvent.id === event.id && !seenCoordinates.has(coordinate)) { + finalEventMap.set(event.id, event); + seenCoordinates.add(coordinate); + } + return; + } + } + + // Non-replaceable events are added directly + finalEventMap.set(event.id, event); + }); - // Combine all events: base events + new publications + new content - allEvents = [ - ...baseEvents, - ...newPublications, - ...newContentEvents - ]; + allEvents = Array.from(finalEventMap.values()); // Apply display limits events = filterByDisplayLimits(allEvents, $displayLimits); @@ -230,7 +451,8 @@ newContent: newContentEvents.length, totalFetched: allEvents.length, displayed: events.length, - missing: missingEventIds.size + missing: missingEventIds.size, + searchMode: $visualizationConfig.searchThroughFetched ? "fetched" : "relays" }); } catch (e) {