Region v2: line-only dilation, real premultiplied blur, dev tuning inputs

User feedback round 1 on the splat regions:
- Double counting: per-node gradient discs stacked with the edge-stroke
  dilation wherever nodes sat on edges (hub hot spots). A cluster is its
  CONNECTED skeleton — the region is now the dilated intra-cluster edge
  network ONLY (round caps make line-end points discs), per the user's
  definition. No per-node discs at all.
- The 'blur' was no blur: bilinear upscale of a 256px bitmap produced
  tent-ramp chunkiness at zoom (the low-quality-JPEG look). Now a REAL
  blur: 3 box-blur rounds on the premultiplied bitmap (separable running
  sum, pure + unit-tested; premultiply keeps halos out of transparent
  space). The upscale now has nothing left to make chunky.
- Live dev controls: number inputs (not sliders) in the settings panel —
  dilation (screen px at fit), blur (bitmap px), bitmap resolution,
  edge alpha. Session-only React state, never persisted; the renderer's
  sanitizeRegionTuning makes bad/empty values safe. Verified end-to-end
  through the real input (dilation 28 → 60 took effect).
- Premultiply un-recovery bug caught live: c = pm/a*255, not pm/a (the
  first version rounded every color to black; regions rendered as
  dark blobs with alpha 185).
- Region canvas: willReadFrequently (Chrome keeps it on the CPU for the
  per-rebuild getImageData); defaults dilation 55 → 28 after measuring
  screen coverage (63% → 55%).
- Perf: 120fps sustained through a drag with blur + rebuild-every-3rd-
  frame (45 frames / 360ms measured), zero idle frames after settle.
