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:
+198
-43
@@ -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
|
* 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
|
* live-physics frame pays one tiny blit instead of 32 clusters × 4
|
||||||
* offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */
|
* 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_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
|
||||||
const REGION_LABEL_SIZE = 14;
|
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
|
/** 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
|
* them into world units (÷ kFit) once per geometry build; camera zoom then
|
||||||
* scales nodes/labels/arrowheads with the graph (Obsidian-style), while
|
* 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;
|
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
|
||||||
/** Region labels: cluster centroid + hue, computed at splat build. */
|
/** Region labels: cluster centroid + hue, computed at splat build. */
|
||||||
private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = [];
|
private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = [];
|
||||||
|
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
|
||||||
private regionBuiltVersion = -1;
|
private regionBuiltVersion = -1;
|
||||||
private frameCount = 0; // draw counter for the live-sim splat throttle
|
private frameCount = 0; // draw counter for the live-sim splat throttle
|
||||||
private labelLines = new Map<string, string[]>();
|
private labelLines = new Map<string, string[]>();
|
||||||
@@ -506,6 +612,15 @@ export class GraphCanvasRenderer {
|
|||||||
this.markDirty();
|
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 {
|
setVisible(visible: ReadonlySet<string> | null): void {
|
||||||
this.visible = visible;
|
this.visible = visible;
|
||||||
this.geoVersion++; // member visibility changes hulls + paths
|
this.geoVersion++; // member visibility changes hulls + paths
|
||||||
@@ -914,21 +1029,21 @@ export class GraphCanvasRenderer {
|
|||||||
const visible = this.visible;
|
const visible = this.visible;
|
||||||
const isVisible = (id: string): boolean => !visible || visible.has(id);
|
const isVisible = (id: string): boolean => !visible || visible.has(id);
|
||||||
|
|
||||||
// World rect over VISIBLE cluster members (regions cover members
|
const tuning = this.regionTuning;
|
||||||
// and their intra-cluster edges — nothing else), padded by the
|
|
||||||
// kernel so discs can bleed past the extremes.
|
|
||||||
const s = 1 / this.kFit;
|
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;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
for (const c of data.clusters) {
|
for (const c of data.clusters) {
|
||||||
for (const id of c.members) {
|
for (const id of c.members) {
|
||||||
if (!isVisible(id)) continue;
|
if (!isVisible(id)) continue;
|
||||||
const n = this.nodeById.get(id);
|
const n = this.nodeById.get(id);
|
||||||
if (!n) continue;
|
if (!n) continue;
|
||||||
if (n.x - kernel < minX) minX = n.x - kernel;
|
if (n.x < minX) minX = n.x;
|
||||||
if (n.x + kernel > maxX) maxX = n.x + kernel;
|
if (n.x > maxX) maxX = n.x;
|
||||||
if (n.y - kernel < minY) minY = n.y - kernel;
|
if (n.y < minY) minY = n.y;
|
||||||
if (n.y + kernel > maxY) maxY = n.y + kernel;
|
if (n.y > maxY) maxY = n.y;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (minX > maxX) {
|
if (minX > maxX) {
|
||||||
@@ -938,10 +1053,17 @@ export class GraphCanvasRenderer {
|
|||||||
return;
|
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 spanX = maxX - minX;
|
||||||
const spanY = maxY - minY;
|
const spanY = maxY - minY;
|
||||||
const cell = Math.max(spanX, spanY) / REGION_BITMAP;
|
|
||||||
const bw = Math.max(1, Math.ceil(spanX / cell));
|
const bw = Math.max(1, Math.ceil(spanX / cell));
|
||||||
const bh = Math.max(1, Math.ceil(spanY / cell));
|
const bh = Math.max(1, Math.ceil(spanY / cell));
|
||||||
let rc = this.regionCanvas;
|
let rc = this.regionCanvas;
|
||||||
@@ -953,12 +1075,14 @@ export class GraphCanvasRenderer {
|
|||||||
rc.width = bw;
|
rc.width = bw;
|
||||||
rc.height = bh; // also clears
|
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;
|
if (!rctx) return;
|
||||||
rctx.clearRect(0, 0, bw, bh);
|
rctx.clearRect(0, 0, bw, bh);
|
||||||
rctx.lineCap = "round";
|
rctx.lineCap = "round";
|
||||||
const R = kernel / cell;
|
rctx.lineWidth = (tuning.dilation * s * 2) / cell;
|
||||||
const edgeW = R * 0.8 * 2;
|
|
||||||
|
|
||||||
// Which cluster is a node in (for intra-cluster edges)?
|
// Which cluster is a node in (for intra-cluster edges)?
|
||||||
const clusterOf = new Map<string, number>();
|
const clusterOf = new Map<string, number>();
|
||||||
@@ -966,32 +1090,19 @@ export class GraphCanvasRenderer {
|
|||||||
for (const id of c.members) clusterOf.set(id, i);
|
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 = [];
|
this.regionMeta = [];
|
||||||
data.clusters.forEach((cluster, ci) => {
|
data.clusters.forEach((cluster, ci) => {
|
||||||
const hue = clusterHue(cluster.id);
|
const hue = clusterHue(cluster.id);
|
||||||
const members: Pt[] = [];
|
rctx.strokeStyle = `hsla(${hue}, 65%, 60%, ${tuning.edgeAlpha})`;
|
||||||
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.beginPath();
|
rctx.beginPath();
|
||||||
|
let members = 0;
|
||||||
|
let sumX = 0;
|
||||||
|
let sumY = 0;
|
||||||
for (const e of data.edges) {
|
for (const e of data.edges) {
|
||||||
if (clusterOf.get(e.from) !== ci) continue;
|
if (clusterOf.get(e.from) !== ci) continue;
|
||||||
if (clusterOf.get(e.to) !== ci) continue;
|
if (clusterOf.get(e.to) !== ci) continue;
|
||||||
@@ -1004,12 +1115,56 @@ export class GraphCanvasRenderer {
|
|||||||
rctx.stroke();
|
rctx.stroke();
|
||||||
|
|
||||||
// Label anchor: member centroid (world coords).
|
// Label anchor: member centroid (world coords).
|
||||||
if (members.length > 0) {
|
for (const id of cluster.members) {
|
||||||
const cx = members.reduce((sum, p) => sum + p.x, 0) / members.length;
|
if (!isVisible(id)) continue;
|
||||||
const cy = members.reduce((sum, p) => sum + p.y, 0) / members.length;
|
const n = this.nodeById.get(id);
|
||||||
this.regionMeta.push({ label: cluster.label || null, cx, cy, hue });
|
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 };
|
this.regionRect = { minX, minY, w: spanX, h: spanY };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,12 @@ import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, Waypoi
|
|||||||
import Graph from "graphology";
|
import Graph from "graphology";
|
||||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||||
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
|
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 {
|
import {
|
||||||
ARROW_LENGTH_RANGE,
|
ARROW_LENGTH_RANGE,
|
||||||
DEFAULT_RENDER_SETTINGS,
|
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}`;
|
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({
|
function SettingSlider({
|
||||||
label,
|
label,
|
||||||
min,
|
min,
|
||||||
@@ -195,6 +227,10 @@ export function GraphView() {
|
|||||||
// Search panel state: the query text + the settings section toggle.
|
// Search panel state: the query text + the settings section toggle.
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
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
|
// Render settings: SERVER-persisted (data/render-settings.json via
|
||||||
// /api/graph/render-settings). Loaded once after mount; every user
|
// /api/graph/render-settings). Loaded once after mount; every user
|
||||||
// change saves back, debounced. Defaults render until the load
|
// 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));
|
setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null));
|
||||||
}, [visibleIds, query]);
|
}, [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
|
// Position the detail card: below the clicked node, flipped above near
|
||||||
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
||||||
// before paint) because the flip needs the card's measured height.
|
// before paint) because the flip needs the card's measured height.
|
||||||
@@ -598,6 +640,38 @@ export function GraphView() {
|
|||||||
className="accent-primary"
|
className="accent-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import assert from "node:assert/strict";
|
|||||||
import { describe, it as test } from "node:test";
|
import { describe, it as test } from "node:test";
|
||||||
import {
|
import {
|
||||||
arrowGeometry,
|
arrowGeometry,
|
||||||
|
boxBlurRound,
|
||||||
fitTransform,
|
fitTransform,
|
||||||
labelAlpha,
|
labelAlpha,
|
||||||
pickNearest,
|
pickNearest,
|
||||||
|
sanitizeRegionTuning,
|
||||||
zoomRatioOf,
|
zoomRatioOf,
|
||||||
|
DEFAULT_REGION_TUNING,
|
||||||
LABELS_FADE_SPAN,
|
LABELS_FADE_SPAN,
|
||||||
} from "../components/graph-canvas.ts";
|
} from "../components/graph-canvas.ts";
|
||||||
|
|
||||||
@@ -137,3 +140,57 @@ describe("pickNearest (distance-check picking)", () => {
|
|||||||
assert.equal(pickNearest([], 0, 0, 100), null);
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user