Label gap, proportional glow, hover-out fade, centered coarse sliders

- LABEL_GAP 6 -> 2 (user: labels sit way too far below their nodes).
- Hover glow is now PROPORTIONAL to the disc — outer radius = node radius
  x 1.7 (GLOW_SCALE), matching the blur-a-copy-of-the-disc mental model,
  instead of the old constant +6px pad that read huge on small nodes and
  thin on big ones. GLOW_ALPHA 0.45 -> 0.3.
- Hover-OUT fade was broken by construction: draw() gated the glow on
  hoverId, which nulls instantly on leave, so the 180ms fade-out ran
  invisibly and the glow popped off in one frame. New hoverNode field
  outlives hoverId through the outgoing fade (cleared when the fade
  completes); verified live: 8 distinct frames over ~200ms then stable.
- Sliders rescaled per spec: ALL five are now log-scaled 0-100 with the
  default dead-center at 50 (ranges chosen as default/4..default*4:
  node/arrow/label size 0.5-8, edge width 0.25-4, labelZoom 0.0625-4),
  coarse step 5 (21 stops; labels-until-zoom was "way too granular" at
  201). Generic toSliderPosition/fromSliderPosition replace the
  labelZoom-only pair; sanitize clamps follow the same range constants;
  size sliders display compact values ("2", "1.6", "0.75").
