From 2a80707b850f674967fbeda3f11eccf450a34064 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 03:53:34 +0300 Subject: [PATCH] Screen-anchored region blur + cell floor: fixes tight-search blur collapse and the Infinity throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stroke width is anchored to kFit (screen px at fit zoom — the camera persists across searches), but the blur radius was anchored to cell, which scales with the FILTERED world span: a tight search shrank cell, and the blur's screen reach (blur*cell*kFit) collapsed with it while the stroke stayed put — the blur visibly went away for close-together matches and the knob did nothing (verified: blur 6 and blur 10 produced identical output under a tight search). The blur now uses the stroke's anchoring: its world reach is blur*cellRef, cellRef being the full-graph cell from worldBBox (the same bbox kFit comes from) — span-independent, and with nothing filtered cell == cellRef so the unfiltered look is unchanged. The cell also gains a floor: a single matching node (span 0) zeroed the cell and blew the bitmap dims to Infinity, throwing the reported getImageData TypeError. The floor is the padded rect's own reach, so dims stay finite however tight the search (and the old bitmap ballooning — ~1040px bitmap for a 3-node search — is gone too). --- components/graph-canvas.ts | 41 ++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 77cf146..5a35676 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -1073,8 +1073,30 @@ export class GraphCanvasRenderer { // Bitmap sizing: longest world span maps to `bitmap` px. The rect // is padded by the stroke half-width (dilation) plus the blur // reach so nothing clips at the bitmap edge. - const cell = Math.max(maxX - minX, maxY - minY) / tuning.bitmap; - const padWorld = tuning.dilation * s + (tuning.blur * cell) * 2; + // + // SCREEN-ANCHORED RADII (user report 2026-09-14: a tight search + // collapsed the blur). The stroke width is anchored to kFit (screen + // px at fit zoom — the camera persists, searches don't refit), but + // the blur radius was anchored to `cell`, which shrinks with the + // FILTERED span: close-together matches → tiny cell → blur reach + // (blur·cell·kFit) collapsed on screen while the stroke stayed put. + // The blur now uses the stroke's anchoring: its world reach is + // blur · cellRef, cellRef being the FULL-graph cell (worldBBox — + // the same bbox kFit comes from). Span-independent, and with + // nothing filtered cell == cellRef, so the look is unchanged. + const dilationW = tuning.dilation * s; + const bbox = this.worldBBox; + const cellRef = bbox + ? Math.max(bbox.maxX - bbox.minX, bbox.maxY - bbox.minY) / tuning.bitmap + : 0; + const blurW = tuning.blur * cellRef; + const padWorld = dilationW + blurW * 2; + // Cell floor: a single visible node (span 0) zeroed the cell and + // blew the bitmap dims to Infinity (getImageData TypeError). The + // floor is the padded rect's own reach, so dims stay finite (long + // side ≤ bitmap · (1 + span/2·pad)) however tight the search. + const spanNow = Math.max(maxX - minX, maxY - minY); + const cell = Math.max(spanNow, padWorld * 2) / tuning.bitmap; minX -= padWorld; minY -= padWorld; maxX += padWorld; @@ -1099,7 +1121,7 @@ export class GraphCanvasRenderer { if (!rctx) return; rctx.clearRect(0, 0, bw, bh); rctx.lineCap = "round"; - rctx.lineWidth = (tuning.dilation * s * 2) / cell; + rctx.lineWidth = (dilationW * 2) / cell; // Which cluster is a node in (for intra-cluster edges)? const clusterOf = new Map(); @@ -1147,7 +1169,7 @@ export class GraphCanvasRenderer { // stroke reached: every member is painted by its edges' caps OR // one disc, never both — the no-double-counting rule survives. rctx.fillStyle = `hsla(${hue}, 65%, 60%, ${tuning.edgeAlpha})`; - const discR = (tuning.dilation * s) / cell; + const discR = dilationW / cell; for (const id of cluster.members) { if (touched.has(id) || !isVisible(id)) continue; const n = this.nodeById.get(id); @@ -1173,8 +1195,11 @@ export class GraphCanvasRenderer { // THE blur: 3 box-blur rounds on the premultiplied bitmap. Bilinear // upscaling alone is no blur at all (tent ramps — the JPEG look); - // this makes the field genuinely smooth before the blit. + // this makes the field genuinely smooth before the blit. The + // radius is blurW in bitmap px (screen-anchored — see sizing above), + // an integer because boxBlurRound indexes with the loop counter. if (tuning.blur > 0) { + const blurPx = Math.max(1, Math.round(blurW / cell)); const img = rctx.getImageData(0, 0, bw, bh); const n = bw * bh * 4; const a = new Float32Array(n); @@ -1187,9 +1212,9 @@ export class GraphCanvasRenderer { } const b = new Float32Array(n); const tmp = new Float32Array(n); // scratch inside each round only - boxBlurRound(a, tmp, b, bw, bh, tuning.blur); // a → b - boxBlurRound(b, tmp, a, bw, bh, tuning.blur); // b → a - boxBlurRound(a, tmp, b, bw, bh, tuning.blur); // a → b (result) + boxBlurRound(a, tmp, b, bw, bh, blurPx); // a → b + boxBlurRound(b, tmp, a, bw, bh, blurPx); // b → a + boxBlurRound(a, tmp, b, bw, bh, blurPx); // a → b (result) // Unpremultiply back into pixels. for (let i = 0; i < n; i += 4) { const alpha = b[i + 3]!;