mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
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:
parent
b69131f16d
commit
6882a3b504
3 changed files with 312 additions and 6 deletions
46
dev/cal-handle-size-check.html
Normal file
46
dev/cal-handle-size-check.html
Normal 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>
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
25
styles.css
25
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue