diff --git a/doc/settings_panel.org b/doc/settings_panel.org new file mode 100644 index 0000000..3730929 --- /dev/null +++ b/doc/settings_panel.org @@ -0,0 +1,124 @@ +* Settings Panel Documentation + +** Overview +The settings panel controls how events are fetched and displayed in the visualization. It has several sections that work together to create an efficient and user-friendly experience. + +** Event Types Configuration + +*** Purpose +Controls which types of Nostr events are fetched and how many of each type. + +*** Key Event Types +- *Kind 30040* (Index Events): Publication indices +- *Kind 30041* (Content Events): Publication content +- *Kind 30818* (Content Events): Alternative content format +- *Kind 30023* (Content Events): Alternative content format + +*** How Limits Work +Each event kind has a limit number that controls different things: + +**** For Kind 0 (Profiles) +- Limit controls how many profiles to fetch from discovered pubkeys +- These profiles are used for: + - Displaying names instead of pubkeys + - Showing profile pictures in tooltips + - When "People" tag anchors are selected, this limit controls how many people anchors to display + +**** For Kind 3 (Follow Lists) +- =limit = 1=: Only fetch the current user's follow list +- =limit > 1=: Fetch the user's follow list PLUS (limit-1) follow lists from people they follow +- The depth selector controls traversal: + - =Direct= (0): Just the immediate follows + - =2 degrees= (1): Follows of follows + - =3 degrees= (2): Three levels deep + +**** For Kind 30040/30041/30818 +- Limit controls maximum number of these events to fetch + +** Tag Anchors + +*** What Are Tag Anchors? +Tag anchors are special nodes in the graph that act as gravity points for events sharing common attributes. They help organize the visualization by grouping related content. + +*** Tag Types Available +- *Hashtags* (t): Groups events by hashtag +- *Authors*: Groups events by author +- *People* (p): Shows people from follow lists as anchor points +- *Event References* (e): Groups events that reference each other +- *Titles*: Groups events by title +- *Summaries*: Groups events by summary + +*** How People Tag Anchors Work +When "People" is selected as the tag type: + +1. The system looks at all loaded follow lists (kind 3 events) +2. Extracts all pubkeys (people) from those follow lists +3. Creates tag anchors for those people (up to the kind 0 limit) +4. Connects each person anchor to: + - Events they authored (where pubkey matches) + - Events where they're mentioned in "p" tags + +*** Display Limiting and Auto-Disable +- Tag anchors are created for ALL discovered tags +- But only displayed up to the configured limit +- When > 20 tag anchors exist, they're all auto-disabled +- Users can selectively enable specific anchors +- The legend becomes scrollable for many anchors + +*** "Only show people with publications" Checkbox +When checked (default): +- Only shows people who have events in the current visualization + +When unchecked: +- Shows ALL people from follow lists, even if they have no events displayed +- Useful for seeing your complete social graph + +** Display Limits Section + +*** Max Publication Indices (30040) +Controls display filtering for publication indices after they're fetched. + +*** Max Events per Index +Limits how many content events to show per publication index. + +*** Fetch if not found +When enabled, automatically fetches missing referenced events. + +** Graph Traversal Section + +*** Search through already fetched +When enabled, tag expansion only searches through events already loaded (more efficient). + +*** Append mode +When enabled, new fetches add to the existing graph instead of replacing it. + +** Current Implementation Questions + +1. *Profile Fetching*: Should we fetch profiles for: + - Only event authors? + - All pubkeys in follow lists? + - All pubkeys mentioned anywhere? + +2. *People Tag Anchors*: Should they connect to: + - Only events where the person is tagged with "p"? + - Events they authored? + - Both? + +3. *Display Limits*: Should limits control: + - How many to fetch from relays? + - How many to display (fetch all, display subset)? + - Both with separate controls? + +4. *Auto-disable Threshold*: Is 20 the right number for auto-disabling tag anchors? + +** Ideal User Flow + +1. User loads the visualization +2. Their follow list is fetched (kind 3, limit 1) +3. Profiles are fetched for people they follow (kind 0, respecting limit) +4. Publications are fetched (kind 30040/30041/30818) +5. User enables "People" tag anchors +6. Sees their follows as anchor points +7. Can see which follows have authored content +8. Can selectively enable/disable specific people +9. Can increase limits to see more content/people diff --git a/package-lock.json b/package-lock.json index f256933..f171292 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,15 @@ { "name": "alexandria", - "version": "0.0.6", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "alexandria", - "version": "0.0.6", + "version": "0.0.2", "dependencies": { + "@noble/curves": "^1.9.4", + "@noble/hashes": "^1.8.0", "@nostr-dev-kit/ndk": "^2.14.32", "@nostr-dev-kit/ndk-cache-dexie": "2.6.x", "@popperjs/core": "2.11.x", @@ -24,33 +26,34 @@ "qrcode": "^1.5.4" }, "devDependencies": { - "@playwright/test": "^1.50.1", - "@sveltejs/adapter-auto": "3.x", + "@playwright/test": "^1.54.1", + "@sveltejs/adapter-auto": "^6.0.1", "@sveltejs/adapter-node": "^5.2.13", "@sveltejs/adapter-static": "3.x", "@sveltejs/kit": "^2.25.0", - "@sveltejs/vite-plugin-svelte": "5.x", + "@sveltejs/vite-plugin-svelte": "^6.1.0", "@types/d3": "^7.4.3", "@types/he": "1.2.x", - "@types/node": "22.x", + "@types/mathjax": "^0.0.40", + "@types/node": "^24.0.15", "@types/qrcode": "^1.5.5", - "autoprefixer": "10.x", - "eslint-plugin-svelte": "2.x", + "autoprefixer": "^10.4.21", + "eslint-plugin-svelte": "^3.11.0", "flowbite": "2.x", "flowbite-svelte": "0.48.x", "flowbite-svelte-icons": "2.1.x", "playwright": "^1.50.1", - "postcss": "8.x", + "postcss": "^8.5.6", "postcss-load-config": "6.x", - "prettier": "3.x", - "prettier-plugin-svelte": "3.x", - "svelte": "5.x", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.36.8", "svelte-check": "4.x", - "tailwind-merge": "^3.3.0", - "tailwindcss": "3.x", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^3.4.17", "tslib": "2.8.x", - "typescript": "5.8.x", - "vite": "6.x", + "typescript": "^5.8.3", + "vite": "^7.0.5", "vitest": "^3.1.3" } }, @@ -585,6 +588,7 @@ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -603,6 +607,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -615,6 +620,7 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -625,6 +631,7 @@ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, + "license": "Apache-2.0", "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.6", @@ -640,6 +647,7 @@ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -650,6 +658,7 @@ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "dev": true, + "license": "Apache-2.0", "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" @@ -663,6 +672,7 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "ajv": "^6.12.4", @@ -687,6 +697,7 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -700,6 +711,7 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -710,6 +722,7 @@ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "dev": true, + "license": "Apache-2.0", "peer": true, "dependencies": { "@eslint/core": "^0.15.1", @@ -749,6 +762,7 @@ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=18.18.0" @@ -759,6 +773,7 @@ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, + "license": "Apache-2.0", "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", @@ -773,6 +788,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=18.18" @@ -787,6 +803,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=12.22" @@ -801,6 +818,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=18.18" @@ -1541,13 +1559,11 @@ } }, "node_modules/@sveltejs/adapter-auto": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.3.1.tgz", - "integrity": "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-6.0.1.tgz", + "integrity": "sha512-mcWud3pYGPWM2Pphdj8G9Qiq24nZ8L4LB7coCUckUEy5Y7wOWGJ/enaZ4AtJTcSm5dNK1rIkBRoqt+ae4zlxcQ==", "dev": true, - "dependencies": { - "import-meta-resolve": "^4.1.0" - }, + "license": "MIT", "peerDependencies": { "@sveltejs/kit": "^2.0.0" } @@ -1608,41 +1624,43 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.1.0.tgz", + "integrity": "sha512-+U6lz1wvGEG/BvQyL4z/flyNdQ9xDNv5vrh+vWBWTHaebqT0c9RNggpZTo/XSPoHsSCWBlYaTlRX8pZ9GATXCw==", "dev": true, + "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0-next.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", - "vitefu": "^1.0.6" + "vitefu": "^1.1.1" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { "svelte": "^5.0.0", - "vite": "^6.0.0" + "vite": "^6.3.0 || ^7.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.0.tgz", + "integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==", "dev": true, + "license": "MIT", "dependencies": { - "debug": "^4.3.7" + "debug": "^4.4.1" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", - "vite": "^6.0.0" + "vite": "^6.3.0 || ^7.0.0" } }, "node_modules/@tailwindcss/forms": { @@ -1967,15 +1985,24 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, + "license": "MIT", "peer": true }, + "node_modules/@types/mathjax": { + "version": "0.0.40", + "resolved": "https://registry.npmjs.org/@types/mathjax/-/mathjax-0.0.40.tgz", + "integrity": "sha512-rHusx08LCg92WJxrsM3SPjvLTSvK5C+gealtSuhKbEOcUZfWlwigaFoPLf6Dfxhg4oryN5qP9Sj7zOQ4HYXINw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "22.16.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.4.tgz", - "integrity": "sha512-PYRhNtZdm2wH/NT2k/oAJ6/f2VD2N2Dag0lGlx2vWgMSJXGNmlce5MiTQzoWAiIJtso30mjnfQCOKVH+kAQC/g==", + "version": "24.0.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.15.tgz", + "integrity": "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.8.0" } }, "node_modules/@types/qrcode": { @@ -2138,6 +2165,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2147,6 +2175,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", @@ -2234,6 +2263,7 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, + "license": "Python-2.0", "peer": true }, "node_modules/aria-query": { @@ -2461,6 +2491,7 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=6" @@ -2559,18 +2590,39 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 8.10.0" }, "funding": { "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/cliui": { @@ -3077,6 +3129,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/deepmerge": { @@ -3262,6 +3315,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=10" @@ -3275,6 +3329,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", @@ -3331,47 +3386,32 @@ } } }, - "node_modules/eslint-compat-utils": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", - "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", - "dev": true, - "dependencies": { - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, "node_modules/eslint-plugin-svelte": { - "version": "2.46.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.1.tgz", - "integrity": "sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.11.0.tgz", + "integrity": "sha512-KliWlkieHyEa65aQIkRwUFfHzT5Cn4u3BQQsu3KlkJOs7c1u7ryn84EWaOjEzilbKgttT4OfBURA8Uc4JBSQIw==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@jridgewell/sourcemap-codec": "^1.4.15", - "eslint-compat-utils": "^0.5.1", + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", - "known-css-properties": "^0.35.0", - "postcss": "^8.4.38", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", - "postcss-safe-parser": "^6.0.0", - "postcss-selector-parser": "^6.1.0", - "semver": "^7.6.2", - "svelte-eslint-parser": "^0.43.0" + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.3.0" }, "engines": { - "node": "^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0", + "eslint": "^8.57.1 || ^9.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -3380,6 +3420,19 @@ } } }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-plugin-svelte/node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -3418,17 +3471,14 @@ } } }, - "node_modules/eslint-plugin-svelte/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "node_modules/eslint-plugin-svelte/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, + "license": "ISC", "engines": { - "node": ">=4" + "node": ">= 6" } }, "node_modules/eslint-scope": { @@ -3436,7 +3486,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "peer": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -3453,7 +3503,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "peer": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3472,7 +3522,7 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "peer": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -3490,6 +3540,7 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, + "license": "BSD-3-Clause", "peer": true, "dependencies": { "estraverse": "^5.1.0" @@ -3512,6 +3563,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -3524,6 +3576,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -3539,6 +3592,7 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -3557,6 +3611,7 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/fast-glob": { @@ -3590,6 +3645,7 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/fast-levenshtein": { @@ -3597,6 +3653,7 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/fastq": { @@ -3626,6 +3683,7 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "flat-cache": "^4.0.0" @@ -3677,6 +3735,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "locate-path": "^6.0.0", @@ -3694,6 +3753,7 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "flatted": "^3.2.9", @@ -3708,6 +3768,7 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, + "license": "ISC", "peer": true }, "node_modules/flowbite": { @@ -3943,6 +4004,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=18" @@ -4058,6 +4120,7 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">= 4" @@ -4068,6 +4131,7 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "parent-module": "^1.0.0", @@ -4080,21 +4144,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=0.8.19" @@ -4300,6 +4355,7 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "argparse": "^2.0.1" @@ -4313,6 +4369,7 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/json-schema-traverse": { @@ -4320,6 +4377,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/json-stable-stringify-without-jsonify": { @@ -4327,6 +4385,7 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/jstransformer": { @@ -4343,6 +4402,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "json-buffer": "3.0.1" @@ -4358,16 +4418,18 @@ } }, "node_modules/known-css-properties": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", - "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", - "dev": true + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "prelude-ls": "^1.2.1", @@ -4423,6 +4485,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "p-locate": "^5.0.0" @@ -4598,6 +4661,7 @@ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/neo-async": { @@ -4774,6 +4838,7 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "deep-is": "^0.1.3", @@ -4792,6 +4857,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "yocto-queue": "^0.1.0" @@ -4808,6 +4874,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "p-limit": "^3.0.2" @@ -4837,6 +4904,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "callsites": "^3.0.0" @@ -5112,19 +5180,30 @@ } }, "node_modules/postcss-safe-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", - "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "node": ">=18.0" }, "peerDependencies": { - "postcss": "^8.3.3" + "postcss": "^8.4.31" } }, "node_modules/postcss-scss": { @@ -5146,6 +5225,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "engines": { "node": ">=12.0" }, @@ -5175,6 +5255,7 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">= 0.8.0" @@ -5330,6 +5411,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=6" @@ -5488,16 +5570,27 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, "engines": { - "node": ">= 14.18.0" + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/require-directory": { @@ -5537,6 +5630,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=4" @@ -5644,6 +5738,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5806,6 +5901,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=8" @@ -5966,76 +6062,77 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-eslint-parser": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", - "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==", + "node_modules/svelte-check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "postcss": "^8.4.39", - "postcss-scss": "^4.0.9" + "readdirp": "^4.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 14.16.0" }, "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } + "url": "https://paulmillr.com/funding/" } }, - "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/svelte-check/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 14.18.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/svelte-eslint-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.3.0.tgz", + "integrity": "sha512-VCgMHKV7UtOGcGLGNFSbmdm6kEKjtzo5nnpGU/mnx4OsFY6bZ7QwRF5DUx+Hokw5Lvdyo8dpk8B1m8mliomrNg==", "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } } }, - "node_modules/svelte-eslint-parser/node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, + "license": "MIT", "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=4" } }, "node_modules/svelte/node_modules/is-reference": { @@ -6184,51 +6281,6 @@ "node": ">=14.0.0" } }, - "node_modules/tailwindcss/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tailwindcss/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tailwindcss/node_modules/postcss-load-config": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", @@ -6275,28 +6327,6 @@ "node": ">=4" } }, - "node_modules/tailwindcss/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6417,6 +6447,7 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "prelude-ls": "^1.2.1" @@ -6456,10 +6487,11 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", @@ -6512,6 +6544,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "peer": true, "dependencies": { "punycode": "^2.1.0" @@ -6523,23 +6556,24 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz", + "integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", + "fdir": "^6.4.6", "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -6548,14 +6582,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -6780,6 +6814,7 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=0.10.0" @@ -6837,12 +6872,15 @@ } }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" } }, "node_modules/yargs": { @@ -6875,6 +6913,7 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=10" diff --git a/src/app.css b/src/app.css index 7a55d9d..4e2c9b2 100644 --- a/src/app.css +++ b/src/app.css @@ -201,6 +201,20 @@ .network-node-content { @apply fill-primary-100; } + + /* Person link colors */ + .person-link-signed { + @apply stroke-green-500; + } + + .person-link-referenced { + @apply stroke-blue-400; + } + + /* Person anchor node */ + .person-anchor-node { + @apply fill-green-400 stroke-green-600; + } } /* Utilities can be applied via the @apply directive. */ diff --git a/src/lib/components/EventKindFilter.svelte b/src/lib/components/EventKindFilter.svelte new file mode 100644 index 0000000..5f7b992 --- /dev/null +++ b/src/lib/components/EventKindFilter.svelte @@ -0,0 +1,204 @@ + + +
+
+ {#each $visualizationConfig.eventConfigs as ec} + {@const isEnabled = ec.enabled !== false} + {@const isLoaded = (eventCounts[ec.kind] || 0) > 0} + {@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'} + + + + {/each} + + {#if !showAddInput} + + {/if} + + +
+ + {#if showAddInput} +
+ { + const value = (e.target as HTMLInputElement).value; + validateKind(value); + }} + /> + + +
+ {#if inputError} +

+ {inputError} +

+ {/if} + {/if} + +
+

+ + Green border = Events loaded +

+

+ + Red border = Not loaded (click Reload to fetch) +

+
+
+ + \ No newline at end of file diff --git a/src/lib/components/EventLimitControl.svelte b/src/lib/components/EventLimitControl.svelte deleted file mode 100644 index 9a32a56..0000000 --- a/src/lib/components/EventLimitControl.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - -
- - - -
diff --git a/src/lib/components/EventTypeConfig.svelte b/src/lib/components/EventTypeConfig.svelte new file mode 100644 index 0000000..4d7bfc7 --- /dev/null +++ b/src/lib/components/EventTypeConfig.svelte @@ -0,0 +1,274 @@ + + +
+ + Showing {Object.values(eventCounts).reduce((a: any, b: any) => a + b, 0)} of {Object.values(eventCounts).reduce((a: any, b: any) => a + b, 0)} events + + + +
+ {#each $visualizationConfig.eventConfigs as config} + {@const isLoaded = (eventCounts[config.kind] || 0) > 0} + {@const isDisabled = config.enabled === false} + {@const color = getEventKindColor(config.kind)} + {@const borderColor = isLoaded ? 'border-green-500' : 'border-red-500'} +
+ + + + + + {#if config.kind === 0} + handleLimitChange(config.kind, e.currentTarget.value)} + title="Max profiles to display" + /> + + of {profileStats.totalFetched} fetched + + {:else} + + handleLimitChange(config.kind, e.currentTarget.value)} + title="Max to display" + disabled={(config.kind === 30041 || config.kind === 30818) && config.showAll} + /> + + + {#if config.kind === 30041 || config.kind === 30818} + + {/if} + {/if} + + + {#if config.kind === 30040} + Nested Levels: + handleNestedLevelsChange(e.currentTarget.value)} + title="Levels to traverse" + /> + {/if} + + + {#if config.kind === 3} + + {/if} + + + {#if config.kind !== 0 && isLoaded} + + ({eventCounts[config.kind]}) + + {:else if config.kind !== 0} + + (not loaded) + + {/if} +
+ {/each} +
+ + + {#if showAddInput} +
+ { + const validation = validateEventKind(e.currentTarget.value, existingKinds); + inputError = validation.error; + }} + /> + + +
+ {#if inputError} +

+ {inputError} +

+ {/if} + {:else} + + {/if} + + + + + +
+

+ + Green = Events loaded +

+

+ + Red = Not loaded (click Reload) +

+
+
\ No newline at end of file diff --git a/src/lib/components/util/ArticleNav.svelte b/src/lib/components/util/ArticleNav.svelte index 1ab1655..f2c986c 100644 --- a/src/lib/components/util/ArticleNav.svelte +++ b/src/lib/components/util/ArticleNav.svelte @@ -4,12 +4,14 @@ CaretLeftOutline, CloseOutline, GlobeOutline, + ChartOutline, } from "flowbite-svelte-icons"; import { Button } from "flowbite-svelte"; import { publicationColumnVisibility } from "$lib/stores"; import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; import { onDestroy, onMount } from "svelte"; + import { goto } from "$app/navigation"; let { publicationType, indexEvent } = $props<{ rootId: any; @@ -102,6 +104,11 @@ } } + function visualizePublication() { + const eventId = indexEvent.id; + goto(`/visualize?event=${eventId}`); + } + let unsubscribe: () => void; onMount(() => { window.addEventListener("scroll", handleScroll); @@ -186,6 +193,16 @@ {/if} + diff --git a/src/lib/consts.ts b/src/lib/consts.ts index ef41e0d..90afa53 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -2,7 +2,7 @@ export const wikiKind = 30818; export const indexKind = 30040; -export const zettelKinds = [30041, 30818]; +export const zettelKinds = [30041, 30818, 30023]; export const communityRelays = [ "wss://theforest.nostr1.com", @@ -29,18 +29,18 @@ export const secondaryRelays = [ export const anonymousRelays = [ "wss://freelay.sovbit.host", - "wss://thecitadel.nostr1.com" + "wss://thecitadel.nostr1.com", ]; export const lowbandwidthRelays = [ "wss://theforest.nostr1.com", "wss://thecitadel.nostr1.com", - "wss://aggr.nostr.land" + "wss://aggr.nostr.land", ]; export const localRelays: string[] = [ "wss://localhost:8080", - "wss://localhost:4869" + "wss://localhost:4869", ]; export enum FeedType { diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index b553cab..1c49fe6 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -1,14 +1,61 @@ - -
-
+

Legend

- +
{#if expanded} -
- -
  • -
    - - C - + +
    +
    tagControlsExpanded = !tagControlsExpanded}> +

    Tag Anchor Controls

    +
    + {#if tagControlsExpanded} + + {:else} + + {/if} +
    - Content events (kinds 30041, 30818) - Publication sections -
  • + + {#if tagControlsExpanded} +
    + +
    + + Show Tag Anchors +
    + + {#if showTagAnchors} + +
    + + + +
    + {/if} +
    + {/if} + - -
  • - - - - Arrows indicate reading/sequence order -
  • - + + {#if showTags && tagAnchors.length > 0} +
    +
    +

    Active Tag Anchors: {tagAnchors[0].type}

    +
    + {#if tagAnchorsExpanded} + + {:else} + + {/if} +
    +
    + + {#if tagAnchorsExpanded} + {@const sortedAnchors = tagSortMode === 'count' + ? [...tagAnchors].sort((a, b) => b.count - a.count) + : [...tagAnchors].sort((a, b) => a.label.localeCompare(b.label)) + } + {#if autoDisabledTags} +
    + Note: All {tagAnchors.length} tags were auto-disabled to prevent graph overload. Click individual tags below to enable them. +
    + {/if} + + +
    +
    + Sort by: + + +
    + + +
    + +
    + {#each sortedAnchors as anchor} + {@const tagId = `${anchor.type}-${anchor.label}`} + {@const isDisabled = disabledTags.has(tagId)} + + {/each} +
    + {/if} +
    + {/if} + + +
    +
    personVisualizerExpanded = !personVisualizerExpanded}> +

    Person Visualizer

    +
    + {#if personVisualizerExpanded} + + {:else} + + {/if} +
    +
    + + {#if personVisualizerExpanded} +
    + +
    +
    + + Show Person Nodes +
    + + {#if showPersonNodes} +
    + + +
    + {/if} +
    + + {#if showPersonNodes && personAnchors.length > 0} +
    +

    + {#if totalPersonCount > displayedPersonCount} + Displaying {displayedPersonCount} of {totalPersonCount} people found: + {:else} + {personAnchors.length} people found: + {/if} +

    + + +
    + +
    + {#each personAnchors as person} + {@const isDisabled = disabledPersons.has(person.pubkey)} + + {/each} +
    + {:else if showPersonNodes} +

    + No people found in the current events. +

    + {/if} +
    + {/if} +
    + {/if} diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index ef455bf..42c72d2 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -8,6 +8,12 @@ import type { NetworkNode } from "./types"; import { onMount } from "svelte"; import { getMatchingTags } from "$lib/utils/nostrUtils"; + import { getEventKindName } from "$lib/utils/eventColors"; + import { + getDisplayNameSync, + replacePubkeysWithDisplayNames, + } from "$lib/utils/profileCache"; + import {indexKind, zettelKinds, wikiKind} from "$lib/consts"; // Component props let { @@ -16,12 +22,14 @@ x, y, onclose, + starMode = false, } = $props<{ node: NetworkNode; // The node to display information for selected?: boolean; // Whether the node is selected (clicked) x: number; // X position for the tooltip y: number; // Y position for the tooltip onclose: () => void; // Function to call when closing the tooltip + starMode?: boolean; // Whether we're in star visualization mode }>(); // DOM reference and positioning @@ -32,6 +40,9 @@ // Maximum content length to display const MAX_CONTENT_LENGTH = 200; + // Publication event kinds (text/article based) + const PUBLICATION_KINDS = [wikiKind, indexKind, ...zettelKinds]; + /** * Gets the author name from the event tags */ @@ -39,7 +50,11 @@ if (node.event) { const authorTags = getMatchingTags(node.event, "author"); if (authorTags.length > 0) { - return authorTags[0][1]; + return getDisplayNameSync(authorTags[0][1]); + } + // Fallback to event pubkey + if (node.event.pubkey) { + return getDisplayNameSync(node.event.pubkey); } } return "Unknown"; @@ -71,6 +86,34 @@ return "View Publication"; } + /** + * Checks if this is a publication event + */ + function isPublicationEvent(kind: number): boolean { + return PUBLICATION_KINDS.includes(kind); + } + + /** + * Gets the appropriate URL for the event + */ + function getEventUrl(node: NetworkNode): string { + if (isPublicationEvent(node.kind)) { + return `/publication?id=${node.id}`; + } + return `/events?id=${node.id}`; + } + + /** + * Gets display text for the link + */ + function getLinkText(node: NetworkNode): string { + if (isPublicationEvent(node.kind)) { + return node.title || "Untitled Publication"; + } + // For arbitrary events, show event kind name + return node.title || `Event ${node.kind}`; + } + /** * Truncates content to a maximum length */ @@ -145,39 +188,92 @@
    - - {node.title || "Untitled"} + + {getLinkText(node)}
    - {node.type} (kind: {node.kind}) + {#if isPublicationEvent(node.kind)} + {node.type} (kind: {node.kind}) + {:else} + {getEventKindName(node.kind)} + {#if node.event?.created_at} + · {new Date(node.event.created_at * 1000).toLocaleDateString()} + {/if} + {/if}
    - +
    - Author: {getAuthorTag(node)} + Pub Author: {getAuthorTag(node)}
    - - {#if node.isContainer && getSummaryTag(node)} -
    - Summary: - {truncateContent(getSummaryTag(node) || "")} + + {#if node.author} + + {:else} + + {/if} - - {#if node.content} -
    - {truncateContent(node.content)} -
    + {#if isPublicationEvent(node.kind)} + + {#if node.isContainer && getSummaryTag(node)} +
    + Summary: + {truncateContent(getSummaryTag(node) || "")} +
    + {/if} + + + {#if node.content} +
    + {truncateContent(node.content)} +
    + {/if} + {:else} + + {#if node.event?.content} +
    + Content: +
    {truncateContent(
    +              node.event.content,
    +            )}
    +
    + {/if} + + + {#if node.event?.tags && node.event.tags.length > 0} + + {/if} {/if} {#if selected} -
    Click node again to dismiss
    +
    + {#if isPublicationEvent(node.kind)} + Click to view publication · Click node again to dismiss + {:else} + Click to view event details · Click node again to dismiss + {/if} +
    {/if}
    diff --git a/src/lib/navigator/EventNetwork/Settings.svelte b/src/lib/navigator/EventNetwork/Settings.svelte index 2cff9e2..584834b 100644 --- a/src/lib/navigator/EventNetwork/Settings.svelte +++ b/src/lib/navigator/EventNetwork/Settings.svelte @@ -1,58 +1,136 @@ -
    -
    +

    Settings

    - +
    {#if expanded}
    - Showing {count} events from {$networkFetchLimit} headers + Showing {count} of {totalCount} events - - + + +
    +
    +

    + Event Configuration +

    +
    + {#if eventTypesExpanded} + + {:else} + + {/if} +
    +
    + {#if eventTypesExpanded} + + {/if} +
    + + + + +
    +
    +

    + Visual Settings +

    +
    + {#if visualSettingsExpanded} + + {:else} + + {/if} +
    +
    + {#if visualSettingsExpanded} + +
    +
    + +

    + Toggle between star clusters (on) and linear sequence (off) + visualization +

    +
    + +
    + + {/if} +
    {/if}
    diff --git a/src/lib/navigator/EventNetwork/TagTable.svelte b/src/lib/navigator/EventNetwork/TagTable.svelte new file mode 100644 index 0000000..fa02295 --- /dev/null +++ b/src/lib/navigator/EventNetwork/TagTable.svelte @@ -0,0 +1,82 @@ + + + +{#if uniqueTags.length > 0} +
    +

    + {tagTypeLabels[selectedTagType] || 'Tags'} +

    + + + + + + + + + {#each uniqueTags as tag} + + + + + {/each} + +
    TagCount
    {tag.value}{tag.count}
    +
    +{:else} +
    + No {tagTypeLabels[selectedTagType]?.toLowerCase() || 'tags'} found +
    +{/if} + + \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index ffbb44a..c9a8149 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -11,6 +11,16 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk"; import { levelsToRender } from "$lib/state"; import { generateGraph, getEventColor } from "./utils/networkBuilder"; + import { getEventKindColor } from "$lib/utils/eventColors"; + import { + generateStarGraph, + applyStarLayout, + } from "./utils/starNetworkBuilder"; + import { + createStarSimulation, + applyInitialStarPositions, + createStarDragHandler, + } from "./utils/starForceSimulation"; import { createSimulation, setupDragHandlers, @@ -22,7 +32,20 @@ import NodeTooltip from "./NodeTooltip.svelte"; import type { NetworkNode, NetworkLink } from "./types"; import Settings from "./Settings.svelte"; + import { + enhanceGraphWithTags, + getTagAnchorColor, + } from "./utils/tagNetworkBuilder"; + import { + extractUniquePersons, + createPersonAnchorNodes, + createPersonLinks, + extractPersonAnchorInfo, + } from "./utils/personNetworkBuilder"; import { Button } from "flowbite-svelte"; + import { visualizationConfig } from "$lib/stores/visualizationConfig"; + import { get } from "svelte/store"; + import type { EventCounts } from "$lib/types"; // Type alias for D3 selections type Selection = any; @@ -45,9 +68,24 @@ } // Component props - let { events = [], onupdate } = $props<{ + let { + events = [], + followListEvents = [], + totalCount = 0, + onupdate, + onclear = () => {}, + onTagExpansionChange, + profileStats = { totalFetched: 0, displayLimit: 50 }, + allEventCounts = {} + } = $props<{ events?: NDKEvent[]; + followListEvents?: NDKEvent[]; + totalCount?: number; onupdate: () => void; + onclear?: () => void; + onTagExpansionChange?: (tags: string[]) => void; + profileStats?: { totalFetched: number; displayLimit: number }; + allEventCounts?: EventCounts; }>(); // Error state @@ -81,10 +119,57 @@ let svgGroup: Selection; let zoomBehavior: any; let svgElement: Selection; + + // Position cache to preserve node positions across updates + let nodePositions = new Map(); // Track current render level let currentLevels = $derived(levelsToRender); + // Star visualization state (default to true) + let starVisualization = $state(true); + + // Tag anchors state + let showTagAnchors = $state(false); + let selectedTagType = $state("t"); // Default to hashtags + let tagAnchorInfo = $state([]); + + // Store initial state to detect if component is being recreated + let componentId = Math.random(); + debug("Component created with ID:", componentId); + + // Event counts by kind - derived from events + let eventCounts = $derived.by(() => { + const counts: { [kind: number]: number } = {}; + events.forEach((event: NDKEvent) => { + if (event.kind !== undefined) { + counts[event.kind] = (counts[event.kind] || 0) + 1; + } + }); + return counts; + }); + + // Disabled tags state for interactive legend + let disabledTags = $state(new Set()); + + // Track if we've auto-disabled tags + let autoDisabledTags = $state(false); + + // Maximum number of tag anchors before auto-disabling + const MAX_TAG_ANCHORS = 20; + + // Person nodes state + let showPersonNodes = $state(false); + let personAnchorInfo = $state([]); + let disabledPersons = $state(new Set()); + let showSignedBy = $state(true); + let showReferenced = $state(true); + let personMap = $state>(new Map()); + let totalPersonCount = $state(0); + let displayedPersonCount = $state(0); + let hasInitializedPersons = $state(false); + + // Update dimensions when container changes $effect(() => { if (container) { @@ -144,193 +229,577 @@ .attr("stroke-width", 1); } + /** - * Updates the graph with new data - * Generates the graph from events, creates the simulation, and renders nodes and links + * Validates that required elements are available for graph rendering */ - function updateGraph() { - debug("Updating graph"); - errorMessage = null; + function validateGraphElements() { + if (!svg) { + throw new Error("SVG element not found"); + } - // Create variables to hold our selections - let link: any; - let node: any; - let dragHandler: any; - let nodes: NetworkNode[] = []; - let links: NetworkLink[] = []; + if (!events?.length) { + throw new Error("No events to render"); + } - try { - // Validate required elements - if (!svg) { - throw new Error("SVG element not found"); - } + if (!svgGroup) { + throw new Error("SVG group not found"); + } + } - if (!events?.length) { - throw new Error("No events to render"); - } + /** + * Generates graph data from events, including tag and person anchors + */ + function generateGraphData() { + debug("Generating graph with events", { + eventCount: events.length, + currentLevels, + starVisualization, + showTagAnchors, + }); - if (!svgGroup) { - throw new Error("SVG group not found"); - } + let graphData = starVisualization + ? generateStarGraph(events, Number(currentLevels)) + : generateGraph(events, Number(currentLevels)); - // Generate graph data from events - debug("Generating graph with events", { + // Enhance with tag anchors if enabled + if (showTagAnchors) { + debug("Enhancing graph with tags", { + selectedTagType, eventCount: events.length, - currentLevels, + width, + height }); + + // Get the display limit based on tag type + let displayLimit: number | undefined; + + graphData = enhanceGraphWithTags( + graphData, + events, + selectedTagType, + width, + height, + displayLimit, + ); + + // Extract tag anchor info for legend + const tagAnchors = graphData.nodes.filter((n) => n.isTagAnchor); + + debug("Tag anchors created", { + count: tagAnchors.length, + anchors: tagAnchors + }); + + tagAnchorInfo = tagAnchors.map((n) => ({ + type: n.tagType, + label: n.title, + count: n.connectedNodes?.length || 0, + color: getTagAnchorColor(n.tagType || ""), + })); + } else { + tagAnchorInfo = []; + } - const graphData = generateGraph(events, Number(currentLevels)); - nodes = graphData.nodes; - links = graphData.links; + // Add person nodes if enabled + if (showPersonNodes) { + debug("Creating person anchor nodes"); + + // Extract unique persons from events and follow lists + personMap = extractUniquePersons(events, followListEvents); + + // Create person anchor nodes based on filters + const personResult = createPersonAnchorNodes( + personMap, + width, + height, + showSignedBy, + showReferenced + ); + + const personAnchors = personResult.nodes; + totalPersonCount = personResult.totalCount; + displayedPersonCount = personAnchors.length; + + // Create links between person anchors and their events + const personLinks = createPersonLinks(personAnchors, graphData.nodes, personMap); + + // Add person anchors to the graph + graphData.nodes = [...graphData.nodes, ...personAnchors]; + graphData.links = [...graphData.links, ...personLinks]; + + // Extract person info for legend + personAnchorInfo = extractPersonAnchorInfo(personAnchors, personMap); + + // Auto-disable all person nodes by default (only on first time showing) + if (!hasInitializedPersons && personAnchors.length > 0) { + personAnchors.forEach(anchor => { + if (anchor.pubkey) { + disabledPersons.add(anchor.pubkey); + } + }); + hasInitializedPersons = true; + } + + debug("Person anchors created", { + count: personAnchors.length, + disabled: disabledPersons.size, + showSignedBy, + showReferenced + }); + } else { + personAnchorInfo = []; + // Reset initialization flag when person nodes are hidden + if (hasInitializedPersons && personAnchorInfo.length === 0) { + hasInitializedPersons = false; + disabledPersons.clear(); + } + } + + return graphData; + } - debug("Generated graph data", { - nodeCount: nodes.length, - linkCount: links.length, + /** + * Filters nodes and links based on disabled tags and persons + */ + function filterNodesAndLinks(graphData: { nodes: NetworkNode[]; links: NetworkLink[] }) { + let nodes = graphData.nodes; + let links = graphData.links; + + // Filter out disabled tag anchors and person nodes from nodes and links + if ((showTagAnchors && disabledTags.size > 0) || (showPersonNodes && disabledPersons.size > 0)) { + // Filter out disabled nodes + nodes = nodes.filter((node: NetworkNode) => { + if (node.isTagAnchor) { + const tagId = `${node.tagType}-${node.title}`; + return !disabledTags.has(tagId); + } + if (node.isPersonAnchor && node.pubkey) { + return !disabledPersons.has(node.pubkey); + } + return true; + }); + + // Filter out links to disabled nodes + links = links.filter((link: NetworkLink) => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + + // Check if either node is disabled + if (source.isTagAnchor) { + const tagId = `${source.tagType}-${source.title}`; + if (disabledTags.has(tagId)) return false; + } + if (target.isTagAnchor) { + const tagId = `${target.tagType}-${target.title}`; + if (disabledTags.has(tagId)) return false; + } + if (source.isPersonAnchor && source.pubkey) { + if (disabledPersons.has(source.pubkey)) return false; + } + if (target.isPersonAnchor && target.pubkey) { + if (disabledPersons.has(target.pubkey)) return false; + } + + return true; + }); + + debug("Filtered links for disabled tags", { + originalCount: graphData.links.length, + filteredCount: links.length, + disabledTags: Array.from(disabledTags) }); + } - if (!nodes.length) { - throw new Error("No nodes to render"); - } + return { nodes, links }; + } + + /** + * Saves current node positions to preserve them across updates + */ + function saveNodePositions(nodes: NetworkNode[]) { + if (simulation && nodes.length > 0) { + nodes.forEach(node => { + if (node.x != null && node.y != null) { + nodePositions.set(node.id, { + x: node.x, + y: node.y, + vx: node.vx, + vy: node.vy + }); + } + }); + debug("Saved positions for", nodePositions.size, "nodes"); + } + } - // Stop any existing simulation - if (simulation) { - debug("Stopping existing simulation"); - simulation.stop(); + /** + * Restores node positions from cache and initializes new nodes + */ + function restoreNodePositions(nodes: NetworkNode[]): number { + let restoredCount = 0; + nodes.forEach(node => { + const savedPos = nodePositions.get(node.id); + if (savedPos && !node.isTagAnchor) { // Don't restore tag anchor positions as they're fixed + node.x = savedPos.x; + node.y = savedPos.y; + node.vx = savedPos.vx || 0; + node.vy = savedPos.vy || 0; + restoredCount++; + } else if (!node.x && !node.y && !node.isTagAnchor && !node.isPersonAnchor) { + // Give disconnected nodes (like kind 0) random initial positions + node.x = width / 2 + (Math.random() - 0.5) * width * 0.5; + node.y = height / 2 + (Math.random() - 0.5) * height * 0.5; + node.vx = 0; + node.vy = 0; } + }); + return restoredCount; + } - // Create new simulation - debug("Creating new simulation"); - simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE); + /** + * Sets up the D3 force simulation and drag handlers + */ + function setupSimulation(nodes: NetworkNode[], links: NetworkLink[], restoredCount: number) { + // Stop any existing simulation + if (simulation) { + debug("Stopping existing simulation"); + simulation.stop(); + } - // Center the nodes when the simulation is done - simulation.on("end", () => { - centerGraph(); - }); + // Create new simulation + debug("Creating new simulation"); + const hasRestoredPositions = restoredCount > 0; + let newSimulation: Simulation; + + if (starVisualization) { + // Use star-specific simulation + newSimulation = createStarSimulation(nodes, links, width, height); + // Apply initial star positioning only if we don't have restored positions + if (!hasRestoredPositions) { + applyInitialStarPositions(nodes, links, width, height); + } + } else { + // Use regular simulation + newSimulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE); + + // Add center force for disconnected nodes (like kind 0) + newSimulation.force("center", d3.forceCenter(width / 2, height / 2).strength(0.05)); + + // Add radial force to keep disconnected nodes in view + newSimulation.force("radial", d3.forceRadial(Math.min(width, height) / 3, width / 2, height / 2) + .strength((d: NetworkNode) => { + // Apply radial force only to nodes without links (disconnected nodes) + const hasLinks = links.some(l => + (l.source as NetworkNode).id === d.id || + (l.target as NetworkNode).id === d.id + ); + return hasLinks ? 0 : 0.1; + })); + } + + // Use gentler alpha for updates with restored positions + if (hasRestoredPositions) { + newSimulation.alpha(0.3); // Gentler restart + } - // 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(), - ); + // Center the nodes when the simulation is done + newSimulation.on("end", () => { + if (!starVisualization) { + centerGraph(); + } + }); - // 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(), - ); + // Create drag handler + const dragHandler = starVisualization + ? createStarDragHandler(newSimulation) + : setupDragHandlers(newSimulation); - // 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), - ); + return { simulation: newSimulation, dragHandler }; + } - 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; + /** + * Renders links in the SVG + */ + function renderLinks(links: NetworkLink[]) { + debug("Updating links"); + return svgGroup + .selectAll("path.link") + .data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`) + .join( + (enter: any) => + enter + .append("path") + .attr("class", (d: any) => { + let classes = "link network-link-leather"; + if (d.connectionType === "signed-by") { + classes += " person-link-signed"; + } else if (d.connectionType === "referenced") { + classes += " person-link-referenced"; + } + return classes; + }) + .attr("stroke-width", 2) + .attr("marker-end", "url(#arrowhead)"), + (update: any) => update.attr("class", (d: any) => { + let classes = "link network-link-leather"; + if (d.connectionType === "signed-by") { + classes += " person-link-signed"; + } else if (d.connectionType === "referenced") { + classes += " person-link-referenced"; } - }) - .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; + return classes; + }), + (exit: any) => exit.remove(), + ); + } + + /** + * Creates the node group and attaches drag handlers + */ + function createNodeGroup(enter: any, dragHandler: 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'); + + // Add shape based on node type + nodeEnter.each(function (this: SVGGElement, d: NetworkNode) { + const g = d3.select(this); + if (d.isPersonAnchor) { + // Diamond shape for person anchors + g.append('rect') + .attr('class', 'visual-shape visual-diamond') + .attr('width', NODE_RADIUS * 1.5) + .attr('height', NODE_RADIUS * 1.5) + .attr('x', -NODE_RADIUS * 0.75) + .attr('y', -NODE_RADIUS * 0.75) + .attr('transform', 'rotate(45)') + .attr('stroke-width', 2); + } else { + // Circle for other nodes + g.append('circle') + .attr('class', 'visual-shape 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') + .attr('stroke', 'none') + .attr('font-weight', 'bold') + .style('pointer-events', 'none'); + + return nodeEnter; + } + + /** + * Updates visual properties for all nodes + */ + function updateNodeAppearance(node: any) { + node + .select('.visual-shape') + .attr('class', (d: NetworkNode) => { + const shapeClass = d.isPersonAnchor ? 'visual-diamond' : 'visual-circle'; + const baseClasses = `visual-shape ${shapeClass} network-node-leather`; + if (d.isPersonAnchor) { + return `${baseClasses} person-anchor-node`; + } + if (d.isTagAnchor) { + return `${baseClasses} tag-anchor-node`; + } + if (!d.isContainer) { + return `${baseClasses} network-node-content`; + } + if (starVisualization && d.kind === 30040) { + return `${baseClasses} star-center-node`; + } + return baseClasses; + }) + .style('fill', (d: NetworkNode) => { + if (d.isPersonAnchor) { + if (d.isFromFollowList) { + return getEventKindColor(3); } - }); + return '#10B981'; + } + if (d.isTagAnchor) { + return getTagAnchorColor(d.tagType || ''); + } + const color = getEventKindColor(d.kind); + return color; + }) + .attr('opacity', 1) + .attr('r', (d: NetworkNode) => { + if (d.isPersonAnchor) return null; + if (d.isTagAnchor) { + return NODE_RADIUS * 0.75; + } + if (starVisualization && d.isContainer && d.kind === 30040) { + return NODE_RADIUS * 1.5; + } + return NODE_RADIUS; + }) + .attr('width', (d: NetworkNode) => { + if (!d.isPersonAnchor) return null; + return NODE_RADIUS * 1.5; + }) + .attr('height', (d: NetworkNode) => { + if (!d.isPersonAnchor) return null; + return NODE_RADIUS * 1.5; + }) + .attr('x', (d: NetworkNode) => { + if (!d.isPersonAnchor) return null; + return -NODE_RADIUS * 0.75; + }) + .attr('y', (d: NetworkNode) => { + if (!d.isPersonAnchor) return null; + return -NODE_RADIUS * 0.75; + }) + .attr('stroke-width', (d: NetworkNode) => { + if (d.isPersonAnchor) { + return 3; + } + if (d.isTagAnchor) { + return 3; + } + return 2; + }); + } + + /** + * Updates the text label for all nodes + */ + function updateNodeLabels(node: any) { + node + .select('text') + .text((d: NetworkNode) => { + if (d.isTagAnchor) { + return d.tagType === 't' ? '#' : 'T'; + } + return ''; + }) + .attr('font-size', (d: NetworkNode) => { + if (d.isTagAnchor) { + return '10px'; + } + if (starVisualization && d.isContainer && d.kind === 30040) { + return '14px'; + } + return '12px'; + }) + .attr('fill', (d: NetworkNode) => { + if (d.isTagAnchor) { + return 'white'; + } + return 'black'; + }) + .style('fill', (d: NetworkNode) => { + if (d.isTagAnchor) { + return 'white'; + } + return null; + }) + .attr('stroke', 'none') + .style('stroke', 'none'); + } - // Set up simulation tick handler - debug("Setting up simulation tick handler"); - if (simulation) { - simulation.on("tick", () => { - // Apply custom forces to each node + /** + * Renders nodes in the SVG (refactored for clarity) + */ + function renderNodes(nodes: NetworkNode[], dragHandler: any) { + debug('Updating nodes'); + const node = svgGroup + .selectAll('g.node') + .data(nodes, (d: NetworkNode) => d.id) + .join( + (enter: any) => createNodeGroup(enter, dragHandler), + (update: any) => { + update.call(dragHandler); + return update; + }, + (exit: any) => exit.remove(), + ); + + updateNodeAppearance(node); + updateNodeLabels(node); + + return node; + } + + /** + * Sets up mouse interactions for nodes (hover and click) + */ + function setupNodeInteractions(node: any) { + 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; + } + }); + } + + /** + * Sets up the simulation tick handler for animation + */ + function setupSimulationTickHandler( + simulation: Simulation | null, + nodes: NetworkNode[], + links: NetworkLink[], + link: any, + node: any + ) { + debug("Setting up simulation tick handler"); + if (simulation) { + simulation.on("tick", () => { + // Apply custom forces to each node + if (!starVisualization) { nodes.forEach((node) => { // Pull nodes toward the center applyGlobalLogGravity( @@ -342,36 +811,95 @@ // 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); + // 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 sourceRadius = + starVisualization && + d.source.isContainer && + d.source.kind === 30040 + ? NODE_RADIUS * 1.5 + : NODE_RADIUS; + const targetRadius = + starVisualization && + d.target.isContainer && + d.target.kind === 30040 + ? NODE_RADIUS * 1.5 + : NODE_RADIUS; + + const sourceGap = sourceRadius; + const targetGap = targetRadius + 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}`; + }); - // Calculate start and end points with offsets for node radius - const sourceGap = NODE_RADIUS; - const targetGap = NODE_RADIUS + ARROW_DISTANCE; + // Update node positions + node.attr( + "transform", + (d: NetworkNode) => `translate(${d.x},${d.y})`, + ); + }); + } + } - 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); + /** + * Handles errors that occur during graph updates + */ + function handleGraphError(error: unknown) { + console.error("Error in updateGraph:", error); + errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; + } - return `M${startX},${startY}L${endX},${endY}`; - }); + /** + * Updates the graph with new data + * Generates the graph from events, creates the simulation, and renders nodes and links + */ + function updateGraph() { + debug("updateGraph called", { + eventCount: events?.length, + starVisualization, + showTagAnchors, + selectedTagType, + disabledTagsCount: disabledTags.size + }); + errorMessage = null; - // Update node positions - node.attr( - "transform", - (d: NetworkNode) => `translate(${d.x},${d.y})`, - ); - }); + try { + validateGraphElements(); + const graphData = generateGraphData(); + + // Save current positions before filtering + saveNodePositions(graphData.nodes); + + const { nodes, links } = filterNodesAndLinks(graphData); + const restoredCount = restoreNodePositions(nodes); + + if (!nodes.length) { + throw new Error("No nodes to render"); } + + const { simulation: newSimulation, dragHandler } = setupSimulation(nodes, links, restoredCount); + simulation = newSimulation; + + const link = renderLinks(links); + const node = renderNodes(nodes, dragHandler); + + setupNodeInteractions(node); + setupSimulationTickHandler(simulation, nodes, links, link, node); } catch (error) { - console.error("Error in updateGraph:", error); - errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; + handleGraphError(error); } } @@ -409,14 +937,22 @@ if (svgGroup) { svgGroup .selectAll("g.node") - .select("circle.visual-circle") - .attr("fill", (d: NetworkNode) => - !d.isContainer - ? newIsDarkMode - ? CONTENT_COLOR_DARK - : CONTENT_COLOR_LIGHT - : getEventColor(d.id), - ); + .select(".visual-shape") + .style("fill", (d: NetworkNode) => { + // Person anchors - color based on source + if (d.isPersonAnchor) { + // If from follow list, use kind 3 color + if (d.isFromFollowList) { + return getEventKindColor(3); + } + // Otherwise green for event authors + return "#10B981"; + } + if (d.isTagAnchor) { + return getTagAnchorColor(d.tagType || ""); + } + return getEventKindColor(d.kind); + }); } } } @@ -457,25 +993,183 @@ /** * Watch for changes that should trigger a graph update */ + let isUpdating = false; + let updateTimer: ReturnType | null = null; + + // Create a derived state that combines all dependencies + const graphDependencies = $derived({ + levels: currentLevels, + star: starVisualization, + tags: showTagAnchors, + tagType: selectedTagType, + disabled: disabledTags.size, + persons: showPersonNodes, + disabledPersons: disabledPersons.size, + showSignedBy, + showReferenced, + eventsLength: events?.length || 0 + }); + + // Debounced update function + function scheduleGraphUpdate() { + if (updateTimer) { + clearTimeout(updateTimer); + } + + updateTimer = setTimeout(() => { + if (!isUpdating && svg && events?.length > 0) { + debug("Scheduled graph update executing", graphDependencies); + isUpdating = true; + try { + updateGraph(); + } catch (error) { + console.error("Error updating graph:", error); + errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; + } finally { + isUpdating = false; + updateTimer = null; + } + } + }, 100); // 100ms debounce + } + $effect(() => { - debug("Effect triggered", { - hasSvg: !!svg, - eventCount: events?.length, - currentLevels, - }); + // Just track the dependencies and schedule update + const deps = graphDependencies; + + if (svg && events?.length > 0) { + scheduleGraphUpdate(); + } + }); - try { - if (svg && events?.length) { - // Include currentLevels in the effect dependencies - const _ = currentLevels; - updateGraph(); + // Track previous values to avoid unnecessary calls + let previousTagType = $state(undefined); + let isInitialized = $state(false); + + // Mark as initialized after first render + $effect(() => { + if (!isInitialized && svg) { + isInitialized = true; + } + }); + + /** + * Watch for tag expansion changes + */ + $effect(() => { + // Skip if not initialized or no callback + if (!isInitialized || !onTagExpansionChange) return; + + // Check if we need to trigger expansion + const tagTypeChanged = selectedTagType !== previousTagType; + const shouldExpand = showTagAnchors && tagTypeChanged; + + if (shouldExpand) { + previousTagType = selectedTagType; + + // Extract unique tags from current events + const tags = new Set(); + events.forEach((event: NDKEvent) => { + const eventTags = event.getMatchingTags(selectedTagType); + eventTags.forEach((tag: string[]) => { + if (tag[1]) tags.add(tag[1]); + }); + }); + + debug("Tag expansion requested", { + tagType: selectedTagType, + tags: Array.from(tags), + tagTypeChanged + }); + + onTagExpansionChange(Array.from(tags)); + } + }); + + /** + * Watch for tag anchor count and auto-disable if exceeds threshold + */ + let autoDisableTimer: ReturnType | null = null; + + $effect(() => { + // Clear any pending timer + if (autoDisableTimer) { + clearTimeout(autoDisableTimer); + autoDisableTimer = null; + } + + // Only check when tag anchors are shown and we have tags + if (showTagAnchors && tagAnchorInfo.length > 0) { + + // If we have more than MAX_TAG_ANCHORS and haven't auto-disabled yet + if (tagAnchorInfo.length > MAX_TAG_ANCHORS && !autoDisabledTags) { + // Defer the state update to break the sync cycle + autoDisableTimer = setTimeout(() => { + debug(`Auto-disabling tags: ${tagAnchorInfo.length} exceeds maximum of ${MAX_TAG_ANCHORS}`); + + // Disable all tags + const newDisabledTags = new Set(); + tagAnchorInfo.forEach(anchor => { + const tagId = `${anchor.type}-${anchor.label}`; + newDisabledTags.add(tagId); + }); + + disabledTags = newDisabledTags; + autoDisabledTags = true; + + // Optional: Show a notification to the user + console.info(`[EventNetwork] Auto-disabled ${tagAnchorInfo.length} tag anchors to prevent graph overload. Click individual tags in the legend to enable them.`); + }, 0); + } + + // Reset auto-disabled flag if tag count goes back down + if (tagAnchorInfo.length <= MAX_TAG_ANCHORS && autoDisabledTags) { + autoDisableTimer = setTimeout(() => { + autoDisabledTags = false; + }, 0); } - } catch (error) { - console.error("Error in effect:", error); - errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; + } + + // Reset when tag anchors are hidden + if (!showTagAnchors && autoDisabledTags) { + autoDisableTimer = setTimeout(() => { + autoDisabledTags = false; + }, 0); } }); + /** + * Handles toggling tag visibility + */ + function handleTagToggle(tagId: string) { + if (disabledTags.has(tagId)) { + const newDisabledTags = new Set(disabledTags); + newDisabledTags.delete(tagId); + disabledTags = newDisabledTags; + } else { + const newDisabledTags = new Set(disabledTags); + newDisabledTags.add(tagId); + disabledTags = newDisabledTags; + } + // Update graph will be triggered by the effect + } + + /** + * Handles toggling person node visibility + */ + function handlePersonToggle(pubkey: string) { + if (disabledPersons.has(pubkey)) { + const newDisabledPersons = new Set(disabledPersons); + newDisabledPersons.delete(pubkey); + disabledPersons = newDisabledPersons; + } else { + const newDisabledPersons = new Set(disabledPersons); + newDisabledPersons.add(pubkey); + disabledPersons = newDisabledPersons; + } + // Update graph will be triggered by the effect + } + /** * Handles tooltip close event */ @@ -531,6 +1225,7 @@ graphInteracted = true; } } +
    @@ -551,10 +1246,50 @@ {/if}
    - + { + // Trigger graph update when tag settings change + if (svg && events?.length) { + updateGraph(); + } + }} + bind:showPersonNodes + personAnchors={personAnchorInfo} + {disabledPersons} + onPersonToggle={handlePersonToggle} + onPersonSettingsChange={() => { + // Trigger graph update when person settings change + if (svg && events?.length) { + updateGraph(); + } + }} + bind:showSignedBy + bind:showReferenced + {totalPersonCount} + {displayedPersonCount} + /> - + @@ -641,6 +1376,7 @@ x={tooltipX} y={tooltipY} onclose={handleTooltipClose} + starMode={starVisualization} /> {/if}
    diff --git a/src/lib/navigator/EventNetwork/types.ts b/src/lib/navigator/EventNetwork/types.ts index 1667a3a..67fe49f 100644 --- a/src/lib/navigator/EventNetwork/types.ts +++ b/src/lib/navigator/EventNetwork/types.ts @@ -43,10 +43,22 @@ export interface NetworkNode extends SimulationNodeDatum { title: string; // Event title content: string; // Event content author: string; // Author's public key - type: "Index" | "Content"; // Node type classification + type: "Index" | "Content" | "TagAnchor" | "PersonAnchor"; // Node type classification naddr?: string; // NIP-19 naddr identifier nevent?: string; // NIP-19 nevent identifier isContainer?: boolean; // Whether this node is a container (index) + + // Tag anchor specific fields + isTagAnchor?: boolean; // Whether this is a tag anchor node + tagType?: string; // Type of tag (t, p, e, etc.) + tagValue?: string; // The tag value + connectedNodes?: string[]; // IDs of nodes that have this tag + + // Person anchor specific fields + isPersonAnchor?: boolean; // Whether this is a person anchor node + pubkey?: string; // The person's public key + displayName?: string; // The person's display name from kind 0 + isFromFollowList?: boolean; // Whether this person comes from follow lists } /** diff --git a/src/lib/navigator/EventNetwork/utils/common.ts b/src/lib/navigator/EventNetwork/utils/common.ts new file mode 100644 index 0000000..f8c0bef --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/common.ts @@ -0,0 +1,41 @@ +/** + * Common utilities shared across network builders + */ + +/** + * Seeded random number generator for deterministic layouts + */ +export class SeededRandom { + private seed: number; + + constructor(seed: number) { + this.seed = seed; + } + + next(): number { + const x = Math.sin(this.seed++) * 10000; + return x - Math.floor(x); + } + + nextFloat(min: number, max: number): number { + return min + this.next() * (max - min); + } + + nextInt(min: number, max: number): number { + return Math.floor(this.nextFloat(min, max + 1)); + } +} + +/** + * Creates a debug function with a prefix + * @param prefix - The prefix to add to all debug messages + * @returns A debug function that can be toggled on/off + */ +export function createDebugFunction(prefix: string) { + const DEBUG = false; + return function debug(...args: any[]) { + if (DEBUG) { + console.log(`[${prefix}]`, ...args); + } + }; +} \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts index 6eb0dd3..d74ba1d 100644 --- a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts +++ b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts @@ -1,45 +1,38 @@ -// deno-lint-ignore-file no-explicit-any /** * 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.ts"; +import type { NetworkNode, NetworkLink } from "../types"; import * as d3 from "d3"; +import { createDebugFunction } from "./common"; // 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); - } -} +// Debug function +const debug = createDebugFunction("ForceSimulation"); /** * 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; + 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; } /** @@ -47,173 +40,175 @@ export interface Simulation { * 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; + 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, + 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 }); - } + 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 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, - centerX: number, - centerY: number, - alpha: number, + node: NetworkNode, + centerX: number, + centerY: number, + alpha: number, ) { - const dx = (node.x ?? 0) - centerX; - const dy = (node.y ?? 0) - centerY; - const distance = Math.sqrt(dx * dx + dy * dy); + // Tag anchors and person anchors should not be affected by gravity + if (node.isTagAnchor || node.isPersonAnchor) return; + + const dx = (node.x ?? 0) - centerX; + const dy = (node.y ?? 0) - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); - if (distance === 0) return; + if (distance === 0) return; - const force = Math.log(distance + 1) * GRAVITY_STRENGTH * alpha; - updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); + 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, + 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)); + // Tag anchors and person anchors should not be affected by connected gravity + if (node.isTagAnchor || node.isPersonAnchor) return; + + // Find all nodes connected to this node (excluding tag anchors and person anchors) + 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(n => !n.isTagAnchor && !n.isPersonAnchor); - if (connectedNodes.length === 0) return; + if (connectedNodes.length === 0) return; - // 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); + // 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; + 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); + // 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; + if (distance === 0) return; - // Apply force proportional to distance - const force = distance * CONNECTED_GRAVITY_STRENGTH * alpha; - updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); + // 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, + simulation: Simulation, + warmupClickEnergy: number = 0.9 ) { - return d3 - .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; - }, - ); + return d3 + .drag() + .on("start", (event: D3DragEvent, d: NetworkNode) => { + // Tag anchors and person anchors retain their anchor behavior + if (d.isTagAnchor || d.isPersonAnchor) { + // Still allow dragging but maintain anchor status + d.fx = d.x; + d.fy = d.y; + return; + } + + // 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 position for all nodes including anchors + + // 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); + } + + // Keep all nodes fixed after dragging + // This allows users to manually position any node type + d.fx = d.x; + d.fy = d.y; + }); } /** * 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 @@ -221,35 +216,34 @@ export function setupDragHandlers( * @returns Configured D3 force simulation */ export function createSimulation( - nodes: NetworkNode[], - links: NetworkLink[], - nodeRadius: number, - linkDistance: number, + nodes: NetworkNode[], + links: NetworkLink[], + nodeRadius: number, + linkDistance: number ): 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; - } + 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 aa234b2..3ba3abd 100644 --- a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts +++ b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts @@ -1,207 +1,186 @@ /** * 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, GraphData, GraphState } from "../types.ts"; +import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import { nip19 } from "nostr-tools"; -import { activeInboxRelays, activeOutboxRelays } from "../../../ndk.ts"; -import { getMatchingTags } from "../../../utils/nostrUtils.ts"; -import { get } from "svelte/store"; +import { communityRelays } from "$lib/consts"; +import { getMatchingTags } from '$lib/utils/nostrUtils'; +import { getDisplayNameSync } from '$lib/utils/profileCache'; +import { createDebugFunction } from "./common"; // 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: unknown[]) { - if (DEBUG) { - console.log("[NetworkBuilder]", ...args); - } -} +// Debug function +const debug = createDebugFunction("NetworkBuilder"); /** * 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, + event: NDKEvent, + level: number = 0 ): NetworkNode { - 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, - isContainer, - level, - title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled", - content: event.content || "", - author: event.pubkey || "", - kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined - type: nodeType, - }; + debug("Creating network node", { eventId: event.id, kind: event.kind, level }); + + const isContainer = event.kind === INDEX_EVENT_KIND; + const nodeType = isContainer ? "Index" : event.kind === CONTENT_EVENT_KIND || event.kind === 30818 ? "Content" : `Kind ${event.kind}`; - // 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, - kind: event.kind, - relays: [...get(activeInboxRelays), ...get(activeOutboxRelays)], - }); - - // Create nevent (NIP-19 event reference) for the event - node.nevent = nip19.neventEncode({ + // Create the base node with essential properties + const node: NetworkNode = { id: event.id, - relays: [...get(activeInboxRelays), ...get(activeOutboxRelays)], - kind: event.kind, - }); - } catch (error) { - console.warn("Failed to generate identifiers for node:", error); + event, + isContainer, + level, + title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled", + content: event.content || "", + author: event.pubkey ? getDisplayNameSync(event.pubkey) : "", + kind: event.kind !== undefined ? event.kind : CONTENT_EVENT_KIND, // Default to content event kind only if truly undefined + type: nodeType as "Index" | "Content" | "TagAnchor", + }; + + // 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, + kind: event.kind, + relays: communityRelays, + }); + + // Create nevent (NIP-19 event reference) for the event + node.nevent = nip19.neventEncode({ + id: event.id, + relays: communityRelays, + kind: event.kind, + }); + } catch (error) { + console.warn("Failed to generate identifiers for node:", error); + } } - } - 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 { - 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; + 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; + return tag[3] || null; } /** * 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}%)`; + // 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 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 set of referenced event IDs to identify root events - const referencedIds = new Set(); - events.forEach((event) => { - // Handle both "a" tags (NIP-62) and "e" tags (legacy) - let tags = getMatchingTags(event, "a"); - if (tags.length === 0) { - tags = getMatchingTags(event, "e"); - } - - debug("Processing tags for event", { - eventId: event.id, - tagCount: tags.length, - tagType: - tags.length > 0 - ? getMatchingTags(event, "a").length > 0 - ? "a" - : "e" - : "none", + debug("Initializing graph state", { eventCount: events.length }); + + const nodeMap = new Map(); + const eventMap = createEventMap(events); + + // Create initial nodes for all events + events.forEach((event) => { + if (!event.id) return; + const node = createNetworkNode(event); + nodeMap.set(event.id, node); }); - - tags.forEach((tag) => { - const id = extractEventIdFromATag(tag); - if (id) referencedIds.add(id); + debug("Node map created", { nodeCount: nodeMap.size }); + + // Build set of referenced event IDs to identify root events + const referencedIds = new Set(); + events.forEach((event) => { + const aTags = getMatchingTags(event, "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, - links: [], - eventMap, - referencedIds, - }; + debug("Referenced IDs set created", { referencedCount: referencedIds.size }); + + return { + nodeMap, + links: [], + eventMap, + referencedIds, + }; } /** * 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 @@ -209,153 +188,156 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { * @param maxLevel - Maximum hierarchy level to process */ export function processSequence( - sequence: NetworkNode[], - indexEvent: NDKEvent, - level: number, - state: GraphState, - maxLevel: number, + sequence: NetworkNode[], + indexEvent: NDKEvent, + level: number, + 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 all nodes in the sequence - sequence.forEach((node) => { - node.level = level + 1; - }); + // Stop if we've reached max level or have no nodes + if (level >= maxLevel || sequence.length === 0) return; - // Create link from index to first content node - const indexNode = state.nodeMap.get(indexEvent.id); - if (indexNode && sequence[0]) { - state.links.push({ - source: indexNode, - target: sequence[0], - isSequential: true, + // Set levels for all nodes in the sequence + sequence.forEach((node) => { + node.level = level + 1; }); - } - // Create sequential links between content nodes - for (let i = 0; i < sequence.length - 1; i++) { - const currentNode = sequence[i]; - const nextNode = sequence[i + 1]; - - state.links.push({ - source: currentNode, - target: nextNode, - isSequential: true, - }); + // Create link from index to first content node + const indexNode = state.nodeMap.get(indexEvent.id); + if (indexNode && sequence[0]) { + state.links.push({ + source: indexNode, + target: sequence[0], + isSequential: true, + }); + } - // Process nested indices recursively - if (currentNode.isContainer) { - processNestedIndex(currentNode, level + 1, state, maxLevel); + // Create sequential links between content nodes + for (let i = 0; i < sequence.length - 1; i++) { + const currentNode = sequence[i]; + const nextNode = sequence[i + 1]; + + state.links.push({ + source: currentNode, + target: nextNode, + isSequential: true, + }); + + // Process nested indices recursively + if (currentNode.isContainer) { + processNestedIndex(currentNode, level + 1, state, maxLevel); + } } - } - // Process the last node if it's an index - const lastNode = sequence[sequence.length - 1]; - if (lastNode?.isContainer) { - processNestedIndex(lastNode, level + 1, state, maxLevel); - } + // 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, - state: GraphState, - maxLevel: number, + node: NetworkNode, + level: number, + state: GraphState, + maxLevel: number, ): void { - if (!node.isContainer || level >= maxLevel) return; + if (!node.isContainer || level >= maxLevel) return; - const nestedEvent = state.eventMap.get(node.id); - if (nestedEvent) { - processIndexEvent(nestedEvent, level, state, maxLevel); - } + const nestedEvent = state.eventMap.get(node.id); + if (nestedEvent) { + processIndexEvent(nestedEvent, level, state, maxLevel); + } } /** * 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, - state: GraphState, - maxLevel: number, + indexEvent: NDKEvent, + level: number, + state: GraphState, + maxLevel: number, ): void { - if (level >= maxLevel) return; - - // Extract the sequence of nodes referenced by this index - // Handle both "a" tags (NIP-62) and "e" tags (legacy) - let tags = getMatchingTags(indexEvent, "a"); - if (tags.length === 0) { - tags = getMatchingTags(indexEvent, "e"); - } + if (level >= maxLevel) return; - const sequence = tags - .map((tag) => extractEventIdFromATag(tag)) - .filter((id): id is string => id !== null) - .map((id) => state.nodeMap.get(id)) - .filter((node): node is NetworkNode => node !== undefined); + // Extract the sequence of nodes referenced by this index + const sequence = getMatchingTags(indexEvent, "a") + .map((tag) => extractEventIdFromATag(tag)) + .filter((id): id is string => id !== null) + .map((id) => state.nodeMap.get(id)) + .filter((node): node is NetworkNode => node !== undefined); - 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(events: NDKEvent[], maxLevel: number): GraphData { - debug("Generating graph", { eventCount: events.length, maxLevel }); - - // Initialize the graph state - const state = initializeGraphState(events); - - // 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: getMatchingTags(rootIndex, "a").length, +export function generateGraph( + events: NDKEvent[], + maxLevel: number +): GraphData { + debug("Generating graph", { eventCount: events.length, maxLevel }); + + // Initialize the graph state + const state = initializeGraphState(events); + + // Find root events (index events not referenced by others, and all non-publication events) + const publicationKinds = [30040, 30041, 30818]; + const rootEvents = events.filter( + (e) => e.id && ( + // Index events not referenced by others + (e.kind === INDEX_EVENT_KIND && !state.referencedIds.has(e.id)) || + // All non-publication events are treated as roots + (e.kind !== undefined && !publicationKinds.includes(e.kind)) + ) + ); + + debug("Found root events", { + rootCount: rootEvents.length, + rootIds: rootEvents.map(e => e.id) + }); + + // Process each root event + rootEvents.forEach((rootEvent) => { + debug("Processing root event", { + rootId: rootEvent.id, + kind: rootEvent.kind, + aTags: getMatchingTags(rootEvent, "a").length + }); + processIndexEvent(rootEvent, 0, state, maxLevel); }); - processIndexEvent(rootIndex, 0, state, maxLevel); - }); - - // Create the final graph data - const result = { - nodes: Array.from(state.nodeMap.values()), - links: state.links, - }; - - debug("Graph generation complete", { - nodeCount: result.nodes.length, - linkCount: result.links.length, - }); - return result; + // Create the final graph data + const result = { + nodes: Array.from(state.nodeMap.values()), + links: state.links, + }; + + debug("Graph generation complete", { + nodeCount: result.nodes.length, + linkCount: result.links.length + }); + + return result; } diff --git a/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts b/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts new file mode 100644 index 0000000..b998703 --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts @@ -0,0 +1,337 @@ +/** + * Person Network Builder + * + * Creates person anchor nodes for event authors in the network visualization + */ + +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import type { NetworkNode, NetworkLink } from "../types"; +import { getDisplayNameSync } from "$lib/utils/profileCache"; +import { SeededRandom, createDebugFunction } from "./common"; + +const PERSON_ANCHOR_RADIUS = 15; +const PERSON_ANCHOR_PLACEMENT_RADIUS = 1000; +const MAX_PERSON_NODES = 20; // Default limit for person nodes + +// Debug function +const debug = createDebugFunction("PersonNetworkBuilder"); + + +/** + * Creates a deterministic seed from a string + */ +function createSeed(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +} + +export interface PersonConnection { + signedByEventIds: Set; + referencedInEventIds: Set; + isFromFollowList?: boolean; // Track if this person comes from follow lists +} + +/** + * Extracts unique persons (pubkeys) from events + * Tracks both signed-by (event.pubkey) and referenced (["p", pubkey] tags) + */ +export function extractUniquePersons( + events: NDKEvent[], + followListEvents?: NDKEvent[] +): Map { + // Map of pubkey -> PersonConnection + const personMap = new Map(); + + debug("Extracting unique persons", { eventCount: events.length, followListCount: followListEvents?.length || 0 }); + + // First collect pubkeys from follow list events + const followListPubkeys = new Set(); + if (followListEvents && followListEvents.length > 0) { + followListEvents.forEach((event) => { + // Follow list author + if (event.pubkey) { + followListPubkeys.add(event.pubkey); + } + // People in follow lists (p tags) + if (event.tags) { + event.tags + .filter(tag => { + tag[0] === 'p' + }) + .forEach(tag => { + followListPubkeys.add(tag[1]); + }); + } + }); + } + + events.forEach((event) => { + if (!event.id) return; + + // Track signed-by connections + if (event.pubkey) { + if (!personMap.has(event.pubkey)) { + personMap.set(event.pubkey, { + signedByEventIds: new Set(), + referencedInEventIds: new Set(), + isFromFollowList: followListPubkeys.has(event.pubkey) + }); + } + personMap.get(event.pubkey)!.signedByEventIds.add(event.id); + } + + // Track referenced connections from "p" tags + if (event.tags) { + event.tags.forEach(tag => { + if (tag[0] === "p" && tag[1]) { + const referencedPubkey = tag[1]; + if (!personMap.has(referencedPubkey)) { + personMap.set(referencedPubkey, { + signedByEventIds: new Set(), + referencedInEventIds: new Set(), + isFromFollowList: followListPubkeys.has(referencedPubkey) + }); + } + personMap.get(referencedPubkey)!.referencedInEventIds.add(event.id); + } + }); + } + }); + + debug("Extracted persons", { personCount: personMap.size }); + + return personMap; +} + +/** + * Helper to build eligible person info for anchor nodes. + */ +function buildEligiblePerson( + pubkey: string, + connection: PersonConnection, + showSignedBy: boolean, + showReferenced: boolean +): { + pubkey: string; + connection: PersonConnection; + connectedEventIds: Set; + totalConnections: number; +} | null { + const connectedEventIds = new Set(); + + if (showSignedBy) { + connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); + } + + if (showReferenced) { + connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); + } + + if (connectedEventIds.size === 0) { + return null; + } + + return { + pubkey, + connection, + connectedEventIds, + totalConnections: connectedEventIds.size + }; +} + +type EligiblePerson = { + pubkey: string; + connection: PersonConnection; + totalConnections: number; + connectedEventIds: Set; +}; + +function getEligiblePersons( + personMap: Map, + showSignedBy: boolean, + showReferenced: boolean, + limit: number +): EligiblePerson[] { + // Build eligible persons and keep only top N using a min-heap or partial sort + const eligible: EligiblePerson[] = []; + + for (const [pubkey, connection] of personMap) { + let totalConnections = 0; + if (showSignedBy) totalConnections += connection.signedByEventIds.size; + if (showReferenced) totalConnections += connection.referencedInEventIds.size; + if (totalConnections === 0) continue; + + // Only build the set if this person is eligible + const connectedEventIds = new Set(); + if (showSignedBy) { + connection.signedByEventIds.forEach(id => connectedEventIds.add(id)); + } + if (showReferenced) { + connection.referencedInEventIds.forEach(id => connectedEventIds.add(id)); + } + + eligible.push({ pubkey, connection, totalConnections, connectedEventIds }); + } + + // Partial sort: get top N by totalConnections + eligible.sort((a, b) => b.totalConnections - a.totalConnections); + return eligible.slice(0, limit); +} + +/** + * Creates person anchor nodes + */ +export function createPersonAnchorNodes( + personMap: Map, + width: number, + height: number, + showSignedBy: boolean, + showReferenced: boolean, + limit: number = MAX_PERSON_NODES +): { nodes: NetworkNode[], totalCount: number } { + const anchorNodes: NetworkNode[] = []; + + const centerX = width / 2; + const centerY = height / 2; + + // Calculate eligible persons and their connection counts + const eligiblePersons = getEligiblePersons(personMap, showSignedBy, showReferenced, limit); + + // Create nodes for the limited set + debug("Creating person anchor nodes", { + eligibleCount: eligiblePersons.length, + limitedCount: eligiblePersons.length, + showSignedBy, + showReferenced + }); + + eligiblePersons.forEach(({ pubkey, connection, connectedEventIds }) => { + // Create seeded random generator for consistent positioning + const rng = new SeededRandom(createSeed(pubkey)); + + // Generate deterministic position + const angle = rng.next() * 2 * Math.PI; + const distance = rng.next() * PERSON_ANCHOR_PLACEMENT_RADIUS; + const x = centerX + distance * Math.cos(angle); + const y = centerY + distance * Math.sin(angle); + + // Get display name + const displayName = getDisplayNameSync(pubkey); + + const anchorNode: NetworkNode = { + id: `person-anchor-${pubkey}`, + title: displayName, + content: `${connection.signedByEventIds.size} signed, ${connection.referencedInEventIds.size} referenced`, + author: "", + kind: 0, // Special kind for anchors + type: "PersonAnchor", + level: -1, + isPersonAnchor: true, + pubkey, + displayName, + connectedNodes: Array.from(connectedEventIds), + isFromFollowList: connection.isFromFollowList, + x, + y, + fx: x, // Fix position + fy: y, + }; + + anchorNodes.push(anchorNode); + }); + + debug("Created person anchor nodes", { count: anchorNodes.length, totalEligible: eligiblePersons.length }); + + return { + nodes: anchorNodes, + totalCount: eligiblePersons.length + }; +} + +export interface PersonLink extends NetworkLink { + connectionType?: "signed-by" | "referenced"; +} + +/** + * Creates links between person anchors and their events + * Adds connection type for coloring + */ +export function createPersonLinks( + personAnchors: NetworkNode[], + nodes: NetworkNode[], + personMap: Map +): PersonLink[] { + debug("Creating person links", { anchorCount: personAnchors.length, nodeCount: nodes.length }); + + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + const links: PersonLink[] = personAnchors.flatMap((anchor) => { + if (!anchor.connectedNodes || !anchor.pubkey) { + return []; + } + + const connection = personMap.get(anchor.pubkey); + if (!connection) { + return []; + } + + return anchor.connectedNodes.map((nodeId) => { + const node = nodeMap.get(nodeId); + if (!node) { + return undefined; + } + + let connectionType: 'signed-by' | 'referenced' | undefined; + if (connection.signedByEventIds.has(nodeId)) { + connectionType = 'signed-by'; + } else if (connection.referencedInEventIds.has(nodeId)) { + connectionType = 'referenced'; + } + + return { + source: anchor, + target: node, + isSequential: false, + connectionType, + }; + }).filter(Boolean); // Remove undefineds + }); + + debug("Created person links", { linkCount: links.length }); + return links; +} + +/** + * Formats person anchor info for display in Legend + */ +export interface PersonAnchorInfo { + pubkey: string; + displayName: string; + signedByCount: number; + referencedCount: number; + isFromFollowList: boolean; +} + +/** + * Extracts person info for Legend display + */ +export function extractPersonAnchorInfo( + personAnchors: NetworkNode[], + personMap: Map +): PersonAnchorInfo[] { + return personAnchors.map(anchor => { + const connection = personMap.get(anchor.pubkey || ""); + return { + pubkey: anchor.pubkey || "", + displayName: anchor.displayName || "", + signedByCount: connection?.signedByEventIds.size || 0, + referencedCount: connection?.referencedInEventIds.size || 0, + isFromFollowList: connection?.isFromFollowList || false, + }; + }); +} \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/utils/starForceSimulation.ts b/src/lib/navigator/EventNetwork/utils/starForceSimulation.ts new file mode 100644 index 0000000..c22ac1d --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/starForceSimulation.ts @@ -0,0 +1,308 @@ +/** + * Star Network Force Simulation + * + * Custom force simulation optimized for star network layouts. + * Provides stronger connections between star centers and their content nodes, + * with specialized forces to maintain hierarchical structure. + */ + +import * as d3 from "d3"; +import type { NetworkNode, NetworkLink } from "../types"; +import type { Simulation } from "./forceSimulation"; +import { createTagGravityForce } from "./tagNetworkBuilder"; + +// Configuration for star network forces +const STAR_CENTER_CHARGE = -300; // Stronger repulsion between star centers +const CONTENT_NODE_CHARGE = -50; // Weaker repulsion for content nodes +const STAR_LINK_STRENGTH = 0.5; // Moderate connection to star center +const INTER_STAR_LINK_STRENGTH = 0.2; // Weaker connection between stars +const STAR_LINK_DISTANCE = 80; // Fixed distance from center to content +const INTER_STAR_DISTANCE = 200; // Distance between star centers +const CENTER_GRAVITY = 0.02; // Gentle pull toward canvas center +const STAR_CENTER_WEIGHT = 10; // Weight multiplier for star centers + +/** + * Creates a custom force simulation for star networks + */ +export function createStarSimulation( + nodes: NetworkNode[], + links: NetworkLink[], + width: number, + height: number +): Simulation { + // Create the simulation + const simulation = d3.forceSimulation(nodes) as any + simulation + .force("center", d3.forceCenter(width / 2, height / 2).strength(CENTER_GRAVITY)) + .velocityDecay(0.2) // Lower decay for more responsive simulation + .alphaDecay(0.0001) // Much slower alpha decay to prevent freezing + .alphaMin(0.001); // Keep minimum energy to prevent complete freeze + + // Custom charge force that varies by node type + const chargeForce = d3.forceManyBody() + .strength((d: NetworkNode) => { + // Tag anchors don't repel + if (d.isTagAnchor) { + return 0; + } + // Star centers repel each other strongly + if (d.isContainer && d.kind === 30040) { + return STAR_CENTER_CHARGE; + } + // Content nodes have minimal repulsion + return CONTENT_NODE_CHARGE; + }) + .distanceMax(300); // Limit charge force range + + // Custom link force with variable strength and distance + const linkForce = d3.forceLink(links) + .id((d: NetworkNode) => d.id) + .strength((link: any) => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + // Strong connection from star center to its content + if (source.kind === 30040 && target.kind === 30041) { + return STAR_LINK_STRENGTH; + } + // Weaker connection between star centers + if (source.kind === 30040 && target.kind === 30040) { + return INTER_STAR_LINK_STRENGTH; + } + return 0.5; // Default strength + }) + .distance((link: any) => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + // Fixed distance for star-to-content links + if (source.kind === 30040 && target.kind === 30041) { + return STAR_LINK_DISTANCE; + } + // Longer distance between star centers + if (source.kind === 30040 && target.kind === 30040) { + return INTER_STAR_DISTANCE; + } + return 100; // Default distance + }); + + // Apply forces to simulation + simulation + .force("charge", chargeForce) + .force("link", linkForce); + + // Custom radial force to keep content nodes around their star center + simulation.force("radial", createRadialForce(nodes, links)); + + // Add tag gravity force if there are tag anchors + const hasTagAnchors = nodes.some(n => n.isTagAnchor); + if (hasTagAnchors) { + simulation.force("tagGravity", createTagGravityForce(nodes, links)); + } + + // Periodic reheat to prevent freezing + let tickCount = 0; + simulation.on("tick", () => { + tickCount++; + // Every 300 ticks, give a small energy boost to prevent freezing + if (tickCount % 300 === 0 && simulation.alpha() < 0.01) { + simulation.alpha(0.02); + } + }); + + return simulation; +} + +/** + * Applies the radial force to keep content nodes in orbit around their star center + * @param nodes - The array of network nodes + * @param nodeToCenter - Map of content node IDs to their star center node + * @param targetDistance - The desired distance from center to content node + * @param alpha - The current simulation alpha + */ +function applyRadialForce( + nodes: NetworkNode[], + nodeToCenter: Map, + targetDistance: number, + alpha: number +): void { + nodes.forEach(node => { + if (node.kind === 30041) { + const center = nodeToCenter.get(node.id); + if ( + center && + center.x != null && + center.y != null && + node.x != null && + node.y != null + ) { + // Calculate desired position + const dx = node.x - center.x; + const dy = node.y - center.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0) { + // Normalize and apply force + const force = (distance - targetDistance) * alpha * 0.3; // Reduced force + node.vx = (node.vx || 0) - (dx / distance) * force; + node.vy = (node.vy || 0) - (dy / distance) * force; + } + } + } + }); +} + +/** + * Creates a custom radial force that keeps content nodes in orbit around their star center + */ +function createRadialForce(nodes: NetworkNode[], links: NetworkLink[]): any { + // Build a map of content nodes to their star centers + const nodeToCenter = new Map(); + + links.forEach(link => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + if (source.kind === 30040 && target.kind === 30041) { + nodeToCenter.set(target.id, source); + } + }); + + function force(alpha: number) { + applyRadialForce(nodes, nodeToCenter, STAR_LINK_DISTANCE, alpha); + } + + force.initialize = function(_: NetworkNode[]) { + nodes = _; + }; + + return force; +} + +/** + * Applies initial positioning for star networks + */ +export function applyInitialStarPositions( + nodes: NetworkNode[], + links: NetworkLink[], + width: number, + height: number +): void { + // Group nodes by their star centers + const starGroups = new Map(); + const starCenters: NetworkNode[] = []; + + // Identify star centers + nodes.forEach(node => { + if (node.isContainer && node.kind === 30040) { + starCenters.push(node); + starGroups.set(node.id, []); + } + }); + + // Assign content nodes to their star centers + links.forEach(link => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + if (source.kind === 30040 && target.kind === 30041) { + const group = starGroups.get(source.id); + if (group) { + group.push(target); + } + } + }); + + // Position star centers in a grid or circle + if (starCenters.length === 1) { + // Single star - center it + const center = starCenters[0]; + center.x = width / 2; + center.y = height / 2; + // Don't fix position initially - let simulation run naturally + } else if (starCenters.length > 1) { + // Multiple stars - arrange in a circle + const centerX = width / 2; + const centerY = height / 2; + const radius = Math.min(width, height) * 0.3; + const angleStep = (2 * Math.PI) / starCenters.length; + + starCenters.forEach((center, i) => { + const angle = i * angleStep; + center.x = centerX + radius * Math.cos(angle); + center.y = centerY + radius * Math.sin(angle); + // Don't fix position initially - let simulation adjust + }); + } + + // Position content nodes around their star centers + starGroups.forEach((contentNodes, centerId) => { + const center = nodes.find(n => n.id === centerId); + if (!center) return; + + const angleStep = (2 * Math.PI) / Math.max(contentNodes.length, 1); + contentNodes.forEach((node, i) => { + const angle = i * angleStep; + node.x = (center.x || 0) + STAR_LINK_DISTANCE * Math.cos(angle); + node.y = (center.y || 0) + STAR_LINK_DISTANCE * Math.sin(angle); + }); + }); +} + +/** + * Handler for the start of a drag event in the star network simulation. + * Sets the fixed position of the node to its current position. + * @param event - The drag event from d3 + * @param d - The node being dragged + * @param simulation - The d3 force simulation instance + */ +function dragstarted(event: any, d: NetworkNode, simulation: Simulation) { + // If no other drag is active, set a low alpha target to keep the simulation running smoothly + if (!event.active) { + simulation.alphaTarget(0.1).restart(); + } + // Set the node's fixed position to its current position + d.fx = d.x; + d.fy = d.y; +} + +/** + * Handler for the drag event in the star network simulation. + * Updates the node's fixed position to follow the mouse. + * @param event - The drag event from d3 + * @param d - The node being dragged + */ +function dragged(event: any, d: NetworkNode) { + // Update the node's fixed position to the current mouse position + d.fx = event.x; + d.fy = event.y; +} + +/** + * Handler for the end of a drag event in the star network simulation. + * Keeps the node fixed at its new position after dragging. + * @param event - The drag event from d3 + * @param d - The node being dragged + * @param simulation - The d3 force simulation instance + */ +function dragended(event: any, d: NetworkNode, simulation: Simulation) { + // If no other drag is active, lower the alpha target to let the simulation cool down + if (!event.active) { + simulation.alphaTarget(0); + } + // Keep the node fixed at its new position + d.fx = event.x; + d.fy = event.y; +} + +/** + * Custom drag handler for star networks + * @param simulation - The d3 force simulation instance + * @returns The d3 drag behavior + */ +export function createStarDragHandler( + simulation: Simulation +): any { + // These handlers are now top-level functions, so we use closures to pass simulation to them. + // This is a common pattern in JavaScript/TypeScript when you need to pass extra arguments to event handlers. + return d3.drag() + .on('start', function(event: any, d: NetworkNode) { dragstarted(event, d, simulation); }) + .on('drag', dragged) + .on('end', function(event: any, d: NetworkNode) { dragended(event, d, simulation); }); +} \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts b/src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts new file mode 100644 index 0000000..9f41031 --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/starNetworkBuilder.ts @@ -0,0 +1,353 @@ +/** + * Star Network Builder for NKBIP-01 Events + * + * This module provides utilities for building star network visualizations specifically + * for NKBIP-01 events (kinds 30040 and 30041). Unlike the sequential network builder, + * this creates star formations where index events (30040) are central nodes with + * content events (30041) arranged around them. + */ + +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; +import { getMatchingTags } from '$lib/utils/nostrUtils'; +import { createNetworkNode, createEventMap, extractEventIdFromATag, getEventColor } from './networkBuilder'; +import { createDebugFunction } from './common'; +import { wikiKind, indexKind, zettelKinds } from '$lib/consts'; + + +// Debug function +const debug = createDebugFunction("StarNetworkBuilder"); + +/** + * Represents a star network with a central index node and peripheral content nodes + */ +export interface StarNetwork { + center: NetworkNode; // Central index node (30040) + peripheralNodes: NetworkNode[]; // Content nodes (30041) and connected indices (30040) + links: NetworkLink[]; // Links within this star +} + +/** + * Creates a star network from an index event and its references + * + * @param indexEvent - The central index event (30040) + * @param state - Current graph state + * @param level - Hierarchy level for this star + * @returns A star network structure + */ +export function createStarNetwork( + indexEvent: NDKEvent, + state: GraphState, + level: number = 0 +): StarNetwork | null { + debug("Creating star network", { indexId: indexEvent.id, level }); + + const centerNode = state.nodeMap.get(indexEvent.id); + if (!centerNode) { + debug("Center node not found for index event", indexEvent.id); + return null; + } + + // Set the center node level + centerNode.level = level; + + // Extract referenced event IDs from 'a' tags + const referencedIds = getMatchingTags(indexEvent, "a") + .map(tag => extractEventIdFromATag(tag)) + .filter((id): id is string => id !== null); + + debug("Found referenced IDs", { count: referencedIds.length, ids: referencedIds }); + + // Get peripheral nodes (both content and nested indices) + const peripheralNodes: NetworkNode[] = []; + const links: NetworkLink[] = []; + + referencedIds.forEach(id => { + const node = state.nodeMap.get(id); + if (node) { + // Set the peripheral node level + node.level += 1; + peripheralNodes.push(node); + + // Create link from center to peripheral node + links.push({ + source: centerNode, + target: node, + isSequential: false // Star links are not sequential + }); + + debug("Added peripheral node", { nodeId: id, nodeType: node.type }); + } + }); + + return { + center: centerNode, + peripheralNodes, + links + }; +} + +/** + * Processes all index events to create star networks + * + * @param events - Array of all events + * @param maxLevel - Maximum nesting level to process + * @returns Array of star networks + */ +export function createStarNetworks( + events: NDKEvent[], + maxLevel: number, + existingNodeMap?: Map +): StarNetwork[] { + debug("Creating star networks", { eventCount: events.length, maxLevel }); + + // Use existing node map or create new one + const nodeMap = existingNodeMap || new Map(); + const eventMap = createEventMap(events); + + // Create nodes for all events if not using existing map + if (!existingNodeMap) { + events.forEach(event => { + if (!event.id) return; + const node = createNetworkNode(event); + nodeMap.set(event.id, node); + }); + } + + const state: GraphState = { + nodeMap, + links: [], + eventMap, + referencedIds: new Set() + }; + + // Find all index events and non-publication events + const publicationKinds = [wikiKind, indexKind, ...zettelKinds]; + const indexEvents = events.filter(event => event.kind === indexKind); + const nonPublicationEvents = events.filter(event => + event.kind !== undefined && !publicationKinds.includes(event.kind) + ); + + debug("Found index events", { count: indexEvents.length }); + debug("Found non-publication events", { count: nonPublicationEvents.length }); + + const starNetworks: StarNetwork[] = []; + const processedIndices = new Set(); + + // Process all index events regardless of level + indexEvents.forEach(indexEvent => { + if (!indexEvent.id || processedIndices.has(indexEvent.id)) return; + + const star = createStarNetwork(indexEvent, state, 0); + if (star && star.peripheralNodes.length > 0) { + starNetworks.push(star); + processedIndices.add(indexEvent.id); + debug("Created star network", { + centerId: star.center.id, + peripheralCount: star.peripheralNodes.length + }); + } + }); + + // Add non-publication events as standalone nodes (stars with no peripherals) + nonPublicationEvents.forEach(event => { + if (!event.id || !nodeMap.has(event.id)) return; + + const node = nodeMap.get(event.id)!; + const star: StarNetwork = { + center: node, + peripheralNodes: [], + links: [] + }; + starNetworks.push(star); + debug("Created standalone star for non-publication event", { + eventId: event.id, + kind: event.kind + }); + }); + + return starNetworks; +} + +/** + * Creates inter-star connections between star networks + * + * @param starNetworks - Array of star networks + * @returns Additional links connecting different star networks + */ +export function createInterStarConnections(starNetworks: StarNetwork[]): NetworkLink[] { + debug("Creating inter-star connections", { starCount: starNetworks.length }); + + const interStarLinks: NetworkLink[] = []; + + // Create a map of center nodes for quick lookup + const centerNodeMap = new Map(); + starNetworks.forEach(star => { + centerNodeMap.set(star.center.id, star.center); + }); + + // For each star, check if any of its peripheral nodes are centers of other stars + starNetworks.forEach(star => { + star.peripheralNodes.forEach(peripheralNode => { + // If this peripheral node is the center of another star, create an inter-star link + if (peripheralNode.isContainer && centerNodeMap.has(peripheralNode.id)) { + const targetStar = starNetworks.find(s => s.center.id === peripheralNode.id); + if (targetStar) { + interStarLinks.push({ + source: star.center, + target: targetStar.center, + isSequential: false + }); + debug("Created inter-star connection", { + from: star.center.id, + to: targetStar.center.id + }); + } + } + }); + }); + + return interStarLinks; +} + +/** + * Applies star-specific positioning to nodes using a radial layout + * + * @param starNetworks - Array of star networks + * @param width - Canvas width + * @param height - Canvas height + */ +export function applyStarLayout( + starNetworks: StarNetwork[], + width: number, + height: number +): void { + debug("Applying star layout", { + starCount: starNetworks.length, + dimensions: { width, height } + }); + + const centerX = width / 2; + const centerY = height / 2; + + // If only one star, center it + if (starNetworks.length === 1) { + const star = starNetworks[0]; + + // Position center node + star.center.x = centerX; + star.center.y = centerY; + star.center.fx = centerX; // Fix center position + star.center.fy = centerY; + + // Position peripheral nodes in a circle around center + const radius = Math.min(width, height) * 0.25; + const angleStep = (2 * Math.PI) / star.peripheralNodes.length; + + star.peripheralNodes.forEach((node, index) => { + const angle = index * angleStep; + node.x = centerX + radius * Math.cos(angle); + node.y = centerY + radius * Math.sin(angle); + }); + + return; + } + + // For multiple stars, arrange them in a grid or circle + const starsPerRow = Math.ceil(Math.sqrt(starNetworks.length)); + const starSpacingX = width / (starsPerRow + 1); + const starSpacingY = height / (Math.ceil(starNetworks.length / starsPerRow) + 1); + + starNetworks.forEach((star, index) => { + const row = Math.floor(index / starsPerRow); + const col = index % starsPerRow; + + const starCenterX = (col + 1) * starSpacingX; + const starCenterY = (row + 1) * starSpacingY; + + // Position center node + star.center.x = starCenterX; + star.center.y = starCenterY; + star.center.fx = starCenterX; // Fix center position + star.center.fy = starCenterY; + + // Position peripheral nodes around this star's center + const radius = Math.min(starSpacingX, starSpacingY) * 0.3; + const angleStep = (2 * Math.PI) / Math.max(star.peripheralNodes.length, 1); + + star.peripheralNodes.forEach((node, nodeIndex) => { + const angle = nodeIndex * angleStep; + node.x = starCenterX + radius * Math.cos(angle); + node.y = starCenterY + radius * Math.sin(angle); + }); + }); +} + +/** + * Generates a complete star network graph from events + * + * @param events - Array of Nostr events + * @param maxLevel - Maximum hierarchy level to process + * @returns Complete graph data with star network layout + */ +export function generateStarGraph( + events: NDKEvent[], + maxLevel: number +): GraphData { + debug("Generating star graph", { eventCount: events.length, maxLevel }); + + // Guard against empty events + if (!events || events.length === 0) { + return { nodes: [], links: [] }; + } + + // Initialize all nodes first + const nodeMap = new Map(); + events.forEach(event => { + if (!event.id) return; + const node = createNetworkNode(event); + nodeMap.set(event.id, node); + }); + + // Create star networks with the existing node map + const starNetworks = createStarNetworks(events, maxLevel, nodeMap); + + // Create inter-star connections + const interStarLinks = createInterStarConnections(starNetworks); + + // Collect nodes that are part of stars + const nodesInStars = new Set(); + const allLinks: NetworkLink[] = []; + + // Add nodes and links from all stars + starNetworks.forEach(star => { + nodesInStars.add(star.center.id); + star.peripheralNodes.forEach(node => { + nodesInStars.add(node.id); + }); + allLinks.push(...star.links); + }); + + // Add inter-star links + allLinks.push(...interStarLinks); + + // Include orphaned nodes (those not in any star) + const allNodes: NetworkNode[] = []; + nodeMap.forEach((node, id) => { + allNodes.push(node); + }); + + const result = { + nodes: allNodes, + links: allLinks + }; + + debug("Star graph generation complete", { + nodeCount: result.nodes.length, + linkCount: result.links.length, + starCount: starNetworks.length, + orphanedNodes: allNodes.length - nodesInStars.size + }); + + return result; +} \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts b/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts new file mode 100644 index 0000000..d4e28c4 --- /dev/null +++ b/src/lib/navigator/EventNetwork/utils/tagNetworkBuilder.ts @@ -0,0 +1,314 @@ +/** + * Tag Network Builder + * + * Enhances network visualizations with tag anchor nodes that act as gravity points + * for nodes sharing the same tags. + */ + +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import type { NetworkNode, NetworkLink, GraphData } from "../types"; +import { getDisplayNameSync } from "$lib/utils/profileCache"; +import { SeededRandom, createDebugFunction } from "./common"; + +// Configuration +const TAG_ANCHOR_RADIUS = 15; +// TODO: Move this to settings panel for user control +const TAG_ANCHOR_PLACEMENT_RADIUS = 1250; // Radius from center within which to randomly place tag anchors + +// Debug function +const debug = createDebugFunction("TagNetworkBuilder"); + + +/** + * Creates a deterministic seed from a string + */ +function createSeed(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +/** + * Color mapping for tag anchor nodes + */ +export function getTagAnchorColor(tagType: string): string { + switch (tagType) { + case "t": + return "#eba5a5"; // Blue for hashtags + case "p": + return "#10B981"; // Green for people + case "author": + return "#8B5CF6"; // Purple for authors + case "e": + return "#F59E0B"; // Yellow for events + case "a": + return "#EF4444"; // Red for articles + case "kind3": + return "#06B6D4"; // Cyan for follow lists + default: + return "#6B7280"; // Gray for others + } +} + +/** + * Extracts unique tags from events for a specific tag type + */ +export function extractUniqueTagsForType( + events: NDKEvent[], + tagType: string, +): Map> { + // Map of tagValue -> Set of event IDs + const tagMap = new Map>(); + debug("Extracting unique tags for type", { tagType, eventCount: events.length }); + + events.forEach((event) => { + if (!event.tags || !event.id) return; + + event.tags.forEach((tag) => { + if (tag.length < 2) return; + + if (tag[0] !== tagType) return; + const tagValue = tag[1]; + + if (!tagValue) return; + + if (!tagMap.has(tagValue)) { + tagMap.set(tagValue, new Set()); + } + + tagMap.get(tagValue)!.add(event.id); + }); + }); + + debug("Extracted tags", { tagCount: tagMap.size }); + + return tagMap; +} + +/** + * Creates tag anchor nodes from extracted tags of a specific type + */ +export function createTagAnchorNodes( + tagMap: Map>, + tagType: string, + width: number, + height: number, +): NetworkNode[] { + const anchorNodes: NetworkNode[] = []; + + debug("Creating tag anchor nodes", { tagType, tagCount: tagMap.size }); + + // Calculate positions for tag anchors randomly within radius + // Show all tags regardless of how many events they appear in + const minEventCount = 1; + let validTags = Array.from(tagMap.entries()).filter( + ([_, eventIds]) => eventIds.size >= minEventCount, + ); + + if (validTags.length === 0) return []; + + // Sort all tags by number of connections (events) descending + validTags.sort((a, b) => b[1].size - a[1].size); + + validTags.forEach(([tagValue, eventIds]) => { + // Position anchors randomly within a radius from the center + const centerX = width / 2; + const centerY = height / 2; + + // Create seeded random generator based on tag type and value for consistent positioning + const seedString = `${tagType}-${tagValue}`; + const rng = new SeededRandom(createSeed(seedString)); + + // Generate deterministic position within the defined radius + const angle = rng.next() * 2 * Math.PI; + const distance = rng.next() * TAG_ANCHOR_PLACEMENT_RADIUS; + const x = centerX + distance * Math.cos(angle); + const y = centerY + distance * Math.sin(angle); + + // Format the display title based on tag type + let displayTitle = tagValue; + if (tagType === "t") { + displayTitle = tagValue.startsWith("#") ? tagValue : `#${tagValue}`; + } else if (tagType === "author") { + displayTitle = tagValue; + } else if (tagType === "p") { + // Use display name for pubkey + displayTitle = getDisplayNameSync(tagValue); + } + + const anchorNode: NetworkNode = { + id: `tag-anchor-${tagType}-${tagValue}`, + title: displayTitle, + content: `${eventIds.size} events`, + author: "", + kind: 0, // Special kind for tag anchors + type: "TagAnchor", + level: -1, // Tag anchors are outside the hierarchy + isTagAnchor: true, + tagType, + tagValue, + connectedNodes: Array.from(eventIds), + x, + y, + fx: x, // Fix position + fy: y, + }; + + anchorNodes.push(anchorNode); + }); + + debug("Created tag anchor nodes", { count: anchorNodes.length }); + return anchorNodes; +} + +/** + * Creates invisible links between tag anchors and nodes that have those tags + */ +export function createTagLinks( + tagAnchors: NetworkNode[], + nodes: NetworkNode[], +): NetworkLink[] { + debug("Creating tag links", { anchorCount: tagAnchors.length, nodeCount: nodes.length }); + + const links: NetworkLink[] = []; + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + tagAnchors.forEach((anchor) => { + if (!anchor.connectedNodes) return; + + anchor.connectedNodes.forEach((nodeId) => { + const node = nodeMap.get(nodeId); + if (node) { + links.push({ + source: anchor, + target: node, + isSequential: false, + }); + } + }); + }); + + debug("Created tag links", { linkCount: links.length }); + return links; +} + +/** + * Enhances a graph with tag anchor nodes for a specific tag type + */ +export function enhanceGraphWithTags( + graphData: GraphData, + events: NDKEvent[], + tagType: string, + width: number, + height: number, + displayLimit?: number, +): GraphData { + debug("Enhancing graph with tags", { tagType, displayLimit }); + + // Extract unique tags for the specified type + const tagMap = extractUniqueTagsForType(events, tagType); + + // Create tag anchor nodes + let tagAnchors = createTagAnchorNodes(tagMap, tagType, width, height); + + // Apply display limit if provided + if (displayLimit && displayLimit > 0 && tagAnchors.length > displayLimit) { + // Sort by connection count (already done in createTagAnchorNodes) + // and take only the top ones up to the limit + tagAnchors = tagAnchors.slice(0, displayLimit); + } + + // Create links between anchors and nodes + const tagLinks = createTagLinks(tagAnchors, graphData.nodes); + + // Return enhanced graph + return { + nodes: [...graphData.nodes, ...tagAnchors], + links: [...graphData.links, ...tagLinks], + }; +} + +/** + * Applies a gentle pull on each node toward its tag anchors. + * + * @param nodes - The array of network nodes to update. + * @param nodeToAnchors - A map from node IDs to their tag anchor nodes. + * @param alpha - The current simulation alpha (cooling factor). + */ +export function applyTagGravity( + nodes: NetworkNode[], + nodeToAnchors: Map, + alpha: number +): void { + nodes.forEach((node) => { + if (node.isTagAnchor) return; // Tag anchors don't move + + const anchors = nodeToAnchors.get(node.id); + if (!anchors || anchors.length === 0) return; + + // Apply gentle pull toward each tag anchor + anchors.forEach((anchor) => { + if ( + anchor.x != null && + anchor.y != null && + node.x != null && + node.y != null + ) { + const dx = anchor.x - node.x; + const dy = anchor.y - node.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0) { + // Gentle force that decreases with distance + const strength = (0.02 * alpha) / anchors.length; + node.vx = (node.vx || 0) + (dx / distance) * strength * distance; + node.vy = (node.vy || 0) + (dy / distance) * strength * distance; + } + } + }); + }); +} + +/** + * Custom force for tag anchor gravity + */ +export function createTagGravityForce( + nodes: NetworkNode[], + links: NetworkLink[], +): any { + // Build a map of nodes to their tag anchors + const nodeToAnchors = new Map(); + + links.forEach((link) => { + const source = link.source as NetworkNode; + const target = link.target as NetworkNode; + + if (source.isTagAnchor && !target.isTagAnchor) { + if (!nodeToAnchors.has(target.id)) { + nodeToAnchors.set(target.id, []); + } + nodeToAnchors.get(target.id)!.push(source); + } else if (target.isTagAnchor && !source.isTagAnchor) { + if (!nodeToAnchors.has(source.id)) { + nodeToAnchors.set(source.id, []); + } + nodeToAnchors.get(source.id)!.push(target); + } + }); + + debug("Creating tag gravity force"); + + function force(alpha: number) { + applyTagGravity(nodes, nodeToAnchors, alpha); + } + + force.initialize = function (_: NetworkNode[]) { + nodes = _; + }; + + return force; +} diff --git a/src/lib/state.ts b/src/lib/state.ts index 280fb48..ba4f8b4 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -11,5 +11,5 @@ export const tabBehaviour: Writable = writable( export const userPublickey: Writable = writable( (browser && localStorage.getItem("wikinostr_loggedInPublicKey")) || "", ); -export const networkFetchLimit: Writable = writable(5); +export const networkFetchLimit: Writable = writable(50); export const levelsToRender: Writable = writable(3); diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts new file mode 100644 index 0000000..467f6e7 --- /dev/null +++ b/src/lib/stores/index.ts @@ -0,0 +1,2 @@ +export * from './relayStore'; +export * from './displayLimits'; \ No newline at end of file diff --git a/src/lib/stores/visualizationConfig.ts b/src/lib/stores/visualizationConfig.ts new file mode 100644 index 0000000..a17c052 --- /dev/null +++ b/src/lib/stores/visualizationConfig.ts @@ -0,0 +1,182 @@ +import { writable, derived, get } from "svelte/store"; + +export interface EventKindConfig { + kind: number; + limit: number; + enabled?: boolean; // Whether this kind is enabled for display + nestedLevels?: number; // Only for kind 30040 + depth?: number; // Only for kind 3 (follow lists) + showAll?: boolean; // Only for content kinds (30041, 30818) - show all loaded content instead of limit +} + +/** + * VisualizationConfig now uses a Map for eventConfigs. + * The key is the event kind (number), and the value is a JSON stringified EventKindConfig. + * This allows O(1) retrieval of config by kind. + */ +export interface VisualizationConfig { + /** + * Event configurations with per-kind limits. + */ + eventConfigs: EventKindConfig[]; + + /** + * Whether to search through all fetched events during graph traversal. + */ + searchThroughFetched: boolean; +} + +// Default configurations for common event kinds +const DEFAULT_EVENT_CONFIGS: EventKindConfig[] = [ + { kind: 30040, limit: 20, nestedLevels: 1, enabled: true }, + { kind: 30041, limit: 20, enabled: false }, + { kind: 30818, limit: 20, enabled: false }, + { kind: 30023, limit: 20, enabled: false }, +]; + +function createVisualizationConfig() { + const initialConfig: VisualizationConfig = { + eventConfigs: DEFAULT_EVENT_CONFIGS, + searchThroughFetched: true, + }; + + const { subscribe, set, update } = writable(initialConfig); + + function reset() { + set(initialConfig); + } + + function addEventKind(kind: number, limit: number = 10) { + update((config) => { + // Check if kind already exists + if (config.eventConfigs.some((ec) => ec.kind === kind)) { + return config; + } + + const newConfig: EventKindConfig = { kind, limit, enabled: true }; + + // Add nestedLevels for 30040 + if (kind === 30040) { + newConfig.nestedLevels = 1; + } + + // Add depth for kind 3 + if (kind === 3) { + newConfig.depth = 0; + } + + return { + ...config, + eventConfigs: [...config.eventConfigs, newConfig], + }; + }); + } + + function removeEventKind(kind: number) { + update((config) => ({ + ...config, + eventConfigs: config.eventConfigs.filter((ec) => ec.kind !== kind), + })); + } + + function updateEventLimit(kind: number, limit: number) { + update((config) => ({ + ...config, + eventConfigs: config.eventConfigs.map((ec) => + ec.kind === kind ? { ...ec, limit } : ec, + ), + })); + } + + function updateNestedLevels(levels: number) { + update((config) => ({ + ...config, + eventConfigs: config.eventConfigs.map((ec) => + ec.kind === 30040 ? { ...ec, nestedLevels: levels } : ec, + ), + })); + } + + function updateFollowDepth(depth: number) { + update((config) => ({ + ...config, + eventConfigs: config.eventConfigs.map((ec) => + ec.kind === 3 ? { ...ec, depth: depth } : ec, + ), + })); + } + + function toggleShowAllContent(kind: number) { + update((config) => ({ + ...config, + eventConfigs: config.eventConfigs.map((ec) => + ec.kind === kind ? { ...ec, showAll: !ec.showAll } : ec, + ), + })); + } + + function getEventConfig(kind: number) { + let config: EventKindConfig | undefined; + subscribe((c) => { + config = c.eventConfigs.find((ec) => ec.kind === kind); + })(); + return config; + } + + function toggleSearchThroughFetched() { + update((config) => ({ + ...config, + searchThroughFetched: !config.searchThroughFetched, + })); + } + + function toggleKind(kind: number) { + update((config) => ({ + ...config, + eventConfigs: config.eventConfigs.map((ec) => + ec.kind === kind ? { ...ec, enabled: !ec.enabled } : ec, + ), + })); + } + + return { + subscribe, + update, + reset, + addEventKind, + removeEventKind, + updateEventLimit, + updateNestedLevels, + updateFollowDepth, + toggleShowAllContent, + getEventConfig, + toggleSearchThroughFetched, + toggleKind, + }; +} + +export const visualizationConfig = createVisualizationConfig(); + +// Helper to get all enabled event kinds +export const enabledEventKinds = derived(visualizationConfig, ($config) => + $config.eventConfigs + .filter((ec) => ec.enabled !== false) + .map((ec) => ec.kind), +); + +/** + * Returns true if the given event kind is enabled in the config. + * @param config - The VisualizationConfig object. + * @param kind - The event kind number to check. + */ +export function isKindEnabledFn(config: VisualizationConfig, kind: number): boolean { + const eventConfig = config.eventConfigs.find((ec) => ec.kind === kind); + // If not found, return false. Otherwise, return true unless explicitly disabled. + return !!eventConfig && eventConfig.enabled !== false; +} + +// Derived store: returns a function that checks if a kind is enabled in the current config. +export const isKindEnabledStore = derived( + visualizationConfig, + ($config) => (kind: number) => isKindEnabledFn($config, kind) +); diff --git a/src/lib/types.ts b/src/lib/types.ts index 06e130c..9587b0c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -13,3 +13,21 @@ export type TabType = | "user" | "settings" | "editor"; + +export type EventCounts = { [kind: number]: number }; + +/** + * Enum of Nostr event kinds relevant to Alexandria. + */ +export enum NostrKind { + /** User metadata event (kind 0) */ + UserMetadata = 0, + /** Text note event (kind 1) */ + TextNote = 1, + /** Publication index event (kind 30040) */ + PublicationIndex = 30040, + /** Publication content event (kind 30041) */ + PublicationContent = 30041, + /** Wiki event (kind 30818) */ + Wiki = 30818, +} diff --git a/src/lib/utils/displayLimits.ts b/src/lib/utils/displayLimits.ts new file mode 100644 index 0000000..029ec25 --- /dev/null +++ b/src/lib/utils/displayLimits.ts @@ -0,0 +1,142 @@ +import type { NDKEvent } from '@nostr-dev-kit/ndk'; +import type { VisualizationConfig } from '$lib/stores/visualizationConfig'; +import { isEventId, isCoordinate, parseCoordinate } from './nostr_identifiers'; +import type { NostrEventId } from './nostr_identifiers'; + +/** + * Filters events based on visualization configuration + * @param events - All available events + * @param config - Visualization configuration + * @returns Filtered events that should be displayed + */ +export function filterByDisplayLimits(events: NDKEvent[], config: VisualizationConfig): NDKEvent[] { + const result: NDKEvent[] = []; + const kindCounts = new Map(); + + for (const event of events) { + const kind = event.kind; + if (kind === undefined) continue; + + // Get the config for this event kind + const eventConfig = config.eventConfigs.find(ec => ec.kind === kind); + + // Skip if the kind is disabled + if (eventConfig && eventConfig.enabled === false) { + continue; + } + + const limit = eventConfig?.limit; + + // Special handling for content kinds (30041, 30818) with showAll option + if ((kind === 30041 || kind === 30818) && eventConfig?.showAll) { + // Show all content events when showAll is true + result.push(event); + // Still update the count for UI display + const currentCount = kindCounts.get(kind) || 0; + kindCounts.set(kind, currentCount + 1); + } else if (limit !== undefined) { + // Normal limit checking + const currentCount = kindCounts.get(kind) || 0; + if (currentCount < limit) { + result.push(event); + kindCounts.set(kind, currentCount + 1); + } + } else { + // No limit configured, add the event + result.push(event); + } + } + + return result; +} + +/** + * Detects events that are referenced but not present in the current set + * @param events - Current events + * @param existingIds - Set of all known event IDs (hex format) + * @param existingCoordinates - Optional map of existing coordinates for NIP-33 detection + * @returns Set of missing event identifiers + */ +export function detectMissingEvents( + events: NDKEvent[], + existingIds: Set, + existingCoordinates?: Map +): Set { + const missing = new Set(); + + for (const event of events) { + // Check 'e' tags for direct event references (hex IDs) + const eTags = event.getMatchingTags('e'); + for (const eTag of eTags) { + if (eTag.length < 2) continue; + + const eventId = eTag[1]; + + // Type check: ensure it's a valid hex event ID + if (!isEventId(eventId)) { + console.warn('Invalid event ID in e tag:', eventId); + continue; + } + + if (!existingIds.has(eventId)) { + missing.add(eventId); + } + } + + // Check 'a' tags for NIP-33 references (kind:pubkey:d-tag) + const aTags = event.getMatchingTags('a'); + for (const aTag of aTags) { + if (aTag.length < 2) continue; + + const identifier = aTag[1]; + + // Type check: ensure it's a valid coordinate + if (!isCoordinate(identifier)) { + console.warn('Invalid coordinate in a tag:', identifier); + continue; + } + + // Parse the coordinate + const parsed = parseCoordinate(identifier); + if (!parsed) continue; + + // If we have existing coordinates, check if this one exists + if (existingCoordinates) { + if (!existingCoordinates.has(identifier)) { + missing.add(identifier); + } + } else { + // Without coordinate map, we can't detect missing NIP-33 events + // This is a limitation when we only have hex IDs + console.debug('Cannot detect missing NIP-33 events without coordinate map:', identifier); + } + } + } + + return missing; +} + +/** + * Builds a map of coordinates to events for NIP-33 detection + * @param events - Array of events to build coordinate map from + * @returns Map of coordinate strings to events + */ +export function buildCoordinateMap(events: NDKEvent[]): Map { + const coordinateMap = new Map(); + + for (const event of events) { + // Only process replaceable events (kinds 30000-39999) + if (event.kind && event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tagValue('d'); + const author = event.pubkey; + + if (dTag && author) { + const coordinate = `${event.kind}:${author}:${dTag}`; + coordinateMap.set(coordinate, event); + } + } + } + + return coordinateMap; +} + diff --git a/src/lib/utils/eventColors.ts b/src/lib/utils/eventColors.ts new file mode 100644 index 0000000..e123c7b --- /dev/null +++ b/src/lib/utils/eventColors.ts @@ -0,0 +1,82 @@ +/** + * Deterministic color mapping for event kinds + * Uses golden ratio to distribute colors evenly across the spectrum + */ + +const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; + +/** + * Get a deterministic color for an event kind + * @param kind - The event kind number + * @returns HSL color string + */ +export function getEventKindColor(kind: number): string { + // Use golden ratio for better distribution + const hue = (kind * GOLDEN_RATIO * 360) % 360; + + // Use different saturation/lightness for better visibility + const saturation = 65 + (kind % 20); // 65-85% + const lightness = 55 + ((kind * 3) % 15); // 55-70% + + return `hsl(${Math.round(hue)}, ${saturation}%, ${lightness}%)`; +} + +/** + * Get a friendly name for an event kind + * @param kind - The event kind number + * @returns Human-readable name + */ +export function getEventKindName(kind: number): string { + const kindNames: Record = { + 0: 'Metadata', + 1: 'Text Note', + 2: 'Recommend Relay', + 3: 'Contact List', + 4: 'Encrypted DM', + 5: 'Event Deletion', + 6: 'Repost', + 7: 'Reaction', + 8: 'Badge Award', + 16: 'Generic Repost', + 40: 'Channel Creation', + 41: 'Channel Metadata', + 42: 'Channel Message', + 43: 'Channel Hide Message', + 44: 'Channel Mute User', + 1984: 'Reporting', + 9734: 'Zap Request', + 9735: 'Zap', + 10000: 'Mute List', + 10001: 'Pin List', + 10002: 'Relay List', + 22242: 'Client Authentication', + 24133: 'Nostr Connect', + 27235: 'HTTP Auth', + 30000: 'Categorized People List', + 30001: 'Categorized Bookmark List', + 30008: 'Profile Badges', + 30009: 'Badge Definition', + 30017: 'Create or update a stall', + 30018: 'Create or update a product', + 30023: 'Long-form Content', + 30024: 'Draft Long-form Content', + 30040: 'Publication Index', + 30041: 'Publication Content', + 30078: 'Application-specific Data', + 30311: 'Live Event', + 30402: 'Classified Listing', + 30403: 'Draft Classified Listing', + 30617: 'Repository', + 30818: 'Wiki Page', + 31922: 'Date-Based Calendar Event', + 31923: 'Time-Based Calendar Event', + 31924: 'Calendar', + 31925: 'Calendar Event RSVP', + 31989: 'Handler recommendation', + 31990: 'Handler information', + 34550: 'Community Definition', + }; + + return kindNames[kind] || `Kind ${kind}`; +} + diff --git a/src/lib/utils/eventDeduplication.ts b/src/lib/utils/eventDeduplication.ts new file mode 100644 index 0000000..8c52e64 --- /dev/null +++ b/src/lib/utils/eventDeduplication.ts @@ -0,0 +1,214 @@ +import type { NDKEvent } from '@nostr-dev-kit/ndk'; + +/** + * Deduplicate content events by keeping only the most recent version + * @param contentEventSets Array of event sets from different sources + * @returns Map of coordinate to most recent event + */ +export function deduplicateContentEvents(contentEventSets: Set[]): Map { + const eventsByCoordinate = new Map(); + + // Track statistics for debugging + let totalEvents = 0; + let duplicateCoordinates = 0; + const duplicateDetails: Array<{ coordinate: string; count: number; events: string[] }> = []; + + contentEventSets.forEach((eventSet) => { + eventSet.forEach(event => { + totalEvents++; + const dTag = event.tagValue("d"); + const author = event.pubkey; + const kind = event.kind; + + if (dTag && author && kind) { + const coordinate = `${kind}:${author}:${dTag}`; + const existing = eventsByCoordinate.get(coordinate); + + if (existing) { + // We found a duplicate coordinate + duplicateCoordinates++; + + // Track details for the first few duplicates + if (duplicateDetails.length < 5) { + const existingDetails = duplicateDetails.find(d => d.coordinate === coordinate); + if (existingDetails) { + existingDetails.count++; + existingDetails.events.push(`${event.id} (created_at: ${event.created_at})`); + } else { + duplicateDetails.push({ + coordinate, + count: 2, // existing + current + events: [ + `${existing.id} (created_at: ${existing.created_at})`, + `${event.id} (created_at: ${event.created_at})` + ] + }); + } + } + } + + // Keep the most recent event (highest created_at) + if (!existing || (event.created_at !== undefined && existing.created_at !== undefined && event.created_at > existing.created_at)) { + eventsByCoordinate.set(coordinate, event); + } + } + }); + }); + + // Log deduplication results if any duplicates were found + if (duplicateCoordinates > 0) { + console.log(`[eventDeduplication] Found ${duplicateCoordinates} duplicate events out of ${totalEvents} total events`); + console.log(`[eventDeduplication] Reduced to ${eventsByCoordinate.size} unique coordinates`); + console.log(`[eventDeduplication] Duplicate details:`, duplicateDetails); + } else if (totalEvents > 0) { + console.log(`[eventDeduplication] No duplicates found in ${totalEvents} events`); + } + + return eventsByCoordinate; +} + +/** + * Deduplicate and combine all events, keeping only the most recent version of replaceable events + * @param nonPublicationEvents Array of non-publication events + * @param validIndexEvents Set of valid index events + * @param contentEvents Set of content events + * @returns Array of deduplicated events + */ +export function deduplicateAndCombineEvents( + nonPublicationEvents: NDKEvent[], + validIndexEvents: Set, + contentEvents: Set +): NDKEvent[] { + // Track statistics for debugging + const initialCount = nonPublicationEvents.length + validIndexEvents.size + contentEvents.size; + let replaceableEventsProcessed = 0; + let duplicateCoordinatesFound = 0; + const duplicateDetails: Array<{ coordinate: string; count: number; events: string[] }> = []; + + // First, build coordinate map for replaceable events + const coordinateMap = new Map(); + const allEventsToProcess = [ + ...nonPublicationEvents, // Non-publication events fetched earlier + ...Array.from(validIndexEvents), + ...Array.from(contentEvents) + ]; + + // First pass: identify the most recent version of each replaceable event + allEventsToProcess.forEach(event => { + if (!event.id) return; + + // For replaceable events (30000-39999), track by coordinate + if (event.kind && event.kind >= 30000 && event.kind < 40000) { + replaceableEventsProcessed++; + const dTag = event.tagValue("d"); + const author = event.pubkey; + + if (dTag && author) { + const coordinate = `${event.kind}:${author}:${dTag}`; + const existing = coordinateMap.get(coordinate); + + if (existing) { + // We found a duplicate coordinate + duplicateCoordinatesFound++; + + // Track details for the first few duplicates + if (duplicateDetails.length < 5) { + const existingDetails = duplicateDetails.find(d => d.coordinate === coordinate); + if (existingDetails) { + existingDetails.count++; + existingDetails.events.push(`${event.id} (created_at: ${event.created_at})`); + } else { + duplicateDetails.push({ + coordinate, + count: 2, // existing + current + events: [ + `${existing.id} (created_at: ${existing.created_at})`, + `${event.id} (created_at: ${event.created_at})` + ] + }); + } + } + } + + // Keep the most recent version + if (!existing || (event.created_at !== undefined && existing.created_at !== undefined && event.created_at > existing.created_at)) { + coordinateMap.set(coordinate, event); + } + } + } + }); + + // Second pass: build final event map + const finalEventMap = new Map(); + const seenCoordinates = new Set(); + + allEventsToProcess.forEach(event => { + if (!event.id) return; + + // For replaceable events, only add if it's the chosen version + if (event.kind && event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tagValue("d"); + const author = event.pubkey; + + if (dTag && author) { + const coordinate = `${event.kind}:${author}:${dTag}`; + const chosenEvent = coordinateMap.get(coordinate); + + // Only add this event if it's the chosen one for this coordinate + if (chosenEvent && chosenEvent.id === event.id) { + if (!seenCoordinates.has(coordinate)) { + finalEventMap.set(event.id, event); + seenCoordinates.add(coordinate); + } + } + return; + } + } + + // Non-replaceable events are added directly + finalEventMap.set(event.id, event); + }); + + const finalCount = finalEventMap.size; + const reduction = initialCount - finalCount; + + // Log deduplication results if any duplicates were found + if (duplicateCoordinatesFound > 0) { + console.log(`[eventDeduplication] deduplicateAndCombineEvents: Found ${duplicateCoordinatesFound} duplicate coordinates out of ${replaceableEventsProcessed} replaceable events`); + console.log(`[eventDeduplication] deduplicateAndCombineEvents: Reduced from ${initialCount} to ${finalCount} events (${reduction} removed)`); + console.log(`[eventDeduplication] deduplicateAndCombineEvents: Duplicate details:`, duplicateDetails); + } else if (replaceableEventsProcessed > 0) { + console.log(`[eventDeduplication] deduplicateAndCombineEvents: No duplicates found in ${replaceableEventsProcessed} replaceable events`); + } + + return Array.from(finalEventMap.values()); +} + +/** + * Check if an event is a replaceable event (kinds 30000-39999) + * @param event The event to check + * @returns True if the event is replaceable + */ +export function isReplaceableEvent(event: NDKEvent): boolean { + return event.kind !== undefined && event.kind >= 30000 && event.kind < 40000; +} + +/** + * Get the coordinate for a replaceable event + * @param event The event to get the coordinate for + * @returns The coordinate string (kind:pubkey:d-tag) or null if not a valid replaceable event + */ +export function getEventCoordinate(event: NDKEvent): string | null { + if (!isReplaceableEvent(event)) { + return null; + } + + const dTag = event.tagValue("d"); + const author = event.pubkey; + + if (!dTag || !author) { + return null; + } + + return `${event.kind}:${author}:${dTag}`; +} \ No newline at end of file diff --git a/src/lib/utils/event_kind_utils.ts b/src/lib/utils/event_kind_utils.ts new file mode 100644 index 0000000..7d40715 --- /dev/null +++ b/src/lib/utils/event_kind_utils.ts @@ -0,0 +1,98 @@ +import type { EventKindConfig } from '$lib/stores/visualizationConfig'; + +/** + * Validates an event kind input value. + * @param value - The input value to validate (string or number). + * @param existingKinds - Array of existing event kind numbers to check for duplicates. + * @returns The validated kind number, or null if validation fails. + */ +export function validateEventKind( + value: string | number, + existingKinds: number[] +): { kind: number | null; error: string } { + // Convert to string for consistent handling + const strValue = String(value); + if (strValue === null || strValue === undefined || strValue.trim() === '') { + return { kind: null, error: '' }; + } + + const kind = parseInt(strValue.trim()); + if (isNaN(kind)) { + return { kind: null, error: 'Must be a number' }; + } + + if (kind < 0) { + return { kind: null, error: 'Must be non-negative' }; + } + + if (existingKinds.includes(kind)) { + return { kind: null, error: 'Already added' }; + } + + return { kind, error: '' }; +} + +/** + * Handles adding a new event kind with validation and state management. + * @param newKind - The new kind value to add. + * @param existingKinds - Array of existing event kind numbers. + * @param addKindFunction - Function to call when adding the kind. + * @param resetStateFunction - Function to call to reset the input state. + * @returns Object with success status and any error message. + */ +export function handleAddEventKind( + newKind: string, + existingKinds: number[], + addKindFunction: (kind: number) => void, + resetStateFunction: () => void +): { success: boolean; error: string } { + console.log('[handleAddEventKind] called with:', newKind); + + const validation = validateEventKind(newKind, existingKinds); + console.log('[handleAddEventKind] Validation result:', validation); + + if (validation.kind !== null) { + console.log('[handleAddEventKind] Adding event kind:', validation.kind); + addKindFunction(validation.kind); + resetStateFunction(); + return { success: true, error: '' }; + } else { + console.log('[handleAddEventKind] Validation failed:', validation.error); + return { success: false, error: validation.error }; + } +} + +/** + * Handles keyboard events for event kind input. + * @param e - The keyboard event. + * @param onEnter - Function to call when Enter is pressed. + * @param onEscape - Function to call when Escape is pressed. + */ +export function handleEventKindKeydown( + e: KeyboardEvent, + onEnter: () => void, + onEscape: () => void +): void { + if (e.key === 'Enter') { + onEnter(); + } else if (e.key === 'Escape') { + onEscape(); + } +} + +/** + * Gets the display name for an event kind. + * @param kind - The event kind number. + * @returns The display name for the kind. + */ +export function getEventKindDisplayName(kind: number): string { + switch (kind) { + case 30040: return 'Publication Index'; + case 30041: return 'Publication Content'; + case 30818: return 'Wiki'; + case 1: return 'Text Note'; + case 0: return 'Metadata'; + case 3: return 'Follow List'; + default: return `Kind ${kind}`; + } +} \ No newline at end of file diff --git a/src/lib/utils/event_search.ts b/src/lib/utils/event_search.ts index 25319c0..aa1e9a7 100644 --- a/src/lib/utils/event_search.ts +++ b/src/lib/utils/event_search.ts @@ -1,7 +1,8 @@ import { ndkInstance } from "../ndk.ts"; import { fetchEventWithFallback } from "./nostrUtils.ts"; import { nip19 } from "nostr-tools"; -import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import type { NDKFilter } from "@nostr-dev-kit/ndk"; import { get } from "svelte/store"; import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts"; import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; diff --git a/src/lib/utils/nostr_identifiers.ts b/src/lib/utils/nostr_identifiers.ts new file mode 100644 index 0000000..246fc9b --- /dev/null +++ b/src/lib/utils/nostr_identifiers.ts @@ -0,0 +1,88 @@ +import { VALIDATION } from './search_constants'; +import type { NostrEventId } from './nostr_identifiers'; + +/** + * Nostr identifier types + */ +export type NostrCoordinate = string; // kind:pubkey:d-tag format +export type NostrIdentifier = NostrEventId | NostrCoordinate; + +/** + * Interface for parsed Nostr coordinate + */ +export interface ParsedCoordinate { + kind: number; + pubkey: string; + dTag: string; +} + +/** + * Check if a string is a valid hex event ID + * @param id The string to check + * @returns True if it's a valid hex event ID + */ +export function isEventId(id: string): id is NostrEventId { + return new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(id); +} + +/** + * Check if a string is a valid Nostr coordinate (kind:pubkey:d-tag) + * @param coordinate The string to check + * @returns True if it's a valid coordinate + */ +export function isCoordinate(coordinate: string): coordinate is NostrCoordinate { + const parts = coordinate.split(':'); + if (parts.length < 3) return false; + + const [kindStr, pubkey, ...dTagParts] = parts; + + // Check if kind is a valid number + const kind = parseInt(kindStr, 10); + if (isNaN(kind) || kind < 0) return false; + + // Check if pubkey is a valid hex string + if (!isEventId(pubkey)) return false; + + // Check if d-tag exists (can contain colons) + if (dTagParts.length === 0) return false; + + return true; +} + +/** + * Parse a Nostr coordinate into its components + * @param coordinate The coordinate string to parse + * @returns Parsed coordinate or null if invalid + */ +export function parseCoordinate(coordinate: string): ParsedCoordinate | null { + if (!isCoordinate(coordinate)) return null; + + const parts = coordinate.split(':'); + const [kindStr, pubkey, ...dTagParts] = parts; + + return { + kind: parseInt(kindStr, 10), + pubkey, + dTag: dTagParts.join(':') // Rejoin in case d-tag contains colons + }; +} + +/** + * Create a coordinate string from components + * @param kind The event kind + * @param pubkey The author's public key + * @param dTag The d-tag value + * @returns The coordinate string + */ +export function createCoordinate(kind: number, pubkey: string, dTag: string): NostrCoordinate { + return `${kind}:${pubkey}:${dTag}`; +} + +/** + * Check if a string is any valid Nostr identifier + * @param identifier The string to check + * @returns True if it's a valid Nostr identifier + */ +export function isNostrIdentifier(identifier: string): identifier is NostrIdentifier { + return isEventId(identifier) || isCoordinate(identifier); +} \ No newline at end of file diff --git a/src/lib/utils/profileCache.ts b/src/lib/utils/profileCache.ts new file mode 100644 index 0000000..2a93a45 --- /dev/null +++ b/src/lib/utils/profileCache.ts @@ -0,0 +1,252 @@ +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import { ndkInstance } from "$lib/ndk"; +import { get } from "svelte/store"; +import { nip19 } from "nostr-tools"; + +interface ProfileData { + display_name?: string; + name?: string; + picture?: string; + about?: string; +} + +// Cache for user profiles +const profileCache = new Map(); + +/** + * Fetches profile data for a pubkey + * @param pubkey - The public key to fetch profile for + * @returns Profile data or null if not found + */ +async function fetchProfile(pubkey: string): Promise { + try { + const ndk = get(ndkInstance); + const profileEvents = await ndk.fetchEvents({ + kinds: [0], + authors: [pubkey], + limit: 1 + }); + + if (profileEvents.size === 0) { + return null; + } + + // Get the most recent profile event + const profileEvent = Array.from(profileEvents)[0]; + + try { + const content = JSON.parse(profileEvent.content); + return content as ProfileData; + } catch (e) { + console.error("Failed to parse profile content:", e); + return null; + } + } catch (e) { + console.error("Failed to fetch profile:", e); + return null; + } +} + +/** + * Gets the display name for a pubkey, using cache + * @param pubkey - The public key to get display name for + * @returns Display name, name, or shortened pubkey + */ +export async function getDisplayName(pubkey: string): Promise { + // Check cache first + if (profileCache.has(pubkey)) { + const profile = profileCache.get(pubkey)!; + return profile.display_name || profile.name || shortenPubkey(pubkey); + } + + // Fetch profile + const profile = await fetchProfile(pubkey); + if (profile) { + profileCache.set(pubkey, profile); + return profile.display_name || profile.name || shortenPubkey(pubkey); + } + + // Fallback to shortened pubkey + return shortenPubkey(pubkey); +} + +/** + * Batch fetches profiles for multiple pubkeys + * @param pubkeys - Array of public keys to fetch profiles for + * @param onProgress - Optional callback for progress updates + * @returns Array of profile events + */ +export async function batchFetchProfiles( + pubkeys: string[], + onProgress?: (fetched: number, total: number) => void +): Promise { + const allProfileEvents: NDKEvent[] = []; + + // Filter out already cached pubkeys + const uncachedPubkeys = pubkeys.filter(pk => !profileCache.has(pk)); + + if (uncachedPubkeys.length === 0) { + if (onProgress) onProgress(pubkeys.length, pubkeys.length); + return allProfileEvents; + } + + try { + const ndk = get(ndkInstance); + + // Report initial progress + const cachedCount = pubkeys.length - uncachedPubkeys.length; + if (onProgress) onProgress(cachedCount, pubkeys.length); + + // Batch fetch in chunks to avoid overwhelming relays + const CHUNK_SIZE = 50; + let fetchedCount = cachedCount; + + for (let i = 0; i < uncachedPubkeys.length; i += CHUNK_SIZE) { + const chunk = uncachedPubkeys.slice(i, Math.min(i + CHUNK_SIZE, uncachedPubkeys.length)); + + const profileEvents = await ndk.fetchEvents({ + kinds: [0], + authors: chunk + }); + + // Process each profile event + profileEvents.forEach((event: NDKEvent) => { + try { + const content = JSON.parse(event.content); + profileCache.set(event.pubkey, content as ProfileData); + allProfileEvents.push(event); + fetchedCount++; + } catch (e) { + console.error("Failed to parse profile content:", e); + } + }); + + // Update progress + if (onProgress) { + onProgress(fetchedCount, pubkeys.length); + } + } + + // Final progress update + if (onProgress) onProgress(pubkeys.length, pubkeys.length); + } catch (e) { + console.error("Failed to batch fetch profiles:", e); + } + + return allProfileEvents; +} + +/** + * Gets display name synchronously from cache + * @param pubkey - The public key to get display name for + * @returns Display name, name, or shortened pubkey + */ +export function getDisplayNameSync(pubkey: string): string { + if (profileCache.has(pubkey)) { + const profile = profileCache.get(pubkey)!; + return profile.display_name || profile.name || shortenPubkey(pubkey); + } + return shortenPubkey(pubkey); +} + +/** + * Shortens a pubkey for display + * @param pubkey - The public key to shorten + * @returns Shortened pubkey (first 8 chars...last 4 chars) + */ +function shortenPubkey(pubkey: string): string { + if (pubkey.length <= 12) return pubkey; + return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; +} + +/** + * Clears the profile cache + */ +export function clearProfileCache(): void { + profileCache.clear(); +} + +/** + * Extracts all pubkeys from events (authors and p tags) + * @param events - Array of events to extract pubkeys from + * @returns Set of unique pubkeys + */ +export function extractPubkeysFromEvents(events: NDKEvent[]): Set { + const pubkeys = new Set(); + + events.forEach(event => { + // Add author pubkey + if (event.pubkey) { + pubkeys.add(event.pubkey); + } + + // Add pubkeys from p tags + const pTags = event.getMatchingTags("p"); + pTags.forEach(tag => { + if (tag[1]) { + pubkeys.add(tag[1]); + } + }); + + // Extract pubkeys from content (nostr:npub1... format) + const npubPattern = /nostr:npub1[a-z0-9]{58}/g; + const matches = event.content?.match(npubPattern) || []; + matches.forEach(match => { + try { + const npub = match.replace('nostr:', ''); + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + pubkeys.add(decoded.data as string); + } + } catch (e) { + // Invalid npub, ignore + } + }); + }); + + return pubkeys; +} + +/** + * Replaces pubkeys in content with display names + * @param content - The content to process + * @returns Content with pubkeys replaced by display names + */ +export function replaceContentPubkeys(content: string): string { + if (!content) return content; + + // Replace nostr:npub1... references + const npubPattern = /nostr:npub[a-z0-9]{58}/g; + let result = content; + + const matches = content.match(npubPattern) || []; + matches.forEach(match => { + try { + const npub = match.replace('nostr:', ''); + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + const pubkey = decoded.data as string; + const displayName = getDisplayNameSync(pubkey); + result = result.replace(match, `@${displayName}`); + } + } catch (e) { + // Invalid npub, leave as is + } + }); + + return result; +} + +/** + * Replaces pubkey references in text with display names + * @param text - Text that may contain pubkey references + * @returns Text with pubkeys replaced by display names + */ +export function replacePubkeysWithDisplayNames(text: string): string { + // Match hex pubkeys (64 characters) + const pubkeyRegex = /\b[0-9a-fA-F]{64}\b/g; + + return text.replace(pubkeyRegex, (match) => { + return getDisplayNameSync(match); + }); +} \ No newline at end of file diff --git a/src/lib/utils/search_types.ts b/src/lib/utils/search_types.ts index 134ceff..167472e 100644 --- a/src/lib/utils/search_types.ts +++ b/src/lib/utils/search_types.ts @@ -1,4 +1,5 @@ -import { NDKEvent, NDKFilter, NDKSubscription } from "@nostr-dev-kit/ndk"; +import { NDKEvent, NDKSubscription } from "@nostr-dev-kit/ndk"; +import type { NDKFilter } from "@nostr-dev-kit/ndk"; /** * Extended NostrProfile interface for search results diff --git a/src/lib/utils/tag_event_fetch.ts b/src/lib/utils/tag_event_fetch.ts new file mode 100644 index 0000000..077a93e --- /dev/null +++ b/src/lib/utils/tag_event_fetch.ts @@ -0,0 +1,206 @@ +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import { ndkInstance } from "../ndk"; +import { get } from "svelte/store"; +import { extractPubkeysFromEvents, batchFetchProfiles } from "./profileCache"; + +// Constants for publication event kinds +const INDEX_EVENT_KIND = 30040; +const CONTENT_EVENT_KINDS = [30041, 30818]; + +/** + * Interface for tag expansion fetch results + */ +export interface TagExpansionResult { + publications: NDKEvent[]; + contentEvents: NDKEvent[]; +} + +/** + * Fetches publications and their content events from relays based on tags + * + * This function handles the relay-based fetching portion of tag expansion: + * 1. Fetches publication index events that have any of the specified tags + * 2. Extracts content event references from those publications + * 3. Fetches the referenced content events + * + * @param tags Array of tags to search for in publications + * @param existingEventIds Set of existing event IDs to avoid duplicates + * @param baseEvents Array of base events to check for existing content + * @param debug Optional debug function for logging + * @returns Promise resolving to publications and content events + */ +export async function fetchTaggedEventsFromRelays( + tags: string[], + existingEventIds: Set, + baseEvents: NDKEvent[], + debug?: (...args: any[]) => void +): Promise { + const log = debug || console.debug; + + log("Fetching from relays for tags:", tags); + + // Fetch publications that have any of the specified tags + const ndk = get(ndkInstance); + const taggedPublications = await ndk.fetchEvents({ + kinds: [INDEX_EVENT_KIND], + "#t": tags, // Match any of these tags + limit: 30 // Reasonable default limit + }); + + log("Found tagged publications from relays:", taggedPublications.size); + + // Filter to avoid duplicates + const newPublications = Array.from(taggedPublications).filter( + (event: NDKEvent) => !existingEventIds.has(event.id) + ); + + // Extract content event d-tags from new publications + const contentEventDTags = new Set(); + const existingContentDTags = new Set( + baseEvents + .filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)) + .map(e => e.tagValue("d")) + .filter(d => d !== undefined) + ); + + newPublications.forEach((event: NDKEvent) => { + const aTags = event.getMatchingTags("a"); + aTags.forEach((tag: string[]) => { + // Parse the 'a' tag identifier: kind:pubkey:d-tag + if (tag[1]) { + const parts = tag[1].split(':'); + if (parts.length >= 3) { + const dTag = parts.slice(2).join(':'); // Handle d-tags with colons + if (!existingContentDTags.has(dTag)) { + contentEventDTags.add(dTag); + } + } + } + }); + }); + + // Fetch the content events + let newContentEvents: NDKEvent[] = []; + if (contentEventDTags.size > 0) { + const contentEventsSet = await ndk.fetchEvents({ + kinds: CONTENT_EVENT_KINDS, + "#d": Array.from(contentEventDTags), // Use d-tag filter + }); + newContentEvents = Array.from(contentEventsSet); + } + + return { + publications: newPublications, + contentEvents: newContentEvents + }; +} + +/** + * Searches through already fetched events for publications with specified tags + * + * This function handles the local search portion of tag expansion: + * 1. Searches through existing events for publications with matching tags + * 2. Extracts content event references from those publications + * 3. Finds the referenced content events in existing events + * + * @param allEvents Array of all fetched events to search through + * @param tags Array of tags to search for in publications + * @param existingEventIds Set of existing event IDs to avoid duplicates + * @param baseEvents Array of base events to check for existing content + * @param debug Optional debug function for logging + * @returns Promise resolving to publications and content events + */ +export function findTaggedEventsInFetched( + allEvents: NDKEvent[], + tags: string[], + existingEventIds: Set, + baseEvents: NDKEvent[], + debug?: (...args: any[]) => void +): TagExpansionResult { + const log = debug || console.debug; + + log("Searching through already fetched events for tags:", tags); + + // Find publications in allEvents that have the specified tags + const taggedPublications = allEvents.filter(event => { + if (event.kind !== INDEX_EVENT_KIND) return false; + if (existingEventIds.has(event.id)) return false; // Skip base events + + // Check if event has any of the specified tags + const eventTags = event.getMatchingTags("t").map(tag => tag[1]); + return tags.some(tag => eventTags.includes(tag)); + }); + + const newPublications = taggedPublications; + log("Found", newPublications.length, "publications in fetched events"); + + // For content events, also search in allEvents + const existingContentDTags = new Set( + baseEvents + .filter(e => e.kind !== undefined && CONTENT_EVENT_KINDS.includes(e.kind)) + .map(e => e.tagValue("d")) + .filter(d => d !== undefined) + ); + + const contentEventDTags = new Set(); + newPublications.forEach((event: NDKEvent) => { + const aTags = event.getMatchingTags("a"); + aTags.forEach((tag: string[]) => { + // Parse the 'a' tag identifier: kind:pubkey:d-tag + if (tag[1]) { + const parts = tag[1].split(':'); + if (parts.length >= 3) { + const dTag = parts.slice(2).join(':'); // Handle d-tags with colons + if (!existingContentDTags.has(dTag)) { + contentEventDTags.add(dTag); + } + } + } + }); + }); + + // Find content events in allEvents + const newContentEvents = allEvents.filter(event => { + if (!CONTENT_EVENT_KINDS.includes(event.kind || 0)) return false; + const dTag = event.tagValue("d"); + return dTag !== undefined && contentEventDTags.has(dTag); + }); + + return { + publications: newPublications, + contentEvents: newContentEvents + }; +} + +/** + * Fetches profiles for new events and updates progress + * + * @param newPublications Array of new publication events + * @param newContentEvents Array of new content events + * @param onProgressUpdate Callback to update progress state + * @param debug Optional debug function for logging + * @returns Promise that resolves when profile fetching is complete + */ +export async function fetchProfilesForNewEvents( + newPublications: NDKEvent[], + newContentEvents: NDKEvent[], + onProgressUpdate: (progress: { current: number; total: number } | null) => void, + debug?: (...args: any[]) => void +): Promise { + const log = debug || console.debug; + + // Extract pubkeys from new events + const newPubkeys = extractPubkeysFromEvents([...newPublications, ...newContentEvents]); + + if (newPubkeys.size > 0) { + log("Fetching profiles for", newPubkeys.size, "new pubkeys from tag expansion"); + + onProgressUpdate({ current: 0, total: newPubkeys.size }); + + await batchFetchProfiles(Array.from(newPubkeys), (fetched, total) => { + onProgressUpdate({ current: fetched, total }); + }); + + onProgressUpdate(null); + } +} \ No newline at end of file diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 66e4516..91925ec 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -6,17 +6,37 @@ -->