From 38f18ac534aaefbefe0b206e7de8eead15b3c72e Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 5 Jun 2026 21:00:40 -0400 Subject: [PATCH] fix(web): graph repaints in place instead of full-scene refresh (BUG-1742) (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graph.refresh() sets three-forcegraph's _flushObjects flag, which destroys and recreates every Three.js object in the scene. Desktop GPUs hide the rebuild inside a frame; on mobile it reads as a full-screen flash on every select, deselect (background tap), SSE pulse, and 2s fade tick. Replace all refresh() call sites with repaint(): re-assigning fresh closures for the selection/pulse/chain-dependent accessors (nodeColor, linkColor, particle trio) takes the lib's in-place material/particle update path — objects survive, no flash. linkWidth is the one visual accessor whose prop-change DOES flush link objects (cylinder geometry is in the clear list), so width is now static per edge type and chain emphasis rides on full-alpha red + particles, as it already did visually. Also kill the gray mobile tap-highlight on the canvas. Reported by Dave on mobile right after PLAN-1730 shipped. --- .../[username]/[workspace]/graph/+page.svelte | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/web/src/routes/[username]/[workspace]/graph/+page.svelte b/web/src/routes/[username]/[workspace]/graph/+page.svelte index c80d7fab..7ffeadb6 100644 --- a/web/src/routes/[username]/[workspace]/graph/+page.svelte +++ b/web/src/routes/[username]/[workspace]/graph/+page.svelte @@ -52,7 +52,7 @@ // fade; created/archived/restored items appear/disappear via a debounced // refetch. All of this state is plain `let` (CONVE-1688) — it's mutated from the // SSE callback + an interval and read by the renderer accessors, never by an - // $effect. Re-evaluation is forced explicitly via graph.refresh(). + // $effect. Re-evaluation is forced explicitly via repaint() (BUG-1742). // How long a touched node glows before it has fully decayed back to its base // collection color (~45s of ambient afterglow). @@ -83,9 +83,9 @@ // The dim-everything-else highlight is driven by two plain `let` Sets that the // renderer accessor closures read. Per CONVE-1688 these stay non-reactive — they // are mutated imperatively in the click handler, never tracked by an $effect. - // Re-evaluation is triggered explicitly by calling `graph.refresh()` after each - // change (3d-force-graph README: `refresh()` "Redraws all the nodes/links", - // re-running every color/opacity accessor). + // Re-evaluation is triggered explicitly by calling `repaint()` after each + // change (fresh accessor closures → the lib's in-place update path; NOT + // graph.refresh(), which flushes + rebuilds the whole scene — BUG-1742). let selectedRef: string | null = null; let neighborRefs = new Set(); @@ -94,7 +94,7 @@ // select we walk the transitive blocker chain UPSTREAM over 'blocks' edges (a // node's blockers are the SOURCES of blocks-edges whose target is that node) and // stash the result in two plain `let` Sets the accessors read (CONVE-1688: no - // $state in the imperative render path; re-evaluated via graph.refresh()). + // $state in the imperative render path; re-evaluated via repaint()). // chainRefs — every node in the transitive chain, NOT including the selected // node itself. // chainEdges — the blocks-edges that make up the chain, keyed @@ -408,7 +408,7 @@ // ── Selection-aware accessors ──────────────────────────────────────────────── // All three read the plain `let` selection Sets directly (CONVE-1688: no $state - // in the imperative path). `graph.refresh()` re-runs them after each change. + // in the imperative path). `repaint()` re-runs them after each change. // ── Terminal recede (TASK-1738) ───────────────────────────────────────────── // Terminal items (only ever on screen with show-completed on) shrink AND dim so @@ -495,18 +495,45 @@ // True when a link is on the lit blocker chain (keyed by raw refs, since the force // layout mutates source/target into node objects after ingest). Drives the chain- - // only width bump + directional particles. + // only color + directional particles (width stays static — see linkWidth). function isChainEdge(l: GraphLink3D): boolean { return chainEdges.has(`${l.sourceRef}->${l.targetRef}`); } - // Link width: chain edges widest (~2), then blocks (1.5), then structural/soft - // (0.5). Chain checked first so a chain'd blocks-edge gets the wider treatment. + // Link width: blocks 1.5, structural/soft 0.5. STATIC per edge type — width + // deliberately does NOT vary with chain state: linkWidth is the one visual + // accessor whose prop-change makes the lib flush + rebuild every link object + // (new cylinder geometry — it's in three-forcegraph's clear list), which is + // exactly the full-scene rebuild repaint() exists to avoid (BUG-1742). Chain + // emphasis rides on full-alpha red + directional particles instead. function linkWidth(l: GraphLink3D): number { - if (isChainEdge(l)) return 2; return l.type === 'blocks' ? 1.5 : 0.5; } + // Re-evaluate the selection/pulse/chain-dependent visual accessors WITHOUT + // graph.refresh(). refresh() sets _flushObjects, which destroys and recreates + // every Three.js object in the scene — invisible on desktop GPUs, a visible + // full-screen flash on mobile (BUG-1742). Re-assigning a FRESH closure per + // prop instead makes the lib take its in-place update path: materials and + // particle photons update, objects survive. Only props whose accessors read + // mutable selection/pulse/chain state are re-assigned; static accessors + // (nodeVal, linkWidth, arrows) are left alone. + function repaint() { + if (!graph) return; + graph + .nodeColor((n: NodeObject) => nodeColor(asNode(n))) + .linkColor((l: LinkObject) => linkColor(asLink(l))) + .linkDirectionalParticles((l: LinkObject) => + isChainEdge(asLink(l)) ? 3 : 0 + ) + .linkDirectionalParticleWidth((l: LinkObject) => + isChainEdge(asLink(l)) ? 1.5 : 0 + ) + .linkDirectionalParticleSpeed((l: LinkObject) => + isChainEdge(asLink(l)) ? 0.006 : 0 + ); + } + // Hex (#rrggbb) → rgba() with the given alpha. The dim treatment for out-of- // neighborhood nodes; mixing toward transparent reads as receding into the // dark backdrop without losing the collection hue entirely. @@ -659,8 +686,9 @@ 800 ); - // Re-run every node/link accessor so the dim/highlight takes effect. - graph?.refresh(); + // Re-run the selection-dependent accessors so the dim/highlight takes + // effect (in-place — see repaint(); BUG-1742). + repaint(); // Fetch richer detail (priority / assignee) for the card. Stale-gated so a // rapid re-select can't be overwritten by an older response. @@ -746,7 +774,7 @@ selectedItem = null; selectedItemLoading = false; selectSeq++; - graph?.refresh(); + repaint(); } // Open the selected item's page — this is where the old direct-click navigation @@ -995,7 +1023,7 @@ if (!ref) return; touchedAt.set(ref, Date.now()); ensurePruneInterval(); - graph?.refresh(); + repaint(); } // Trailing-debounced refetch of the CURRENT (wsSlug, showCompleted) graph through @@ -1013,7 +1041,7 @@ // The prune/fade ticker. Started lazily when the first node is touched and // stopped once touchedAt empties, so an idle graph runs no timer (the fade is // only interesting while something is glowing). Each tick drops fully-decayed - // entries and calls graph.refresh() so nodeColor re-runs and the fade animates. + // entries and calls repaint() so nodeColor re-runs and the fade animates. function ensurePruneInterval() { if (pruneInterval) return; pruneInterval = setInterval(() => { @@ -1021,7 +1049,7 @@ for (const [ref, at] of touchedAt) { if (now - at >= PULSE_MS) touchedAt.delete(ref); } - graph?.refresh(); + repaint(); if (touchedAt.size === 0 && pruneInterval) { clearInterval(pruneInterval); pruneInterval = null; @@ -1168,6 +1196,9 @@ .canvas { position: absolute; inset: 0; + /* Kill the gray mobile tap-highlight overlay on the full-screen canvas — + it reads as a flash on every tap (BUG-1742). */ + -webkit-tap-highlight-color: transparent; } /* ── State overlays ───────────────────────────────────────────────────────── */