From e9348b7baf9ef031023ea68ac18a03727e1447f2 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 15 Sep 2026 08:08:30 +0300 Subject: [PATCH 1/9] =?UTF-8?q?follows=20renders=20as=20the=20double-shaft?= =?UTF-8?q?=20glyph=20(=E2=9F=B9),=20supports=20stays=20single?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/vision-edge-glyph.md: a `follows` edge (the derivation backbone) draws two parallel shaft tails offset along the edge normal by a fixed SCREEN-space gap (FOLLOWS_GAP_RATIO x edgeWidth — constant on screen at every zoom, like the stroke width), converging on the shared single arrowhead; `supports` keeps the plain single line (dashed was rejected earlier — not revisited). - CanvasEdge carries the edge type; graph-view passes it from the snapshot. - followsEdgeGeometry (pure, tested): offset tails + convergence at the head base — a strictly-parallel pair would poke out beside the head whenever the screen-fixed gap exceeds the head's world-scaled base half-width. The offset tails sit farther from the source center than the axis start, so the source disc never clips them. - Edge paths leave buildGeometry: their gap is anchored to the CURRENT camera scale, so buildEdgePaths(k) rebuilds on geoVersion changes and camera-scale drift (> EDGE_PATHS_ZOOM_EPSILON) — 0.08ms per rebuild at 211 edges; pans and settle frames reuse the cached paths. - Legend entry bottom-right: ⟹ follows · → supports. Live-verified: isolated follows edge (search "likelihood") shows two 1px shafts with a ~3px centerline gap converging into one 8px-base shared head; gap measured constant on screen at 0.2x, 1x, and 5x fit zoom while heads/nodes scale. Gates: tsc, 435/435 tests, lint, build. --- components/graph-canvas.ts | 154 +++++++++++++++++++++++++++++++------ components/graph-view.tsx | 15 +++- tests/graph-canvas.test.ts | 52 +++++++++++++ 3 files changed, 198 insertions(+), 23 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 91950d8..f3d648c 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -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). */ @@ -166,6 +171,16 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest const REGION_LABEL_SIZE = 14; +/** Follows glyph: screen-space distance between the two shaft CENTERLINES, + * in units of the edge stroke width (constant on screen — so the gap is + * too, at every zoom; DECIDED 2026-09-14, docs/vision-edge-glyph.md). + * 3× a 1px stroke = 3px centers, 2px of clear space between the shafts. */ +const FOLLOWS_GAP_RATIO = 3; +/** Rebuild the (zoom-anchored) edge paths when the camera scale drifts + * more than this far from the scale they were built for — a handful of + * cheap rebuilds per zoom gesture, zero per pan/settle frame. */ +const EDGE_PATHS_ZOOM_EPSILON = 0.02; + /** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA * (Float32, stride 4, length w*h*4). Premultiplying first is what keeps @@ -307,6 +322,46 @@ export function arrowGeometry( }; } +/** The double-shaft glyph for a `follows` edge (⟹ — DECIDED + * 2026-09-14, docs/vision-edge-glyph.md): two shafts offset along the + * edge normal by `gap` (a FIXED screen-space distance, world-converted + * by the caller), converging on the shared single arrowhead — both + * shafts meet at the head's base point. `supports` needs no helper + * (the plain single shaft IS arrowGeometry). Pure: DOM-free, tested. */ +export function followsEdgeGeometry( + a: Pt, + b: Pt, + fromRadius: number, + toRadius: number, + headLength: number, + gap: number, +): { shaftAFrom: Pt; shaftBFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null { + const g = arrowGeometry(a, b, fromRadius, toRadius, headLength); + if (!g) return null; // degenerate (coincident) — skip + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + const nx = -dy / len; + const ny = dx / len; + const half = gap / 2; + return { + // Parallel tails at the source, ± half-gap along the normal. The + // offset starts sit FARTHER from the source center than the axis + // start (√(r²+half²) > r) — never clipped by the source disc. + shaftAFrom: { x: g.shaftFrom.x + nx * half, y: g.shaftFrom.y + ny * half }, + shaftBFrom: { x: g.shaftFrom.x - nx * half, y: g.shaftFrom.y - ny * half }, + // Convergence: both shafts end at the head's base point — the + // merge lands exactly where the shared arrowhead begins, clean at + // every zoom (a strictly-parallel pair would poke out beside the + // head whenever the screen-fixed gap exceeds the head's base + // half-width — the head scales with the graph, the gap doesn't). + shaftTo: g.shaftTo, + tip: g.tip, + left: g.left, + right: g.right, + }; +} + /** 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( @@ -370,6 +425,7 @@ interface WorldSizes { glowOuter: number; // hover halo outer radius, world units labelSize: number; // node label font, world units labelGap: number; // node edge → first label line, world units + arrowLen: number; // arrowhead length, world units (shared by both glyphs) } /** The pre-wrapped node name, centered below the node in WORLD units — @@ -466,6 +522,10 @@ export class GraphCanvasRenderer { // changes (geoVersion). Camera moves just re-rasterize the same paths. private geoVersion = 0; // bump = geometry inputs changed private builtVersion = -1; // version the cache was built at + /** Edge paths: built for this geoVersion AND this camera scale (the + * follows gap is screen-anchored). -1/0 force the first build. */ + private edgeBuiltVersion = -1; + private edgeBuiltK = 0; private nodesPath: Path2D | null = null; private shaftsPath: Path2D = new Path2D(); private headsPath: Path2D = new Path2D(); @@ -482,7 +542,7 @@ export class GraphCanvasRenderer { private frameCount = 0; // draw counter for the live-sim splat throttle private labelLines = new Map(); private worldLabelFont = ""; - private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0 }; + private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0, arrowLen: 0 }; constructor(canvas: HTMLCanvasElement, callbacks: RendererCallbacks) { this.canvas = canvas; @@ -938,14 +998,14 @@ 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, + arrowLen: settings.arrowLength * 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,25 +1018,10 @@ export class GraphCanvasRenderer { } this.nodesPath = nodesPath; - // Edge shafts + arrowheads: tip lands on the target disc edge. - const shafts = new Path2D(); - const heads = new Path2D(); - for (const e of data.edges) { - if (!isVisible(e.from) || !isVisible(e.to)) continue; - const a = this.nodeById.get(e.from); - const b = this.nodeById.get(e.to); - if (!a || !b) continue; - const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen); - if (!g) continue; - shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y); - shafts.lineTo(g.shaftTo.x, g.shaftTo.y); - heads.moveTo(g.tip.x, g.tip.y); - heads.lineTo(g.left.x, g.left.y); - heads.lineTo(g.right.x, g.right.y); - heads.closePath(); - } - this.shaftsPath = shafts; - this.headsPath = heads; + // Edge shafts + heads are NOT built here: the follows glyph's gap + // is anchored to the CURRENT camera scale (constant on screen), so + // buildEdgePaths(k) runs from draw() on its own schedule — geometry + // changes AND camera-scale drift (EDGE_PATHS_ZOOM_EPSILON). // Cluster regions are NOT built here: they rasterize into their own // low-res bitmap (see buildRegions), on their own rebuild schedule @@ -996,6 +1041,60 @@ export class GraphCanvasRenderer { ctx.font = prevFont; } + /** Edge shafts + arrowheads (the relation glyphs). Own build schedule, + * separate from buildGeometry: the follows glyph's shaft gap is a + * FIXED screen-space distance (FOLLOWS_GAP_RATIO × stroke width — + * constant on screen at every zoom, docs/vision-edge-glyph.md), so + * in world units it scales with 1/k and must be rebuilt as the camera + * zoom drifts (EDGE_PATHS_ZOOM_EPSILON gates that — pans and settle + * frames reuse the paths). Cheap either way: ~hundreds of edges × + * trivial trig, no rasterization. */ + private buildEdgePaths(k: number): void { + const data = this.data; + const settings = this.settings; + if (!data || !settings) return; + const visible = this.visible; + const isVisible = (id: string) => !visible || visible.has(id); + const { nodeR, arrowLen } = this.world; + // The follows gap in WORLD units: constant on screen (· k stays + // FOLLOWS_GAP_RATIO × edgeWidth px at any zoom). Supports ignores it. + const gapW = (FOLLOWS_GAP_RATIO * settings.edgeWidth) / k; + const shafts = new Path2D(); + const heads = new Path2D(); + for (const e of data.edges) { + if (!isVisible(e.from) || !isVisible(e.to)) continue; + const a = this.nodeById.get(e.from); + const b = this.nodeById.get(e.to); + if (!a || !b) continue; + if (e.type === "follows") { + // ⟹: two parallel tails, one shared head. + const g = followsEdgeGeometry(a, b, nodeR, nodeR, arrowLen, gapW); + if (!g) continue; + shafts.moveTo(g.shaftAFrom.x, g.shaftAFrom.y); + shafts.lineTo(g.shaftTo.x, g.shaftTo.y); + shafts.moveTo(g.shaftBFrom.x, g.shaftBFrom.y); + shafts.lineTo(g.shaftTo.x, g.shaftTo.y); + heads.moveTo(g.tip.x, g.tip.y); + heads.lineTo(g.left.x, g.left.y); + heads.lineTo(g.right.x, g.right.y); + heads.closePath(); + } else { + // supports: the plain single shaft (the majority relation + // stays light — dashed was rejected 2026-09-14). + const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen); + if (!g) continue; + shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y); + shafts.lineTo(g.shaftTo.x, g.shaftTo.y); + heads.moveTo(g.tip.x, g.tip.y); + heads.lineTo(g.left.x, g.left.y); + heads.lineTo(g.right.x, g.right.y); + heads.closePath(); + } + } + this.shaftsPath = shafts; + this.headsPath = heads; + } + /** (Re)create the live sim from data.nodes at their current * positions + the current physics tuning. Called by setData and * setPhysicsTuning. */ @@ -1280,6 +1379,17 @@ export class GraphCanvasRenderer { if (!this.nodesPath) return; const { x: tx, y: ty, k } = this.transform; + // Edge paths: rebuild on geometry changes OR camera-scale drift + // (the follows glyph's gap is constant on screen, so its world size + // tracks 1/k). Pans and settle frames reuse the cached paths. + if ( + this.edgeBuiltVersion !== this.geoVersion || + Math.abs(k - this.edgeBuiltK) > this.edgeBuiltK * EDGE_PATHS_ZOOM_EPSILON + ) { + this.buildEdgePaths(k); + this.edgeBuiltVersion = this.geoVersion; + this.edgeBuiltK = k; + } // THE camera: one transform, then everything is world coordinates. ctx.setTransform(dpr * k, 0, 0, dpr * k, dpr * tx, dpr * ty); diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 21dc619..76f96a2 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -753,7 +753,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), @@ -896,6 +896,19 @@ export function GraphView() { )} + {/* Edge glyph legend (bottom-right; the pipeline status pills own + the bottom-left). ⟹ follows (the derivation backbone) · → + supports — DECIDED 2026-09-14, docs/vision-edge-glyph.md. */} + {!isEmpty && ( +
+ ⟹ + follows + · + → + supports +
+ )} + {/* Background pipeline status: what is currently running. */} {pipelineRuns.length > 0 && (
diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index b51ea74..4857354 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -13,6 +13,7 @@ import assert from "node:assert/strict"; import { describe, it as test } from "node:test"; import { arrowGeometry, + followsEdgeGeometry, boxBlurRound, fitTransform, labelAlpha, @@ -113,6 +114,57 @@ describe("arrowGeometry", () => { }); }); +describe("followsEdgeGeometry (the ⟹ double-shaft glyph)", () => { + test("tails offset ±half-gap along the edge normal, converging on the shared head base", () => { + // Horizontal edge a→b: normal is (0, 1) — offsets are vertical. + const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 4)!; + assert.ok(g); + assert.ok(Math.abs(g.shaftAFrom.x - 6) < 1e-9 && Math.abs(g.shaftAFrom.y - 2) < 1e-9); + assert.ok(Math.abs(g.shaftBFrom.x - 6) < 1e-9 && Math.abs(g.shaftBFrom.y + 2) < 1e-9); + // Both shafts converge on the SAME point: the head's base. + assert.deepEqual(g.shaftTo, { x: 89, y: 0 }); + }); + + test("the offset tails sit farther from the source center than the axis start (never clipped by the disc)", () => { + const a = { x: 0, y: 0 }; + const g = followsEdgeGeometry(a, { x: 100, y: 0 }, 6, 6, 5, 4)!; + for (const tail of [g.shaftAFrom, g.shaftBFrom]) { + assert.ok(Math.hypot(tail.x - a.x, tail.y - a.y) >= 6 - 1e-9); + } + }); + + test("shared head geometry matches the plain arrow (tip/base corners)", () => { + const a = { x: 0, y: 0 }; + const b = { x: 100, y: 0 }; + const plain = arrowGeometry(a, b, 6, 6, 5)!; + const g = followsEdgeGeometry(a, b, 6, 6, 5, 4)!; + assert.deepEqual(g.tip, plain.tip); + assert.deepEqual(g.left, plain.left); + assert.deepEqual(g.right, plain.right); + }); + + test("works at arbitrary angles (diagonal normal normalized)", () => { + // dir (0.6, 0.8) → normal (-0.8, 0.6); half-gap 1.5. + const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 60, y: 80 }, 10, 10, 8, 3)!; + assert.ok(g); + const ax = 0.6 * 10; // axis start + const ay = 0.8 * 10; + assert.ok(Math.abs(g.shaftAFrom.x - (ax - 0.8 * 1.5)) < 1e-9); + assert.ok(Math.abs(g.shaftAFrom.y - (ay + 0.6 * 1.5)) < 1e-9); + assert.ok(Math.abs(g.shaftBFrom.x - (ax + 0.8 * 1.5)) < 1e-9); + assert.ok(Math.abs(g.shaftBFrom.y - (ay - 0.6 * 1.5)) < 1e-9); + }); + + test("null for coincident endpoints (same guard as the plain arrow)", () => { + assert.equal(followsEdgeGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5, 4), null); + }); + + test("zero gap degenerates to the plain single shaft", () => { + const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 0)!; + assert.deepEqual(g.shaftAFrom, g.shaftBFrom); + }); +}); + describe("pickNearest (distance-check picking)", () => { const nodes = [ { id: "a", x: 10, y: 10 }, From e8fa9403cadf32ca845fb79a06c224faf9edd561 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 15 Sep 2026 08:20:04 +0300 Subject: [PATCH 2/9] =?UTF-8?q?follows=20glyph:=20two=20complete=20offset?= =?UTF-8?q?=20arrows=20=E2=80=94=20no=20convergence,=20no=20head=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner rejected the first cut: two tails converging on one shared head reads as two arrows feeding a single point, not as parallel strokes. - followsEdgeGeometry now returns two COMPLETE regular arrows (own shaft + own head each), translated ±half-gap along the edge normal — parallel shafts AND parallel heads, nothing converges. Offset shafts never clip the source disc; offset tips land a hair outside the target disc edge (accepted price of parallel heads). - Gap floors: screen-anchored 3x edgeWidth (dominates at fit/zoomed-out, unchanged look) OR head-anchored 1.2x arrowLen (world) — without the head floor, the world-scaled heads merge into one blob at deep zoom (measured: one 10px band at 5x). max() keeps >= 0.4 arrowLen of clear space between the heads past the ~1.5x crossover. - Vision doc revised (2026-09-15) to record the rejection. Live-verified at 5x fit zoom: two shafts (11.5px gap = widened floor), two separate ~8px-base heads with clear space between. At fit: 3px gap, two heads, unchanged. Gates: tsc, 435/435 tests, lint, build. --- components/graph-canvas.ts | 105 ++++++++++++++++++++++++------------- docs/vision-edge-glyph.md | 3 +- tests/graph-canvas.test.ts | 60 ++++++++++++--------- 3 files changed, 106 insertions(+), 62 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index f3d648c..0d498aa 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -172,10 +172,17 @@ const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest const REGION_LABEL_SIZE = 14; /** Follows glyph: screen-space distance between the two shaft CENTERLINES, - * in units of the edge stroke width (constant on screen — so the gap is - * too, at every zoom; DECIDED 2026-09-14, docs/vision-edge-glyph.md). - * 3× a 1px stroke = 3px centers, 2px of clear space between the shafts. */ + * in units of the edge stroke width — the floor that dominates at fit and + * zoomed-out (constant on screen, like the stroke width; DECIDED + * 2026-09-14, docs/vision-edge-glyph.md). 3× a 1px stroke = 3px centers, + * 2px of clear space between the shafts. */ const FOLLOWS_GAP_RATIO = 3; +/** Follows glyph: head-anchored gap floor, in units of the arrowhead + * length (world). Two complete arrows offset by only the screen gap + * would merge their world-scaled heads into one blob at deep zoom + * (REVISED 2026-09-15); this floor keeps the heads separated once the + * user zooms in past the crossover (~1.5× fit). */ +const FOLLOWS_HEAD_CLEARANCE = 1.2; /** Rebuild the (zoom-anchored) edge paths when the camera scale drifts * more than this far from the scale they were built for — a handful of * cheap rebuilds per zoom gesture, zero per pan/settle frame. */ @@ -297,13 +304,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); @@ -323,11 +339,14 @@ export function arrowGeometry( } /** The double-shaft glyph for a `follows` edge (⟹ — DECIDED - * 2026-09-14, docs/vision-edge-glyph.md): two shafts offset along the - * edge normal by `gap` (a FIXED screen-space distance, world-converted - * by the caller), converging on the shared single arrowhead — both - * shafts meet at the head's base point. `supports` needs no helper - * (the plain single shaft IS arrowGeometry). Pure: DOM-free, tested. */ + * 2026-09-14, docs/vision-edge-glyph.md; REVISED 2026-09-15: the + * converged variant drew two tails merging into one shared head and + * read as two arrows feeding one point — owner rejected it). Two + * COMPLETE regular arrows (own shaft + own head each), translated + * ±half-gap along the edge normal: parallel shafts, parallel heads, + * nothing converges. The gap is a FIXED screen-space distance, + * world-converted by the caller. `supports` needs no helper (the plain + * single arrow IS arrowGeometry). Pure: DOM-free, tested. */ export function followsEdgeGeometry( a: Pt, b: Pt, @@ -335,7 +354,7 @@ export function followsEdgeGeometry( toRadius: number, headLength: number, gap: number, -): { shaftAFrom: Pt; shaftBFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null { +): { first: ArrowGeom; second: ArrowGeom } | null { const g = arrowGeometry(a, b, fromRadius, toRadius, headLength); if (!g) return null; // degenerate (coincident) — skip const dx = b.x - a.x; @@ -344,21 +363,27 @@ export function followsEdgeGeometry( const nx = -dy / len; const ny = dx / len; const half = gap / 2; + // Translate the whole regular arrow ±half-gap along the normal. + // Offset shafts never clip the source disc (their points sit at + // distance √(axial²+half²) > fromRadius from the center), and the + // offset tips land √(toRadius²+half²) from the target center — a + // hair outside its disc edge, the price of parallel heads (accepted). + const shift = (p: Pt, s: number): Pt => ({ x: p.x + nx * s, y: p.y + ny * s }); return { - // Parallel tails at the source, ± half-gap along the normal. The - // offset starts sit FARTHER from the source center than the axis - // start (√(r²+half²) > r) — never clipped by the source disc. - shaftAFrom: { x: g.shaftFrom.x + nx * half, y: g.shaftFrom.y + ny * half }, - shaftBFrom: { x: g.shaftFrom.x - nx * half, y: g.shaftFrom.y - ny * half }, - // Convergence: both shafts end at the head's base point — the - // merge lands exactly where the shared arrowhead begins, clean at - // every zoom (a strictly-parallel pair would poke out beside the - // head whenever the screen-fixed gap exceeds the head's base - // half-width — the head scales with the graph, the gap doesn't). - shaftTo: g.shaftTo, - tip: g.tip, - left: g.left, - right: g.right, + first: { + shaftFrom: shift(g.shaftFrom, half), + shaftTo: shift(g.shaftTo, half), + tip: shift(g.tip, half), + left: shift(g.left, half), + right: shift(g.right, half), + }, + second: { + shaftFrom: shift(g.shaftFrom, -half), + shaftTo: shift(g.shaftTo, -half), + tip: shift(g.tip, -half), + left: shift(g.left, -half), + right: shift(g.right, -half), + }, }; } @@ -1056,9 +1081,16 @@ export class GraphCanvasRenderer { const visible = this.visible; const isVisible = (id: string) => !visible || visible.has(id); const { nodeR, arrowLen } = this.world; - // The follows gap in WORLD units: constant on screen (· k stays - // FOLLOWS_GAP_RATIO × edgeWidth px at any zoom). Supports ignores it. - const gapW = (FOLLOWS_GAP_RATIO * settings.edgeWidth) / k; + // The follows gap in WORLD units. Two floors, the max of: + // - screen-anchored: FOLLOWS_GAP_RATIO × edgeWidth px on screen at + // any zoom (dominates zoomed-out/at-fit — the shafts stay + // distinguishable even when the heads shrink to specks); + // - head-anchored: 1.2 × arrowLen (world) — the heads grow with the + // graph, and a screen-fixed gap would let them merge into one + // blob at deep zoom. This floor keeps ≥ 0.4·arrowLen of clear + // space between the two heads once zoomed in past the crossover. + // Supports ignores the gap entirely. + const gapW = Math.max((FOLLOWS_GAP_RATIO * settings.edgeWidth) / k, FOLLOWS_HEAD_CLEARANCE * arrowLen); const shafts = new Path2D(); const heads = new Path2D(); for (const e of data.edges) { @@ -1067,17 +1099,18 @@ export class GraphCanvasRenderer { const b = this.nodeById.get(e.to); if (!a || !b) continue; if (e.type === "follows") { - // ⟹: two parallel tails, one shared head. + // ⟹: two complete regular arrows offset ±half-gap along + // the edge normal — parallel shafts AND parallel heads. const g = followsEdgeGeometry(a, b, nodeR, nodeR, arrowLen, gapW); if (!g) continue; - shafts.moveTo(g.shaftAFrom.x, g.shaftAFrom.y); - shafts.lineTo(g.shaftTo.x, g.shaftTo.y); - shafts.moveTo(g.shaftBFrom.x, g.shaftBFrom.y); - shafts.lineTo(g.shaftTo.x, g.shaftTo.y); - heads.moveTo(g.tip.x, g.tip.y); - heads.lineTo(g.left.x, g.left.y); - heads.lineTo(g.right.x, g.right.y); - heads.closePath(); + for (const arrow of [g.first, g.second]) { + shafts.moveTo(arrow.shaftFrom.x, arrow.shaftFrom.y); + shafts.lineTo(arrow.shaftTo.x, arrow.shaftTo.y); + heads.moveTo(arrow.tip.x, arrow.tip.y); + heads.lineTo(arrow.left.x, arrow.left.y); + heads.lineTo(arrow.right.x, arrow.right.y); + heads.closePath(); + } } else { // supports: the plain single shaft (the majority relation // stays light — dashed was rejected 2026-09-14). diff --git a/docs/vision-edge-glyph.md b/docs/vision-edge-glyph.md index 9609345..129b58d 100644 --- a/docs/vision-edge-glyph.md +++ b/docs/vision-edge-glyph.md @@ -21,7 +21,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. diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index 4857354..522bdc3 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -115,53 +115,63 @@ describe("arrowGeometry", () => { }); describe("followsEdgeGeometry (the ⟹ double-shaft glyph)", () => { - test("tails offset ±half-gap along the edge normal, converging on the shared head base", () => { + test("two COMPLETE arrows translated ±half-gap along the edge normal — nothing converges", () => { // Horizontal edge a→b: normal is (0, 1) — offsets are vertical. const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 4)!; assert.ok(g); - assert.ok(Math.abs(g.shaftAFrom.x - 6) < 1e-9 && Math.abs(g.shaftAFrom.y - 2) < 1e-9); - assert.ok(Math.abs(g.shaftBFrom.x - 6) < 1e-9 && Math.abs(g.shaftBFrom.y + 2) < 1e-9); - // Both shafts converge on the SAME point: the head's base. - assert.deepEqual(g.shaftTo, { x: 89, y: 0 }); + // Each arrow is the plain regular arrow, shifted whole: own shaft, + // own head. First at +2, second at −2 (half-gap 2). + assert.ok(Math.abs(g.first.shaftFrom.x - 6) < 1e-9 && Math.abs(g.first.shaftFrom.y - 2) < 1e-9); + assert.ok(Math.abs(g.second.shaftFrom.x - 6) < 1e-9 && Math.abs(g.second.shaftFrom.y + 2) < 1e-9); + // Shafts are PARALLEL: same span, both endpoints offset equally. + assert.ok(Math.abs(g.first.shaftTo.y - 2) < 1e-9 && Math.abs(g.second.shaftTo.y + 2) < 1e-9); + assert.ok(Math.abs(g.first.shaftTo.x - g.second.shaftTo.x) < 1e-9); }); - test("the offset tails sit farther from the source center than the axis start (never clipped by the disc)", () => { - const a = { x: 0, y: 0 }; - const g = followsEdgeGeometry(a, { x: 100, y: 0 }, 6, 6, 5, 4)!; - for (const tail of [g.shaftAFrom, g.shaftBFrom]) { - assert.ok(Math.hypot(tail.x - a.x, tail.y - a.y) >= 6 - 1e-9); - } - }); - - test("shared head geometry matches the plain arrow (tip/base corners)", () => { + test("each arrow keeps its OWN head, offset with it (no shared head)", () => { const a = { x: 0, y: 0 }; const b = { x: 100, y: 0 }; const plain = arrowGeometry(a, b, 6, 6, 5)!; const g = followsEdgeGeometry(a, b, 6, 6, 5, 4)!; - assert.deepEqual(g.tip, plain.tip); - assert.deepEqual(g.left, plain.left); - assert.deepEqual(g.right, plain.right); + // First arrow's head = plain head + (0, +2); second = −(0, +2). + assert.deepEqual(g.first.tip, { x: plain.tip.x, y: plain.tip.y + 2 }); + assert.deepEqual(g.second.tip, { x: plain.tip.x, y: plain.tip.y - 2 }); + assert.deepEqual(g.first.left, { x: plain.left.x, y: plain.left.y + 2 }); + assert.deepEqual(g.second.right, { x: plain.right.x, y: plain.right.y - 2 }); + // The two heads never meet: tips stay a full gap apart. + assert.ok(Math.abs(g.first.tip.y - g.second.tip.y - 4) < 1e-9); + }); + + test("the offset shafts never clip the source disc (farther than the axis start)", () => { + const a = { x: 0, y: 0 }; + const g = followsEdgeGeometry(a, { x: 100, y: 0 }, 6, 6, 5, 4)!; + for (const arrow of [g.first, g.second]) { + assert.ok(Math.hypot(arrow.shaftFrom.x - a.x, arrow.shaftFrom.y - a.y) >= 6 - 1e-9); + } }); test("works at arbitrary angles (diagonal normal normalized)", () => { - // dir (0.6, 0.8) → normal (-0.8, 0.6); half-gap 1.5. + // dir (0.6, 0.8) → normal (−0.8, 0.6); half-gap 1.5. const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 60, y: 80 }, 10, 10, 8, 3)!; assert.ok(g); const ax = 0.6 * 10; // axis start const ay = 0.8 * 10; - assert.ok(Math.abs(g.shaftAFrom.x - (ax - 0.8 * 1.5)) < 1e-9); - assert.ok(Math.abs(g.shaftAFrom.y - (ay + 0.6 * 1.5)) < 1e-9); - assert.ok(Math.abs(g.shaftBFrom.x - (ax + 0.8 * 1.5)) < 1e-9); - assert.ok(Math.abs(g.shaftBFrom.y - (ay - 0.6 * 1.5)) < 1e-9); + assert.ok(Math.abs(g.first.shaftFrom.x - (ax - 0.8 * 1.5)) < 1e-9); + assert.ok(Math.abs(g.first.shaftFrom.y - (ay + 0.6 * 1.5)) < 1e-9); + assert.ok(Math.abs(g.second.shaftFrom.x - (ax + 0.8 * 1.5)) < 1e-9); + assert.ok(Math.abs(g.second.shaftFrom.y - (ay - 0.6 * 1.5)) < 1e-9); }); test("null for coincident endpoints (same guard as the plain arrow)", () => { assert.equal(followsEdgeGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5, 4), null); }); - test("zero gap degenerates to the plain single shaft", () => { - const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 0)!; - assert.deepEqual(g.shaftAFrom, g.shaftBFrom); + test("zero gap degenerates to the plain single arrow twice", () => { + const a = { x: 0, y: 0 }; + const b = { x: 100, y: 0 }; + const g = followsEdgeGeometry(a, b, 6, 6, 5, 0)!; + assert.deepEqual(g.first.shaftFrom, g.second.shaftFrom); + assert.deepEqual(g.first.tip, g.second.tip); }); }); From 65dfda2342573272a78c923e6aa42f372f177279 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 15 Sep 2026 08:38:13 +0300 Subject: [PATCH 3/9] =?UTF-8?q?Withdraw=20the=20double-shaft=20edge=20glyp?= =?UTF-8?q?h=20=E2=80=94=20both=20variants=20rejected=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The converged variant read as two arrows feeding one point; the two- complete-arrows variant as two arrows next to each other, not coherent. Owner withdrew the double-shaft idea entirely (2026-09-15); dashed was already rejected 2026-09-14. Both relation types draw the same plain single arrow again. Kept: CanvasEdge.type plumbing from the snapshot (the next differentiator will need it) and the ArrowGeom named type. Removed: followsEdgeGeometry, the gap constants, the zoom-drift edge-path rebuild (edges are back to a single loop in buildGeometry), the ⟹ legend entry, and the glyph tests. Vision doc records the withdrawal and keeps the still-valid constraints. Gates: tsc, 429/429 tests, lint, build. Live: canvas coverage 0.477, no console errors, no legend remnants. --- components/graph-canvas.ts | 172 ++++++------------------------------- components/graph-view.tsx | 13 --- docs/vision-edge-glyph.md | 14 ++- tests/graph-canvas.test.ts | 61 ------------- 4 files changed, 36 insertions(+), 224 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 0d498aa..fd941f5 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -171,22 +171,6 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest const REGION_LABEL_SIZE = 14; -/** Follows glyph: screen-space distance between the two shaft CENTERLINES, - * in units of the edge stroke width — the floor that dominates at fit and - * zoomed-out (constant on screen, like the stroke width; DECIDED - * 2026-09-14, docs/vision-edge-glyph.md). 3× a 1px stroke = 3px centers, - * 2px of clear space between the shafts. */ -const FOLLOWS_GAP_RATIO = 3; -/** Follows glyph: head-anchored gap floor, in units of the arrowhead - * length (world). Two complete arrows offset by only the screen gap - * would merge their world-scaled heads into one blob at deep zoom - * (REVISED 2026-09-15); this floor keeps the heads separated once the - * user zooms in past the crossover (~1.5× fit). */ -const FOLLOWS_HEAD_CLEARANCE = 1.2; -/** Rebuild the (zoom-anchored) edge paths when the camera scale drifts - * more than this far from the scale they were built for — a handful of - * cheap rebuilds per zoom gesture, zero per pan/settle frame. */ -const EDGE_PATHS_ZOOM_EPSILON = 0.02; /** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA @@ -338,54 +322,6 @@ export function arrowGeometry( }; } -/** The double-shaft glyph for a `follows` edge (⟹ — DECIDED - * 2026-09-14, docs/vision-edge-glyph.md; REVISED 2026-09-15: the - * converged variant drew two tails merging into one shared head and - * read as two arrows feeding one point — owner rejected it). Two - * COMPLETE regular arrows (own shaft + own head each), translated - * ±half-gap along the edge normal: parallel shafts, parallel heads, - * nothing converges. The gap is a FIXED screen-space distance, - * world-converted by the caller. `supports` needs no helper (the plain - * single arrow IS arrowGeometry). Pure: DOM-free, tested. */ -export function followsEdgeGeometry( - a: Pt, - b: Pt, - fromRadius: number, - toRadius: number, - headLength: number, - gap: number, -): { first: ArrowGeom; second: ArrowGeom } | null { - const g = arrowGeometry(a, b, fromRadius, toRadius, headLength); - if (!g) return null; // degenerate (coincident) — skip - const dx = b.x - a.x; - const dy = b.y - a.y; - const len = Math.hypot(dx, dy); - const nx = -dy / len; - const ny = dx / len; - const half = gap / 2; - // Translate the whole regular arrow ±half-gap along the normal. - // Offset shafts never clip the source disc (their points sit at - // distance √(axial²+half²) > fromRadius from the center), and the - // offset tips land √(toRadius²+half²) from the target center — a - // hair outside its disc edge, the price of parallel heads (accepted). - const shift = (p: Pt, s: number): Pt => ({ x: p.x + nx * s, y: p.y + ny * s }); - return { - first: { - shaftFrom: shift(g.shaftFrom, half), - shaftTo: shift(g.shaftTo, half), - tip: shift(g.tip, half), - left: shift(g.left, half), - right: shift(g.right, half), - }, - second: { - shaftFrom: shift(g.shaftFrom, -half), - shaftTo: shift(g.shaftTo, -half), - tip: shift(g.tip, -half), - left: shift(g.left, -half), - right: shift(g.right, -half), - }, - }; -} /** Nearest node within maxDist of a screen point (linear scan — µs at * our scale; a spatial grid only earns its keep past ~10k nodes). */ @@ -450,7 +386,6 @@ interface WorldSizes { glowOuter: number; // hover halo outer radius, world units labelSize: number; // node label font, world units labelGap: number; // node edge → first label line, world units - arrowLen: number; // arrowhead length, world units (shared by both glyphs) } /** The pre-wrapped node name, centered below the node in WORLD units — @@ -547,10 +482,6 @@ export class GraphCanvasRenderer { // changes (geoVersion). Camera moves just re-rasterize the same paths. private geoVersion = 0; // bump = geometry inputs changed private builtVersion = -1; // version the cache was built at - /** Edge paths: built for this geoVersion AND this camera scale (the - * follows gap is screen-anchored). -1/0 force the first build. */ - private edgeBuiltVersion = -1; - private edgeBuiltK = 0; private nodesPath: Path2D | null = null; private shaftsPath: Path2D = new Path2D(); private headsPath: Path2D = new Path2D(); @@ -567,7 +498,7 @@ export class GraphCanvasRenderer { private frameCount = 0; // draw counter for the live-sim splat throttle private labelLines = new Map(); private worldLabelFont = ""; - private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0, arrowLen: 0 }; + private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0 }; constructor(canvas: HTMLCanvasElement, callbacks: RendererCallbacks) { this.canvas = canvas; @@ -1029,7 +960,6 @@ export class GraphCanvasRenderer { glowOuter: nodeR * GLOW_SCALE, labelSize: settings.labelSize * s, labelGap: LABEL_GAP * s, - arrowLen: settings.arrowLength * s, }; const visible = this.visible; const isVisible = (id: string) => !visible || visible.has(id); @@ -1043,10 +973,29 @@ export class GraphCanvasRenderer { } this.nodesPath = nodesPath; - // Edge shafts + heads are NOT built here: the follows glyph's gap - // is anchored to the CURRENT camera scale (constant on screen), so - // buildEdgePaths(k) runs from draw() on its own schedule — geometry - // changes AND camera-scale drift (EDGE_PATHS_ZOOM_EPSILON). + // 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) { + if (!isVisible(e.from) || !isVisible(e.to)) continue; + const a = this.nodeById.get(e.from); + const b = this.nodeById.get(e.to); + if (!a || !b) continue; + const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen); + if (!g) continue; + shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y); + shafts.lineTo(g.shaftTo.x, g.shaftTo.y); + heads.moveTo(g.tip.x, g.tip.y); + heads.lineTo(g.left.x, g.left.y); + heads.lineTo(g.right.x, g.right.y); + heads.closePath(); + } + this.shaftsPath = shafts; + this.headsPath = heads; // Cluster regions are NOT built here: they rasterize into their own // low-res bitmap (see buildRegions), on their own rebuild schedule @@ -1066,68 +1015,6 @@ export class GraphCanvasRenderer { ctx.font = prevFont; } - /** Edge shafts + arrowheads (the relation glyphs). Own build schedule, - * separate from buildGeometry: the follows glyph's shaft gap is a - * FIXED screen-space distance (FOLLOWS_GAP_RATIO × stroke width — - * constant on screen at every zoom, docs/vision-edge-glyph.md), so - * in world units it scales with 1/k and must be rebuilt as the camera - * zoom drifts (EDGE_PATHS_ZOOM_EPSILON gates that — pans and settle - * frames reuse the paths). Cheap either way: ~hundreds of edges × - * trivial trig, no rasterization. */ - private buildEdgePaths(k: number): void { - const data = this.data; - const settings = this.settings; - if (!data || !settings) return; - const visible = this.visible; - const isVisible = (id: string) => !visible || visible.has(id); - const { nodeR, arrowLen } = this.world; - // The follows gap in WORLD units. Two floors, the max of: - // - screen-anchored: FOLLOWS_GAP_RATIO × edgeWidth px on screen at - // any zoom (dominates zoomed-out/at-fit — the shafts stay - // distinguishable even when the heads shrink to specks); - // - head-anchored: 1.2 × arrowLen (world) — the heads grow with the - // graph, and a screen-fixed gap would let them merge into one - // blob at deep zoom. This floor keeps ≥ 0.4·arrowLen of clear - // space between the two heads once zoomed in past the crossover. - // Supports ignores the gap entirely. - const gapW = Math.max((FOLLOWS_GAP_RATIO * settings.edgeWidth) / k, FOLLOWS_HEAD_CLEARANCE * arrowLen); - const shafts = new Path2D(); - const heads = new Path2D(); - for (const e of data.edges) { - if (!isVisible(e.from) || !isVisible(e.to)) continue; - const a = this.nodeById.get(e.from); - const b = this.nodeById.get(e.to); - if (!a || !b) continue; - if (e.type === "follows") { - // ⟹: two complete regular arrows offset ±half-gap along - // the edge normal — parallel shafts AND parallel heads. - const g = followsEdgeGeometry(a, b, nodeR, nodeR, arrowLen, gapW); - if (!g) continue; - for (const arrow of [g.first, g.second]) { - shafts.moveTo(arrow.shaftFrom.x, arrow.shaftFrom.y); - shafts.lineTo(arrow.shaftTo.x, arrow.shaftTo.y); - heads.moveTo(arrow.tip.x, arrow.tip.y); - heads.lineTo(arrow.left.x, arrow.left.y); - heads.lineTo(arrow.right.x, arrow.right.y); - heads.closePath(); - } - } else { - // supports: the plain single shaft (the majority relation - // stays light — dashed was rejected 2026-09-14). - const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen); - if (!g) continue; - shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y); - shafts.lineTo(g.shaftTo.x, g.shaftTo.y); - heads.moveTo(g.tip.x, g.tip.y); - heads.lineTo(g.left.x, g.left.y); - heads.lineTo(g.right.x, g.right.y); - heads.closePath(); - } - } - this.shaftsPath = shafts; - this.headsPath = heads; - } - /** (Re)create the live sim from data.nodes at their current * positions + the current physics tuning. Called by setData and * setPhysicsTuning. */ @@ -1412,17 +1299,6 @@ export class GraphCanvasRenderer { if (!this.nodesPath) return; const { x: tx, y: ty, k } = this.transform; - // Edge paths: rebuild on geometry changes OR camera-scale drift - // (the follows glyph's gap is constant on screen, so its world size - // tracks 1/k). Pans and settle frames reuse the cached paths. - if ( - this.edgeBuiltVersion !== this.geoVersion || - Math.abs(k - this.edgeBuiltK) > this.edgeBuiltK * EDGE_PATHS_ZOOM_EPSILON - ) { - this.buildEdgePaths(k); - this.edgeBuiltVersion = this.geoVersion; - this.edgeBuiltK = k; - } // THE camera: one transform, then everything is world coordinates. ctx.setTransform(dpr * k, 0, 0, dpr * k, dpr * tx, dpr * ty); diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 76f96a2..ff001ba 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -896,19 +896,6 @@ export function GraphView() {
)} - {/* Edge glyph legend (bottom-right; the pipeline status pills own - the bottom-left). ⟹ follows (the derivation backbone) · → - supports — DECIDED 2026-09-14, docs/vision-edge-glyph.md. */} - {!isEmpty && ( -
- ⟹ - follows - · - → - supports -
- )} - {/* Background pipeline status: what is currently running. */} {pipelineRuns.length > 0 && (
diff --git a/docs/vision-edge-glyph.md b/docs/vision-edge-glyph.md index 129b58d..c79bb0e 100644 --- a/docs/vision-edge-glyph.md +++ b/docs/vision-edge-glyph.md @@ -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. --- diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index 522bdc3..ef7ebe3 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -13,7 +13,6 @@ import assert from "node:assert/strict"; import { describe, it as test } from "node:test"; import { arrowGeometry, - followsEdgeGeometry, boxBlurRound, fitTransform, labelAlpha, @@ -114,66 +113,6 @@ describe("arrowGeometry", () => { }); }); -describe("followsEdgeGeometry (the ⟹ double-shaft glyph)", () => { - test("two COMPLETE arrows translated ±half-gap along the edge normal — nothing converges", () => { - // Horizontal edge a→b: normal is (0, 1) — offsets are vertical. - const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 4)!; - assert.ok(g); - // Each arrow is the plain regular arrow, shifted whole: own shaft, - // own head. First at +2, second at −2 (half-gap 2). - assert.ok(Math.abs(g.first.shaftFrom.x - 6) < 1e-9 && Math.abs(g.first.shaftFrom.y - 2) < 1e-9); - assert.ok(Math.abs(g.second.shaftFrom.x - 6) < 1e-9 && Math.abs(g.second.shaftFrom.y + 2) < 1e-9); - // Shafts are PARALLEL: same span, both endpoints offset equally. - assert.ok(Math.abs(g.first.shaftTo.y - 2) < 1e-9 && Math.abs(g.second.shaftTo.y + 2) < 1e-9); - assert.ok(Math.abs(g.first.shaftTo.x - g.second.shaftTo.x) < 1e-9); - }); - - test("each arrow keeps its OWN head, offset with it (no shared head)", () => { - const a = { x: 0, y: 0 }; - const b = { x: 100, y: 0 }; - const plain = arrowGeometry(a, b, 6, 6, 5)!; - const g = followsEdgeGeometry(a, b, 6, 6, 5, 4)!; - // First arrow's head = plain head + (0, +2); second = −(0, +2). - assert.deepEqual(g.first.tip, { x: plain.tip.x, y: plain.tip.y + 2 }); - assert.deepEqual(g.second.tip, { x: plain.tip.x, y: plain.tip.y - 2 }); - assert.deepEqual(g.first.left, { x: plain.left.x, y: plain.left.y + 2 }); - assert.deepEqual(g.second.right, { x: plain.right.x, y: plain.right.y - 2 }); - // The two heads never meet: tips stay a full gap apart. - assert.ok(Math.abs(g.first.tip.y - g.second.tip.y - 4) < 1e-9); - }); - - test("the offset shafts never clip the source disc (farther than the axis start)", () => { - const a = { x: 0, y: 0 }; - const g = followsEdgeGeometry(a, { x: 100, y: 0 }, 6, 6, 5, 4)!; - for (const arrow of [g.first, g.second]) { - assert.ok(Math.hypot(arrow.shaftFrom.x - a.x, arrow.shaftFrom.y - a.y) >= 6 - 1e-9); - } - }); - - test("works at arbitrary angles (diagonal normal normalized)", () => { - // dir (0.6, 0.8) → normal (−0.8, 0.6); half-gap 1.5. - const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 60, y: 80 }, 10, 10, 8, 3)!; - assert.ok(g); - const ax = 0.6 * 10; // axis start - const ay = 0.8 * 10; - assert.ok(Math.abs(g.first.shaftFrom.x - (ax - 0.8 * 1.5)) < 1e-9); - assert.ok(Math.abs(g.first.shaftFrom.y - (ay + 0.6 * 1.5)) < 1e-9); - assert.ok(Math.abs(g.second.shaftFrom.x - (ax + 0.8 * 1.5)) < 1e-9); - assert.ok(Math.abs(g.second.shaftFrom.y - (ay - 0.6 * 1.5)) < 1e-9); - }); - - test("null for coincident endpoints (same guard as the plain arrow)", () => { - assert.equal(followsEdgeGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5, 4), null); - }); - - test("zero gap degenerates to the plain single arrow twice", () => { - const a = { x: 0, y: 0 }; - const b = { x: 100, y: 0 }; - const g = followsEdgeGeometry(a, b, 6, 6, 5, 0)!; - assert.deepEqual(g.first.shaftFrom, g.second.shaftFrom); - assert.deepEqual(g.first.tip, g.second.tip); - }); -}); describe("pickNearest (distance-check picking)", () => { const nodes = [ From 173059eb8f9134d80d90f972b41077a0f784da3a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 16 Sep 2026 10:54:05 +0300 Subject: [PATCH 4/9] Region fade between rebuilds + compute off switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The region bitmap rebuilds only every REGION_EVERY-th frame while the live sim runs, so during drags the tints stepped at 1/3 the frame rate. Between rebuilds draw() now linearly blends the previous final bitmap into the current one ('lighter' + globalAlpha weights = a true lerp of the premultiplied pixels — no source-over alpha stacking). Because box blur and the end-alpha scale are linear operators, lerping the finished bitmaps is equivalent to fading coverage before the blur, the mathematically exact form of the user's preferred pipeline. Two tiny GPU blits per frame; splat/blur/readback still only on rebuilds. New RegionTuning.enabled is a compute switch (not a paint toggle): off skips the entire build (splat, blur, readback, blit) — the settings panel exposes it as "Background colors". Cluster labels stay decoupled and follow clusterLabels. Old persisted files predate the field and sanitize to enabled: true. --- components/graph-canvas.ts | 177 +++++++++++++++++++++++++++++----- components/graph-view.tsx | 17 +++- src/core/render-settings.ts | 9 ++ tests/render-settings.test.ts | 12 +++ 4 files changed, 190 insertions(+), 25 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index fd941f5..55a7ee9 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -490,6 +490,17 @@ export class GraphCanvasRenderer { private regionRect: { minX: number; minY: number; w: number; h: number } | null = null; /** Region labels: cluster centroid + hue, computed at splat build. */ private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = []; + /** Previous FINAL bitmap + its world rect — the fade's "from" state, + * captured at each rebuild while the live sim runs (see buildRegions). */ + private regionPrevCanvas: HTMLCanvasElement | null = null; + private regionPrevRect: { minX: number; minY: number; w: number; h: number } | null = null; + /** Offscreen scratch the per-frame linear blend renders into (sized + * to the current bitmap; GPU blits only — no per-pixel CPU work). */ + private regionBlendCanvas: HTMLCanvasElement | null = null; + /** Fade state: true between a rebuild and the moment the blend has + * fully converged on the new bitmap (REGION_EVERY frames later). */ + private regionFading = false; + private regionFadePhase = 0; private regionTuning: RegionTuning = DEFAULT_REGION_TUNING; private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING; /** Gravity center for the live sim (graph bbox centroid at setData). */ @@ -598,6 +609,16 @@ export class GraphCanvasRenderer { setRegionTuning(input: Partial): void { this.regionTuning = sanitizeRegionTuning(input); this.regionBuiltVersion = -2; // never equals geoVersion → rebuild + // Off switch turning off: drop the bitmaps immediately so the + // blit (and any in-flight fade) stops on the next frame — the + // rebuild itself will skip all compute (buildRegions). + if (!this.regionTuning.enabled) { + this.regionCanvas = null; + this.regionRect = null; + this.regionPrevCanvas = null; + this.regionPrevRect = null; + this.regionFading = false; + } this.markDirty(); } @@ -1086,6 +1107,51 @@ export class GraphCanvasRenderer { this.regionCanvas = null; this.regionRect = null; this.regionMeta = []; + this.regionFading = false; + return; + } + + // Which cluster is a node in (for intra-cluster edges)? + const clusterOf = new Map(); + 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; + this.regionPrevCanvas = null; + this.regionPrevRect = null; + this.regionFading = false; return; } @@ -1129,6 +1195,31 @@ export class GraphCanvasRenderer { rc = document.createElement("canvas"); this.regionCanvas = rc; } + // Fade capture (user 2026-09-15): while the live sim moves nodes, + // the bitmap only rebuilds every REGION_EVERY-th frame — the + // in-between frames blit stale pixels and the regions visibly + // jump at 1/3 the frame rate. Draw() now linearly blends + // prev → cur across the frames after each rebuild. Blur and the + // end-alpha are LINEAR operators, so blending the finished + // bitmaps with a true lerp equals fade-then-blur-then-alpha — the + // user's preferred order — at zero extra blur cost. + this.regionFading = false; + if (this.simActive && this.regionRect) { + if (!this.regionPrevCanvas) this.regionPrevCanvas = document.createElement("canvas"); + const pc = this.regionPrevCanvas; + if (pc.width !== rc.width || pc.height !== rc.height) { + pc.width = rc.width; + pc.height = rc.height; + } + const pctx = pc.getContext("2d"); + if (pctx) { + pctx.clearRect(0, 0, pc.width, pc.height); + pctx.drawImage(rc, 0, 0); + this.regionPrevRect = this.regionRect; + this.regionFading = true; + this.regionFadePhase = 0; + } + } if (rc.width !== bw || rc.height !== bh) { rc.width = bw; rc.height = bh; // also clears @@ -1142,17 +1233,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(); - 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 @@ -1164,9 +1248,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(); for (const e of data.edges) { @@ -1204,19 +1285,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 @@ -1314,7 +1382,68 @@ export class GraphCanvasRenderer { if (this.regionCanvas && this.regionRect) { const { minX, minY, w, h } = this.regionRect; ctx.imageSmoothingEnabled = true; - ctx.drawImage(this.regionCanvas, minX, minY, w, h); + if (this.regionFading && this.regionPrevCanvas && this.regionPrevRect) { + // Linear fade prev → cur (user 2026-09-15): the bitmap rebuilds + // only every REGION_EVERY-th sim frame, so the regions would + // otherwise step at 1/3 the frame rate. TRUE LERP, not source- + // over: 'lighter' + globalAlpha weights sum the PREMULTIPLIED + // pixels — where old and new coverage both sit, the result + // stays at full coverage (no alpha stacking, no bright spots + // — the exact artifact source-over would add). And since blur + // and the end-alpha are linear operators, lerping the finished + // bitmaps ≡ the user's preferred fade → blur → alpha order. + // Two tiny GPU blits; the expensive path (splat/blur/readback) + // still runs only on rebuild frames. + const t = (this.regionFadePhase + 1) / REGION_EVERY; + if (t >= 1) { + // Converged: the blend would reproduce the bitmap + // exactly — skip the scratch work, stop the fade. + this.regionFading = false; + ctx.drawImage(this.regionCanvas, minX, minY, w, h); + } else { + const bw = this.regionCanvas.width; + const bh = this.regionCanvas.height; + if (!this.regionBlendCanvas) this.regionBlendCanvas = document.createElement("canvas"); + const bc = this.regionBlendCanvas; + if (bc.width !== bw || bc.height !== bh) { + bc.width = bw; + bc.height = bh; + } + const bctx = bc.getContext("2d"); + if (bctx) { + bctx.setTransform(1, 0, 0, 1, 0, 0); + bctx.clearRect(0, 0, bw, bh); + bctx.globalCompositeOperation = "lighter"; + bctx.imageSmoothingEnabled = true; + // The prev bitmap covers regionPrevRect; map it into THIS + // rect's pixel grid (rects shift a little every rebuild). + const pr = this.regionPrevRect; + bctx.globalAlpha = 1 - t; + bctx.drawImage( + this.regionPrevCanvas, + 0, + 0, + this.regionPrevCanvas.width, + this.regionPrevCanvas.height, + ((pr.minX - minX) * bw) / w, + ((pr.minY - minY) * bh) / h, + (pr.w * bw) / w, + (pr.h * bh) / h, + ); + bctx.globalAlpha = t; + bctx.drawImage(this.regionCanvas, 0, 0); + bctx.globalAlpha = 1; + bctx.globalCompositeOperation = "source-over"; + ctx.drawImage(bc, minX, minY, w, h); + } + this.regionFadePhase++; + // The sim may have settled mid-fade; flush the remaining + // substeps so the frame never freezes partway blended. + this.markDirty(); + } + } else { + ctx.drawImage(this.regionCanvas, minX, minY, w, h); + } } // 3. Edges: shafts + arrowheads from cached paths; width constant on diff --git a/components/graph-view.tsx b/components/graph-view.tsx index ff001ba..d564d60 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -292,11 +292,26 @@ function GraphSettingsControl({ 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. */}
Regions + | 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, }; } diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts index 4d613bc..f12430c 100644 --- a/tests/render-settings.test.ts +++ b/tests/render-settings.test.ts @@ -214,4 +214,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); + }); }); From 74c14931af75e191a81e22e3ad03ffad19e03a41 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 16 Sep 2026 11:28:39 +0300 Subject: [PATCH 5/9] Velocity-weighted region opacity replaces the (sub-perceptual) frame fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linear prev→cur bitmap blend between rebuilds was verified working but made no visible difference — the 3-frame bitmap cadence was already below perceptual threshold for large blurred blobs; the churn during fast drags is the SHAPE, not the cadence. Rolled back entirely (no extra per-frame blits). In its place, per the owner: while nodes move fast, their blob fades out and fades back in on settling. Speed per node = screen px per frame from position stamps between rebuilds, EMA-smoothed. The blob is a CLUSTER attribute (still neighbors' skeleton strokes pass right through a moving node's area — per-node weighting measurably did nothing), so each cluster's weight = f(max member speed): linear 1→0 between 1.5 and 8 px/frame. Strokes and discs paint at that alpha — before the blur + final edgeAlpha multiply, so coverage semantics are unchanged at rest (all weights 1 = the 2026-09-14 no-stacking fix). Stamps reset on data-object change, NOT geoVersion (the live sim bumps geoVersion every frame — resetting on it zeroed all speeds; caught live). regionMotionPending keeps rebuilding after the sim stops until weights converge back to 1, so a blob never freezes at partial opacity. --- components/graph-canvas.ts | 223 ++++++++++++++++++------------------- tests/graph-canvas.test.ts | 27 +++++ 2 files changed, 133 insertions(+), 117 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 55a7ee9..b83385b 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -171,6 +171,22 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest const REGION_LABEL_SIZE = 14; +// Velocity-weighted region opacity (user 2026-09-16): a node's blob +// contribution fades as the node moves — the bitmap is only rebuilt +// every REGION_EVERY-th sim frame, and interpolating position between +// rebuilds proved sub-perceptual (the churn during fast drags is the +// SHAPE, not the cadence). Speed is SCREEN px per frame (zoom- +// independent semantics: "how fast does it LOOK"). +const REGION_VELOCITY_STILL = 1.5; // ≤ this → full blob opacity +const REGION_VELOCITY_MOVING = 8; // ≥ this → the node's blob is off + +/** Linear 1 → 0 ramp between the two thresholds. Pure — unit-tested. */ +export function regionVelocityWeight(speedScreen: number): number { + if (speedScreen <= REGION_VELOCITY_STILL) return 1; + if (speedScreen >= REGION_VELOCITY_MOVING) return 0; + return (REGION_VELOCITY_MOVING - speedScreen) / (REGION_VELOCITY_MOVING - REGION_VELOCITY_STILL); +} + /** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA @@ -492,15 +508,25 @@ export class GraphCanvasRenderer { private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = []; /** Previous FINAL bitmap + its world rect — the fade's "from" state, * captured at each rebuild while the live sim runs (see buildRegions). */ - private regionPrevCanvas: HTMLCanvasElement | null = null; - private regionPrevRect: { minX: number; minY: number; w: number; h: number } | null = null; - /** Offscreen scratch the per-frame linear blend renders into (sized - * to the current bitmap; GPU blits only — no per-pixel CPU work). */ - private regionBlendCanvas: HTMLCanvasElement | null = null; - /** Fade state: true between a rebuild and the moment the blend has - * fully converged on the new bitmap (REGION_EVERY frames later). */ - private regionFading = false; - private regionFadePhase = 0; + /** Velocity-weighted splat opacity (user 2026-09-16): a node's blob + * contribution fades as it moves. Speed = screen px per frame, + * measured between region rebuilds (position stamps) and EMA-smoothed + * so the opacity transitions are gradual, never a snap. */ + private regionPosStamp = new Map(); + private regionSpeedEMA = new Map(); + private regionStampFrame = 0; + /** The data object the stamps were taken against. geoVersion is NOT + * usable here: the live sim bumps it EVERY frame, which would clear + * the stamps on every rebuild and zero all speeds (caught live). A + * new snapshot object = positions may have teleported; same object = + * positions only ever move by physics. */ + private regionStampData: unknown = null; + /** True when the last bitmap was built with some node still above the + * still-velocity threshold: the sim can stop mid-fade, and without a + * flush the blob would freeze at partial opacity until the next + * geometry change. draw() keeps rebuilding (and ticking) until the + * weights converge back to 1. */ + private regionMotionPending = false; private regionTuning: RegionTuning = DEFAULT_REGION_TUNING; private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING; /** Gravity center for the live sim (graph bbox centroid at setData). */ @@ -609,15 +635,12 @@ export class GraphCanvasRenderer { setRegionTuning(input: Partial): void { this.regionTuning = sanitizeRegionTuning(input); this.regionBuiltVersion = -2; // never equals geoVersion → rebuild - // Off switch turning off: drop the bitmaps immediately so the - // blit (and any in-flight fade) stops on the next frame — the - // rebuild itself will skip all compute (buildRegions). + // 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.regionPrevCanvas = null; - this.regionPrevRect = null; - this.regionFading = false; } this.markDirty(); } @@ -1076,6 +1099,7 @@ export class GraphCanvasRenderer { * frames blit the previous bitmap — one sim step of staleness at * 120fps is invisible). Positions come straight from data.nodes. */ private buildRegions(): void { + this.regionMotionPending = false; const data = this.data; if (!data || data.clusters.length === 0) { this.regionCanvas = null; @@ -1107,7 +1131,6 @@ export class GraphCanvasRenderer { this.regionCanvas = null; this.regionRect = null; this.regionMeta = []; - this.regionFading = false; return; } @@ -1149,9 +1172,6 @@ export class GraphCanvasRenderer { if (!tuning.enabled) { this.regionCanvas = null; this.regionRect = null; - this.regionPrevCanvas = null; - this.regionPrevRect = null; - this.regionFading = false; return; } @@ -1195,31 +1215,6 @@ export class GraphCanvasRenderer { rc = document.createElement("canvas"); this.regionCanvas = rc; } - // Fade capture (user 2026-09-15): while the live sim moves nodes, - // the bitmap only rebuilds every REGION_EVERY-th frame — the - // in-between frames blit stale pixels and the regions visibly - // jump at 1/3 the frame rate. Draw() now linearly blends - // prev → cur across the frames after each rebuild. Blur and the - // end-alpha are LINEAR operators, so blending the finished - // bitmaps with a true lerp equals fade-then-blur-then-alpha — the - // user's preferred order — at zero extra blur cost. - this.regionFading = false; - if (this.simActive && this.regionRect) { - if (!this.regionPrevCanvas) this.regionPrevCanvas = document.createElement("canvas"); - const pc = this.regionPrevCanvas; - if (pc.width !== rc.width || pc.height !== rc.height) { - pc.width = rc.width; - pc.height = rc.height; - } - const pctx = pc.getContext("2d"); - if (pctx) { - pctx.clearRect(0, 0, pc.width, pc.height); - pctx.drawImage(rc, 0, 0); - this.regionPrevRect = this.regionRect; - this.regionFading = true; - this.regionFadePhase = 0; - } - } if (rc.width !== bw || rc.height !== bh) { rc.width = bw; rc.height = bh; // also clears @@ -1233,21 +1228,66 @@ export class GraphCanvasRenderer { rctx.lineCap = "round"; rctx.lineWidth = (dilationW * 2) / cell; + // Velocity weights (user 2026-09-16): position stamps between + // rebuilds give each node's speed in SCREEN px per frame + // (world distance × current camera scale — zoom-independent + // semantics). An EMA (halved each rebuild) smooths the weight + // transitions; the first build after a data change has no stamps + // (positions teleported, they didn't MOVE) → full weight. + // + // The blob is a CLUSTER attribute: the strokes/discs around a + // still node are mostly its still neighbors' skeleton, so per-node + // weighting left the local blob nearly untouched while dragging + // (measured live). One fast member — the node being dragged — + // therefore fades its WHOLE cluster's blob: weight per cluster = + // f(max member speed). + const k = this.transform.k; + if (this.regionStampData !== data) { + this.regionPosStamp.clear(); + this.regionSpeedEMA.clear(); + this.regionStampData = data; + } + const frames = Math.max(1, this.frameCount - this.regionStampFrame); + for (const n of data.nodes) { + const stamp = this.regionPosStamp.get(n.id); + const speed = stamp ? (Math.hypot(n.x - stamp.x, n.y - stamp.y) * k) / frames : 0; + const prev = this.regionSpeedEMA.get(n.id); + const ema = prev === undefined ? speed : prev * 0.5 + speed * 0.5; + this.regionSpeedEMA.set(n.id, ema); + this.regionPosStamp.set(n.id, { x: n.x, y: n.y }); + } + this.regionStampFrame = this.frameCount; + const clusterW = new Array(data.clusters.length).fill(1); + let motionPending = false; + data.clusters.forEach((cluster, ci) => { + let maxSpeed = 0; + for (const id of cluster.members) { + const e = this.regionSpeedEMA.get(id); + if (e !== undefined && e > maxSpeed) maxSpeed = e; + } + const w = regionVelocityWeight(maxSpeed); + clusterW[ci] = w; + if (w < 0.999) motionPending = true; + }); + this.regionMotionPending = motionPending; + // 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). + // + // VELOCITY WEIGHTS (user 2026-09-16): each edge strokes with its + // own alpha = min of its endpoints' weights, so a fast-moving + // node's blob contribution fades out while its still neighbors + // keep theirs. This reintroduces per-shape alpha ONLY while + // nodes move: at rest every weight is 1 and the alpha channel + // is pure coverage again (the 2026-09-14 no-stacking fix). While + // things move, two strokes sharing an endpoint can overlap at + // < 1 and stack — a transient joint spot on a blob that is + // already fading out; accepted. data.clusters.forEach((cluster, ci) => { const hue = clusterHue(cluster.id); - // Full-opacity strokes: alpha is applied ONCE at the very end - // (dilate at 1 → blur → scale by edgeAlpha). Baking per-stroke - // alpha in here made source-over stacking ACCUMULATE it — an - // orphan disc overlapping anything read as a darker double- - // counted spot (user report 2026-09-14). At alpha 1 the alpha - // channel is a pure coverage indicator: overlaps of any count - // contribute exactly 1.0. rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`; - rctx.beginPath(); // Endpoints actually covered by a stroke's round caps. const touched = new Set(); for (const e of data.edges) { @@ -1261,12 +1301,15 @@ export class GraphCanvasRenderer { const a = this.nodeById.get(e.from); const b = this.nodeById.get(e.to); if (!a || !b) continue; + rctx.globalAlpha = clusterW[ci] ?? 1; + rctx.beginPath(); rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell); rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell); + rctx.stroke(); touched.add(e.from); touched.add(e.to); } - rctx.stroke(); + rctx.globalAlpha = 1; // Member discs for the SEMANTIC era (DECIDED 2026-09-13): clusters // are now topics by meaning, and many members (orphans, isolated @@ -1281,10 +1324,12 @@ export class GraphCanvasRenderer { if (touched.has(id) || !isVisible(id)) continue; const n = this.nodeById.get(id); if (!n) continue; + rctx.globalAlpha = clusterW[ci] ?? 1; rctx.beginPath(); rctx.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2); rctx.fill(); } + rctx.globalAlpha = 1; }); // THE blur + alpha (in that order, by design — user-decided @@ -1356,12 +1401,17 @@ export class GraphCanvasRenderer { this.frameCount++; // Regions: rebuild on stale geometry at rest; during the live sim, // every REGION_EVERY-th frame (others blit the last bitmap). + // regionMotionPending (velocity weights mid-fade when the sim + // stops) forces rebuilds until the weights converge back to 1 — + // otherwise the blob would freeze at partial opacity. if ( - this.regionBuiltVersion !== this.geoVersion && - (!this.simActive || this.frameCount % REGION_EVERY === 0) + (this.regionBuiltVersion !== this.geoVersion && + (!this.simActive || this.frameCount % REGION_EVERY === 0)) || + this.regionMotionPending ) { this.buildRegions(); this.regionBuiltVersion = this.geoVersion; + if (this.regionMotionPending) this.markDirty(); } if (this.builtVersion !== this.geoVersion) this.buildGeometry(); if (!this.nodesPath) return; @@ -1382,68 +1432,7 @@ export class GraphCanvasRenderer { if (this.regionCanvas && this.regionRect) { const { minX, minY, w, h } = this.regionRect; ctx.imageSmoothingEnabled = true; - if (this.regionFading && this.regionPrevCanvas && this.regionPrevRect) { - // Linear fade prev → cur (user 2026-09-15): the bitmap rebuilds - // only every REGION_EVERY-th sim frame, so the regions would - // otherwise step at 1/3 the frame rate. TRUE LERP, not source- - // over: 'lighter' + globalAlpha weights sum the PREMULTIPLIED - // pixels — where old and new coverage both sit, the result - // stays at full coverage (no alpha stacking, no bright spots - // — the exact artifact source-over would add). And since blur - // and the end-alpha are linear operators, lerping the finished - // bitmaps ≡ the user's preferred fade → blur → alpha order. - // Two tiny GPU blits; the expensive path (splat/blur/readback) - // still runs only on rebuild frames. - const t = (this.regionFadePhase + 1) / REGION_EVERY; - if (t >= 1) { - // Converged: the blend would reproduce the bitmap - // exactly — skip the scratch work, stop the fade. - this.regionFading = false; - ctx.drawImage(this.regionCanvas, minX, minY, w, h); - } else { - const bw = this.regionCanvas.width; - const bh = this.regionCanvas.height; - if (!this.regionBlendCanvas) this.regionBlendCanvas = document.createElement("canvas"); - const bc = this.regionBlendCanvas; - if (bc.width !== bw || bc.height !== bh) { - bc.width = bw; - bc.height = bh; - } - const bctx = bc.getContext("2d"); - if (bctx) { - bctx.setTransform(1, 0, 0, 1, 0, 0); - bctx.clearRect(0, 0, bw, bh); - bctx.globalCompositeOperation = "lighter"; - bctx.imageSmoothingEnabled = true; - // The prev bitmap covers regionPrevRect; map it into THIS - // rect's pixel grid (rects shift a little every rebuild). - const pr = this.regionPrevRect; - bctx.globalAlpha = 1 - t; - bctx.drawImage( - this.regionPrevCanvas, - 0, - 0, - this.regionPrevCanvas.width, - this.regionPrevCanvas.height, - ((pr.minX - minX) * bw) / w, - ((pr.minY - minY) * bh) / h, - (pr.w * bw) / w, - (pr.h * bh) / h, - ); - bctx.globalAlpha = t; - bctx.drawImage(this.regionCanvas, 0, 0); - bctx.globalAlpha = 1; - bctx.globalCompositeOperation = "source-over"; - ctx.drawImage(bc, minX, minY, w, h); - } - this.regionFadePhase++; - // The sim may have settled mid-fade; flush the remaining - // substeps so the frame never freezes partway blended. - this.markDirty(); - } - } else { - ctx.drawImage(this.regionCanvas, minX, minY, w, h); - } + ctx.drawImage(this.regionCanvas, minX, minY, w, h); } // 3. Edges: shafts + arrowheads from cached paths; width constant on diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index ef7ebe3..e01d3e7 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -17,6 +17,7 @@ import { fitTransform, labelAlpha, pickNearest, + regionVelocityWeight, zoomRatioOf, LABELS_FADE_SPAN, } from "../components/graph-canvas.ts"; @@ -226,3 +227,29 @@ describe("sanitizePhysicsTuning (physics controls)", () => { assert.equal(t.velocityDecay, 0.9); }); }); + +describe("regionVelocityWeight", () => { + test("still nodes: at or below the still threshold → full opacity", () => { + assert.equal(regionVelocityWeight(0), 1); + assert.equal(regionVelocityWeight(1.5), 1); + }); + + test("fast nodes: at or above the moving threshold → blob off", () => { + assert.equal(regionVelocityWeight(8), 0); + assert.equal(regionVelocityWeight(100), 0); + }); + + test("linear ramp between the thresholds, monotone decreasing", () => { + const mid = regionVelocityWeight((1.5 + 8) / 2); + assert.ok(Math.abs(mid - 0.5) < 1e-12); + // strictly decreasing across the ramp + for (let s = 1.5; s <= 8; s += 0.25) { + assert.ok(regionVelocityWeight(s) >= regionVelocityWeight(s + 0.25)); + } + // never leaves [0, 1] + for (let s = -10; s <= 50; s += 1.7) { + const w = regionVelocityWeight(s); + assert.ok(w >= 0 && w <= 1); + } + }); +}); From b24bce3f83c836461382935f07df12e68622d437 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 16 Sep 2026 11:35:28 +0300 Subject: [PATCH 6/9] Abandon velocity-weighted region opacity (owner-rejected, buggy at scale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-node speed is invisible inside a cluster blob (still neighbors' skeleton strokes dominate the local area), and per-cluster max-speed couples everything: dragging ONE node dimmed the whole cluster's blob to nothing — including disconnected members at near-zero velocity. Neither granularity is right, so the idea is withdrawn entirely. Manual revert of the velocity parts (a git revert of 154f6c8 would have resurrected the frame fade): weight ramp, position stamps, EMA state, motion-pending flush, per-edge stroke batching, and the tests. Region splat is back to full-opacity coverage at rest AND in motion (2026-09-14 no-stacking invariant). The regions.enabled compute off switch stays. --- components/graph-canvas.ts | 115 ++++--------------------------------- tests/graph-canvas.test.ts | 26 --------- 2 files changed, 11 insertions(+), 130 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index b83385b..e1deaa5 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -171,22 +171,6 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest const REGION_LABEL_SIZE = 14; -// Velocity-weighted region opacity (user 2026-09-16): a node's blob -// contribution fades as the node moves — the bitmap is only rebuilt -// every REGION_EVERY-th sim frame, and interpolating position between -// rebuilds proved sub-perceptual (the churn during fast drags is the -// SHAPE, not the cadence). Speed is SCREEN px per frame (zoom- -// independent semantics: "how fast does it LOOK"). -const REGION_VELOCITY_STILL = 1.5; // ≤ this → full blob opacity -const REGION_VELOCITY_MOVING = 8; // ≥ this → the node's blob is off - -/** Linear 1 → 0 ramp between the two thresholds. Pure — unit-tested. */ -export function regionVelocityWeight(speedScreen: number): number { - if (speedScreen <= REGION_VELOCITY_STILL) return 1; - if (speedScreen >= REGION_VELOCITY_MOVING) return 0; - return (REGION_VELOCITY_MOVING - speedScreen) / (REGION_VELOCITY_MOVING - REGION_VELOCITY_STILL); -} - /** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA @@ -506,27 +490,6 @@ export class GraphCanvasRenderer { private regionRect: { minX: number; minY: number; w: number; h: number } | null = null; /** Region labels: cluster centroid + hue, computed at splat build. */ private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = []; - /** Previous FINAL bitmap + its world rect — the fade's "from" state, - * captured at each rebuild while the live sim runs (see buildRegions). */ - /** Velocity-weighted splat opacity (user 2026-09-16): a node's blob - * contribution fades as it moves. Speed = screen px per frame, - * measured between region rebuilds (position stamps) and EMA-smoothed - * so the opacity transitions are gradual, never a snap. */ - private regionPosStamp = new Map(); - private regionSpeedEMA = new Map(); - private regionStampFrame = 0; - /** The data object the stamps were taken against. geoVersion is NOT - * usable here: the live sim bumps it EVERY frame, which would clear - * the stamps on every rebuild and zero all speeds (caught live). A - * new snapshot object = positions may have teleported; same object = - * positions only ever move by physics. */ - private regionStampData: unknown = null; - /** True when the last bitmap was built with some node still above the - * still-velocity threshold: the sim can stop mid-fade, and without a - * flush the blob would freeze at partial opacity until the next - * geometry change. draw() keeps rebuilding (and ticking) until the - * weights converge back to 1. */ - private regionMotionPending = false; private regionTuning: RegionTuning = DEFAULT_REGION_TUNING; private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING; /** Gravity center for the live sim (graph bbox centroid at setData). */ @@ -1099,7 +1062,6 @@ export class GraphCanvasRenderer { * frames blit the previous bitmap — one sim step of staleness at * 120fps is invisible). Positions come straight from data.nodes. */ private buildRegions(): void { - this.regionMotionPending = false; const data = this.data; if (!data || data.clusters.length === 0) { this.regionCanvas = null; @@ -1228,66 +1190,21 @@ export class GraphCanvasRenderer { rctx.lineCap = "round"; rctx.lineWidth = (dilationW * 2) / cell; - // Velocity weights (user 2026-09-16): position stamps between - // rebuilds give each node's speed in SCREEN px per frame - // (world distance × current camera scale — zoom-independent - // semantics). An EMA (halved each rebuild) smooths the weight - // transitions; the first build after a data change has no stamps - // (positions teleported, they didn't MOVE) → full weight. - // - // The blob is a CLUSTER attribute: the strokes/discs around a - // still node are mostly its still neighbors' skeleton, so per-node - // weighting left the local blob nearly untouched while dragging - // (measured live). One fast member — the node being dragged — - // therefore fades its WHOLE cluster's blob: weight per cluster = - // f(max member speed). - const k = this.transform.k; - if (this.regionStampData !== data) { - this.regionPosStamp.clear(); - this.regionSpeedEMA.clear(); - this.regionStampData = data; - } - const frames = Math.max(1, this.frameCount - this.regionStampFrame); - for (const n of data.nodes) { - const stamp = this.regionPosStamp.get(n.id); - const speed = stamp ? (Math.hypot(n.x - stamp.x, n.y - stamp.y) * k) / frames : 0; - const prev = this.regionSpeedEMA.get(n.id); - const ema = prev === undefined ? speed : prev * 0.5 + speed * 0.5; - this.regionSpeedEMA.set(n.id, ema); - this.regionPosStamp.set(n.id, { x: n.x, y: n.y }); - } - this.regionStampFrame = this.frameCount; - const clusterW = new Array(data.clusters.length).fill(1); - let motionPending = false; - data.clusters.forEach((cluster, ci) => { - let maxSpeed = 0; - for (const id of cluster.members) { - const e = this.regionSpeedEMA.get(id); - if (e !== undefined && e > maxSpeed) maxSpeed = e; - } - const w = regionVelocityWeight(maxSpeed); - clusterW[ci] = w; - if (w < 0.999) motionPending = true; - }); - this.regionMotionPending = motionPending; - // 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). - // - // VELOCITY WEIGHTS (user 2026-09-16): each edge strokes with its - // own alpha = min of its endpoints' weights, so a fast-moving - // node's blob contribution fades out while its still neighbors - // keep theirs. This reintroduces per-shape alpha ONLY while - // nodes move: at rest every weight is 1 and the alpha channel - // is pure coverage again (the 2026-09-14 no-stacking fix). While - // things move, two strokes sharing an endpoint can overlap at - // < 1 and stack — a transient joint spot on a blob that is - // already fading out; accepted. data.clusters.forEach((cluster, ci) => { const hue = clusterHue(cluster.id); + // Full-opacity strokes: alpha is applied ONCE at the very end + // (dilate at 1 → blur → scale by edgeAlpha). Baking per-stroke + // alpha in here made source-over stacking ACCUMULATE it — an + // orphan disc overlapping anything read as a darker double- + // counted spot (user report 2026-09-14). At alpha 1 the alpha + // channel is a pure coverage indicator: overlaps of any count + // contribute exactly 1.0. rctx.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`; + rctx.beginPath(); // Endpoints actually covered by a stroke's round caps. const touched = new Set(); for (const e of data.edges) { @@ -1301,15 +1218,12 @@ export class GraphCanvasRenderer { const a = this.nodeById.get(e.from); const b = this.nodeById.get(e.to); if (!a || !b) continue; - rctx.globalAlpha = clusterW[ci] ?? 1; - rctx.beginPath(); rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell); rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell); - rctx.stroke(); touched.add(e.from); touched.add(e.to); } - rctx.globalAlpha = 1; + rctx.stroke(); // Member discs for the SEMANTIC era (DECIDED 2026-09-13): clusters // are now topics by meaning, and many members (orphans, isolated @@ -1324,12 +1238,10 @@ export class GraphCanvasRenderer { if (touched.has(id) || !isVisible(id)) continue; const n = this.nodeById.get(id); if (!n) continue; - rctx.globalAlpha = clusterW[ci] ?? 1; rctx.beginPath(); rctx.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2); rctx.fill(); } - rctx.globalAlpha = 1; }); // THE blur + alpha (in that order, by design — user-decided @@ -1401,17 +1313,12 @@ export class GraphCanvasRenderer { this.frameCount++; // Regions: rebuild on stale geometry at rest; during the live sim, // every REGION_EVERY-th frame (others blit the last bitmap). - // regionMotionPending (velocity weights mid-fade when the sim - // stops) forces rebuilds until the weights converge back to 1 — - // otherwise the blob would freeze at partial opacity. if ( - (this.regionBuiltVersion !== this.geoVersion && - (!this.simActive || this.frameCount % REGION_EVERY === 0)) || - this.regionMotionPending + this.regionBuiltVersion !== this.geoVersion && + (!this.simActive || this.frameCount % REGION_EVERY === 0) ) { this.buildRegions(); this.regionBuiltVersion = this.geoVersion; - if (this.regionMotionPending) this.markDirty(); } if (this.builtVersion !== this.geoVersion) this.buildGeometry(); if (!this.nodesPath) return; diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index e01d3e7..011d3a2 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -17,7 +17,6 @@ import { fitTransform, labelAlpha, pickNearest, - regionVelocityWeight, zoomRatioOf, LABELS_FADE_SPAN, } from "../components/graph-canvas.ts"; @@ -228,28 +227,3 @@ describe("sanitizePhysicsTuning (physics controls)", () => { }); }); -describe("regionVelocityWeight", () => { - test("still nodes: at or below the still threshold → full opacity", () => { - assert.equal(regionVelocityWeight(0), 1); - assert.equal(regionVelocityWeight(1.5), 1); - }); - - test("fast nodes: at or above the moving threshold → blob off", () => { - assert.equal(regionVelocityWeight(8), 0); - assert.equal(regionVelocityWeight(100), 0); - }); - - test("linear ramp between the thresholds, monotone decreasing", () => { - const mid = regionVelocityWeight((1.5 + 8) / 2); - assert.ok(Math.abs(mid - 0.5) < 1e-12); - // strictly decreasing across the ramp - for (let s = 1.5; s <= 8; s += 0.25) { - assert.ok(regionVelocityWeight(s) >= regionVelocityWeight(s + 0.25)); - } - // never leaves [0, 1] - for (let s = -10; s <= 50; s += 1.7) { - const w = regionVelocityWeight(s); - assert.ok(w >= 0 && w <= 1); - } - }); -}); From 2f4b9c82e407eb86f72004404b3ae71851672e08 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 17 Sep 2026 09:35:11 +0300 Subject: [PATCH 7/9] Cluster labels: topmost layer + user-facing size setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Draw order: the cluster-label block moves from layer 5 to THE LAST layer of draw() — labels can never be occluded by node labels or hover names (user 2026-09-17; chose "topmost" after reassessing that discs were never actually on top — the white-out over discs is source-over compositing of the 85%-alpha text over near-white node fill, an optical contrast flip, not a draw-order bug). - clusterLabelSize: new persisted setting (default 14 = the old REGION_LABEL_SIZE constant, so rendering is pixel-identical). Screen px at ANY zoom (drawn at size/k — camera-compensated, UI-like, same law as before). Log-slider range [7, 28] puts the default dead-center per the house slider law. Sanitized: old persisted files (no field) fall back to 14. - Panel: the Cluster labels checkbox moves from the Labels section into Regions (user: cluster settings live with regions), joined by the new "Cluster label size" slider. Labels stay decoupled from the Background colors compute switch, as designed on 2026-09-16. --- components/graph-canvas.ts | 40 ++++++++++++++++++----------------- components/graph-view.tsx | 37 ++++++++++++++++++++++++-------- src/core/render-settings.ts | 14 ++++++++++++ tests/render-settings.test.ts | 17 +++++++++++++++ 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index e1deaa5..c0f8b08 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -169,7 +169,6 @@ 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; @@ -1357,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) { @@ -1413,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(); + } } } diff --git a/components/graph-view.tsx b/components/graph-view.tsx index d564d60..3c65f17 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -69,6 +69,7 @@ import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts import { ARROW_LENGTH_RANGE, CHARGE_RANGE, + CLUSTER_LABEL_SIZE_RANGE, CLUSTER_PULL_RANGE, DEPTH_BASE_RADIUS_RANGE, DEPTH_SPACING_RANGE, @@ -279,15 +280,6 @@ function GraphSettingsControl({ onChange={(p) => changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))} /> - {/* 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 @@ -376,6 +368,33 @@ function GraphSettingsControl({ ))} + {/* 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). */} + + + changeSettings((cur) => ({ + ...cur, + clusterLabelSize: fromSliderPosition(p, CLUSTER_LABEL_SIZE_RANGE.min, CLUSTER_LABEL_SIZE_RANGE.max), + }))} + />
{/* Physics: the live sim's forces. Every value feeds the SAME engine the server settle runs (createGraphSimulation); diff --git a/src/core/render-settings.ts b/src/core/render-settings.ts index 3444440..02becac 100644 --- a/src/core/render-settings.ts +++ b/src/core/render-settings.ts @@ -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), @@ -214,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, }; @@ -229,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 diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts index f12430c..7602bea 100644 --- a/tests/render-settings.test.ts +++ b/tests/render-settings.test.ts @@ -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, }; From 3c5c48f502cc284617f811c7f2989ab30c44ffc8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 17 Sep 2026 09:41:43 +0300 Subject: [PATCH 8/9] Settings panel: gray out dependent controls when their parent is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency graying everywhere, consistent across sections (user 2026-09-17 — chose "gray out children" over hiding rows, so the layout stays stable and the required-by relationship stays visible): - Background colors off → Dilation, Blur, Edge alpha, Bitmap resolution dim (opacity-40) + lock (native disabled). It's a COMPUTE switch, so the whole region tune section follows it. - Cluster labels off → Cluster label size dims + locks. Decoupled from Background colors (labels follow their own toggle, by design 2026-09-16): label controls stay live while regions are off. - Depth anchor off → Depth strength + Depth spacing dim + lock. - Base ring radius is radial-only: dims + locks unless Depth anchor is Radial (vertical doesn't use a ring). SettingSlider gains an optional `disabled` prop (row opacity-40 + range input disabled); the two select rows dim + disable inline. Pure UI — no schema or renderer changes. --- components/graph-view.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 3c65f17..5112f98 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -166,6 +166,7 @@ function SettingSlider({ value, display, onChange, + disabled, }: { label: string; min: number; @@ -175,9 +176,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 ( -