Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ rust_parser/target/
# Assembled static site (tools/build_site.py output)
dist/
LAUNCH.md
.wrangler
.wrangler
# Screenshot-harness output (tools/ui_shots.py)
ui_shots/
19 changes: 18 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ same `dist/` still serves in the browser via `serve_site.py`.
| Path | Contents |
| --- | --- |
| `map/static/map/` | the web frontend (vanilla JS + Leaflet + WebGL layer, `worker.js`/`save_client.js` host the WASM parser) |
| `map/static/map/map.css` | design tokens (colour/type/spacing/radius scales), the app-shell grid, and per-feature styling |
| `map/static/map/ui.css` + `ui.js` | the shared UI primitives every feature builds from: buttons, fields, dialogs, list rows, bars, toggles, and the Escape-layer stack. New chrome should reuse these rather than restyle its own |
| `map/static/map/icons/` | *(generated)* item/building icon PNGs |
| `rust_parser/core/` | `sav_core`: the save parser + map-payload builder (pure Rust, embeds the game-data tables) |
| `rust_parser/wasm/` | `sav_wasm`: the wasm-bindgen boundary the worker loads |
Expand All @@ -90,7 +92,7 @@ same `dist/` still serves in the browser via `serve_site.py`.
| `game_data/generated/docs/` | *(generated)* item/building/recipe/schematic/category/phase JSONs |
| `game_data/generated/world/` | *(generated)* level-export tables: resource nodes, slugs, somersloops, mercer spheres, crash sites, dropped items, creature spawners, caves, world bounds |
| `game_data/generated/` | *(generated)* `map_highres.png` + its tile pyramid |
| `tools/` | `build_site.py` / `serve_site.py` / `benchmark.py` / `fetch_test_saves.py` / `e2e_editor.py` / `release.py` |
| `tools/` | `build_site.py` / `serve_site.py` / `benchmark.py` / `fetch_test_saves.py` / `e2e_editor.py` / `ui_shots.py` / `ui_behaviour.py` / `release.py` |
| `dist/` | *(generated)* the assembled static site |

Everything marked *(generated)* is git-ignored and produced by the steps
Expand All @@ -112,6 +114,21 @@ regression, needs `pip install playwright`) and the CI workflow in
`.github/workflows/ci.yml`, which runs the Rust suite and the wasm build on
every push to `main` and on pull requests.

The frontend has two browser-driven guards of its own — the chrome has no unit
tests, so these are what make a CSS or layout change verifiable:

```bash
py tools/ui_shots.py --serve --out ui_shots/before # record, then make changes
py tools/ui_shots.py --serve --out ui_shots/after --baseline ui_shots/before
py tools/ui_behaviour.py --serve # dialogs, Escape layers, docks
```

`ui_shots.py` captures 17 UI states at three viewport widths and diffs them
pixel-wise against a previous run; `ui_behaviour.py` asserts the things a
screenshot cannot see (that dialogs are real modals, that the tooltip still
paints above one, that opening a category does not resize the map). Both run
against `dist/`, so build or copy the changed files there first.

Note: `sav_core` embeds `game_data/generated/{docs,world}/*.json` and the icon
manifest at compile time, and **nothing generated is committed**, so the Rust
crates do not build until you have either run `game_data/extract_all.py` or
Expand Down
94 changes: 94 additions & 0 deletions docs/dock-map-anchoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Keeping the map still when a dock opens — two failed attempts

## The problem

The docks are columns of the app-shell grid, so opening one changes the map's
box. Leaflet's `invalidateSize()` preserves the map's **centre**, which is the
wrong invariant when the box changes from one side: opening the ~280px layers
dock moves the map's left edge right by 280 *and* shrinks its width by 280, and
holding the centre still splits the difference. Measured drift of a fixed world
point, in viewport pixels:

| Action | Drift |
| --- | --- |
| Show/hide the layers dock | ±141 px |
| Open/close a tool dock | ±161 px |

This is real and worth fixing. Two attempts were made and **both were
reverted**; `panels.js` is back to a plain `invalidateSize()` in a
rAF-coalesced ResizeObserver.

## Attempt 1 — compensate inside the ResizeObserver

Pin the world point at viewport (0, 0), call
`invalidateSize({pan: false, animate: false})`, measure where that point ended
up, `panBy` the difference. Done synchronously in the observer, which runs
after layout and before paint.

Geometrically correct — before/after drift went to 0 px. But the map still
visibly moved, because the object canvases are positioned relative to Leaflet's
map pane and repaint on the *next* frame via `_requestReset`'s rAF coalescing.
Re-anchoring the pane in one frame while the canvases catch up in the next
leaves a frame where every object is drawn offset from the tiles beneath it.

