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
+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}×`;