From 5f3c3e09b29a3e15d89de38ed5e3c8b35ee6e829 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 5 Jun 2026 22:38:18 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20/graph=20route=20MVP=20?= =?UTF-8?q?=E2=80=94=20lazy-loaded=203D=20force=20graph=20(TASK-1733)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New full-viewport graph page at /{username}/{workspace}/graph rendering the TASK-1731 endpoint via 3d-force-graph. Three.js loads only through a dynamic import inside onMount, landing in its own ~1.3MB chunk referenced solely by the graph route node β€” entry bundle unchanged. Node color = collection (local hex palette; the chart PALETTE's CSS vars can't reach WebGL), node size = 1 + 2Γ—child_count, blocks edges red with directional arrows, structural links brighter than soft ones. Click navigates to the item page (ResolveItem accepts refs in the slug param). Active items by default with a "Show completed" toggle that refetches in place; workspace switches refetch with a stale-response guard. Teardown via _destructor + ResizeObserver disconnect. Nav: 'graph' added to destinations.ts (NavKey, RESERVED_SLUGS, primary destinations, getActiveKey) and a Sidebar entry after Insights β€” the mobile More sheet picks it up from the shared source automatically. Parent: PLAN-1730. --- web/package-lock.json | 222 ++++++++++ web/package.json | 1 + web/src/lib/components/layout/Sidebar.svelte | 10 + web/src/lib/nav/destinations.ts | 4 + .../[username]/[workspace]/graph/+page.svelte | 385 ++++++++++++++++++ 5 files changed, 622 insertions(+) create mode 100644 web/src/routes/[username]/[workspace]/graph/+page.svelte diff --git a/web/package-lock.json b/web/package-lock.json index 3b4e116c3..5395c8114 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,6 +23,7 @@ "@tiptap/starter-kit": "^3.22.5", "@tiptap/suggestion": "^3.22.5", "@tiptap/y-tiptap": "^3.0.3", + "3d-force-graph": "1.80.0", "d3-scale": "4.0.2", "diff": "^9.0.0", "dompurify": "^3.4.2", @@ -65,6 +66,15 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -1158,6 +1168,12 @@ "yjs": "^13.5.38" } }, + "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/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1514,6 +1530,31 @@ "d3-transition": "^3.0.1" } }, + "node_modules/3d-force-graph": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/3d-force-graph/-/3d-force-graph-1.80.0.tgz", + "integrity": "sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1", + "kapsule": "^1.16", + "three": ">=0.179 <1", + "three-forcegraph": "1", + "three-render-objects": "^1.41" + }, + "engines": { + "node": ">=12" + } + }, + "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/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1775,6 +1816,12 @@ "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-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -1918,6 +1965,22 @@ "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", @@ -1960,6 +2023,12 @@ "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-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -2164,6 +2233,18 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-bind-mapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz", + "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -2342,6 +2423,20 @@ "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/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -2446,6 +2541,18 @@ "url": "https://github.com/sponsors/dmonad" } }, + "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/katex": { "version": "0.16.45", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", @@ -2996,6 +3103,44 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/ngraph.events": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz", + "integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==", + "license": "BSD-3-Clause" + }, + "node_modules/ngraph.forcelayout": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz", + "integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==", + "license": "BSD-3-Clause", + "dependencies": { + "ngraph.events": "^1.0.0", + "ngraph.merge": "^1.0.0", + "ngraph.random": "^1.0.0" + } + }, + "node_modules/ngraph.graph": { + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz", + "integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ngraph.events": "^1.4.0" + } + }, + "node_modules/ngraph.merge": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-1.0.0.tgz", + "integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==", + "license": "MIT" + }, + "node_modules/ngraph.random": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-1.2.0.tgz", + "integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==", + "license": "BSD-3-Clause" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3147,6 +3292,18 @@ "points-on-curve": "0.2.0" } }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -3176,6 +3333,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prosemirror-changeset": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", @@ -3606,6 +3773,61 @@ "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, + "node_modules/three-forcegraph": { + "version": "1.43.4", + "resolved": "https://registry.npmjs.org/three-forcegraph/-/three-forcegraph-1.43.4.tgz", + "integrity": "sha512-FtmiZP/T16ZQaHza3JDaDn0YTXFtg9e7pGnTeU8nzu0NNkx7MpWbF/GvmpbQsWHx3rukHtkRv1fTorLPB3FDEA==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1", + "d3-array": "1 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "data-bind-mapper": "1", + "kapsule": "^1.16", + "ngraph.forcelayout": "3", + "ngraph.graph": "20", + "tinycolor2": "1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.118.3" + } + }, + "node_modules/three-render-objects": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.42.0.tgz", + "integrity": "sha512-KYfkPrYGEbIK8ChFocWqOF1aAN80FBUBWVYB8mB2oBpVuVN+52FvvngVYB5ieFANQu7Rt21rPYZ/xKaAgVWWRQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "float-tooltip": "^1.7", + "kapsule": "^1.16", + "polished": "4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.179" + } + }, + "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.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", diff --git a/web/package.json b/web/package.json index 6a94ad897..0d51279cd 100644 --- a/web/package.json +++ b/web/package.json @@ -50,6 +50,7 @@ "@tiptap/starter-kit": "^3.22.5", "@tiptap/suggestion": "^3.22.5", "@tiptap/y-tiptap": "^3.0.3", + "3d-force-graph": "1.80.0", "d3-scale": "4.0.2", "diff": "^9.0.0", "dompurify": "^3.4.2", diff --git a/web/src/lib/components/layout/Sidebar.svelte b/web/src/lib/components/layout/Sidebar.svelte index cb016f142..587710a64 100644 --- a/web/src/lib/components/layout/Sidebar.svelte +++ b/web/src/lib/components/layout/Sidebar.svelte @@ -35,6 +35,7 @@ let activeKey = $derived(getActiveKey(page.url.pathname, wsPrefix)); let isDashboardPage = $derived(activeKey === 'dashboard'); let isInsightsPage = $derived(activeKey === 'insights'); + let isGraphPage = $derived(activeKey === 'graph'); let isRolesPage = $derived(activeKey === 'roles'); let isActivityPage = $derived(activeKey === 'activity'); let isStarredPage = $derived(activeKey === 'starred'); @@ -420,6 +421,15 @@ πŸ“ˆ Insights + uiStore.onNavigate()} + > + πŸ•Έ + Graph + + // 3D workspace graph (PLAN-1730 / TASK-1733). + // + // Full-viewport force-directed view of the whole workspace: every item is a + // node, every typed link an edge. The 3d-force-graph renderer pulls in Three.js, + // so it's loaded ONLY via dynamic import inside onMount β€” that keeps WebGL out of + // the main SPA bundle and out of any build-time SSR pass. + import { page } from '$app/state'; + import { goto } from '$app/navigation'; + import { onMount, onDestroy } from 'svelte'; + import { api } from '$lib/api/client'; + import { workspaceStore } from '$lib/stores/workspace.svelte'; + import { titleStore } from '$lib/stores/title.svelte'; + import type { NodeObject, LinkObject } from '3d-force-graph'; + import type { GraphResponse } from '$lib/types'; + + let wsSlug = $derived(page.params.workspace ?? ''); + let username = $derived(page.params.username ?? ''); + + // ── Data / UI state ───────────────────────────────────────────────────────── + let graphData = $state(null); + let loading = $state(true); + let error = $state(''); + // Toggle: by default the API returns active items only; flip to pull terminal + // (completed/closed) items too. Refetches and updates graphData in place β€” + // the renderer instance is never recreated. + let showCompleted = $state(false); + + // The workspace the loaded graph belongs to. SvelteKit reuses this route + // component across workspace param changes; track it so a switch refetches. + let graphWsSlug = ''; + // Monotonic request counter. Plain `let` (non-reactive) so it only gates which + // in-flight load commits β€” discards stale/out-of-order responses. + let reqSeq = 0; + + // ── Renderer handles (all plain `let`, never $state) ───────────────────────── + // The graph instance is imperative, not template-reactive. Per CONVE-1688 we + // never write a $state that an $effect also reads β€” these are read/written from + // effects and handlers, so they stay non-reactive. + let containerEl: HTMLDivElement | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let graph: any = null; + let resizeObserver: ResizeObserver | null = null; + // Latches once the renderer is constructed; the data-sync effect waits on it. + let rendererReady = $state(false); + + // Node-count / edge-count readout for the toolbar. + const nodeCount = $derived(graphData?.nodes.length ?? 0); + const edgeCount = $derived(graphData?.edges.length ?? 0); + const isEmpty = $derived(graphData !== null && graphData.nodes.length === 0); + + // ── Color palette ──────────────────────────────────────────────────────────── + // The chart PALETTE in $lib/components/charts/theme.ts is CSS-var-based, which + // WebGL can't resolve β€” so we keep a local hex palette and assign colors to + // collection slugs in first-seen order (stable within a single graph payload). + const PALETTE = [ + '#6366f1', // indigo + '#06b6d4', // cyan + '#f59e0b', // amber + '#10b981', // emerald + '#f43f5e', // rose + '#8b5cf6', // violet + '#84cc16', // lime + '#0ea5e9' // sky + ]; + // Built fresh on each graphData change. Plain `let` (rebuilt imperatively). + let collectionColors: Record = {}; + + function colorForCollection(slug: string): string { + if (!collectionColors[slug]) { + const idx = Object.keys(collectionColors).length % PALETTE.length; + collectionColors[slug] = PALETTE[idx]; + } + return collectionColors[slug]; + } + + // The renderer's builder methods type their accessor params as the library's + // own NodeObject / LinkObject (an open record), not our concrete shapes. We + // map our GraphNode/GraphEdge fields onto each node/link, so cast at the + // accessor boundary to read them with our local interfaces. + const asNode = (n: NodeObject) => n as unknown as GraphNode3D; + const asLink = (l: LinkObject) => l as unknown as GraphLink3D; + + // nodeLabel renders raw HTML into a tooltip div, so escape user content. + function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // ── Title (kept separate from data-sync effects per CONVE-606) ─────────────── + onMount(() => { + workspaceStore.setCurrent(wsSlug); + }); + + $effect(() => { + page.url.pathname; + titleStore.setPageTitle({ section: 'Graph', item: null }); + }); + + // ── Renderer construction (onMount only β€” dynamic import keeps Three.js lazy) ── + onMount(() => { + let cancelled = false; + + (async () => { + if (!containerEl) return; + // CRITICAL: dynamic import so Three.js lands in its own chunk, not the + // entry bundle. The default export is a factory class; v1.80 supports + // `new ForceGraph3D(el)`. + const ForceGraph3D = (await import('3d-force-graph')).default; + if (cancelled || !containerEl) return; + + const instance = new ForceGraph3D(containerEl) + .backgroundColor('rgba(0,0,0,0)') + .nodeRelSize(4) + // Subtree-weighted node size: parents/plans with children read bigger. + .nodeVal((n: NodeObject) => 1 + (asNode(n).child_count ?? 0) * 2) + .nodeColor((n: NodeObject) => colorForCollection(asNode(n).collection)) + .nodeLabel((n: NodeObject) => `${escapeHtml(asNode(n).ref)} β€” ${escapeHtml(asNode(n).name)}`) + .nodeOpacity(0.95) + // 'blocks' edges read red with a directional arrow; structural links + // (parent/implements) brighter than soft links (wiki-link/related). + .linkColor((l: LinkObject) => linkColor(asLink(l))) + .linkOpacity(0.5) + .linkWidth((l: LinkObject) => (asLink(l).type === 'blocks' ? 1.5 : 0.5)) + .linkDirectionalArrowLength((l: LinkObject) => + asLink(l).type === 'blocks' ? 3 : 0 + ) + .linkDirectionalArrowRelPos(1) + .onNodeClick((n: NodeObject) => { + const node = asNode(n); + // Item pages live at [collection]/[slug]; the server's ResolveItem + // resolves a PREFIX-NUMBER ref in the slug param (same path the + // insights "What shipped" links use), so the ref works directly. + void goto(`/${username}/${wsSlug}/${node.collection}/${node.ref}`); + }); + + graph = instance; + rendererReady = true; + + // Size to the container now and on every resize. + syncSize(); + resizeObserver = new ResizeObserver(syncSize); + resizeObserver.observe(containerEl); + })(); + + return () => { + cancelled = true; + }; + }); + + function syncSize() { + if (!graph || !containerEl) return; + const w = containerEl.clientWidth; + const h = containerEl.clientHeight; + if (w > 0 && h > 0) { + graph.width(w).height(h); + } + } + + // 'blocks' β†’ red-ish; structural (parent/implements/supersedes/split-from) β†’ + // bright slate; soft (wiki-link/related) β†’ dim slate. Alpha carries emphasis. + function linkColor(l: GraphLink3D): string { + if (l.type === 'blocks') return 'rgba(244, 63, 94, 0.85)'; + if (l.type === 'parent' || l.type === 'implements' || l.type === 'supersedes' || l.type === 'split-from') { + return 'rgba(148, 163, 184, 0.85)'; + } + return 'rgba(148, 163, 184, 0.35)'; + } + + // ── Data load + sync ───────────────────────────────────────────────────────── + // Fetch whenever the workspace or the "show completed" toggle changes, with a + // request token so a stale response can't clobber a newer one. + $effect(() => { + const slug = wsSlug; + const withTerminal = showCompleted; + if (slug !== graphWsSlug) { + graphWsSlug = slug; + // Drop the previous workspace's graph so it doesn't linger under the new + // URL while the fetch is in flight β€” `loading` covers the gap. + graphData = null; + } + if (slug) { + void loadGraph(slug, withTerminal); + } + }); + + // Push freshly-loaded data into the renderer once both are ready. Reads + // graphData (reactive) + rendererReady (reactive); writes only the imperative + // `graph` handle and the plain `collectionColors` map, never a tracked $state. + $effect(() => { + const data = graphData; + if (!rendererReady || !graph || !data) return; + // Reset color assignment so collectionβ†’color stays stable per payload. + collectionColors = {}; + graph.graphData({ + nodes: data.nodes.map((n) => ({ ...n, id: n.ref, name: n.title })), + links: data.edges.map((e) => ({ source: e.source, target: e.target, type: e.type })) + }); + }); + + async function loadGraph(slug: string, withTerminal: boolean) { + const seq = ++reqSeq; + loading = true; + error = ''; + try { + const data = await api.graph.get(slug, withTerminal); + if (seq !== reqSeq) return; + graphData = data; + } catch (e) { + if (seq !== reqSeq) return; + error = e instanceof Error ? e.message : 'Failed to load graph.'; + graphData = null; + } finally { + if (seq === reqSeq) loading = false; + } + } + + // ── Teardown ───────────────────────────────────────────────────────────────── + onDestroy(() => { + resizeObserver?.disconnect(); + resizeObserver = null; + // 3d-force-graph's teardown: stops the render loop and frees WebGL context. + graph?._destructor?.(); + graph = null; + }); + + // Local renderer-facing node/link shapes (post-mapping). GraphNode fields are + // spread onto the node, plus the id/name aliases the renderer keys on. + interface GraphNode3D { + id: string; + name: string; + ref: string; + title: string; + collection: string; + status?: string; + is_terminal: boolean; + child_count: number; + updated_at: string; + } + interface GraphLink3D { + source: string; + target: string; + type: string; + } + + +
+ +
+ + + {nodeCount} node{nodeCount === 1 ? '' : 's'} + Β· + {edgeCount} edge{edgeCount === 1 ? '' : 's'} + +
+ + +
+ + + {#if error} +
+
+

Couldn't load the graph

+

{error}

+
+
+ {:else if loading && !graphData} +
+
+

Loading graph…

+
+
+ {:else if isEmpty} +
+
+

No active items to map

+

+ {showCompleted + ? 'This workspace has no items yet.' + : 'Turn on β€œShow completed” to include finished items.'} +

+
+
+ {/if} +
+ + From be8f64d6a12d2faa1db8077e23326b53dde0667e Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 5 Jun 2026 22:43:23 +0000 Subject: [PATCH 2/2] fix: clear stale canvas on workspace switch + reserve 'graph' collection slug per Codex review (round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. The renderer-sync effect now pushes empty graphData when the reactive payload is null (workspace switch in flight / load error) instead of early-returning — the previous workspace's nodes no longer linger behind the loading overlay. 2. 'graph' added to reservedCollectionSlugs so a collection can't shadow the /{username}/{workspace}/graph route, matching the frontend's RESERVED_SLUGS. --- internal/store/collections.go | 1 + .../routes/[username]/[workspace]/graph/+page.svelte | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/store/collections.go b/internal/store/collections.go index 11446f1d0..d993829fe 100644 --- a/internal/store/collections.go +++ b/internal/store/collections.go @@ -657,6 +657,7 @@ var reservedCollectionSlugs = map[string]bool{ "tags": true, // Tag pages: /{ws}/tags and /{ws}/tags/{tag} (PLAN-1652) "library": true, "insights": true, // Insights analytics page route (PLAN-1628 / TASK-1633) + "graph": true, // 3D workspace graph page route (PLAN-1730 / TASK-1733) "new": true, // "ref" is reserved for the cross-workspace wiki-link resolver route // (IDEA-1492): GET /{username}/{workspace}/ref/{REF} → 302 to the diff --git a/web/src/routes/[username]/[workspace]/graph/+page.svelte b/web/src/routes/[username]/[workspace]/graph/+page.svelte index ea27f3ee7..dc4237d8d 100644 --- a/web/src/routes/[username]/[workspace]/graph/+page.svelte +++ b/web/src/routes/[username]/[workspace]/graph/+page.svelte @@ -191,14 +191,19 @@ // Push freshly-loaded data into the renderer once both are ready. Reads // graphData (reactive) + rendererReady (reactive); writes only the imperative // `graph` handle and the plain `collectionColors` map, never a tracked $state. + // A null graphData (workspace switch in flight, or load error) clears the + // canvas too — otherwise the previous workspace's nodes linger behind the + // loading overlay (Codex round-1 finding #1). $effect(() => { const data = graphData; - if (!rendererReady || !graph || !data) return; + if (!rendererReady || !graph) return; // Reset color assignment so collection→color stays stable per payload. collectionColors = {}; graph.graphData({ - nodes: data.nodes.map((n) => ({ ...n, id: n.ref, name: n.title })), - links: data.edges.map((e) => ({ source: e.source, target: e.target, type: e.type })) + nodes: data ? data.nodes.map((n) => ({ ...n, id: n.ref, name: n.title })) : [], + links: data + ? data.edges.map((e) => ({ source: e.source, target: e.target, type: e.type })) + : [] }); });