Browse Source

Template Legend & Settings panels

Indicators of not testes/not functional
master
limina1 9 months ago
parent
commit
bd3bc4ef00
  1. 216
      src/lib/navigator/EventNetwork/Legend.svelte
  2. 246
      src/lib/navigator/EventNetwork/Settings.svelte
  3. 72
      src/lib/navigator/EventNetwork/index.svelte
  4. 70
      src/lib/stores/visualizationConfig.ts
  5. 346
      src/routes/visualize/+page.svelte

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

@ -11,6 +11,8 @@ @@ -11,6 +11,8 @@
showTags = false,
tagAnchors = [],
eventCounts = {},
disabledTags = new Set<string>(),
onTagToggle = (tagId: string) => {},
} = $props<{
collapsedOnInteraction: boolean;
className: string;
@ -18,9 +20,13 @@ @@ -18,9 +20,13 @@
showTags?: boolean;
tagAnchors?: any[];
eventCounts?: { [kind: number]: number };
disabledTags?: Set<string>;
onTagToggle?: (tagId: string) => void;
}>();
let expanded = $state(true);
let nodeTypesExpanded = $state(true);
let tagAnchorsExpanded = $state(true);
$effect(() => {
if (collapsedOnInteraction) {
@ -31,6 +37,14 @@ @@ -31,6 +37,14 @@
function toggle() {
expanded = !expanded;
}
function toggleNodeTypes() {
nodeTypesExpanded = !nodeTypesExpanded;
}
function toggleTagAnchors() {
tagAnchorsExpanded = !tagAnchorsExpanded;
}
</script>
<div class={`leather-legend ${className}`}>
@ -52,7 +66,27 @@ @@ -52,7 +66,27 @@
</div>
{#if expanded}
<ul class="legend-list">
<div class="legend-content">
<!-- Node Types Section -->
<div class="legend-section">
<div class="legend-section-header" onclick={toggleNodeTypes}>
<h4 class="legend-section-title">Node Types</h4>
<Button
color="none"
outline
size="xs"
class="rounded-full p-1"
>
{#if nodeTypesExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</Button>
</div>
{#if nodeTypesExpanded}
<ul class="legend-list">
{#if starMode}
<!-- Star center node -->
<li class="legend-item">
@ -141,44 +175,158 @@ @@ -141,44 +175,158 @@
>
</li>
{/if}
</ul>
{/if}
</div>
<!-- Tag Anchors section -->
{#if showTags && tagAnchors.length > 0}
<li class="legend-item mt-3 border-t pt-2 w-full">
<span class="legend-text font-semibold"
>Active Tag Anchors: {tagAnchors[0].type}</span
>
</li>
<li class="w-full">
<div
class="tag-grid"
style="grid-template-columns: repeat({TAG_LEGEND_COLUMNS}, 1fr);"
>
{#each tagAnchors as anchor}
<div class="tag-grid-item">
<div class="legend-icon">
<span
class="legend-circle"
style="background-color: {anchor.color}; width: 18px; height: 18px; border: 2px solid white;"
>
<span class="legend-letter text-xs text-white font-bold">
{anchor.type === "t"
? "#"
: anchor.type === "author"
? "A"
: anchor.type.charAt(0).toUpperCase()}
<div class="legend-section">
<div class="legend-section-header" onclick={toggleTagAnchors}>
<h4 class="legend-section-title">Active Tag Anchors: {tagAnchors[0].type}</h4>
<Button
color="none"
outline
size="xs"
class="rounded-full p-1"
>
{#if tagAnchorsExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</Button>
</div>
{#if tagAnchorsExpanded}
<div
class="tag-grid"
style="grid-template-columns: repeat({TAG_LEGEND_COLUMNS}, 1fr);"
>
{#each tagAnchors as anchor}
{@const tagId = `${anchor.type}-${anchor.label}`}
{@const isDisabled = disabledTags.has(tagId)}
<button
class="tag-grid-item {isDisabled ? 'disabled' : ''}"
onclick={() => onTagToggle(tagId)}
title={isDisabled ? `Click to enable ${anchor.label}` : `Click to disable ${anchor.label}`}
>
<div class="legend-icon">
<span
class="legend-circle"
style="background-color: {anchor.color}; width: 18px; height: 18px; border: 2px solid white; opacity: {isDisabled ? 0.3 : 1};"
>
<span class="legend-letter text-xs text-white font-bold">
{anchor.type === "t"
? "#"
: anchor.type === "author"
? "A"
: anchor.type.charAt(0).toUpperCase()}
</span>
</span>
</div>
<span class="legend-text text-xs" style="opacity: {isDisabled ? 0.5 : 1};">
{anchor.label}
<span class="text-gray-500">({anchor.count})</span>
</span>
</div>
<span class="legend-text text-xs">
{anchor.label}
<span class="text-gray-500">({anchor.count})</span>
</span>
</div>
{/each}
</div>
</li>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</ul>
</div>
{/if}
</div>
<style>
.legend-section {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.legend-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.legend-section-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 0.5rem 0;
margin-bottom: 0.75rem;
}
.legend-section-header:hover {
background-color: #f9fafb;
border-radius: 0.375rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.legend-section-title {
font-weight: 600;
color: #374151;
margin: 0;
font-size: 0.875rem;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
}
.legend-item:last-child {
margin-bottom: 0;
}
.tag-grid-item {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border: none;
background: none;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 0.25rem;
width: 100%;
text-align: left;
}
.tag-grid-item:hover:not(.disabled) {
background-color: rgba(0, 0, 0, 0.05);
}
.tag-grid-item.disabled {
cursor: pointer;
}
.tag-grid-item:hover.disabled {
background-color: rgba(0, 0, 0, 0.02);
}
:global(.dark) .legend-section-header:hover {
background-color: rgba(255, 255, 255, 0.05);
}
:global(.dark) .legend-section-title {
color: #d1d5db;
}
:global(.dark) .legend-section {
border-bottom-color: #374151;
}
:global(.dark) .tag-grid-item:hover:not(.disabled) {
background-color: rgba(255, 255, 255, 0.05);
}
:global(.dark) .tag-grid-item:hover.disabled {
background-color: rgba(255, 255, 255, 0.02);
}
</style>

246
src/lib/navigator/EventNetwork/Settings.svelte

@ -5,8 +5,10 @@ @@ -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 @@ @@ -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 @@ @@ -108,44 +135,105 @@
<span class="leather bg-transparent legend-text">
Showing {count} of {totalCount} events
</span>
<!-- Event Kind Filter Section -->
<div class="settings-section">
<div class="settings-section-header" onclick={toggleEventTypes}>
<h4 class="settings-section-title">Event Types <span class="text-orange-500 text-xs font-normal">(not tested)</span></h4>
<Button
color="none"
outline
size="xs"
class="rounded-full p-1"
>
{#if eventTypesExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</Button>
</div>
{#if eventTypesExpanded}
<EventKindFilter />
{/if}
</div>
<!-- Initial Load Settings Section -->
<div class="border-t border-gray-300 dark:border-gray-700 pt-3">
<h4 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Initial Load</h4>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
<div class="settings-section">
<div class="settings-section-header" onclick={toggleInitialLoad}>
<h4 class="settings-section-title">Initial Load</h4>
<Button
color="none"
outline
size="xs"
class="rounded-full p-1"
>
{#if initialLoadExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</Button>
</div>
{#if initialLoadExpanded}
<div>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>
{/if}
</div>
<!-- Display Limits Section -->
<div class="border-t border-gray-300 dark:border-gray-700 pt-3">
<h4 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Display Limits</h4>
<div class="settings-section">
<div class="settings-section-header" onclick={toggleDisplayLimits}>
<h4 class="settings-section-title">Display Limits</h4>
<Button
color="none"
outline
size="xs"
class="rounded-full p-1"
>
{#if displayLimitsExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</Button>
</div>
{#if displayLimitsExpanded}
<div class="space-y-3">
<div>
<Label for="max-30040" class="text-xs text-gray-600 dark:text-gray-400">
<Label for="max-pub-indices" class="text-xs text-gray-600 dark:text-gray-400">
Max Publication Indices (30040)
</Label>
<input
type="number"
id="max-30040"
id="max-pub-indices"
min="-1"
value={$displayLimits.max30040}
oninput={(e) => handleDisplayLimitInput(e, 'max30040')}
value={$visualizationConfig.maxPublicationIndices}
oninput={(e) => {
const value = parseInt(e.currentTarget.value) || -1;
visualizationConfig.setMaxPublicationIndices(value);
}}
placeholder="-1 for unlimited"
class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
/>
</div>
<div>
<Label for="max-30041" class="text-xs text-gray-600 dark:text-gray-400">
Max Content Events (30041)
<Label for="max-per-index" class="text-xs text-gray-600 dark:text-gray-400">
Max Events per Index
</Label>
<input
type="number"
id="max-30041"
id="max-per-index"
min="-1"
value={$displayLimits.max30041}
oninput={(e) => handleDisplayLimitInput(e, 'max30041')}
value={$visualizationConfig.maxEventsPerIndex}
oninput={(e) => {
const value = parseInt(e.currentTarget.value) || -1;
visualizationConfig.setMaxEventsPerIndex(value);
}}
placeholder="-1 for unlimited"
class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
/>
@ -154,26 +242,82 @@ @@ -154,26 +242,82 @@
<label class="flex items-center space-x-2">
<Toggle
checked={$displayLimits.fetchIfNotFound}
on:click={toggleFetchIfNotFound}
onclick={toggleFetchIfNotFound}
class="text-xs"
/>
<span class="text-xs text-gray-600 dark:text-gray-400">Fetch if not found</span>
<span class="text-xs text-gray-600 dark:text-gray-400">Fetch if not found <span class="text-orange-500 font-normal">(not tested)</span></span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 ml-6">
Automatically fetch missing referenced events
</p>
</div>
{/if}
</div>
<!-- Graph Traversal Section -->
<div class="settings-section">
<div class="settings-section-header" onclick={toggleGraphTraversal}>
<h4 class="settings-section-title">Graph Traversal <span class="text-orange-500 text-xs font-normal">(not tested)</span></h4>
<Button
color="none"
outline
size="xs"
class="rounded-full p-1"
>
{#if graphTraversalExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</Button>
</div>
{#if graphTraversalExpanded}
<label class="flex items-center space-x-2">
<Toggle
checked={$visualizationConfig.searchThroughFetched}
onclick={() => visualizationConfig.toggleSearchThroughFetched()}
class="text-xs"
/>
<span class="text-xs text-gray-600 dark:text-gray-400">Search through already fetched</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 ml-6">
When enabled, graph expansion will only use events already loaded
</p>
{/if}
</div>
<!-- Visual Settings Section -->
<div class="border-t border-gray-300 dark:border-gray-700 pt-3">
<h4 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Visual Settings</h4>
<div class="settings-section">
<div class="settings-section-header" onclick={toggleVisualSettings}>
<h4 class="settings-section-title">Visual Settings</h4>
<Button
color="none"
outline
size="xs"
class="rounded-full p-1"
>
{#if visualSettingsExpanded}
<CaretUpOutline class="w-3 h-3" />
{:else}
<CaretDownOutline class="w-3 h-3" />
{/if}
</Button>
</div>
{#if visualSettingsExpanded}
<div class="space-y-2">
<label
class="leather bg-transparent legend-text flex items-center space-x-2"
>
<Toggle bind:checked={starVisualization} class="text-xs" />
<Toggle
checked={starVisualization}
onchange={(e: Event) => {
const target = e.target as HTMLInputElement;
starVisualization = target.checked;
}}
class="text-xs"
/>
<span>Star Network View</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
@ -186,7 +330,14 @@ @@ -186,7 +330,14 @@
<label
class="leather bg-transparent legend-text flex items-center space-x-2"
>
<Toggle bind:checked={showTagAnchors} class="text-xs" />
<Toggle
checked={showTagAnchors}
onchange={(e: Event) => {
const target = e.target as HTMLInputElement;
showTagAnchors = target.checked;
}}
class="text-xs"
/>
<span>Show Tag Anchors</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
@ -222,7 +373,7 @@ @@ -222,7 +373,7 @@
<label
for="tag-depth-input"
class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap"
>Expansion Depth:</label
>Expansion Depth: <span class="text-red-500 font-semibold">(not functional)</span></label
>
<input
type="number"
@ -244,7 +395,56 @@ @@ -244,7 +395,56 @@
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
.settings-section {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.settings-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.settings-section-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 0.5rem 0;
margin-bottom: 0.75rem;
}
.settings-section-header:hover {
background-color: #f9fafb;
border-radius: 0.375rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.settings-section-title {
font-weight: 600;
color: #374151;
margin: 0;
font-size: 0.875rem;
}
:global(.dark) .settings-section-header:hover {
background-color: rgba(255, 255, 255, 0.05);
}
:global(.dark) .settings-section-title {
color: #d1d5db;
}
:global(.dark) .settings-section {
border-bottom-color: #374151;
}
</style>

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

@ -41,7 +41,7 @@ @@ -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 @@ @@ -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<string>());
// Debug function - call from browser console: window.debugTagAnchors()
if (typeof window !== "undefined") {
@ -206,7 +209,13 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -712,6 +757,7 @@
const __ = starVisualization;
const ___ = showTagAnchors;
const ____ = selectedTagType;
const _____ = disabledTags.size;
updateGraph();
}
} catch (error) {
@ -824,6 +870,22 @@ @@ -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();
}
</script>
<div class="network-container">
@ -851,6 +913,8 @@ @@ -851,6 +913,8 @@
showTags={showTagAnchors}
tagAnchors={tagAnchorInfo}
eventCounts={eventCounts}
{disabledTags}
onTagToggle={handleTagToggle}
/>
<!-- Settings Panel (shown when settings button is clicked) -->

70
src/lib/stores/visualizationConfig.ts

@ -0,0 +1,70 @@ @@ -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<VisualizationConfig>({
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)
);

346
src/routes/visualize/+page.svelte

@ -12,11 +12,12 @@ @@ -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 @@ @@ -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<string>();
// Step 3: Extract content event references from index events
const contentReferences = new Map<string, { kind: number; pubkey: string; dTag: string }>();
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<string, Array<{ kind: number; dTag: string }>>();
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<string, NDKEvent>();
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<string, NDKEvent>();
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<string, NDKEvent>();
const seenCoordinates = new Set<string>();
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 @@ @@ -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 @@ @@ -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<string>();
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<string>();
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<string>();
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<string, NDKEvent>();
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<string, NDKEvent>();
const seenCoordinates = new Set<string>();
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 @@ @@ -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) {

Loading…
Cancel
Save