From b42fdbec1e597dac37501305586aa75b0c879eb4 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 03:03:04 +0300 Subject: [PATCH] Region blur becomes a fixed-set dropdown (2/4/6/8/10, default 6) Non-integer blur radii broke the rendering (the log slider mapped to fractional values), so the control is now integer-only by design: REGION_BLUR_OPTIONS with snap-to-nearest sanitization for any input, same law as the bitmap resolution. The old off zone (blur 0) is gone; stored 0s snap back into the set. --- components/graph-view.tsx | 34 ++++++++++++++++++++-------------- src/core/render-settings.ts | 29 +++++++++++++++++------------ tests/graph-canvas.test.ts | 6 +++--- tests/render-settings.test.ts | 30 +++++++++++++++++++++--------- 4 files changed, 61 insertions(+), 38 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 60c3ff3..ecc3f38 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -83,7 +83,7 @@ import { LINK_STRENGTH_RANGE, NODE_SIZE_RANGE, REGION_BITMAP_OPTIONS, - REGION_BLUR_RANGE, + REGION_BLUR_OPTIONS, REGION_DILATION_RANGE, REGION_EDGE_ALPHA_RANGE, VELOCITY_DECAY_RANGE, @@ -625,19 +625,25 @@ export function GraphView() { regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) }, }))} /> - - changeSettings((cur) => ({ - ...cur, - regions: { ...cur.regions, blur: fromOffSliderPosition(p, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) }, - }))} - /> + (Math.abs(o - value) < Math.abs(best - value) ? o : best), options[0] as number); +} + +/** Clamp/validate a region tuning. `bitmap` and `blur` snap to the nearest + * allowed option (they're fixed sets — dropdowns, not free numbers). */ export function sanitizeRegionTuning(input: Partial | null | undefined): RegionTuning { const d = DEFAULT_REGION_TUNING; const num = (v: unknown, fallback: number): number => @@ -115,11 +123,8 @@ export function sanitizeRegionTuning(input: Partial | null | undef const bitmap = num(input?.bitmap, d.bitmap); return { dilation: Math.min(Math.max(num(input?.dilation, d.dilation), REGION_DILATION_RANGE.min), REGION_DILATION_RANGE.max), - blur: Math.min(Math.max(num(input?.blur, d.blur), 0), REGION_BLUR_RANGE.max), - bitmap: REGION_BITMAP_OPTIONS.reduce( - (best, o) => (Math.abs(o - bitmap) < Math.abs(best - bitmap) ? o : best), - REGION_BITMAP_OPTIONS[0] as number, - ), + blur: snapToOption(num(input?.blur, d.blur), REGION_BLUR_OPTIONS), + bitmap: snapToOption(bitmap, REGION_BITMAP_OPTIONS), edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), REGION_EDGE_ALPHA_RANGE.min), 1), }; } @@ -221,11 +226,11 @@ export const LABEL_ZOOM_MAX = 4; // slider far right: labels visible at fit zoom // the geometric mean of its ends), coarse 5-step tracks. Signed/zeroed // fields get special treatment: // chargeStrength is NEGATIVE — the slider maps |charge| and displays "-x"; -// clusterPull and region blur have an OFF zone — position 0 is exactly 0 -// (see toOffSliderPosition), the log range starts one step in. +// clusterPull has an OFF zone — position 0 is exactly 0 (see +// toOffSliderPosition), the log range starts one step in. Blur used to +// have one too; it's a dropdown since 2026-09-13 (non-integers broke). export const REGION_DILATION_RANGE = { min: 7, max: 112 } as const; // gm = 28 -export const REGION_BLUR_RANGE = { min: 2.5, max: 40 } as const; // gm = 10; 0 = off export const REGION_EDGE_ALPHA_RANGE = { min: 0.12, max: 1 } as const; // gm ≈ 0.35 export const CHARGE_RANGE = { min: -480, max: -30 } as const; // |gm| = 120 diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index d472e2b..b51ea74 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -150,15 +150,15 @@ 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); + const half = sanitizeRegionTuning({ blur: 7.2 }); + assert.equal(half.blur, 8); // snaps into the fixed set 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.blur, 2); // snaps into the fixed set assert.equal(t.bitmap, REGION_BITMAP_OPTIONS[REGION_BITMAP_OPTIONS.length - 1]); // 99999 snaps to the largest option assert.equal(t.edgeAlpha, 1); }); diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts index 38e45f1..4d613bc 100644 --- a/tests/render-settings.test.ts +++ b/tests/render-settings.test.ts @@ -23,7 +23,8 @@ import { LINK_DISTANCE_RANGE, LINK_STRENGTH_RANGE, NODE_SIZE_RANGE, - REGION_BLUR_RANGE, + REGION_BLUR_OPTIONS, + sanitizeRegionTuning, REGION_DILATION_RANGE, REGION_EDGE_ALPHA_RANGE, VELOCITY_DECAY_RANGE, @@ -109,7 +110,7 @@ describe("sanitizeRenderSettings", () => { physics: { linkStrength: 99, gravity: 7, depthMode: "sideways" }, }); assert.equal(s.nodeSize, 3); - assert.equal(s.regions.blur, 0); + assert.equal(s.regions.blur, 2); // snaps into the fixed set {2,4,6,8,10} assert.equal(s.regions.bitmap, 512); // snaps to the nearest fixed option assert.equal(s.regions.edgeAlpha, 1); assert.equal(s.regions.dilation, DEFAULT_REGION_TUNING.dilation); @@ -188,18 +189,29 @@ describe("slider mapping (log scale, default-centered)", () => { } // Off-zone fields: the default sits at 50 INSIDE the on-part. assert.ok(Math.abs(toOffSliderPosition(DEFAULT_PHYSICS_TUNING.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) - 50) < 0.6); - assert.ok(Math.abs(toOffSliderPosition(DEFAULT_REGION_TUNING.blur, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) - 50) < 0.6); }); test("off-slider mapping: position 0 ↔ value 0; the log part round-trips", () => { - assert.equal(toOffSliderPosition(0, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max), 0); - assert.equal(fromOffSliderPosition(0, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max), 0); - const v = fromOffSliderPosition(toOffSliderPosition(10, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max), REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max); - assert.ok(Math.abs(v - 10) < 1e-9); + assert.equal(toOffSliderPosition(0, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), 0); + assert.equal(fromOffSliderPosition(0, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), 0); + const v = fromOffSliderPosition(toOffSliderPosition(0.15, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max); + assert.ok(Math.abs(v - 0.15) < 1e-9); // One step in (position 5) sits just above the log floor: // min * (max/min)^(5/100), NOT the floor itself (that's position 0, // the off zone). - const stepIn = REGION_BLUR_RANGE.min * (REGION_BLUR_RANGE.max / REGION_BLUR_RANGE.min) ** (5 / 100); - assert.ok(Math.abs(fromOffSliderPosition(5, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) - stepIn) < 1e-9); + const stepIn = CLUSTER_PULL_RANGE.min * (CLUSTER_PULL_RANGE.max / CLUSTER_PULL_RANGE.min) ** (5 / 100); + assert.ok(Math.abs(fromOffSliderPosition(5, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) - stepIn) < 1e-9); + }); + + test("blur is a fixed set: the default is 6, non-members snap to the nearest", () => { + // Non-integer radii broke the rendering, so the control became a + // dropdown — the sanitizer enforces the set for ANY input. + assert.equal(DEFAULT_REGION_TUNING.blur, 6); + assert.equal(sanitizeRegionTuning({ blur: 3.7 }).blur, 4); + assert.equal(sanitizeRegionTuning({ blur: 7.2 }).blur, 8); + assert.equal(sanitizeRegionTuning({ blur: -5 }).blur, 2); + assert.equal(sanitizeRegionTuning({ blur: 99 }).blur, 10); + // Stored blur 0 (the old off zone) snaps back into the set. + assert.equal(sanitizeRegionTuning({ blur: 0 }).blur, 2); }); });