From 0e65565e25e1abb6fa7a2d30f6b205034dffcd46 Mon Sep 17 00:00:00 2001 From: "isaprettycoolguy@protonmail.com" <6576516+nyxsys@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:09:12 -0400 Subject: [PATCH] fix: coord labels no longer slip up-left during zoom bake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dev/coord-slip-sandbox.html | 197 ++++++++++++++++++++++++++++++++++++ dev/snapshot-coord-slip.mjs | 46 +++++++++ src/hex-map/HexMapView.ts | 21 ++++ 3 files changed, 264 insertions(+) create mode 100644 dev/coord-slip-sandbox.html create mode 100644 dev/snapshot-coord-slip.mjs diff --git a/dev/coord-slip-sandbox.html b/dev/coord-slip-sandbox.html new file mode 100644 index 0000000..cbb6901 --- /dev/null +++ b/dev/coord-slip-sandbox.html @@ -0,0 +1,197 @@ + + + + +Coord-slip during bakeZoom — repro + + + +

Coord-slip during bakeZoom — buggy vs fixed order

+
+ + + +
+
click "Bake" then look at the screenshot for the half-baked frame
+ +
+
+
+
+
+ + + + diff --git a/dev/snapshot-coord-slip.mjs b/dev/snapshot-coord-slip.mjs new file mode 100644 index 0000000..700cd44 --- /dev/null +++ b/dev/snapshot-coord-slip.mjs @@ -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 } }, +); diff --git a/src/hex-map/HexMapView.ts b/src/hex-map/HexMapView.ts index 09f18d8..25ec5fa 100644 --- a/src/hex-map/HexMapView.ts +++ b/src/hex-map/HexMapView.ts @@ -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();