perf+fix: calibration handle sizing + snapshot-based drag/resize/nudge

Resize handles live inside the scaled bg-image (~4.8×) / grid (~0.35×) layers,
but --duckmage-cal-scale only cancelled the viewport zoom, so image handles
rendered ~5× too large and grid handles too small. updateCalHandleScale() now
sets the var per layer to 1/(zoom × layerScale) so handles stay a constant
on-screen size at any zoom/scale.

Calibration drag/resize/arrow-nudge was paint-bound at ~2fps: moving or scaling
the grid re-rasterises all 3843 clip-path hex cells every frame (a probe showed
~0.05ms JS work vs ~540ms frame gaps). During an active adjustment we now swap
the cells for a single pre-drawn <canvas> colour snapshot (buildCalibrationSnapshot)
— transforming one canvas is a single composite op — and drop icons/labels/the
SVG outline. Mouse drags coalesce moves to one rAF tick and tear down on mouseup;
arrow-key nudges use a bake-on-quiet settle timer (no mouseup to end on).

Adds a dev-gated drag probe (window.DUCKMAGE_PERF) and dev/cal-handle-size-check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaprettycoolguy@protonmail.com 2026-06-30 07:04:12 -04:00
parent b69131f16d
commit 6882a3b504
3 changed files with 312 additions and 6 deletions

View file

