Browse Source

Add star layout, tag-anchor visualization

master
limina1 10 months ago
parent
commit
c1c64860b3
  1. 119
      src/lib/navigator/EventNetwork/Legend.svelte
  2. 46
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  3. 75
      src/lib/navigator/EventNetwork/Settings.svelte
  4. 82
      src/lib/navigator/EventNetwork/TagTable.svelte
  5. 352
      src/lib/navigator/EventNetwork/index.svelte
  6. 8
      src/lib/navigator/EventNetwork/types.ts
  7. 20
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  8. 265
      src/lib/navigator/EventNetwork/utils/starForceSimulation.ts
  9. 338
      src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts
  10. 284
      src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts
  11. 68
      src/styles/visualize.css

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

@ -1,12 +1,24 @@ @@ -1,12 +1,24 @@
<!-- Legend Component (Svelte 5, Runes Mode) -->
<script lang="ts">
import {Button} from 'flowbite-svelte';
import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
// TODO: Move this to settings panel for user control
const TAG_LEGEND_COLUMNS = 4; // Number of columns for tag anchor table
let {
collapsedOnInteraction = false,
className = ""
} = $props<{collapsedOnInteraction: boolean, className: string}>();
className = "",
starMode = false,
showTags = false,
tagAnchors = [],
} = $props<{
collapsedOnInteraction: boolean;
className: string;
starMode?: boolean;
showTags?: boolean;
tagAnchors?: any[];
}>();
let expanded = $state(true);
@ -24,7 +36,13 @@ @@ -24,7 +36,13 @@
<div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Legend</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" >
<Button
color="none"
outline
size="xs"
onclick={toggle}
class="rounded-full"
>
{#if expanded}
<CaretUpOutline />
{:else}
@ -35,6 +53,49 @@ @@ -35,6 +53,49 @@
{#if expanded}
<ul class="legend-list">
{#if starMode}
<!-- Star center 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"
>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>
</div>
<span class="legend-text"
>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">
@ -45,7 +106,9 @@ @@ -45,7 +106,9 @@
<span class="legend-letter">I</span>
</span>
</div>
<span class="legend-text">Index events (kind 30040) - Each with a unique pastel color</span>
<span class="legend-text"
>Index events (kind 30040) - Each with a unique pastel color</span
>
</li>
<!-- Content event node -->
@ -55,7 +118,9 @@ @@ -55,7 +118,9 @@
<span class="legend-letter">C</span>
</span>
</div>
<span class="legend-text">Content events (kinds 30041, 30818) - Publication sections</span>
<span class="legend-text"
>Content events (kinds 30041, 30818) - Publication sections</span
>
</li>
<!-- Link arrow -->
@ -68,8 +133,48 @@ @@ -68,8 +133,48 @@
fill="none"
/>
</svg>
<span class="legend-text">Arrows indicate reading/sequence order</span>
<span class="legend-text">Arrows indicate reading/sequence order</span
>
</li>
{/if}
<!-- 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()}
</span>
</span>
</div>
<span class="legend-text text-xs">
{anchor.label}
<span class="text-gray-500">({anchor.count})</span>
</span>
</div>
{/each}
</div>
</li>
{/if}
</ul>
{/if}
</div>

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

@ -7,15 +7,23 @@ @@ -7,15 +7,23 @@
<script lang="ts">
import type { NetworkNode } from "./types";
import { onMount } from "svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { getMatchingTags } from "$lib/utils/nostrUtils";
// Component props
let { node, selected = false, x, y, onclose } = $props<{
let {
node,
selected = false,
x,
y,
onclose,
starMode = false,
} = $props<{
node: NetworkNode; // The node to display information for
selected?: boolean; // Whether the node is selected (clicked)
x: number; // X position for the tooltip
y: number; // Y position for the tooltip
onclose: () => void; // Function to call when closing the tooltip
starMode?: boolean; // Whether we're in star visualization mode
}>();
// DOM reference and positioning
@ -68,7 +76,10 @@ @@ -68,7 +76,10 @@
/**
* Truncates content to a maximum length
*/
function truncateContent(content: string, maxLength: number = MAX_CONTENT_LENGTH): string {
function truncateContent(
content: string,
maxLength: number = MAX_CONTENT_LENGTH,
): string {
if (!content) return "";
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "...";
@ -117,13 +128,18 @@ @@ -117,13 +128,18 @@
style="left: {tooltipX}px; top: {tooltipY}px;"
>
<!-- Close button -->
<button
class="tooltip-close-btn"
onclick={closeTooltip}
aria-label="Close"
<button class="tooltip-close-btn" onclick={closeTooltip} aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
@ -131,10 +147,7 @@ @@ -131,10 +147,7 @@
<div class="tooltip-content">
<!-- Title with link -->
<div class="tooltip-title">
<a
href="/publication?id={node.id}"
class="tooltip-title-link"
>
<a href="/publication?id={node.id}" class="tooltip-title-link">
{node.title || "Untitled"}
</a>
</div>
@ -152,7 +165,8 @@ @@ -152,7 +165,8 @@
<!-- Summary (for index nodes) -->
{#if node.isContainer && getSummaryTag(node)}
<div class="tooltip-summary">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "")}
<span class="font-semibold">Summary:</span>
{truncateContent(getSummaryTag(node) || "")}
</div>
{/if}
@ -165,9 +179,7 @@ @@ -165,9 +179,7 @@
<!-- Help text for selected nodes -->
{#if selected}
<div class="tooltip-help-text">
Click node again to dismiss
</div>
<div class="tooltip-help-text">Click node again to dismiss</div>
{/if}
</div>
</div>

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

@ -2,18 +2,28 @@ @@ -2,18 +2,28 @@
Settings Component
-->
<script lang="ts">
import {Button} from 'flowbite-svelte';
import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
import { fly } from "svelte/transition";
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 { Toggle, Select } from "flowbite-svelte";
let {
count = 0,
onupdate
} = $props<{count: number, onupdate: () => void}>();
onupdate,
starVisualization = $bindable(true),
showTagAnchors = $bindable(false),
selectedTagType = $bindable("t"),
} = $props<{
count: number;
onupdate: () => void;
starVisualization?: boolean;
showTagAnchors?: boolean;
selectedTagType?: string;
}>();
let expanded = $state(false);
@ -28,10 +38,16 @@ @@ -28,10 +38,16 @@
}
</script>
<div class="leather-legend sm:!right-1 sm:!left-auto" >
<div class="leather-legend sm:!right-1 sm:!left-auto">
<div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Settings</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" >
<Button
color="none"
outline
size="xs"
onclick={toggle}
class="rounded-full"
>
{#if expanded}
<CaretUpOutline />
{:else}
@ -45,6 +61,55 @@ @@ -45,6 +61,55 @@
<span class="leather bg-transparent legend-text">
Showing {count} events from {$networkFetchLimit} headers
</span>
<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" />
<span>Star Network View</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
Toggle between star clusters (on) and linear sequence (off)
visualization
</p>
</div>
<div class="space-y-2">
<label
class="leather bg-transparent legend-text flex items-center space-x-2"
>
<Toggle bind:checked={showTagAnchors} class="text-xs" />
<span>Show Tag Anchors</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
Display tag anchors that attract nodes with matching tags
</p>
{#if showTagAnchors}
<div class="mt-2">
<label
for="tag-type-select"
class="text-xs text-gray-600 dark:text-gray-400">Tag Type:</label
>
<Select
id="tag-type-select"
bind:value={selectedTagType}
size="sm"
class="text-xs mt-1"
>
<option value="t">Hashtags</option>
<option value="author">Authors</option>
<option value="p">People (Pubkeys)</option>
<option value="e">Event References</option>
<!-- <option value="a">Article References</option> -->
<option value="title">Titles</option>
<option value="summary">Summaries</option>
</Select>
</div>
{/if}
</div>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>

82
src/lib/navigator/EventNetwork/TagTable.svelte

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
<!--
TagTable Component
Displays a table of unique tags found in the event network
-->
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Table } from "flowbite-svelte";
let { events = [], selectedTagType = "t" } = $props<{
events: NDKEvent[];
selectedTagType: string;
}>();
// Computed property for unique tags
let uniqueTags = $derived(() => {
const tagMap = new Map();
events.forEach(event => {
const tags = event.tags || [];
tags.forEach(tag => {
if (tag[0] === selectedTagType) {
const tagValue = tag[1];
const count = tagMap.get(tagValue)?.count || 0;
tagMap.set(tagValue, {
value: tagValue,
count: count + 1,
// Store first event that references this tag
firstEvent: tagMap.get(tagValue)?.firstEvent || event.id
});
}
});
});
return Array.from(tagMap.values())
.sort((a, b) => b.count - a.count); // Sort by frequency
});
// Tag type labels for display
const tagTypeLabels: Record<string, string> = {
't': 'Hashtags',
'author': 'Authors',
'p': 'People',
'e': 'Events',
'title': 'Titles',
'summary': 'Summaries'
};
</script>
{#if uniqueTags.length > 0}
<div class="tag-table-container p-4">
<h3 class="text-lg font-semibold mb-2">
{tagTypeLabels[selectedTagType] || 'Tags'}
</h3>
<Table hoverable>
<thead>
<tr>
<th>Tag</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{#each uniqueTags as tag}
<tr>
<td>{tag.value}</td>
<td>{tag.count}</td>
</tr>
{/each}
</tbody>
</Table>
</div>
{:else}
<div class="p-4 text-gray-500">
No {tagTypeLabels[selectedTagType]?.toLowerCase() || 'tags'} found
</div>
{/if}
<style>
.tag-table-container {
max-height: 300px;
overflow-y: auto;
}
</style>

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

@ -11,18 +11,31 @@ @@ -11,18 +11,31 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { levelsToRender } from "$lib/state";
import { generateGraph, getEventColor } from "./utils/networkBuilder";
import {
generateStarGraph,
applyStarLayout,
} from "./utils/starNetworkBuilder";
import {
createStarSimulation,
applyInitialStarPositions,
createStarDragHandler,
} from "./utils/starForceSimulation";
import {
createSimulation,
setupDragHandlers,
applyGlobalLogGravity,
applyConnectedGravity,
type Simulation
type Simulation,
} from "./utils/forceSimulation";
import Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte";
import type { NetworkNode, NetworkLink } from "./types";
import Settings from "./Settings.svelte";
import {Button} from 'flowbite-svelte';
import {
enhanceGraphWithTags,
getTagAnchorColor,
} from "./utils/tagNetworkBuilder";
import { Button } from "flowbite-svelte";
// Type alias for D3 selections
type Selection = any;
@ -45,7 +58,10 @@ @@ -45,7 +58,10 @@
}
// Component props
let { events = [], onupdate } = $props<{ events?: NDKEvent[], onupdate: () => void }>();
let { events = [], onupdate } = $props<{
events?: NDKEvent[];
onupdate: () => void;
}>();
// Error state
let errorMessage = $state<string | null>(null);
@ -69,7 +85,9 @@ @@ -69,7 +85,9 @@
let width = $state(1000);
let height = $state(600);
let windowHeight = $state<number | undefined>(undefined);
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400);
let graphHeight = $derived(
windowHeight ? Math.max(windowHeight * 0.2, 400) : 400,
);
// D3 objects
let simulation: Simulation<NetworkNode, NetworkLink> | null = null;
@ -80,6 +98,30 @@ @@ -80,6 +98,30 @@
// Track current render level
let currentLevels = $derived(levelsToRender);
// Star visualization state (default to true)
let starVisualization = $state(true);
// Tag anchors state
let showTagAnchors = $state(false);
let selectedTagType = $state("t"); // Default to hashtags
let tagAnchorInfo = $state<any[]>([]);
// Debug function - call from browser console: window.debugTagAnchors()
if (typeof window !== "undefined") {
window.debugTagAnchors = () => {
console.log("=== TAG ANCHOR DEBUG INFO ===");
console.log("Tag Anchor Info:", tagAnchorInfo);
console.log("Show Tag Anchors:", showTagAnchors);
console.log("Selected Tag Type:", selectedTagType);
const tagNodes = nodes.filter((n) => n.isTagAnchor);
console.log("Tag Anchor Nodes:", tagNodes);
console.log("Tag Types Found:", [
...new Set(tagNodes.map((n) => n.tagType)),
]);
return tagAnchorInfo;
};
}
// Update dimensions when container changes
$effect(() => {
if (container) {
@ -100,8 +142,7 @@ @@ -100,8 +142,7 @@
}
debug("SVG dimensions", { width, height });
const svgElement = d3.select(svg)
.attr("viewBox", `0 0 ${width} ${height}`);
const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
// Clear existing content
svgElement.selectAll("*").remove();
@ -172,16 +213,44 @@ @@ -172,16 +213,44 @@
// Generate graph data from events
debug("Generating graph with events", {
eventCount: events.length,
currentLevels
currentLevels,
starVisualization,
showTagAnchors,
});
const graphData = generateGraph(events, Number(currentLevels));
let graphData = starVisualization
? generateStarGraph(events, Number(currentLevels))
: generateGraph(events, Number(currentLevels));
// Enhance with tag anchors if enabled
if (showTagAnchors) {
graphData = enhanceGraphWithTags(
graphData,
events,
selectedTagType,
width,
height,
);
// Extract tag anchor info for legend
tagAnchorInfo = graphData.nodes
.filter((n) => n.isTagAnchor)
.map((n) => ({
type: n.tagType,
label: n.title,
count: n.connectedNodes?.length || 0,
color: getTagAnchorColor(n.tagType || ""),
}));
} else {
tagAnchorInfo = [];
}
nodes = graphData.nodes;
links = graphData.links;
debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length
linkCount: links.length,
});
if (!nodes.length) {
@ -196,15 +265,31 @@ @@ -196,15 +265,31 @@
// Create new simulation
debug("Creating new simulation");
if (starVisualization) {
// Use star-specific simulation
simulation = createStarSimulation(nodes, links, width, height);
// Apply initial star positioning
applyInitialStarPositions(nodes, links, width, height);
} else {
// Use regular simulation
simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE);
}
// Center the nodes when the simulation is done
if (simulation) {
simulation.on("end", () => {
if (!starVisualization) {
centerGraph();
}
});
}
// Create drag handler
dragHandler = setupDragHandlers(simulation);
if (simulation) {
dragHandler = starVisualization
? createStarDragHandler(simulation)
: setupDragHandlers(simulation);
}
// Update links
debug("Updating links");
@ -212,13 +297,14 @@ @@ -212,13 +297,14 @@
.selectAll("path.link")
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`)
.join(
(enter: any) => enter
(enter: any) =>
enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
(update: any) => update,
(exit: any) => exit.remove()
(exit: any) => exit.remove(),
);
// Update nodes
@ -255,28 +341,100 @@ @@ -255,28 +341,100 @@
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "black")
.attr("font-size", "12px");
.attr("font-size", "12px")
.attr("stroke", "none")
.attr("font-weight", "bold")
.style("pointer-events", "none");
return nodeEnter;
},
(update: any) => update,
(exit: any) => exit.remove()
(exit: any) => exit.remove(),
);
// Update node appearances
debug("Updating node appearances");
node.select("circle.visual-circle")
.attr("class", (d: NetworkNode) => !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather"
)
.attr("fill", (d: NetworkNode) => !d.isContainer
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
);
node
.select("circle.visual-circle")
.attr("class", (d: NetworkNode) => {
const baseClasses = "visual-circle network-node-leather";
if (d.isTagAnchor) {
return `${baseClasses} tag-anchor-node`;
}
if (!d.isContainer) {
return `${baseClasses} network-node-content`;
}
if (starVisualization && d.kind === 30040) {
return `${baseClasses} star-center-node`;
}
return baseClasses;
})
.attr("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("r", (d: NetworkNode) => {
// Tag anchors are smaller
if (d.isTagAnchor) {
return NODE_RADIUS * 0.75;
}
// Make star center nodes larger
if (starVisualization && d.isContainer && d.kind === 30040) {
return NODE_RADIUS * 1.5;
}
return NODE_RADIUS;
})
.attr("stroke-width", (d: NetworkNode) => {
// Tag anchors have thicker stroke
if (d.isTagAnchor) {
return 3;
}
return 2;
});
node.select("text")
.text((d: NetworkNode) => d.isContainer ? "I" : "C");
node
.select("text")
.text((d: NetworkNode) => {
// Tag anchors show abbreviated type
if (d.isTagAnchor) {
return d.tagType === "t" ? "#" : "T";
}
// Always use I for index and C for content
return d.isContainer ? "I" : "C";
})
.attr("font-size", (d: NetworkNode) => {
if (d.isTagAnchor) {
return "10px";
}
if (starVisualization && d.isContainer && d.kind === 30040) {
return "14px";
}
return "12px";
})
.attr("fill", (d: NetworkNode) => {
// White text on tag anchors
if (d.isTagAnchor) {
return "white";
}
return "black";
})
.style("fill", (d: NetworkNode) => {
// Force fill style for tag anchors
if (d.isTagAnchor) {
return "white";
}
return null;
})
.attr("stroke", "none")
.style("stroke", "none");
// Set up node interactions
debug("Setting up node interactions");
@ -322,12 +480,19 @@ @@ -322,12 +480,19 @@
if (simulation) {
simulation.on("tick", () => {
// Apply custom forces to each node
nodes.forEach(node => {
if (!starVisualization) {
nodes.forEach((node) => {
// Pull nodes toward the center
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha());
applyGlobalLogGravity(
node,
width / 2,
height / 2,
simulation!.alpha(),
);
// Pull connected nodes toward each other
applyConnectedGravity(node, links, simulation!.alpha());
});
}
// Update link positions
link.attr("d", (d: NetworkLink) => {
@ -337,8 +502,21 @@ @@ -337,8 +502,21 @@
const angle = Math.atan2(dy, dx);
// Calculate start and end points with offsets for node radius
const sourceGap = NODE_RADIUS;
const targetGap = NODE_RADIUS + ARROW_DISTANCE;
const sourceRadius =
starVisualization &&
d.source.isContainer &&
d.source.kind === 30040
? NODE_RADIUS * 1.5
: NODE_RADIUS;
const targetRadius =
starVisualization &&
d.target.isContainer &&
d.target.kind === 30040
? NODE_RADIUS * 1.5
: NODE_RADIUS;
const sourceGap = sourceRadius;
const targetGap = targetRadius + ARROW_DISTANCE;
const startX = d.source.x! + sourceGap * Math.cos(angle);
const startY = d.source.y! + sourceGap * Math.sin(angle);
@ -349,7 +527,10 @@ @@ -349,7 +527,10 @@
});
// Update node positions
node.attr("transform", (d: NetworkNode) => `translate(${d.x},${d.y})`);
node.attr(
"transform",
(d: NetworkNode) => `translate(${d.x},${d.y})`,
);
});
}
} catch (error) {
@ -390,11 +571,15 @@ @@ -390,11 +571,15 @@
isDarkMode = newIsDarkMode;
// Update node colors when theme changes
if (svgGroup) {
svgGroup.selectAll("g.node")
svgGroup
.selectAll("g.node")
.select("circle.visual-circle")
.attr("fill", (d: NetworkNode) => !d.isContainer
? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
.attr("fill", (d: NetworkNode) =>
!d.isContainer
? newIsDarkMode
? CONTENT_COLOR_DARK
: CONTENT_COLOR_LIGHT
: getEventColor(d.id),
);
}
}
@ -440,13 +625,16 @@ @@ -440,13 +625,16 @@
debug("Effect triggered", {
hasSvg: !!svg,
eventCount: events?.length,
currentLevels
currentLevels,
});
try {
if (svg && events?.length) {
// Include currentLevels in the effect dependencies
// Include all relevant state in the effect dependencies
const _ = currentLevels;
const __ = starVisualization;
const ___ = showTagAnchors;
const ____ = selectedTagType;
updateGraph();
}
} catch (error) {
@ -472,9 +660,12 @@ @@ -472,9 +660,12 @@
const svgHeight = svg.clientHeight || height;
// Reset zoom and center
d3.select(svg).transition().duration(750).call(
d3.select(svg)
.transition()
.duration(750)
.call(
zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8)
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8),
);
}
}
@ -484,9 +675,7 @@ @@ -484,9 +675,7 @@
*/
function zoomIn() {
if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call(
zoomBehavior.scaleBy, 1.3
);
d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 1.3);
}
}
@ -495,9 +684,7 @@ @@ -495,9 +684,7 @@
*/
function zoomOut() {
if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call(
zoomBehavior.scaleBy, 0.7
);
d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 0.7);
}
}
@ -520,7 +707,10 @@ @@ -520,7 +707,10 @@
<p>{errorMessage}</p>
<button
class="network-error-retry"
onclick={() => { errorMessage = null; updateGraph(); }}
onclick={() => {
errorMessage = null;
updateGraph();
}}
>
Retry
</button>
@ -528,50 +718,94 @@ @@ -528,50 +718,94 @@
{/if}
<div class="network-svg-container" bind:this={container} role="figure">
<Legend collapsedOnInteraction={graphInteracted} className='' />
<Legend
collapsedOnInteraction={graphInteracted}
className=""
starMode={starVisualization}
showTags={showTagAnchors}
tagAnchors={tagAnchorInfo}
/>
<!-- Settings Panel (shown when settings button is clicked) -->
<Settings count={events.length} onupdate={onupdate} />
<Settings
count={events.length}
{onupdate}
bind:starVisualization
bind:showTagAnchors
bind:selectedTagType
/>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
bind:this={svg}
class="network-svg"
onclick={handleGraphClick}
/>
<svg bind:this={svg} class="network-svg" onclick={handleGraphClick} />
<!-- Zoom controls -->
<div class="network-controls">
<Button outline size="lg"
<Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomIn}
aria-label="Zoom in"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</Button>
<Button outline size="lg"
<Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomOut}
aria-label="Zoom out"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</Button>
<Button outline size="lg"
<Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={centerGraph}
aria-label="Center graph"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
@ -586,7 +820,7 @@ @@ -586,7 +820,7 @@
x={tooltipX}
y={tooltipY}
onclose={handleTooltipClose}
starMode={starVisualization}
/>
{/if}
</div>

8
src/lib/navigator/EventNetwork/types.ts

@ -43,10 +43,16 @@ export interface NetworkNode extends SimulationNodeDatum { @@ -43,10 +43,16 @@ export interface NetworkNode extends SimulationNodeDatum {
title: string; // Event title
content: string; // Event content
author: string; // Author's public key
type: "Index" | "Content"; // Node type classification
type: "Index" | "Content" | "TagAnchor"; // Node type classification
naddr?: string; // NIP-19 naddr identifier
nevent?: string; // NIP-19 nevent identifier
isContainer?: boolean; // Whether this node is a container (index)
// Tag anchor specific fields
isTagAnchor?: boolean; // Whether this is a tag anchor node
tagType?: string; // Type of tag (t, p, e, etc.)
tagValue?: string; // The tag value
connectedNodes?: string[]; // IDs of nodes that have this tag
}
/**

20
src/lib/navigator/EventNetwork/utils/forceSimulation.ts

@ -102,6 +102,9 @@ export function applyGlobalLogGravity( @@ -102,6 +102,9 @@ export function applyGlobalLogGravity(
centerY: number,
alpha: number,
) {
// Tag anchors should not be affected by gravity
if (node.isTagAnchor) return;
const dx = (node.x ?? 0) - centerX;
const dy = (node.y ?? 0) - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
@ -127,10 +130,14 @@ export function applyConnectedGravity( @@ -127,10 +130,14 @@ export function applyConnectedGravity(
links: NetworkLink[],
alpha: number,
) {
// Find all nodes connected to this node
// Tag anchors should not be affected by connected gravity
if (node.isTagAnchor) return;
// Find all nodes connected to this node (excluding tag anchors)
const connectedNodes = links
.filter(link => link.source.id === node.id || link.target.id === node.id)
.map(link => link.source.id === node.id ? link.target : link.source);
.map(link => link.source.id === node.id ? link.target : link.source)
.filter(n => !n.isTagAnchor);
if (connectedNodes.length === 0) return;
@ -168,6 +175,9 @@ export function setupDragHandlers( @@ -168,6 +175,9 @@ export function setupDragHandlers(
return d3
.drag()
.on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Tag anchors should never be draggable
if (d.isTagAnchor) return;
// Warm up simulation if it's cooled down
if (!event.active) {
simulation.alphaTarget(warmupClickEnergy).restart();
@ -177,11 +187,17 @@ export function setupDragHandlers( @@ -177,11 +187,17 @@ export function setupDragHandlers(
d.fy = d.y;
})
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Tag anchors should never be draggable
if (d.isTagAnchor) return;
// Update fixed position to mouse position
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Tag anchors should never be draggable
if (d.isTagAnchor) return;
// Cool down simulation when drag ends
if (!event.active) {
simulation.alphaTarget(0);

265
src/lib/navigator/EventNetwork/utils/starForceSimulation.ts

@ -0,0 +1,265 @@ @@ -0,0 +1,265 @@
/**
* Star Network Force Simulation
*
* Custom force simulation optimized for star network layouts.
* Provides stronger connections between star centers and their content nodes,
* with specialized forces to maintain hierarchical structure.
*/
import * as d3 from "d3";
import type { NetworkNode, NetworkLink } from "../types";
import type { Simulation } from "./forceSimulation";
import { createTagGravityForce } from "./tagNetworkBuilder";
// Configuration for star network forces
const STAR_CENTER_CHARGE = -300; // Stronger repulsion between star centers
const CONTENT_NODE_CHARGE = -50; // Weaker repulsion for content nodes
const STAR_LINK_STRENGTH = 0.5; // Moderate connection to star center
const INTER_STAR_LINK_STRENGTH = 0.2; // Weaker connection between stars
const STAR_LINK_DISTANCE = 80; // Fixed distance from center to content
const INTER_STAR_DISTANCE = 200; // Distance between star centers
const CENTER_GRAVITY = 0.02; // Gentle pull toward canvas center
const STAR_CENTER_WEIGHT = 10; // Weight multiplier for star centers
/**
* Creates a custom force simulation for star networks
*/
export function createStarSimulation(
nodes: NetworkNode[],
links: NetworkLink[],
width: number,
height: number
): Simulation<NetworkNode, NetworkLink> {
// Create the simulation
const simulation = d3.forceSimulation(nodes) as any
simulation
.force("center", d3.forceCenter(width / 2, height / 2).strength(CENTER_GRAVITY))
.velocityDecay(0.2) // Lower decay for more responsive simulation
.alphaDecay(0.0001) // Much slower alpha decay to prevent freezing
.alphaMin(0.001); // Keep minimum energy to prevent complete freeze
// Custom charge force that varies by node type
const chargeForce = d3.forceManyBody()
.strength((d: NetworkNode) => {
// Tag anchors don't repel
if (d.isTagAnchor) {
return 0;
}
// Star centers repel each other strongly
if (d.isContainer && d.kind === 30040) {
return STAR_CENTER_CHARGE;
}
// Content nodes have minimal repulsion
return CONTENT_NODE_CHARGE;
})
.distanceMax(300); // Limit charge force range
// Custom link force with variable strength and distance
const linkForce = d3.forceLink(links)
.id((d: NetworkNode) => d.id)
.strength((link: any) => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
// Strong connection from star center to its content
if (source.kind === 30040 && target.kind === 30041) {
return STAR_LINK_STRENGTH;
}
// Weaker connection between star centers
if (source.kind === 30040 && target.kind === 30040) {
return INTER_STAR_LINK_STRENGTH;
}
return 0.5; // Default strength
})
.distance((link: any) => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
// Fixed distance for star-to-content links
if (source.kind === 30040 && target.kind === 30041) {
return STAR_LINK_DISTANCE;
}
// Longer distance between star centers
if (source.kind === 30040 && target.kind === 30040) {
return INTER_STAR_DISTANCE;
}
return 100; // Default distance
});
// Apply forces to simulation
simulation
.force("charge", chargeForce)
.force("link", linkForce);
// Custom radial force to keep content nodes around their star center
simulation.force("radial", createRadialForce(nodes, links));
// Add tag gravity force if there are tag anchors
const hasTagAnchors = nodes.some(n => n.isTagAnchor);
if (hasTagAnchors) {
simulation.force("tagGravity", createTagGravityForce(nodes, links));
}
// Periodic reheat to prevent freezing
let tickCount = 0;
simulation.on("tick", () => {
tickCount++;
// Every 300 ticks, give a small energy boost to prevent freezing
if (tickCount % 300 === 0 && simulation.alpha() < 0.01) {
simulation.alpha(0.02);
}
});
return simulation;
}
/**
* Creates a custom radial force that keeps content nodes in orbit around their star center
*/
function createRadialForce(nodes: NetworkNode[], links: NetworkLink[]): any {
// Build a map of content nodes to their star centers
const nodeToCenter = new Map<string, NetworkNode>();
links.forEach(link => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
if (source.kind === 30040 && target.kind === 30041) {
nodeToCenter.set(target.id, source);
}
});
// Custom force function
function force(alpha: number) {
nodes.forEach(node => {
if (node.kind === 30041) {
const center = nodeToCenter.get(node.id);
if (center && center.x != null && center.y != null && node.x != null && node.y != null) {
// Calculate desired position
const dx = node.x - center.x;
const dy = node.y - center.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
// Normalize and apply force
const targetDistance = STAR_LINK_DISTANCE;
const force = (distance - targetDistance) * alpha * 0.3; // Reduced force
node.vx = (node.vx || 0) - (dx / distance) * force;
node.vy = (node.vy || 0) - (dy / distance) * force;
}
}
}
});
}
force.initialize = function(_: NetworkNode[]) {
nodes = _;
};
return force;
}
/**
* Applies initial positioning for star networks
*/
export function applyInitialStarPositions(
nodes: NetworkNode[],
links: NetworkLink[],
width: number,
height: number
): void {
// Group nodes by their star centers
const starGroups = new Map<string, NetworkNode[]>();
const starCenters: NetworkNode[] = [];
// Identify star centers
nodes.forEach(node => {
if (node.isContainer && node.kind === 30040) {
starCenters.push(node);
starGroups.set(node.id, []);
}
});
// Assign content nodes to their star centers
links.forEach(link => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
if (source.kind === 30040 && target.kind === 30041) {
const group = starGroups.get(source.id);
if (group) {
group.push(target);
}
}
});
// Position star centers in a grid or circle
if (starCenters.length === 1) {
// Single star - center it
const center = starCenters[0];
center.x = width / 2;
center.y = height / 2;
// Don't fix position initially - let simulation run naturally
} else if (starCenters.length > 1) {
// Multiple stars - arrange in a circle
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.3;
const angleStep = (2 * Math.PI) / starCenters.length;
starCenters.forEach((center, i) => {
const angle = i * angleStep;
center.x = centerX + radius * Math.cos(angle);
center.y = centerY + radius * Math.sin(angle);
// Don't fix position initially - let simulation adjust
});
}
// Position content nodes around their star centers
starGroups.forEach((contentNodes, centerId) => {
const center = nodes.find(n => n.id === centerId);
if (!center) return;
const angleStep = (2 * Math.PI) / Math.max(contentNodes.length, 1);
contentNodes.forEach((node, i) => {
const angle = i * angleStep;
node.x = (center.x || 0) + STAR_LINK_DISTANCE * Math.cos(angle);
node.y = (center.y || 0) + STAR_LINK_DISTANCE * Math.sin(angle);
});
});
}
/**
* Custom drag handler for star networks
*/
export function createStarDragHandler(
simulation: Simulation<NetworkNode, NetworkLink>
): any {
function dragstarted(event: any, d: NetworkNode) {
if (!event.active) simulation.alphaTarget(0.1).restart(); // Lower target for smoother dragging
// For all nodes, set their fixed position at start
d.fx = d.x;
d.fy = d.y;
}
function dragged(event: any, d: NetworkNode) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event: any, d: NetworkNode) {
if (!event.active) simulation.alphaTarget(0);
// Tag anchors and star centers stay fixed after dragging
if (d.isTagAnchor || d.kind === 30040) {
d.fx = event.x;
d.fy = event.y;
} else {
// Let content nodes float
d.fx = null;
d.fy = null;
}
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}

338
src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts

@ -0,0 +1,338 @@ @@ -0,0 +1,338 @@
/**
* Star Network Builder for NKBIP-01 Events
*
* This module provides utilities for building star network visualizations specifically
* for NKBIP-01 events (kinds 30040 and 30041). Unlike the sequential network builder,
* this creates star formations where index events (30040) are central nodes with
* content events (30041) arranged around them.
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { createNetworkNode, createEventMap, extractEventIdFromATag, getEventColor } from './networkBuilder';
// Configuration
const DEBUG = false;
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KIND = 30041;
/**
* Debug logging function
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[StarNetworkBuilder]", ...args);
}
}
/**
* Represents a star network with a central index node and peripheral content nodes
*/
export interface StarNetwork {
center: NetworkNode; // Central index node (30040)
peripheralNodes: NetworkNode[]; // Content nodes (30041) and connected indices (30040)
links: NetworkLink[]; // Links within this star
}
/**
* Creates a star network from an index event and its references
*
* @param indexEvent - The central index event (30040)
* @param state - Current graph state
* @param level - Hierarchy level for this star
* @returns A star network structure
*/
export function createStarNetwork(
indexEvent: NDKEvent,
state: GraphState,
level: number = 0
): StarNetwork | null {
debug("Creating star network", { indexId: indexEvent.id, level });
const centerNode = state.nodeMap.get(indexEvent.id);
if (!centerNode) {
debug("Center node not found for index event", indexEvent.id);
return null;
}
// Set the center node level
centerNode.level = level;
// Extract referenced event IDs from 'a' tags
const referencedIds = getMatchingTags(indexEvent, "a")
.map(tag => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null);
debug("Found referenced IDs", { count: referencedIds.length, ids: referencedIds });
// Get peripheral nodes (both content and nested indices)
const peripheralNodes: NetworkNode[] = [];
const links: NetworkLink[] = [];
referencedIds.forEach(id => {
const node = state.nodeMap.get(id);
if (node) {
// Set the peripheral node level
node.level = level + 1;
peripheralNodes.push(node);
// Create link from center to peripheral node
links.push({
source: centerNode,
target: node,
isSequential: false // Star links are not sequential
});
debug("Added peripheral node", { nodeId: id, nodeType: node.type });
}
});
return {
center: centerNode,
peripheralNodes,
links
};
}
/**
* Processes all index events to create star networks
*
* @param events - Array of all events
* @param maxLevel - Maximum nesting level to process
* @returns Array of star networks
*/
export function createStarNetworks(
events: NDKEvent[],
maxLevel: number,
existingNodeMap?: Map<string, NetworkNode>
): StarNetwork[] {
debug("Creating star networks", { eventCount: events.length, maxLevel });
// Use existing node map or create new one
const nodeMap = existingNodeMap || new Map<string, NetworkNode>();
const eventMap = createEventMap(events);
// Create nodes for all events if not using existing map
if (!existingNodeMap) {
events.forEach(event => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
});
}
const state: GraphState = {
nodeMap,
links: [],
eventMap,
referencedIds: new Set<string>()
};
// Find all index events
const indexEvents = events.filter(event => event.kind === INDEX_EVENT_KIND);
debug("Found index events", { count: indexEvents.length });
const starNetworks: StarNetwork[] = [];
const processedIndices = new Set<string>();
// Process all index events regardless of level
indexEvents.forEach(indexEvent => {
if (!indexEvent.id || processedIndices.has(indexEvent.id)) return;
const star = createStarNetwork(indexEvent, state, 0);
if (star && star.peripheralNodes.length > 0) {
starNetworks.push(star);
processedIndices.add(indexEvent.id);
debug("Created star network", {
centerId: star.center.id,
peripheralCount: star.peripheralNodes.length
});
}
});
return starNetworks;
}
/**
* Creates inter-star connections between star networks
*
* @param starNetworks - Array of star networks
* @returns Additional links connecting different star networks
*/
export function createInterStarConnections(starNetworks: StarNetwork[]): NetworkLink[] {
debug("Creating inter-star connections", { starCount: starNetworks.length });
const interStarLinks: NetworkLink[] = [];
// Create a map of center nodes for quick lookup
const centerNodeMap = new Map<string, NetworkNode>();
starNetworks.forEach(star => {
centerNodeMap.set(star.center.id, star.center);
});
// For each star, check if any of its peripheral nodes are centers of other stars
starNetworks.forEach(star => {
star.peripheralNodes.forEach(peripheralNode => {
// If this peripheral node is the center of another star, create an inter-star link
if (peripheralNode.isContainer && centerNodeMap.has(peripheralNode.id)) {
const targetStar = starNetworks.find(s => s.center.id === peripheralNode.id);
if (targetStar) {
interStarLinks.push({
source: star.center,
target: targetStar.center,
isSequential: false
});
debug("Created inter-star connection", {
from: star.center.id,
to: targetStar.center.id
});
}
}
});
});
return interStarLinks;
}
/**
* Applies star-specific positioning to nodes using a radial layout
*
* @param starNetworks - Array of star networks
* @param width - Canvas width
* @param height - Canvas height
*/
export function applyStarLayout(
starNetworks: StarNetwork[],
width: number,
height: number
): void {
debug("Applying star layout", {
starCount: starNetworks.length,
dimensions: { width, height }
});
const centerX = width / 2;
const centerY = height / 2;
// If only one star, center it
if (starNetworks.length === 1) {
const star = starNetworks[0];
// Position center node
star.center.x = centerX;
star.center.y = centerY;
star.center.fx = centerX; // Fix center position
star.center.fy = centerY;
// Position peripheral nodes in a circle around center
const radius = Math.min(width, height) * 0.25;
const angleStep = (2 * Math.PI) / star.peripheralNodes.length;
star.peripheralNodes.forEach((node, index) => {
const angle = index * angleStep;
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
});
return;
}
// For multiple stars, arrange them in a grid or circle
const starsPerRow = Math.ceil(Math.sqrt(starNetworks.length));
const starSpacingX = width / (starsPerRow + 1);
const starSpacingY = height / (Math.ceil(starNetworks.length / starsPerRow) + 1);
starNetworks.forEach((star, index) => {
const row = Math.floor(index / starsPerRow);
const col = index % starsPerRow;
const starCenterX = (col + 1) * starSpacingX;
const starCenterY = (row + 1) * starSpacingY;
// Position center node
star.center.x = starCenterX;
star.center.y = starCenterY;
star.center.fx = starCenterX; // Fix center position
star.center.fy = starCenterY;
// Position peripheral nodes around this star's center
const radius = Math.min(starSpacingX, starSpacingY) * 0.3;
const angleStep = (2 * Math.PI) / Math.max(star.peripheralNodes.length, 1);
star.peripheralNodes.forEach((node, nodeIndex) => {
const angle = nodeIndex * angleStep;
node.x = starCenterX + radius * Math.cos(angle);
node.y = starCenterY + radius * Math.sin(angle);
});
});
}
/**
* Generates a complete star network graph from events
*
* @param events - Array of Nostr events
* @param maxLevel - Maximum hierarchy level to process
* @returns Complete graph data with star network layout
*/
export function generateStarGraph(
events: NDKEvent[],
maxLevel: number
): GraphData {
debug("Generating star graph", { eventCount: events.length, maxLevel });
// Guard against empty events
if (!events || events.length === 0) {
return { nodes: [], links: [] };
}
// Initialize all nodes first
const nodeMap = new Map<string, NetworkNode>();
events.forEach(event => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
});
// Create star networks with the existing node map
const starNetworks = createStarNetworks(events, maxLevel, nodeMap);
// Create inter-star connections
const interStarLinks = createInterStarConnections(starNetworks);
// Collect nodes that are part of stars
const nodesInStars = new Set<string>();
const allLinks: NetworkLink[] = [];
// Add nodes and links from all stars
starNetworks.forEach(star => {
nodesInStars.add(star.center.id);
star.peripheralNodes.forEach(node => {
nodesInStars.add(node.id);
});
allLinks.push(...star.links);
});
// Add inter-star links
allLinks.push(...interStarLinks);
// Include orphaned nodes (those not in any star)
const allNodes: NetworkNode[] = [];
nodeMap.forEach((node, id) => {
allNodes.push(node);
});
const result = {
nodes: allNodes,
links: allLinks
};
debug("Star graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length,
starCount: starNetworks.length,
orphanedNodes: allNodes.length - nodesInStars.size
});
return result;
}

284
src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts

@ -0,0 +1,284 @@ @@ -0,0 +1,284 @@
/**
* Tag Network Builder
*
* Enhances network visualizations with tag anchor nodes that act as gravity points
* for nodes sharing the same tags.
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData } from "../types";
// Configuration
const TAG_ANCHOR_RADIUS = 15;
// TODO: Move this to settings panel for user control
const TAG_ANCHOR_PLACEMENT_RADIUS = 2500; // Radius from center within which to randomly place tag anchors
/**
* Simple seeded random number generator (using a Linear Congruential Generator)
* This ensures consistent positioning for the same tag values across sessions
*/
class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
// Generate next random number between 0 and 1
next(): number {
this.seed = (this.seed * 9301 + 49297) % 233280;
return this.seed / 233280;
}
}
/**
* Creates a deterministic seed from a string
*/
function createSeed(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Color mapping for tag anchor nodes
*/
export function getTagAnchorColor(tagType: string): string {
switch (tagType) {
case "t":
return "#3B82F6"; // Blue for hashtags
case "p":
return "#10B981"; // Green for people
case "author":
return "#8B5CF6"; // Purple for authors
case "e":
return "#F59E0B"; // Yellow for events
case "a":
return "#EF4444"; // Red for articles
default:
return "#6B7280"; // Gray for others
}
}
/**
* Extracts unique tags from events for a specific tag type
*/
export function extractUniqueTagsForType(
events: NDKEvent[],
tagType: string,
): Map<string, Set<string>> {
// Map of tagValue -> Set of event IDs
const tagMap = new Map<string, Set<string>>();
events.forEach((event) => {
if (!event.tags || !event.id) return;
event.tags.forEach((tag) => {
if (tag.length < 2) return;
if (tag[0] !== tagType) return;
const tagValue = tag[1];
if (!tagValue) return;
if (!tagMap.has(tagValue)) {
tagMap.set(tagValue, new Set());
}
tagMap.get(tagValue)!.add(event.id);
});
});
return tagMap;
}
/**
* Creates tag anchor nodes from extracted tags of a specific type
*/
export function createTagAnchorNodes(
tagMap: Map<string, Set<string>>,
tagType: string,
width: number,
height: number,
): NetworkNode[] {
const anchorNodes: NetworkNode[] = [];
// Calculate positions for tag anchors randomly within radius
const validTags = Array.from(tagMap.entries()).filter(
([_, eventIds]) => eventIds.size >= 2,
);
if (validTags.length === 0) return [];
validTags.forEach(([tagValue, eventIds]) => {
// Position anchors randomly within a radius from the center
const centerX = width / 2;
const centerY = height / 2;
// Create seeded random generator based on tag type and value for consistent positioning
const seedString = `${tagType}-${tagValue}`;
const rng = new SeededRandom(createSeed(seedString));
// Generate deterministic position within the defined radius
const angle = rng.next() * 2 * Math.PI;
const distance = rng.next() * TAG_ANCHOR_PLACEMENT_RADIUS;
const x = centerX + distance * Math.cos(angle);
const y = centerY + distance * Math.sin(angle);
// Format the display title based on tag type
let displayTitle = tagValue;
if (tagType === "t") {
displayTitle = tagValue.startsWith("#") ? tagValue : `#${tagValue}`;
} else if (tagType === "author") {
displayTitle = tagValue;
} else if (tagType === "p") {
// Truncate pubkey for display
displayTitle = `${tagValue.substring(0, 8)}...`;
}
const anchorNode: NetworkNode = {
id: `tag-anchor-${tagType}-${tagValue}`,
title: displayTitle,
content: `${eventIds.size} events`,
author: "",
kind: 0, // Special kind for tag anchors
type: "TagAnchor",
level: -1, // Tag anchors are outside the hierarchy
isTagAnchor: true,
tagType,
tagValue,
connectedNodes: Array.from(eventIds),
x,
y,
fx: x, // Fix position
fy: y,
};
anchorNodes.push(anchorNode);
});
return anchorNodes;
}
/**
* Creates invisible links between tag anchors and nodes that have those tags
*/
export function createTagLinks(
tagAnchors: NetworkNode[],
nodes: NetworkNode[],
): NetworkLink[] {
const links: NetworkLink[] = [];
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
tagAnchors.forEach((anchor) => {
if (!anchor.connectedNodes) return;
anchor.connectedNodes.forEach((nodeId) => {
const node = nodeMap.get(nodeId);
if (node) {
links.push({
source: anchor,
target: node,
isSequential: false,
});
}
});
});
return links;
}
/**
* Enhances a graph with tag anchor nodes for a specific tag type
*/
export function enhanceGraphWithTags(
graphData: GraphData,
events: NDKEvent[],
tagType: string,
width: number,
height: number,
): GraphData {
// Extract unique tags for the specified type
const tagMap = extractUniqueTagsForType(events, tagType);
// Create tag anchor nodes
const tagAnchors = createTagAnchorNodes(tagMap, tagType, width, height);
// Create links between anchors and nodes
const tagLinks = createTagLinks(tagAnchors, graphData.nodes);
// Return enhanced graph
return {
nodes: [...graphData.nodes, ...tagAnchors],
links: [...graphData.links, ...tagLinks],
};
}
/**
* Custom force for tag anchor gravity
*/
export function createTagGravityForce(
nodes: NetworkNode[],
links: NetworkLink[],
): any {
// Build a map of nodes to their tag anchors
const nodeToAnchors = new Map<string, NetworkNode[]>();
links.forEach((link) => {
const source = link.source as NetworkNode;
const target = link.target as NetworkNode;
if (source.isTagAnchor && !target.isTagAnchor) {
if (!nodeToAnchors.has(target.id)) {
nodeToAnchors.set(target.id, []);
}
nodeToAnchors.get(target.id)!.push(source);
} else if (target.isTagAnchor && !source.isTagAnchor) {
if (!nodeToAnchors.has(source.id)) {
nodeToAnchors.set(source.id, []);
}
nodeToAnchors.get(source.id)!.push(target);
}
});
// Custom force function
function force(alpha: number) {
nodes.forEach((node) => {
if (node.isTagAnchor) return; // Tag anchors don't move
const anchors = nodeToAnchors.get(node.id);
if (!anchors || anchors.length === 0) return;
// Apply gentle pull toward each tag anchor
anchors.forEach((anchor) => {
if (
anchor.x != null &&
anchor.y != null &&
node.x != null &&
node.y != null
) {
const dx = anchor.x - node.x;
const dy = anchor.y - node.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
// Gentle force that decreases with distance
const strength = (0.02 * alpha) / anchors.length;
node.vx = (node.vx || 0) + (dx / distance) * strength * distance;
node.vy = (node.vy || 0) + (dy / distance) * strength * distance;
}
}
});
});
}
force.initialize = function (_: NetworkNode[]) {
nodes = _;
};
return force;
}

