From 0d67fd20738779253d07efe0c3e0cad57f39e64d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 04:56:15 +0300 Subject: [PATCH] Region alpha applies once after the blur: no more overlap stacking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-stroke alpha baked into every stroke/disc accumulated through source-over compositing: one shape 0.35, an orphan disc over anything 1-0.65^2 ~ 0.58, hubs toward 1.0 — a lone node's glow read as a darker double-counted spot on top of other regions. The pipeline is now dilate at full opacity -> blur -> scale the whole field by edgeAlpha once at the end. Painted at 1, the alpha channel is a pure coverage indicator (overlaps of any count contribute exactly 1.0), the blur smooths it into a coverage falloff, and the final scaling gives a constant interior alpha everywhere covered, varying only across the blur edges. The blur-off path also runs the readback now (radius 0 = copy) so the end-alpha applies uniformly without blur. Interaction change (user-flagged, accepted for now): different clusters' overlapping halos no longer blend hues proportionally — the later-painted cluster covers the earlier, ownership boundaries softened only by the blur reach. Verified: old build spread 36k of 55k tinted pixels above alpha 100 (stacking); new build concentrates at 70-90 (0.35*255=89 interior with falloff), and an orphan disc now reads no brighter than the skeleton it sits on. --- components/graph-canvas.ts | 32 +++++++++++++++++++++++--------- src/core/render-settings.ts | 5 ++++- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 5a35676..91950d8 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -1136,7 +1136,14 @@ export class GraphCanvasRenderer { this.regionMeta = []; data.clusters.forEach((cluster, ci) => { const hue = clusterHue(cluster.id); - rctx.strokeStyle = `hsla(${hue}, 65%, 60%, ${tuning.edgeAlpha})`; + // Full-opacity strokes: alpha is applied ONCE at the very end + // (dilate at 1 → blur → scale by edgeAlpha). Baking per-stroke + // alpha in here made source-over stacking ACCUMULATE it — an + // orphan disc overlapping anything read as a darker double- + // counted spot (user report 2026-09-14). At alpha 1 the alpha + // channel is a pure coverage indicator: overlaps of any count + // contribute exactly 1.0. + rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`; rctx.beginPath(); let members = 0; let sumX = 0; @@ -1168,7 +1175,7 @@ export class GraphCanvasRenderer { // radius as a round cap = dilation) covers exactly the members no // 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})`; + rctx.fillStyle = `hsla(${hue}, 65%, 60%, 1)`; const discR = dilationW / cell; for (const id of cluster.members) { if (touched.has(id) || !isVisible(id)) continue; @@ -1193,13 +1200,18 @@ export class GraphCanvasRenderer { } }); - // THE blur: 3 box-blur rounds on the premultiplied bitmap. Bilinear + // THE blur + alpha (in that order, by design — user-decided + // 2026-09-14): 3 box-blur rounds on the premultiplied bitmap, + // THEN scale the whole field by edgeAlpha once. Bilinear // upscaling alone is no blur at all (tent ramps — the JPEG look); // 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)); + // radius is blurW in bitmap px (screen-anchored — see sizing + // above), an integer because boxBlurRound indexes with the loop + // counter. Radius 0 (blur off) still runs this path — boxBlurRound + // copies — so the end-alpha applies uniformly with or without + // blur. + { + const blurPx = tuning.blur > 0 ? Math.max(1, Math.round(blurW / cell)) : 0; const img = rctx.getImageData(0, 0, bw, bh); const n = bw * bh * 4; const a = new Float32Array(n); @@ -1215,7 +1227,9 @@ export class GraphCanvasRenderer { 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. + // Unpremultiply back into pixels; scale the alpha by edgeAlpha + // HERE — the one, final application. Result: constant interior + // alpha everywhere covered, tapering only across the blur edges. for (let i = 0; i < n; i += 4) { const alpha = b[i + 3]!; if (alpha <= 0) { @@ -1228,7 +1242,7 @@ export class GraphCanvasRenderer { img.data[i] = Math.round((b[i]! / alpha) * 255); img.data[i + 1] = Math.round((b[i + 1]! / alpha) * 255); img.data[i + 2] = Math.round((b[i + 2]! / alpha) * 255); - img.data[i + 3] = Math.round(alpha); + img.data[i + 3] = Math.round(alpha * tuning.edgeAlpha); } } rctx.putImageData(img, 0, 0); diff --git a/src/core/render-settings.ts b/src/core/render-settings.ts index 6a1cfb2..1002985 100644 --- a/src/core/render-settings.ts +++ b/src/core/render-settings.ts @@ -95,7 +95,10 @@ export interface RegionTuning { /** Max bitmap dimension (cell = worldSpan / this). Fixed set: * see REGION_BITMAP_OPTIONS (dropdown in the panel). */ bitmap: number; - /** Per-stroke alpha; overlapping strokes stack toward the core. */ + /** Region alpha, applied ONCE after the blur (dilate at full + * opacity → blur → scale). Constant interior, soft edges; overlaps + * of any count never stack (DECIDED 2026-09-14 — per-stroke alpha + * accumulated through source-over and double-counted orphans). */ edgeAlpha: number; }