@ -0,0 +1,46 @@
<!doctype html>
<html><head><meta charset="utf-8"/><style>
.viewport { position: absolute; top: 100px; left: 100px; transform-origin: 0 0;
transform: scale(var(--vp, 1)); }
.layer { position: absolute; width: 200px; height: 150px; transform-origin: 0 0;
transform: scale(var(--ls, 1)); background: #357; }
/* Mirror of styles.css handle rule (size + counter-scale). */
.handle { position: absolute; width: 18px; height: 18px; background: red;
transform-origin: center; }
.handle.nw { top: -9px; left: -9px; transform: scale(var(--cal, 1)); }
</style></head>
<body>
<div id="root"></div>
<script>
const root = document.getElementById("root");
// OLD behaviour: cal = 1/zoom (ignores layer scale). NEW: cal = 1/(zoom*layerScale).
function build(zoom, layerScale, mode) {
root.replaceChildren();
const vp = document.createElement("div"); vp.className = "viewport";
vp.style.setProperty("--vp", zoom);
const layer = document.createElement("div"); layer.className = "layer";
layer.style.setProperty("--ls", layerScale);
const cal = mode === "new" ? 1 / (zoom * layerScale) : 1 / zoom;
layer.style.setProperty("--cal", cal);
const handle = document.createElement("div"); handle.className = "handle nw";
layer.appendChild(handle); vp.appendChild(layer); root.appendChild(vp);
void document.body.offsetHeight;
return handle.getBoundingClientRect().width; // on-screen px
}
window.__check = () => {
const cases = [
["bg image zoomed out", 0.3, 4.84],
["bg image zoom 1", 1.0, 4.84],
["bg image zoomed in", 2.5, 4.84],
["grid zoomed out", 0.3, 0.35],
["grid zoom 1", 1.0, 0.35],
["grid zoomed in", 2.5, 0.35],
];
return cases.map(([name, z, ls]) => ({
name, zoom: z, layerScale: ls,
oldPx: +build(z, ls, "old").toFixed(1),
newPx: +build(z, ls, "new").toFixed(1),
}));
};
document.title = "ready";
</script></body></html>

View file

@ -185,6 +185,9 @@ export class HexMapView extends ItemView {
// renderGrid()'s viewportEl.empty() doesn't force an image re-decode each time.
private bgLayerEl: HTMLElement | null = null;
private bgLayerSrc: string | null = null;
// Single-canvas terrain-colour snapshot shown during a calibration drag in
// place of the 3843 hex cells (see buildCalibrationSnapshot).
private calSnapshotEl: HTMLCanvasElement | null = null;
private drawingMode:
| "path"
| "terrain"
@ -1930,6 +1933,122 @@ export class HexMapView extends ItemView {
"--duckmage-cal-scale": String(1 / Math.max(0.01, this.zoom)),
});
}
this.updateCalHandleScale();
}
/**
* Keep the calibration resize handles a constant on-screen size. A handle is
* a child of the element it resizes (the bg image layer or the grid), so its
* rendered size is `handleCss × parentScale × viewportZoom`. The global
* `--duckmage-cal-scale` on the viewport only cancels the zoom; the parent's
* own scale (bg image ~4.8×, grid ~0.35×) is left in which is why image
* handles looked huge and grid handles tiny. We override the var on each
* parent with `1 / (zoom × parentScale)` so the chain cancels to 1.
*/
private updateCalHandleScale(): void {
if (!this.bgCalibrating || !this.viewportEl) return;
const z = Math.max(0.01, this.zoom);
const map = this.getActiveMap();
const bgLayer = this.viewportEl.querySelector<HTMLElement>(
".duckmage-bg-image-layer",
);
if (bgLayer && map.backgroundImage) {
const s = Math.max(0.01, map.backgroundImage.scale);
bgLayer.setCssProps({ "--duckmage-cal-scale": String(1 / (z * s)) });
}
const grid = this.viewportEl.querySelector<HTMLElement>(
".duckmage-hex-map-grid",
);
if (grid) {
const legacy = map.gridDisplayScale ?? 1;
// Average the two axes — handles are uniform squares, so per-axis
// anisotropy is invisible at handle size.
const sx = map.gridDisplayScaleX ?? legacy;
const sy = map.gridDisplayScaleY ?? legacy;
const gs = Math.max(0.01, (sx + sy) / 2);
grid.setCssProps({ "--duckmage-cal-scale": String(1 / (z * gs)) });
}
}
/**
* Coalesce a drag's mousemove to at most once per animation frame (latest
* event wins) so calibration drag/resize don't issue redundant CSS-var
* writes and the style recalcs they trigger several times per painted
* frame. `stop()` flushes the final position and cancels any pending frame;
* call it from mouseup so the release lands exactly under the cursor.
*
* When `window.DUCKMAGE_PERF` is truthy it records, for the drag's lifetime:
* mousemove count, frames run, mean synchronous work() time per frame, and
* the median inter-frame gap logged on stop(). A cheap work() paired with a
* large frame gap means the drag is paint/composite-bound (the scene, not our
* JS) the expected signature when resizing over a big background image.
*/
private coalesceDragMove(
label: string,
work: (ev: MouseEvent) => void,
): { onMove: (ev: MouseEvent) => void; stop: () => void } {
// Swap the 3843 hex cells for a single-canvas colour snapshot for the
// drag's duration: build the snapshot, then add the class that hides the
// real cells. Transforming one canvas per frame is a single composite op vs
// re-rasterising every cell (a translate alone measured ~373ms/frame).
this.buildCalibrationSnapshot();
this.viewportEl?.addClass("duckmage-calibrating-drag");
let latest: MouseEvent | null = null;
let frameId: number | null = null;
const perf = !!(window as unknown as { DUCKMAGE_PERF?: unknown })
.DUCKMAGE_PERF;
let moves = 0,
frames = 0,
workMs = 0,
lastTickAt = 0;
const gaps: number[] = [];
const run = (ev: MouseEvent) => {
if (!perf) {
work(ev);
return;
}
const now = performance.now();
if (lastTickAt) gaps.push(now - lastTickAt);
lastTickAt = now;
work(ev);
workMs += performance.now() - now;
frames++;
};
const tick = () => {
frameId = null;
if (latest) {
run(latest);
latest = null;
}
};
return {
onMove: (ev: MouseEvent) => {
if (perf) moves++;
latest = ev;
if (frameId === null) frameId = window.requestAnimationFrame(tick);
},
stop: () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
if (latest) {
run(latest);
latest = null;
}
this.viewportEl?.removeClass("duckmage-calibrating-drag");
this.removeCalibrationSnapshot();
if (perf) {
const sorted = gaps.slice().sort((a, b) => a - b);
const med = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
console.warn(
`[duckmage perf] ${label}: moves=${moves} frames=${frames} ` +
`workMs/frame=${(workMs / Math.max(1, frames)).toFixed(2)} ` +
`medianFrameGapMs=${med.toFixed(1)}`,
);
}
},
};
}
private bgCalibrating = false;
@ -1941,6 +2060,9 @@ export class HexMapView extends ItemView {
/** Coalesce a stream of arrow-key nudges into a single undo entry. */
private nudgeUndoTimer: number | null = null;
private nudgeUndoBefore: CalibrateSnapshot | null = null;
/** Bake-on-quiet timer that tears down the calibration snapshot after a burst
* of arrow-key nudges goes quiet (arrow keys have no mouseup to end on). */
private calActivityTimer: number | null = null;
/** Snapshot the bg image + grid display transforms for the active map. */
private captureCalibration(map: MapData): CalibrateSnapshot {
const bg = map.backgroundImage;
@ -2105,14 +2227,15 @@ export class HexMapView extends ItemView {
const startOX = bg.offsetX, startOY = bg.offsetY;
const zoom = this.zoom;
const before = this.captureCalibration(map);
const onMove = (ev: MouseEvent) => {
const { onMove, stop } = this.coalesceDragMove("bg-move", (ev) => {
bg.offsetX = startOX + (ev.clientX - startX) / zoom;
bg.offsetY = startOY + (ev.clientY - startY) / zoom;
this.applyBgLayerVars(layer, bg);
};
});
const onUp = () => {
activeDocument.removeEventListener("mousemove", onMove);
activeDocument.removeEventListener("mouseup", onUp);
stop();
this.pushCalibrationUndo(this.activeMapName, before, this.captureCalibration(map));
};
activeDocument.addEventListener("mousemove", onMove);
@ -2138,6 +2261,7 @@ export class HexMapView extends ItemView {
bg.offsetY = ty;
bg.scale = Math.max(0.05, Math.min(20, sx));
this.applyBgLayerVars(layer, bg);
this.updateCalHandleScale();
},
onDragStart: () => { bgResizeBefore = this.captureCalibration(map); },
onDragEnd: () => {
@ -2176,14 +2300,15 @@ export class HexMapView extends ItemView {
const startOY = map.gridDisplayOffsetY ?? 0;
const zoom = this.zoom;
const before = this.captureCalibration(map);
const onMove = (ev: MouseEvent) => {
const { onMove, stop } = this.coalesceDragMove("grid-move", (ev) => {
map.gridDisplayOffsetX = startOX + (ev.clientX - startX) / zoom;
map.gridDisplayOffsetY = startOY + (ev.clientY - startY) / zoom;
applyGridTransform();
};
});
const onUp = () => {
activeDocument.removeEventListener("mousemove", onMove);
activeDocument.removeEventListener("mouseup", onUp);
stop();
this.pushCalibrationUndo(this.activeMapName, before, this.captureCalibration(map));
};
activeDocument.addEventListener("mousemove", onMove);
@ -2213,6 +2338,7 @@ export class HexMapView extends ItemView {
map.gridDisplayScaleY = Math.max(0.1, Math.min(20, sy));
if (Math.abs(sx - sy) < 0.001) map.gridDisplayScale = sx;
applyGridTransform();
this.updateCalHandleScale();
},
onDragStart: () => { gridResizeBefore = this.captureCalibration(map); },
onDragEnd: () => {
@ -2288,7 +2414,7 @@ export class HexMapView extends ItemView {
const ay = h.includes("s") ? 0 : h.includes("n") ? H0 : H0 / 2;
const startX = e.clientX, startY = e.clientY;
const onMove = (ev: MouseEvent) => {
const { onMove, stop } = this.coalesceDragMove(`resize-${h}`, (ev) => {
// Cursor delta in screen pixels → pre-transform pixels (undo viewport zoom)
const dx = (ev.clientX - startX) / viewportZoom;
const dy = (ev.clientY - startY) / viewportZoom;
@ -2313,10 +2439,11 @@ export class HexMapView extends ItemView {
const tyNew = start.ty + ay * (start.sy - syNew);
opts.onResize({ tx: txNew, ty: tyNew, sx: sxNew, sy: syNew });
};
});
const onUp = () => {
activeDocument.removeEventListener("mousemove", onMove);
activeDocument.removeEventListener("mouseup", onUp);
stop();
opts.onDragEnd?.();
};
activeDocument.addEventListener("mousemove", onMove);
@ -2360,6 +2487,9 @@ export class HexMapView extends ItemView {
this.handleCalibrationArrowKey(e);
});
this.renderGrid();
// Handles are created during renderGrid; size them to the current zoom +
// per-layer scale now that they exist.
this.updateCalHandleScale();
this.showCalibrationHelp();
}
@ -2406,6 +2536,9 @@ export class HexMapView extends ItemView {
const grid = this.viewportEl?.querySelector<HTMLElement>(".duckmage-hex-map-grid");
if (grid) this.applyGridLayerVars(grid, map);
}
// Swap in the cheap colour snapshot for the nudge burst (same lever as the
// mouse drag) so held/repeated arrows don't re-rasterise every hex cell.
this.markCalibrationActivity();
if (this.nudgeUndoTimer !== null) window.clearTimeout(this.nudgeUndoTimer);
this.nudgeUndoTimer = window.setTimeout(() => {
const after = this.captureCalibration(map);
@ -2422,6 +2555,15 @@ export class HexMapView extends ItemView {
if (!this.bgCalibrating) return;
this.bgCalibrating = false;
this.bgCalibrationFocus = null;
// Tear down any active snapshot/drag state (renderGrid below rebuilds the
// grid, but the class lives on the persistent viewportEl and the settle
// timer could otherwise fire after exit).
if (this.calActivityTimer !== null) {
window.clearTimeout(this.calActivityTimer);
this.calActivityTimer = null;
}
this.viewportEl?.removeClass("duckmage-calibrating-drag");
this.removeCalibrationSnapshot();
// Flush any pending arrow-nudge undo entry so the final state is recorded
if (this.nudgeUndoTimer !== null) {
window.clearTimeout(this.nudgeUndoTimer);
@ -2558,6 +2700,99 @@ export class HexMapView extends ItemView {
gridContainer.appendChild(svg);
}
/**
* Draw the current terrain colours as filled hexagons onto a single <canvas>
* laid over the grid, then (via the `duckmage-calibrating-drag` CSS class)
* hide the real hex cells. During a calibration drag the grid's transform is
* mutated every frame; transforming this one canvas is a single composite op,
* versus re-rasterising 3843 clip-path cells (~373ms/frame). Synchronous and
* one-shot at drag start (~tens of ms); torn down on release. Geometry mirrors
* renderCalibrationOutlines so the snapshot lines up exactly with the cells.
*/
private buildCalibrationSnapshot(): void {
if (!this.viewportEl || this.calSnapshotEl) return;
const gridContainer = this.viewportEl.querySelector<HTMLElement>(
".duckmage-hex-map-grid",
);
if (!gridContainer) return;
const W = gridContainer.offsetWidth;
const H = gridContainer.offsetHeight;
if (W === 0 || H === 0) return;
const canvas = activeDocument.createElement("canvas");
canvas.width = W;
canvas.height = H;
canvas.addClass("duckmage-calibration-snapshot");
const ctx = canvas.getContext("2d");
if (!ctx) return;
const isFlat = this.plugin.settings.hexOrientation === "flat";
// Read phase only — no DOM writes, so offset reads cost a single layout.
gridContainer
.querySelectorAll<HTMLElement>(".duckmage-hex")
.forEach((hex) => {
const color = hex.style.backgroundColor;
if (!color) return; // empty hex → transparent, nothing to draw
let x = 0,
y = 0;
let cur: HTMLElement | null = hex;
while (cur && cur !== gridContainer) {
x += cur.offsetLeft;
y += cur.offsetTop;
cur = cur.offsetParent as HTMLElement | null;
}
const w = hex.offsetWidth,
h = hex.offsetHeight;
const pts: [number, number][] = isFlat
? [
[w * 0.25, 0], [w * 0.75, 0], [w, h * 0.5],
[w * 0.75, h], [w * 0.25, h], [0, h * 0.5],
]
: [
[w * 0.5, 0], [w, h * 0.25], [w, h * 0.75],
[w * 0.5, h], [0, h * 0.75], [0, h * 0.25],
];
ctx.beginPath();
pts.forEach((p, i) => {
const px = p[0] + x,
py = p[1] + y;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
});
gridContainer.prepend(canvas);
this.calSnapshotEl = canvas;
}
private removeCalibrationSnapshot(): void {
this.calSnapshotEl?.remove();
this.calSnapshotEl = null;
}
/**
* Show the colour snapshot during a burst of arrow-key nudges and tear it
* down once the keys go quiet. Arrow keys have no mouseup to end on, so
* like zoom's bake-on-quiet we (re)arm a short settle timer on each nudge;
* when it finally fires, the real hex cells come back. Without this, every
* nudge (and every key-repeat while held) re-rasterised all 3843 cells.
*/
private markCalibrationActivity(): void {
if (!this.calSnapshotEl) {
this.buildCalibrationSnapshot();
this.viewportEl?.addClass("duckmage-calibrating-drag");
}
if (this.calActivityTimer !== null)
window.clearTimeout(this.calActivityTimer);
this.calActivityTimer = window.setTimeout(() => {
this.calActivityTimer = null;
this.viewportEl?.removeClass("duckmage-calibrating-drag");
this.removeCalibrationSnapshot();
}, 200);
}
private fitGridToView(): void {
const clipEl = this.viewportEl?.parentElement;
const gridEl = this.viewportEl?.querySelector<HTMLElement>(".duckmage-hex-map-grid");

View file

@ -216,6 +216,31 @@
z-index: 10;
}
/* During an active calibration drag/resize, replace the 3843 individually-
painted hex cells with ONE pre-drawn <canvas> "colour snapshot" (built in JS
at drag start see buildCalibrationSnapshot). Moving/scaling 3843 clip-path
cells re-rasterises them every frame (a translate alone measured ~373ms);
moving one canvas is a single composite op. The cells are kept in the layout
with `visibility: hidden` (NOT `display: none`) so the grid keeps its size
the resize handles are positioned against the grid box and would jump to the
origin if it collapsed. Icons/labels/coord-labels and the SVG outline are
redundant with the snapshot and dropped entirely. All restored on release. */
.duckmage-hex-map-viewport.duckmage-calibrating-drag .duckmage-hex {
visibility: hidden;
}
.duckmage-hex-map-viewport.duckmage-calibrating-drag .duckmage-coord-labels-layer,
.duckmage-hex-map-viewport.duckmage-calibrating-drag .duckmage-calibration-outlines-svg {
display: none;
}
.duckmage-calibration-snapshot {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 1;
}
/* Floating commit chip during calibration — single ✓ button */
.duckmage-calibration-help {
position: fixed;