Browse Source

Merge branch 'master' into issue#199#202

# Conflicts:
#	src/lib/components/Publication.svelte
#	src/routes/publication/+page.svelte
master
Nuša Pukšič 11 months ago
parent
commit
e739f1b49b
  1. 62
      .cursor/rules/alexandria.mdc
  2. 3
      deno.lock
  3. 292
      package-lock.json
  4. 3
      package.json
  5. 159
      src/app.css
  6. 8
      src/app.d.ts
  7. 6
      src/lib/components/EventLimitControl.svelte
  8. 6
      src/lib/components/EventRenderLevelLimit.svelte
  9. 189
      src/lib/components/Publication.svelte
  10. 108
      src/lib/components/PublicationSection.svelte
  11. 97
      src/lib/navigator/EventNetwork/Legend.svelte
  12. 112
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  13. 52
      src/lib/navigator/EventNetwork/Settings.svelte
  14. 627
      src/lib/navigator/EventNetwork/index.svelte
  15. 94
      src/lib/navigator/EventNetwork/types.ts
  16. 215
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  17. 182
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  18. 43
      src/lib/snippets/PublicationSnippets.svelte
  19. 8
      src/routes/+layout.svelte
  20. 32
      src/routes/publication/+page.svelte
  21. 3
      src/routes/publication/+page.ts
  22. 128
      src/routes/visualize/+page.svelte
  23. 90
      src/styles/visualize.css
  24. 19
      src/types/d3.d.ts

62
.cursor/rules/alexandria.mdc

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
---
description:
globs:
alwaysApply: true
---
# Project Alexandria
You are senior full-stack software engineer with 20 years of experience writing web apps. You have been working with the Svelte web development framework for 8 years, since it was first released, and you currently are a leading expert on Svelte 5 and SvelteKit 2. Additionally, you are a pioneer developer on the Nostr protocol, and have developing production-quality Nostr apps for 4 years.
## Project Overview
Alexandria is a Nostr project written in Svelte 5 and SvelteKit 2. It is a web app for reading, commenting on, and publishing books, blogs, and other long-form content stored on Nostr relays. It revolves around breaking long AsciiDoc documents into Nostr events, with each event containing a paragraph or so of text from the document. These individual content events are organized by index events into publications. An index contains an ordered list of references to other index events or content events, forming a tree.
### Reader Features
In reader mode, Alexandria loads a document tree from a root publication index event. The AsciiDoc text content of the various content events, along with headers specified by tags in the index events, is composed and rendered as a single document from the user's point of view.
### Tech Stack
Svelte components in Alexandria use TypeScript exclusively over plain JavaScript. Styles are defined via Tailwind 4 utility classes, and some custom utility classes are defined in [app.css](mdc:src/app.css). The app runs on Deno, but maintains compatibility with Node.js.
## General Guidelines
When responding to prompts, adhere to the following rules:
- Avoid making apologetic or conciliatory statements.
- Avoid verbose responses; be direct and to the point.
- Provide links to relevant documentation so that I can do further reading on the tools or techniques discussed and used in your responses.
- When I tell you a response is incorrect, avoid simply agreeing with me; think about the points raised and provide well-reasoned explanations for your subsequent responses.
- Avoid proposing code edits unless I specifically tell you to do so.
- When giving examples from my codebase, include the file name and line numbers so I can find the relevant code easily.
## Code Style
Observe the following style guidelines when writing code:
### General Guidance
- Use PascalCase names for Svelte 5 components and their files.
- Use snake_case names for plain TypeScript files.
- Use comments sparingly; code should be self-documenting.
### JavaScript/TypeScript
- Use an indentation size of 2 spaces.
- Use camelCase names for variables, classes, and functions.
- Give variables, classes, and functions descriptive names that reflect their content and purpose.
- Use Svelte 5 features, such as runes. Avoid using legacy Svelte 4 features.
- Write JSDoc comments for all functions.
- Use blocks enclosed by curly brackets when writing control flow expressions such as `for` and `while` loops, and `if` and `switch` statements.
- Begin `case` expressions in a `switch` statement at the same indentation level as the `switch` itself. Indent code within a `case` block.
- Limit line length to 100 characters; break statements across lines if necessary.
- Default to single quotes.
### HTML
- Use an indentation size of 2 spaces.
- Break long tags across multiple lines.
- Use Tailwind 4 utility classes for styling.
- Default to single quotes.

