diff --git a/.cursor/rules/alexandria.mdc b/.cursor/rules/alexandria.mdc new file mode 100644 index 0000000..4fe24b0 --- /dev/null +++ b/.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. + + diff --git a/deno.lock b/deno.lock index f113237..c97022c 100644 --- a/deno.lock +++ b/deno.lock @@ -2887,10 +2887,11 @@ "npm:@sveltejs/adapter-auto@3", "npm:@sveltejs/adapter-node@^5.2.12", "npm:@sveltejs/adapter-static@3", - "npm:@sveltejs/kit@2", + "npm:@sveltejs/kit@^2.16.0", "npm:@sveltejs/vite-plugin-svelte@4", "npm:@tailwindcss/forms@0.5", "npm:@tailwindcss/typography@0.5", + "npm:@types/d3@^7.4.3", "npm:@types/he@1.2", "npm:@types/node@22", "npm:asciidoctor@3.0", diff --git a/package-lock.json b/package-lock.json index d447e7a..59e5f20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@sveltejs/adapter-static": "3.x", "@sveltejs/kit": "2.x", "@sveltejs/vite-plugin-svelte": "4.x", + "@types/d3": "^7.4.3", "@types/he": "1.2.x", "@types/node": "22.x", "autoprefixer": "10.x", @@ -1531,6 +1532,290 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1538,6 +1823,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/he": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", diff --git a/package.json b/package.json index 2323efa..b0f1151 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "@sveltejs/adapter-auto": "3.x", "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-static": "3.x", - "@sveltejs/kit": "2.x", + "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "4.x", + "@types/d3": "^7.4.3", "@types/he": "1.2.x", "@types/node": "22.x", "autoprefixer": "10.x", diff --git a/src/app.css b/src/app.css index f7e32e3..c730a8d 100644 --- a/src/app.css +++ b/src/app.css @@ -196,10 +196,7 @@ @apply bg-gray-200 dark:bg-gray-700; } - /* Unordered list */ - .ul-leather li a { - @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; - } + /* Network visualization */ .network-link-leather { @apply stroke-gray-400 fill-gray-400; } @@ -211,13 +208,161 @@ } } +/* Utilities can be applied via the @apply directive. */ +@layer utilities { + .h-leather { + @apply text-gray-800 dark:text-gray-300 pt-4; + } + + .h1-leather { + @apply text-4xl font-bold; + } + + .h2-leather { + @apply text-3xl font-bold; + } + + .h3-leather { + @apply text-2xl font-bold; + } + + .h4-leather { + @apply text-xl font-bold; + } + + .h5-leather { + @apply text-lg font-semibold; + } + + .h6-leather { + @apply text-base font-semibold; + } + + /* Lists */ + .ol-leather li a, + .ul-leather li a { + @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; + } + + .link { + @apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500; + } +} + @layer components { + /* Legend */ .leather-legend { - @apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow - border border-gray-200 dark:border-gray-800; + @apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded; + @apply shadow-none text-primary-1000 border border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; + @apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; } + + /* Tooltip */ .tooltip-leather { - @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; + @apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 + text-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700 + transition-colors duration-200; + max-width: 400px; + z-index: 1000; + } + + .leather-legend button { + @apply dark:text-white; + } + + /* Rendered publication content */ + .publication-leather { + @apply flex flex-col space-y-4; + + h1, h2, h3, h4, h5, h6 { + @apply h-leather; + } + + h1 { + @apply h1-leather; + } + + h2 { + @apply h2-leather; + } + + h3 { + @apply h3-leather; + } + + h4 { + @apply h4-leather; + } + + h5 { + @apply h5-leather; + } + + h6 { + @apply h6-leather; + } + + div { + @apply flex flex-col space-y-4; + } + + .olist { + @apply flex flex-col space-y-4; + + ol { + @apply ol-leather list-decimal px-6 flex flex-col space-y-2; + + li { + .paragraph { + @apply py-2; + } + } + } + } + + .ulist { + @apply flex flex-col space-y-4; + + ul { + @apply ul-leather list-disc px-6 flex flex-col space-y-2; + + li { + .paragraph { + @apply py-2; + } + } + } + } + + a { + @apply link; + } + + .imageblock { + @apply flex flex-col items-center; + + .title { + @apply text-sm text-center; + } + } + + .stemblock { + @apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg; + } + + table { + @apply w-full overflow-x-auto; + + caption { + @apply text-sm; + } + + thead, tbody { + th, td { + @apply border border-gray-200 dark:border-gray-700; + } + } + } } } diff --git a/src/app.d.ts b/src/app.d.ts index 731967b..1b260cf 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,14 +1,18 @@ // See https://kit.svelte.dev/docs/types#app + +import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; +import Pharos from "./lib/parser.ts"; + // for information about these interfaces declare global { namespace App { // interface Error {} // interface Locals {} interface PageData { - ndk?: NDK; - parser?: Pharos; waitable?: Promise; publicationType?: string; + indexEvent?: NDKEvent; + url?: URL; } // interface Platform {} } diff --git a/src/lib/components/EventLimitControl.svelte b/src/lib/components/EventLimitControl.svelte index aafd91f..d8c28be 100644 --- a/src/lib/components/EventLimitControl.svelte +++ b/src/lib/components/EventLimitControl.svelte @@ -30,7 +30,7 @@
- diff --git a/src/lib/components/EventRenderLevelLimit.svelte b/src/lib/components/EventRenderLevelLimit.svelte index bbfdc87..3a7d8a8 100644 --- a/src/lib/components/EventRenderLevelLimit.svelte +++ b/src/lib/components/EventRenderLevelLimit.svelte @@ -29,16 +29,16 @@
- - + - import Preview from "./Preview.svelte"; - import { pharosInstance } from "$lib/parser"; + import { + Button, + Sidebar, + SidebarGroup, + SidebarItem, + SidebarWrapper, + Skeleton, + TextPlaceholder, + Tooltip, + } from "flowbite-svelte"; + import { getContext, onMount } from "svelte"; + import { BookOutline } from "flowbite-svelte-icons"; + import { page } from "$app/state"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; + import PublicationSection from "./PublicationSection.svelte"; + import type { PublicationTree } from "$lib/data_structures/publication_tree"; import Details from "$components/util/Details.svelte"; import { publicationColumnVisibility } from "$lib/stores"; - let { rootId, publicationType, indexEvent } = $props<{ - rootId: string, + let { rootAddress, publicationType, indexEvent } = $props<{ + rootAddress: string, publicationType: string, indexEvent: NDKEvent }>(); - if (rootId !== $pharosInstance.getRootIndexId()) { - console.error("Root ID does not match parser root index ID"); + const publicationTree = getContext('publicationTree') as PublicationTree; + + // #region Loading + + // TODO: Test load handling. + + let leaves = $state([]); + let isLoading = $state(false); + let lastElementRef = $state(null); + + let observer: IntersectionObserver; + + async function loadMore(count: number) { + isLoading = true; + + for (let i = 0; i < count; i++) { + const nextItem = await publicationTree.next(); + if (leaves.includes(nextItem.value) || nextItem.done) { + isLoading = false; + return; + } + leaves.push(nextItem.value); + } + + isLoading = false; + } + + function setLastElementRef(el: HTMLElement, i: number) { + if (i === leaves.length - 1) { + lastElementRef = el; + } } + $effect(() => { + if (!lastElementRef) { + return; + } + + observer.observe(lastElementRef!); + return () => observer.unobserve(lastElementRef!); + }); + + // #endregion let currentBlog: null|string = $state(null); @@ -34,6 +86,80 @@ $publicationColumnVisibility.inner = true; currentBlog = rootId; } + + // #region ToC + + + function scrollToElementWithOffset() { + const hash = window.location.hash; + if (hash) { + const targetElement = document.querySelector(hash); + if (targetElement) { + const headerOffset = 80; + const elementPosition = targetElement.getBoundingClientRect().top; + const offsetPosition = elementPosition + window.scrollY - headerOffset; + + window.scrollTo({ + top: offsetPosition, + behavior: "auto", + }); + } + } + } + + /** + * Hides the table of contents sidebar when the window shrinks below a certain size. This + * prevents the sidebar from occluding the article content. + */ + function setTocVisibilityOnResize() { + showToc = window.innerWidth >= tocBreakpoint; + showTocButton = window.innerWidth < tocBreakpoint; + } + + /** + * Hides the table of contents sidebar when the user clicks outside of it. + */ + function hideTocOnClick(ev: MouseEvent) { + const target = ev.target as HTMLElement; + + if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) { + return; + } + + if (showToc) { + showToc = false; + } + } + + // #endregion + + onMount(() => { + // Always check whether the TOC sidebar should be visible. + setTocVisibilityOnResize(); + window.addEventListener("hashchange", scrollToElementWithOffset); + // Also handle the case where the user lands on the page with a hash in the URL + scrollToElementWithOffset(); + window.addEventListener("resize", setTocVisibilityOnResize); + window.addEventListener("click", hideTocOnClick); + + // Set up the intersection observer. + observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !isLoading) { + loadMore(4); + } + }); + }, { threshold: 0.5 }); + loadMore(8); + + return () => { + window.removeEventListener("hashchange", scrollToElementWithOffset); + window.removeEventListener("resize", setTocVisibilityOnResize); + window.removeEventListener("click", hideTocOnClick); + + observer.disconnect(); + }; + }); {#if $publicationColumnVisibility.details} @@ -44,24 +170,63 @@
{/if} + +{#if showTocButton && !showToc} + +{/if} + + + {#if isDefaultVisible()} -
- - +
+ {#each leaves as leaf, i} + setLastElementRef(el, i)} + /> + {/each} +
{/if} {#if currentBlog !== null && $publicationColumnVisibility.inner } {#key currentBlog } -
- -
+
+ Todo... +
{/key} {/if} diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte new file mode 100644 index 0000000..5eb4f24 --- /dev/null +++ b/src/lib/components/PublicationSection.svelte @@ -0,0 +1,108 @@ + + + +
+ {#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} + + {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} + + {#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} +
diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index fe88aac..024037f 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -1,30 +1,75 @@ + + -
-

Legend

-
    -
  • -
    - - - I -
    - Index events (kind 30040) - Each with a unique pastel color -
  • -
  • -
    - - C -
    - Content events (kinds 30041, 30818) - Publication sections -
  • -
  • - - - - Arrows indicate reading/sequence order -
  • -
+
+
+

Legend

+ +
+ + {#if expanded} +
    + +
  • +
    + + I + +
    + Index events (kind 30040) - Each with a unique pastel color +
  • + + +
  • +
    + + C + +
    + Content events (kinds 30041, 30818) - Publication sections +
  • + + +
  • + + + + Arrows indicate reading/sequence order +
  • +
+ {/if}
diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index cb6f779..4aedc8e 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -1,20 +1,33 @@ + + +
+
+

Settings

+ +
+ + {#if expanded} +
+ + Showing {count} events from {$networkFetchLimit} headers + + + +
+ {/if} +
diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index 6a7fa61..7742475 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -1,40 +1,84 @@ - + -
-
+
+ {#if hasError} +
+

Error

+

{errorMessage}

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

- {title} -

- {:else if depth === 1} -

- {title} -

- {:else if depth === 2} -

- {title} -

- {:else if depth === 3} -

- {title} -

- {:else if depth === 4} -
- {title} -
- {:else} -
- {title} -
- {/if} + {@const headingLevel = Math.min(depth + 1, 6)} + + + + {title} + {/snippet} {#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} - {#if publicationType === 'novel'} -

- {@html content} -

- {:else} -

- {@html content} -

- {/if} +
+ {@html content} +
{/snippet} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4cac70e..b7593c3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,8 @@ import Navigation from "$lib/components/Navigation.svelte"; import { onMount } from "svelte"; import { page } from "$app/stores"; + import { Alert } from "flowbite-svelte"; + import { HammerSolid } from "flowbite-svelte-icons"; // Compute viewport height. $: displayHeight = window.innerHeight; @@ -42,5 +44,11 @@
+ + + + Pardon our dust! The publication view is currently using an experimental loader, and may be unstable. + +
diff --git a/src/routes/publication/+page.svelte b/src/routes/publication/+page.svelte index 486a655..f0695e2 100644 --- a/src/routes/publication/+page.svelte +++ b/src/routes/publication/+page.svelte @@ -1,26 +1,22 @@
-
-

Publication Network

- - - {#if !loading && !error} - - {/if} + +
+

Publication Network

- - {#if !loading && !error && showSettings} - -
-
-

- Visualization Settings -

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