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 @@
---
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 @@
"npm:@sveltejs/adapter-auto@3", "npm:@sveltejs/adapter-auto@3",
"npm:@sveltejs/adapter-node@^5.2.12", "npm:@sveltejs/adapter-node@^5.2.12",
"npm:@sveltejs/adapter-static@3", "npm:@sveltejs/adapter-static@3",
"npm:@sveltejs/kit@2", "npm:@sveltejs/kit@^2.16.0",
"npm:@sveltejs/vite-plugin-svelte@4", "npm:@sveltejs/vite-plugin-svelte@4",
"npm:@tailwindcss/forms@0.5", "npm:@tailwindcss/forms@0.5",
"npm:@tailwindcss/typography@0.5", "npm:@tailwindcss/typography@0.5",
"npm:@types/d3@^7.4.3",
"npm:@types/he@1.2", "npm:@types/he@1.2",
"npm:@types/node@22", "npm:@types/node@22",
"npm:asciidoctor@3.0", "npm:asciidoctor@3.0",

292
package-lock.json generated

@ -25,6 +25,7 @@
"@sveltejs/adapter-static": "3.x", "@sveltejs/adapter-static": "3.x",
"@sveltejs/kit": "2.x", "@sveltejs/kit": "2.x",
"@sveltejs/vite-plugin-svelte": "4.x", "@sveltejs/vite-plugin-svelte": "4.x",
"@types/d3": "^7.4.3",
"@types/he": "1.2.x", "@types/he": "1.2.x",
"@types/node": "22.x", "@types/node": "22.x",
"autoprefixer": "10.x", "autoprefixer": "10.x",
@ -1531,6 +1532,290 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -1538,6 +1823,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/he": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",

3
package.json

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

159
src/app.css

@ -196,10 +196,7 @@
@apply bg-gray-200 dark:bg-gray-700; @apply bg-gray-200 dark:bg-gray-700;
} }
/* Unordered list */ /* Network visualization */
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.network-link-leather { .network-link-leather {
@apply stroke-gray-400 fill-gray-400; @apply stroke-gray-400 fill-gray-400;
} }
@ -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 { @layer components {
/* Legend */
.leather-legend { .leather-legend {
@apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow @apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded;
border border-gray-200 dark:border-gray-800; @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 { .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 @@
// See https://kit.svelte.dev/docs/types#app // 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 // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
interface PageData { interface PageData {
ndk?: NDK;
parser?: Pharos;
waitable?: Promise<any>; waitable?: Promise<any>;
publicationType?: string; publicationType?: string;
indexEvent?: NDKEvent;
url?: URL;
} }
// interface Platform {} // interface Platform {}
} }

6
src/lib/components/EventLimitControl.svelte

@ -30,7 +30,7 @@
</script> </script>
<div class="flex items-center gap-2 mb-4"> <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: >Number of root events:
</label> </label>
<input <input
@ -38,14 +38,14 @@
id="event-limit" id="event-limit"
min="1" min="1"
max="50" 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} bind:value={inputValue}
on:input={handleInput} on:input={handleInput}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
/> />
<button <button
on:click={handleUpdate} 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 Update
</button> </button>

6
src/lib/components/EventRenderLevelLimit.svelte

@ -29,16 +29,16 @@
</script> </script>
<div class="flex items-center gap-2 mb-4"> <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: >Levels to render:
</label> </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 <input
type="number" type="number"
id="levels-to-render" id="levels-to-render"
min="1" min="1"
max="50" 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} bind:value={inputValue}
oninput={handleInput} oninput={handleInput}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}

189
src/lib/components/Publication.svelte