3
deno.lock

@ -2887,10 +2887,11 @@ @@ -2887,10 +2887,11 @@
"npm:@sveltejs/adapter-auto@3",
"npm:@sveltejs/adapter-node@^5.2.12",
"npm:@sveltejs/adapter-static@3",
"npm:@sveltejs/kit@2",
"npm:@sveltejs/kit@^2.16.0",
"npm:@sveltejs/vite-plugin-svelte@4",
"npm:@tailwindcss/forms@0.5",
"npm:@tailwindcss/typography@0.5",
"npm:@types/d3@^7.4.3",
"npm:@types/he@1.2",
"npm:@types/node@22",
"npm:asciidoctor@3.0",

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",

3
package.json

@ -29,8 +29,9 @@ @@ -29,8 +29,9 @@
"@sveltejs/adapter-auto": "3.x",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-static": "3.x",
"@sveltejs/kit": "2.x",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "4.x",
"@types/d3": "^7.4.3",
"@types/he": "1.2.x",
"@types/node": "22.x",
"autoprefixer": "10.x",

159
src/app.css

@ -196,10 +196,7 @@ @@ -196,10 +196,7 @@
@apply bg-gray-200 dark:bg-gray-700;
}
/* Unordered list */
.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;
}
@ -211,13 +208,161 @@ @@ -211,13 +208,161 @@
}
}
/* Utilities can be applied via the @apply directive. */
@layer utilities {
.h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4;
}
.h1-leather {
@apply text-4xl font-bold;
}
.h2-leather {
@apply text-3xl font-bold;
}
.h3-leather {
@apply text-2xl font-bold;
}
.h4-leather {
@apply text-xl font-bold;
}
.h5-leather {
@apply text-lg font-semibold;
}
.h6-leather {
@apply text-base font-semibold;
}
/* Lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
}
}
@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;
@apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded;
@apply shadow-none text-primary-1000 border border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
}
/* 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;
}
.leather-legend button {
@apply dark:text-white;
}
/* Rendered publication content */
.publication-leather {
@apply flex flex-col space-y-4;
h1, h2, h3, h4, h5, h6 {
@apply h-leather;
}
h1 {
@apply h1-leather;
}
h2 {
@apply h2-leather;
}
h3 {
@apply h3-leather;
}
h4 {
@apply h4-leather;
}
h5 {
@apply h5-leather;
}
h6 {
@apply h6-leather;
}
div {
@apply flex flex-col space-y-4;
}
.olist {
@apply flex flex-col space-y-4;
ol {
@apply ol-leather list-decimal px-6 flex flex-col space-y-2;
li {
.paragraph {
@apply py-2;
}
}
}
}
.ulist {
@apply flex flex-col space-y-4;
ul {
@apply ul-leather list-disc px-6 flex flex-col space-y-2;
li {
.paragraph {
@apply py-2;
}
}
}
}
a {
@apply link;
}
.imageblock {
@apply flex flex-col items-center;
.title {
@apply text-sm text-center;
}
}
.stemblock {
@apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg;
}
table {
@apply w-full overflow-x-auto;
caption {
@apply text-sm;
}
thead, tbody {
th, td {
@apply border border-gray-200 dark:border-gray-700;
}
}
}
}
}

8
src/app.d.ts vendored

@ -1,14 +1,18 @@ @@ -1,14 +1,18 @@
// See https://kit.svelte.dev/docs/types#app
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import Pharos from "./lib/parser.ts";
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface PageData {
ndk?: NDK;
parser?: Pharos;
waitable?: Promise<any>;
publicationType?: string;
indexEvent?: NDKEvent;
url?: URL;
}
// interface Platform {}
}

6
src/lib/components/EventLimitControl.svelte

