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_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
|
||||||
const REGION_LABEL_SIZE = 14;
|
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
|
/** 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 }[] = [];
|
private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = [];
|
||||||
/** Previous FINAL bitmap + its world rect — the fade's "from" state,
|
/** Previous FINAL bitmap + its world rect — the fade's "from" state,
|
||||||
* captured at each rebuild while the live sim runs (see buildRegions). */
|
* captured at each rebuild while the live sim runs (see buildRegions). */
|
||||||
private regionPrevCanvas: HTMLCanvasElement | null = null;
|
/** Velocity-weighted splat opacity (user 2026-09-16): a node's blob
|
||||||
private regionPrevRect: { minX: number; minY: number; w: number; h: number } | null = null;
|
* contribution fades as it moves. Speed = screen px per frame,
|
||||||
/** Offscreen scratch the per-frame linear blend renders into (sized
|
* measured between region rebuilds (position stamps) and EMA-smoothed
|
||||||
* to the current bitmap; GPU blits only — no per-pixel CPU work). */
|
* so the opacity transitions are gradual, never a snap. */
|
||||||
private regionBlendCanvas: HTMLCanvasElement | null = null;
|
private regionPosStamp = new Map<string, { x: number; y: number }>();
|
||||||
/** Fade state: true between a rebuild and the moment the blend has
|
private regionSpeedEMA = new Map<string, number>();
|
||||||
* fully converged on the new bitmap (REGION_EVERY frames later). */
|
private regionStampFrame = 0;
|
||||||
private regionFading = false;
|
/** The data object the stamps were taken against. geoVersion is NOT
|
||||||
private regionFadePhase = 0;
|
* 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 regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
|
||||||
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
|
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
|
||||||
/** Gravity center for the live sim (graph bbox centroid at setData). */
|
/** Gravity center for the live sim (graph bbox centroid at setData). */
|
||||||
@@ -609,15 +635,12 @@ export class GraphCanvasRenderer {
|
|||||||
setRegionTuning(input: Partial<RegionTuning>): void {
|
setRegionTuning(input: Partial<RegionTuning>): void {
|
||||||
this.regionTuning = sanitizeRegionTuning(input);
|
this.regionTuning = sanitizeRegionTuning(input);
|
||||||
this.regionBuiltVersion = -2; // never equals geoVersion → rebuild
|
this.regionBuiltVersion = -2; // never equals geoVersion → rebuild
|
||||||
// Off switch turning off: drop the bitmaps immediately so the
|
// Off switch turning off: drop the bitmap immediately so the
|
||||||
// blit (and any in-flight fade) stops on the next frame — the
|
// blit stops on the next frame — the rebuild itself skips all
|
||||||
// rebuild itself will skip all compute (buildRegions).
|
// compute (buildRegions).
|
||||||
if (!this.regionTuning.enabled) {
|
if (!this.regionTuning.enabled) {
|
||||||
this.regionCanvas = null;
|
this.regionCanvas = null;
|
||||||
this.regionRect = null;
|
this.regionRect = null;
|
||||||
this.regionPrevCanvas = null;
|
|
||||||
this.regionPrevRect = null;
|
|
||||||
this.regionFading = false;
|
|
||||||
}
|
}
|
||||||
this.markDirty();
|
this.markDirty();
|
||||||
}
|
}
|
||||||
@@ -1076,6 +1099,7 @@ export class GraphCanvasRenderer {
|
|||||||
* frames blit the previous bitmap — one sim step of staleness at
|
* frames blit the previous bitmap — one sim step of staleness at
|
||||||
* 120fps is invisible). Positions come straight from data.nodes. */
|
* 120fps is invisible). Positions come straight from data.nodes. */
|
||||||
private buildRegions(): void {
|
private buildRegions(): void {
|
||||||
|
this.regionMotionPending = false;
|
||||||
const data = this.data;
|
const data = this.data;
|
||||||
if (!data || data.clusters.length === 0) {
|
if (!data || data.clusters.length === 0) {
|
||||||
this.regionCanvas = null;
|
this.regionCanvas = null;
|
||||||
@@ -1107,7 +1131,6 @@ export class GraphCanvasRenderer {
|
|||||||
this.regionCanvas = null;
|
this.regionCanvas = null;
|
||||||
this.regionRect = null;
|
this.regionRect = null;
|
||||||
this.regionMeta = [];
|
this.regionMeta = [];
|
||||||
this.regionFading = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1149,9 +1172,6 @@ export class GraphCanvasRenderer {
|
|||||||
if (!tuning.enabled) {
|
if (!tuning.enabled) {
|
||||||
this.regionCanvas = null;
|
this.regionCanvas = null;
|
||||||
this.regionRect = null;
|
this.regionRect = null;
|
||||||
this.regionPrevCanvas = null;
|
|
||||||
this.regionPrevRect = null;
|
|
||||||
this.regionFading = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,31 +1215,6 @@ export class GraphCanvasRenderer {
|
|||||||
rc = document.createElement("canvas");
|
rc = document.createElement("canvas");
|
||||||
this.regionCanvas = rc;
|
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) {
|
if (rc.width !== bw || rc.height !== bh) {
|
||||||
rc.width = bw;
|
rc.width = bw;
|
||||||
rc.height = bh; // also clears
|
rc.height = bh; // also clears
|
||||||
@@ -1233,21 +1228,66 @@ export class GraphCanvasRenderer {
|
|||||||
rctx.lineCap = "round";
|
rctx.lineCap = "round";
|
||||||
rctx.lineWidth = (dilationW * 2) / cell;
|
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
|
// The region IS the dilated skeleton: intra-cluster edges as
|
||||||
// round-cap strokes (Minkowski dilation). NO blanket per-node discs
|
// round-cap strokes (Minkowski dilation). NO blanket per-node discs
|
||||||
// — they double-counted where nodes sit on edges (user-identified
|
// — they double-counted where nodes sit on edges (user-identified
|
||||||
// hot spots, when clusters were edge-based).
|
// hot spots, when clusters were edge-based).
|
||||||
|
//
|
||||||
|
// 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) => {
|
data.clusters.forEach((cluster, ci) => {
|
||||||
const hue = clusterHue(cluster.id);
|
const hue = clusterHue(cluster.id);
|
||||||
// Full-opacity strokes: alpha is applied ONCE at the very end
|
|
||||||
// (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.strokeStyle = `hsla(${hue}, 65%, 60%, 1)`;
|
||||||
rctx.beginPath();
|
|
||||||
// Endpoints actually covered by a stroke's round caps.
|
// Endpoints actually covered by a stroke's round caps.
|
||||||
const touched = new Set<string>();
|
const touched = new Set<string>();
|
||||||
for (const e of data.edges) {
|
for (const e of data.edges) {
|
||||||
@@ -1261,12 +1301,15 @@ export class GraphCanvasRenderer {
|
|||||||
const a = this.nodeById.get(e.from);
|
const a = this.nodeById.get(e.from);
|
||||||
const b = this.nodeById.get(e.to);
|
const b = this.nodeById.get(e.to);
|
||||||
if (!a || !b) continue;
|
if (!a || !b) continue;
|
||||||
|
rctx.globalAlpha = clusterW[ci] ?? 1;
|
||||||
|
rctx.beginPath();
|
||||||
rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell);
|
rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell);
|
||||||
rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell);
|
rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell);
|
||||||
|
rctx.stroke();
|
||||||
touched.add(e.from);
|
touched.add(e.from);
|
||||||
touched.add(e.to);
|
touched.add(e.to);
|
||||||
}
|
}
|
||||||
rctx.stroke();
|
rctx.globalAlpha = 1;
|
||||||
|
|
||||||
// Member discs for the SEMANTIC era (DECIDED 2026-09-13): clusters
|
// Member discs for the SEMANTIC era (DECIDED 2026-09-13): clusters
|
||||||
// are now topics by meaning, and many members (orphans, isolated
|
// are now topics by meaning, and many members (orphans, isolated
|
||||||
@@ -1281,10 +1324,12 @@ export class GraphCanvasRenderer {
|
|||||||
if (touched.has(id) || !isVisible(id)) continue;
|
if (touched.has(id) || !isVisible(id)) continue;
|
||||||
const n = this.nodeById.get(id);
|
const n = this.nodeById.get(id);
|
||||||
if (!n) continue;
|
if (!n) continue;
|
||||||
|
rctx.globalAlpha = clusterW[ci] ?? 1;
|
||||||
rctx.beginPath();
|
rctx.beginPath();
|
||||||
rctx.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2);
|
rctx.arc((n.x - minX) / cell, (n.y - minY) / cell, discR, 0, Math.PI * 2);
|
||||||
rctx.fill();
|
rctx.fill();
|
||||||
}
|
}
|
||||||
|
rctx.globalAlpha = 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// THE blur + alpha (in that order, by design — user-decided
|
// THE blur + alpha (in that order, by design — user-decided
|
||||||
@@ -1356,12 +1401,17 @@ export class GraphCanvasRenderer {
|
|||||||
this.frameCount++;
|
this.frameCount++;
|
||||||
// Regions: rebuild on stale geometry at rest; during the live sim,
|
// Regions: rebuild on stale geometry at rest; during the live sim,
|
||||||
// every REGION_EVERY-th frame (others blit the last bitmap).
|
// 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 (
|
if (
|
||||||
this.regionBuiltVersion !== this.geoVersion &&
|
(this.regionBuiltVersion !== this.geoVersion &&
|
||||||
(!this.simActive || this.frameCount % REGION_EVERY === 0)
|
(!this.simActive || this.frameCount % REGION_EVERY === 0)) ||
|
||||||
|
this.regionMotionPending
|
||||||
) {
|
) {
|
||||||
this.buildRegions();
|
this.buildRegions();
|
||||||
this.regionBuiltVersion = this.geoVersion;
|
this.regionBuiltVersion = this.geoVersion;
|
||||||
|
if (this.regionMotionPending) this.markDirty();
|
||||||
}
|
}
|
||||||
if (this.builtVersion !== this.geoVersion) this.buildGeometry();
|
if (this.builtVersion !== this.geoVersion) this.buildGeometry();
|
||||||
if (!this.nodesPath) return;
|
if (!this.nodesPath) return;
|
||||||
@@ -1382,68 +1432,7 @@ export class GraphCanvasRenderer {
|
|||||||
if (this.regionCanvas && this.regionRect) {
|
if (this.regionCanvas && this.regionRect) {
|
||||||
const { minX, minY, w, h } = this.regionRect;
|
const { minX, minY, w, h } = this.regionRect;
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
if (this.regionFading && this.regionPrevCanvas && this.regionPrevRect) {
|
ctx.drawImage(this.regionCanvas, minX, minY, w, h);
|
||||||
// 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
|
// 3. Edges: shafts + arrowheads from cached paths; width constant on
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
fitTransform,
|
fitTransform,
|
||||||
labelAlpha,
|
labelAlpha,
|
||||||
pickNearest,
|
pickNearest,
|
||||||
|
regionVelocityWeight,
|
||||||
zoomRatioOf,
|
zoomRatioOf,
|
||||||
LABELS_FADE_SPAN,
|
LABELS_FADE_SPAN,
|
||||||
} from "../components/graph-canvas.ts";
|
} from "../components/graph-canvas.ts";
|
||||||
@@ -226,3 +227,29 @@ describe("sanitizePhysicsTuning (physics controls)", () => {
|
|||||||
assert.equal(t.velocityDecay, 0.9);
|
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