@ -1,20 +1,72 @@
<script lang="ts"> <script lang="ts">
import Preview from "./Preview.svelte"; import {
import { pharosInstance } from "$lib/parser"; 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 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 Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
let { rootId, publicationType, indexEvent } = $props<{ let { rootAddress, publicationType, indexEvent } = $props<{
rootId: string, rootAddress: string,
publicationType: string, publicationType: string,
indexEvent: NDKEvent indexEvent: NDKEvent
}>(); }>();
if (rootId !== $pharosInstance.getRootIndexId()) { const publicationTree = getContext('publicationTree') as PublicationTree;
console.error("Root ID does not match parser root index ID");
// #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); let currentBlog: null|string = $state(null);
@ -34,6 +86,80 @@
$publicationColumnVisibility.inner = true; $publicationColumnVisibility.inner = true;
currentBlog = rootId; 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> </script>
{#if $publicationColumnVisibility.details} {#if $publicationColumnVisibility.details}
@ -44,24 +170,63 @@
</div> </div>
{/if} {/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()} {#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' } {publicationType === 'blog' ? 'max-w-xl flex-grow-1' : 'max-w-2xl flex-grow-2' }
{currentBlog !== null ? 'discreet' : ''} {currentBlog !== null ? 'discreet' : ''}
"> ">
<div class="card-leather bg-highlight dark:bg-primary-800 p-4 mx-2 mb-4 rounded-lg border"> <div class="card-leather bg-highlight dark:bg-primary-800 p-4 mx-2 mb-4 rounded-lg border">
<Details event={indexEvent} /> <Details event={indexEvent} />
</div> </div>
<div class="flex flex-col space-y-4 max-w-2xl">
<Preview {rootId} {publicationType} index={0} onBlogUpdate={loadBlog} /> {#each leaves as leaf, i}
<PublicationSection
rootAddress={rootAddress}
leaves={leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/each}
</div>
</div> </div>
{/if} {/if}
{#if currentBlog !== null && $publicationColumnVisibility.inner } {#if currentBlog !== null && $publicationColumnVisibility.inner }
{#key currentBlog } {#key currentBlog }
<div class="flex flex-col space-y-4 max-w-3xl overflow-auto flex-grow-2"> <div class="flex flex-col space-y-4 max-w-3xl overflow-auto flex-grow-2">
<Preview rootId={currentBlog} {publicationType} index={0} /> <span>Todo...</span>
</div> </div>
{/key} {/key}
{/if} {/if}

108
src/lib/components/PublicationSection.svelte

@ -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 @@
<!-- Legend Component (Svelte 5, Runes Mode) -->
<script lang="ts"> <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> </script>
<div class="leather-legend {className}"> <div class={`leather-legend ${className}`}>
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3> <div class="flex items-center justify-between space-x-3">
<ul class="legend-list"> <h3 class="h-leather">Legend</h3>
<li class="legend-item"> <Button color='none' outline size='xs' onclick={toggle} class="rounded-full" >
<div class="legend-icon"> {#if expanded}
<span class="legend-circle" style="background-color: hsl(200, 70%, 75%)"> <CaretUpOutline />
</span> {:else}
<span class="legend-letter">I</span> <CaretDownOutline />
</div> {/if}
<span>Index events (kind 30040) - Each with a unique pastel color</span> </Button>
</li> </div>
<li class="legend-item">
<div class="legend-icon"> {#if expanded}
<span class="legend-circle content"></span> <ul class="legend-list">
<span class="legend-letter">C</span> <!-- Index event node -->
</div> <li class="legend-item">
<span>Content events (kinds 30041, 30818) - Publication sections</span> <div class="legend-icon">
</li> <span
<li class="legend-item"> class="legend-circle"
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> style="background-color: hsl(200, 70%, 75%)"
<path d="M4 12h16M16 6l6 6-6 6" class="network-link-leather" /> >
</svg> <span class="legend-letter">I</span>
<span>Arrows indicate reading/sequence order</span> </span>
</li> </div>
</ul> <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> </div>

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

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

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

@ -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 @@
<!-- 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"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import * as d3 from "d3"; import * as d3 from "d3";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { levelsToRender } from "$lib/state"; import { levelsToRender } from "$lib/state";
import { generateGraph, getEventColor } from "./utils/networkBuilder"; 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 Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte"; import NodeTooltip from "./NodeTooltip.svelte";
import type { NetworkNode, NetworkLink } from "./types"; 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 svg: SVGSVGElement;
let isDarkMode = $state(false);
let container: HTMLDivElement; 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 selectedNodeId = $state<string | null>(null);
let tooltipVisible = $state(false); let tooltipVisible = $state(false);
let tooltipX = $state(0); let tooltipX = $state(0);
let tooltipY = $state(0); let tooltipY = $state(0);
let tooltipNode = $state<NetworkNode | null>(null); let tooltipNode = $state<NetworkNode | null>(null);
const nodeRadius = 20; // Dimensions
const linkDistance = 10;
const arrowDistance = 10;
let width = $state(1000); let width = $state(1000);
let height = $state(600); let height = $state(600);
let windowHeight = $state<number | undefined>(undefined); 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; // D3 objects
let svgGroup: d3.Selection<SVGGElement, unknown, null, undefined>; 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 // Update dimensions when container changes
$effect(() => { $effect(() => {
@ -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() { 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) const svgElement = d3.select(svg)
.attr("viewBox", `0 0 ${width} ${height}`); .attr("viewBox", `0 0 ${width} ${height}`);
// Clear existing content // Clear existing content
svgElement.selectAll("*").remove(); svgElement.selectAll("*").remove();
debug("Cleared SVG content");
// Create main group for zoom // Create main group for zoom
svgGroup = svgElement.append("g"); svgGroup = svgElement.append("g");
debug("Created SVG group");
// Set up zoom behavior // Set up zoom behavior
const zoom = d3 zoomBehavior = d3
.zoom<SVGSVGElement, unknown>() .zoom()
.scaleExtent([0.1, 9]) .scaleExtent([0.1, 9]) // Min/max zoom levels
.on("zoom", (event) => { .on("zoom", (event: any) => {
svgGroup.attr("transform", event.transform); 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"); const defs = svgElement.append("defs");
defs defs
.append("marker") .append("marker")
@ -88,166 +140,248 @@
.attr("stroke-width", 1); .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() { function updateGraph() {
if (!svg || !events?.length || !svgGroup) return; debug("Updating graph");
errorMessage = null;
const { nodes, links } = generateGraph(events, Number(currentLevels));
if (!nodes.length) return; // Create variables to hold our selections
let link: any;
// Stop any existing simulation let node: any;
if (simulation) simulation.stop(); let dragHandler: any;
let nodes: NetworkNode[] = [];
// Create new simulation let links: NetworkLink[] = [];
simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance));
const dragHandler = setupDragHandlers(simulation); try {
// Validate required elements
// Update links if (!svg) {
const link = svgGroup throw new Error("SVG element not found");
.selectAll<SVGPathElement, NetworkLink>("path.link") }
.data(links, d => `${d.source.id}-${d.target.id}`)
.join( if (!events?.length) {
enter => enter throw new Error("No events to render");
.append("path") }
.attr("class", "link network-link-leather")
.attr("stroke-width", 2) if (!svgGroup) {
.attr("marker-end", "url(#arrowhead)"), throw new Error("SVG group not found");
update => update, }
exit => exit.remove()
); // Generate graph data from events
debug("Generating graph with events", {
// Update nodes eventCount: events.length,
const node = svgGroup currentLevels
.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;
}
}); });
// Handle simulation ticks const graphData = generateGraph(events, Number(currentLevels));
simulation.on("tick", () => { nodes = graphData.nodes;
nodes.forEach(node => { links = graphData.links;
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha());
applyConnectedGravity(node, links, simulation!.alpha()); debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length
}); });
// Update positions if (!nodes.length) {
link.attr("d", d => { throw new Error("No nodes to render");
const dx = d.target.x! - d.source.x!; }
const dy = d.target.y! - d.source.y!;
const angle = Math.atan2(dy, dx); // Stop any existing simulation
if (simulation) {
const sourceGap = nodeRadius; debug("Stopping existing simulation");
const targetGap = nodeRadius + arrowDistance; simulation.stop();
}
const startX = d.source.x! + sourceGap * Math.cos(angle);
const startY = d.source.y! + sourceGap * Math.sin(angle); // Create new simulation
const endX = d.target.x! - targetGap * Math.cos(angle); debug("Creating new simulation");
const endY = d.target.y! - targetGap * Math.sin(angle); simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE);
return `M${startX},${startY}L${endX},${endY}`; // 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(() => { onMount(() => {
isDarkMode = document.body.classList.contains("dark"); debug("Component mounted");
try {
// Initialize the graph structure // Detect initial theme
initializeGraph(); 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 = () => { const handleResize = () => {
windowHeight = window.innerHeight; windowHeight = window.innerHeight;
}; };
windowHeight = window.innerHeight; windowHeight = window.innerHeight;
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
// Watch for theme changes // Set up theme change observer
const themeObserver = new MutationObserver((mutations) => { const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.attributeName === "class") { if (mutation.attributeName === "class") {
@ -256,10 +390,10 @@
isDarkMode = newIsDarkMode; isDarkMode = newIsDarkMode;
// Update node colors when theme changes // Update node colors when theme changes
if (svgGroup) { if (svgGroup) {
svgGroup.selectAll<SVGGElement, NetworkNode>("g.node") svgGroup.selectAll("g.node")
.select("circle.visual-circle") .select("circle.visual-circle")
.attr("fill", d => !d.isContainer .attr("fill", (d: NetworkNode) => !d.isContainer
? newIsDarkMode ? "#FFFFFF" : "network-link-leather" ? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id) : getEventColor(d.id)
); );
} }
@ -268,6 +402,7 @@
}); });
}); });
// Set up container resize observer
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
width = entry.contentRect.width; width = entry.contentRect.width;
@ -275,19 +410,21 @@
} }
if (svg) { if (svg) {
d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`); d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
// Trigger simulation to adjust to new dimensions // Restart simulation with new dimensions
if (simulation) { if (simulation) {
simulation.alpha(0.3).restart(); simulation.alpha(0.3).restart();
} }
} }
}); });
// Start observers
themeObserver.observe(document.documentElement, { themeObserver.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ["class"], attributeFilter: ["class"],
}); });
resizeObserver.observe(container); resizeObserver.observe(container);
// Clean up on component destruction
return () => { return () => {
themeObserver.disconnect(); themeObserver.disconnect();
resizeObserver.disconnect(); resizeObserver.disconnect();
@ -296,29 +433,150 @@
}; };
}); });
// Watch for changes that should trigger a graph update /**
* Watch for changes that should trigger a graph update
*/
$effect(() => { $effect(() => {
if (svg && events?.length) { debug("Effect triggered", {
// Include currentLevels in the effect dependencies hasSvg: !!svg,
const _ = currentLevels; eventCount: events?.length,
updateGraph(); 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() { function handleTooltipClose() {
tooltipVisible = false; tooltipVisible = false;
selectedNodeId = null; 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> </script>
<div <div class="network-container">
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4" {#if hasError}
> <div class="network-error">
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}> <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 <svg
bind:this={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> </div>
{#if tooltipVisible && tooltipNode} {#if tooltipVisible && tooltipNode}
@ -327,9 +585,8 @@
selected={tooltipNode.id === selectedNodeId} selected={tooltipNode.id === selectedNodeId}
x={tooltipX} x={tooltipX}
y={tooltipY} y={tooltipY}
on:close={handleTooltipClose} onclose={handleTooltipClose}
/> />
{/if} {/if}
<Legend />
</div> </div>

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

@ -1,35 +1,79 @@
/**
* Type definitions for the Event Network visualization
*
* This module defines the core data structures used in the D3 force-directed
* graph visualization of Nostr events.
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
export interface NetworkNode extends d3.SimulationNodeDatum { /**
id: string; * Base interface for nodes in a D3 force simulation
event?: NDKEvent; * Represents the physical properties of a node in the simulation
level: number; */
kind: number; export interface SimulationNodeDatum {
title: string; index?: number; // Node index in the simulation
content: string; x?: number; // X position
author: string; y?: number; // Y position
type: "Index" | "Content"; vx?: number; // X velocity
naddr?: string; vy?: number; // Y velocity
nevent?: string; fx?: number | null; // Fixed X position (when node is pinned)
x?: number; fy?: number | null; // Fixed Y position (when node is pinned)
y?: number; }
isContainer?: boolean;
/**
* 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; * Represents a node in the event network visualization
target: NetworkNode; * Extends the base simulation node with Nostr event-specific properties
isSequential: boolean; */
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 { export interface GraphData {
nodes: NetworkNode[]; nodes: NetworkNode[]; // All nodes in the graph
links: NetworkLink[]; 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 { export interface GraphState {
nodeMap: Map<string, NetworkNode>; nodeMap: Map<string, NetworkNode>; // Maps event IDs to nodes
links: NetworkLink[]; links: NetworkLink[]; // All links in the graph
eventMap: Map<string, NDKEvent>; eventMap: Map<string, NDKEvent>; // Maps event IDs to original events
referencedIds: Set<string>; referencedIds: Set<string>; // Set of event IDs referenced by other events
} }

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

@ -1,27 +1,100 @@
/** /**
* D3 force simulation utilities for the event network * D3 Force Simulation Utilities
*
* This module provides utilities for creating and managing D3 force-directed
* graph simulations for the event network visualization.
*/ */
import type { NetworkNode, NetworkLink } from "../types"; import type { NetworkNode, NetworkLink } from "../types";
import type { Simulation } from "d3";
import * as d3 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( export function updateNodeVelocity(
node: NetworkNode, node: NetworkNode,
deltaVx: number, deltaVx: number,
deltaVy: 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") { if (typeof node.vx === "number" && typeof node.vy === "number") {
node.vx = node.vx - deltaVx; node.vx = node.vx - deltaVx;
node.vy = node.vy - deltaVy; 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( export function applyGlobalLogGravity(
node: NetworkNode, node: NetworkNode,
@ -35,102 +108,128 @@ export function applyGlobalLogGravity(
if (distance === 0) return; 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); updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
} }
/** /**
* Applies gravity between connected nodes * 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( export function applyConnectedGravity(
node: NetworkNode, node: NetworkNode,
links: NetworkLink[], links: NetworkLink[],
alpha: number, alpha: number,
) { ) {
// Find all nodes connected to this node
const connectedNodes = links const connectedNodes = links
.filter( .filter(link => link.source.id === node.id || link.target.id === node.id)
(link) => link.source.id === node.id || link.target.id === node.id, .map(link => link.source.id === node.id ? link.target : link.source);
)
.map((link) => (link.source.id === node.id ? link.target : link.source));
if (connectedNodes.length === 0) return; if (connectedNodes.length === 0) return;
const cogX = d3.mean(connectedNodes, (n) => n.x); // Calculate center of gravity of connected nodes
const cogY = d3.mean(connectedNodes, (n) => n.y); const cogX = d3.mean(connectedNodes, (n: NetworkNode) => n.x);
const cogY = d3.mean(connectedNodes, (n: NetworkNode) => n.y);
if (cogX === undefined || cogY === undefined) return; if (cogX === undefined || cogY === undefined) return;
// Calculate force direction and magnitude
const dx = (node.x ?? 0) - cogX; const dx = (node.x ?? 0) - cogX;
const dy = (node.y ?? 0) - cogY; const dy = (node.y ?? 0) - cogY;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return; 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); updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
} }
/** /**
* Sets up drag behavior for nodes * 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( export function setupDragHandlers(
simulation: Simulation<NetworkNode, NetworkLink>, simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9 warmupClickEnergy: number = 0.9
) { ) {
return d3 return d3
.drag<SVGGElement, NetworkNode>() .drag()
.on( .on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
"start", // Warm up simulation if it's cooled down
( if (!event.active) {
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, simulation.alphaTarget(warmupClickEnergy).restart();
d: NetworkNode, }
) => { // Fix node position at current location
if (!event.active) d.fx = d.x;
simulation.alphaTarget(warmupClickEnergy).restart(); d.fy = d.y;
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;
.on( d.fy = event.y;
"drag", })
( .on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, // Cool down simulation when drag ends
d: NetworkNode, if (!event.active) {
) => { simulation.alphaTarget(0);
d.fx = event.x; }
d.fy = event.y; // Release fixed position
}, d.fx = null;
) d.fy = null;
.on( });
"end",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
},
);
} }
/** /**
* Creates a D3 force simulation for the network * 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( export function createSimulation(
nodes: NetworkNode[], nodes: NetworkNode[],
links: NetworkLink[], links: NetworkLink[],
nodeRadius: number, nodeRadius: number,
linkDistance: number linkDistance: number
) { ): Simulation<NetworkNode, NetworkLink> {
return d3 debug("Creating simulation", {
.forceSimulation<NetworkNode>(nodes) nodeCount: nodes.length,
.force( linkCount: links.length,
"link", nodeRadius,
d3 linkDistance
.forceLink<NetworkNode, NetworkLink>(links) });
.id((d) => d.id)
.distance(linkDistance * 0.1), try {
) // Create the simulation with nodes
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4)); 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 @@
/**
* 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 { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { standardRelays } from "$lib/consts"; 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 * 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( export function createNetworkNode(
event: NDKEvent, event: NDKEvent,
level: number = 0 level: number = 0
): NetworkNode { ): 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 = { const node: NetworkNode = {
id: event.id, id: event.id,
event, event,
@ -20,13 +52,16 @@ export function createNetworkNode(
title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled", title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled",
content: event.content || "", content: event.content || "",
author: event.pubkey || "", author: event.pubkey || "",
kind: event.kind, kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined
type: event?.kind === 30040 ? "Index" : "Content", type: nodeType,
}; };
// Add NIP-19 identifiers if possible
if (event.kind && event.pubkey) { if (event.kind && event.pubkey) {
try { try {
const dTag = event.getMatchingTags("d")?.[0]?.[1] || ""; const dTag = event.getMatchingTags("d")?.[0]?.[1] || "";
// Create naddr (NIP-19 address) for the event
node.naddr = nip19.naddrEncode({ node.naddr = nip19.naddrEncode({
pubkey: event.pubkey, pubkey: event.pubkey,
identifier: dTag, identifier: dTag,
@ -34,6 +69,7 @@ export function createNetworkNode(
relays: standardRelays, relays: standardRelays,
}); });
// Create nevent (NIP-19 event reference) for the event
node.nevent = nip19.neventEncode({ node.nevent = nip19.neventEncode({
id: event.id, id: event.id,
relays: standardRelays, relays: standardRelays,
@ -47,50 +83,93 @@ export function createNetworkNode(
return node; 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> { export function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> {
debug("Creating event map", { eventCount: events.length });
const eventMap = new Map<string, NDKEvent>(); const eventMap = new Map<string, NDKEvent>();
events.forEach((event) => { events.forEach((event) => {
if (event.id) { if (event.id) {
eventMap.set(event.id, event); eventMap.set(event.id, event);
} }
}); });
debug("Event map created", { mapSize: eventMap.size });
return eventMap; 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 { export function extractEventIdFromATag(tag: string[]): string | null {
return tag[3] || 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 { 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); const num = parseInt(eventId.slice(0, 4), 16);
// Convert to a hue value (0-359)
const hue = num % 360; const hue = num % 360;
// Use fixed saturation and lightness for pastel colors
const saturation = 70; const saturation = 70;
const lightness = 75; const lightness = 75;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`; 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 { export function initializeGraphState(events: NDKEvent[]): GraphState {
debug("Initializing graph state", { eventCount: events.length });
const nodeMap = new Map<string, NetworkNode>(); const nodeMap = new Map<string, NetworkNode>();
const eventMap = createEventMap(events); const eventMap = createEventMap(events);
// Create initial nodes // Create initial nodes for all events
events.forEach((event) => { events.forEach((event) => {
if (!event.id) return; if (!event.id) return;
const node = createNetworkNode(event); const node = createNetworkNode(event);
nodeMap.set(event.id, node); 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>(); const referencedIds = new Set<string>();
events.forEach((event) => { 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); const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id); if (id) referencedIds.add(id);
}); });
}); });
debug("Referenced IDs set created", { referencedCount: referencedIds.size });
return { return {
nodeMap, nodeMap,
@ -100,6 +179,18 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
}; };
} }
/**
* Processes a sequence of nodes referenced by an index event
*
* Creates links between the index and its content, and between sequential content nodes.
* Also processes nested indices recursively up to the maximum level.
*
* @param sequence - Array of nodes in the sequence
* @param indexEvent - The index event referencing the sequence
* @param level - Current hierarchy level
* @param state - Current graph state
* @param maxLevel - Maximum hierarchy level to process
*/
export function processSequence( export function processSequence(
sequence: NetworkNode[], sequence: NetworkNode[],
indexEvent: NDKEvent, indexEvent: NDKEvent,
@ -107,14 +198,15 @@ export function processSequence(
state: GraphState, state: GraphState,
maxLevel: number, maxLevel: number,
): void { ): void {
// Stop if we've reached max level or have no nodes
if (level >= maxLevel || sequence.length === 0) return; if (level >= maxLevel || sequence.length === 0) return;
// Set levels for sequence nodes // Set levels for all nodes in the sequence
sequence.forEach((node) => { sequence.forEach((node) => {
node.level = level + 1; 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); const indexNode = state.nodeMap.get(indexEvent.id);
if (indexNode && sequence[0]) { if (indexNode && sequence[0]) {
state.links.push({ state.links.push({
@ -124,7 +216,7 @@ export function processSequence(
}); });
} }
// Create sequential links // Create sequential links between content nodes
for (let i = 0; i < sequence.length - 1; i++) { for (let i = 0; i < sequence.length - 1; i++) {
const currentNode = sequence[i]; const currentNode = sequence[i];
const nextNode = sequence[i + 1]; const nextNode = sequence[i + 1];
@ -135,16 +227,27 @@ export function processSequence(
isSequential: true, 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]; const lastNode = sequence[sequence.length - 1];
if (lastNode?.isContainer) { if (lastNode?.isContainer) {
processNestedIndex(lastNode, level + 1, state, maxLevel); 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( export function processNestedIndex(
node: NetworkNode, node: NetworkNode,
level: number, level: number,
@ -159,6 +262,14 @@ export function processNestedIndex(
} }
} }
/**
* Processes an index event and its referenced content
*
* @param indexEvent - The index event to process
* @param level - Current hierarchy level
* @param state - Current graph state
* @param maxLevel - Maximum hierarchy level to process
*/
export function processIndexEvent( export function processIndexEvent(
indexEvent: NDKEvent, indexEvent: NDKEvent,
level: number, level: number,
@ -167,6 +278,7 @@ export function processIndexEvent(
): void { ): void {
if (level >= maxLevel) return; if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
const sequence = indexEvent const sequence = indexEvent
.getMatchingTags("a") .getMatchingTags("a")
.map((tag) => extractEventIdFromATag(tag)) .map((tag) => extractEventIdFromATag(tag))
@ -177,19 +289,53 @@ export function processIndexEvent(
processSequence(sequence, indexEvent, level, state, maxLevel); 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( export function generateGraph(
events: NDKEvent[], events: NDKEvent[],
maxLevel: number maxLevel: number
): GraphData { ): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel });
// Initialize the graph state
const state = initializeGraphState(events); const state = initializeGraphState(events);
// Process root indices // Find root index events (those not referenced by other events)
events const rootIndices = events.filter(
.filter((e) => e.kind === 30040 && e.id && !state.referencedIds.has(e.id)) (e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id)
.forEach((rootIndex) => processIndexEvent(rootIndex, 0, state, maxLevel)); );
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()), nodes: Array.from(state.nodeMap.values()),
links: state.links, 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 @@
</script> </script>
{#snippet sectionHeading(title: string, depth: number)} {#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0} {@const headingLevel = Math.min(depth + 1, 6)}
<h1 class='h-leather'>
{title} <!-- TODO: Handle floating titles. -->
</h1> <svelte:element this={`h${headingLevel}`} class='h-leather'>
{:else if depth === 1} {title}
<h2 class='h-leather'> </svelte:element>
{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}
{/snippet} {/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} {#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)}
{#if publicationType === 'novel'} <section class='whitespace-normal publication-leather'>
<P class='whitespace-normal' firstupper={isSectionStart}> {@html content}
{@html content} </section>
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{/if}
{/snippet} {/snippet}

8
src/routes/+layout.svelte

@ -3,6 +3,8 @@
import Navigation from "$lib/components/Navigation.svelte"; import Navigation from "$lib/components/Navigation.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
// Compute viewport height. // Compute viewport height.
$: displayHeight = window.innerHeight; $: displayHeight = window.innerHeight;
@ -42,5 +44,11 @@
<div class={'leather min-h-full w-full flex flex-col items-center'}> <div class={'leather min-h-full w-full flex flex-col items-center'}>
<Navigation class='sticky top-0' /> <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 /> <slot />
</div> </div>

32
src/routes/publication/+page.svelte

@ -1,26 +1,22 @@
<script lang="ts"> <script lang="ts">
import Article from "$lib/components/Publication.svelte"; import Publication from "$lib/components/Publication.svelte";
import { TextPlaceholder } from "flowbite-svelte"; import { TextPlaceholder } from "flowbite-svelte";
import type { PageData } from "./$types"; import type { PageProps } from "./$types";
import { onDestroy } from "svelte"; 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 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 let { data }: PageProps = $props();
interface ExtendedPageData extends PageData {
waitable: Promise<any>;
publicationType: string;
indexEvent: NDKEvent;
parser: any;
}
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 // Get publication metadata for OpenGraph tags
let title = $derived(data.indexEvent?.getMatchingTags('title')[0]?.[1] || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || 'Alexandria Publication'); 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 // Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic. // If image unavailable, use the Alexandria default pic.
@ -58,10 +54,10 @@
{#await data.waitable} {#await data.waitable}
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" /> <TextPlaceholder divClass='skeleton-leather w-full' size="xxl" />
{:then} {:then}
<Article <Publication
rootId={data.parser.getRootIndexId()} rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType} publicationType={data.publicationType}
indexEvent={data.indexEvent} indexEvent={data.indexEvent}
/> />
{/await} {/await}
</main> </main>

3
src/routes/publication/+page.ts

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

128
src/routes/visualize/+page.svelte

@ -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"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import EventNetwork from "$lib/navigator/EventNetwork/index.svelte"; import EventNetwork from "$lib/navigator/EventNetwork/index.svelte";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils"; 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 { networkFetchLimit } from "$lib/state";
import { fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import { CogSolid } from "flowbite-svelte-icons"; 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 events: NDKEvent[] = [];
let loading = true; let loading = true;
let error: string | null = null; let error: string | null = null;
// panel visibility
let showSettings = false; 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() { async function fetchEvents() {
debug("Fetching events with limit:", $networkFetchLimit);
try { try {
loading = true; loading = true;
error = null; 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( const indexEvents = await $ndkInstance.fetchEvents(
{ kinds: [30040], limit: $networkFetchLimit }, {
kinds: [INDEX_EVENT_KIND],
limit: $networkFetchLimit
},
{ {
groupable: true, groupable: true,
skipVerification: false, skipVerification: false,
skipValidation: 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); 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>(); const contentEventIds = new Set<string>();
validIndexEvents.forEach((event) => { validIndexEvents.forEach((event) => {
event.getMatchingTags("a").forEach((tag) => { const aTags = event.getMatchingTags("a");
let eventId = tag[3]; debug(`Event ${event.id} has ${aTags.length} a-tags`);
contentEventIds.add(eventId);
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( const contentEvents = await $ndkInstance.fetchEvents(
{ {
kinds: [30041, 30818], kinds: CONTENT_EVENT_KINDS,
ids: Array.from(contentEventIds), ids: Array.from(contentEventIds),
}, },
{ {
@ -58,9 +94,11 @@
skipValidation: false, 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)]; events = [...Array.from(validIndexEvents), ...Array.from(contentEvents)];
debug("Total events for visualization:", events.length);
} catch (e) { } catch (e) {
console.error("Error fetching events:", e); console.error("Error fetching events:", e);
error = e instanceof Error ? e.message : String(e); error = e instanceof Error ? e.message : String(e);
@ -69,55 +107,20 @@
} }
} }
function handleLimitUpdate() {
fetchEvents();
}
// Fetch events when component mounts
onMount(() => { onMount(() => {
debug("Component mounted");
fetchEvents(); fetchEvents();
}); });
</script> </script>
<div class="leather w-full p-4 relative"> <div class="leather w-full p-4 relative">
<div class="flex items-center gap-4 mb-4"> <!-- Header with title and settings button -->
<h1 class="h-leather text-2xl font-bold">Publication Network</h1> <div class="flex items-center mb-4">
<h1 class="h-leather">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}
</div> </div>
<!-- Loading spinner -->
{#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}
{#if loading} {#if loading}
<div class="flex justify-center items-center h-64"> <div class="flex justify-center items-center h-64">
<div role="status"> <div role="status">
@ -140,12 +143,14 @@
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</div> </div>
</div> </div>
<!-- Error message -->
{:else if error} {:else if error}
<div <div
class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" 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" 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 <button
type="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" 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 @@
Retry Retry
</button> </button>
</div> </div>
<!-- Network visualization -->
{:else} {:else}
<EventNetwork {events} /> <!-- Event network visualization -->
<div class="mt-8 prose dark:prose-invert max-w-none"></div> <EventNetwork {events} onupdate={fetchEvents} />
{/if} {/if}
</div> </div>

90
src/styles/visualize.css

@ -1,6 +1,7 @@
@layer components { @layer components {
/* Legend styles - specific to visualization */
.legend-list { .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 { .legend-item {
@ -20,7 +21,92 @@
background-color: #d6c1a8; 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 { .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 @@
/**
* 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