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:
+92
-46
@@ -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
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user