## Attempt 2 — make the whole transition atomic

Route every chrome mutation through a `withMapAnchored(mutate)` helper so the
resize, the compensation and a forced synchronous canvas repaint
(`MapApp.layer.resetNow()`, added to `map.js`) all happen in one task, and add
an idempotence guard so the helper and the ResizeObserver could not both
correct the same resize.

Reported as **worse than either the bug or attempt 1**. Two likely reasons,
neither of which the tests could see:

1. `resetNow()` forces a full synchronous redraw of every bucket in the click
handler. On a large save, with hardware acceleration off, that is a
main-thread stall where there used to be a coalesced repaint one frame
later. Trading a visual glitch for a freeze is a bad trade.
2. The drag path called it on every `pointermove`, i.e. a full redraw per
pointer event.

## Why the tests said it was fine

`tools/ui_behaviour.py` sampled `requestAnimationFrame` geometry in **headless**
Chrome against a small save. That measures whether the numbers line up. It does
not measure:

- how long the main thread is blocked (the actual regression in attempt 2),
- what is really painted, as opposed to what the DOM says between frames,
- software rendering, which is how the app runs on at least one real machine.

The lesson is not "add more geometry assertions". It is that this specific
problem cannot be validated headlessly: it needs a headed browser, a large
save, and a measurement of frame timing rather than element positions.

## Resolution — don't resize the map at all

There was no third attempt at compensating. The docks were changed to **overlay
the map** instead of taking grid columns: they are `position: fixed` against the
window's edges, and `#map` fills everything below the app bar at all times.

The map's box is now a function of the window alone, so a dock opening, closing
or being dragged never resizes it, Leaflet is never asked to re-fit it, and
there is nothing to re-centre. The bug is gone by construction rather than by
correction — no compensation code, no forced repaint, no extra work per toggle.

The docks still read as attached: flush to the edge, square, full height, one
border, opaque. What changed is only what is *behind* them.

Costs, accepted:

- A sliver of the world sits behind each dock. Panning reaches it, and closing
the docks reveals it with no movement at all.
- `#mapOverlays` (the layer holding the hint bars, the selection bar, the
active-filter banner) insets by `--dock-left-inset` / `--dock-right-inset` so
those still centre on the *visible* map rather than the full one. Those two
custom properties, and `body.has-rail` (set by altitude.js), exist only
because the layout no longer derives the dock widths from content.
- Leaflet's own controls live inside `#map`, so `.leaflet-left` / `.leaflet-right`
inset by the same values to stay clear of a dock.

`tools/ui_behaviour.py` now asserts the CAUSE rather than the symptom: across
five actions plus a width drag, sampled every animation frame, the map's
bounding box must not change and a pinned world point must not move.
5 changes: 5 additions & 0 deletions map/static/map/altitude.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ var Altitude = {};
MapApp.setAltitudeRange(isFullRange ? -Infinity : initMin, isFullRange ? Infinity : initMax);

panel.style.display = "flex";
// The docks overlay the map, so the layout cannot derive their width from
// content any more -- this class is how the overlay layer and the Leaflet
// controls know the rail is taking room on the right (see map.css).
document.body.classList.add("has-rail");
layoutSliders();
};

