Velocity-weighted region opacity replaces the (sub-perceptual) frame fade
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.
This commit is contained in:
+106
-117
@@ -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<string, { x: number; y: number }>();
|
||||
private regionSpeedEMA = new Map<string, number>();
|
||||
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<RegionTuning>): 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<number>(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<string>();
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user