mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
fix: coord labels no longer slip up-left during zoom bake
The bug the user has been chasing for several sessions. Sandbox
repro at dev/coord-slip-sandbox.html, snapshot-coord-slip.mjs
captures the half-baked frame so we can SEE the slip.
Root cause: bakeZoom mutates the viewport font-size mid-function,
which triggers a layout reflow growing every em-based hex
dimension. The path-overlay SVG holds coord labels at pixel
positions computed against the PRE-bake hex sizes, so any paint
frame between the font-size change and the SVG rebuild shows the
labels at where the hexes USED to be — visibly shifted up-left of
the now-larger hexes. updatePathOverlay() further down in bakeZoom
eventually fixes it ("snap back into place"), but the intermediate
slip-then-snap is what the user reported across multiple zoom
sessions.
Fix: tear down the path / faction / region SVGs at the START of
bakeZoom, before the font-size mutation. The CSS rule
`.duckmage-svg-labels-active .duckmage-hex-label { visibility:
hidden }` is keyed off a class on the viewport — that class gets
removed alongside the SVG, so the intermediate paint falls back to
the HTML `.duckmage-hex-label` spans. Those live inside each hex's
own DOM tree, so they always track the hex's layout perfectly
across font-size changes. No slip.
The overlays are then re-rendered later in the function via the
existing updatePathOverlay() / updateFactionOverlay() /
updateRegionOverlay() / updateTokenLayer() calls. Net result: zero
extra rebuild work, just earlier tear-down.
Sandbox confirms:
buggy-mid: SVG labels visibly shifted up-left of small hexes
fixed-mid: HTML labels inside hexes, tracking correctly
351 unit tests / 18 e2e pass.
This commit is contained in:
parent
c82bbb3fdb
commit
0e65565e25
3 changed files with 264 additions and 0 deletions
197
dev/coord-slip-sandbox.html
Normal file
197
dev/coord-slip-sandbox.html
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Coord-slip during bakeZoom — repro</title>
|
||||
<style>
|
||||
:root { --background-secondary: #2a2a2a; }
|
||||
body { background: #1e1e1e; color: #ddd; font-family: system-ui, sans-serif; margin: 0; padding: 8px; }
|
||||
h1 { font-size: 0.95em; margin: 0 0 8px; }
|
||||
.controls { display: flex; gap: 8px; margin: 0 0 8px; }
|
||||
.controls button { background: #3b82f6; color: white; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; }
|
||||
#log { font-family: monospace; font-size: 0.85em; color: #999; padding: 6px; background: #0c111a; border-radius: 3px; margin: 0 0 8px; min-height: 36px; white-space: pre; }
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
width: 700px;
|
||||
height: 480px;
|
||||
background: #0c111a;
|
||||
overflow: hidden;
|
||||
}
|
||||
.grid-wrap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 1em;
|
||||
transform-origin: 0 0;
|
||||
transform: scale(var(--vp-scale, 1));
|
||||
}
|
||||
.grid {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row { display: flex; flex-wrap: nowrap; margin-bottom: calc(var(--hex-size, 2.2em) * -0.5); }
|
||||
.row.odd { margin-left: calc(var(--hex-size, 2.2em) * 1.732 / 2); }
|
||||
.hex {
|
||||
--hex-size: 2.2em;
|
||||
width: calc(var(--hex-size) * 1.732);
|
||||
height: calc(var(--hex-size) * 2);
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
background: rgba(120, 200, 80, 0.5);
|
||||
margin: 0.15em;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; color: #1a1a1a;
|
||||
}
|
||||
.hex-label {
|
||||
pointer-events: none;
|
||||
font-weight: 600;
|
||||
/* Hidden when SVG labels are active (path SVG present) — same rule
|
||||
as the plugin uses. */
|
||||
}
|
||||
.grid-wrap.svg-active .hex-label { visibility: hidden; }
|
||||
.path-svg {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
.svg-coord {
|
||||
fill: #fff;
|
||||
stroke: rgba(0,0,0,0.85);
|
||||
stroke-width: 2;
|
||||
paint-order: stroke;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Coord-slip during bakeZoom — buggy vs fixed order</h1>
|
||||
<div class="controls">
|
||||
<button id="buggy-bake">Bake (buggy: SVG torn down LAST)</button>
|
||||
<button id="fixed-bake">Bake (fixed: SVG torn down FIRST)</button>
|
||||
<button id="reset">Reset</button>
|
||||
</div>
|
||||
<div id="log">click "Bake" then look at the screenshot for the half-baked frame</div>
|
||||
|
||||
<div class="viewport" id="viewport">
|
||||
<div class="grid-wrap svg-active" id="gridWrap">
|
||||
<div class="grid" id="grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ROWS = 4, COLS = 5;
|
||||
let zoom = 2; // start "zoomed in"
|
||||
let fontSize = 16; // pre-bake font-size
|
||||
const grid = document.getElementById("grid");
|
||||
const gridWrap = document.getElementById("gridWrap");
|
||||
const viewport = document.getElementById("viewport");
|
||||
const log = document.getElementById("log");
|
||||
|
||||
function buildGrid() {
|
||||
grid.replaceChildren();
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row" + (r % 2 === 1 ? " odd" : "");
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const hex = document.createElement("div");
|
||||
hex.className = "hex";
|
||||
hex.dataset.x = c; hex.dataset.y = r;
|
||||
const label = document.createElement("span");
|
||||
label.className = "hex-label";
|
||||
label.textContent = `${c},${r}`;
|
||||
hex.appendChild(label);
|
||||
row.appendChild(hex);
|
||||
}
|
||||
grid.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
gridWrap.style.setProperty("--vp-scale", String(zoom));
|
||||
gridWrap.style.fontSize = `${fontSize}px`;
|
||||
}
|
||||
|
||||
function renderPathSvg() {
|
||||
grid.querySelector(".path-svg")?.remove();
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.classList.add("path-svg");
|
||||
svg.setAttribute("width", String(grid.offsetWidth));
|
||||
svg.setAttribute("height", String(grid.offsetHeight));
|
||||
grid.querySelectorAll(".hex").forEach((hex) => {
|
||||
let ox = hex.offsetWidth / 2, oy = hex.offsetHeight / 2;
|
||||
let cur = hex;
|
||||
while (cur && cur !== grid) {
|
||||
ox += cur.offsetLeft; oy += cur.offsetTop;
|
||||
cur = cur.offsetParent;
|
||||
}
|
||||
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
t.setAttribute("x", String(ox));
|
||||
t.setAttribute("y", String(oy + hex.offsetHeight * 0.28));
|
||||
t.setAttribute("text-anchor", "middle");
|
||||
t.setAttribute("class", "svg-coord");
|
||||
t.textContent = `${hex.dataset.x},${hex.dataset.y}`;
|
||||
svg.appendChild(t);
|
||||
});
|
||||
grid.appendChild(svg);
|
||||
gridWrap.classList.add("svg-active");
|
||||
}
|
||||
|
||||
function reset() {
|
||||
zoom = 2; fontSize = 16;
|
||||
applyTransform();
|
||||
buildGrid();
|
||||
renderPathSvg();
|
||||
log.textContent = "reset — zoomed in 2× via CSS transform, SVG labels active";
|
||||
}
|
||||
|
||||
// BUGGY bake: change font-size first, rebuild SVG at the end.
|
||||
document.getElementById("buggy-bake").addEventListener("click", () => {
|
||||
const F = zoom;
|
||||
// 1. font-size mutated (hexes grow in layout)
|
||||
fontSize *= F;
|
||||
gridWrap.style.fontSize = `${fontSize}px`;
|
||||
// 2. transform reset to scale 1
|
||||
zoom = 1;
|
||||
gridWrap.style.setProperty("--vp-scale", "1");
|
||||
// Force a paint here to simulate the worst case
|
||||
void grid.offsetWidth;
|
||||
// 3. SVG rebuilt (now with new sizes)
|
||||
setTimeout(() => {
|
||||
renderPathSvg();
|
||||
log.textContent = "buggy bake done. Between font-size change and SVG rebuild, OLD SVG labels were at OLD positions → slip visible.";
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// FIXED bake: tear down SVG FIRST so HTML labels take over during the transition.
|
||||
document.getElementById("fixed-bake").addEventListener("click", () => {
|
||||
const F = zoom;
|
||||
// 1. TEAR DOWN old SVG + remove the class so HTML labels become visible.
|
||||
grid.querySelector(".path-svg")?.remove();
|
||||
gridWrap.classList.remove("svg-active");
|
||||
// 2. font-size mutated (hexes grow). HTML labels are inside hexes — they
|
||||
// track perfectly. User sees correctly-placed HTML labels during the
|
||||
// in-between frame.
|
||||
fontSize *= F;
|
||||
gridWrap.style.fontSize = `${fontSize}px`;
|
||||
// 3. transform reset to scale 1.
|
||||
zoom = 1;
|
||||
gridWrap.style.setProperty("--vp-scale", "1");
|
||||
void grid.offsetWidth;
|
||||
// 4. SVG rebuilt with new positions.
|
||||
setTimeout(() => {
|
||||
renderPathSvg();
|
||||
log.textContent = "fixed bake done. SVG was torn down before font-size change → HTML labels took over for the transition → no slip.";
|
||||
}, 300);
|
||||
});
|
||||
|
||||
document.getElementById("reset").addEventListener("click", reset);
|
||||
reset();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
46
dev/snapshot-coord-slip.mjs
Normal file
46
dev/snapshot-coord-slip.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env node
|
||||
/** Capture the half-baked frame between font-size change and SVG rebuild
|
||||
* in both the buggy order (SVG torn down last) and the fixed order
|
||||
* (SVG torn down first). The 300ms setTimeout in the sandbox is the
|
||||
* paint window — snapshot DURING that window, while the SVG is in its
|
||||
* stale-vs-absent state, to see which one shows misplaced labels. */
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import fs from "node:fs/promises";
|
||||
import { withBrowser } from "/mnt/c/Users/markr/work/tools/chromium-driver/lib/browser.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sandbox = path.join(here, "coord-slip-sandbox.html");
|
||||
const outDir = path.join(here, "out");
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
await withBrowser(
|
||||
async ({ page }) => {
|
||||
await page.goto(pathToFileURL(sandbox).href, { waitUntil: "networkidle0" });
|
||||
const snap = async (name) => {
|
||||
const el = await page.$(".viewport");
|
||||
await el.screenshot({ path: path.join(outDir, `${name}.png`) });
|
||||
console.log(path.join(outDir, `${name}.png`));
|
||||
};
|
||||
|
||||
await snap("slip-1-baseline");
|
||||
|
||||
await page.click("#reset");
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await page.click("#buggy-bake");
|
||||
// Capture mid-transition — during the 300ms SVG-rebuild delay
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await snap("slip-2-buggy-mid");
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await snap("slip-3-buggy-final");
|
||||
|
||||
await page.click("#reset");
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await page.click("#fixed-bake");
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await snap("slip-4-fixed-mid");
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await snap("slip-5-fixed-final");
|
||||
},
|
||||
{ viewport: { width: 900, height: 600 } },
|
||||
);
|
||||
|
|
@ -2533,6 +2533,27 @@ export class HexMapView extends ItemView {
|
|||
if (this.bgCalibrating) return;
|
||||
const F = this.zoom;
|
||||
const currentFs = parseFloat(getComputedStyle(this.viewportEl).fontSize);
|
||||
|
||||
// Tear down the path SVG FIRST, before the font-size mutation below.
|
||||
// The path SVG holds coord labels at pixel positions computed against
|
||||
// the pre-bake hex sizes. After font-size grows the hexes, those
|
||||
// pixel positions point to where the hexes USED to be, so any paint
|
||||
// frame between the font-size change and the SVG rebuild shows
|
||||
// labels visibly shifted up-left from the hexes. By removing the
|
||||
// SVG (and its `duckmage-svg-labels-active` class) here, the
|
||||
// intermediate frame falls back to the HTML `.duckmage-hex-label`
|
||||
// spans — those live inside the hex DOM and track the hex layout
|
||||
// perfectly across font-size changes. The new path SVG is rebuilt
|
||||
// via `updatePathOverlay()` further down. Sandbox repro of the
|
||||
// pre-fix slip: dev/coord-slip-sandbox.html.
|
||||
this.viewportEl.querySelector("svg.duckmage-path-svg")?.remove();
|
||||
this.viewportEl.removeClass("duckmage-svg-labels-active");
|
||||
// Same for the faction/region overlays — they also hold pre-bake
|
||||
// pixel positions and would visibly mis-align during the transition.
|
||||
this.viewportEl.querySelector("svg.duckmage-faction-svg")?.remove();
|
||||
this.viewportEl.querySelector(".duckmage-faction-legend")?.remove();
|
||||
this.viewportEl.querySelector("svg.duckmage-region-svg")?.remove();
|
||||
|
||||
this.viewportEl.style.fontSize = `${currentFs * F}px`;
|
||||
this.zoom = 1;
|
||||
this.applyTransform();
|
||||
|
|
|
|||
Loading…
Reference in a new issue