diff --git a/package-lock.json b/package-lock.json index d447e7a..59e5f20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@sveltejs/adapter-static": "3.x", "@sveltejs/kit": "2.x", "@sveltejs/vite-plugin-svelte": "4.x", + "@types/d3": "^7.4.3", "@types/he": "1.2.x", "@types/node": "22.x", "autoprefixer": "10.x", @@ -1531,6 +1532,290 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1538,6 +1823,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/he": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", diff --git a/package.json b/package.json index 2323efa..eab2229 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@sveltejs/adapter-static": "3.x", "@sveltejs/kit": "2.x", "@sveltejs/vite-plugin-svelte": "4.x", + "@types/d3": "^7.4.3", "@types/he": "1.2.x", "@types/node": "22.x", "autoprefixer": "10.x", diff --git a/src/app.css b/src/app.css index 011ebd9..235f36b 100644 --- a/src/app.css +++ b/src/app.css @@ -190,6 +190,8 @@ .ul-leather li a { @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; } + + /* Network visualization */ .network-link-leather { @apply stroke-gray-400 fill-gray-400; } @@ -202,11 +204,23 @@ } @layer components { + /* Legend */ .leather-legend { @apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow border border-gray-200 dark:border-gray-800; } + + /* Tooltip */ .tooltip-leather { - @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; + @apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 + text-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700 + transition-colors duration-200; + max-width: 400px; + z-index: 1000; + } + + /* Heading for leather components */ + h3.h-leather { + @apply text-gray-800 dark:text-gray-200 text-lg font-bold mb-2; } } diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index fe88aac..76be770 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -1,30 +1,61 @@ +
-

Legend

+

Legend

+ + diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index cb6f779..4aedc8e 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -1,20 +1,33 @@ + -
-
+
+ {#if hasError} +
+

Error

+

{errorMessage}

+ +
+ {/if} + +
+ + +
+ + + +
{#if tooltipVisible && tooltipNode} @@ -327,7 +564,7 @@ selected={tooltipNode.id === selectedNodeId} x={tooltipX} y={tooltipY} - on:close={handleTooltipClose} + onclose={handleTooltipClose} /> {/if} diff --git a/src/lib/navigator/EventNetwork/types.ts b/src/lib/navigator/EventNetwork/types.ts index 276b871..db2d46b 100644 --- a/src/lib/navigator/EventNetwork/types.ts +++ b/src/lib/navigator/EventNetwork/types.ts @@ -1,35 +1,79 @@ +/** + * Type definitions for the Event Network visualization + * + * This module defines the core data structures used in the D3 force-directed + * graph visualization of Nostr events. + */ + import type { NDKEvent } from "@nostr-dev-kit/ndk"; -export interface NetworkNode extends d3.SimulationNodeDatum { - id: string; - event?: NDKEvent; - level: number; - kind: number; - title: string; - content: string; - author: string; - type: "Index" | "Content"; - naddr?: string; - nevent?: string; - x?: number; - y?: number; - isContainer?: boolean; +/** + * Base interface for nodes in a D3 force simulation + * Represents the physical properties of a node in the simulation + */ +export interface SimulationNodeDatum { + index?: number; // Node index in the simulation + x?: number; // X position + y?: number; // Y position + vx?: number; // X velocity + vy?: number; // Y velocity + fx?: number | null; // Fixed X position (when node is pinned) + fy?: number | null; // Fixed Y position (when node is pinned) +} + +/** + * Base interface for links in a D3 force simulation + * Represents connections between nodes + */ +export interface SimulationLinkDatum { + source: NodeType | string | number; // Source node or identifier + target: NodeType | string | number; // Target node or identifier + index?: number; // Link index in the simulation } -export interface NetworkLink extends d3.SimulationLinkDatum { - source: NetworkNode; - target: NetworkNode; - isSequential: boolean; +/** + * Represents a node in the event network visualization + * Extends the base simulation node with Nostr event-specific properties + */ +export interface NetworkNode extends SimulationNodeDatum { + id: string; // Unique identifier (event ID) + event?: NDKEvent; // Reference to the original NDK event + level: number; // Hierarchy level in the network + kind: number; // Nostr event kind (30040 for index, 30041/30818 for content) + title: string; // Event title + content: string; // Event content + author: string; // Author's public key + type: "Index" | "Content"; // Node type classification + naddr?: string; // NIP-19 naddr identifier + nevent?: string; // NIP-19 nevent identifier + isContainer?: boolean; // Whether this node is a container (index) } +/** + * Represents a link between nodes in the event network + * Extends the base simulation link with event-specific properties + */ +export interface NetworkLink extends SimulationLinkDatum { + source: NetworkNode; // Source node (overridden to be more specific) + target: NetworkNode; // Target node (overridden to be more specific) + isSequential: boolean; // Whether this link represents a sequential relationship +} + +/** + * Represents the complete graph data for visualization + */ export interface GraphData { - nodes: NetworkNode[]; - links: NetworkLink[]; + nodes: NetworkNode[]; // All nodes in the graph + links: NetworkLink[]; // All links in the graph } +/** + * Represents the internal state of the graph during construction + * Used to track relationships and build the final graph + */ export interface GraphState { - nodeMap: Map; - links: NetworkLink[]; - eventMap: Map; - referencedIds: Set; -} \ No newline at end of file + nodeMap: Map; // Maps event IDs to nodes + links: NetworkLink[]; // All links in the graph + eventMap: Map; // Maps event IDs to original events + referencedIds: Set; // Set of event IDs referenced by other events +} diff --git a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts index 2ba8e90..34731b3 100644 --- a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts +++ b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts @@ -1,27 +1,100 @@ /** - * D3 force simulation utilities for the event network + * D3 Force Simulation Utilities + * + * This module provides utilities for creating and managing D3 force-directed + * graph simulations for the event network visualization. */ import type { NetworkNode, NetworkLink } from "../types"; -import type { Simulation } from "d3"; import * as d3 from "d3"; +// Configuration +const DEBUG = false; // Set to true to enable debug logging +const GRAVITY_STRENGTH = 0.05; // Strength of global gravity +const CONNECTED_GRAVITY_STRENGTH = 0.3; // Strength of gravity between connected nodes + +/** + * Debug logging function that only logs when DEBUG is true + */ +function debug(...args: any[]) { + if (DEBUG) { + console.log("[ForceSimulation]", ...args); + } +} + +/** + * Type definition for D3 force simulation + * Provides type safety for simulation operations + */ +export interface Simulation { + nodes(): NodeType[]; + nodes(nodes: NodeType[]): this; + alpha(): number; + alpha(alpha: number): this; + alphaTarget(): number; + alphaTarget(target: number): this; + restart(): this; + stop(): this; + tick(): this; + on(type: string, listener: (this: this) => void): this; + force(name: string): any; + force(name: string, force: any): this; +} + /** - * Updates a node's velocity + * Type definition for D3 drag events + * Provides type safety for drag operations + */ +export interface D3DragEvent { + active: number; + sourceEvent: any; + subject: Subject; + x: number; + y: number; + dx: number; + dy: number; + identifier: string | number; +} + +/** + * Updates a node's velocity by applying a force + * + * @param node - The node to update + * @param deltaVx - Change in x velocity + * @param deltaVy - Change in y velocity */ export function updateNodeVelocity( node: NetworkNode, deltaVx: number, deltaVy: number ) { + debug("Updating node velocity", { + nodeId: node.id, + currentVx: node.vx, + currentVy: node.vy, + deltaVx, + deltaVy + }); + if (typeof node.vx === "number" && typeof node.vy === "number") { node.vx = node.vx - deltaVx; node.vy = node.vy - deltaVy; + debug("New velocity", { nodeId: node.id, vx: node.vx, vy: node.vy }); + } else { + debug("Node velocity not defined", { nodeId: node.id }); } } /** - * Applies a logarithmic gravity force to a node + * Applies a logarithmic gravity force pulling the node toward the center + * + * The logarithmic scale ensures that nodes far from the center experience + * stronger gravity, preventing them from drifting too far away. + * + * @param node - The node to apply gravity to + * @param centerX - X coordinate of the center + * @param centerY - Y coordinate of the center + * @param alpha - Current simulation alpha (cooling factor) */ export function applyGlobalLogGravity( node: NetworkNode, @@ -35,102 +108,128 @@ export function applyGlobalLogGravity( if (distance === 0) return; - const force = Math.log(distance + 1) * 0.05 * alpha; + const force = Math.log(distance + 1) * GRAVITY_STRENGTH * alpha; updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); } /** * Applies gravity between connected nodes + * + * This creates a cohesive force that pulls connected nodes toward their + * collective center of gravity, creating more meaningful clusters. + * + * @param node - The node to apply connected gravity to + * @param links - All links in the network + * @param alpha - Current simulation alpha (cooling factor) */ export function applyConnectedGravity( node: NetworkNode, links: NetworkLink[], alpha: number, ) { + // Find all nodes connected to this node 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)); + .filter(link => link.source.id === node.id || link.target.id === node.id) + .map(link => link.source.id === node.id ? link.target : link.source); if (connectedNodes.length === 0) return; - const cogX = d3.mean(connectedNodes, (n) => n.x); - const cogY = d3.mean(connectedNodes, (n) => n.y); + // Calculate center of gravity of connected nodes + const cogX = d3.mean(connectedNodes, (n: NetworkNode) => n.x); + const cogY = d3.mean(connectedNodes, (n: NetworkNode) => n.y); if (cogX === undefined || cogY === undefined) return; + // Calculate force direction and magnitude const dx = (node.x ?? 0) - cogX; const dy = (node.y ?? 0) - cogY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance === 0) return; - const force = distance * 0.3 * alpha; + // Apply force proportional to distance + const force = distance * CONNECTED_GRAVITY_STRENGTH * alpha; updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); } /** * Sets up drag behavior for nodes + * + * This enables interactive dragging of nodes in the visualization. + * + * @param simulation - The D3 force simulation + * @param warmupClickEnergy - Alpha target when dragging starts (0-1) + * @returns D3 drag behavior configured for the simulation */ export function setupDragHandlers( simulation: Simulation, warmupClickEnergy: number = 0.9 ) { return d3 - .drag() - .on( - "start", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - if (!event.active) - simulation.alphaTarget(warmupClickEnergy).restart(); - d.fx = d.x; - d.fy = d.y; - }, - ) - .on( - "drag", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - d.fx = event.x; - d.fy = event.y; - }, - ) - .on( - "end", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - if (!event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; - }, - ); + .drag() + .on("start", (event: D3DragEvent, d: NetworkNode) => { + // Warm up simulation if it's cooled down + if (!event.active) { + simulation.alphaTarget(warmupClickEnergy).restart(); + } + // Fix node position at current location + d.fx = d.x; + d.fy = d.y; + }) + .on("drag", (event: D3DragEvent, d: NetworkNode) => { + // Update fixed position to mouse position + d.fx = event.x; + d.fy = event.y; + }) + .on("end", (event: D3DragEvent, d: NetworkNode) => { + // Cool down simulation when drag ends + if (!event.active) { + simulation.alphaTarget(0); + } + // Release fixed position + d.fx = null; + d.fy = null; + }); } /** * Creates a D3 force simulation for the network + * + * @param nodes - Array of network nodes + * @param links - Array of network links + * @param nodeRadius - Radius of node circles + * @param linkDistance - Desired distance between linked nodes + * @returns Configured D3 force simulation */ export function createSimulation( nodes: NetworkNode[], links: NetworkLink[], nodeRadius: number, linkDistance: number -) { - return d3 - .forceSimulation(nodes) - .force( - "link", - d3 - .forceLink(links) - .id((d) => d.id) - .distance(linkDistance * 0.1), - ) - .force("collide", d3.forceCollide().radius(nodeRadius * 4)); -} \ No newline at end of file +): Simulation { + debug("Creating simulation", { + nodeCount: nodes.length, + linkCount: links.length, + nodeRadius, + linkDistance + }); + + try { + // Create the simulation with nodes + const simulation = d3 + .forceSimulation(nodes) + .force( + "link", + d3.forceLink(links) + .id((d: NetworkNode) => d.id) + .distance(linkDistance * 0.1) + ) + .force("collide", d3.forceCollide().radius(nodeRadius * 4)); + + debug("Simulation created successfully"); + return simulation; + } catch (error) { + console.error("Error creating simulation:", error); + throw error; + } +} diff --git a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts index d9daa58..4f27c1f 100644 --- a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts +++ b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts @@ -1,17 +1,49 @@ +/** + * Network Builder Utilities + * + * This module provides utilities for building a network graph from Nostr events. + * It handles the creation of nodes and links, and the processing of event relationships. + */ + import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import { nip19 } from "nostr-tools"; import { standardRelays } from "$lib/consts"; +// Configuration +const DEBUG = false; // Set to true to enable debug logging +const INDEX_EVENT_KIND = 30040; +const CONTENT_EVENT_KIND = 30041; + +/** + * Debug logging function that only logs when DEBUG is true + */ +function debug(...args: any[]) { + if (DEBUG) { + console.log("[NetworkBuilder]", ...args); + } +} + /** * Creates a NetworkNode from an NDKEvent + * + * Extracts relevant information from the event and creates a node representation + * for the visualization. + * + * @param event - The Nostr event to convert to a node + * @param level - The hierarchy level of the node (default: 0) + * @returns A NetworkNode object representing the event */ export function createNetworkNode( event: NDKEvent, level: number = 0 ): NetworkNode { - const isContainer = event.kind === 30040; + debug("Creating network node", { eventId: event.id, kind: event.kind, level }); + + const isContainer = event.kind === INDEX_EVENT_KIND; + const nodeType = isContainer ? "Index" : "Content"; + // Create the base node with essential properties const node: NetworkNode = { id: event.id, event, @@ -20,13 +52,16 @@ export function createNetworkNode( title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled", content: event.content || "", author: event.pubkey || "", - kind: event.kind, - type: event?.kind === 30040 ? "Index" : "Content", + kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined + type: nodeType, }; + // Add NIP-19 identifiers if possible if (event.kind && event.pubkey) { try { const dTag = event.getMatchingTags("d")?.[0]?.[1] || ""; + + // Create naddr (NIP-19 address) for the event node.naddr = nip19.naddrEncode({ pubkey: event.pubkey, identifier: dTag, @@ -34,6 +69,7 @@ export function createNetworkNode( relays: standardRelays, }); + // Create nevent (NIP-19 event reference) for the event node.nevent = nip19.neventEncode({ id: event.id, relays: standardRelays, @@ -47,50 +83,93 @@ export function createNetworkNode( return node; } +/** + * Creates a map of event IDs to events for quick lookup + * + * @param events - Array of Nostr events + * @returns Map of event IDs to events + */ export function createEventMap(events: NDKEvent[]): Map { + debug("Creating event map", { eventCount: events.length }); + const eventMap = new Map(); events.forEach((event) => { if (event.id) { eventMap.set(event.id, event); } }); + + debug("Event map created", { mapSize: eventMap.size }); return eventMap; } +/** + * Extracts an event ID from an 'a' tag + * + * @param tag - The tag array from a Nostr event + * @returns The event ID or null if not found + */ export function extractEventIdFromATag(tag: string[]): string | null { return tag[3] || null; } /** - * Generates a color for an event based on its ID + * Generates a deterministic color for an event based on its ID + * + * This creates visually distinct colors for different index events + * while ensuring the same event always gets the same color. + * + * @param eventId - The event ID to generate a color for + * @returns An HSL color string */ export function getEventColor(eventId: string): string { + // Use first 4 characters of event ID as a hex number const num = parseInt(eventId.slice(0, 4), 16); + // Convert to a hue value (0-359) const hue = num % 360; + // Use fixed saturation and lightness for pastel colors const saturation = 70; const lightness = 75; return `hsl(${hue}, ${saturation}%, ${lightness}%)`; } +/** + * Initializes the graph state from a set of events + * + * Creates nodes for all events and identifies referenced events. + * + * @param events - Array of Nostr events + * @returns Initial graph state + */ export function initializeGraphState(events: NDKEvent[]): GraphState { + debug("Initializing graph state", { eventCount: events.length }); + const nodeMap = new Map(); const eventMap = createEventMap(events); - // Create initial nodes + // Create initial nodes for all events events.forEach((event) => { if (!event.id) return; const node = createNetworkNode(event); nodeMap.set(event.id, node); }); + debug("Node map created", { nodeCount: nodeMap.size }); - // Build referenced IDs set + // Build set of referenced event IDs to identify root events const referencedIds = new Set(); events.forEach((event) => { - event.getMatchingTags("a").forEach((tag) => { + const aTags = event.getMatchingTags("a"); + debug("Processing a-tags for event", { + eventId: event.id, + aTagCount: aTags.length + }); + + aTags.forEach((tag) => { const id = extractEventIdFromATag(tag); if (id) referencedIds.add(id); }); }); + debug("Referenced IDs set created", { referencedCount: referencedIds.size }); return { nodeMap, @@ -100,6 +179,18 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { }; } +/** + * Processes a sequence of nodes referenced by an index event + * + * Creates links between the index and its content, and between sequential content nodes. + * Also processes nested indices recursively up to the maximum level. + * + * @param sequence - Array of nodes in the sequence + * @param indexEvent - The index event referencing the sequence + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processSequence( sequence: NetworkNode[], indexEvent: NDKEvent, @@ -107,14 +198,15 @@ export function processSequence( state: GraphState, maxLevel: number, ): void { + // Stop if we've reached max level or have no nodes if (level >= maxLevel || sequence.length === 0) return; - // Set levels for sequence nodes + // Set levels for all nodes in the sequence sequence.forEach((node) => { node.level = level + 1; }); - // Create initial link from index to first content + // Create link from index to first content node const indexNode = state.nodeMap.get(indexEvent.id); if (indexNode && sequence[0]) { state.links.push({ @@ -124,7 +216,7 @@ export function processSequence( }); } - // Create sequential links + // Create sequential links between content nodes for (let i = 0; i < sequence.length - 1; i++) { const currentNode = sequence[i]; const nextNode = sequence[i + 1]; @@ -135,16 +227,27 @@ export function processSequence( isSequential: true, }); - processNestedIndex(currentNode, level + 1, state, maxLevel); + // Process nested indices recursively + if (currentNode.isContainer) { + processNestedIndex(currentNode, level + 1, state, maxLevel); + } } - // Process final node if it's an index + // Process the last node if it's an index const lastNode = sequence[sequence.length - 1]; if (lastNode?.isContainer) { processNestedIndex(lastNode, level + 1, state, maxLevel); } } +/** + * Processes a nested index node + * + * @param node - The index node to process + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processNestedIndex( node: NetworkNode, level: number, @@ -159,6 +262,14 @@ export function processNestedIndex( } } +/** + * Processes an index event and its referenced content + * + * @param indexEvent - The index event to process + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processIndexEvent( indexEvent: NDKEvent, level: number, @@ -167,6 +278,7 @@ export function processIndexEvent( ): void { if (level >= maxLevel) return; + // Extract the sequence of nodes referenced by this index const sequence = indexEvent .getMatchingTags("a") .map((tag) => extractEventIdFromATag(tag)) @@ -177,19 +289,53 @@ export function processIndexEvent( processSequence(sequence, indexEvent, level, state, maxLevel); } +/** + * Generates a complete graph from a set of events + * + * This is the main entry point for building the network visualization. + * + * @param events - Array of Nostr events + * @param maxLevel - Maximum hierarchy level to process + * @returns Complete graph data for visualization + */ export function generateGraph( events: NDKEvent[], maxLevel: number ): GraphData { + debug("Generating graph", { eventCount: events.length, maxLevel }); + + // Initialize the graph state const state = initializeGraphState(events); - // Process root indices - events - .filter((e) => e.kind === 30040 && e.id && !state.referencedIds.has(e.id)) - .forEach((rootIndex) => processIndexEvent(rootIndex, 0, state, maxLevel)); + // Find root index events (those not referenced by other events) + const rootIndices = events.filter( + (e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id) + ); + + debug("Found root indices", { + rootCount: rootIndices.length, + rootIds: rootIndices.map(e => e.id) + }); + + // Process each root index + rootIndices.forEach((rootIndex) => { + debug("Processing root index", { + rootId: rootIndex.id, + aTags: rootIndex.getMatchingTags("a").length + }); + processIndexEvent(rootIndex, 0, state, maxLevel); + }); - return { + // Create the final graph data + const result = { nodes: Array.from(state.nodeMap.values()), links: state.links, }; -} \ No newline at end of file + + debug("Graph generation complete", { + nodeCount: result.nodes.length, + linkCount: result.links.length + }); + + return result; +} diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index f1f7d33..39eaa06 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -1,3 +1,9 @@ +
-
+ +

Publication Network

- + {#if !loading && !error}
+ {#if !loading && !error && showSettings} -
{/if} + {#if loading}
@@ -140,12 +188,14 @@ Loading...
+ {:else if error} + {:else} - -
+
+ + +
{/if}
diff --git a/src/styles/visualize.css b/src/styles/visualize.css index 59360c9..456a4c1 100644 --- a/src/styles/visualize.css +++ b/src/styles/visualize.css @@ -1,4 +1,5 @@ @layer components { + /* Legend styles - specific to visualization */ .legend-list { @apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300; } @@ -21,6 +22,84 @@ } .legend-letter { - @apply absolute inset-0 flex items-center justify-center text-black text-xs; + @apply absolute inset-0 flex items-center justify-center text-black text-xs font-bold; + } + + .legend-text { + @apply text-sm; + } + + /* Network visualization styles - specific to visualization */ + .network-container { + @apply flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4; + } + + .network-svg-container { + @apply h-[calc(100%-130px)] min-h-[300px]; + } + + .network-svg { + @apply w-full h-full border border-gray-300 dark:border-gray-700 rounded; + } + + .network-error { + @apply w-full p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded-lg mb-4; + } + + .network-error-title { + @apply font-bold text-lg; + } + + .network-error-retry { + @apply mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700; + } + + .network-debug { + @apply mt-4 text-sm text-gray-500; + } + + /* Zoom controls */ + .network-controls { + @apply absolute bottom-4 right-4 flex flex-col gap-2 z-10; + } + + .network-control-button { + @apply bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 + shadow-md hover:shadow-lg transition-shadow duration-200 + border border-gray-300 dark:border-gray-700; + } + + /* Tooltip styles - specific to visualization tooltips */ + .tooltip-close-btn { + @apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 + rounded-full p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200; + } + + .tooltip-content { + @apply space-y-2 pr-6; + } + + .tooltip-title { + @apply font-bold text-base; + } + + .tooltip-title-link { + @apply text-gray-800 hover:text-blue-600 dark:text-gray-200 dark:hover:text-blue-400; + } + + .tooltip-metadata { + @apply text-gray-600 dark:text-gray-400 text-sm; + } + + .tooltip-summary { + @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; + } + + .tooltip-content-preview { + @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; + } + + .tooltip-help-text { + @apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic; } } diff --git a/src/types/d3.d.ts b/src/types/d3.d.ts new file mode 100644 index 0000000..3d230f5 --- /dev/null +++ b/src/types/d3.d.ts @@ -0,0 +1,19 @@ +/** + * Type declarations for D3.js and related modules + * + * These declarations allow TypeScript to recognize D3 imports without requiring + * detailed type definitions. For a project requiring more type safety, consider + * using the @types/d3 package and its related sub-packages. + */ + +// Core D3 library +declare module 'd3'; + +// Force simulation module for graph layouts +declare module 'd3-force'; + +// DOM selection and manipulation module +declare module 'd3-selection'; + +// Drag behavior module +declare module 'd3-drag';