Merge lh-graph refinement wave (feat/edge-glyph @ 66e6212) into main

Edge glyph (⟹ follows arrow) WITHDRAWN by owner after two live-rejected
variants — CanvasEdge.type plumbing + ArrowGeom retained, differentiation
approach still open (docs/vision-edge-glyph.md decision record).

Keepers: regions.enabled "Background colors" compute off-switch (skips
splat/blur/readback/blit), clusterLabelSize (default 14, topmost draw
layer, screen-px) with Cluster labels toggle + size slider in the Regions
section, dependency graying across the settings panels, physics panel
reorder (general forces first, Depth anchor + conditionals last).

Merged cleanly over master's coverage-chrome changes in graph-view.tsx
(their gated fragment + the ungated GraphCoverageControl coexist); data/
scratch not carried (untracked, master's real render-settings.json picked
up clusterLabelSize=14 via sanitizer fallback). Gates 488/488; live-
verified: chrome intact, Background colors graying works, physics order,
coverage panel opens (11 sessions).
This commit is contained in:
amosblomqvist
2026-09-17 09:48:41 +03:00
6 changed files with 288 additions and 120 deletions
+92 -46
View File
@@ -64,6 +64,7 @@ import {
sanitizeRegionTuning,
type PhysicsTuning,
type RegionTuning,
type EdgeType,
INCREMENTAL_GRAVITY,
LINK_DISTANCE,
type DepthAnchorMode,
@@ -88,6 +89,10 @@ export interface CanvasNode {
export interface CanvasEdge {
from: string;
to: string;
/** Relation type: `follows` renders the double-shaft glyph (⟹),
* `supports` the plain single line (DECIDED 2026-09-14,
* docs/vision-edge-glyph.md). */
type: EdgeType;
}
/** One cluster region: member node ids + the LLM-coined label (or null). */
@@ -164,7 +169,7 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel
* live-physics frame pays one tiny blit instead of 32 clusters × 4
* offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */
const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
const REGION_LABEL_SIZE = 14;
/** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA
@@ -282,13 +287,22 @@ export function fitTransform(
/** The arrowhead geometry for a directed edge a→b: shaft endpoints
* (clear of both node discs) and the head triangle (tip on the target
* disc edge, wideness = 0.8 × head length — the sigma arrow shape). */
/** One complete arrow glyph: shaft + head triangle. */
export interface ArrowGeom {
shaftFrom: Pt;
shaftTo: Pt;
tip: Pt;
left: Pt;
right: Pt;
}
export function arrowGeometry(
a: Pt,
b: Pt,
fromRadius: number,
toRadius: number,
headLength: number,
): { shaftFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null {
): ArrowGeom | null {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
@@ -307,6 +321,7 @@ export function arrowGeometry(
};
}
/** Nearest node within maxDist of a screen point (linear scan — µs at
* our scale; a spatial grid only earns its keep past ~10k nodes). */
export function pickNearest<T extends { id: string; x: number; y: number }>(
@@ -582,6 +597,13 @@ export class GraphCanvasRenderer {
setRegionTuning(input: Partial<RegionTuning>): void {
this.regionTuning = sanitizeRegionTuning(input);
this.regionBuiltVersion = -2; // never equals geoVersion → rebuild
// Off switch turning off: drop the bitmap immediately so the
// blit stops on the next frame — the rebuild itself skips all
// compute (buildRegions).
if (!this.regionTuning.enabled) {
this.regionCanvas = null;
this.regionRect = null;
}
this.markDirty();
}
@@ -938,14 +960,13 @@ export class GraphCanvasRenderer {
// Screen px at fit zoom → world units.
const s = 1 / this.kFit;
const nodeR = settings.nodeSize * s;
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize * s}px ${data.fontFamily}`;
this.world = {
nodeR,
glowOuter: nodeR * GLOW_SCALE,
labelSize: settings.labelSize * s,
labelGap: LABEL_GAP * s,
};
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize * s}px ${data.fontFamily}`;
const arrowLen = settings.arrowLength * s;
const visible = this.visible;
const isVisible = (id: string) => !visible || visible.has(id);
@@ -958,7 +979,11 @@ export class GraphCanvasRenderer {
}
this.nodesPath = nodesPath;
// Edge shafts + arrowheads: tip lands on the target disc edge.
// Edge shafts + arrowheads: tip lands on the target disc edge. Both
// relation types draw the same arrow for now — the double-shaft
// glyph was rejected (2026-09-15); differentiation is pending a new
// approach. CanvasEdge.type is plumbed for whatever comes next.
const arrowLen = settings.arrowLength * s;
const shafts = new Path2D();
const heads = new Path2D();
for (const e of data.edges) {
@@ -1070,6 +1095,47 @@ export class GraphCanvasRenderer {
return;
}
// Which cluster is a node in (for intra-cluster edges)?
const clusterOf = new Map<string, number>();
data.clusters.forEach((c, i) => {
for (const id of c.members) clusterOf.set(id, i);
});
// Label anchors ALWAYS compute (cheap — a centroid pass, no canvas):
// cluster labels follow their own `clusterLabels` setting and must
// survive the region paint being turned off.
this.regionMeta = [];
data.clusters.forEach((cluster) => {
let members = 0;
let sumX = 0;
let sumY = 0;
for (const id of cluster.members) {
if (!isVisible(id)) continue;
const n = this.nodeById.get(id);
if (!n) continue;
members++;
sumX += n.x;
sumY += n.y;
}
if (members > 0) {
this.regionMeta.push({
label: cluster.label || null,
cx: sumX / members,
cy: sumY / members,
hue: clusterHue(cluster.id),
});
}
});
// THE off switch (user 2026-09-15): compute is skipped, not just
// paint hidden — no splat, no blur, no readback, no bitmap, no
// blit. Everything below this return is the bitmap path.
if (!tuning.enabled) {
this.regionCanvas = null;
this.regionRect = null;
return;
}
// Bitmap sizing: longest world span maps to `bitmap` px. The rect
// is padded by the stroke half-width (dilation) plus the blur
// reach so nothing clips at the bitmap edge.
@@ -1123,17 +1189,10 @@ export class GraphCanvasRenderer {
rctx.lineCap = "round";
rctx.lineWidth = (dilationW * 2) / cell;
// Which cluster is a node in (for intra-cluster edges)?
const clusterOf = new Map<string, number>();
data.clusters.forEach((c, i) => {
for (const id of c.members) clusterOf.set(id, i);
});
// The region IS the dilated skeleton: intra-cluster edges as
// round-cap strokes (Minkowski dilation). NO blanket per-node discs
// — they double-counted where nodes sit on edges (user-identified
// hot spots, when clusters were edge-based).
this.regionMeta = [];
data.clusters.forEach((cluster, ci) => {
const hue = clusterHue(cluster.id);
// Full-opacity strokes: alpha is applied ONCE at the very end
@@ -1145,9 +1204,6 @@ export class GraphCanvasRenderer {
// contribute exactly 1.0.
rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`;
rctx.beginPath();
let members = 0;
let sumX = 0;
let sumY = 0;
// Endpoints actually covered by a stroke's round caps.
const touched = new Set<string>();
for (const e of data.edges) {
@@ -1185,19 +1241,6 @@ export class GraphCanvasRenderer {
rctx.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2);
rctx.fill();
}
// Label anchor: member centroid (world coords).
for (const id of cluster.members) {
if (!isVisible(id)) continue;
const n = this.nodeById.get(id);
if (!n) continue;
members++;
sumX += n.x;
sumY += n.y;
}
if (members > 0) {
this.regionMeta.push({ label: cluster.label || null, cx: sumX / members, cy: sumY / members, hue });
}
});
// THE blur + alpha (in that order, by design — user-decided
@@ -1313,24 +1356,6 @@ export class GraphCanvasRenderer {
ctx.fillStyle = data.colors.node;
ctx.fill(this.nodesPath);
// 5. Cluster region labels — inverse-gated on THE zoom control.
// Viewer-relative by design (UI, not graph content): drawn at the
// world centroid with a font of REGION_LABEL_SIZE / k, which is a
// constant 18px on screen at any zoom.
if (settings.clusterLabels && regionAlpha > 0.01) {
ctx.save();
ctx.globalAlpha = regionAlpha;
ctx.font = `${LABEL_WEIGHT} ${REGION_LABEL_SIZE / k}px ${data.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const rm of this.regionMeta) {
if (!rm.label) continue;
ctx.fillStyle = `hsla(${rm.hue}, 55%, 78%, 0.85)`;
ctx.fillText(rm.label, rm.cx, rm.cy);
}
ctx.restore();
}
// 6. Node labels — one global gate, faded together. World-scaled
// with the graph (they grow as you zoom in, Obsidian-style).
if (nodeAlpha > 0.01) {
@@ -1369,5 +1394,26 @@ export class GraphCanvasRenderer {
ctx.restore();
}
}
// 8. Cluster region labels — THE TOPMOST layer (user 2026-09-16:
// node labels and hover names drew over them before; region labels
// must never be occluded by content). Inverse-gated on THE zoom
// control (visible zoomed-out, fading as node labels come in).
// Viewer-relative by design (UI, not graph content): drawn at the
// world centroid with a font of clusterLabelSize / k — a constant
// size on screen at any zoom (user-tunable since 2026-09-16).
if (settings.clusterLabels && regionAlpha > 0.01) {
ctx.save();
ctx.globalAlpha = regionAlpha;
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize / k}px ${data.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const rm of this.regionMeta) {
if (!rm.label) continue;
ctx.fillStyle = `hsla(${rm.hue}, 55%, 78%, 0.85)`;
ctx.fillText(rm.label, rm.cx, rm.cy);
}
ctx.restore();
}
}
}
+128 -71
View File
@@ -70,6 +70,7 @@ import { GraphCoverageControl } from "./graph-coverage.tsx";
import {
ARROW_LENGTH_RANGE,
CHARGE_RANGE,
CLUSTER_LABEL_SIZE_RANGE,
CLUSTER_PULL_RANGE,
DEPTH_BASE_RADIUS_RANGE,
DEPTH_SPACING_RANGE,
@@ -166,6 +167,7 @@ function SettingSlider({
value,
display,
onChange,
disabled,
}: {
label: string;
min: number;
@@ -175,9 +177,12 @@ function SettingSlider({
/** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */
display?: string;
onChange: (v: number) => void;
/** Dependency graying (user 2026-09-17): children dim + lock when
* their parent toggle/mode is off — consistently across sections. */
disabled?: boolean;
}) {
return (
<label className="flex flex-col gap-1">
<label className={`flex flex-col gap-1${disabled ? " opacity-40" : ""}`}>
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
{label}
<span className="tabular-nums">{display ?? value}</span>
@@ -188,6 +193,7 @@ function SettingSlider({
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className="h-1.5 w-full accent-primary"
/>
@@ -280,26 +286,33 @@ function GraphSettingsControl({
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>
<input
type="checkbox"
checked={settings.clusterLabels}
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
className="accent-primary"
/>
</label>
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
dropdown for the bitmap resolution (it's a discrete
choice, not a free number). Server-persisted like the
sizes; every range end was sweep-validated on the real
graph. */}
graph. The enable switch is a COMPUTE switch: off skips
the whole build (splat/blur/readback/blit), not just
the paint. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Regions
</span>
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Background colors</span>
<input
type="checkbox"
checked={settings.regions.enabled}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, enabled: e.target.checked },
}))}
className="accent-primary"
/>
</label>
<SettingSlider
label="Dilation"
disabled={!regionTuning.enabled}
min={0}
max={100}
step={5}
@@ -311,9 +324,12 @@ function GraphSettingsControl({
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
}))}
/>
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<label
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
>
<span>Blur</span>
<select
disabled={!regionTuning.enabled}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.blur}
onChange={(e) =>
@@ -332,6 +348,7 @@ function GraphSettingsControl({
</label>
<SettingSlider
label="Edge alpha"
disabled={!regionTuning.enabled}
min={0}
max={100}
step={5}
@@ -343,9 +360,12 @@ function GraphSettingsControl({
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) },
}))}
/>
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<label
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
>
<span>Bitmap resolution</span>
<select
disabled={!regionTuning.enabled}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.bitmap}
onChange={(e) =>
@@ -362,6 +382,34 @@ function GraphSettingsControl({
))}
</select>
</label>
{/* Cluster labels moved here 2026-09-16 (user: cluster
stuff lives in the regions section). Decoupled from
the Background colors switch above — labels follow
their own clusterLabels toggle. Size is screen px
(constant at any zoom, unlike node labels). */}
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Cluster labels</span>
<input
type="checkbox"
checked={settings.clusterLabels}
onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
className="accent-primary"
/>
</label>
<SettingSlider
label="Cluster label size"
disabled={!settings.clusterLabels}
min={0}
max={100}
step={5}
value={toSliderPosition(settings.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.min, CLUSTER_LABEL_SIZE_RANGE.max)}
display={sizeDisplay(settings.clusterLabelSize)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
clusterLabelSize: fromSliderPosition(p, CLUSTER_LABEL_SIZE_RANGE.min, CLUSTER_LABEL_SIZE_RANGE.max),
}))}
/>
</div>
{/* Physics: the live sim's forces. Every value feeds the SAME
engine the server settle runs (createGraphSimulation);
@@ -370,67 +418,13 @@ function GraphSettingsControl({
sweep-validated ranges (src/core/render-settings.ts):
range ends and all four corners settle cleanly — no NaN,
no explosion, no non-settle. Repulsion is displayed as
the negative it is; cluster pull's far left = off. */}
the negative it is; cluster pull's far left = off.
Order (user 2026-09-17): general forces first; depth
anchor + its gray-out-able controls last. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Physics
</span>
<label className="flex items-center justify-between gap-2 text-xs">
Depth anchor
<select
className="rounded border bg-background px-1 py-0.5 text-xs"
value={physicsTuning.depthMode}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
}))
}
>
<option value="off">Off</option>
<option value="vertical">Vertical (up)</option>
<option value="radial">Radial (outward)</option>
</select>
</label>
<SettingSlider
label="Depth strength"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
display={sizeDisplay(physicsTuning.depthStrength)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
}))}
/>
<SettingSlider
label="Depth spacing"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
display={sizeDisplay(physicsTuning.depthSpacing)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
}))}
/>
<SettingSlider
label="Base ring radius"
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
display={sizeDisplay(physicsTuning.depthBaseRadius)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
}))}
/>
<SettingSlider
label="Node repulsion"
min={0}
@@ -509,6 +503,69 @@ function GraphSettingsControl({
physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) },
}))}
/>
{/* Depth anchor LAST (user 2026-09-17): general
forces first, then the mode + its conditional
controls at the very bottom — the grayed group reads
as a self-contained sub-section. */}
<label className="flex items-center justify-between gap-2 text-xs">
Depth anchor
<select
className="rounded border bg-background px-1 py-0.5 text-xs"
value={physicsTuning.depthMode}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
}))
}
>
<option value="off">Off</option>
<option value="vertical">Vertical (up)</option>
<option value="radial">Radial (outward)</option>
</select>
</label>
<SettingSlider
label="Depth strength"
disabled={physicsTuning.depthMode === "off"}
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)}
display={sizeDisplay(physicsTuning.depthStrength)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) },
}))}
/>
<SettingSlider
label="Depth spacing"
disabled={physicsTuning.depthMode === "off"}
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
display={sizeDisplay(physicsTuning.depthSpacing)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
}))}
/>
<SettingSlider
label="Base ring radius"
disabled={physicsTuning.depthMode !== "radial"}
min={0}
max={100}
step={5}
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
display={sizeDisplay(physicsTuning.depthBaseRadius)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
}))}
/>
</div>
</div>
</div>
@@ -754,7 +811,7 @@ export function GraphView() {
y: positions.get(n.id)?.y ?? 0,
label: n.name,
})),
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })),
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to, type: e.type })),
clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
colors: {
node: tokenHex(NODE_COLOR_TOKEN),
+14 -3
View File
@@ -1,8 +1,18 @@
# Vision — The edge glyph: follows vs supports
Ideation settled 2026-09-14. Not yet planned, not yet assigned. Companion: `vision.md` § "Edges" (the relation types) and § "Rendering".
Ideation settled 2026-09-14. **WITHDRAWN 2026-09-15**: both shipped variants of
the double-shaft glyph (tails converging on a shared head; two complete arrows
offset ±half-gap) were rejected by the owner live — the first "two arrows
feeding one point", the second "two arrows next to each other, not coherent".
Dashed was already rejected 2026-09-14 (dash patterns crawl under the constant
screen-space stroke width). **The relation still needs a differentiator —
approach open.** The constraints below that are NOT glyph-specific still stand.
Companion: `vision.md` § "Edges" (the relation types) and § "Rendering".
One line: the two relation types become visually distinct — **`follows` renders as a double-shaft arrow** (two parallel tails side by side, the implication glyph ⟹), **`supports` stays a single line**. Arrows keep their existing direction: prerequisite → dependent.
One line (withdrawn): the two relation types become visually distinct —
**`follows` renders as a double-shaft arrow** (two parallel tails side by
side, the implication glyph ⟹), **`supports` stays a single line**. Arrows
keep their existing direction: prerequisite → dependent.
---
@@ -21,7 +31,8 @@ Dash patterns break under our **constant screen-space stroke width**: the dash a
The renderer is the custom 2D canvas; edges draw in the edge layer with shafts + arrowheads from `arrowGeometry` (sigma-style head, prerequisite → dependent). For `follows`:
- Both shafts offset along the edge normal by a **fixed screen-space gap**, converging on a **shared single arrowhead**.
- Both shafts offset along the edge normal by a **fixed screen-space gap**.
- **REVISED 2026-09-15 (owner rejected the first cut):** the converged variant — two tails merging into one shared arrowhead — read as two arrows feeding a single point, not as parallel strokes. The glyph is two **complete regular arrows** (own shaft + own head each) translated ±half-gap along the normal: parallel shafts AND parallel heads, nothing converges.
- The gap is proportional to the constant stroke width — a constant visual ratio at every zoom level.
- **No LOD collapse (withdrawn 2026-09-14).** The earlier idea of collapsing to a single shaft below a stroke-width threshold assumed width shrinks with zoom — it doesn't; stroke width is constant on screen, so the double shaft renders identically at every zoom level. Simpler than proposed: nothing to gate.
+23
View File
@@ -34,6 +34,10 @@ export interface GraphRenderSettings {
labelSize: number;
/** Toggle for the cluster region labels (hulls always draw). */
clusterLabels: boolean;
/** Cluster label font size, px on screen (camera-compensated —
* constant at any zoom, unlike labelSize which scales with the
* graph). Defaults to the old REGION_LABEL_SIZE constant (14). */
clusterLabelSize: number;
/** Region rendering (the blurred cluster tints). Server-persisted
* since 2026-09-13 (user request) — was session-only dev state. */
regions: RegionTuning;
@@ -65,6 +69,12 @@ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings {
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,
clusterLabelSize: num(
r.clusterLabelSize,
DEFAULT_RENDER_SETTINGS.clusterLabelSize,
CLUSTER_LABEL_SIZE_RANGE.min,
CLUSTER_LABEL_SIZE_RANGE.max,
),
// Nested sections (2026-09-13): old files lack them → defaults.
regions: sanitizeRegionTuning(r.regions),
physics: sanitizePhysicsTuning(r.physics),
@@ -100,6 +110,11 @@ export interface RegionTuning {
* of any count never stack (DECIDED 2026-09-14 — per-stroke alpha
* accumulated through source-over and double-counted orphans). */
edgeAlpha: number;
/** THE off switch (user 2026-09-15): false = the renderer never runs
* the region build — no splat, no blur, no readback, no bitmap
* allocation, no blit. Compute skipped, not just paint hidden.
* Cluster labels are decoupled and follow `clusterLabels`. */
enabled: boolean;
}
export const REGION_BITMAP_OPTIONS = [128, 192, 256, 384, 512] as const;
@@ -110,6 +125,7 @@ export const DEFAULT_REGION_TUNING: RegionTuning = {
blur: 6,
bitmap: 384,
edgeAlpha: 0.35,
enabled: true,
};
/** Nearest member of a fixed option set (dropdowns have no free values). */
@@ -129,6 +145,9 @@ export function sanitizeRegionTuning(input: Partial<RegionTuning> | null | undef
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),
// Old persisted files predate the switch: missing → on (the look
// users tuned); only an explicit false turns regions off.
enabled: typeof input?.enabled === "boolean" ? input.enabled : true,
};
}
@@ -205,6 +224,7 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = {
labelZoom: 0.5,
labelSize: 2,
clusterLabels: true,
clusterLabelSize: 14,
regions: DEFAULT_REGION_TUNING,
physics: DEFAULT_PHYSICS_TUNING,
};
@@ -220,6 +240,9 @@ 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;
// Screen-px at ANY zoom (drawn at size / k). 14 = geometric mean — old
// REGION_LABEL_SIZE default, so the panel slider starts dead-center.
export const CLUSTER_LABEL_SIZE_RANGE = { min: 7, max: 28 } 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
+2
View File
@@ -113,6 +113,7 @@ describe("arrowGeometry", () => {
});
});
describe("pickNearest (distance-check picking)", () => {
const nodes = [
{ id: "a", x: 10, y: 10 },
@@ -225,3 +226,4 @@ describe("sanitizePhysicsTuning (physics controls)", () => {
assert.equal(t.velocityDecay, 0.9);
});
});
+29
View File
@@ -10,6 +10,7 @@ import { describe, it as test } from "node:test";
import {
ARROW_LENGTH_RANGE,
CHARGE_RANGE,
CLUSTER_LABEL_SIZE_RANGE,
CLUSTER_PULL_RANGE,
DEFAULT_RENDER_SETTINGS,
DEPTH_BASE_RADIUS_RANGE,
@@ -53,6 +54,7 @@ describe("sanitizeRenderSettings", () => {
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize);
assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom);
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels);
assert.equal(s.clusterLabelSize, DEFAULT_RENDER_SETTINGS.clusterLabelSize); // old files → 14
});
test("out-of-range values clamp; wrong types reset", () => {
@@ -63,6 +65,7 @@ describe("sanitizeRenderSettings", () => {
labelZoom: 100,
labelSize: "big" as unknown as number,
clusterLabels: "yes" as unknown as boolean,
clusterLabelSize: "huge" as unknown as number,
});
assert.equal(s.nodeSize, NODE_SIZE_RANGE.max); // clamped to max
assert.equal(s.edgeWidth, EDGE_WIDTH_RANGE.min); // clamped to min
@@ -70,6 +73,19 @@ describe("sanitizeRenderSettings", () => {
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);
assert.equal(s.clusterLabelSize, DEFAULT_RENDER_SETTINGS.clusterLabelSize); // NaN-safe reset
});
test("clusterLabelSize clamps to its own range (slider law)", () => {
// Default 14 is the geometric mean of [7, 28] → dead-center slider.
assert.equal(
Math.sqrt(CLUSTER_LABEL_SIZE_RANGE.min * CLUSTER_LABEL_SIZE_RANGE.max),
DEFAULT_RENDER_SETTINGS.clusterLabelSize,
);
const s = sanitizeRenderSettings({ clusterLabelSize: 999 });
assert.equal(s.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.max);
const s2 = sanitizeRenderSettings({ clusterLabelSize: 1 });
assert.equal(s2.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.min);
});
test("the low ends accept small sizes (log-slider floors)", () => {
@@ -88,6 +104,7 @@ describe("sanitizeRenderSettings", () => {
labelZoom: 0.25,
labelSize: 4,
clusterLabels: false,
clusterLabelSize: 20,
regions: DEFAULT_REGION_TUNING,
physics: DEFAULT_PHYSICS_TUNING,
};
@@ -214,4 +231,16 @@ describe("slider mapping (log scale, default-centered)", () => {
// Stored blur 0 (the old off zone) snaps back into the set.
assert.equal(sanitizeRegionTuning({ blur: 0 }).blur, 2);
});
test("enabled is the compute off switch: only an explicit false turns it off", () => {
// Default on; old persisted files predate the field (missing → on);
// garbage falls back to on (fail toward the tuned look).
assert.equal(DEFAULT_REGION_TUNING.enabled, true);
assert.equal(sanitizeRegionTuning({}).enabled, true);
assert.equal(sanitizeRegionTuning({ enabled: false }).enabled, false);
assert.equal(sanitizeRegionTuning({ enabled: true }).enabled, true);
assert.equal(sanitizeRegionTuning({ enabled: "no" as unknown as boolean }).enabled, true);
// An explicit false survives even alongside garbage in other fields.
assert.equal(sanitizeRegionTuning({ enabled: false, blur: 99, bitmap: 99999 }).enabled, false);
});
});