Region alpha applies once after the blur: no more overlap stacking

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.
This commit is contained in:
amosblomqvist
2026-09-14 04:56:15 +03:00
parent 2a80707b85
commit 0d67fd2073
2 changed files with 27 additions and 10 deletions
+23 -9
View File
@@ -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);
+4 -1
View File
@@ -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;
}