Browse Source

Fix event network visualization colors and improve tag anchor handling

- Fixed node colors not updating when event kinds change by using inline styles instead of attributes
- Implemented auto-disable feature for tag anchors when count exceeds 20 to prevent graph overload
- Changed tag anchor behavior to completely hide disabled tags instead of just making them transparent
- Enhanced NodeTooltip to handle arbitrary events differently:
  - Publication events (30040, 30041, 30818, 30023) route to /publication
  - Other events route to /events with raw content display
- Added visual notification in Legend when tags are auto-disabled
- Improved performance by fixing infinite update loops caused by state mutations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
master
limina1 9 months ago
parent
commit
33d634b4d2
  1. 139
      src/lib/navigator/EventNetwork/Legend.svelte
  2. 101
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  3. 149
      src/lib/navigator/EventNetwork/index.svelte
  4. 92
      src/lib/utils/eventColors.ts

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

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
import { getEventKindColor, getEventKindName } from '$lib/utils/eventColors';
// TODO: Move this to settings panel for user control // TODO: Move this to settings panel for user control
const TAG_LEGEND_COLUMNS = 4; // Number of columns for tag anchor table const TAG_LEGEND_COLUMNS = 4; // Number of columns for tag anchor table
@ -13,6 +14,7 @@
eventCounts = {}, eventCounts = {},
disabledTags = new Set<string>(), disabledTags = new Set<string>(),
onTagToggle = (tagId: string) => {}, onTagToggle = (tagId: string) => {},
autoDisabledTags = false,
} = $props<{ } = $props<{
collapsedOnInteraction: boolean; collapsedOnInteraction: boolean;
className: string; className: string;
@ -22,6 +24,7 @@
eventCounts?: { [kind: number]: number }; eventCounts?: { [kind: number]: number };
disabledTags?: Set<string>; disabledTags?: Set<string>;
onTagToggle?: (tagId: string) => void; onTagToggle?: (tagId: string) => void;
autoDisabledTags?: boolean;
}>(); }>();
let expanded = $state(true); let expanded = $state(true);
@ -87,94 +90,45 @@
{#if nodeTypesExpanded} {#if nodeTypesExpanded}
<ul class="legend-list"> <ul class="legend-list">
{#if starMode} <!-- Dynamic event kinds -->
<!-- Star center node --> {#each Object.entries(eventCounts).sort(([a], [b]) => Number(a) - Number(b)) as [kindStr, count]}
<li class="legend-item"> {@const kind = Number(kindStr)}
<div class="legend-icon"> {@const color = getEventKindColor(kind)}
<span {@const name = getEventKindName(kind)}
class="legend-circle" {#if count > 0}
style="background-color: hsl(200, 70%, 75%)" <li class="legend-item">
> <div class="legend-icon">
<span class="legend-letter">I</span> <span
</span> class="legend-circle"
</div> style="background-color: {color}"
<span class="legend-text" >
>{eventCounts[30040] || 0} Index events (kind 30040) - Star centers with </span>
unique colors</span </div>
> <span class="legend-text">
</li> {kind} - {name} ({count})
</span>
<!-- Content event node --> </li>
<li class="legend-item"> {/if}
<div class="legend-icon"> {/each}
<span class="legend-circle content">
<span class="legend-letter">C</span>
</span>
</div>
<span class="legend-text"
>{eventCounts[30041] || 0} Content nodes (kind 30041) - Arranged around
star centers</span
>
</li>
<!-- Star links -->
<li class="legend-item">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path
d="M12 4l8 8-8 8M12 4l-8 8 8 8"
class="network-link-leather"
stroke-width="2"
fill="none"
/>
</svg>
<span class="legend-text"
>Radial connections from star centers to content</span
>
</li>
{:else}
<!-- Index event node -->
<li class="legend-item">
<div class="legend-icon">
<span
class="legend-circle"
style="background-color: hsl(200, 70%, 75%)"
>
<span class="legend-letter">I</span>
</span>
</div>
<span class="legend-text"
>{eventCounts[30040] || 0} Index events (kind 30040) - Each with a unique
pastel color</span
>
</li>
<!-- Content event node -->
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle content">
<span class="legend-letter">C</span>
</span>
</div>
<span class="legend-text"
>{(eventCounts[30041] || 0) + (eventCounts[30818] || 0)} Content events
(kinds 30041, 30818) - Publication sections</span
>
</li>
<!-- Link arrow --> <!-- Connection lines -->
<li class="legend-item"> <li class="legend-item">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> <svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path <path
d="M4 12h16M16 6l6 6-6 6" d="M4 12h16M16 6l6 6-6 6"
class="network-link-leather" class="network-link-leather"
stroke-width="2" stroke-width="2"
fill="none" fill="none"
/> />
</svg> </svg>
<span class="legend-text">Arrows indicate reading/sequence order</span <span class="legend-text">
> {#if starMode}
</li> Radial connections from centers to related events
{/if} {:else}
Arrows indicate relationships and sequence
{/if}
</span>
</li>
</ul> </ul>
{/if} {/if}
</div> </div>
@ -199,6 +153,11 @@
</div> </div>
{#if tagAnchorsExpanded} {#if tagAnchorsExpanded}
{#if autoDisabledTags}
<div class="text-xs text-amber-600 dark:text-amber-400 mb-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded">
<strong>Note:</strong> All {tagAnchors.length} tags were auto-disabled to prevent graph overload. Click individual tags below to enable them.
</div>
{/if}
<div <div
class="tag-grid" class="tag-grid"
style="grid-template-columns: repeat({TAG_LEGEND_COLUMNS}, 1fr);" style="grid-template-columns: repeat({TAG_LEGEND_COLUMNS}, 1fr);"
@ -209,7 +168,7 @@
<button <button
class="tag-grid-item {isDisabled ? 'disabled' : ''}" class="tag-grid-item {isDisabled ? 'disabled' : ''}"
onclick={() => onTagToggle(tagId)} onclick={() => onTagToggle(tagId)}
title={isDisabled ? `Click to enable ${anchor.label}` : `Click to disable ${anchor.label}`} title={isDisabled ? `Click to show ${anchor.label}` : `Click to hide ${anchor.label}`}
> >
<div class="legend-icon"> <div class="legend-icon">
<span <span
@ -227,7 +186,9 @@
</div> </div>
<span class="legend-text text-xs" style="opacity: {isDisabled ? 0.5 : 1};"> <span class="legend-text text-xs" style="opacity: {isDisabled ? 0.5 : 1};">
{anchor.label} {anchor.label}
<span class="text-gray-500">({anchor.count})</span> {#if !isDisabled}
<span class="text-gray-500">({anchor.count})</span>
{/if}
</span> </span>
</button> </button>
{/each} {/each}

101
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -8,6 +8,7 @@
import type { NetworkNode } from "./types"; import type { NetworkNode } from "./types";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { getEventKindName } from "$lib/utils/eventColors";
// Component props // Component props
let { let {
@ -33,6 +34,9 @@
// Maximum content length to display // Maximum content length to display
const MAX_CONTENT_LENGTH = 200; const MAX_CONTENT_LENGTH = 200;
// Publication event kinds (text/article based)
const PUBLICATION_KINDS = [30040, 30041, 30818, 30023]; // Added 30023 (long-form content)
/** /**
* Gets the author name from the event tags * Gets the author name from the event tags
@ -72,6 +76,34 @@
} }
return "View Publication"; return "View Publication";
} }
/**
* Checks if this is a publication event
*/
function isPublicationEvent(kind: number): boolean {
return PUBLICATION_KINDS.includes(kind);
}
/**
* Gets the appropriate URL for the event
*/
function getEventUrl(node: NetworkNode): string {
if (isPublicationEvent(node.kind)) {
return `/publication?id=${node.id}`;
}
return `/events?id=${node.id}`;
}
/**
* Gets display text for the link
*/
function getLinkText(node: NetworkNode): string {
if (isPublicationEvent(node.kind)) {
return node.title || "Untitled Publication";
}
// For arbitrary events, show event kind name
return node.title || `Event ${node.kind}`;
}
/** /**
* Truncates content to a maximum length * Truncates content to a maximum length
@ -147,14 +179,21 @@
<div class="tooltip-content"> <div class="tooltip-content">
<!-- Title with link --> <!-- Title with link -->
<div class="tooltip-title"> <div class="tooltip-title">
<a href="/publication?id={node.id}" class="tooltip-title-link"> <a href={getEventUrl(node)} class="tooltip-title-link">
{node.title || "Untitled"} {getLinkText(node)}
</a> </a>
</div> </div>
<!-- Node type and kind --> <!-- Node type and kind -->
<div class="tooltip-metadata"> <div class="tooltip-metadata">
{node.type} (kind: {node.kind}) {#if isPublicationEvent(node.kind)}
{node.type} (kind: {node.kind})
{:else}
{getEventKindName(node.kind)}
{#if node.event?.created_at}
· {new Date(node.event.created_at * 1000).toLocaleDateString()}
{/if}
{/if}
</div> </div>
<!-- Author --> <!-- Author -->
@ -162,24 +201,52 @@
Author: {getAuthorTag(node)} Author: {getAuthorTag(node)}
</div> </div>
<!-- Summary (for index nodes) --> {#if isPublicationEvent(node.kind)}
{#if node.isContainer && getSummaryTag(node)} <!-- Summary (for publication index nodes) -->
<div class="tooltip-summary"> {#if node.isContainer && getSummaryTag(node)}
<span class="font-semibold">Summary:</span> <div class="tooltip-summary">
{truncateContent(getSummaryTag(node) || "")} <span class="font-semibold">Summary:</span>
</div> {truncateContent(getSummaryTag(node) || "")}
{/if} </div>
{/if}
<!-- Content preview -->
{#if node.content} <!-- Content preview for publications -->
<div class="tooltip-content-preview"> {#if node.content}
{truncateContent(node.content)} <div class="tooltip-content-preview">
</div> {truncateContent(node.content)}
</div>
{/if}
{:else}
<!-- For arbitrary events, show raw content or tags -->
{#if node.event?.content}
<div class="tooltip-content-preview">
<span class="font-semibold">Content:</span>
<pre class="whitespace-pre-wrap">{truncateContent(node.event.content)}</pre>
</div>
{/if}
<!-- Show some relevant tags for non-publication events -->
{#if node.event?.tags && node.event.tags.length > 0}
<div class="tooltip-metadata">
Tags: {node.event.tags.length}
{#if node.event.tags.length <= 3}
{#each node.event.tags as tag}
<span class="text-xs">· {tag[0]}{tag[1] ? `: ${tag[1].substring(0, 20)}${tag[1].length > 20 ? '...' : ''}` : ''}</span>
{/each}
{/if}
</div>
{/if}
{/if} {/if}
<!-- Help text for selected nodes --> <!-- Help text for selected nodes -->
{#if selected} {#if selected}
<div class="tooltip-help-text">Click node again to dismiss</div> <div class="tooltip-help-text">
{#if isPublicationEvent(node.kind)}
Click to view publication · Click node again to dismiss
{:else}
Click to view event details · Click node again to dismiss
{/if}
</div>
{/if} {/if}
</div> </div>
</div> </div>

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

@ -11,6 +11,7 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { levelsToRender } from "$lib/state"; import { levelsToRender } from "$lib/state";
import { generateGraph, getEventColor } from "./utils/networkBuilder"; import { generateGraph, getEventColor } from "./utils/networkBuilder";
import { getEventKindColor } from "$lib/utils/eventColors";
import { import {
generateStarGraph, generateStarGraph,
applyStarLayout, applyStarLayout,
@ -123,11 +124,25 @@
let componentId = Math.random(); let componentId = Math.random();
debug("Component created with ID:", componentId); debug("Component created with ID:", componentId);
// Event counts by kind // Event counts by kind - derived from events
let eventCounts = $state<{ [kind: number]: number }>({}); let eventCounts = $derived.by(() => {
const counts: { [kind: number]: number } = {};
events.forEach((event: NDKEvent) => {
if (event.kind !== undefined) {
counts[event.kind] = (counts[event.kind] || 0) + 1;
}
});
return counts;
});
// Disabled tags state for interactive legend // Disabled tags state for interactive legend
let disabledTags = $state(new Set<string>()); let disabledTags = $state(new Set<string>());
// Track if we've auto-disabled tags
let autoDisabledTags = $state(false);
// Maximum number of tag anchors before auto-disabling
const MAX_TAG_ANCHORS = 20;
// Debug function - call from browser console: window.debugTagAnchors() // Debug function - call from browser console: window.debugTagAnchors()
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -304,8 +319,18 @@
nodes = graphData.nodes; nodes = graphData.nodes;
links = graphData.links; links = graphData.links;
// Filter out links to disabled tag anchors // Filter out disabled tag anchors from nodes and links
if (showTagAnchors && disabledTags.size > 0) { if (showTagAnchors && disabledTags.size > 0) {
// Filter out disabled tag anchor nodes
nodes = nodes.filter((node: NetworkNode) => {
if (node.isTagAnchor) {
const tagId = `${node.tagType}-${node.title}`;
return !disabledTags.has(tagId);
}
return true;
});
// Filter out links to disabled tag anchors
links = links.filter((link: NetworkLink) => { links = links.filter((link: NetworkLink) => {
const source = link.source as NetworkNode; const source = link.source as NetworkNode;
const target = link.target as NetworkNode; const target = link.target as NetworkNode;
@ -330,14 +355,8 @@
}); });
} }
// Count events by kind // Event counts are now derived, no need to set them here
const counts: { [kind: number]: number } = {}; debug("Event counts by kind:", eventCounts);
events.forEach((event: NDKEvent) => {
if (event.kind !== undefined) {
counts[event.kind] = (counts[event.kind] || 0) + 1;
}
});
eventCounts = counts;
// Restore positions for existing nodes // Restore positions for existing nodes
let restoredCount = 0; let restoredCount = 0;
@ -462,12 +481,18 @@
return nodeEnter; return nodeEnter;
}, },
(update: any) => update, (update: any) => {
// Ensure drag handler is applied to updated nodes
update.call(dragHandler);
return update;
},
(exit: any) => exit.remove(), (exit: any) => exit.remove(),
); );
// Update node appearances // Update node appearances
debug("Updating node appearances"); debug("Updating node appearances");
// Update visual properties for ALL nodes (both new and existing)
node node
.select("circle.visual-circle") .select("circle.visual-circle")
.attr("class", (d: NetworkNode) => { .attr("class", (d: NetworkNode) => {
@ -483,26 +508,16 @@
} }
return baseClasses; return baseClasses;
}) })
.attr("fill", (d: NetworkNode) => { .style("fill", (d: NetworkNode) => {
// Tag anchors get their specific colors // Tag anchors get their specific colors
if (d.isTagAnchor) { if (d.isTagAnchor) {
return getTagAnchorColor(d.tagType || ""); return getTagAnchorColor(d.tagType || "");
} }
// Use consistent colors for both modes // Use deterministic color based on event kind
if (!d.isContainer) { const color = getEventKindColor(d.kind);
return isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT; return color;
}
// 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("opacity", 1)
.attr("r", (d: NetworkNode) => { .attr("r", (d: NetworkNode) => {
// Tag anchors are smaller // Tag anchors are smaller
if (d.isTagAnchor) { if (d.isTagAnchor) {
@ -529,8 +544,8 @@
if (d.isTagAnchor) { if (d.isTagAnchor) {
return d.tagType === "t" ? "#" : "T"; return d.tagType === "t" ? "#" : "T";
} }
// Always use I for index and C for content // No text for regular nodes - just show the colored circle
return d.isContainer ? "I" : "C"; return "";
}) })
.attr("font-size", (d: NetworkNode) => { .attr("font-size", (d: NetworkNode) => {
if (d.isTagAnchor) { if (d.isTagAnchor) {
@ -696,13 +711,12 @@
svgGroup svgGroup
.selectAll("g.node") .selectAll("g.node")
.select("circle.visual-circle") .select("circle.visual-circle")
.attr("fill", (d: NetworkNode) => .style("fill", (d: NetworkNode) => {
!d.isContainer if (d.isTagAnchor) {
? newIsDarkMode return getTagAnchorColor(d.tagType || "");
? CONTENT_COLOR_DARK }
: CONTENT_COLOR_LIGHT return getEventKindColor(d.kind);
: getEventColor(d.id), });
);
} }
} }
} }
@ -743,7 +757,12 @@
/** /**
* Watch for changes that should trigger a graph update * Watch for changes that should trigger a graph update
*/ */
let isUpdating = false;
$effect(() => { $effect(() => {
// Prevent recursive updates
if (isUpdating) return;
debug("Effect triggered", { debug("Effect triggered", {
hasSvg: !!svg, hasSvg: !!svg,
eventCount: events?.length, eventCount: events?.length,
@ -752,17 +771,23 @@
try { try {
if (svg && events?.length) { if (svg && events?.length) {
// Include all relevant state in the effect dependencies // Track dependencies
const _ = currentLevels; const deps = {
const __ = starVisualization; levels: currentLevels,
const ___ = showTagAnchors; star: starVisualization,
const ____ = selectedTagType; tags: showTagAnchors,
const _____ = disabledTags.size; tagType: selectedTagType,
disabled: disabledTags.size
};
isUpdating = true;
updateGraph(); updateGraph();
isUpdating = false;
} }
} catch (error) { } catch (error) {
console.error("Error in effect:", error); console.error("Error in effect:", error);
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`;
isUpdating = false;
} }
}); });
@ -814,6 +839,42 @@
onTagExpansionChange(tagExpansionDepth, Array.from(tags)); onTagExpansionChange(tagExpansionDepth, Array.from(tags));
} }
}); });
/**
* Watch for tag anchor count and auto-disable if exceeds threshold
*/
$effect(() => {
// Only check when tag anchors are shown and we have tags
if (showTagAnchors && tagAnchorInfo.length > 0) {
// If we have more than MAX_TAG_ANCHORS and haven't auto-disabled yet
if (tagAnchorInfo.length > MAX_TAG_ANCHORS && !autoDisabledTags) {
debug(`Auto-disabling tags: ${tagAnchorInfo.length} exceeds maximum of ${MAX_TAG_ANCHORS}`);
// Disable all tags
const newDisabledTags = new Set<string>();
tagAnchorInfo.forEach(anchor => {
const tagId = `${anchor.type}-${anchor.label}`;
newDisabledTags.add(tagId);
});
disabledTags = newDisabledTags;
autoDisabledTags = true;
// Optional: Show a notification to the user
console.info(`[EventNetwork] Auto-disabled ${tagAnchorInfo.length} tag anchors to prevent graph overload. Click individual tags in the legend to enable them.`);
}
// Reset auto-disabled flag if tag count goes back down
if (tagAnchorInfo.length <= MAX_TAG_ANCHORS && autoDisabledTags) {
autoDisabledTags = false;
}
}
// Reset when tag anchors are hidden
if (!showTagAnchors && autoDisabledTags) {
autoDisabledTags = false;
}
});
/** /**
* Handles tooltip close event * Handles tooltip close event
@ -883,8 +944,7 @@
} }
disabledTags = newDisabledTags; disabledTags = newDisabledTags;
// Trigger graph update to apply visibility changes // Don't call updateGraph() here - the effect will handle it
updateGraph();
} }
</script> </script>
@ -915,6 +975,7 @@
eventCounts={eventCounts} eventCounts={eventCounts}
{disabledTags} {disabledTags}
onTagToggle={handleTagToggle} onTagToggle={handleTagToggle}
{autoDisabledTags}
/> />
<!-- Settings Panel (shown when settings button is clicked) --> <!-- Settings Panel (shown when settings button is clicked) -->

92
src/lib/utils/eventColors.ts

@ -0,0 +1,92 @@
/**
* Deterministic color mapping for event kinds
* Uses golden ratio to distribute colors evenly across the spectrum
*/
const GOLDEN_RATIO = 0.618033988749895;
/**
* Get a deterministic color for an event kind
* @param kind - The event kind number
* @returns HSL color string
*/
export function getEventKindColor(kind: number): string {
// Use golden ratio for better distribution
const hue = (kind * GOLDEN_RATIO * 360) % 360;
// Use different saturation/lightness for better visibility
const saturation = 65 + (kind % 20); // 65-85%
const lightness = 55 + ((kind * 3) % 15); // 55-70%
return `hsl(${Math.round(hue)}, ${saturation}%, ${lightness}%)`;
}
/**
* Get a friendly name for an event kind
* @param kind - The event kind number
* @returns Human-readable name
*/
export function getEventKindName(kind: number): string {
const kindNames: Record<number, string> = {
0: 'Metadata',
1: 'Text Note',
2: 'Recommend Relay',
3: 'Contact List',
4: 'Encrypted DM',
5: 'Event Deletion',
6: 'Repost',
7: 'Reaction',
8: 'Badge Award',
16: 'Generic Repost',
40: 'Channel Creation',
41: 'Channel Metadata',
42: 'Channel Message',
43: 'Channel Hide Message',
44: 'Channel Mute User',
1984: 'Reporting',
9734: 'Zap Request',
9735: 'Zap',
10000: 'Mute List',
10001: 'Pin List',
10002: 'Relay List',
22242: 'Client Authentication',
24133: 'Nostr Connect',
27235: 'HTTP Auth',
30000: 'Categorized People List',
30001: 'Categorized Bookmark List',
30008: 'Profile Badges',
30009: 'Badge Definition',
30017: 'Create or update a stall',
30018: 'Create or update a product',
30023: 'Long-form Content',
30024: 'Draft Long-form Content',
30040: 'Publication Index',
30041: 'Publication Content',
30078: 'Application-specific Data',
30311: 'Live Event',
30402: 'Classified Listing',
30403: 'Draft Classified Listing',
30617: 'Repository',
30818: 'Wiki Page',
31922: 'Date-Based Calendar Event',
31923: 'Time-Based Calendar Event',
31924: 'Calendar',
31925: 'Calendar Event RSVP',
31989: 'Handler recommendation',
31990: 'Handler information',
34550: 'Community Definition',
};
return kindNames[kind] || `Kind ${kind}`;
}
/**
* Get the short label for an event kind (for node display)
* @param kind - The event kind number
* @returns Short label (usually just the kind number)
*/
export function getEventKindLabel(kind: number): string {
// For now, just return the kind number
// Could be extended to return short codes if needed
return kind.toString();
}
Loading…
Cancel
Save