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, sanitizeRegionTuning,
type PhysicsTuning, type PhysicsTuning,
type RegionTuning, type RegionTuning,
type EdgeType,
INCREMENTAL_GRAVITY, INCREMENTAL_GRAVITY,
LINK_DISTANCE, LINK_DISTANCE,
type DepthAnchorMode, type DepthAnchorMode,
@@ -88,6 +89,10 @@ export interface CanvasNode {
export interface CanvasEdge { export interface CanvasEdge {
from: string; from: string;
to: 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). */ /** 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 * live-physics frame pays one tiny blit instead of 32 clusters × 4
* offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */ * 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_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 /** 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 /** The arrowhead geometry for a directed edge a→b: shaft endpoints
* (clear of both node discs) and the head triangle (tip on the target * (clear of both node discs) and the head triangle (tip on the target
* disc edge, wideness = 0.8 × head length — the sigma arrow shape). */ * 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( export function arrowGeometry(
a: Pt, a: Pt,
b: Pt, b: Pt,
fromRadius: number, fromRadius: number,
toRadius: number, toRadius: number,
headLength: number, headLength: number,
): { shaftFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null { ): ArrowGeom | null {
const dx = b.x - a.x; const dx = b.x - a.x;
const dy = b.y - a.y; const dy = b.y - a.y;
const len = Math.hypot(dx, dy); 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 /** Nearest node within maxDist of a screen point (linear scan — µs at
* our scale; a spatial grid only earns its keep past ~10k nodes). */ * our scale; a spatial grid only earns its keep past ~10k nodes). */
export function pickNearest<T extends { id: string; x: number; y: number }>( export function pickNearest<T extends { id: string; x: number; y: number }>(
@@ -582,6 +597,13 @@ export class GraphCanvasRenderer {
setRegionTuning(input: Partial<RegionTuning>): void { setRegionTuning(input: Partial<RegionTuning>): void {
this.regionTuning = sanitizeRegionTuning(input); this.regionTuning = sanitizeRegionTuning(input);
this.regionBuiltVersion = -2; // never equals geoVersion → rebuild 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(); this.markDirty();
} }
@@ -938,14 +960,13 @@ export class GraphCanvasRenderer {
// Screen px at fit zoom → world units. // Screen px at fit zoom → world units.
const s = 1 / this.kFit; const s = 1 / this.kFit;
const nodeR = settings.nodeSize * s; const nodeR = settings.nodeSize * s;
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize * s}px ${data.fontFamily}`;
this.world = { this.world = {
nodeR, nodeR,
glowOuter: nodeR * GLOW_SCALE, glowOuter: nodeR * GLOW_SCALE,
labelSize: settings.labelSize * s, labelSize: settings.labelSize * s,
labelGap: LABEL_GAP * 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 visible = this.visible;
const isVisible = (id: string) => !visible || visible.has(id); const isVisible = (id: string) => !visible || visible.has(id);
@@ -958,7 +979,11 @@ export class GraphCanvasRenderer {
} }
this.nodesPath = nodesPath; 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 shafts = new Path2D();
const heads = new Path2D(); const heads = new Path2D();
for (const e of data.edges) { for (const e of data.edges) {
@@ -1070,6 +1095,47 @@ export class GraphCanvasRenderer {
return; 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 // Bitmap sizing: longest world span maps to `bitmap` px. The rect
// is padded by the stroke half-width (dilation) plus the blur // is padded by the stroke half-width (dilation) plus the blur
// reach so nothing clips at the bitmap edge. // reach so nothing clips at the bitmap edge.
@@ -1123,17 +1189,10 @@ export class GraphCanvasRenderer {
rctx.lineCap = "round"; rctx.lineCap = "round";
rctx.lineWidth = (dilationW * 2) / cell; 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 // The region IS the dilated skeleton: intra-cluster edges as
// round-cap strokes (Minkowski dilation). NO blanket per-node discs // round-cap strokes (Minkowski dilation). NO blanket per-node discs
// — they double-counted where nodes sit on edges (user-identified // — they double-counted where nodes sit on edges (user-identified
// hot spots, when clusters were edge-based). // hot spots, when clusters were edge-based).
this.regionMeta = [];
data.clusters.forEach((cluster, ci) => { data.clusters.forEach((cluster, ci) => {
const hue = clusterHue(cluster.id); const hue = clusterHue(cluster.id);
// Full-opacity strokes: alpha is applied ONCE at the very end // Full-opacity strokes: alpha is applied ONCE at the very end
@@ -1145,9 +1204,6 @@ export class GraphCanvasRenderer {
// contribute exactly 1.0. // contribute exactly 1.0.
rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`; rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`;
rctx.beginPath(); rctx.beginPath();
let members = 0;
let sumX = 0;
let sumY = 0;
// Endpoints actually covered by a stroke's round caps. // Endpoints actually covered by a stroke's round caps.
const touched = new Set<string>(); const touched = new Set<string>();
for (const e of data.edges) { 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.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2);
rctx.fill(); 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 // THE blur + alpha (in that order, by design — user-decided
@@ -1313,24 +1356,6 @@ export class GraphCanvasRenderer {
ctx.fillStyle = data.colors.node; ctx.fillStyle = data.colors.node;
ctx.fill(this.nodesPath); 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 // 6. Node labels — one global gate, faded together. World-scaled
// with the graph (they grow as you zoom in, Obsidian-style). // with the graph (they grow as you zoom in, Obsidian-style).
if (nodeAlpha > 0.01) { if (nodeAlpha > 0.01) {
@@ -1369,5 +1394,26 @@ export class GraphCanvasRenderer {
ctx.restore(); 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 { import {
ARROW_LENGTH_RANGE, ARROW_LENGTH_RANGE,
CHARGE_RANGE, CHARGE_RANGE,
CLUSTER_LABEL_SIZE_RANGE,
CLUSTER_PULL_RANGE, CLUSTER_PULL_RANGE,
DEPTH_BASE_RADIUS_RANGE, DEPTH_BASE_RADIUS_RANGE,
DEPTH_SPACING_RANGE, DEPTH_SPACING_RANGE,
@@ -166,6 +167,7 @@ function SettingSlider({
value, value,
display, display,
onChange, onChange,
disabled,
}: { }: {
label: string; label: string;
min: number; min: number;
@@ -175,9 +177,12 @@ function SettingSlider({
/** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */ /** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */
display?: string; display?: string;
onChange: (v: number) => void; 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 ( 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"> <span className="flex items-center justify-between text-[11px] text-muted-foreground">
{label} {label}
<span className="tabular-nums">{display ?? value}</span> <span className="tabular-nums">{display ?? value}</span>
@@ -188,6 +193,7 @@ function SettingSlider({
max={max} max={max}
step={step} step={step}
value={value} value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => onChange(Number(e.target.value))}
className="h-1.5 w-full accent-primary" className="h-1.5 w-full accent-primary"
/> />
@@ -280,26 +286,33 @@ function GraphSettingsControl({
onChange={(p) => onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))} 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 {/* Regions: the blurred cluster tints. Sliders + a fixed-set
dropdown for the bitmap resolution (it's a discrete dropdown for the bitmap resolution (it's a discrete
choice, not a free number). Server-persisted like the choice, not a free number). Server-persisted like the
sizes; every range end was sweep-validated on the real 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"> <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"> <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Regions Regions
</span> </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 <SettingSlider
label="Dilation" label="Dilation"
disabled={!regionTuning.enabled}
min={0} min={0}
max={100} max={100}
step={5} step={5}
@@ -311,9 +324,12 @@ function GraphSettingsControl({
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) },
}))} }))}
/> />
<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> <span>Blur</span>
<select <select
disabled={!regionTuning.enabled}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground" className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.blur} value={regionTuning.blur}
onChange={(e) => onChange={(e) =>
@@ -332,6 +348,7 @@ function GraphSettingsControl({
</label> </label>
<SettingSlider <SettingSlider
label="Edge alpha" label="Edge alpha"
disabled={!regionTuning.enabled}
min={0} min={0}
max={100} max={100}
step={5} step={5}
@@ -343,9 +360,12 @@ function GraphSettingsControl({
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) }, 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> <span>Bitmap resolution</span>
<select <select
disabled={!regionTuning.enabled}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground" className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.bitmap} value={regionTuning.bitmap}
onChange={(e) => onChange={(e) =>
@@ -362,6 +382,34 @@ function GraphSettingsControl({
))} ))}
</select> </select>
</label> </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> </div>
{/* Physics: the live sim's forces. Every value feeds the SAME {/* Physics: the live sim's forces. Every value feeds the SAME
engine the server settle runs (createGraphSimulation); engine the server settle runs (createGraphSimulation);
@@ -370,67 +418,13 @@ function GraphSettingsControl({
sweep-validated ranges (src/core/render-settings.ts): sweep-validated ranges (src/core/render-settings.ts):
range ends and all four corners settle cleanly — no NaN, range ends and all four corners settle cleanly — no NaN,
no explosion, no non-settle. Repulsion is displayed as 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"> <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"> <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Physics Physics
</span> </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 <SettingSlider
label="Node repulsion" label="Node repulsion"
min={0} min={0}
@@ -509,6 +503,69 @@ function GraphSettingsControl({
physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) }, 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> </div>
</div> </div>
@@ -754,7 +811,7 @@ export function GraphView() {
y: positions.get(n.id)?.y ?? 0, y: positions.get(n.id)?.y ?? 0,
label: n.name, 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 })), clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
colors: { colors: {
node: tokenHex(NODE_COLOR_TOKEN), node: tokenHex(NODE_COLOR_TOKEN),
+14 -3
View File
@@ -1,8 +1,18 @@
# Vision — The edge glyph: follows vs supports # 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`: 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. - 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. - **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; labelSize: number;
/** Toggle for the cluster region labels (hulls always draw). */ /** Toggle for the cluster region labels (hulls always draw). */
clusterLabels: boolean; 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 /** Region rendering (the blurred cluster tints). Server-persisted
* since 2026-09-13 (user request) — was session-only dev state. */ * since 2026-09-13 (user request) — was session-only dev state. */
regions: RegionTuning; 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), labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max),
clusterLabels: clusterLabels:
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.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. // Nested sections (2026-09-13): old files lack them → defaults.
regions: sanitizeRegionTuning(r.regions), regions: sanitizeRegionTuning(r.regions),
physics: sanitizePhysicsTuning(r.physics), physics: sanitizePhysicsTuning(r.physics),
@@ -100,6 +110,11 @@ export interface RegionTuning {
* of any count never stack (DECIDED 2026-09-14 — per-stroke alpha * of any count never stack (DECIDED 2026-09-14 — per-stroke alpha
* accumulated through source-over and double-counted orphans). */ * accumulated through source-over and double-counted orphans). */
edgeAlpha: number; 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; export const REGION_BITMAP_OPTIONS = [128, 192, 256, 384, 512] as const;
@@ -110,6 +125,7 @@ export const DEFAULT_REGION_TUNING: RegionTuning = {
blur: 6, blur: 6,
bitmap: 384, bitmap: 384,
edgeAlpha: 0.35, edgeAlpha: 0.35,
enabled: true,
}; };
/** Nearest member of a fixed option set (dropdowns have no free values). */ /** 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), blur: snapToOption(num(input?.blur, d.blur), REGION_BLUR_OPTIONS),
bitmap: snapToOption(bitmap, REGION_BITMAP_OPTIONS), bitmap: snapToOption(bitmap, REGION_BITMAP_OPTIONS),
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),
// 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, labelZoom: 0.5,
labelSize: 2, labelSize: 2,
clusterLabels: true, clusterLabels: true,
clusterLabelSize: 14,
regions: DEFAULT_REGION_TUNING, regions: DEFAULT_REGION_TUNING,
physics: DEFAULT_PHYSICS_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 EDGE_WIDTH_RANGE = { min: 0.25, max: 4 } as const;
export const ARROW_LENGTH_RANGE = { min: 0.5, max: 8 } 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_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_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 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)", () => { describe("pickNearest (distance-check picking)", () => {
const nodes = [ const nodes = [
{ id: "a", x: 10, y: 10 }, { id: "a", x: 10, y: 10 },
@@ -225,3 +226,4 @@ describe("sanitizePhysicsTuning (physics controls)", () => {
assert.equal(t.velocityDecay, 0.9); assert.equal(t.velocityDecay, 0.9);
}); });
}); });
+29
View File
@@ -10,6 +10,7 @@ import { describe, it as test } from "node:test";
import { import {
ARROW_LENGTH_RANGE, ARROW_LENGTH_RANGE,
CHARGE_RANGE, CHARGE_RANGE,
CLUSTER_LABEL_SIZE_RANGE,
CLUSTER_PULL_RANGE, CLUSTER_PULL_RANGE,
DEFAULT_RENDER_SETTINGS, DEFAULT_RENDER_SETTINGS,
DEPTH_BASE_RADIUS_RANGE, DEPTH_BASE_RADIUS_RANGE,
@@ -53,6 +54,7 @@ describe("sanitizeRenderSettings", () => {
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize);
assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom); assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom);
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels); 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", () => { test("out-of-range values clamp; wrong types reset", () => {
@@ -63,6 +65,7 @@ describe("sanitizeRenderSettings", () => {
labelZoom: 100, labelZoom: 100,
labelSize: "big" as unknown as number, labelSize: "big" as unknown as number,
clusterLabels: "yes" as unknown as boolean, 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.nodeSize, NODE_SIZE_RANGE.max); // clamped to max
assert.equal(s.edgeWidth, EDGE_WIDTH_RANGE.min); // clamped to min 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.labelZoom, LABEL_ZOOM_MAX);
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels); 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)", () => { test("the low ends accept small sizes (log-slider floors)", () => {
@@ -88,6 +104,7 @@ describe("sanitizeRenderSettings", () => {
labelZoom: 0.25, labelZoom: 0.25,
labelSize: 4, labelSize: 4,
clusterLabels: false, clusterLabels: false,
clusterLabelSize: 20,
regions: DEFAULT_REGION_TUNING, regions: DEFAULT_REGION_TUNING,
physics: DEFAULT_PHYSICS_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. // Stored blur 0 (the old off zone) snaps back into the set.
assert.equal(sanitizeRegionTuning({ blur: 0 }).blur, 2); 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);
});
}); });