This commit is contained in:
amosblomqvist
2026-09-13 09:39:22 +03:00
parent 05712037b2
commit 60686d7831
3 changed files with 330 additions and 44 deletions
+198 -43
View File
@@ -153,13 +153,118 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel
* the jagged close-up look), there is no border to lag behind, and a
* live-physics frame pays one tiny blit instead of 32 clusters × 4
* offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */
const REGION_KERNEL = 30; // screen px at fit — soft disc radius
const REGION_NODE_ALPHA = 0.2; // per disc; overlaps saturate toward the core
const REGION_EDGE_ALPHA = 0.08; // round-cap strokes, width = kernel × 0.8
const REGION_BITMAP = 256; // max bitmap dimension; cell = worldSpan / this
const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
const REGION_LABEL_SIZE = 14;
/** Dev-tunable region rendering (2026-09-13, user request): plain number
* inputs in the settings panel — session-only, never persisted — so the
* look can be tuned live against the real graph. A cluster is its
* CONNECTED SKELETON: the region is the dilated line network ONLY (no
* per-node discs — they double-counted where nodes sit on edges and
* made hubs hot). Blur is REAL: a premultiplied 3-pass box blur on the
* bitmap (bilinear upscale alone was no blur at all — zoom-in showed
* tent-ramp chunkiness, the "low-quality JPEG" look). */
export interface RegionTuning {
/** Dilation half-width of the round-cap edge strokes, screen px at
* fit zoom (the expansion of the cluster skeleton). */
dilation: number;
/** Box-blur radius in bitmap px (3 passes ≈ Gaussian). 0 = off. */
blur: number;
/** Max bitmap dimension (cell = worldSpan / this). */
bitmap: number;
/** Per-stroke alpha; overlapping strokes stack toward the core. */
edgeAlpha: number;
}
export const DEFAULT_REGION_TUNING: RegionTuning = {
dilation: 28,
blur: 10,
bitmap: 384,
edgeAlpha: 0.35,
};
/** Clamp anything into a usable tuning (number inputs can be empty/NaN). */
export function sanitizeRegionTuning(input: Partial<RegionTuning> | null | undefined): RegionTuning {
const d = DEFAULT_REGION_TUNING;
const num = (v: unknown, fallback: number): number =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
return {
dilation: Math.min(Math.max(num(input?.dilation, d.dilation), 4), 250),
blur: Math.min(Math.max(num(input?.blur, d.blur), 0), 40),
bitmap: Math.round(Math.min(Math.max(num(input?.bitmap, d.bitmap), 128), 1024)),
edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), 0.02), 1),
};
}
/** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA
* (Float32, stride 4, length w*h*4). Premultiplying first is what keeps
* the blur halo-free: blurring non-premultiplied RGB over varying alpha
* bleeds solid color into transparent space. Pure: DOM-free, tested. */
export function boxBlurRound(
src: Float32Array,
tmp: Float32Array,
dst: Float32Array,
w: number,
h: number,
radius: number,
): void {
if (radius <= 0) {
dst.set(src);
return;
}
const div = radius * 2 + 1;
// Horizontal: src → tmp (clamped window, running sum).
for (let y = 0; y < h; y++) {
const row = y * w * 4;
let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
for (let x = -radius; x <= radius; x++) {
const i = row + Math.min(Math.max(x, 0), w - 1) * 4;
sum0 += src[i]!;
sum1 += src[i + 1]!;
sum2 += src[i + 2]!;
sum3 += src[i + 3]!;
}
for (let x = 0; x < w; x++) {
const o = row + x * 4;
tmp[o] = sum0 / div;
tmp[o + 1] = sum1 / div;
tmp[o + 2] = sum2 / div;
tmp[o + 3] = sum3 / div;
const addI = row + Math.min(x + radius + 1, w - 1) * 4;
const subI = row + Math.max(x - radius, 0) * 4;
sum0 += src[addI]! - src[subI]!;
sum1 += src[addI + 1]! - src[subI + 1]!;
sum2 += src[addI + 2]! - src[subI + 2]!;
sum3 += src[addI + 3]! - src[subI + 3]!;
}
}
// Vertical: tmp → dst.
for (let x = 0; x < w; x++) {
const col = x * 4;
let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
for (let y = -radius; y <= radius; y++) {
const i = col + Math.min(Math.max(y, 0), h - 1) * w * 4;
sum0 += tmp[i]!;
sum1 += tmp[i + 1]!;
sum2 += tmp[i + 2]!;
sum3 += tmp[i + 3]!;
}
for (let y = 0; y < h; y++) {
const o = col + y * w * 4;
dst[o] = sum0 / div;
dst[o + 1] = sum1 / div;
dst[o + 2] = sum2 / div;
dst[o + 3] = sum3 / div;
const addI = col + Math.min(y + radius + 1, h - 1) * w * 4;
const subI = col + Math.max(y - radius, 0) * w * 4;
sum0 += tmp[addI]! - tmp[subI]!;
sum1 += tmp[addI + 1]! - tmp[subI + 1]!;
sum2 += tmp[addI + 2]! - tmp[subI + 2]!;
sum3 += tmp[addI + 3]! - tmp[subI + 3]!;
}
}
}
/** All "px" constants above are screen px AT FIT ZOOM. The renderer scales
* them into world units (÷ kFit) once per geometry build; camera zoom then
* scales nodes/labels/arrowheads with the graph (Obsidian-style), while
@@ -398,6 +503,7 @@ export class GraphCanvasRenderer {
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
/** Region labels: cluster centroid + hue, computed at splat build. */
private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = [];
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
private regionBuiltVersion = -1;
private frameCount = 0; // draw counter for the live-sim splat throttle
private labelLines = new Map<string, string[]>();
@@ -506,6 +612,15 @@ export class GraphCanvasRenderer {
this.markDirty();
}
/** Dev tuning for region rendering (session-only, never persisted):
* invalid numbers fall back to defaults — the number inputs can be
* emptied while typing. Forces a region bitmap rebuild. */
setRegionTuning(input: Partial<RegionTuning>): void {
this.regionTuning = sanitizeRegionTuning(input);
this.regionBuiltVersion = -2; // never equals geoVersion → rebuild
this.markDirty();
}
setVisible(visible: ReadonlySet<string> | null): void {
this.visible = visible;
this.geoVersion++; // member visibility changes hulls + paths
@@ -914,21 +1029,21 @@ export class GraphCanvasRenderer {
const visible = this.visible;
const isVisible = (id: string): boolean => !visible || visible.has(id);
// World rect over VISIBLE cluster members (regions cover members
// and their intra-cluster edges — nothing else), padded by the
// kernel so discs can bleed past the extremes.
const tuning = this.regionTuning;
const s = 1 / this.kFit;
const kernel = REGION_KERNEL * s;
// World rect over VISIBLE cluster members (regions cover members
// and their intra-cluster edges — nothing else).
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const c of data.clusters) {
for (const id of c.members) {
if (!isVisible(id)) continue;
const n = this.nodeById.get(id);
if (!n) continue;
if (n.x - kernel < minX) minX = n.x - kernel;
if (n.x + kernel > maxX) maxX = n.x + kernel;
if (n.y - kernel < minY) minY = n.y - kernel;
if (n.y + kernel > maxY) maxY = n.y + kernel;
if (n.x < minX) minX = n.x;
if (n.x > maxX) maxX = n.x;
if (n.y < minY) minY = n.y;
if (n.y > maxY) maxY = n.y;
}
}
if (minX > maxX) {
@@ -938,10 +1053,17 @@ export class GraphCanvasRenderer {
return;
}
// Bitmap sizing: longest world span maps to REGION_BITMAP px.
// 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;
minX -= padWorld;
minY -= padWorld;
maxX += padWorld;
maxY += padWorld;
const spanX = maxX - minX;
const spanY = maxY - minY;
const cell = Math.max(spanX, spanY) / REGION_BITMAP;
const bw = Math.max(1, Math.ceil(spanX / cell));
const bh = Math.max(1, Math.ceil(spanY / cell));
let rc = this.regionCanvas;
@@ -953,12 +1075,14 @@ export class GraphCanvasRenderer {
rc.width = bw;
rc.height = bh; // also clears
}
const rctx = rc.getContext("2d");
// willReadFrequently: the blur step getImageData/putImageData every
// rebuild — Chrome keeps this canvas on the CPU instead of GPU-
// shuttling it.
const rctx = rc.getContext("2d", { willReadFrequently: true });
if (!rctx) return;
rctx.clearRect(0, 0, bw, bh);
rctx.lineCap = "round";
const R = kernel / cell;
const edgeW = R * 0.8 * 2;
rctx.lineWidth = (tuning.dilation * s * 2) / cell;
// Which cluster is a node in (for intra-cluster edges)?
const clusterOf = new Map<string, number>();
@@ -966,32 +1090,19 @@ export class GraphCanvasRenderer {
for (const id of c.members) clusterOf.set(id, i);
});
// The region IS the dilated skeleton: intra-cluster edges as
// round-cap strokes (Minkowski dilation — points become discs at
// line ends via the caps). NO per-node discs: a cluster is its
// connected points, and discs double-counted where nodes sit on
// edges (user-identified hot spots).
this.regionMeta = [];
data.clusters.forEach((cluster, ci) => {
const hue = clusterHue(cluster.id);
const members: Pt[] = [];
const alpha = (a: number): string => `hsla(${hue}, 65%, 60%, ${a})`;
// Discs: one soft radial gradient per member node.
rctx.fillStyle = alpha(REGION_NODE_ALPHA);
for (const id of cluster.members) {
if (!isVisible(id)) continue;
const n = this.nodeById.get(id);
if (!n) continue;
members.push({ x: n.x, y: n.y });
const px = (n.x - minX) / cell;
const py = (n.y - minY) / cell;
const grad = rctx.createRadialGradient(px, py, 0, px, py, R);
grad.addColorStop(0, alpha(REGION_NODE_ALPHA));
grad.addColorStop(1, alpha(0));
rctx.fillStyle = grad;
rctx.fillRect(px - R, py - R, R * 2, R * 2);
}
// Minkowski strokes: intra-cluster edges with round caps make
// the region follow its own linkage, not just its node dots.
rctx.strokeStyle = alpha(REGION_EDGE_ALPHA);
rctx.lineWidth = edgeW;
rctx.strokeStyle = `hsla(${hue}, 65%, 60%, ${tuning.edgeAlpha})`;
rctx.beginPath();
let members = 0;
let sumX = 0;
let sumY = 0;
for (const e of data.edges) {
if (clusterOf.get(e.from) !== ci) continue;
if (clusterOf.get(e.to) !== ci) continue;
@@ -1004,12 +1115,56 @@ export class GraphCanvasRenderer {
rctx.stroke();
// Label anchor: member centroid (world coords).
if (members.length > 0) {
const cx = members.reduce((sum, p) => sum + p.x, 0) / members.length;
const cy = members.reduce((sum, p) => sum + p.y, 0) / members.length;
this.regionMeta.push({ label: cluster.label || null, cx, cy, hue });
for (const id of cluster.members) {
if (!isVisible(id)) continue;
const n = this.nodeById.get(id);
if (!n) continue;
members++;
sumX += n.x;
sumY += n.y;
}
if (members > 0) {
this.regionMeta.push({ label: cluster.label || null, cx: sumX / members, cy: sumY / members, hue });
}
});
// 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.
if (tuning.blur > 0) {
const img = rctx.getImageData(0, 0, bw, bh);
const n = bw * bh * 4;
const a = new Float32Array(n);
for (let i = 0; i < n; i += 4) {
const alpha = img.data[i + 3]! / 255;
a[i] = img.data[i]! * alpha;
a[i + 1] = img.data[i + 1]! * alpha;
a[i + 2] = img.data[i + 2]! * alpha;
a[i + 3] = img.data[i + 3]!;
}
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)
// Unpremultiply back into pixels.
for (let i = 0; i < n; i += 4) {
const alpha = b[i + 3]!;
if (alpha <= 0) {
img.data[i] = 0;
img.data[i + 1] = 0;
img.data[i + 2] = 0;
img.data[i + 3] = 0;
} else {
// Un-premultiply: pm = c * a/255, so c = pm / a * 255.
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);
}
}
rctx.putImageData(img, 0, 0);
}
this.regionRect = { minX, minY, w: spanX, h: spanY };
}
+75 -1
View File
@@ -67,7 +67,12 @@ import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, Waypoi
import Graph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2";
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import {
DEFAULT_REGION_TUNING,
GraphCanvasRenderer,
type GraphRenderSettings,
type RegionTuning,
} from "./graph-canvas.ts";
import {
ARROW_LENGTH_RANGE,
DEFAULT_RENDER_SETTINGS,
@@ -144,6 +149,33 @@ function sizeDisplay(v: number): string {
return `${v >= 1 ? Math.round(v * 10) / 10 : Math.round(v * 100) / 100}`;
}
/** Dev number input: raw value in, raw value out (NaN while empty → the
* renderer's sanitizer holds the last sane tuning). Development tool. */
function NumberField({
label,
value,
step,
onChange,
}: {
label: string;
value: number;
step: number;
onChange: (v: number) => void;
}) {
return (
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>{label}</span>
<input
type="number"
value={Number.isFinite(value) ? value : ""}
step={step}
onChange={(e) => onChange(e.target.valueAsNumber)}
className="w-20 rounded-md border bg-background px-1.5 py-0.5 text-right tabular-nums text-foreground"
/>
</label>
);
}
function SettingSlider({
label,
min,
@@ -195,6 +227,10 @@ export function GraphView() {
// Search panel state: the query text + the settings section toggle.
const [query, setQuery] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
// Region tuning: DEV-ONLY, session state, never persisted (unlike the
// render settings, which live on the server). Number inputs so any
// value can be tried quickly.
const [regionTuning, setRegionTuning] = useState<RegionTuning>(DEFAULT_REGION_TUNING);
// Render settings: SERVER-persisted (data/render-settings.json via
// /api/graph/render-settings). Loaded once after mount; every user
// change saves back, debounced. Defaults render until the load
@@ -467,6 +503,12 @@ export function GraphView() {
setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null));
}, [visibleIds, query]);
// Feed #4 — region tuning (dev number inputs): its own update path,
// independent of the server-persisted render settings.
useEffect(() => {
rendererRef.current?.setRegionTuning(regionTuning);
}, [regionTuning]);
// Position the detail card: below the clicked node, flipped above near
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
// before paint) because the flip needs the card's measured height.
@@ -598,6 +640,38 @@ export function GraphView() {
className="accent-primary"
/>
</label>
{/* Regions (dev): quick-tuning number inputs. Session state
only - never persisted. Bad/empty values fall back to
the last sane tuning inside the renderer. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Regions (dev)
</span>
<NumberField
label="Dilation (px at fit)"
value={regionTuning.dilation}
step={5}
onChange={(v) => setRegionTuning((t) => ({ ...t, dilation: v }))}
/>
<NumberField
label="Blur (bitmap px)"
value={regionTuning.blur}
step={1}
onChange={(v) => setRegionTuning((t) => ({ ...t, blur: v }))}
/>
<NumberField
label="Bitmap resolution"
value={regionTuning.bitmap}
step={64}
onChange={(v) => setRegionTuning((t) => ({ ...t, bitmap: v }))}
/>
<NumberField
label="Edge alpha"
value={regionTuning.edgeAlpha}
step={0.05}
onChange={(v) => setRegionTuning((t) => ({ ...t, edgeAlpha: v }))}
/>
</div>
</div>
)}
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
+57
View File
@@ -13,10 +13,13 @@ import assert from "node:assert/strict";
import { describe, it as test } from "node:test";
import {
arrowGeometry,
boxBlurRound,
fitTransform,
labelAlpha,
pickNearest,
sanitizeRegionTuning,
zoomRatioOf,
DEFAULT_REGION_TUNING,
LABELS_FADE_SPAN,
} from "../components/graph-canvas.ts";
@@ -137,3 +140,57 @@ describe("pickNearest (distance-check picking)", () => {
assert.equal(pickNearest([], 0, 0, 100), null);
});
});
describe("sanitizeRegionTuning (dev number inputs)", () => {
test("defaults survive untouched; partial input keeps the rest", () => {
const t = sanitizeRegionTuning(undefined);
assert.deepEqual(t, DEFAULT_REGION_TUNING);
const half = sanitizeRegionTuning({ blur: 0 });
assert.equal(half.blur, 0);
assert.equal(half.dilation, DEFAULT_REGION_TUNING.dilation);
});
test("garbage clamps: NaN falls back, out-of-range clamps", () => {
const t = sanitizeRegionTuning({ dilation: Number.NaN, blur: -5, bitmap: 99999, edgeAlpha: 42 });
assert.equal(t.dilation, DEFAULT_REGION_TUNING.dilation); // NaN -> default
assert.equal(t.blur, 0);
assert.equal(t.bitmap, 1024);
assert.equal(t.edgeAlpha, 1);
});
test("empty string / null / undefined values fall back per-field", () => {
const t = sanitizeRegionTuning({ edgeAlpha: Number.NaN });
assert.equal(t.edgeAlpha, DEFAULT_REGION_TUNING.edgeAlpha);
});
});
describe("boxBlurRound (premultiplied region blur)", () => {
test("spreads a single opaque px into its neighborhood and conserves sum", () => {
// 5x5 RGBA, one white opaque pixel at (2,2)
const w = 5, h = 5;
const a = new Float32Array(w * h * 4);
const center = (2 * w + 2) * 4;
a[center] = 255; a[center + 1] = 255; a[center + 2] = 255; a[center + 3] = 255;
const tmp = new Float32Array(a.length);
const b = new Float32Array(a.length);
boxBlurRound(a, tmp, b, w, h, 1);
// Alpha conservation: total alpha unchanged.
let sumA = 0;
for (let i = 3; i < b.length; i += 4) sumA += b[i]!;
assert.ok(Math.abs(sumA - 255) < 1e-3, `alpha sum ${sumA}`);
// The center dimmed, the 8 neighbors picked some up.
assert.ok(b[center + 3]! < 255 && b[center + 3]! > 0);
const up = ((1 * w) + 2) * 4 + 3;
assert.ok(b[up]! > 0, "neighbor got alpha");
// Far corner (4,4) untouched.
assert.equal(b[(4 * 5 + 4) * 4 + 3], 0);
});
test("radius 0 is a plain copy", () => {
const a = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8]);
const tmp = new Float32Array(8);
const b = new Float32Array(8);
boxBlurRound(a, tmp, b, 1, 2, 0);
assert.deepEqual([...b], [1, 2, 3, 4, 5, 6, 7, 8]);
});
});