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. 97
      src/lib/navigator/EventNetwork/Legend.svelte
  2. 77
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  3. 149
      src/lib/navigator/EventNetwork/index.svelte
  4. 92
      src/lib/utils/eventColors.ts

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

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

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

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
import type { NetworkNode } from "./types";
import { onMount } from "svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { getEventKindName } from "$lib/utils/eventColors";
// Component props
let {
@ -34,6 +35,9 @@ @@ -34,6 +35,9 @@
// Maximum content length to display
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
*/
@ -73,6 +77,34 @@ @@ -73,6 +77,34 @@
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
*/
@ -147,14 +179,21 @@ @@ -147,14 +179,21 @@
<div class="tooltip-content">
<!-- Title with link -->
<div class="tooltip-title">
<a href="/publication?id={node.id}" class="tooltip-title-link">
{node.title || "Untitled"}
<a href={getEventUrl(node)} class="tooltip-title-link">
{getLinkText(node)}
</a>
</div>
<!-- Node type and kind -->
<div class="tooltip-metadata">
{#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>
<!-- Author -->
@ -162,7 +201,8 @@ @@ -162,7 +201,8 @@
Author: {getAuthorTag(node)}
</div>
<!-- Summary (for index nodes) -->
{#if isPublicationEvent(node.kind)}
<!-- Summary (for publication index nodes) -->
{#if node.isContainer && getSummaryTag(node)}
<div class="tooltip-summary">
<span class="font-semibold">Summary:</span>
@ -170,16 +210,43 @@ @@ -170,16 +210,43 @@
</div>
{/if}
<!-- Content preview -->
<!-- Content preview for publications -->
{#if node.content}
<div class="tooltip-content-preview">
{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}
<!-- Help text for selected nodes -->
{#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}
</div>
</div>

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

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

92
src/lib/utils/eventColors.ts

@ -0,0 +1,92 @@ @@ -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