The region bitmap rebuilds only every REGION_EVERY-th frame while the
live sim runs, so during drags the tints stepped at 1/3 the frame
rate. Between rebuilds draw() now linearly blends the previous final
bitmap into the current one ('lighter' + globalAlpha weights = a true
lerp of the premultiplied pixels — no source-over alpha stacking).
Because box blur and the end-alpha scale are linear operators, lerping
the finished bitmaps is equivalent to fading coverage before the blur,
the mathematically exact form of the user's preferred pipeline. Two
tiny GPU blits per frame; splat/blur/readback still only on rebuilds.
New RegionTuning.enabled is a compute switch (not a paint toggle):
off skips the entire build (splat, blur, readback, blit) — the settings
panel exposes it as "Background colors". Cluster labels stay decoupled
and follow clusterLabels. Old persisted files predate the field and
sanitize to enabled: true.
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.
All graph panel controls are now user-facing and server-persisted:
the old session-only dev number inputs are gone. GraphRenderSettings
gains nested regions/physics sections (backward compatible: old flat
files load with defaults), and RegionTuning/PhysicsTuning moved from
the renderer into core/render-settings.ts — one shape, one sanitizer,
one source for the store, the panel, and the renderer.
Physics/region clamps are sweep-validated safe ranges (each field at
both ends + all four corners settles cleanly on the real graph: no
NaN, no explosion — linkStrength caps at 4 since 8 explodes and 5
wobbles; friction floors at 0.1 since 0 never damps). Panel: log
sliders (default = geometric mean = position 50, step 5), off-zone
sliders for blur/cluster pull (position 0 = exactly 0), a fixed-set
dropdown for bitmap resolution, depth-anchor modes promoted to the
select.
- 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
The defaults were calibrated in the screen-fixed era; world-scaling renders
sizes at setting x k/kFit, so at the default label gate (2x fit) everything
read ~2x too big ("labels way too large even at the minimum"). Fit-zoom
normalizes layout scale away, so the fix belongs in sizes, not layout:
spreading nodes further apart in world units changes nothing at fit.
- Defaults: nodeSize 6 -> 4, arrowLength 5 -> 4, labelSize 12 -> 7.
- Clamps/slider ranges widened at the low end: nodeSize 1-14 (step 0.5),
edgeWidth 0.25-4 (step 0.25), arrowLength 1-15 (step 0.5),
labelSize 3-24 (step 0.5).
- GLOW_PAD 9 -> 6 (halo dominated small discs), REGION_LABEL_SIZE 18 -> 14.
- Tests updated for the new clamp bounds plus a low-end pass-through case
(403/403). The saved render-settings.json still shadows defaults by
design; the local copy was reset so the new defaults showed immediately.
Five refinements to the hand-rolled renderer, all user-directed:
1. Settings are SERVER-persisted (data/render-settings.json via new
GET/PUT /api/graph/render-settings) - one source of truth for every
browser, replacing localStorage. The client loads once after mount,
saves debounced on user changes, and never lets a slow GET clobber a
faster user (or mounting PUT defaults over the server copy). The
shape/defaults/sanitizer moved to src/core/render-settings.ts - pure,
shared by client, server store, and tests; every disk/network value
passes through sanitizeRenderSettings.
2. New "Label size" control (default 12px - the previous hardcoded size).
Font, wrap width, and line height all scale; verified live: +76k px
with the label gate open.
3. "Labels until zoom" slider reworked: log-scaled over 0.0625-4 so the
useful band (0.25-0.5 camera-ratio units) sits at 33%/50% of the
track instead of crammed at the far left of the old linear 0.25-4
slider. Display units are normalized: the default 0.5 reads as "1x",
0.25 as "0.5x". The underlying labelZoom semantics are unchanged,
so existing values survive.
4. Smooth wheel zoom: d3-zoom instant per-notch jumps (x1.2, bang) are
replaced by an eased glide - wheel events accumulate a cursor-anchored
target scale and each tick eases the camera toward it (exponential,
120ms constant, frame-rate independent). Pan/pinch stay d3-owned; a
pan cancels a glide in progress. Verified live: 8-frame glide.
5. Resize blanking fixed (graph vanished for the duration of a window
drag, reappearing ~300ms after). Cause: every resize event
reallocated the canvas backing store - which CLEARS it - and the
redraw was deferred to rAF, which live resizes starve via relayout,
so the canvas sat wiped for the whole drag. Fix: the ResizeObserver
callback now redraws SYNCHRONOUSLY after reallocating (verified via
a probe RO firing in the same delivery batch: canvas fully painted).
kFit (the label gate 1x reference) is also recomputed on resize from
the now-cached world bbox - the camera itself still persists.
tests/render-settings.test.ts: 10 tests pinning the sanitizer (garbage/
partial/clamps, legacy no-labelSize records) and the slider mapping
(default dead-center at 500, monotonic, exact round-trips, display
units). 402/402 green; typecheck, lint, build pass.