diff --git a/.cursor/rules/code-review.mdc b/.cursor/rules/code-review.mdc new file mode 100644 index 0000000..119054d --- /dev/null +++ b/.cursor/rules/code-review.mdc @@ -0,0 +1,40 @@ +--- +description: +globs: +alwaysApply: false +--- +You are an agentic AI assistant that quickly identifies key focus areas in a git diff so that a developer can efficiently review files within a change set in a logical order. + +## Interaction Workflow + +### Prerequisites + +- Determine which branch the developer is working on. If the branch is the main branch, tell the developer that you cannot perform a code review on the main branch, and end your turn. + +### Project Structure + +This project broadly follows a model-view-controller (MVC) pattern. + +- The Model consists primarily of Nostr relays, accessed via WebSocket APIs. The Model layer also includes data stored in the web browser. +- The View is a reactive UI defined by SvelteKit pages and Svelte components. +- The Controller layer is defined by various TypeScript modules that provide utility functions, classes, singletons, and other facilities that prepare data for the view layer or handle user-provided data for to be saved to the browser or relays. + +### Additional Context + +- The primary branch for this repo is called `master`. + +### Expected Output + +- The developer may leave comments on the reviewed changes via an external PR tool, such as GitHub or OneDev, so specify filenames and line numbers for each highlighted item of code. +- Specify the context of highlighted items, such as function, class, or component names. +- Always explain why an item is worth the developer's particular attention. +- Keep the code diff surveys concise and to the point. + +### Code Review Order + +1. Obtain the diff of the current branch with the main branch. If necessary, ask the developer to provide the diff as context. +2. Read the diff and associated commit messages, if available, and give the developer a brief summary of the changes and key items to note. +3. Tell the developer you will provide a more detailed description of the changes. +4. Tell the developer to review model-level changes first. These may include changes to API clients, changes to database access, and changes to cache or browser storage patterns. +5. Next, point the developer's attention to changes in the controller/view controller layer. These may include changes to service classes, utility functions, and anything else that could be classified as "business logic". +6. Finally, draw the developer's attention to view/UI changes. In this project, view changes will be almost entirely in `.svelte` component files. \ No newline at end of file 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 ad65282..c5b7295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "tailwindcss": "^3.4.17", "tslib": "2.8.x", "typescript": "^5.8.3", - "vite": "^6.3.5", + "vite": "^7.0.5", "vitest": "^3.1.3" } }, @@ -588,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" }, @@ -606,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" }, @@ -618,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" @@ -628,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", @@ -643,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" @@ -653,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" @@ -666,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", @@ -690,6 +697,7 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -703,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" @@ -713,6 +722,7 @@ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, + "license": "Apache-2.0", "peer": true, "dependencies": { "@eslint/core": "^0.15.1", @@ -752,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" @@ -762,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", @@ -776,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" @@ -790,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" @@ -804,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" @@ -1548,6 +1563,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-6.0.1.tgz", "integrity": "sha512-mcWud3pYGPWM2Pphdj8G9Qiq24nZ8L4LB7coCUckUEy5Y7wOWGJ/enaZ4AtJTcSm5dNK1rIkBRoqt+ae4zlxcQ==", "dev": true, + "license": "MIT", "peerDependencies": { "@sveltejs/kit": "^2.0.0" } @@ -1612,6 +1628,7 @@ "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": "^5.0.0-next.1", "debug": "^4.4.1", @@ -1633,6 +1650,7 @@ "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.4.1" }, @@ -1967,19 +1985,22 @@ "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 + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", - "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "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": "~7.8.0" } @@ -2144,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" } @@ -2153,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", @@ -2240,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": { @@ -2467,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" @@ -2568,6 +2593,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -2591,6 +2617,7 @@ "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" }, @@ -3102,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": { @@ -3287,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" @@ -3300,6 +3329,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", @@ -3361,6 +3391,7 @@ "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.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3394,6 +3425,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -3444,6 +3476,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "dev": true, + "license": "ISC", "engines": { "node": ">= 6" } @@ -3453,6 +3486,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -3469,6 +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, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3487,6 +3522,7 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -3504,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" @@ -3526,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" }, @@ -3538,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" } @@ -3553,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" } @@ -3571,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": { @@ -3604,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": { @@ -3611,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": { @@ -3640,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" @@ -3691,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", @@ -3708,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", @@ -3722,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": { @@ -3957,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" @@ -4072,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" @@ -4082,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", @@ -4099,6 +4149,7 @@ "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" @@ -4304,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" @@ -4317,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": { @@ -4324,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": { @@ -4331,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": { @@ -4347,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" @@ -4365,13 +4421,15 @@ "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 + "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", @@ -4427,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" @@ -4602,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": { @@ -4778,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", @@ -4796,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" @@ -4812,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" @@ -4841,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" @@ -5134,6 +5198,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "engines": { "node": ">=18.0" }, @@ -5160,6 +5225,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "engines": { "node": ">=12.0" }, @@ -5189,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" @@ -5344,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" @@ -5505,6 +5573,7 @@ "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" }, @@ -5516,6 +5585,7 @@ "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" }, @@ -5560,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" @@ -5667,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" }, @@ -5829,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" @@ -5994,6 +6067,7 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, + "license": "MIT", "dependencies": { "readdirp": "^4.0.1" }, @@ -6009,6 +6083,7 @@ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.18.0" }, @@ -6022,6 +6097,7 @@ "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", @@ -6050,6 +6126,7 @@ "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": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6370,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" @@ -6412,7 +6490,8 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", @@ -6465,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" @@ -6476,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" @@ -6501,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" @@ -6733,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" @@ -6793,6 +6875,7 @@ "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" }, @@ -6830,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/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/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 @@ -->