From b7d690905e42b754e07e7ddd1819240c6c5a6768 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Tue, 9 Jun 2026 17:10:16 +0200 Subject: [PATCH] new plug: graph view (imported from separate repo) --- Makefile | 1 + package-lock.json | 359 ++++++ package.json | 3 + plugs/builtin_plugs.ts | 1 + plugs/object-graph/.gitignore | 1 + plugs/object-graph/object-graph.plug.yaml | 22 + plugs/object-graph/src/graph_builder.test.ts | 178 +++ plugs/object-graph/src/graph_builder.ts | 401 +++++++ plugs/object-graph/src/graph_html.ts | 27 + plugs/object-graph/src/model.ts | 114 ++ plugs/object-graph/src/object_graph.ts | 99 ++ plugs/object-graph/ui/colors.test.ts | 23 + plugs/object-graph/ui/colors.ts | 15 + plugs/object-graph/ui/components/app.tsx | 368 ++++++ .../ui/components/graph_canvas.tsx | 1005 +++++++++++++++++ plugs/object-graph/ui/components/header.tsx | 75 ++ plugs/object-graph/ui/components/sidebar.tsx | 346 ++++++ .../ui/components/visibility.test.ts | 136 +++ .../object-graph/ui/components/visibility.ts | 67 ++ plugs/object-graph/ui/index.tsx | 30 + plugs/object-graph/ui/object-graph.scss | 464 ++++++++ plugs/object-graph/ui/use_escape.ts | 15 + website/CHANGELOG.md | 5 +- website/Object Graph.md | 36 + website/Object.md | 4 + website/X-Ray.md | 2 + 26 files changed, 3795 insertions(+), 2 deletions(-) create mode 100644 plugs/object-graph/.gitignore create mode 100644 plugs/object-graph/object-graph.plug.yaml create mode 100644 plugs/object-graph/src/graph_builder.test.ts create mode 100644 plugs/object-graph/src/graph_builder.ts create mode 100644 plugs/object-graph/src/graph_html.ts create mode 100644 plugs/object-graph/src/model.ts create mode 100644 plugs/object-graph/src/object_graph.ts create mode 100644 plugs/object-graph/ui/colors.test.ts create mode 100644 plugs/object-graph/ui/colors.ts create mode 100644 plugs/object-graph/ui/components/app.tsx create mode 100644 plugs/object-graph/ui/components/graph_canvas.tsx create mode 100644 plugs/object-graph/ui/components/header.tsx create mode 100644 plugs/object-graph/ui/components/sidebar.tsx create mode 100644 plugs/object-graph/ui/components/visibility.test.ts create mode 100644 plugs/object-graph/ui/components/visibility.ts create mode 100644 plugs/object-graph/ui/index.tsx create mode 100644 plugs/object-graph/ui/object-graph.scss create mode 100644 plugs/object-graph/ui/use_escape.ts create mode 100644 website/Object Graph.md diff --git a/Makefile b/Makefile index 83781446..ce8bfbdb 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,7 @@ clean: rm -f silverbullet silverbullet-arm64 silverbullet-amd64 silverbullet-arm silverbullet.exe silverbullet-server-*.zip rm -f sb sb.exe sb-*.zip rm -rf plugs/configuration-manager/assets + rm -rf plugs/object-graph/assets check: npm run check diff --git a/package-lock.json b/package-lock.json index 85d8dca0..fe81dfac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,9 +34,11 @@ "@replit/codemirror-vim": "6.3.0", "commander": "^14.0.3", "crelt": "1.0.6", + "d3-force": "^3.0.0", "esbuild": "^0.27.3", "fast-diff": "1.3.0", "fast-glob": "^3.3.3", + "force-graph": "^1.49.0", "gitignore-parser": "0.0.2", "idb": "8.0.3", "js-yaml": "^4.1.1", @@ -55,6 +57,7 @@ "@biomejs/biome": "2.4.6", "@playwright/test": "^1.58.2", "@preact/preset-vite": "^2.10.3", + "@types/d3-force": "^3.0.10", "@types/gitignore-parser": "^0.0.3", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", @@ -2102,6 +2105,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2113,6 +2122,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2279,6 +2295,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2315,6 +2340,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2389,6 +2424,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2466,6 +2513,236 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2719,6 +2996,46 @@ "node": ">=8" } }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2799,6 +3116,24 @@ "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", "license": "MIT" }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2874,6 +3209,18 @@ "node": ">=6" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", @@ -2881,6 +3228,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3396,6 +3749,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", diff --git a/package.json b/package.json index d9566b95..42f3d708 100644 --- a/package.json +++ b/package.json @@ -96,8 +96,10 @@ "@replit/codemirror-vim": "6.3.0", "commander": "^14.0.3", "crelt": "1.0.6", + "d3-force": "^3.0.0", "esbuild": "^0.27.3", "fast-diff": "1.3.0", + "force-graph": "^1.49.0", "fast-glob": "^3.3.3", "gitignore-parser": "0.0.2", "idb": "8.0.3", @@ -114,6 +116,7 @@ "@biomejs/biome": "2.4.6", "@playwright/test": "^1.58.2", "@preact/preset-vite": "^2.10.3", + "@types/d3-force": "^3.0.10", "@types/gitignore-parser": "^0.0.3", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", diff --git a/plugs/builtin_plugs.ts b/plugs/builtin_plugs.ts index 8890fba9..cd8a0ba4 100644 --- a/plugs/builtin_plugs.ts +++ b/plugs/builtin_plugs.ts @@ -7,6 +7,7 @@ export const builtinPlugNames = [ "emoji", "image-viewer", "configuration-manager", + "object-graph", ]; export const builtinPlugPaths = builtinPlugNames.map( diff --git a/plugs/object-graph/.gitignore b/plugs/object-graph/.gitignore new file mode 100644 index 00000000..ee889666 --- /dev/null +++ b/plugs/object-graph/.gitignore @@ -0,0 +1 @@ +assets/ diff --git a/plugs/object-graph/object-graph.plug.yaml b/plugs/object-graph/object-graph.plug.yaml new file mode 100644 index 00000000..c9da10f6 --- /dev/null +++ b/plugs/object-graph/object-graph.plug.yaml @@ -0,0 +1,22 @@ +name: object-graph +build: + - in: ./ui/index.tsx + out: ./assets/object-graph.js + - type: sass + in: ./ui/object-graph.scss + out: ./assets/object-graph.css +assets: + - "assets/**" +functions: + showGraph: + path: ./src/object_graph.ts:showGraph + command: + name: "Graph: Explore" + key: "Ctrl-Shift-g" + mac: "Cmd-Shift-g" + showGlobalGraph: + path: ./src/object_graph.ts:showGlobalGraph + command: + name: "Graph: Global Page Map" + expandObject: + path: ./src/object_graph.ts:expandObject diff --git a/plugs/object-graph/src/graph_builder.test.ts b/plugs/object-graph/src/graph_builder.test.ts new file mode 100644 index 00000000..3383072a --- /dev/null +++ b/plugs/object-graph/src/graph_builder.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { + classifyKind, + collapseEdges, + deriveTagFields, + endpointMatchesRef, + isCoreTag, + type RelationRow, + stripPagePos, +} from "./graph_builder.ts"; + +describe("stripPagePos", () => { + it("returns bare refs unchanged", () => { + expect(stripPagePos("Page")).toBe("Page"); + expect(stripPagePos("Drivers/Max Verstappen")).toBe( + "Drivers/Max Verstappen", + ); + }); + + it("strips @pos, #header, $anchor", () => { + expect(stripPagePos("Page@744")).toBe("Page"); + expect(stripPagePos("Page#header")).toBe("Page"); + expect(stripPagePos("Page$anchor")).toBe("Page"); + }); + + it("leaves url and file refs untouched", () => { + expect(stripPagePos("https://example.com/path#x")).toBe( + "https://example.com/path#x", + ); + expect(stripPagePos("docs.pdf")).toBe("docs.pdf"); + }); +}); + +describe("endpointMatchesRef", () => { + it("matches exact refs", () => { + expect(endpointMatchesRef("Page", "Page")).toBe(true); + }); + + it("matches positional / header / anchor variants", () => { + expect(endpointMatchesRef("Page@123", "Page")).toBe(true); + expect(endpointMatchesRef("Page#h1", "Page")).toBe(true); + expect(endpointMatchesRef("Page$anchor", "Page")).toBe(true); + }); + + it("rejects prefix overlap that isn't a real boundary", () => { + expect(endpointMatchesRef("Pages", "Page")).toBe(false); + expect(endpointMatchesRef("Page/sub", "Page")).toBe(false); + expect(endpointMatchesRef("Page-x", "Page")).toBe(false); + }); + + it("rejects unrelated endpoints", () => { + expect(endpointMatchesRef("Other", "Page")).toBe(false); + }); +}); + +describe("classifyKind", () => { + it("identifies URLs", () => { + expect(classifyKind("https://example.com")).toBe("url"); + expect(classifyKind("http://localhost:3000")).toBe("url"); + }); + + it("identifies files (single dot, non-md)", () => { + expect(classifyKind("doc.pdf")).toBe("file"); + expect(classifyKind("notes.txt")).toBe("file"); + }); + + it("treats .md and pathy refs as pages", () => { + expect(classifyKind("Notes")).toBe("page"); + expect(classifyKind("Notes/2025")).toBe("page"); + expect(classifyKind("Notes.md")).toBe("page"); + }); +}); + +describe("isCoreTag", () => { + it("recognises structural tags", () => { + expect(isCoreTag("page")).toBe(true); + expect(isCoreTag("item")).toBe(true); + expect(isCoreTag("task")).toBe(true); + expect(isCoreTag("block")).toBe(true); + }); + + it("recognises meta and meta/* tags", () => { + expect(isCoreTag("meta")).toBe(true); + expect(isCoreTag("meta/library")).toBe(true); + }); + + it("rejects user tags", () => { + expect(isCoreTag("driver")).toBe(false); + expect(isCoreTag("project")).toBe(false); + }); +}); + +describe("deriveTagFields", () => { + it("uses hostTag as rootTag for ordinary structural objects", () => { + const r = deriveTagFields(["page", "driver"], "page"); + expect(r.rootTag).toBe("page"); + expect(r.primaryTag).toBe("driver"); + expect(r.tags).toEqual(["driver"]); + }); + + it("promotes item to task when the task tag is present", () => { + const r = deriveTagFields(["item", "task", "todo"], "item"); + expect(r.rootTag).toBe("task"); + expect(r.primaryTag).toBe("todo"); + }); + + it("filters core + meta tags out of the user-tag list", () => { + const r = deriveTagFields( + ["page", "meta", "meta/template", "research"], + "page", + ); + expect(r.tags).toEqual(["research"]); + expect(r.primaryTag).toBe("research"); + }); + + it("returns null rootTag for non-core host tags", () => { + expect(deriveTagFields(["foo"], "foo").rootTag).toBeNull(); + }); +}); + +describe("collapseEdges", () => { + const baseRow = (overrides: Partial): RelationRow => ({ + from: "A", + to: "B", + kind: "mention", + page: "A", + ...overrides, + }); + + it("passes through a single mention as-is", () => { + const out = collapseEdges([baseRow({})]); + expect(out).toHaveLength(1); + expect(out[0].source).toBe("A"); + expect(out[0].target).toBe("B"); + expect(out[0].label).toBe("mention"); + expect(out[0].undirected).toBe(false); + }); + + it("collapses reciprocal co-mention pairs into one undirected edge", () => { + const rows: RelationRow[] = [ + baseRow({ kind: "co-mention", via: "page1" }), + baseRow({ + from: "B", + to: "A", + kind: "co-mention", + via: "page1", + page: "page1", + }), + ]; + const out = collapseEdges(rows); + expect(out).toHaveLength(1); + expect(out[0].undirected).toBe(true); + expect(out[0].refs).toHaveLength(2); + }); + + it("keeps distinct co-mention pairs separate when 'via' differs", () => { + const rows: RelationRow[] = [ + baseRow({ kind: "co-mention", via: "p1" }), + baseRow({ + from: "B", + to: "A", + kind: "co-mention", + via: "p2", + }), + ]; + expect(collapseEdges(rows)).toHaveLength(2); + }); + + it("uses the relation kind as the edge label", () => { + const out = collapseEdges([baseRow({ kind: "principal" })]); + expect(out[0].label).toBe("principal"); + }); + + it("preserves provenance positions from the range tuple", () => { + const out = collapseEdges([baseRow({ range: [42, 60], snippet: "hello" })]); + expect(out[0].refs[0]).toEqual({ page: "A", pos: 42, snippet: "hello" }); + }); +}); diff --git a/plugs/object-graph/src/graph_builder.ts b/plugs/object-graph/src/graph_builder.ts new file mode 100644 index 00000000..c4cb44ef --- /dev/null +++ b/plugs/object-graph/src/graph_builder.ts @@ -0,0 +1,401 @@ +import { index, lua } from "@silverbulletmd/silverbullet/syscalls"; +import type { + Edge, + EdgeProvenance, + ExpansionResult, + GraphUniverse, + ObjectKind, + ObjectNode, +} from "./model.ts"; + +/** + * Core tags published by SilverBullet's indexer. These are NEVER shown in + * the "Tags" sidebar (which is reserved for user tags); they appear in the + * "Root tags" sidebar instead, and a single one is stored as each node's + * `rootTag`. + */ +const CORE_TAGS = new Set([ + "page", + "item", + "task", + "header", + "paragraph", + "block", + "meta", + "aspiring-page", + "document", + "url", +]); + +export function isCoreTag(t: string): boolean { + return CORE_TAGS.has(t) || t.startsWith("meta/"); +} + +export type RelationRow = { + from: string; + to: string; + fromTag?: string; + toTag?: string; + kind: string; + via?: string; + page: string; + range?: [number, number]; + snippet?: string; +}; + +type IndexedObject = { + ref?: string; + name?: string; + tag?: string; + tags?: string[]; + pageDecoration?: { prefix?: string }; + [k: string]: unknown; +}; + +/** + * Strip positional / header / anchor suffixes from a page-style ref so a + * link to `Page@744` (or `Page#header`, `Page$anchor`) lands on the bare + * `Page` node. URL / file refs are untouched. + */ +export function stripPagePos(ref: string): string { + if (classifyKind(ref) !== "page") return ref; + return ref.split(/[@#$]/)[0] || ref; +} + +/** + * Graph cache populated lazily on the first call and reused across every + * subsequent `expandObject` / `buildGlobalGraph` round-trip the panel makes. + * SilverBullet's `index.queryLuaObjects` isn't cheap; one bulk pull + in-memory filter is much faster overall than + * a per-ref query for every expansion. The cache is cleared by + * `invalidateGraphCache()` at the start of each top-level command so a + * freshly opened modal sees fresh data. + */ +type GraphCache = { + relations: RelationRow[]; + pagesByName: Map; +}; +let cache: GraphCache | null = null; + +export function invalidateGraphCache(): void { + cache = null; +} + +async function ensureCache(): Promise { + if (cache) return cache; + const [relations, pages] = (await Promise.all([ + index.queryLuaObjects("relation", { objectVariable: "r" }), + index.queryLuaObjects("page", { objectVariable: "p" }), + ])) as [RelationRow[], IndexedObject[]]; + const pagesByName = new Map(); + for (const p of pages) { + const name = String(p.name ?? ""); + if (name) pagesByName.set(name, p); + } + cache = { relations, pagesByName }; + return cache; +} + +/** + * Match an edge endpoint against a bare ref, including positional / + * header / anchor variants (`Page@123`, `Page#header`, `Page$anchor`). + */ +export function endpointMatchesRef(endpoint: string, ref: string): boolean { + if (endpoint === ref) return true; + if (!endpoint.startsWith(ref)) return false; + const c = endpoint.charAt(ref.length); + return c === "@" || c === "#" || c === "$"; +} + +async function queryRelations(ref: string): Promise { + const { relations } = await ensureCache(); + return relations.filter( + (r) => endpointMatchesRef(r.from, ref) || endpointMatchesRef(r.to, ref), + ); +} + +/** + * Collapse co-mention pairs in opposite directions sharing the same `via` + * into a single undirected edge. Other parallel edges (different `kind`) + * remain distinct. + */ +export function collapseEdges(rows: RelationRow[]): Edge[] { + const out: Edge[] = []; + const seenCoMention = new Map(); // key -> out index + + for (const r of rows) { + const label = r.kind; + const prov: EdgeProvenance = { + page: r.page, + pos: r.range?.[0], + snippet: r.snippet, + }; + + if (r.kind === "co-mention") { + const k = [r.from, r.to, r.via ?? ""].sort().join(" "); + const existing = seenCoMention.get(k); + if (existing !== undefined) { + out[existing].refs.push(prov); + out[existing].undirected = true; + continue; + } + seenCoMention.set(k, out.length); + out.push({ + source: r.from, + target: r.to, + label, + kind: r.kind, + refs: [prov], + undirected: false, // becomes true when the reverse pair lands + }); + continue; + } + + out.push({ + source: r.from, + target: r.to, + label, + kind: r.kind, + refs: [prov], + undirected: false, + }); + } + + return out; +} + +/** + * Resolve a ref to an indexed object. Pages are served instantly from the + * in-memory cache (the common case). Items and blocks fall back to a + * single targeted query each — they're rare as relation endpoints, so + * pre-loading all of them would cost more than the occasional miss. + */ +async function findIndexed( + ref: string, +): Promise<{ tag: string; obj: IndexedObject } | null> { + const { pagesByName } = await ensureCache(); + const page = pagesByName.get(ref); + if (page) return { tag: "page", obj: page }; + const byRefExpr = await lua.parseExpression("o.ref == ref"); + const queryFor = (tag: string) => + index.queryLuaObjects( + tag, + { objectVariable: "o", where: byRefExpr }, + { ref }, + ) as Promise; + const [items, blocks] = await Promise.all([ + queryFor("item"), + queryFor("block"), + ]); + if (items.length) return { tag: "item", obj: items[0] }; + if (blocks.length) return { tag: "block", obj: blocks[0] }; + return null; +} + +export function classifyKind(ref: string): ObjectKind { + if (/^https?:\/\//.test(ref)) return "url"; + if (/^[^/]+\.[^/]+$/.test(ref) && !ref.endsWith(".md")) return "file"; + return "page"; +} + +function deriveTitle( + ref: string, + kind: ObjectKind, + obj: IndexedObject | null, +): string { + if (obj?.name) return String(obj.name); + if (kind === "url" || kind === "file") return ref.split(/[\\/]/).pop() ?? ref; + return ref; +} + +export function deriveTagFields( + allTags: string[], + hostTag: string, +): { + rootTag: string | null; + primaryTag: string | null; + tags: string[]; +} { + // `hostTag` is the structural tag the indexer keyed on (page/item/block). + // SilverBullet's indexer also exposes additional user tags via `tags`. + const hasTask = allTags.includes("task"); + let rootTag: string | null; + if (hostTag === "item" && hasTask) rootTag = "task"; + else if (CORE_TAGS.has(hostTag)) rootTag = hostTag; + else rootTag = null; + + const userTags = allTags.filter((t) => !isCoreTag(t)); + return { + rootTag, + primaryTag: userTags[0] ?? null, + tags: userTags, + }; +} + +// Project an already-fetched indexed object into the panel's ObjectNode shape. +function projectIndexed( + ref: string, + structuralTag: string, + obj: IndexedObject, +): ObjectNode { + const tagsField = Array.isArray(obj.tags) ? obj.tags.map(String) : []; + const allTags = tagsField.includes(structuralTag) + ? tagsField + : [structuralTag, ...tagsField]; + const { rootTag, primaryTag, tags } = deriveTagFields(allTags, structuralTag); + const kind: ObjectKind = + structuralTag === "page" + ? "page" + : structuralTag === "item" || structuralTag === "task" + ? "item" + : "block"; + return { + ref, + kind, + title: deriveTitle(ref, kind, obj), + rootTag, + primaryTag, + tags, + dangling: false, + prefix: obj.pageDecoration?.prefix, + attributes: obj as Record, + }; +} + +function stubNode( + ref: string, + kind: ObjectKind, + dangling: boolean, +): ObjectNode { + return { + ref, + kind, + title: deriveTitle(ref, kind, null), + rootTag: null, + primaryTag: null, + tags: [], + dangling, + attributes: { ref, ...(dangling ? { dangling: true } : {}) }, + }; +} + +async function resolveObject(ref: string): Promise { + const kindHint = classifyKind(ref); + if (kindHint === "url" || kindHint === "file") { + return stubNode(ref, kindHint, false); + } + const found = await findIndexed(ref); + if (!found) return stubNode(ref, "page", true); + return projectIndexed(ref, found.tag, found.obj); +} + +export async function expandObject(ref: string): Promise { + const normRef = stripPagePos(ref); + const rows = await queryRelations(normRef); + // Normalize endpoints so positional variants (`Page@123`) collapse to the + // bare page ref across the whole object graph. + const edges = collapseEdges(rows).map((e) => ({ + ...e, + source: stripPagePos(e.source), + target: stripPagePos(e.target), + })); + + const neighborRefs = new Set(); + for (const e of edges) { + if (e.source !== normRef) neighborRefs.add(e.source); + if (e.target !== normRef) neighborRefs.add(e.target); + } + + const [object, neighbors] = await Promise.all([ + resolveObject(normRef), + Promise.all([...neighborRefs].map(resolveObject)), + ]); + + return { object, neighbors, edges }; +} + +/** + * Build the data for the global view: every page + every relation in the + * space. The caller is expected to default the label filter to "mention + * only" — but the data is here so the user can toggle other labels on + * without a refetch. `rootRef` picks the anchor (current page if available). + */ +export async function buildGlobalGraph( + rootRef: string | null, +): Promise { + const { relations, pagesByName } = await ensureCache(); + const allPages = [...pagesByName.values()]; + // Equivalent of `index.contentPages()`: drop meta-tagged pages. + const isMeta = (p: IndexedObject) => { + const tags = Array.isArray(p.tags) ? (p.tags as string[]) : []; + return tags.some((t) => t === "meta" || t.startsWith("meta/")); + }; + const pages = allPages.filter((p) => !isMeta(p)); + const metaRefs = new Set( + allPages.filter(isMeta).map((p) => String(p.name ?? "")), + ); + if (pages.length === 0) return null; + const rows = relations; + + const nodeByRef = new Map(); + for (const p of pages) { + const ref = String(p.name ?? p.ref ?? ""); + if (!ref) continue; + nodeByRef.set(ref, projectIndexed(ref, "page", p)); + } + + // Drop edges that touch meta pages, and constrain the global view to + // page-to-page edges only: URL / file / item / block endpoints are + // out of scope here. They'd otherwise drag in non-page nodes (random + // URLs and so on) that the Global Page Map promises not to show. + const edges = collapseEdges(rows) + .map((e) => ({ + ...e, + source: stripPagePos(e.source), + target: stripPagePos(e.target), + })) + .filter( + (e) => + !metaRefs.has(e.source) && + !metaRefs.has(e.target) && + nodeByRef.has(e.source) && + nodeByRef.has(e.target), + ); + + // Choose the root: prefer the caller-supplied ref when available; + // otherwise pick an arbitrary page (first by name). + const anchor = + rootRef && nodeByRef.has(rootRef) + ? rootRef + : [...nodeByRef.keys()].sort()[0]; + const object = nodeByRef.get(anchor)!; + const neighbors = [...nodeByRef.values()].filter((n) => n.ref !== anchor); + + return { object, neighbors, edges }; +} + +/** + * Distinct root tags, user tags, and relation labels present in the space. + * Drives the sidebar's option lists so checkboxes show every value the + * user might want to toggle, not just what's in the current explored set. + */ +export async function buildUniverse(): Promise { + const { relations, pagesByName } = await ensureCache(); + + // User tags: every non-core tag attached to any page in the space. + const tagSet = new Set(); + for (const p of pagesByName.values()) { + const tags = Array.isArray(p.tags) ? (p.tags as string[]) : []; + for (const t of tags) { + if (isCoreTag(t)) continue; + tagSet.add(t); + } + } + const tags = [...tagSet].sort(); + + // Relation labels: distinct `kind` across all relations. + const labelSet = new Set(); + for (const r of relations) labelSet.add(r.kind); + const labels = [...labelSet].sort(); + + return { tags, labels }; +} diff --git a/plugs/object-graph/src/graph_html.ts b/plugs/object-graph/src/graph_html.ts new file mode 100644 index 00000000..10aebb9c --- /dev/null +++ b/plugs/object-graph/src/graph_html.ts @@ -0,0 +1,27 @@ +import { asset } from "@silverbulletmd/silverbullet/syscalls"; +import { panelStyles } from "@silverbulletmd/silverbullet/ui"; +import type { RootViewModel } from "./model.ts"; + +const PLUG_NAME = "object-graph"; + +export async function buildGraphHtml( + view: RootViewModel, +): Promise<{ html: string; script: string }> { + const [preamble, css, jsTemplate] = await Promise.all([ + // Shared component stylesheet (`.sb-*`) + the user's space styles, so the + // standard UI components render themed; our own CSS is appended after so + // it stays highest-precedence for graph-specific styling. + panelStyles(), + asset.readAsset(PLUG_NAME, "assets/object-graph.css"), + asset.readAsset(PLUG_NAME, "assets/object-graph.js"), + ]); + + const html = + `${preamble}
`; + + // `var` (not `const`) so the declaration hoists into the eval'd scope and + // is lexically visible to the IIFE the bundled script wraps us in. + const script = `var __GRAPH = ${JSON.stringify(view)};\n${jsTemplate}`; + + return { html, script }; +} diff --git a/plugs/object-graph/src/model.ts b/plugs/object-graph/src/model.ts new file mode 100644 index 00000000..7a390239 --- /dev/null +++ b/plugs/object-graph/src/model.ts @@ -0,0 +1,114 @@ +// The relation `kind` is either a reserved structural value (`mention`, +// `co-mention`) or a user predicate (e.g. `spouse`). Mirrors `plugs/index/relation.ts`. +export const STRUCTURAL_KINDS = new Set(["mention", "co-mention"]); + +export type ObjectKind = "page" | "item" | "block" | "url" | "file"; + +/** + * A node in the explored graph. Carries enough data for the panel to render + * the node, drive filters, and populate the right-panel object view. + */ +export type ObjectNode = { + ref: string; + kind: ObjectKind; + title: string; + // Single core tag (page/item/task/block); drives the Root-tags filter. + rootTag: string | null; + // First non-core tag; drives node color. null when none. + primaryTag: string | null; + // All non-core tags; drives the Tags filter. + tags: string[]; + dangling: boolean; + // pageDecoration.prefix passthrough (e.g. emoji prefix). + prefix?: string; + // Full indexed payload, rendered verbatim into the object view. + attributes: Record; +}; + +export type EdgeProvenance = { + page: string; + pos?: number; + snippet?: string; +}; + +/** + * One edge between two ObjectNodes. Parallel typed edges between the same + * pair stay distinct. Co-mention pairs in opposite directions are collapsed + * into one record with `undirected: true`. + */ +export type Edge = { + source: string; + target: string; + label: string; // == kind + kind: string; + refs: EdgeProvenance[]; + undirected: boolean; +}; + +export type ExpansionResult = { + object: ObjectNode; + neighbors: ObjectNode[]; + edges: Edge[]; +}; + +export type Filters = { + hiddenTags: string[]; + hiddenLabels: string[]; + // When true, edge labels on the canvas are suppressed entirely. + hideEdgeLabels: boolean; + // When true, nodes with no visible incoming or outgoing relation in + // the current filter set are hidden (except the root). + hideOrphans: boolean; +}; + +// Tunable force-simulation knobs exposed as sliders in the sidebar. +export type ForceSettings = { + centerStrength: number; + chargeStrength: number; + linkDistance: number; + linkStrength: number; +}; + +export const defaultForceSettings: ForceSettings = { + centerStrength: 0.18, + chargeStrength: -430, + linkDistance: 223, + linkStrength: 0.1, +}; + +export const defaultFilters: Filters = { + hiddenTags: [], + hiddenLabels: [], + hideEdgeLabels: false, + hideOrphans: true, +}; + +/** + * Universe of filter options that exist in the whole space, regardless of + * what the user has currently explored. Drives the sidebar's option lists + * so checkboxes for not-yet-visible tags / labels remain togglable. + * Counts in the sidebar still reflect the explored subgraph. + */ +export type GraphUniverse = { + tags: string[]; + labels: string[]; +}; + +export type RootViewModel = { + root: ExpansionResult; + universe: GraphUniverse; + filters: Filters; + forces: ForceSettings; + /** + * When true, the panel marks all of `root.neighbors` as already-expanded + * (instead of the default ghost state). Used by the global view, which + * ships every page at once and lets the user trim down rather than walk out. + */ + initialAllExpanded?: boolean; + /** + * When false, the panel does not persist in-session filter changes back + * to the datastore. Used by transient views like the global view so they + * don't pollute the local-view filter preferences. + */ + persistFilters?: boolean; +}; diff --git a/plugs/object-graph/src/object_graph.ts b/plugs/object-graph/src/object_graph.ts new file mode 100644 index 00000000..361c7ed0 --- /dev/null +++ b/plugs/object-graph/src/object_graph.ts @@ -0,0 +1,99 @@ +import { datastore, editor } from "@silverbulletmd/silverbullet/syscalls"; +import { + buildGlobalGraph, + buildUniverse, + expandObject as expandObjectImpl, + invalidateGraphCache, +} from "./graph_builder.ts"; +import { buildGraphHtml } from "./graph_html.ts"; +import { + defaultFilters, + defaultForceSettings, + type ExpansionResult, + type Filters, + type ForceSettings, + type RootViewModel, +} from "./model.ts"; + +const FILTERS_KEY = ["object-graph", "filters"]; +const FORCES_KEY = ["object-graph", "forces"]; + +async function loadFilters(): Promise { + const raw = (await datastore.get(FILTERS_KEY)) as + | Partial + | undefined; + if (!raw) return defaultFilters; + return { ...defaultFilters, ...raw } as Filters; +} + +async function loadForces(): Promise { + const raw = (await datastore.get(FORCES_KEY)) as + | Partial + | undefined; + if (!raw) return defaultForceSettings; + return { ...defaultForceSettings, ...raw } as ForceSettings; +} + +export async function showGraph() { + const currentPage = await editor.getCurrentPage(); + if (!currentPage) { + await editor.flashNotification("Graph: no current page", "error"); + return; + } + // Each top-level open of the modal gets fresh data. Subsequent + // `expandObject` round-trips from the panel reuse the populated cache. + invalidateGraphCache(); + const [root, universe, filters, forces] = await Promise.all([ + expandObjectImpl(currentPage), + buildUniverse(), + loadFilters(), + loadForces(), + ]); + const view: RootViewModel = { root, universe, filters, forces }; + const { html, script } = await buildGraphHtml(view); + await editor.showPanel("modal", 100, html, script); +} + +/** + * Called from the panel via system.invokeFunction("object-graph.expandObject", ref). + * Pure: takes a ref, returns an ExpansionResult, no side effects. + */ +export async function expandObject(ref: string): Promise { + return expandObjectImpl(ref); +} + +/** + * "Graph: Global Page Map" — all pages + every relation in the space, + * pre-expanded. Filter state defaults to "mention only" and is not + * persisted (so the local view's saved filters aren't disturbed). + */ +export async function showGlobalGraph() { + invalidateGraphCache(); + const [root, universe] = await Promise.all([ + buildGlobalGraph((await editor.getCurrentPage()) ?? null), + buildUniverse(), + ]); + if (!root) { + await editor.flashNotification("Graph: no pages in space", "error"); + return; + } + // Default the global view to "mention only" — every other relation + // label in the universe is hidden but can be toggled on without a + // refetch (the full relation set is already loaded). + const hiddenLabels = universe.labels.filter((l) => l !== "mention"); + const view: RootViewModel = { + root, + universe, + filters: { + hiddenTags: [], + hiddenLabels, + hideEdgeLabels: true, + hideOrphans: false, + }, + forces: await loadForces(), + initialAllExpanded: true, + persistFilters: false, + }; + const { html, script } = await buildGraphHtml(view); + await editor.showPanel("modal", 100, html, script); +} diff --git a/plugs/object-graph/ui/colors.test.ts b/plugs/object-graph/ui/colors.test.ts new file mode 100644 index 00000000..c6f6ecb1 --- /dev/null +++ b/plugs/object-graph/ui/colors.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { colorForTag, UNTAGGED_COLOR_VAR } from "./colors.ts"; + +describe("colorForTag", () => { + it("returns the untagged CSS variable for null", () => { + expect(colorForTag(null)).toBe(UNTAGGED_COLOR_VAR); + }); + + it("is deterministic — same tag yields the same color", () => { + expect(colorForTag("driver")).toBe(colorForTag("driver")); + expect(colorForTag("research")).toBe(colorForTag("research")); + }); + + it("produces different colors for distinct tags (typically)", () => { + // Hash collisions are possible in principle; the F1 demo tags do not collide. + expect(colorForTag("driver")).not.toBe(colorForTag("team")); + expect(colorForTag("driver")).not.toBe(colorForTag("person")); + }); + + it("returns hsl() strings", () => { + expect(colorForTag("anything")).toMatch(/^hsl\(\d+ \d+% \d+%\)$/); + }); +}); diff --git a/plugs/object-graph/ui/colors.ts b/plugs/object-graph/ui/colors.ts new file mode 100644 index 00000000..53d7470a --- /dev/null +++ b/plugs/object-graph/ui/colors.ts @@ -0,0 +1,15 @@ +// Color used for nodes/swatches with no user tag. The CSS custom +// property is defined in the stylesheet; the hex is the static fallback +// for canvas drawing (which can't resolve vars). +export const UNTAGGED_COLOR_VAR = "var(--gv-untagged, #9e9e9e)"; + +// Deterministic color per user tag: stable hash → HSL hue with fixed +// saturation and lightness. `null` (no tag) returns the untagged fallback. +export function colorForTag(tag: string | null): string { + if (!tag) return UNTAGGED_COLOR_VAR; + let h = 0; + for (let i = 0; i < tag.length; i++) { + h = (h * 31 + tag.charCodeAt(i)) >>> 0; + } + return `hsl(${h % 360} 60% 55%)`; +} diff --git a/plugs/object-graph/ui/components/app.tsx b/plugs/object-graph/ui/components/app.tsx new file mode 100644 index 00000000..31f665db --- /dev/null +++ b/plugs/object-graph/ui/components/app.tsx @@ -0,0 +1,368 @@ +import { datastore, system } from "@silverbulletmd/silverbullet/syscalls"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "preact/hooks"; +import type { + Edge, + ExpansionResult, + Filters, + ForceSettings, + ObjectNode, + RootViewModel, +} from "../../src/model.ts"; +import { GraphCanvas } from "./graph_canvas.tsx"; +import { Header } from "./header.tsx"; +import { Sidebar } from "./sidebar.tsx"; +import { computeVisible, type NodeState } from "./visibility.ts"; + +const FILTERS_KEY = ["plug", "object-graph", "filters"]; +const FORCES_KEY = ["plug", "object-graph", "forces"]; + +function edgeKey(e: Edge): string { + return `${e.source} ${e.target} ${e.label} ${e.kind}`; +} + +export function App({ vm }: { vm: RootViewModel }) { + const [nodes, setNodes] = useState>(() => { + const m = new Map(); + const initialStatus: NodeState["status"] = vm.initialAllExpanded + ? "expanded" + : "ghost"; + m.set(vm.root.object.ref, { node: vm.root.object, status: "expanded" }); + for (const n of vm.root.neighbors) { + if (!m.has(n.ref)) m.set(n.ref, { node: n, status: initialStatus }); + } + return m; + }); + const [edges, setEdges] = useState(() => [...vm.root.edges]); + // Derived dedupe index kept in lock-step with `edges`. Lives in a ref + // (not state) because we mutate it in place and never want it to be a + // trigger for re-renders. + const edgeKeysRef = useRef>(new Set(vm.root.edges.map(edgeKey))); + const [selectedRef, setSelectedRef] = useState( + vm.root.object.ref, + ); + const [filters, setFilters] = useState(vm.filters); + const [sidebarWidth, setSidebarWidth] = useState(230); + const [forces, setForces] = useState(vm.forces); + const [cache] = useState>(() => { + const m = new Map(); + m.set(vm.root.object.ref, vm.root); + return m; + }); + + // Persist filters whenever they change. Disabled for transient modes + // (e.g. the global view) so they don't trample the local view's saved + // preferences. Skip the initial mount — those values came FROM the + // datastore (or the view's preset) and writing them back is pointless. + const persistFilters = vm.persistFilters !== false; + const didMountRef = useRef(false); + useEffect(() => { + if (!persistFilters) return; + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + void datastore.set(FILTERS_KEY, filters); + }, [filters, persistFilters]); + + // Forces persist regardless of view (global and explore share them). + const didMountForcesRef = useRef(false); + useEffect(() => { + if (!didMountForcesRef.current) { + didMountForcesRef.current = true; + return; + } + void datastore.set(FORCES_KEY, forces); + }, [forces]); + + const fetchExpansion = useCallback( + async (ref: string): Promise => { + const cached = cache.get(ref); + if (cached) return cached; + const result = (await system.invokeFunction( + "object-graph.expandObject", + ref, + )) as ExpansionResult; + cache.set(ref, result); + return result; + }, + [cache], + ); + + const applyExpansions = useCallback((results: ExpansionResult[]) => { + if (results.length === 0) return; + setNodes((prev) => { + const next = new Map(prev); + for (const r of results) { + next.set(r.object.ref, { node: r.object, status: "expanded" }); + for (const n of r.neighbors) { + if (!next.has(n.ref)) next.set(n.ref, { node: n, status: "ghost" }); + } + } + return next; + }); + setEdges((prev) => { + const additions: Edge[] = []; + const keys = edgeKeysRef.current; + for (const r of results) { + for (const e of r.edges) { + const k = edgeKey(e); + if (!keys.has(k)) { + keys.add(k); + additions.push(e); + } + } + } + return additions.length ? [...prev, ...additions] : prev; + }); + }, []); + + const expandRef = useCallback( + async (ref: string) => { + applyExpansions([await fetchExpansion(ref)]); + }, + [applyExpansions, fetchExpansion], + ); + + const onNodeClick = useCallback( + (ref: string) => { + const state = nodes.get(ref); + setSelectedRef(ref); + if (state && state.status === "ghost") { + void expandRef(ref); + } + }, + [nodes, expandRef], + ); + + const removeNode = useCallback((ref: string) => { + setNodes((prev) => { + if (!prev.has(ref)) return prev; + const next = new Map(prev); + next.delete(ref); + return next; + }); + setEdges((prev) => { + const remaining: Edge[] = []; + const keys = edgeKeysRef.current; + for (const e of prev) { + if (e.source === ref || e.target === ref) { + keys.delete(edgeKey(e)); + continue; + } + remaining.push(e); + } + return remaining.length === prev.length ? prev : remaining; + }); + setSelectedRef((sel) => (sel === ref ? null : sel)); + }, []); + + const selected: ObjectNode | null = selectedRef + ? (nodes.get(selectedRef)?.node ?? null) + : null; + + // Stable ref to the current visibleNodes so callbacks always see the latest + // set without forcing the callback identity to churn on every filter tweak. + const visibleNodesRef = useRef([]); + + // Transitive expansion: follow every relation outward, round by round, + // until no new ghosts remain along allowed labels. Hidden-label edges + // are recorded (so toggling them on later reveals them) but they + // don't seed further exploration. Safety-capped at 5000 nodes. + const expandAllVisibleGhosts = useCallback(async () => { + const SAFETY_CAP = 5000; + const hiddenLabels = new Set(filters.hiddenLabels); + const expanded = new Set(); + const pending = new Set(); + for (const ns of visibleNodesRef.current) { + if (ns.status === "expanded") expanded.add(ns.node.ref); + else pending.add(ns.node.ref); + } + while (pending.size > 0) { + if (expanded.size + pending.size > SAFETY_CAP) break; + const refs = [...pending]; + pending.clear(); + const results = await Promise.all(refs.map(fetchExpansion)); + applyExpansions(results); + for (const r of results) { + expanded.add(r.object.ref); + // Only follow edges whose label is currently visible. + for (const e of r.edges) { + if (hiddenLabels.has(e.label)) continue; + for (const ref of [e.source, e.target]) { + if (!expanded.has(ref) && !pending.has(ref)) pending.add(ref); + } + } + } + } + }, [fetchExpansion, applyExpansions, filters.hiddenLabels]); + + /** + * Reset the explored set to "currently-selected object + its 1-hop + * ghosts". The selection stays put; only the broader exploration is + * collapsed back to that node's immediate neighborhood. + * + * Always issues a fresh worker call (skipping the cache) because the + * cache may hold a wide expansion for this ref — notably, in the + * Global Page Map view the cache entry for the anchor IS the global + * graph, and returning that here would defeat the whole point of Focus. + */ + const collapseAll = useCallback(async () => { + const anchor = selectedRef ?? vm.root.object.ref; + const result = (await system.invokeFunction( + "object-graph.expandObject", + anchor, + )) as ExpansionResult; + cache.set(anchor, result); + const m = new Map(); + m.set(result.object.ref, { node: result.object, status: "expanded" }); + for (const n of result.neighbors) { + if (!m.has(n.ref)) m.set(n.ref, { node: n, status: "ghost" }); + } + setNodes(m); + setEdges([...result.edges]); + const keys = edgeKeysRef.current; + keys.clear(); + for (const e of result.edges) keys.add(edgeKey(e)); + setSelectedRef(result.object.ref); + }, [selectedRef, cache, vm.root.object.ref]); + + // Keyboard: Backspace / Delete removes selected, unless focused in an input. + useEffect(() => { + const handler = (ev: KeyboardEvent) => { + if (ev.key !== "Backspace" && ev.key !== "Delete") return; + const tag = (ev.target as HTMLElement | null)?.tagName?.toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return; + if (!selectedRef) return; + ev.preventDefault(); + removeNode(selectedRef); + }; + globalThis.addEventListener("keydown", handler); + return () => globalThis.removeEventListener("keydown", handler); + }, [selectedRef, removeNode]); + + // Visible subgraph (view-layer only). Filter rules: + // • The root is always visible — it's the user's anchor into the graph. + // • Every other node (expanded or ghost) must pass the tag filter. + // • Edges must pass the label filter and connect two visible nodes. + // • A ghost that has no remaining edges to a visible neighbor is + // hidden — otherwise unchecking an edge label leaves orphan ghosts + // floating with no visible reason to be there. + const rootRef = vm.root.object.ref; + const { visibleNodes, visibleEdges } = useMemo( + () => computeVisible(nodes.values(), edges, filters, rootRef), + [nodes, edges, filters, rootRef], + ); + + // Keep the ref in sync so expandAllVisibleGhosts sees the latest set. + visibleNodesRef.current = visibleNodes; + + const ghostCount = visibleNodes.reduce( + (a, ns) => a + (ns.status === "ghost" ? 1 : 0), + 0, + ); + + // Stable arrays for the sidebar's tally inputs (the underlying state + // changes only when nodes/edges actually change; without these, every + // App render would produce fresh array identities and re-tally everything). + const allObjectNodes = useMemo( + () => [...nodes.values()].map((ns) => ns.node), + [nodes], + ); + const visibleObjectNodes = useMemo( + () => visibleNodes.map((ns) => ns.node), + [visibleNodes], + ); + + return ( +
+
+ setFilters({ ...filters, hideEdgeLabels: v }) + } + hideOrphans={filters.hideOrphans} + onToggleHideOrphans={(v) => setFilters({ ...filters, hideOrphans: v })} + /> +
+ + + +
+
+ ); +} + +const MIN_SIDEBAR_WIDTH = 160; +const MAX_SIDEBAR_WIDTH = 600; + +function SidebarResizer({ + width, + onResize, +}: { + width: number; + onResize: (w: number) => void; +}) { + const [dragging, setDragging] = useState(false); + const startRef = useRef<{ x: number; w: number } | null>(null); + + useEffect(() => { + if (!dragging) return; + const onMove = (e: MouseEvent) => { + const start = startRef.current; + if (!start) return; + const next = Math.min( + MAX_SIDEBAR_WIDTH, + Math.max(MIN_SIDEBAR_WIDTH, start.w + (e.clientX - start.x)), + ); + onResize(next); + }; + const onUp = () => setDragging(false); + globalThis.addEventListener("mousemove", onMove); + globalThis.addEventListener("mouseup", onUp); + return () => { + globalThis.removeEventListener("mousemove", onMove); + globalThis.removeEventListener("mouseup", onUp); + }; + }, [dragging, onResize]); + + return ( +
{ + startRef.current = { x: e.clientX, w: width }; + setDragging(true); + e.preventDefault(); + }} + /> + ); +} diff --git a/plugs/object-graph/ui/components/graph_canvas.tsx b/plugs/object-graph/ui/components/graph_canvas.tsx new file mode 100644 index 00000000..bb2d045d --- /dev/null +++ b/plugs/object-graph/ui/components/graph_canvas.tsx @@ -0,0 +1,1005 @@ +import { editor } from "@silverbulletmd/silverbullet/syscalls"; +import * as d3 from "d3-force"; +import ForceGraphImpl from "force-graph"; +import { Component } from "preact"; +import type { Edge, ForceSettings, ObjectNode } from "../../src/model.ts"; +import { STRUCTURAL_KINDS } from "../../src/model.ts"; +import { colorForTag } from "../colors.ts"; + +// All four force parameters are now driven by sliders in the sidebar; +// see ForceSettings in src/model.ts for the defaults. +const CLICK_DELAY_MS = 220; +// Upper bound for the auto-fit camera scale. Prevents zoomToFit from +// magnifying a single isolated node to fill the entire canvas. +const MAX_AUTO_ZOOM = 4; + +// Tick budgets before the simulation cools and we auto-fit the camera (via +// onEngineStop → recenter). Fitting on a fixed timer fit mid-layout and left +// nodes half off-screen; fitting on settle is reliable. A from-scratch first +// layout needs more settling than an incremental expansion. +const FIRST_FIT_COOLDOWN_TICKS = 140; +const REFIT_COOLDOWN_TICKS = 60; + +type NodeStatus = "expanded" | "ghost"; + +type Props = { + nodes: { node: ObjectNode; status: NodeStatus }[]; + edges: Edge[]; + selectedRef: string | null; + onNodeClick: (ref: string) => void; + hideEdgeLabels: boolean; + forces: ForceSettings; +}; + +type FGNode = { + id: string; + node: ObjectNode; + status: NodeStatus; + kind: ObjectNode["kind"]; + dangling: boolean; + title: string; + prefix?: string; + degree: number; + color: string; + x?: number; + y?: number; + // Pin coordinates set on drag. Kept as `number` (never null) so FGNode + // satisfies both force-graph's `NodeObject` and d3's `SimulationNodeDatum`. + fx?: number; + fy?: number; +}; + +// Panel-side merged edge — collapses parallel edges between the same pair +// of nodes (regardless of direction) into one record. `bidirectional` is +// set when at least one of the merged inputs ran the reverse direction. +type MergedEdge = Edge & { bidirectional: boolean }; + +type FGLink = { + source: string | FGNode; + target: string | FGNode; + edge: MergedEdge; +}; + +// force-graph 1.51 ships class-style typings (`new ForceGraph(el)`), but the +// runtime default export is still the Kapsule factory invoked as +// `ForceGraph()(element)`. Bridge the two: keep the chainable instance type +// (parameterized with our own node/link shapes so the accessor callbacks +// below type-check) while treating the import as the runtime factory. +type ForceGraphInstance = ForceGraphImpl; +const ForceGraph = ForceGraphImpl as unknown as () => ( + element: HTMLElement, +) => ForceGraphInstance; + +type Theme = { + bg: string; + nodeDim: string; + label: string; + labelDim: string; + labelHalo: string; + link: string; + linkDim: string; + linkHot: string; + accent: string; +}; + +type State = { + edgeHover: { edge: MergedEdge; x: number; y: number } | null; + ghostHover: { title: string; x: number; y: number } | null; +}; + +type ComputedGraph = { + nodes: FGNode[]; + links: FGLink[]; + adjacency: Map>; + radii: Map; +}; + +function readTheme(): Theme { + const cs = getComputedStyle(document.documentElement); + const v = (n: string, fallback: string) => + cs.getPropertyValue(n).trim() || fallback; + return { + bg: v("--gv-bg", "#ffffff"), + nodeDim: v("--gv-node-dim", "#9e4705"), + label: v("--gv-label", "#333"), + labelDim: v("--gv-label-dim", "#676767"), + labelHalo: v("--gv-label-halo", "rgba(255,255,255,0.78)"), + link: v("--gv-link", "#c8c8c8"), + linkDim: v("--gv-link-dim", "#e6e6e6"), + linkHot: v("--gv-link-hot", "#464cfc"), + accent: v("--gv-accent", "#650007"), + }; +} + +function getId(end: string | FGNode): string { + return typeof end === "string" ? end : end.id; +} + +function nodeRadius(degree: number): number { + // Modest growth with degree. Hubs go a bit larger but stay readable. + return 6 + Math.min(degree, 10) * 0.5; +} + +// Strip directory prefix; show just the last path segment for graph labels. +function displayName(title: string): string { + const i = title.lastIndexOf("/"); + return i === -1 ? title : title.slice(i + 1); +} + +// Label visibility thresholds expressed in zoom (graph→screen) scale. +const NODE_LABEL_MIN_SCALE = 0.6; +const EDGE_LABEL_MIN_SCALE = 1.8; + +function computeGraph( + inputNodes: Props["nodes"], + edges: Edge[], +): ComputedGraph { + const degree = new Map(); + const adj = new Map>(); + for (const ns of inputNodes) { + degree.set(ns.node.ref, 0); + adj.set(ns.node.ref, new Set()); + } + for (const e of edges) { + degree.set(e.source, (degree.get(e.source) ?? 0) + 1); + degree.set(e.target, (degree.get(e.target) ?? 0) + 1); + adj.get(e.source)?.add(e.target); + adj.get(e.target)?.add(e.source); + } + const fgNodes: FGNode[] = inputNodes.map(({ node, status }) => ({ + id: node.ref, + node, + status, + kind: node.kind, + dangling: node.dangling, + title: node.title, + prefix: node.prefix, + degree: degree.get(node.ref) ?? 0, + color: colorForTag(node.primaryTag), + })); + // Merge all edges between the same pair of nodes (unordered) into a + // single visual edge with a comma-joined label, combined provenance, + // and a `bidirectional` flag when both directions were present. + type Accum = MergedEdge & { labels: Set }; + const merged = new Map(); + for (const e of edges) { + const [a, b] = + e.source < e.target ? [e.source, e.target] : [e.target, e.source]; + const key = `${a}\x00${b}`; + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + ...e, + refs: [...e.refs], + labels: new Set([e.label]), + bidirectional: false, + }); + continue; + } + existing.labels.add(e.label); + existing.refs.push(...e.refs); + if (e.undirected) existing.undirected = true; + if (e.source !== existing.source) existing.bidirectional = true; + // Non-mention kinds beat `mention` for styling purposes. + if (existing.kind === "mention" && e.kind !== "mention") { + existing.kind = e.kind; + } + } + const fgLinks: FGLink[] = [...merged.values()].map((acc) => { + const { labels, ...edge } = acc; + edge.label = [...labels].join(", "); + return { source: edge.source, target: edge.target, edge }; + }); + const rmap = new Map(); + for (const n of fgNodes) rmap.set(n.id, nodeRadius(n.degree)); + return { + nodes: fgNodes, + links: fgLinks, + adjacency: adj, + radii: rmap, + }; +} + +export class GraphCanvas extends Component { + state: State = { edgeHover: null, ghostHover: null }; + + // DOM + force-graph handles. + private containerRef: HTMLDivElement | null = null; + private fg: ForceGraphInstance | null = null; + + // Hover/adjacency state read by draw callbacks every frame. + private hoveredId: string | null = null; + private neighbors: Set = new Set(); + private adjacency: Map> = new Map(); + private theme: Theme = readTheme(); + private radiusMap: Map = new Map(); + + // Mouse position relative to the canvas container; drives the edge tooltip. + private mousePos: { x: number; y: number } = { x: 0, y: 0 }; + + // Click-cancel timers for distinguishing single vs. double click. + private clickTimer: number | null = null; + private edgeClickTimer: number | null = null; + + // Whether we've fed data once already. Both the first feed and later ones + // (expansions/collapses) arm `pendingFit` and auto-fit once the simulation + // settles (see onEngineStop); only the cooldown budget differs. + private fedOnce = false; + + // Set when a data-driven feed wants the camera re-fitted after the + // simulation next settles. Gated so node drags (which also re-heat and + // fire onEngineStop) don't trigger camera jumps. + private pendingFit = false; + + // Cached computation of nodes/links/adjacency/radii; recomputed in + // componentDidUpdate when props.nodes or props.edges identity changes. + private computed: ComputedGraph; + + // Listeners/observers we own and must tear down. + private resizeObserver: ResizeObserver | null = null; + private mql: MediaQueryList | null = null; + + constructor(props: Props) { + super(props); + this.computed = computeGraph(props.nodes, props.edges); + // Bind handlers used as listener references / bare JSX onClicks so + // `this` resolves correctly and identities stay stable across + // add/removeEventListener pairs. + this.onMouseMove = this.onMouseMove.bind(this); + this.onThemeChange = this.onThemeChange.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.zoomIn = this.zoomIn.bind(this); + this.zoomOut = this.zoomOut.bind(this); + this.pan = this.pan.bind(this); + this.recenter = this.recenter.bind(this); + } + + private onMouseMove(ev: MouseEvent) { + if (!this.containerRef) return; + const rect = this.containerRef.getBoundingClientRect(); + this.mousePos = { + x: ev.clientX - rect.left, + y: ev.clientY - rect.top, + }; + } + + private onThemeChange() { + this.theme = readTheme(); + this.rerender(); + } + + private onKeyDown(e: KeyboardEvent) { + const tgt = e.target as HTMLElement | null; + if ( + tgt && + (tgt.tagName === "INPUT" || + tgt.tagName === "TEXTAREA" || + (tgt as HTMLElement).isContentEditable) + ) + return; + + switch (e.key) { + case "ArrowUp": + this.pan(0, -1); + break; + case "ArrowDown": + this.pan(0, 1); + break; + case "ArrowLeft": + this.pan(-1, 0); + break; + case "ArrowRight": + this.pan(1, 0); + break; + case "PageUp": + case "+": + case "=": + this.zoomIn(); + break; + case "PageDown": + case "-": + case "_": + this.zoomOut(); + break; + case "f": + case "F": + this.recenter(); + break; + default: + return; + } + e.preventDefault(); + } + + // Configure the link force's strength + distance accessors. The link + // set itself is owned by force-graph (rebound from graphData on every + // update), so we only set the per-link knobs. Multi-edge pairs get a + // proportionally stronger spring for Obsidian-style cluster pull. + private configureLinkForce() { + const fg = this.fg; + if (!fg) return; + const f = this.props.forces; + const linkForce = fg.d3Force("link") as d3.ForceLink | null; + if (!linkForce) return; + const mult = (l: FGLink) => Math.min(l.edge.refs.length, 6); + linkForce.strength((l: FGLink) => f.linkStrength * mult(l)); + linkForce.distance(f.linkDistance); + } + + private applyForces() { + const fg = this.fg; + if (!fg) return; + const f = this.props.forces; + const centerStrength = (n: FGNode) => + n.degree === 0 ? f.centerStrength * 6 : f.centerStrength; + (fg.d3Force("x") as d3.ForceX | null)?.strength(centerStrength); + (fg.d3Force("y") as d3.ForceY | null)?.strength(centerStrength); + (fg.d3Force("charge") as d3.ForceManyBody | null)?.strength( + f.chargeStrength, + ); + this.configureLinkForce(); + // Re-heat softly so the new force field has a chance to settle. + fg.d3ReheatSimulation(); + } + + componentDidMount() { + if (!this.containerRef) return; + const fg = ForceGraph()(this.containerRef); + this.fg = fg; + + fg.backgroundColor("transparent") + .nodeId("id") + .nodeCanvasObjectMode(() => "replace") + .nodeCanvasObject( + (node: FGNode, ctx: CanvasRenderingContext2D, scale: number) => + this.drawNode(node, ctx, scale), + ) + .nodePointerAreaPaint( + (node: FGNode, color: string, ctx: CanvasRenderingContext2D) => { + ctx.fillStyle = color; + ctx.beginPath(); + const r = (this.radiusMap.get(node.id) ?? 12) + 4; + ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI); + ctx.fill(); + }, + ) + .linkCanvasObjectMode(() => "replace") + .linkCanvasObject( + (link: FGLink, ctx: CanvasRenderingContext2D, scale: number) => + this.drawLink(link, ctx, scale), + ) + .onNodeHover((node: FGNode | null) => { + const id = node?.id ?? null; + this.hoveredId = id; + this.neighbors = id ? (this.adjacency.get(id) ?? new Set()) : new Set(); + if (node && node.status === "ghost") { + this.setState({ + ghostHover: { + title: node.title, + x: this.mousePos.x, + y: this.mousePos.y, + }, + }); + } else if (this.state.ghostHover) { + this.setState({ ghostHover: null }); + } + this.rerender(); + }) + .onNodeClick((node: FGNode) => this.handleNodeClick(node)) + .onLinkHover((link: FGLink | null) => { + if (!link) { + this.setState({ edgeHover: null }); + return; + } + this.setState({ + edgeHover: { + edge: link.edge, + x: this.mousePos.x, + y: this.mousePos.y, + }, + }); + }) + .onLinkClick((link: FGLink) => this.handleLinkClick(link)) + .onNodeDrag((node: FGNode) => { + // Pin node where it's dragged. + node.fx = node.x; + node.fy = node.y; + }) + .onNodeDragEnd((node: FGNode) => { + node.fx = node.x; + node.fy = node.y; + }) + .onEngineStop(() => { + // After a data-driven feed re-heats the simulation and it settles, + // re-fit the camera so newly added/removed nodes stay in view. + // Gated by pendingFit so drag-induced settles don't move the camera. + if (this.pendingFit) { + this.pendingFit = false; + this.recenter(); + } + }); + + const f = this.props.forces; + // Center pull, degree-aware: isolated nodes (degree 0) get a much + // stronger pull so they don't drift off into the void; well-connected + // nodes use the slider's base strength so clusters can still spread. + const centerStrength = (n: FGNode) => + n.degree === 0 ? f.centerStrength * 6 : f.centerStrength; + fg.d3Force("x", d3.forceX(0).strength(centerStrength)); + fg.d3Force("y", d3.forceY(0).strength(centerStrength)); + // Generous collide padding so node labels (rendered below each node) + // have room to breathe and don't overlap with neighbors. + fg.d3Force( + "collide", + d3 + .forceCollide((n) => (this.radiusMap.get(n.id) ?? 12) + 16) + .strength(0.85), + ); + (fg.d3Force("charge") as d3.ForceManyBody | null)?.strength( + f.chargeStrength, + ); + // Force-graph maintains the link force itself: its `update()` runs + // after each graphData change and rebinds the link set from the + // merged links we pass in. We only override the per-link strength + // accessor (Obsidian-style cluster pull: pairs joined by multiple + // relations get a proportionally stronger spring) and the rest + // length, plus the radial center pull above. + this.configureLinkForce(); + + const resize = () => { + if (!this.containerRef) return; + fg.width(this.containerRef.clientWidth); + fg.height(this.containerRef.clientHeight); + }; + resize(); + this.resizeObserver = new ResizeObserver(resize); + this.resizeObserver.observe(this.containerRef); + + this.containerRef.addEventListener("mousemove", this.onMouseMove); + + this.mql = window.matchMedia("(prefers-color-scheme: dark)"); + this.mql.addEventListener?.("change", this.onThemeChange); + + window.addEventListener("keydown", this.onKeyDown); + + // Initial data feed. + this.feedData(); + } + + componentDidUpdate(prevProps: Props) { + // Recompute derived graph data if either input identity changed. + const inputsChanged = + prevProps.nodes !== this.props.nodes || + prevProps.edges !== this.props.edges; + if (inputsChanged) { + this.computed = computeGraph(this.props.nodes, this.props.edges); + this.feedData(); + } + + if (prevProps.hideEdgeLabels !== this.props.hideEdgeLabels) { + // Edge-label visibility flipped: force a repaint via no-op accessor swap. + const fg = this.fg; + if (fg) fg.linkCanvasObject(fg.linkCanvasObject()); + } + + if (prevProps.selectedRef !== this.props.selectedRef) { + // Selected-node ring change: force a repaint. + const fg = this.fg; + if (fg) fg.nodeCanvasObject(fg.nodeCanvasObject()); + } + + if (prevProps.forces !== this.props.forces) { + this.applyForces(); + } + } + + componentWillUnmount() { + this.cancelClickTimer(); + this.cancelEdgeClickTimer(); + if (this.containerRef) { + this.containerRef.removeEventListener("mousemove", this.onMouseMove); + } + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.mql?.removeEventListener?.("change", this.onThemeChange); + this.mql = null; + window.removeEventListener("keydown", this.onKeyDown); + if (this.fg) { + this.fg.pauseAnimation(); + (this.fg as any)._destructor?.(); + } + if (this.containerRef) this.containerRef.innerHTML = ""; + this.fg = null; + } + + private rerender() { + // No-op accessor swap forces force-graph to repaint immediately. + const fg = this.fg; + if (!fg) return; + fg.nodeCanvasObject(fg.nodeCanvasObject()); + } + + // Feed nodes/links into force-graph, preserving positions and softly + // re-heating the simulation. The first feed auto-fits the camera after a + // short delay; later feeds re-fit once the simulation settles. + private feedData() { + const fg = this.fg; + if (!fg) return; + const { nodes, links, adjacency, radii } = this.computed; + this.adjacency = adjacency; + this.radiusMap = radii; + + // Carry over positions from the previous data so existing nodes stay put + // and new ghosts emerge from the position of an already-placed neighbor + // (rather than dropping in from random coordinates). + const prev = fg.graphData().nodes as FGNode[]; + const prevById = new Map(); + for (const p of prev) prevById.set(p.id, p); + + for (const n of nodes) { + const old = prevById.get(n.id); + if (old) { + // Existing node — preserve its current physics state. + n.x = old.x; + n.y = old.y; + n.fx = old.fx; + n.fy = old.fy; + continue; + } + // New node — start at an already-placed connected neighbor. + const neighbors = adjacency.get(n.id); + if (!neighbors) continue; + for (const nbId of neighbors) { + const placed = prevById.get(nbId); + if (placed && placed.x !== undefined && placed.y !== undefined) { + // Tiny jitter avoids stacking new ghosts perfectly on top of each other. + n.x = placed.x + (Math.random() - 0.5) * 2; + n.y = placed.y + (Math.random() - 0.5) * 2; + break; + } + } + } + + fg.graphData({ nodes, links }); + // Re-apply our link-force tuning. force-graph's update() runs after + // graphData and rebinds the link set from the merged links, but + // doesn't touch strength/distance accessors. Re-set them now so the + // per-link multiplicity factor still applies after the rebind. + this.configureLinkForce(); + this.hoveredId = null; + this.neighbors = new Set(); + + // Auto-fit the camera once the simulation cools (onEngineStop → + // recenter), never on a fixed timer — fitting mid-layout leaves nodes + // half off-screen. Bound the run with cooldownTicks so the first + // from-scratch layout settles in a few hundred ms instead of the + // engine's multi-second default. recenter() handles the single-node and + // MAX_AUTO_ZOOM cases. + this.pendingFit = true; + fg.cooldownTicks( + this.fedOnce ? REFIT_COOLDOWN_TICKS : FIRST_FIT_COOLDOWN_TICKS, + ); + this.fedOnce = true; + } + + private cancelClickTimer() { + if (this.clickTimer !== null) { + clearTimeout(this.clickTimer); + this.clickTimer = null; + } + } + + private handleNodeClick(node: FGNode) { + if (this.clickTimer !== null) { + // Second click within window → treat as double-click. + this.cancelClickTimer(); + void this.handleDoubleClick(node); + return; + } + this.clickTimer = window.setTimeout(() => { + this.clickTimer = null; + this.props.onNodeClick(node.id); + }, CLICK_DELAY_MS); + } + + private cancelEdgeClickTimer() { + if (this.edgeClickTimer !== null) { + clearTimeout(this.edgeClickTimer); + this.edgeClickTimer = null; + } + } + + private handleLinkClick(link: FGLink) { + if (this.edgeClickTimer !== null) { + this.cancelEdgeClickTimer(); + void this.handleLinkDoubleClick(link); + return; + } + this.edgeClickTimer = window.setTimeout(() => { + this.edgeClickTimer = null; + // Single click on an edge: no-op for now (no edge selection model). + }, CLICK_DELAY_MS); + } + + private async handleLinkDoubleClick(link: FGLink) { + const prov = link.edge.refs[0]; + if (!prov) return; + try { + const ref = + prov.pos !== undefined ? `${prov.page}@${prov.pos}` : prov.page; + await editor.navigate(ref); + await editor.hidePanel("modal"); + } catch (err) { + console.error("object-graph: edge navigation failed", err); + } + } + + private async handleDoubleClick(node: FGNode) { + try { + if (node.kind === "url") { + await editor.openUrl(node.id); + return; + } + // An item/block node whose ref carries no `@pos` holds a bare + // `$anchor` name (e.g. an item tagged `$pete-ref`). Navigate via the + // anchor (`$name`, empty page path) so SB resolves it through the + // index to wherever the anchor currently lives, instead of treating + // the bare name as a page. Positional refs (`page@pos`), pages and + // files navigate directly. + const isAnchorRef = + (node.kind === "item" || node.kind === "block") && + !node.id.includes("@"); + const ref = isAnchorRef ? `$${node.id}` : node.id; + await editor.navigate(ref); + await editor.hidePanel("modal"); + } catch (err) { + console.error("object-graph: navigation failed", err); + } + } + + private isHighlighted(id: string): boolean { + const hov = this.hoveredId; + if (!hov) return true; + return id === hov || this.neighbors.has(id); + } + + private linkTouchesHover(link: FGLink): boolean { + const hov = this.hoveredId; + if (!hov) return false; + return getId(link.source) === hov || getId(link.target) === hov; + } + + private drawNode(node: FGNode, ctx: CanvasRenderingContext2D, scale: number) { + const t = this.theme; + const radius = this.radiusMap.get(node.id) ?? nodeRadius(node.degree); + const x = node.x ?? 0; + const y = node.y ?? 0; + const highlighted = this.isHighlighted(node.id); + const isSelected = node.id === this.props.selectedRef; + + ctx.save(); + let alpha = node.status === "ghost" ? 0.45 : 1; + if (!highlighted) alpha *= 0.35; + ctx.globalAlpha = alpha; + + if (node.dangling) { + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = t.bg || "#fff"; + ctx.fill(); + ctx.setLineDash([3 / scale, 2 / scale]); + ctx.lineWidth = 1.5 / scale; + ctx.strokeStyle = t.nodeDim; + ctx.stroke(); + ctx.setLineDash([]); + } else { + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = node.color; + ctx.fill(); + } + + if (isSelected) { + ctx.beginPath(); + ctx.arc(x, y, radius + 2 / scale, 0, Math.PI * 2); + ctx.lineWidth = 2 / scale; + ctx.strokeStyle = t.accent; + ctx.stroke(); + } + + // Label rendered BELOW the node, scaled to constant screen pixels. + // Hide when zoomed way out OR when this node is dimmed by hover. + const showLabel = + scale >= NODE_LABEL_MIN_SCALE && (highlighted || this.hoveredId === null); + if (showLabel) { + const fontPx = isSelected ? 13 : 11; + const fontSize = fontPx / scale; + ctx.font = `${isSelected ? "600 " : ""}${fontSize}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const name = displayName(node.title); + const label = node.prefix ? `${node.prefix}${name}` : name; + const gap = 4 / scale; + // Halo behind the label for legibility over edges. + ctx.lineWidth = 3 / scale; + ctx.strokeStyle = t.labelHalo; + ctx.strokeText(label, x, y + radius + gap); + ctx.fillStyle = node.dangling ? t.nodeDim : t.label; + ctx.fillText(label, x, y + radius + gap); + } + ctx.restore(); + } + + private drawLink(link: FGLink, ctx: CanvasRenderingContext2D, scale: number) { + const t = this.theme; + const source = link.source as FGNode; + const target = link.target as FGNode; + if ( + typeof source !== "object" || + typeof target !== "object" || + source.x === undefined || + target.x === undefined + ) + return; + + const sx = source.x!; + const sy = source.y!; + const tx = target.x!; + const ty = target.y!; + const dx = tx - sx; + const dy = ty - sy; + const len = Math.hypot(dx, dy) || 1; + const ux = dx / len; + const uy = dy / len; + const sR = this.radiusMap.get(source.id) ?? 12; + const tR = this.radiusMap.get(target.id) ?? 12; + const ax = sx + sR * ux; + const ay = sy + sR * uy; + const bx = tx - tR * ux; + const by = ty - tR * uy; + + const kind: string = link.edge.kind; + const dimmed = STRUCTURAL_KINDS.has(kind); + const hov = this.hoveredId; + const touches = this.linkTouchesHover(link); + + let strokeStyle: string; + let alpha = 1; + if (hov) { + if (touches) { + strokeStyle = t.linkHot; + } else { + strokeStyle = t.linkDim; + alpha = 0.5; + } + } else { + strokeStyle = dimmed ? t.linkDim : t.link; + } + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = strokeStyle; + ctx.lineWidth = (touches ? 1.8 : 1.2) / scale; + if (dimmed) ctx.setLineDash([3 / scale, 3 / scale]); + else ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + ctx.setLineDash([]); + + // Arrowheads — skip on undirected co-mention collapses. + if (!link.edge.undirected) { + const headLen = 7 / scale; + const drawHead = (hx: number, hy: number, angle: number) => { + ctx.beginPath(); + ctx.moveTo(hx, hy); + ctx.lineTo( + hx - headLen * Math.cos(angle - Math.PI / 6), + hy - headLen * Math.sin(angle - Math.PI / 6), + ); + ctx.lineTo( + hx - headLen * Math.cos(angle + Math.PI / 6), + hy - headLen * Math.sin(angle + Math.PI / 6), + ); + ctx.closePath(); + ctx.fillStyle = strokeStyle; + ctx.fill(); + }; + // Target-end arrow. + drawHead(bx, by, Math.atan2(uy, ux)); + // Source-end arrow when both directions exist between this pair. + if (link.edge.bidirectional) drawHead(ax, ay, Math.atan2(-uy, -ux)); + } + + // Rotated edge label riding above the midpoint. + // Suppressed entirely when the global "hide labels" toggle is on, + // unless the edge is incident to the hovered node. + const label = link.edge.label; + const labelsAllowed = !this.props.hideEdgeLabels || touches; + const showLabel = + label && labelsAllowed && (touches || scale >= EDGE_LABEL_MIN_SCALE); + if (showLabel) { + const mx = (ax + bx) / 2; + const my = (ay + by) / 2; + let angle = Math.atan2(by - ay, bx - ax); + if (angle > Math.PI / 2) angle -= Math.PI; + if (angle < -Math.PI / 2) angle += Math.PI; + ctx.translate(mx, my); + ctx.rotate(angle); + const fontSize = 10 / scale; + ctx.font = `${fontSize}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.lineWidth = 3 / scale; + ctx.strokeStyle = t.labelHalo; + ctx.strokeText(label, 0, -2 / scale); + ctx.fillStyle = dimmed ? t.labelDim : t.label; + ctx.fillText(label, 0, -2 / scale); + } + ctx.restore(); + } + + private zoomIn() { + const fg = this.fg; + if (!fg) return; + fg.zoom(fg.zoom() * 1.4, 250); + } + + private zoomOut() { + const fg = this.fg; + if (!fg) return; + fg.zoom(fg.zoom() / 1.4, 250); + } + + // dx/dy in {-1, 0, 1} — step scales inversely with zoom so the visual + // pan distance stays constant. + private pan(dx: number, dy: number) { + const fg = this.fg; + if (!fg) return; + const step = 80 / fg.zoom(); + const c = fg.centerAt(); + if (!c) return; + fg.centerAt(c.x + dx * step, c.y + dy * step, 250); + } + + private recenter() { + const fg = this.fg; + if (!fg) return; + if (this.computed.nodes.length <= 1) { + fg.centerAt(0, 0, 400); + fg.zoom(2, 400); + return; + } + fg.zoomToFit(400, 40); + if (fg.zoom() > MAX_AUTO_ZOOM) fg.zoom(MAX_AUTO_ZOOM, 400); + } + + render() { + const { edgeHover, ghostHover } = this.state; + const isEmpty = this.computed.nodes.length === 0; + return ( +
+
{ + this.containerRef = el; + }} + class="graph-canvas" + /> + {isEmpty &&
Graph is empty
} + {edgeHover && } + {ghostHover && !edgeHover && } +
+
+ + + + + +
+
+ + +
+
+
+ ); + } +} + +function GhostTooltip({ + title, + x, + y, +}: { + title: string; + x: number; + y: number; +}) { + return ( +
+ Click to add {title} to the graph +
+ ); +} + +function EdgeTooltip({ + edge, + x, + y, +}: { + edge: MergedEdge; + x: number; + y: number; +}) { + // Collect distinct, trimmed snippets across all merged provenance refs. + const snippets: string[] = []; + const seen = new Set(); + for (const r of edge.refs) { + const s = r.snippet?.trim(); + if (!s || seen.has(s)) continue; + seen.add(s); + snippets.push(s); + } + return ( +
+
{edge.label}
+ {snippets.length === 0 ? ( +
+ (no snippet) +
+ ) : ( + snippets.map((s, i) => ( +
+ {s} +
+ )) + )} +
+ ); +} diff --git a/plugs/object-graph/ui/components/header.tsx b/plugs/object-graph/ui/components/header.tsx new file mode 100644 index 00000000..859d40fb --- /dev/null +++ b/plugs/object-graph/ui/components/header.tsx @@ -0,0 +1,75 @@ +import { editor } from "@silverbulletmd/silverbullet/syscalls"; +import { Button, Checkbox } from "@silverbulletmd/silverbullet/ui"; + +type Props = { + ghostCount: number; + onExpandAll: () => void; + onCollapseAll: () => void; + hideEdgeLabels: boolean; + onToggleHideEdgeLabels: (v: boolean) => void; + hideOrphans: boolean; + onToggleHideOrphans: (v: boolean) => void; +}; + +export function Header({ + ghostCount, + onExpandAll, + onCollapseAll, + hideEdgeLabels, + onToggleHideEdgeLabels, + hideOrphans, + onToggleHideOrphans, +}: Props) { + return ( +
+

Object Graph

+
+ + + + + +
+
+ ); +} diff --git a/plugs/object-graph/ui/components/sidebar.tsx b/plugs/object-graph/ui/components/sidebar.tsx new file mode 100644 index 00000000..8c44fc20 --- /dev/null +++ b/plugs/object-graph/ui/components/sidebar.tsx @@ -0,0 +1,346 @@ +import { syscall } from "@silverbulletmd/silverbullet/syscall"; +import { editor } from "@silverbulletmd/silverbullet/syscalls"; +import { Checkbox, Input } from "@silverbulletmd/silverbullet/ui"; +import { useEffect, useMemo, useState } from "preact/hooks"; +import { + defaultForceSettings, + type Edge, + type Filters, + type ForceSettings, + type GraphUniverse, + type ObjectNode, +} from "../../src/model.ts"; +import { colorForTag } from "../colors.ts"; + +type Props = { + // Currently-visible nodes (used to compute counts for present-only filter UX). + nodes: ObjectNode[]; + edges: Edge[]; + // Full set; used so the filter list still shows recently-hidden entries. + allNodes: ObjectNode[]; + allEdges: Edge[]; + // Full option lists derived from the entire space. + universe: GraphUniverse; + filters: Filters; + onFiltersChange: (f: Filters) => void; + forces: ForceSettings; + onForcesChange: (f: ForceSettings) => void; + // Selected node; rendered as an object-detail section below the filters. + selected: ObjectNode | null; +}; + +const LONG_LIST_THRESHOLD = 10; +const MAX_VISIBLE = 50; + +type Counted = { key: string; count: number }; + +export function Sidebar(props: Props) { + return ( + + ); +} + +function ForcesSection({ + forces, + onForcesChange, +}: { + forces: ForceSettings; + onForcesChange: (f: ForceSettings) => void; +}) { + const [open, setOpen] = useState(false); + const update = (patch: Partial) => + onForcesChange({ ...forces, ...patch }); + const sliders: { + label: string; + key: keyof ForceSettings; + min: number; + max: number; + step: number; + }[] = [ + { + label: "Center pull", + key: "centerStrength", + min: 0, + max: 0.3, + step: 0.005, + }, + { + label: "Repulsion", + key: "chargeStrength", + min: -800, + max: 0, + step: 10, + }, + { label: "Link distance", key: "linkDistance", min: 10, max: 600, step: 1 }, + { + label: "Link strength", + key: "linkStrength", + min: 0.01, + max: 1, + step: 0.01, + }, + ]; + return ( +
+
setOpen(!open)}> + + ▸ + Forces + + e.stopPropagation()}> + onForcesChange(defaultForceSettings)}>reset + +
+ {open && ( +
+ {sliders.map((s) => ( + + ))} +
+ )} +
+ ); +} + +function ObjectSection({ selected }: { selected: ObjectNode | null }) { + const [yamlText, setYamlText] = useState(""); + useEffect(() => { + if (!selected) return; + let cancelled = false; + void syscall("yaml.stringify", selected.attributes).then((text: string) => { + if (!cancelled) setYamlText(text); + }); + return () => { + cancelled = true; + }; + }, [selected]); + if (!selected) return null; + const swatch = colorForTag(selected.primaryTag); + const navigate = async () => { + try { + await editor.navigate(selected.ref); + await editor.hidePanel("modal"); + } catch (err) { + console.error("object-graph: object navigation failed", err); + } + }; + + return ( +
+
+ + + {selected.title} + +
+
{yamlText}
+
+ ); +} + +function Section({ + title, + rows, + hidden, + onToggle, + onAll, + onNone, + renderRow, +}: { + title: string; + rows: Counted[]; + hidden: string[]; + onToggle: (key: string) => void; + onAll: () => void; + onNone: () => void; + renderRow: (r: Counted) => preact.ComponentChildren; +}) { + const [open, setOpen] = useState(true); + const [query, setQuery] = useState(""); + const [showAll, setShowAll] = useState(false); + const filtered = query + ? rows.filter((r) => r.key.toLowerCase().includes(query.toLowerCase())) + : rows; + const display = showAll ? filtered : filtered.slice(0, MAX_VISIBLE); + const overflow = filtered.length - display.length; + + return ( +
+
setOpen(!open)}> + + ▸ + {title} + + e.stopPropagation()}> + all + {" · "} + none + +
+ {open && ( +
+ {rows.length > LONG_LIST_THRESHOLD && ( + setQuery((e.currentTarget as HTMLInputElement).value)} + /> + )} + {display.map((r) => ( + + ))} + {overflow > 0 && ( + setShowAll(true)}> + show {overflow} more + + )} +
+ )} +
+ ); +} + +/** + * Build a `Counted[]` whose key set is exactly `keys`, with counts pulled + * from the explored-graph tally (defaulting to 0). Sorted: present-in-graph + * entries first (by count desc), then absent entries alphabetically. + */ +function universeRows(keys: string[], counts: Map): Counted[] { + const rows: Counted[] = keys.map((key) => ({ + key, + count: counts.get(key) ?? 0, + })); + rows.sort((a, b) => { + if (a.count !== b.count) return b.count - a.count; + return a.key.localeCompare(b.key); + }); + return rows; +} + +function TagsSection({ allNodes, universe, filters, onFiltersChange }: Props) { + const rows = useMemo(() => { + const counts = new Map(); + for (const n of allNodes) { + if (n.tags.length === 0) { + counts.set("(untagged)", (counts.get("(untagged)") ?? 0) + 1); + continue; + } + for (const t of n.tags) { + counts.set(t, (counts.get(t) ?? 0) + 1); + } + } + // "(untagged)" is always presented at the bottom of the universe list + // — it's a bucket, not a real tag. + return universeRows([...universe.tags, "(untagged)"], counts); + }, [allNodes, universe.tags]); + const toggle = (key: string) => + onFiltersChange({ + ...filters, + hiddenTags: filters.hiddenTags.includes(key) + ? filters.hiddenTags.filter((x) => x !== key) + : [...filters.hiddenTags, key], + }); + return ( +