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.
This commit is contained in:
+20
-14
@@ -83,7 +83,7 @@ import {
|
|||||||
LINK_STRENGTH_RANGE,
|
LINK_STRENGTH_RANGE,
|
||||||
NODE_SIZE_RANGE,
|
NODE_SIZE_RANGE,
|
||||||
REGION_BITMAP_OPTIONS,
|
REGION_BITMAP_OPTIONS,
|
||||||
REGION_BLUR_RANGE,
|
REGION_BLUR_OPTIONS,
|
||||||
REGION_DILATION_RANGE,
|
REGION_DILATION_RANGE,
|
||||||
REGION_EDGE_ALPHA_RANGE,
|
REGION_EDGE_ALPHA_RANGE,
|
||||||
VELOCITY_DECAY_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) },
|
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<SettingSlider
|
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||||
label="Blur"
|
<span>Blur</span>
|
||||||
min={0}
|
<select
|
||||||
max={100}
|
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||||
step={5}
|
value={regionTuning.blur}
|
||||||
value={toOffSliderPosition(regionTuning.blur, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max)}
|
onChange={(e) =>
|
||||||
display={regionTuning.blur <= 0 ? "off" : sizeDisplay(regionTuning.blur)}
|
changeSettings((cur) => ({
|
||||||
onChange={(p) =>
|
...cur,
|
||||||
changeSettings((cur) => ({
|
regions: { ...cur.regions, blur: Number(e.target.value) },
|
||||||
...cur,
|
}))
|
||||||
regions: { ...cur.regions, blur: fromOffSliderPosition(p, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) },
|
}
|
||||||
}))}
|
>
|
||||||
/>
|
{REGION_BLUR_OPTIONS.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<SettingSlider
|
<SettingSlider
|
||||||
label="Edge alpha"
|
label="Edge alpha"
|
||||||
min={0}
|
min={0}
|
||||||
|
|||||||
+17
-12
@@ -88,7 +88,9 @@ export interface RegionTuning {
|
|||||||
/** Dilation half-width of the round-cap edge strokes, screen px at
|
/** Dilation half-width of the round-cap edge strokes, screen px at
|
||||||
* fit zoom (the expansion of the cluster skeleton). */
|
* fit zoom (the expansion of the cluster skeleton). */
|
||||||
dilation: number;
|
dilation: number;
|
||||||
/** Box-blur radius in bitmap px (3 passes ≈ Gaussian). 0 = off. */
|
/** Box-blur radius in bitmap px (3 passes ≈ Gaussian). Fixed set:
|
||||||
|
* see REGION_BLUR_OPTIONS (dropdown in the panel — non-integer
|
||||||
|
* radii broke the rendering, so the control is integer-only). */
|
||||||
blur: number;
|
blur: number;
|
||||||
/** Max bitmap dimension (cell = worldSpan / this). Fixed set:
|
/** Max bitmap dimension (cell = worldSpan / this). Fixed set:
|
||||||
* see REGION_BITMAP_OPTIONS (dropdown in the panel). */
|
* see REGION_BITMAP_OPTIONS (dropdown in the panel). */
|
||||||
@@ -98,16 +100,22 @@ export interface RegionTuning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const REGION_BITMAP_OPTIONS = [128, 192, 256, 384, 512] as const;
|
export const REGION_BITMAP_OPTIONS = [128, 192, 256, 384, 512] as const;
|
||||||
|
export const REGION_BLUR_OPTIONS = [2, 4, 6, 8, 10] as const;
|
||||||
|
|
||||||
export const DEFAULT_REGION_TUNING: RegionTuning = {
|
export const DEFAULT_REGION_TUNING: RegionTuning = {
|
||||||
dilation: 28,
|
dilation: 28,
|
||||||
blur: 10,
|
blur: 6,
|
||||||
bitmap: 384,
|
bitmap: 384,
|
||||||
edgeAlpha: 0.35,
|
edgeAlpha: 0.35,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Clamp/validate a region tuning. `bitmap` snaps to the nearest allowed
|
/** Nearest member of a fixed option set (dropdowns have no free values). */
|
||||||
* option (it's a fixed set — a dropdown, not a free number). */
|
function snapToOption(value: number, options: readonly number[]): number {
|
||||||
|
return options.reduce((best, o) => (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<RegionTuning> | null | undefined): RegionTuning {
|
export function sanitizeRegionTuning(input: Partial<RegionTuning> | null | undefined): RegionTuning {
|
||||||
const d = DEFAULT_REGION_TUNING;
|
const d = DEFAULT_REGION_TUNING;
|
||||||
const num = (v: unknown, fallback: number): number =>
|
const num = (v: unknown, fallback: number): number =>
|
||||||
@@ -115,11 +123,8 @@ export function sanitizeRegionTuning(input: Partial<RegionTuning> | null | undef
|
|||||||
const bitmap = num(input?.bitmap, d.bitmap);
|
const bitmap = num(input?.bitmap, d.bitmap);
|
||||||
return {
|
return {
|
||||||
dilation: Math.min(Math.max(num(input?.dilation, d.dilation), REGION_DILATION_RANGE.min), REGION_DILATION_RANGE.max),
|
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),
|
blur: snapToOption(num(input?.blur, d.blur), REGION_BLUR_OPTIONS),
|
||||||
bitmap: REGION_BITMAP_OPTIONS.reduce(
|
bitmap: snapToOption(bitmap, REGION_BITMAP_OPTIONS),
|
||||||
(best, o) => (Math.abs(o - bitmap) < Math.abs(best - bitmap) ? o : best),
|
|
||||||
REGION_BITMAP_OPTIONS[0] as number,
|
|
||||||
),
|
|
||||||
edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), REGION_EDGE_ALPHA_RANGE.min), 1),
|
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
|
// the geometric mean of its ends), coarse 5-step tracks. Signed/zeroed
|
||||||
// fields get special treatment:
|
// fields get special treatment:
|
||||||
// chargeStrength is NEGATIVE — the slider maps |charge| and displays "-x";
|
// chargeStrength is NEGATIVE — the slider maps |charge| and displays "-x";
|
||||||
// clusterPull and region blur have an OFF zone — position 0 is exactly 0
|
// clusterPull has an OFF zone — position 0 is exactly 0 (see
|
||||||
// (see toOffSliderPosition), the log range starts one step in.
|
// 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_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 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
|
export const CHARGE_RANGE = { min: -480, max: -30 } as const; // |gm| = 120
|
||||||
|
|||||||
@@ -150,15 +150,15 @@ describe("sanitizeRegionTuning (dev number inputs)", () => {
|
|||||||
test("defaults survive untouched; partial input keeps the rest", () => {
|
test("defaults survive untouched; partial input keeps the rest", () => {
|
||||||
const t = sanitizeRegionTuning(undefined);
|
const t = sanitizeRegionTuning(undefined);
|
||||||
assert.deepEqual(t, DEFAULT_REGION_TUNING);
|
assert.deepEqual(t, DEFAULT_REGION_TUNING);
|
||||||
const half = sanitizeRegionTuning({ blur: 0 });
|
const half = sanitizeRegionTuning({ blur: 7.2 });
|
||||||
assert.equal(half.blur, 0);
|
assert.equal(half.blur, 8); // snaps into the fixed set
|
||||||
assert.equal(half.dilation, DEFAULT_REGION_TUNING.dilation);
|
assert.equal(half.dilation, DEFAULT_REGION_TUNING.dilation);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("garbage clamps: NaN falls back, out-of-range clamps", () => {
|
test("garbage clamps: NaN falls back, out-of-range clamps", () => {
|
||||||
const t = sanitizeRegionTuning({ dilation: Number.NaN, blur: -5, bitmap: 99999, edgeAlpha: 42 });
|
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.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.bitmap, REGION_BITMAP_OPTIONS[REGION_BITMAP_OPTIONS.length - 1]); // 99999 snaps to the largest option
|
||||||
assert.equal(t.edgeAlpha, 1);
|
assert.equal(t.edgeAlpha, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
LINK_DISTANCE_RANGE,
|
LINK_DISTANCE_RANGE,
|
||||||
LINK_STRENGTH_RANGE,
|
LINK_STRENGTH_RANGE,
|
||||||
NODE_SIZE_RANGE,
|
NODE_SIZE_RANGE,
|
||||||
REGION_BLUR_RANGE,
|
REGION_BLUR_OPTIONS,
|
||||||
|
sanitizeRegionTuning,
|
||||||
REGION_DILATION_RANGE,
|
REGION_DILATION_RANGE,
|
||||||
REGION_EDGE_ALPHA_RANGE,
|
REGION_EDGE_ALPHA_RANGE,
|
||||||
VELOCITY_DECAY_RANGE,
|
VELOCITY_DECAY_RANGE,
|
||||||
@@ -109,7 +110,7 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
physics: { linkStrength: 99, gravity: 7, depthMode: "sideways" },
|
physics: { linkStrength: 99, gravity: 7, depthMode: "sideways" },
|
||||||
});
|
});
|
||||||
assert.equal(s.nodeSize, 3);
|
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.bitmap, 512); // snaps to the nearest fixed option
|
||||||
assert.equal(s.regions.edgeAlpha, 1);
|
assert.equal(s.regions.edgeAlpha, 1);
|
||||||
assert.equal(s.regions.dilation, DEFAULT_REGION_TUNING.dilation);
|
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.
|
// 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_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", () => {
|
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(toOffSliderPosition(0, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), 0);
|
||||||
assert.equal(fromOffSliderPosition(0, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max), 0);
|
assert.equal(fromOffSliderPosition(0, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), 0);
|
||||||
const v = fromOffSliderPosition(toOffSliderPosition(10, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max), REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max);
|
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 - 10) < 1e-9);
|
assert.ok(Math.abs(v - 0.15) < 1e-9);
|
||||||
// One step in (position 5) sits just above the log floor:
|
// One step in (position 5) sits just above the log floor:
|
||||||
// min * (max/min)^(5/100), NOT the floor itself (that's position 0,
|
// min * (max/min)^(5/100), NOT the floor itself (that's position 0,
|
||||||
// the off zone).
|
// the off zone).
|
||||||
const stepIn = REGION_BLUR_RANGE.min * (REGION_BLUR_RANGE.max / REGION_BLUR_RANGE.min) ** (5 / 100);
|
const stepIn = CLUSTER_PULL_RANGE.min * (CLUSTER_PULL_RANGE.max / CLUSTER_PULL_RANGE.min) ** (5 / 100);
|
||||||
assert.ok(Math.abs(fromOffSliderPosition(5, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) - stepIn) < 1e-9);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user