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); + } + }); +});