Browse Source

Fixes #217

Adds zoom and centering buttons. Implements D3 properly and updates everything to Svelte 5 and CSS styles.
master
Silberengel 11 months ago
parent
commit
1a17892942
  1. 292
      package-lock.json
  2. 1
      package.json
  3. 16
      src/app.css
  4. 47
      src/lib/navigator/EventNetwork/Legend.svelte
  5. 114
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  6. 377
      src/lib/navigator/EventNetwork/index.svelte
  7. 92
      src/lib/navigator/EventNetwork/types.ts
  8. 193
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  9. 178
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  10. 91
      src/routes/visualize/+page.svelte
  11. 81
      src/styles/visualize.css
  12. 19
      src/types/d3.d.ts

292
package-lock.json generated

@ -25,6 +25,7 @@ @@ -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 @@ @@ -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 @@ @@ -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",

1
package.json

@ -31,6 +31,7 @@ @@ -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",

16
src/app.css

@ -190,6 +190,8 @@ @@ -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 @@ @@ -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;
}
}

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

@ -1,30 +1,61 @@ @@ -1,30 +1,61 @@
<!--
Legend Component
Displays a legend explaining the different node and link types in the
event network visualization.
-->
<script lang="ts">
// Optional class name to apply to the legend container
export let className: string = "";
</script>
<div class="leather-legend {className}">
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3>
<h3 class="h-leather">Legend</h3>
<ul class="legend-list">
<!-- Index event node -->
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle" style="background-color: hsl(200, 70%, 75%)">
</span>
<span
class="legend-circle"
style="background-color: hsl(200, 70%, 75%)"
>
<span class="legend-letter">I</span>
</span>
</div>
<span>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 -->
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle content"></span>
<span class="legend-circle content">
<span class="legend-letter">C</span>
</span>
</div>
<span>Content events (kinds 30041, 30818) - Publication sections</span>
<span class="legend-text">Content events (kinds 30041, 30818) - Publication sections</span>
</li>
<!-- Link arrow -->
<li class="legend-item">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path d="M4 12h16M16 6l6 6-6 6" class="network-link-leather" />
<path
d="M4 12h16M16 6l6 6-6 6"
class="network-link-leather"
stroke-width="2"
fill="none"
/>
</svg>
<span>Arrows indicate reading/sequence order</span>
<span class="legend-text">Arrows indicate reading/sequence order</span>
</li>
</ul>
</div>
<style>
.legend-circle.content {
background-color: var(--content-color, #d6c1a8);
}
:global(.dark) .legend-circle.content {
background-color: var(--content-color-dark, #FFFFFF);
}
</style>

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

@ -1,20 +1,33 @@ @@ -1,20 +1,33 @@
<!--
NodeTooltip Component
Displays detailed information about a node when hovering or clicking on it
in the event network visualization.
-->
<script lang="ts">
import type { NetworkNode } from "./types";
import { onMount, createEventDispatcher } from "svelte";
let { node, selected = false, x, y } = $props<{
node: NetworkNode;
selected?: boolean;
x: number;
y: number;
import { onMount } from "svelte";
// Component props
let { node, selected = false, x, y, onclose } = $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
}>();
const dispatch = createEventDispatcher();
// DOM reference and positioning
let tooltipElement: HTMLDivElement;
let tooltipX = $state(x + 10);
let tooltipX = $state(x + 10); // Add offset to avoid cursor overlap
let tooltipY = $state(y - 10);
// Maximum content length to display
const MAX_CONTENT_LENGTH = 200;
/**
* Gets the author name from the event tags
*/
function getAuthorTag(node: NetworkNode): string {
if (node.event) {
const authorTags = node.event.getMatchingTags("author");
@ -25,6 +38,9 @@ @@ -25,6 +38,9 @@
return "Unknown";
}
/**
* Gets the summary from the event tags
*/
function getSummaryTag(node: NetworkNode): string | null {
if (node.event) {
const summaryTags = node.event.getMatchingTags("summary");
@ -35,6 +51,9 @@ @@ -35,6 +51,9 @@
return null;
}
/**
* Gets the d-tag from the event
*/
function getDTag(node: NetworkNode): string {
if (node.event) {
const dTags = node.event.getMatchingTags("d");
@ -45,40 +64,47 @@ @@ -45,40 +64,47 @@
return "View Publication";
}
function truncateContent(content: string, maxLength: number = 200): string {
/**
* Truncates content to a maximum length
*/
function truncateContent(content: string, maxLength: number = MAX_CONTENT_LENGTH): string {
if (!content) return "";
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "...";
}
/**
* Closes the tooltip
*/
function closeTooltip() {
dispatch('close');
onclose();
}
// Ensure tooltip is fully visible on screen
/**
* Ensures tooltip is fully visible on screen
*/
onMount(() => {
if (tooltipElement) {
const rect = tooltipElement.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const padding = 10; // Padding from window edges
// Check if tooltip goes off the right edge
// Adjust position if tooltip goes off screen
if (rect.right > windowWidth) {
tooltipX = windowWidth - rect.width - 10;
tooltipX = windowWidth - rect.width - padding;
}
// Check if tooltip goes off the bottom edge
if (rect.bottom > windowHeight) {
tooltipY = windowHeight - rect.height - 10;
tooltipY = windowHeight - rect.height - padding;
}
// Check if tooltip goes off the left edge
if (rect.left < 0) {
tooltipX = 10;
tooltipX = padding;
}
// Check if tooltip goes off the top edge
if (rect.top < 0) {
tooltipY = 10;
tooltipY = padding;
}
}
});
@ -86,12 +112,12 @@ @@ -86,12 +112,12 @@
<div
bind:this={tooltipElement}
class="tooltip-leather fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-800
border border-gray-200 dark:border-gray-800 transition-colors duration-200"
style="left: {tooltipX}px; top: {tooltipY}px; z-index: 1000; max-width: 400px;"
class="tooltip-leather"
style="left: {tooltipX}px; top: {tooltipY}px;"
>
<!-- Close button -->
<button
class="absolute top-2 left-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"
class="tooltip-close-btn"
onclick={closeTooltip}
aria-label="Close"
>
@ -99,34 +125,46 @@ @@ -99,34 +125,46 @@
<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>
<div class="space-y-2 pl-6">
<div class="font-bold text-base">
<a href="/publication?id={node.id}" class="text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500">
{node.title}
<!-- Tooltip content -->
<div class="tooltip-content">
<!-- Title with link -->
<div class="tooltip-title">
<a
href="/publication?id={node.id}"
class="tooltip-title-link"
>
{node.title || "Untitled"}
</a>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
{node.type} ({node.kind})
<!-- Node type and kind -->
<div class="tooltip-metadata">
{node.type} (kind: {node.kind})
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
<!-- Author -->
<div class="tooltip-metadata">
Author: {getAuthorTag(node)}
</div>
<!-- Summary (for index nodes) -->
{#if node.isContainer && getSummaryTag(node)}
<div class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "", 200)}
<div class="tooltip-summary">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "")}
</div>
{/if}
<!-- Content preview -->
{#if node.content}
<div
class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40"
>
<div class="tooltip-content-preview">
{truncateContent(node.content)}
</div>
{/if}
<!-- Help text for selected nodes -->
{#if selected}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<div class="tooltip-help-text">
Click node again to dismiss
</div>
{/if}

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

@ -1,40 +1,82 @@ @@ -1,40 +1,82 @@
<!-- EventNetwork.svelte -->
<!--
EventNetwork Component
A force-directed graph visualization of Nostr events, showing the relationships
between index events and their content. This component handles the D3 force
simulation, SVG rendering, and user interactions.
-->
<script lang="ts">
import { onMount } from "svelte";
import * as d3 from "d3";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { levelsToRender } from "$lib/state";
import { generateGraph, getEventColor } from "./utils/networkBuilder";
import { createSimulation, setupDragHandlers, applyGlobalLogGravity, applyConnectedGravity } from "./utils/forceSimulation";
import {
createSimulation,
setupDragHandlers,
applyGlobalLogGravity,
applyConnectedGravity,
type Simulation
} from "./utils/forceSimulation";
import Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte";
import type { NetworkNode, NetworkLink } from "./types";
// Type alias for D3 selections
type Selection = any;
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const NODE_RADIUS = 20;
const LINK_DISTANCE = 10;
const ARROW_DISTANCE = 10;
const CONTENT_COLOR_LIGHT = "#d6c1a8";
const CONTENT_COLOR_DARK = "#FFFFFF";
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[EventNetwork]", ...args);
}
}
// Component props
let { events = [] } = $props<{ events?: NDKEvent[] }>();
// Error state
let errorMessage = $state<string | null>(null);
let hasError = $derived(!!errorMessage);
// DOM references
let svg: SVGSVGElement;
let isDarkMode = $state(false);
let container: HTMLDivElement;
// Use a string ID for comparisons instead of the node object
// Theme state
let isDarkMode = $state(false);
// Tooltip state
let selectedNodeId = $state<string | null>(null);
let tooltipVisible = $state(false);
let tooltipX = $state(0);
let tooltipY = $state(0);
let tooltipNode = $state<NetworkNode | null>(null);
const nodeRadius = 20;
const linkDistance = 10;
const arrowDistance = 10;
// Dimensions
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 simulation: d3.Simulation<NetworkNode, NetworkLink> | null = null;
let svgGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
// D3 objects
let simulation: Simulation<NetworkNode, NetworkLink> | null = null;
let svgGroup: Selection;
let zoomBehavior: any;
let svgElement: Selection;
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400);
// Track current render level
let currentLevels = $derived(levelsToRender);
// Update dimensions when container changes
$effect(() => {
@ -44,32 +86,40 @@ @@ -44,32 +86,40 @@
}
});
// Track levelsToRender changes
let currentLevels = $derived(levelsToRender);
/**
* Initializes the SVG graph structure
* Sets up the SVG element, zoom behavior, and arrow marker
*/
function initializeGraph() {
if (!svg) return;
debug("Initializing graph");
if (!svg) {
debug("SVG element not found");
return;
}
debug("SVG dimensions", { width, height });
const svgElement = d3.select(svg)
.attr("viewBox", `0 0 ${width} ${height}`);
// Clear existing content
svgElement.selectAll("*").remove();
debug("Cleared SVG content");
// Create main group for zoom
svgGroup = svgElement.append("g");
debug("Created SVG group");
// Set up zoom behavior
const zoom = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 9])
.on("zoom", (event) => {
zoomBehavior = d3
.zoom()
.scaleExtent([0.1, 9]) // Min/max zoom levels
.on("zoom", (event: any) => {
svgGroup.attr("transform", event.transform);
});
svgElement.call(zoom);
svgElement.call(zoomBehavior);
// Set up arrow marker
// Set up arrow marker for links
const defs = svgElement.append("defs");
defs
.append("marker")
@ -88,58 +138,116 @@ @@ -88,58 +138,116 @@
.attr("stroke-width", 1);
}
/**
* Updates the graph with new data
* Generates the graph from events, creates the simulation, and renders nodes and links
*/
function updateGraph() {
if (!svg || !events?.length || !svgGroup) return;
debug("Updating graph");
errorMessage = null;
// Create variables to hold our selections
let link: any;
let node: any;
let dragHandler: any;
let nodes: NetworkNode[] = [];
let links: NetworkLink[] = [];
try {
// Validate required elements
if (!svg) {
throw new Error("SVG element not found");
}
const { nodes, links } = generateGraph(events, Number(currentLevels));
if (!nodes.length) return;
if (!events?.length) {
throw new Error("No events to render");
}
if (!svgGroup) {
throw new Error("SVG group not found");
}
// Generate graph data from events
debug("Generating graph with events", {
eventCount: events.length,
currentLevels
});
const graphData = generateGraph(events, Number(currentLevels));
nodes = graphData.nodes;
links = graphData.links;
debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length
});
if (!nodes.length) {
throw new Error("No nodes to render");
}
// Stop any existing simulation
if (simulation) simulation.stop();
if (simulation) {
debug("Stopping existing simulation");
simulation.stop();
}
// Create new simulation
simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance));
const dragHandler = setupDragHandlers(simulation);
debug("Creating new simulation");
simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE);
// Center the nodes when the simulation is done
simulation.on("end", () => {
centerGraph();
});
// Create drag handler
dragHandler = setupDragHandlers(simulation);
// Update links
const link = svgGroup
.selectAll<SVGPathElement, NetworkLink>("path.link")
.data(links, d => `${d.source.id}-${d.target.id}`)
debug("Updating links");
link = svgGroup
.selectAll("path.link")
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`)
.join(
enter => enter
(enter: any) => enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
update => update,
exit => exit.remove()
(update: any) => update,
(exit: any) => exit.remove()
);
// Update nodes
const node = svgGroup
.selectAll<SVGGElement, NetworkNode>("g.node")
.data(nodes, d => d.id)
debug("Updating nodes");
node = svgGroup
.selectAll("g.node")
.data(nodes, (d: NetworkNode) => d.id)
.join(
enter => {
(enter: any) => {
const nodeEnter = enter
.append("g")
.attr("class", "node network-node-leather")
.call(dragHandler);
// Larger transparent circle for better drag handling
nodeEnter
.append("circle")
.attr("class", "drag-circle")
.attr("r", nodeRadius * 2.5)
.attr("r", NODE_RADIUS * 2.5)
.attr("fill", "transparent")
.attr("stroke", "transparent")
.style("cursor", "move");
// Visible circle
nodeEnter
.append("circle")
.attr("class", "visual-circle")
.attr("r", nodeRadius)
.attr("r", NODE_RADIUS)
.attr("stroke-width", 2);
// Node label
nodeEnter
.append("text")
.attr("dy", "0.35em")
@ -149,27 +257,29 @@ @@ -149,27 +257,29 @@
return nodeEnter;
},
update => update,
exit => exit.remove()
(update: any) => update,
(exit: any) => exit.remove()
);
// Update node appearances
debug("Updating node appearances");
node.select("circle.visual-circle")
.attr("class", d => !d.isContainer
.attr("class", (d: NetworkNode) => !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather"
)
.attr("fill", d => !d.isContainer
? isDarkMode ? "#FFFFFF" : "network-link-leather"
.attr("fill", (d: NetworkNode) => !d.isContainer
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
);
node.select("text")
.text(d => d.isContainer ? "I" : "C");
.text((d: NetworkNode) => d.isContainer ? "I" : "C");
// Update node interactions
// Set up node interactions
debug("Setting up node interactions");
node
.on("mouseover", (event, d) => {
.on("mouseover", (event: any, d: NetworkNode) => {
if (!selectedNodeId) {
tooltipVisible = true;
tooltipNode = d;
@ -177,7 +287,7 @@ @@ -177,7 +287,7 @@
tooltipY = event.pageY;
}
})
.on("mousemove", (event, d) => {
.on("mousemove", (event: any) => {
if (!selectedNodeId) {
tooltipX = event.pageX;
tooltipY = event.pageY;
@ -189,15 +299,14 @@ @@ -189,15 +299,14 @@
tooltipNode = null;
}
})
.on("click", (event, d) => {
.on("click", (event: any, d: NetworkNode) => {
event.stopPropagation();
if (selectedNodeId === d.id) {
// Clicking the selected node again deselects it
selectedNodeId = null;
tooltipVisible = false;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
} else {
// Select the node and show its tooltip
selectedNodeId = d.id;
tooltipVisible = true;
tooltipNode = d;
@ -206,21 +315,28 @@ @@ -206,21 +315,28 @@
}
});
// Handle simulation ticks
// Set up simulation tick handler
debug("Setting up simulation tick handler");
if (simulation) {
simulation.on("tick", () => {
// Apply custom forces to each node
nodes.forEach(node => {
// Pull nodes toward the center
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha());
// Pull connected nodes toward each other
applyConnectedGravity(node, links, simulation!.alpha());
});
// Update positions
link.attr("d", d => {
// Update link positions
link.attr("d", (d: NetworkLink) => {
// Calculate angle between source and target
const dx = d.target.x! - d.source.x!;
const dy = d.target.y! - d.source.y!;
const angle = Math.atan2(dy, dx);
const sourceGap = nodeRadius;
const targetGap = nodeRadius + arrowDistance;
// Calculate start and end points with offsets for node radius
const sourceGap = NODE_RADIUS;
const targetGap = NODE_RADIUS + ARROW_DISTANCE;
const startX = d.source.x! + sourceGap * Math.cos(angle);
const startY = d.source.y! + sourceGap * Math.sin(angle);
@ -230,24 +346,40 @@ @@ -230,24 +346,40 @@
return `M${startX},${startY}L${endX},${endY}`;
});
node.attr("transform", d => `translate(${d.x},${d.y})`);
// Update node positions
node.attr("transform", (d: NetworkNode) => `translate(${d.x},${d.y})`);
});
}
} catch (error) {
console.error("Error in updateGraph:", error);
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`;
}
}
/**
* Component lifecycle setup
*/
onMount(() => {
debug("Component mounted");
try {
// Detect initial theme
isDarkMode = document.body.classList.contains("dark");
// Initialize the graph structure
initializeGraph();
} catch (error) {
console.error("Error in onMount:", error);
errorMessage = `Error initializing graph: ${error instanceof Error ? error.message : String(error)}`;
}
// Handle window resizing
// Set up window resize handler
const handleResize = () => {
windowHeight = window.innerHeight;
};
windowHeight = window.innerHeight;
window.addEventListener("resize", handleResize);
// Watch for theme changes
// Set up theme change observer
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
@ -256,10 +388,10 @@ @@ -256,10 +388,10 @@
isDarkMode = newIsDarkMode;
// Update node colors when theme changes
if (svgGroup) {
svgGroup.selectAll<SVGGElement, NetworkNode>("g.node")
svgGroup.selectAll("g.node")
.select("circle.visual-circle")
.attr("fill", d => !d.isContainer
? newIsDarkMode ? "#FFFFFF" : "network-link-leather"
.attr("fill", (d: NetworkNode) => !d.isContainer
? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
);
}
@ -268,6 +400,7 @@ @@ -268,6 +400,7 @@
});
});
// Set up container resize observer
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
@ -275,19 +408,21 @@ @@ -275,19 +408,21 @@
}
if (svg) {
d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
// Trigger simulation to adjust to new dimensions
// Restart simulation with new dimensions
if (simulation) {
simulation.alpha(0.3).restart();
}
}
});
// Start observers
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
resizeObserver.observe(container);
// Clean up on component destruction
return () => {
themeObserver.disconnect();
resizeObserver.disconnect();
@ -296,29 +431,131 @@ @@ -296,29 +431,131 @@
};
});
// Watch for changes that should trigger a graph update
/**
* Watch for changes that should trigger a graph update
*/
$effect(() => {
debug("Effect triggered", {
hasSvg: !!svg,
eventCount: events?.length,
currentLevels
});
try {
if (svg && events?.length) {
// Include currentLevels in the effect dependencies
const _ = currentLevels;
updateGraph();
}
} catch (error) {
console.error("Error in effect:", error);
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`;
}
});
/**
* Handles tooltip close event
*/
function handleTooltipClose() {
tooltipVisible = false;
selectedNodeId = null;
}
/**
* Centers the graph in the viewport
*/
function centerGraph() {
if (svg && svgGroup && zoomBehavior) {
const svgWidth = svg.clientWidth || width;
const svgHeight = svg.clientHeight || height;
// Reset zoom and center
d3.select(svg).transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8)
);
}
}
/**
* Zooms in the graph
*/
function zoomIn() {
if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call(
zoomBehavior.scaleBy, 1.3
);
}
}
/**
* Zooms out the graph
*/
function zoomOut() {
if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call(
zoomBehavior.scaleBy, 0.7
);
}
}
</script>
<div
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4"
<div class="network-container">
{#if hasError}
<div class="network-error">
<h3 class="network-error-title">Error</h3>
<p>{errorMessage}</p>
<button
class="network-error-retry"
onclick={() => { errorMessage = null; updateGraph(); }}
>
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}>
Retry
</button>
</div>
{/if}
<div class="network-svg-container" bind:this={container}>
<svg
bind:this={svg}
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded"
class="network-svg"
/>
<!-- Zoom controls -->
<div class="network-controls">
<button
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">
<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
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">
<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
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">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
</div>
{#if tooltipVisible && tooltipNode}
@ -327,7 +564,7 @@ @@ -327,7 +564,7 @@
selected={tooltipNode.id === selectedNodeId}
x={tooltipX}
y={tooltipY}
on:close={handleTooltipClose}
onclose={handleTooltipClose}
/>
{/if}

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

@ -1,35 +1,79 @@ @@ -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<NodeType> {
source: NodeType | string | number; // Source node or identifier
target: NodeType | string | number; // Target node or identifier
index?: number; // Link index in the simulation
}
/**
* 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)
}
export interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> {
source: NetworkNode;
target: NetworkNode;
isSequential: boolean;
/**
* Represents a link between nodes in the event network
* Extends the base simulation link with event-specific properties
*/
export interface NetworkLink extends SimulationLinkDatum<NetworkNode> {
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<string, NetworkNode>;
links: NetworkLink[];
eventMap: Map<string, NDKEvent>;
referencedIds: Set<string>;
nodeMap: Map<string, NetworkNode>; // Maps event IDs to nodes
links: NetworkLink[]; // All links in the graph
eventMap: Map<string, NDKEvent>; // Maps event IDs to original events
referencedIds: Set<string>; // Set of event IDs referenced by other events
}

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

@ -1,27 +1,100 @@ @@ -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
/**
* Updates a node's velocity
* 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<NodeType, LinkType> {
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;
}
/**
* Type definition for D3 drag events
* Provides type safety for drag operations
*/
export interface D3DragEvent<GElement extends Element, Datum, Subject> {
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( @@ -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<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9
) {
return d3
.drag<SVGGElement, NetworkNode>()
.on(
"start",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
if (!event.active)
.drag()
.on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, 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: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
})
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Update fixed position to mouse position
d.fx = event.x;
d.fy = event.y;
},
)
.on(
"end",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
if (!event.active) simulation.alphaTarget(0);
})
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, 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<NetworkNode>(nodes)
): Simulation<NetworkNode, NetworkLink> {
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<NetworkNode, NetworkLink>(links)
.id((d) => d.id)
.distance(linkDistance * 0.1),
d3.forceLink(links)
.id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1)
)
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4));
.force("collide", d3.forceCollide().radius(nodeRadius * 4));
debug("Simulation created successfully");
return simulation;
} catch (error) {
console.error("Error creating simulation:", error);
throw error;
}
}

178
src/lib/navigator/EventNetwork/utils/networkBuilder.ts

@ -1,17 +1,49 @@ @@ -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( @@ -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( @@ -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( @@ -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<string, NDKEvent> {
debug("Creating event map", { eventCount: events.length });
const eventMap = new Map<string, NDKEvent>();
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<string, NetworkNode>();
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<string>();
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 { @@ -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( @@ -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( @@ -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( @@ -135,16 +227,27 @@ export function processSequence(
isSequential: true,
});
// 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( @@ -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( @@ -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( @@ -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)
);
return {
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);
});
// Create the final graph data
const result = {
nodes: Array.from(state.nodeMap.values()),
links: state.links,
};
debug("Graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length
});
return result;
}

91
src/routes/visualize/+page.svelte

@ -1,3 +1,9 @@ @@ -1,3 +1,9 @@
<!--
Visualization Page
This page displays a network visualization of Nostr publications,
showing the relationships between index events and their content.
-->
<script lang="ts">
import { onMount } from "svelte";
import EventNetwork from "$lib/navigator/EventNetwork/index.svelte";
@ -6,50 +12,83 @@ @@ -6,50 +12,83 @@
import { filterValidIndexEvents } from "$lib/utils";
import EventLimitControl from "$lib/components/EventLimitControl.svelte";
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte";
import { networkFetchLimit } from "$lib/state";
import { fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import { CogSolid } from "flowbite-svelte-icons";
import { Button, Tooltip } from "flowbite-svelte";
import { Button } from "flowbite-svelte";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KINDS = [30041, 30818];
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[VisualizePage]", ...args);
}
}
// State
let events: NDKEvent[] = [];
let loading = true;
let error: string | null = null;
// panel visibility
let showSettings = false;
/**
* Fetches events from the Nostr network
*
* This function fetches index events and their referenced content events,
* filters them according to NIP-62, and combines them for visualization.
*/
async function fetchEvents() {
debug("Fetching events with limit:", $networkFetchLimit);
try {
loading = true;
error = null;
// Fetch both index and content events
// Step 1: Fetch index events
debug(`Fetching index events (kind ${INDEX_EVENT_KIND})`);
const indexEvents = await $ndkInstance.fetchEvents(
{ kinds: [30040], limit: $networkFetchLimit },
{
kinds: [INDEX_EVENT_KIND],
limit: $networkFetchLimit
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
);
debug("Fetched index events:", indexEvents.size);
// Filter valid index events according to NIP-62
// Step 2: Filter valid index events according to NIP-62
const validIndexEvents = filterValidIndexEvents(indexEvents);
debug("Valid index events after filtering:", validIndexEvents.size);
// Get all the content event IDs referenced by the index events
// Step 3: Extract content event IDs from index events
const contentEventIds = new Set<string>();
validIndexEvents.forEach((event) => {
event.getMatchingTags("a").forEach((tag) => {
let eventId = tag[3];
const aTags = event.getMatchingTags("a");
debug(`Event ${event.id} has ${aTags.length} a-tags`);
aTags.forEach((tag) => {
const eventId = tag[3];
if (eventId) {
contentEventIds.add(eventId);
}
});
});
debug("Content event IDs to fetch:", contentEventIds.size);
// Fetch the referenced content events
// Step 4: Fetch the referenced content events
debug(`Fetching content events (kinds ${CONTENT_EVENT_KINDS.join(', ')})`);
const contentEvents = await $ndkInstance.fetchEvents(
{
kinds: [30041, 30818],
kinds: CONTENT_EVENT_KINDS,
ids: Array.from(contentEventIds),
},
{
@ -58,9 +97,11 @@ @@ -58,9 +97,11 @@
skipValidation: false,
},
);
debug("Fetched content events:", contentEvents.size);
// Combine both sets of events
// Step 5: Combine both sets of events
events = [...Array.from(validIndexEvents), ...Array.from(contentEvents)];
debug("Total events for visualization:", events.length);
} catch (e) {
console.error("Error fetching events:", e);
error = e instanceof Error ? e.message : String(e);
@ -69,23 +110,29 @@ @@ -69,23 +110,29 @@
}
}
/**
* Handles updates to visualization settings
*/
function handleLimitUpdate() {
fetchEvents();
}
// Fetch events when component mounts
onMount(() => {
debug("Component mounted");
fetchEvents();
});
</script>
<div class="leather w-full p-4 relative">
<div class="flex items-center gap-4 mb-4">
<!-- Header with title and settings button -->
<div class="flex items-center mb-4">
<h1 class="h-leather text-2xl font-bold">Publication Network</h1>
<!-- Settings Button - Using Flowbite Components -->
<!-- Settings Button -->
{#if !loading && !error}
<Button
class="btn-leather z-10 rounded-lg min-w-[120px]"
class="btn-leather z-10 rounded-lg min-w-[120px] ml-3"
on:click={() => (showSettings = !showSettings)}
>
<CogSolid class="mr-2 h-5 w-5" />
@ -94,10 +141,10 @@ @@ -94,10 +141,10 @@
{/if}
</div>
<!-- Settings Panel (shown when settings button is clicked) -->
{#if !loading && !error && showSettings}
<!-- Settings Panel -->
<div
class="absolute left-[220px] top-14 h-auto w-80 bg-white dark:bg-gray-800 p-4 shadow-lg z-10
class="absolute left-0 top-14 h-auto w-80 bg-white dark:bg-gray-800 p-4 shadow-lg z-10
overflow-y-auto max-h-[calc(100vh-96px)] rounded-lg border
border-gray-200 dark:border-gray-700"
transition:fly={{ duration: 300, y: -10, opacity: 1, easing: quintOut }}
@ -118,6 +165,7 @@ @@ -118,6 +165,7 @@
</div>
{/if}
<!-- Loading spinner -->
{#if loading}
<div class="flex justify-center items-center h-64">
<div role="status">
@ -140,12 +188,14 @@ @@ -140,12 +188,14 @@
<span class="sr-only">Loading...</span>
</div>
</div>
<!-- Error message -->
{:else if error}
<div
class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400"
role="alert"
>
<p>Error loading network: {error}</p>
<p class="font-bold mb-2">Error loading network:</p>
<p class="mb-3">{error}</p>
<button
type="button"
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 mt-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800"
@ -154,8 +204,11 @@ @@ -154,8 +204,11 @@
Retry
</button>
</div>
<!-- Network visualization -->
{:else}
<div class="relative">
<!-- Event network visualization -->
<EventNetwork {events} />
<div class="mt-8 prose dark:prose-invert max-w-none"></div>
</div>
{/if}
</div>

81
src/styles/visualize.css

@ -1,4 +1,5 @@ @@ -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 @@ @@ -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;
}
}

19
src/types/d3.d.ts vendored

@ -0,0 +1,19 @@ @@ -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';
Loading…
Cancel
Save