@ -30,7 +30,7 @@ @@ -30,7 +30,7 @@
</script>
<div class="flex items-center gap-2 mb-4">
<label for="event-limit" class="text-sm font-medium"
<label for="event-limit" class="leather bg-transparent text-sm font-medium"
>Number of root events:
</label>
<input
@ -38,14 +38,14 @@ @@ -38,14 +38,14 @@
id="event-limit"
min="1"
max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
bind:value={inputValue}
on:input={handleInput}
on:keydown={handleKeyDown}
/>
<button
on:click={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>

6
src/lib/components/EventRenderLevelLimit.svelte

@ -29,16 +29,16 @@ @@ -29,16 +29,16 @@
</script>
<div class="flex items-center gap-2 mb-4">
<label for="levels-to-render" class="text-sm font-medium"
<label for="levels-to-render" class="leather bg-transparent text-sm font-medium"
>Levels to render:
</label>
<label for="event-limit" class="text-sm font-medium">Limit: </label>
<label for="event-limit" class="leather bg-transparent text-sm font-medium">Limit: </label>
<input
type="number"
id="levels-to-render"
min="1"
max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
bind:value={inputValue}
oninput={handleInput}
onkeydown={handleKeyDown}

189
src/lib/components/Publication.svelte

@ -1,20 +1,72 @@ @@ -1,20 +1,72 @@
<script lang="ts">
import Preview from "./Preview.svelte";
import { pharosInstance } from "$lib/parser";
import {
Button,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
Skeleton,
TextPlaceholder,
Tooltip,
} from "flowbite-svelte";
import { getContext, onMount } from "svelte";
import { BookOutline } from "flowbite-svelte-icons";
import { page } from "$app/state";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores";
let { rootId, publicationType, indexEvent } = $props<{
rootId: string,
let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string,
publicationType: string,
indexEvent: NDKEvent
}>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
const publicationTree = getContext('publicationTree') as PublicationTree;
// #region Loading
// TODO: Test load handling.
let leaves = $state<NDKEvent[]>([]);
let isLoading = $state<boolean>(false);
let lastElementRef = $state<HTMLElement | null>(null);
let observer: IntersectionObserver;
async function loadMore(count: number) {
isLoading = true;
for (let i = 0; i < count; i++) {
const nextItem = await publicationTree.next();
if (leaves.includes(nextItem.value) || nextItem.done) {
isLoading = false;
return;
}
leaves.push(nextItem.value);
}
isLoading = false;
}
function setLastElementRef(el: HTMLElement, i: number) {
if (i === leaves.length - 1) {
lastElementRef = el;
}
}
$effect(() => {
if (!lastElementRef) {
return;
}
observer.observe(lastElementRef!);
return () => observer.unobserve(lastElementRef!);
});
// #endregion
let currentBlog: null|string = $state(null);
@ -34,6 +86,80 @@ @@ -34,6 +86,80 @@
$publicationColumnVisibility.inner = true;
currentBlog = rootId;
}
// #region ToC
function scrollToElementWithOffset() {
const hash = window.location.hash;
if (hash) {
const targetElement = document.querySelector(hash);
if (targetElement) {
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "auto",
});
}
}
}
/**
* Hides the table of contents sidebar when the window shrinks below a certain size. This
* prevents the sidebar from occluding the article content.
*/
function setTocVisibilityOnResize() {
showToc = window.innerWidth >= tocBreakpoint;
showTocButton = window.innerWidth < tocBreakpoint;
}
/**
* Hides the table of contents sidebar when the user clicks outside of it.
*/
function hideTocOnClick(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return;
}
if (showToc) {
showToc = false;
}
}
// #endregion
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset();
window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener("click", hideTocOnClick);
// Set up the intersection observer.
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading) {
loadMore(4);
}
});
}, { threshold: 0.5 });
loadMore(8);
return () => {
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
observer.disconnect();
};
});
</script>
{#if $publicationColumnVisibility.details}
@ -44,24 +170,63 @@ @@ -44,24 +170,63 @@
</div>
{/if}
{#if showTocButton && !showToc}
<!-- <Button
class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip> -->
{/if}
<!-- TODO: Use loader to build ToC. -->
<!-- {#if showToc}
<Sidebar class='sidebar-leather fixed top-20 left-0 px-4 w-60' {activeHash}>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather overflow-y-scroll'>
{#each events as event}
<SidebarItem
class='sidebar-item-leather'
label={event.getMatchingTags('title')[0][1]}
href={`${$page.url.pathname}#${normalizeHashPath(event.getMatchingTags('title')[0][1])}`}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if} -->
{#if isDefaultVisible()}
<div class="flex flex-col space-y-4 overflow-auto
<div class="flex flex-col space-y-4 overflow-auto
{publicationType === 'blog' ? 'max-w-xl flex-grow-1' : 'max-w-2xl flex-grow-2' }
{currentBlog !== null ? 'discreet' : ''}
">
<div class="card-leather bg-highlight dark:bg-primary-800 p-4 mx-2 mb-4 rounded-lg border">
<Details event={indexEvent} />
</div>
<Preview {rootId} {publicationType} index={0} onBlogUpdate={loadBlog} />
<div class="flex flex-col space-y-4 max-w-2xl">
{#each leaves as leaf, i}
<PublicationSection
rootAddress={rootAddress}
leaves={leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/each}
</div>
</div>
{/if}
{#if currentBlog !== null && $publicationColumnVisibility.inner }
{#key currentBlog }
<div class="flex flex-col space-y-4 max-w-3xl overflow-auto flex-grow-2">
<Preview rootId={currentBlog} {publicationType} index={0} />
</div>
<div class="flex flex-col space-y-4 max-w-3xl overflow-auto flex-grow-2">
<span>Todo...</span>
</div>
{/key}
{/if}

108
src/lib/components/PublicationSection.svelte

@ -0,0 +1,108 @@ @@ -0,0 +1,108 @@
<script lang='ts'>
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
let {
address,
rootAddress,
leaves,
ref,
}: {
address: string,
rootAddress: string,
leaves: NDKEvent[],
ref: (ref: HTMLElement) => void,
} = $props();
const publicationTree: PublicationTree = getContext('publicationTree');
const asciidoctor: Asciidoctor = getContext('asciidoctor');
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(address));
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(rootAddress));
let publicationType: Promise<string | undefined> = $derived.by(async () =>
(await rootEvent)?.getMatchingTags('type')[0]?.[1]);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () =>
await publicationTree.getHierarchy(address));
let leafTitle: Promise<string | undefined> = $derived.by(async () =>
(await leafEvent)?.getMatchingTags('title')[0]?.[1]);
let leafContent: Promise<string | Document> = $derived.by(async () =>
asciidoctor.convert((await leafEvent)?.content ?? ''));
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
const index = leaves.findIndex(leaf => leaf.tagAddress() === address);
if (index === 0) {
return null;
}
return leaves[index - 1];
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => {
if (!previousLeafEvent) {
return null;
}
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
});
let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]);
const branches: [NDKEvent, number][] = [];
if (!previousLeafHierarchyValue) {
for (let i = 0; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
}
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length);
// Find the first diverging node.
let divergingIndex = 0;
while (
divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress()
) {
divergingIndex++;
}
// Add all branches from the first diverging node to the current leaf.
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
});
let sectionRef: HTMLElement;
$effect(() => {
if (!sectionRef) {
return;
}
ref(sectionRef);
});
</script>
<!-- TODO: Correctly handle events that are the start of a content section. -->
<section bind:this={sectionRef} class='publication-leather'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- TODO: Ensure we render all headings, not just the first one. -->
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)}
{/await}
</section>

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

@ -1,30 +1,75 @@ @@ -1,30 +1,75 @@
<!-- Legend Component (Svelte 5, Runes Mode) -->
<script lang="ts">
export let className: string = "";
import {Button} from 'flowbite-svelte';
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
let {
collapsedOnInteraction = false,
className = ""
} = $props<{collapsedOnInteraction: boolean, className: string}>();
let expanded = $state(true);
$effect(() => {
if (collapsedOnInteraction) {
expanded = false;
}
});
function toggle() {
expanded = !expanded;
}
</script>
<div class="leather-legend {className}">
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3>
<ul class="legend-list">
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle" style="background-color: hsl(200, 70%, 75%)">
</span>
<span class="legend-letter">I</span>
</div>
<span>Index events (kind 30040) - Each with a unique pastel color</span>
</li>
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle content"></span>
<span class="legend-letter">C</span>
</div>
<span>Content events (kinds 30041, 30818) - Publication sections</span>
</li>
<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" />
</svg>
<span>Arrows indicate reading/sequence order</span>
</li>
</ul>
<div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Legend</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" >
{#if expanded}
<CaretUpOutline />
{:else}
<CaretDownOutline />
{/if}
</Button>
</div>
{#if expanded}
<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 class="legend-letter">I</span>
</span>
</div>
<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 class="legend-letter">C</span>
</span>
</div>
<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"
stroke-width="2"
fill="none"
/>
</svg>
<span class="legend-text">Arrows indicate reading/sequence order</span>
</li>
</ul>
{/if}
</div>

112
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";
import { onMount } from "svelte";
let { node, selected = false, x, y } = $props<{
node: NetworkNode;
selected?: boolean;
x: number;
y: number;
// 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}

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

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
<!--
Settings Component
-->
<script lang="ts">
import {Button} from 'flowbite-svelte';
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
import { fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import EventLimitControl from "$lib/components/EventLimitControl.svelte";
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte";
import { networkFetchLimit } from "$lib/state";
let {
count = 0,
onupdate
} = $props<{count: number, onupdate: () => void}>();
let expanded = $state(false);
function toggle() {
expanded = !expanded;
}
/**
* Handles updates to visualization settings
*/
function handleLimitUpdate() {
onupdate();
}
</script>
<div class="leather-legend sm:!right-1 sm:!left-auto" >
<div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Settings</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" >
{#if expanded}
<CaretUpOutline />
{:else}
<CaretDownOutline />
{/if}
</Button>
</div>
{#if expanded}
<div class="space-y-4">
<span class="leather bg-transparent legend-text">
Showing {count} events from {$networkFetchLimit} headers
</span>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>
{/if}
</div>

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

@ -1,40 +1,84 @@ @@ -1,40 +1,84 @@
<!-- 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";
import Settings from "./Settings.svelte";
import {Button} from 'flowbite-svelte';
// 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 = [], onupdate } = $props<{ events?: NDKEvent[], onupdate: () => void }>();
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 +88,40 @@ @@ -44,32 +88,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,166 +140,248 @@ @@ -88,166 +140,248 @@
.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;
const { nodes, links } = generateGraph(events, Number(currentLevels));
if (!nodes.length) return;
// Stop any existing simulation
if (simulation) simulation.stop();
// Create new simulation
simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance));
const dragHandler = setupDragHandlers(simulation);
// Update links
const link = svgGroup
.selectAll<SVGPathElement, NetworkLink>("path.link")
.data(links, d => `${d.source.id}-${d.target.id}`)
.join(
enter => enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
update => update,
exit => exit.remove()
);
// Update nodes
const node = svgGroup
.selectAll<SVGGElement, NetworkNode>("g.node")
.data(nodes, d => d.id)
.join(
enter => {
const nodeEnter = enter
.append("g")
.attr("class", "node network-node-leather")
.call(dragHandler);
nodeEnter
.append("circle")
.attr("class", "drag-circle")
.attr("r", nodeRadius * 2.5)
.attr("fill", "transparent")
.attr("stroke", "transparent")
.style("cursor", "move");
nodeEnter
.append("circle")
.attr("class", "visual-circle")
.attr("r", nodeRadius)
.attr("stroke-width", 2);
nodeEnter
.append("text")
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "black")
.attr("font-size", "12px");
return nodeEnter;
},
update => update,
exit => exit.remove()
);
// Update node appearances
node.select("circle.visual-circle")
.attr("class", d => !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"
: getEventColor(d.id)
);
node.select("text")
.text(d => d.isContainer ? "I" : "C");
// Update node interactions
node
.on("mouseover", (event, d) => {
if (!selectedNodeId) {
tooltipVisible = true;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
}
})
.on("mousemove", (event, d) => {
if (!selectedNodeId) {
tooltipX = event.pageX;
tooltipY = event.pageY;
}
})
.on("mouseout", () => {
if (!selectedNodeId) {
tooltipVisible = false;
tooltipNode = null;
}
})
.on("click", (event, d) => {
event.stopPropagation();
if (selectedNodeId === d.id) {
selectedNodeId = null;
tooltipVisible = false;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
} else {
selectedNodeId = d.id;
tooltipVisible = true;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
}
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");
}
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
});
// Handle simulation ticks
simulation.on("tick", () => {
nodes.forEach(node => {
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha());
applyConnectedGravity(node, links, simulation!.alpha());
const graphData = generateGraph(events, Number(currentLevels));
nodes = graphData.nodes;
links = graphData.links;
debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length
});
// Update positions
link.attr("d", d => {
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;
const startX = d.source.x! + sourceGap * Math.cos(angle);
const startY = d.source.y! + sourceGap * Math.sin(angle);
const endX = d.target.x! - targetGap * Math.cos(angle);
const endY = d.target.y! - targetGap * Math.sin(angle);
return `M${startX},${startY}L${endX},${endY}`;
if (!nodes.length) {
throw new Error("No nodes to render");
}
// Stop any existing simulation
if (simulation) {
debug("Stopping existing simulation");
simulation.stop();
}
// Create new 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();
});
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
// Create drag handler
dragHandler = setupDragHandlers(simulation);
// Update links
debug("Updating links");
link = svgGroup
.selectAll("path.link")
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`)
.join(
(enter: any) => enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
(update: any) => update,
(exit: any) => exit.remove()
);
// Update nodes
debug("Updating nodes");
node = svgGroup
.selectAll("g.node")
.data(nodes, (d: NetworkNode) => d.id)
.join(
(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", NODE_RADIUS * 2.5)
.attr("fill", "transparent")
.attr("stroke", "transparent")
.style("cursor", "move");
// Visible circle
nodeEnter
.append("circle")
.attr("class", "visual-circle")
.attr("r", NODE_RADIUS)
.attr("stroke-width", 2);
// Node label
nodeEnter
.append("text")
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "black")
.attr("font-size", "12px");
return nodeEnter;
},
(update: any) => update,
(exit: any) => exit.remove()
);
// Update node appearances
debug("Updating node appearances");
node.select("circle.visual-circle")
.attr("class", (d: NetworkNode) => !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather"
)
.attr("fill", (d: NetworkNode) => !d.isContainer
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
);
node.select("text")
.text((d: NetworkNode) => d.isContainer ? "I" : "C");
// Set up node interactions
debug("Setting up node interactions");
node
.on("mouseover", (event: any, d: NetworkNode) => {
if (!selectedNodeId) {
tooltipVisible = true;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
}
})
.on("mousemove", (event: any) => {
if (!selectedNodeId) {
tooltipX = event.pageX;
tooltipY = event.pageY;
}
})
.on("mouseout", () => {
if (!selectedNodeId) {
tooltipVisible = false;
tooltipNode = null;
}
})
.on("click", (event: any, d: NetworkNode) => {
event.stopPropagation();
if (selectedNodeId === d.id) {
// Clicking the selected node again deselects it
selectedNodeId = null;
tooltipVisible = false;
} else {
// Select the node and show its tooltip
selectedNodeId = d.id;
tooltipVisible = true;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
}
});
// 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 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);
// 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);
const endX = d.target.x! - targetGap * Math.cos(angle);
const endY = d.target.y! - targetGap * Math.sin(angle);
return `M${startX},${startY}L${endX},${endY}`;
});
// 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(() => {
isDarkMode = document.body.classList.contains("dark");
// Initialize the graph structure
initializeGraph();
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 +390,10 @@ @@ -256,10 +390,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 +402,7 @@ @@ -268,6 +402,7 @@
});
});
// Set up container resize observer
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
@ -275,19 +410,21 @@ @@ -275,19 +410,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 +433,150 @@ @@ -296,29 +433,150 @@
};
});
// Watch for changes that should trigger a graph update
/**
* Watch for changes that should trigger a graph update
*/
$effect(() => {
if (svg && events?.length) {
// Include currentLevels in the effect dependencies
const _ = currentLevels;
updateGraph();
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
);
}
}
/**
* Legend interactions
*/
let graphInteracted = $state(false);
function handleGraphClick() {
if (!graphInteracted) {
graphInteracted = true;
}
}
</script>
<div
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4"
>
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}>
<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(); }}
>
Retry
</button>
</div>
{/if}
<div class="network-svg-container" bind:this={container} role="figure">
<Legend collapsedOnInteraction={graphInteracted} className='' />
<!-- Settings Panel (shown when settings button is clicked) -->
<Settings count={events.length} onupdate={onupdate} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
bind:this={svg}
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded"
class="network-svg"
onclick={handleGraphClick}
/>
<!-- Zoom controls -->
<div class="network-controls">
<Button outline size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomIn}
aria-label="Zoom in"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</Button>
<Button outline size="lg"
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 outline size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={centerGraph}
aria-label="Center graph"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</Button>
</div>
</div>
{#if tooltipVisible && tooltipNode}
@ -327,9 +585,8 @@ @@ -327,9 +585,8 @@
selected={tooltipNode.id === selectedNodeId}
x={tooltipX}
y={tooltipY}
on:close={handleTooltipClose}
onclose={handleTooltipClose}
/>
{/if}
<Legend />
</div>

94
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
}
export interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> {
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<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
}

215
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
/**
* 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;
}
/**
* Updates a node's velocity
* 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)
simulation.alphaTarget(warmupClickEnergy).restart();
d.fx = d.x;
d.fy = d.y;
},
)
.on(
"drag",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
d.fx = event.x;
d.fy = event.y;
},
)
.on(
"end",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
},
);
.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: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Update fixed position to mouse position
d.fx = event.x;
d.fy = event.y;
})
.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)
.force(
"link",
d3
.forceLink<NetworkNode, NetworkLink>(links)
.id((d) => d.id)
.distance(linkDistance * 0.1),
)
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4));
}
): 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(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;
}
}

182
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,
});
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( @@ -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)
);
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,
};
}
debug("Graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length
});
return result;
}

43
src/lib/snippets/PublicationSnippets.svelte

@ -5,41 +5,16 @@ @@ -5,41 +5,16 @@
</script>
{#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0}
<h1 class='h-leather'>
{title}
</h1>
{:else if depth === 1}
<h2 class='h-leather'>
{title}
</h2>
{:else if depth === 2}
<h3 class='h-leather'>
{title}
</h3>
{:else if depth === 3}
<h4 class='h-leather'>
{title}
</h4>
{:else if depth === 4}
<h5 class='h-leather'>
{title}
</h5>
{:else}
<h6 class='h-leather'>
{title}
</h6>
{/if}
{@const headingLevel = Math.min(depth + 1, 6)}
<!-- TODO: Handle floating titles. -->
<svelte:element this={`h${headingLevel}`} class='h-leather'>
{title}
</svelte:element>
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)}
{#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{/if}
<section class='whitespace-normal publication-leather'>
{@html content}
</section>
{/snippet}

8
src/routes/+layout.svelte

@ -3,6 +3,8 @@ @@ -3,6 +3,8 @@
import Navigation from "$lib/components/Navigation.svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
// Compute viewport height.
$: displayHeight = window.innerHeight;
@ -42,5 +44,11 @@ @@ -42,5 +44,11 @@
<div class={'leather min-h-full w-full flex flex-col items-center'}>
<Navigation class='sticky top-0' />
<Alert rounded={false} class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left'>
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' />
<span class='font-medium'>
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable.
</span>
</Alert>
<slot />
</div>

32
src/routes/publication/+page.svelte

@ -1,26 +1,22 @@ @@ -1,26 +1,22 @@
<script lang="ts">
import Article from "$lib/components/Publication.svelte";
import Publication from "$lib/components/Publication.svelte";
import { TextPlaceholder } from "flowbite-svelte";
import type { PageData } from "./$types";
import { onDestroy } from "svelte";
import type { PageProps } from "./$types";
import { onDestroy, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree";
import Processor from "asciidoctor";
import ArticleNav from "$components/util/ArticleNav.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { pharosInstance } from "$lib/parser";
import { page } from "$app/stores";
// Extend the PageData type with the properties we need
interface ExtendedPageData extends PageData {
waitable: Promise<any>;
publicationType: string;
indexEvent: NDKEvent;
parser: any;
}
let { data }: PageProps = $props();
let { data } = $props<{ data: ExtendedPageData }>();
const publicationTree = new PublicationTree(data.indexEvent, data.ndk);
setContext('publicationTree', publicationTree);
setContext('asciidoctor', Processor());
// Get publication metadata for OpenGraph tags
let title = $derived(data.indexEvent?.getMatchingTags('title')[0]?.[1] || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || 'Alexandria Publication');
let currentUrl = $page.url.href;
let currentUrl = data.url?.href ?? '';
// Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic.
@ -58,10 +54,10 @@ @@ -58,10 +54,10 @@
{#await data.waitable}
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" />
{:then}
<Article
rootId={data.parser.getRootIndexId()}
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
indexEvent={data.indexEvent}
indexEvent={data.indexEvent}
/>
{/await}
</main>

3
src/routes/publication/+page.ts

@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit'; @@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk.ts';
import { getActiveRelays } from '$lib/ndk';
/**
* Decodes an naddr identifier and returns a filter object
@ -103,5 +103,6 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom @@ -103,5 +103,6 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
waitable: fetchPromise,
publicationType,
indexEvent,
url,
};
};

128
src/routes/visualize/+page.svelte

@ -1,55 +1,91 @@ @@ -1,55 +1,91 @@
<!--
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";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
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";
import Settings from "$lib/navigator/EventNetwork/Settings.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];
contentEventIds.add(eventId);
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 +94,11 @@ @@ -58,9 +94,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,55 +107,20 @@ @@ -69,55 +107,20 @@
}
}
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">
<h1 class="h-leather text-2xl font-bold">Publication Network</h1>
<!-- Settings Button - Using Flowbite Components -->
{#if !loading && !error}
<Button
class="btn-leather z-10 rounded-lg min-w-[120px]"
on:click={() => (showSettings = !showSettings)}
>
<CogSolid class="mr-2 h-5 w-5" />
Settings
</Button>
{/if}
<!-- Header with title and settings button -->
<div class="flex items-center mb-4">
<h1 class="h-leather">Publication Network</h1>
</div>
{#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
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 }}
>
<div class="card space-y-4">
<h2 class="text-xl font-bold mb-4 h-leather">
Visualization Settings
</h2>
<div class="space-y-4">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing {events.length} events from {$networkFetchLimit} headers
</span>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>
</div>
</div>
{/if}
<!-- Loading spinner -->
{#if loading}
<div class="flex justify-center items-center h-64">
<div role="status">
@ -140,12 +143,14 @@ @@ -140,12 +143,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 +159,9 @@ @@ -154,8 +159,9 @@
Retry
</button>
</div>
<!-- Network visualization -->
{:else}
<EventNetwork {events} />
<div class="mt-8 prose dark:prose-invert max-w-none"></div>
<!-- Event network visualization -->
<EventNetwork {events} onupdate={fetchEvents} />
{/if}
</div>

90
src/styles/visualize.css

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
@layer components {
/* Legend styles - specific to visualization */
.legend-list {
@apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300;
@apply list-disc mt-2 space-y-2 text-gray-800 dark:text-gray-300;
}
.legend-item {
@ -20,7 +21,92 @@ @@ -20,7 +21,92 @@
background-color: #d6c1a8;
}
.legend-circle.content {
background-color: var(--content-color, #d6c1a8);
}
:global(.dark) .legend-circle.content {
background-color: var(--content-color-dark, #FFFFFF);
}
.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-138px)] min-h-[400px] max-h-[900px];
}
.network-svg-container {
@apply relative sm:h-[100%];
}
.network-svg {
@apply w-full sm:h-[100%] border;
@apply border border-primary-200 has-[:hover]:border-primary-700 dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500 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;
}
/* 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