Expand All @@ -267,6 +271,7 @@ var Altitude = {};
// start, unlike the save-to-save reloads build() preserves it across.
Altitude.clear = function() {
panel.style.display = "none";
document.body.classList.remove("has-rail");
savedRange = null;
MapApp.setAltitudeRange(-Infinity, Infinity);
};
Expand Down
9 changes: 5 additions & 4 deletions map/static/map/contextmenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,12 @@ var ContextMenu = {};
hide();
}
});
document.addEventListener("keydown", function(e) {
if (e.key === "Escape" && !e.defaultPrevented && menu.style.display !== "none") {
hide();
e.preventDefault(); // One layer per press -- see finditem.js.
UI.onEscape(UI.LAYER.menu, function() {
if (menu.style.display === "none") {
return false;
}
hide();
return true;
});
window.addEventListener("blur", hide);
var mapContainer = document.getElementById("map");
Expand Down
11 changes: 7 additions & 4 deletions map/static/map/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@
// top progress bar above was easy to miss on big operations, leaving no
// clear sign that anything was happening. Shown after a short delay so
// instant edits don't flash it.
var busyOverlay = document.getElementById("busyOverlay");
// A modal <dialog> rather than a plain overlay: the browser then makes the
// page genuinely inert while the worker crunches, and top-layer order (by
// promotion time) puts this above any dialog that was already open.
var busyDialog = UI.dialog("busyDialog");
var busyLabel = document.getElementById("busyLabel");
var busyFill = document.getElementById("busyFill");
var busyPhase = document.getElementById("busyPhase");
Expand All @@ -45,10 +48,10 @@
busyLabel.textContent = label || "Working…";
busyPhase.textContent = "";
busyFill.style.width = "0%";
if (busyTimer === null && busyOverlay.style.display === "none") {
if (busyTimer === null && !busyDialog.isOpen()) {
busyTimer = setTimeout(function() {
busyTimer = null;
busyOverlay.style.display = "flex";
busyDialog.open();
}, BUSY_SHOW_DELAY_MS);
}
}
Expand All @@ -63,7 +66,7 @@
clearTimeout(busyTimer);
busyTimer = null;
}
busyOverlay.style.display = "none";
busyDialog.close();
}

// Load panel: always-on drop zone + hidden file input (the click target).
Expand Down
47 changes: 20 additions & 27 deletions map/static/map/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ var EditorTool = (function() {
// container-positioned div: the pane transform carries it through pans AND
// the wheel-zoom CSS animation, so it never desyncs mid-animation.
var ghostRect = null;
var offsetOverlay, offsetDx, offsetDy, offsetDz, offsetRot, offsetApply, offsetCancel;
var offsetDialog, offsetDx, offsetDy, offsetDz, offsetRot, offsetApply, offsetCancel;
var pastePanel, pastePanelTitle, pastePosOriginal, pastePosCustom;
var pasteX, pasteY, pasteZ, pasteDx, pasteDy, pasteDz, pasteRot, pasteResult;
var pastePanelApplyBtn, pastePanelCancelBtn;
Expand Down Expand Up @@ -924,11 +924,11 @@ var EditorTool = (function() {
pasteRot.value = "0";
setPasteXYFields(p.anchorWorld);
refreshPasteResult();
pastePanel.style.display = "block";
Panels.openTool(pastePanel); // Into the right tool dock -- see panels.js.
}

function closePastePanel() {
pastePanel.style.display = "none";
Panels.closeTool(pastePanel);
}

// Typing X/Y switches to a custom position; the radio switch back to
Expand Down Expand Up @@ -1003,13 +1003,12 @@ var EditorTool = (function() {
offsetDy.value = "0";
offsetDz.value = "0";
offsetRot.value = "0";
offsetOverlay.style.display = "flex";
offsetDialog.open();
offsetDx.focus();
}

function closeOffsetDialog() {
offsetOverlay.style.display = "none";
offsetTargets = null;
offsetDialog.close(); // onClose (bound in init) drops offsetTargets.
}

function applyOffsetDialog() {
Expand Down Expand Up @@ -1087,7 +1086,7 @@ var EditorTool = (function() {
undoBtn = document.getElementById("editorUndoBtn");
redoBtn = document.getElementById("editorRedoBtn");
hintBar = document.getElementById("editorHint");
offsetOverlay = document.getElementById("offsetDialogOverlay");
offsetDialog = UI.dialog("offsetDialog");
offsetDx = document.getElementById("offsetDx");
offsetDy = document.getElementById("offsetDy");
offsetDz = document.getElementById("offsetDz");
Expand Down Expand Up @@ -1131,33 +1130,27 @@ var EditorTool = (function() {
e.preventDefault();
}
});
offsetOverlay.addEventListener("click", function(e) {
if (e.target === offsetOverlay) {
closeOffsetDialog();
}
});
offsetOverlay.addEventListener("keydown", function(e) {
// The <dialog> handles Escape, the backdrop click and the X itself;
// this only adds Enter-to-apply and the state teardown.
offsetDialog.el.addEventListener("keydown", function(e) {
if (e.key === "Enter") {
applyOffsetDialog();
}
});
offsetDialog.onClose(function() { offsetTargets = null; });

// Escape while a placement ghost is up cancels the placement. The offset
// dialog is a real <dialog>, so its own Escape is the browser's job.
UI.onEscape(UI.LAYER.placement, function() {
if (placement === null) {
return false;
}
cancelPlacement();
return true;
});

document.addEventListener("keydown", function(e) {
var inInput = e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA");
if (e.key === "Escape") {
// Peel one layer per press (see finditem.js): only claim the event
// if a placement or the offset dialog was actually open.
if (e.defaultPrevented) {
return;
}
var acted = placement !== null || offsetOverlay.style.display !== "none";
cancelPlacement();
closeOffsetDialog();
if (acted) {
e.preventDefault();
}
return;
}
if (placement && !inInput && (e.key === "r" || e.key === "R")) {
placement.rotSteps = (placement.rotSteps + 1) % 4;
if (placement.mode !== "move") {
Expand Down
Loading
Loading