EOF
This commit is contained in:
amosblomqvist
2026-09-13 07:49:00 +03:00
parent 6692b19a53
commit 11bca055dd
4 changed files with 131 additions and 88 deletions
+17 -7
View File
@@ -112,13 +112,13 @@ export interface RendererCallbacks {
const LABEL_SIZE = 12; // px — the default label size (settings.labelSize scales it)
const LABEL_WEIGHT = "500";
const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip
const LABEL_GAP = 6; // px between node edge and first label line
const LABEL_GAP = 2; // px between node edge and first label line (was 6 — user: way too far)
const LABEL_LINE_HEIGHT = 1.3;
/** Zoom span (camera-ratio units) over which labels fade together. */
export const LABELS_FADE_SPAN = 0.5;
const GLOW_PAD = 6; // px the halo extends beyond the node disc (constant)
const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward
const GLOW_SCALE = 1.7; // halo outer radius = node radius x this (proportional, like blurring a copy of the disc)
const GLOW_ALPHA = 0.3; // halo opacity at the disc edge, fading outward
const HOVER_FADE_MS = 180; // hover halo opacity fade length
const ZOOM_EASE_MS = 45; // wheel-zoom glide: exponential-ease time constant (~250ms per notch)
@@ -327,6 +327,11 @@ export class GraphCanvasRenderer {
// Hover transition: fade 0 = resting, 1 = fully hovered; the animating
// flag keeps the loop scheduled until the fade settles (then rAF stops).
private hoverId: string | null = null;
/** The node the glow is drawn on. OUTLIVES hoverId on hover-out so the
* fade has something to draw (hoverId nulls instantly; without this
* the glow popped off in one frame instead of fading). Cleared when
* the outgoing fade completes. */
private hoverNode: CanvasNode | null = null;
private hoverFade = 0;
private hoverFrom = 0;
private hoverTarget = 0;
@@ -548,6 +553,9 @@ export class GraphCanvasRenderer {
private setHover(nodeId: string | null): void {
if (nodeId === this.hoverId && this.hoverTarget === (nodeId ? 1 : 0)) return;
this.hoverId = nodeId;
// Hover-out deliberately KEEPS hoverNode: the glow must fade out
// from the last hovered node, not vanish with the id.
if (nodeId) this.hoverNode = this.nodeById.get(nodeId) ?? null;
// Rebase the animation from the CURRENT fade: rapid hover toggles
// glide instead of snapping (ported from the sigma-era behavior).
this.hoverFrom = this.hoverFade;
@@ -596,6 +604,8 @@ export class GraphCanvasRenderer {
if (this.hoverFade === this.hoverTarget) return false;
const t = Math.min((performance.now() - this.hoverStart) / HOVER_FADE_MS, 1);
this.hoverFade = this.hoverFrom + (this.hoverTarget - this.hoverFrom) * t;
// Outgoing fade finished — release the glow node.
if (this.hoverTarget === 0 && this.hoverFade === 0) this.hoverNode = null;
return t < 1;
}
@@ -655,7 +665,7 @@ export class GraphCanvasRenderer {
const nodeR = settings.nodeSize * s;
this.world = {
nodeR,
glowOuter: (settings.nodeSize + GLOW_PAD) * s,
glowOuter: nodeR * GLOW_SCALE,
labelSize: settings.labelSize * s,
labelGap: LABEL_GAP * s,
};
@@ -813,10 +823,10 @@ export class GraphCanvasRenderer {
// name, both on the SAME fade factor. World units like everything
// else. Shows even when labels are zoomed out (hover is its own
// affordance, not a label).
if (this.hoverId && this.hoverFade > 0.02) {
const n = this.visible && !this.visible.has(this.hoverId)
if (this.hoverNode && this.hoverFade > 0.02) {
const n = this.visible && !this.visible.has(this.hoverNode.id)
? null // filtered out by search — no glow on hidden nodes
: this.nodeById.get(this.hoverId);
: this.hoverNode;
if (n) {
const glow = ctx.createRadialGradient(n.x, n.y, nodeR, n.x, n.y, glowOuter);
glow.addColorStop(0, rgbaFromHex(data.colors.node, GLOW_ALPHA * this.hoverFade));
+48 -29
View File
@@ -69,11 +69,17 @@ import forceAtlas2 from "graphology-layout-forceatlas2";
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import {
ARROW_LENGTH_RANGE,
DEFAULT_RENDER_SETTINGS,
EDGE_WIDTH_RANGE,
LABEL_SIZE_RANGE,
LABEL_ZOOM_MAX,
LABEL_ZOOM_MIN,
NODE_SIZE_RANGE,
fromSliderPosition,
labelZoomDisplay,
labelZoomToSlider,
sanitizeRenderSettings,
sliderToLabelZoom,
toSliderPosition,
} from "@/src/core";
/** The uniform node look (no state classes — canvas wants hex, not oklch):
@@ -133,6 +139,11 @@ interface Selection {
}
/** One labeled range row for the rendering-settings panel. */
/** Compact size display for the log sliders: "2", "1.6", "0.75". */
function sizeDisplay(v: number): string {
return `${v >= 1 ? Math.round(v * 10) / 10 : Math.round(v * 100) / 100}`;
}
function SettingSlider({
label,
min,
@@ -498,50 +509,58 @@ export function GraphView() {
</div>
{settingsOpen && (
<div className="mt-1.5 flex flex-col gap-2 border-t pt-1.5">
{/* All sliders: log-scaled 0-100, step 5, so every
DEFAULT sits at 50 (src/core/render-settings.ts). */}
<SettingSlider
label="Node size"
min={1}
max={14}
step={0.5}
value={settings.nodeSize}
onChange={(v) => changeSettings((cur) => ({ ...cur, nodeSize: v }))}
min={0}
max={100}
step={5}
value={toSliderPosition(settings.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max)}
display={sizeDisplay(settings.nodeSize)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))}
/>
<SettingSlider
label="Edge width"
min={0.25}
max={4}
step={0.25}
value={settings.edgeWidth}
onChange={(v) => changeSettings((cur) => ({ ...cur, edgeWidth: v }))}
min={0}
max={100}
step={5}
value={toSliderPosition(settings.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max)}
display={sizeDisplay(settings.edgeWidth)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))}
/>
<SettingSlider
label="Arrowhead size"
min={1}
max={15}
step={0.5}
value={settings.arrowLength}
onChange={(v) => changeSettings((cur) => ({ ...cur, arrowLength: v }))}
min={0}
max={100}
step={5}
value={toSliderPosition(settings.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max)}
display={sizeDisplay(settings.arrowLength)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))}
/>
<SettingSlider
label="Label size"
min={1}
max={24}
step={0.5}
value={settings.labelSize}
onChange={(v) => changeSettings((cur) => ({ ...cur, labelSize: v }))}
min={0}
max={100}
step={5}
value={toSliderPosition(settings.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max)}
display={sizeDisplay(settings.labelSize)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))}
/>
{/* Log-scaled: the useful band (0.25–0.5 in camera-ratio
units) sits mid-slider; displayed units normalize the
default (0.5) to "1×". Slider position ↔ labelZoom via
src/core/render-settings.ts. */}
{/* Displayed units normalize the default (0.5) to "1×". */}
<SettingSlider
label="Labels until zoom"
min={0}
max={1000}
max={100}
step={5}
value={labelZoomToSlider(settings.labelZoom)}
value={toSliderPosition(settings.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX)}
display={labelZoomDisplay(settings.labelZoom)}
onChange={(v) => changeSettings((cur) => ({ ...cur, labelZoom: sliderToLabelZoom(v) }))}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))}
/>
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Cluster labels</span>
+26 -22
View File
@@ -1,6 +1,6 @@
/**
* Graph render settings: the shape, the defaults, the sanitizer, and the
* label-zoom slider mapping — shared by the client (components/graph-canvas.ts,
* slider mapping (log-scaled, default-centered) — shared by the client (components/graph-canvas.ts,
* graph-view.tsx), the server store (src/store/render-settings.ts, which
* persists them to data/render-settings.json), and the API route.
*
@@ -56,42 +56,46 @@ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings {
return clamp(n, min, max);
};
return {
nodeSize: num(r.nodeSize, DEFAULT_RENDER_SETTINGS.nodeSize, 1, 14),
edgeWidth: num(r.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth, 0.25, 4),
arrowLength: num(r.arrowLength, DEFAULT_RENDER_SETTINGS.arrowLength, 1, 15),
nodeSize: num(r.nodeSize, DEFAULT_RENDER_SETTINGS.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max),
edgeWidth: num(r.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max),
arrowLength: num(r.arrowLength, DEFAULT_RENDER_SETTINGS.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max),
// Range matches the log slider's span [LABEL_ZOOM_MIN, LABEL_ZOOM_MAX].
labelZoom: num(r.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom, 0.0625, 4),
labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, 1, 24),
labelZoom: num(r.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX),
labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max),
clusterLabels:
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
};
}
// ─── The "Labels until zoom" slider mapping ──────────────────────────────────
// ─── The slider mapping ────────────────────────────────────────────────────────
//
// The slider is LOG-scaled over the labelZoom range so the useful band
// (0.25–0.5) sits mid-slider instead of crammed at the far left of a linear
// 0.25–4 track. Display units are normalized to the default: labelZoom 0.5
// shows as "1×", 0.25 as "0.5×" — the default reads as 1, per spec.
// EVERY slider is log-scaled over its setting's range with the DEFAULT at
// position 50 (dead center), per the user's spec: centered defaults, coarse
// steps (the UI steps by 5 → 21 stops), no fine-grained linear tracks.
// Ranges are chosen so each default is the geometric mean of its ends.
export const NODE_SIZE_RANGE = { min: 0.5, max: 8 } as const;
export const EDGE_WIDTH_RANGE = { min: 0.25, max: 4 } as const;
export const ARROW_LENGTH_RANGE = { min: 0.5, max: 8 } as const;
export const LABEL_SIZE_RANGE = { min: 0.5, max: 8 } as const;
export const LABEL_ZOOM_MIN = 0.0625; // slider far left: labels only very deep in
export const LABEL_ZOOM_MAX = 4; // slider far right: labels visible at fit zoom
/** labelZoom → slider position [0, 1000] (log scale). 0.5 lands at 500. */
export function labelZoomToSlider(labelZoom: number): number {
const clamped = clamp(labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX);
const logSpan = Math.log(LABEL_ZOOM_MAX / LABEL_ZOOM_MIN);
return (1000 * Math.log(clamped / LABEL_ZOOM_MIN)) / logSpan;
/** Setting value → slider position [0, 100] (log scale, default at 50). */
export function toSliderPosition(value: number, min: number, max: number): number {
const v = clamp(value, min, max);
const logSpan = Math.log(max / min);
return (100 * Math.log(v / min)) / logSpan;
}
/** Slider position [0, 1000] → labelZoom (inverse of labelZoomToSlider). */
export function sliderToLabelZoom(position: number): number {
const p = clamp(position, 0, 1000) / 1000;
const logSpan = Math.log(LABEL_ZOOM_MAX / LABEL_ZOOM_MIN);
return LABEL_ZOOM_MIN * Math.exp(p * logSpan);
/** Slider position [0, 100] → setting value (inverse of toSliderPosition). */
export function fromSliderPosition(position: number, min: number, max: number): number {
const p = clamp(position, 0, 100) / 100;
const logSpan = Math.log(max / min);
return min * Math.exp(p * logSpan);
}
/** Display units: normalized so the default (0.5) reads as 1. */
/** Display units for labelZoom: normalized so the default (0.5) reads as 1. */
export function labelZoomDisplay(labelZoom: number): string {
const v = Math.round((labelZoom / DEFAULT_RENDER_SETTINGS.labelZoom) * 100) / 100;
return `${v}×`;
+40 -30
View File
@@ -8,13 +8,17 @@
import assert from "node:assert/strict";
import { describe, it as test } from "node:test";
import {
ARROW_LENGTH_RANGE,
DEFAULT_RENDER_SETTINGS,
EDGE_WIDTH_RANGE,
LABEL_SIZE_RANGE,
LABEL_ZOOM_MAX,
LABEL_ZOOM_MIN,
NODE_SIZE_RANGE,
fromSliderPosition,
labelZoomDisplay,
labelZoomToSlider,
sanitizeRenderSettings,
sliderToLabelZoom,
toSliderPosition,
} from "../src/core/render-settings.ts";
describe("sanitizeRenderSettings", () => {
@@ -26,8 +30,8 @@ describe("sanitizeRenderSettings", () => {
});
test("partial input → defaults fill the gaps", () => {
const s = sanitizeRenderSettings({ nodeSize: 9 });
assert.equal(s.nodeSize, 9);
const s = sanitizeRenderSettings({ nodeSize: 6 });
assert.equal(s.nodeSize, 6);
assert.equal(s.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth);
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize);
assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom);
@@ -43,24 +47,24 @@ describe("sanitizeRenderSettings", () => {
labelSize: "big" as unknown as number,
clusterLabels: "yes" as unknown as boolean,
});
assert.equal(s.nodeSize, 14); // clamped to max
assert.equal(s.edgeWidth, 0.25); // clamped to min
assert.equal(s.arrowLength, 1);
assert.equal(s.nodeSize, NODE_SIZE_RANGE.max); // clamped to max
assert.equal(s.edgeWidth, EDGE_WIDTH_RANGE.min); // clamped to min
assert.equal(s.arrowLength, ARROW_LENGTH_RANGE.min);
assert.equal(s.labelZoom, LABEL_ZOOM_MAX);
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels);
});
test("the widened low ends accept small sizes", () => {
const s = sanitizeRenderSettings({ nodeSize: 1, edgeWidth: 0.25, arrowLength: 1, labelSize: 3 });
test("the low ends accept small sizes (log-slider floors)", () => {
const s = sanitizeRenderSettings({ nodeSize: 0.5, edgeWidth: 0.25, arrowLength: 0.5, labelSize: 0.5 });
assert.deepEqual(
{ nodeSize: s.nodeSize, edgeWidth: s.edgeWidth, arrowLength: s.arrowLength, labelSize: s.labelSize },
{ nodeSize: 1, edgeWidth: 0.25, arrowLength: 1, labelSize: 3 },
{ nodeSize: 0.5, edgeWidth: 0.25, arrowLength: 0.5, labelSize: 0.5 },
);
});
test("a fully valid object passes through unchanged", () => {
const valid = { nodeSize: 8, edgeWidth: 2, arrowLength: 7, labelZoom: 0.25, labelSize: 16, clusterLabels: false };
const valid = { nodeSize: 4, edgeWidth: 2, arrowLength: 3, labelZoom: 0.25, labelSize: 4, clusterLabels: false };
assert.deepEqual(sanitizeRenderSettings(valid), valid);
});
@@ -72,30 +76,36 @@ describe("sanitizeRenderSettings", () => {
});
});
describe("label-zoom slider mapping (log scale)", () => {
test("the default (0.5) sits dead-center at position 500", () => {
assert.equal(Math.round(labelZoomToSlider(DEFAULT_RENDER_SETTINGS.labelZoom)), 500);
describe("slider mapping (log scale, default-centered)", () => {
const RANGES: [string, number, number, number][] = [
["nodeSize", NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max, DEFAULT_RENDER_SETTINGS.nodeSize],
["edgeWidth", EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max, DEFAULT_RENDER_SETTINGS.edgeWidth],
["arrowLength", ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max, DEFAULT_RENDER_SETTINGS.arrowLength],
["labelSize", LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max, DEFAULT_RENDER_SETTINGS.labelSize],
["labelZoom", LABEL_ZOOM_MIN, LABEL_ZOOM_MAX, DEFAULT_RENDER_SETTINGS.labelZoom],
];
test("every default sits dead-center at position 50", () => {
for (const [name, min, max, dflt] of RANGES)
assert.equal(Math.round(toSliderPosition(dflt, min, max)), 50, name);
});
test("the useful band sits mid-slider, not crammed at the left", () => {
// 0.25 → a third of the way in; the old linear 0.25–4 slider had it at 0%.
const p025 = labelZoomToSlider(0.25);
assert.ok(p025 > 250 && p025 < 400, `0.25 at ${p025}`);
assert.ok(labelZoomToSlider(0.5) > p025, "monotonic in labelZoom");
});
test("round-trips both directions across the whole range", () => {
for (const lz of [LABEL_ZOOM_MIN, 0.0625, 0.1, 0.25, 0.5, 1, 2, LABEL_ZOOM_MAX]) {
const back = sliderToLabelZoom(labelZoomToSlider(lz));
assert.ok(Math.abs(back - lz) < 1e-9, `${lz} -> ${back}`);
const fwd = labelZoomToSlider(sliderToLabelZoom(labelZoomToSlider(lz)));
assert.ok(Math.abs(fwd - labelZoomToSlider(lz)) < 1e-9);
test("round-trips both directions across the whole span", () => {
for (const [name, min, max] of RANGES) {
for (let p = 0; p <= 100; p += 5) {
const v = fromSliderPosition(p, min, max);
assert.ok(Math.abs(toSliderPosition(v, min, max) - p) < 1e-9, `${name} @ ${p}`);
}
}
});
test("slider extremes map to the range ends", () => {
assert.equal(Math.round(sliderToLabelZoom(0) * 1e6) / 1e6, LABEL_ZOOM_MIN);
assert.ok(Math.abs(sliderToLabelZoom(1000) - LABEL_ZOOM_MAX) < 1e-9);
test("slider extremes map to the range ends and clamp beyond", () => {
for (const [name, min, max] of RANGES) {
assert.equal(fromSliderPosition(0, min, max), min, name);
assert.ok(Math.abs(fromSliderPosition(100, min, max) - max) < 1e-9, name); // exp/log float noise
assert.equal(toSliderPosition(min * 0.1, min, max), 0, name); // clamps
assert.equal(toSliderPosition(max * 10, min, max), 100, name);
}
});
test("display units normalize the default to 1", () => {