68
src/styles/visualize.css

@ -26,7 +26,7 @@ @@ -26,7 +26,7 @@
}
:global(.dark) .legend-circle.content {
background-color: var(--content-color-dark, #FFFFFF);
background-color: var(--content-color-dark, #ffffff);
}
.legend-letter {
@ -109,4 +109,70 @@ @@ -109,4 +109,70 @@
.tooltip-help-text {
@apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic;
}
/* Star network visualization styles */
.star-center-node {
@apply transition-all duration-300;
stroke-width: 3px;
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.2));
}
.star-center-node:hover {
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.3));
transform: scale(1.05);
}
.network-node-content {
@apply transition-all duration-200;
}
.network-node-content:hover {
@apply transform scale-110;
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.3));
}
/* Tag anchor nodes */
.tag-anchor-node {
@apply transition-all duration-300;
stroke: rgba(255, 255, 255, 0.8);
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.4));
cursor: default !important;
}
.tag-anchor-node:hover {
filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.6));
transform: scale(1.1);
}
/* Ensure drag cursor doesn't appear on tag anchors */
.node.drag-circle {
cursor: move;
}
.node.tag-anchor-node .drag-circle {
cursor: default !important;
}
/* Tag grid layout in legend */
.tag-grid {
display: grid;
gap: 0.5rem; /* Change this value to adjust spacing between columns and rows */
column-gap: 1.75rem; /* Use this to set column spacing independently */
row-gap: 0.5rem; /* Use this to set row spacing independently */
width: 100%;
}
.tag-grid-item {
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
}
.tag-grid-item .legend-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
}

Loading…
Cancel
Save