Skip to content

feat(builder): B2 — preset demo maps + in-browser previews (#899) - #987

Merged
timohueser merged 11 commits into
developfrom
feat/bakery-b2-899
Jul 29, 2026
Merged

feat(builder): B2 — preset demo maps + in-browser previews (#899)#987
timohueser merged 11 commits into
developfrom
feat/bakery-b2-899

Conversation

@timohueser

@timohueser timohueser commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #899. Part of #894 (phase B — the bakery).

With the style editor locked to desktop-only, presets are the only style content on the hosted site. They now sell themselves: each card renders that preset's own demo map, through the device's own render path, at the panel's own 240×320.

What's here

One demo map per preset, one box for all of them. builder/bake-previews.sh iterates builder/presets/*.json and packs each over the same Geofabrik extract at the same bbox — a 1.2 × 1.6 km crop of Meiringen in the Haslital (valley town, the Aare and the Alpbach, the Brünig rail line, the pass road, farmland, forest on the flanks), shaped to the panel's 3:4 portrait so a 240×320 frame fits it at ≈4.9 m/px. The issue is explicit that this must not be a contest of who got the prettier valley, so the config file is the only variable in the row. Adding a preset needs no edit to the script and no code change — one run and it has a demo map, an index entry and a card.

Two provenance decisions worth reviewing, both pinned in the script header:

  • The crop handed to the packer is the framed box padded ~600 m. Not slack — a correctness fix. --bbox keeps ways with a node inside the box and completes them, but drops a landcover polygon whose vertices all lie outside it, and this valley's forest and meadow polygons are exactly that shape. Cropped tight, the preview loses ground cover the device would draw at the same spot, which is the one thing a preview may not do. The pad costs ~1.7× the bytes.
  • The camera frames the pinned box, never a map's own header bbox. The packer's completed ways widen the header by a different amount per preset (a preset that keeps more features completes more ways), so fitting the header would hand each card a slightly different view — reintroducing exactly the unfairness the shared bbox removes.

Committed vs built at site-build time — committed, and regenerated by the script. Repo precedent is apps/obc-sim/assets/repack.sh, and the arithmetic agrees: baking needs libGEOS, a 600 MB extract download and minutes of CPU, none of which belongs in a Pages workflow, for bytes that change only when a preset does. The cost is 1.4 MB in the repo (minimal 116 KB, high-detail 466 KB, default 807 KB) — the low end of the ~1–4 MB per preset the issue anticipated, because the crop is small and the framed scale is the panel's own. .gitignore gets a negation next to the existing ones, with the reason.

apps/obc-web-preview — a new wasm-pack library over obc-reader + obc-render: bytes in, device-resolution RGBA frames out, plus a clamped camera (pan, zoom, reset). The same code the nRF54L runs, through the same RGB565 → RGB222 → RGB888 quantization, so a preview cannot flatter a preset with shades the panel cannot make. Deliberately not a host: no App, no screens, no replay, no planner — which is why it is 125 KB raw / 60 KB gzipped, a hair over the conversion bridge. Nine unit tests cover the framing, the clamps, the dirty flag and the two ways a deploy goes wrong; the default preset's committed demo map is the fixture, so a missing or unbaked map fails cargo test rather than a visitor's page.

The frontend. Every card's picture is its main hit target — a button around the preview on each unselected card, and nothing around the selected one, because that one is live and a drag inside a button is a click. PresetPreview.svelte renders it; lib/preview/{bridge,demoMaps,copy}.ts are the loader, the index reader and the copy. Cards appear in both the catalog tier's PresetStep and the build tier's PresetCards — a preset is a preset. The catalog's optional preset.preview (OBCC §2, B1's to publish) stays honoured as a fallback for a preset with no demo map; the live render wins where there is one, because it cannot go stale when a preset is restyled.

Copy is site-side in lib/preview/copy.ts — one plain line per preset on what it is for, beside the config's own line on what it draws. No packer-schema change, so no ripple into obc-pack schema and the schema_* pinning tests. A preset with no line falls back to its description, so a new card always says something.

Acceptance criteria

From #899 How
A real rendering from that preset's own config, not a mockup obc-web-preview renders the committed .obcm the packer produced from builder/presets/<id>.json, through obc-render, at 240×320. Nothing is drawn by hand and no screenshot is stored.
A new preset gets a demo map and a preview from a single run bake-previews.sh globs the preset directory and writes <id>.obcm + an index entry; the card reads the index by id. demoMaps.test.ts fails if the index and builder/presets/ disagree, or if a map was baked from an older _meta.version.
Small enough not to dominate first paint Module and maps are behind dynamic import()s inside an IntersectionObserver callback. lib/preview/bundle.test.ts asserts against the chunks Rollup actually emits — for all three hosts — that none of it is in any entry chunk, and that the copy still is.
At least one interactive The selected card is drag-to-pan, double-click-to-reset and keyboard-navigable (arrows / + - / 0); the wheel zooms only once the card holds focus or ctrl/meta is down, so a card halfway down a page never swallows the page's scroll. The others are static renders: drag-to-pan on every card in a grid fights page scrolling on a phone for no benefit. Verified in the browser — dragging the selected card repaints it and leaves the other two untouched.

CI

The wasm-convert job becomes wasm-bridges and builds both modules, each gated by its own size budget (wasm_size_guard.py --module convert|preview); web and desktop-frontend download both artifacts. deploy-site.yml gains the second wasm-pack invocation too — it built one bridge and then ran npm run build:web, which imports the bindings of both, so the site build would have failed on the first push after this rather than shipping a builder without previews. npm run build:wasm builds both (:convert / :preview build one). Those workflow changes are why this branch was pushed via a token URL.

Verified

  • cargo test --workspace (all green), cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all --check
  • builder/app: npm run check (0 errors, 0 warnings), npm test (535 tests, 49 files), npm run build:all
  • python3 firmware/tools/wasm_size_guard.py --module preview → 124,857 B raw / 59,556 B gzipped, 88 % of budget
  • python3 docs/build_docs.py --check-links
  • The site run locally against the web host: all three previews render, they are visibly different maps of the same ground, and only the selected card responds to a drag.

Docs

Doc-relevant, and landed as a separate docs: commit: the builder page claimed presets are configs and nothing more, and that the GPX converter is the one thing the firmware runs in the tab. Both moved. Updated packer-routing.md (a new "a preset shows what it draws" bullet plus the client-side-wasm bullet widened to both bridges), architecture.md (the parenthetical listing the non-host wasm crates now lists two), and index.md (a stack-table row).

Follow-up

  • Fold the demo bake into obc-bake's runner. bake-previews.sh is deliberately standalone here: it was written while B1 (B1 — The bakery: curated region list, matrix bake, publish #898) was in flight, and this PR touches none of that territory. B1 — The bakery: curated region list, matrix bake, publish #898 has since merged, so the follow-up is now actionable — the demo maps are three more (region, preset) pairs at a fixed tiny bbox and should ride the same runner, and previews.json could then be dropped in favour of the catalog's preset.preview, which the card already honours as a fallback. (Rebased onto the merged develop; obc-pack catalog passes _meta.preview straight through, and the real presets do not set it, so the live render is what a card shows today.)

Deliberately not here

  • No pre-rendered PNG stills. The issue allows them as a fallback; they were not needed once the maps came in small enough to render live, and every card being a live render means no second pipeline to keep in step with the presets.
  • No packer-config change for the copy or the preview, so obc-pack schema and its pinning tests are untouched.
  • minimal's card is sparse, because at 4.9 m/px minimal genuinely draws major roads, rail and water and nothing else. That is the comparison working, not a bug — and it is the sentence its copy makes.

🤖 Generated with Claude Code

timohueser and others added 7 commits July 29, 2026 18:15
`RgbaFrame` — the in-memory RGBA8888 `DrawTarget` a wasm host blits to a
`<canvas>` — lived inside `obc-web-demo`. B2 (#899) adds a second browser host
that needs exactly the same buffer with exactly the same clipping and
alpha-opacity invariants, so it moves next to the other shared host glue rather
than being copied.

No behaviour change: the file is unmodified apart from its header, and its own
unit test moves with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hosted tier has no style editor, so presets are the only styling a visitor
ever sees. They now show what they draw.

**One demo map per preset, one box for all of them.** `builder/bake-previews.sh`
iterates `builder/presets/*.json` and packs each one over the *same* Geofabrik
extract at the *same* bounding box — a 1.2 × 1.6 km crop of Meiringen in the
Haslital, shaped to the panel's 3:4 portrait so a 240×320 frame fits it at
≈4.9 m/px. A preset that got a prettier valley would win the comparison without
being a better style, so the config file is the only variable. Adding a preset
needs no edit to the script and no code change: one run and it has a card.

The maps are **committed artifacts** (~120–810 KB each, 1.4 MB total),
regenerated by the script — the same call the simulator fixtures make. Baking at
site-build time would put libGEOS, a 600 MB extract download and minutes of CPU
into a static-site deploy, for bytes that change only when a preset does.

The crop handed to the packer is the framed box **padded ~600 m**. That is a
correctness fix, not slack: `--bbox` completes ways that leave the box but drops
a landcover polygon whose vertices all lie outside it, and the valley's forest
and meadow are exactly that shape — crop tight and the preview loses ground
cover the device would draw.

**The renderer runs in the tab.** `apps/obc-web-preview` is a new wasm-pack
library over `obc-reader` + `obc-render` — the same code the nRF54L runs, at the
panel's own resolution, through the same RGB565 → RGB222 quantization, so the
preview cannot flatter a preset with colours the display cannot make. It is a
map layer and a camera, not an app: no screens, no replay, no planner, which is
why it is 125 KB raw / 60 KB gzipped. The camera frames the box the bake pinned
rather than a map's header bbox — the packer widens that by a different amount
per preset, which would hand each card its own view.

**Nothing loads until it is seen.** `PresetPreview.svelte` reaches the module and
the map through dynamic imports inside an IntersectionObserver callback;
`lib/preview/bundle.test.ts` asserts against the chunks Rollup emits that none of
it is in any tier's entry chunk, and that the copy still is. The selected card is
draggable, zoomable and keyboard-navigable (arrows / +- / 0); the rest are
pictures. Cards live in both the catalog tier's step and the build tier's, since
a preset is a preset.

**Copy** lives site-side in `lib/preview/copy.ts`: one plain line per preset on
what it is *for*, next to the config's own line on what it *draws*. Site copy has
no business going through `obc-pack schema` and its pinning tests. A preset with
no line falls back to its description, so a new preset's card still says
something.

CI: the `wasm-convert` job becomes `wasm-bridges` and builds both modules with a
size budget each (`wasm_size_guard.py --module`); `web` and `desktop-frontend`
take both artifacts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The site's builder page said presets are complete packer configs and that the
GPX converter is the one thing the firmware runs in the tab. Both moved with
#899, and the second more than the first: the renderer compiles to wasm the same
way, which is what makes a preset card a real rendering rather than a screenshot
someone has to remember to retake.

- `packer-routing.md` — a new "a preset shows what it draws" bullet (the one
  extract, the one box, and why the camera frames it rather than a map's own
  header bounds), and the client-side-wasm bullet widened from routes to both
  bridges.
- `architecture.md` — the parenthetical listing the wasm crates that are
  deliberately *not* hosts now has two of them.
- `index.md` — a stack-table row for `obc-web-preview`.

`python3 docs/build_docs.py --check-links` passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lickable

Two things the first pass got wrong.

`firmware/tools/check_dependencies.py` requires every workspace package to sit
in exactly one dependency group; the new crate was unclassified, which failed
the `test` job. It belongs in `host` with the other two wasm shims — it is
reachable from no device image.

And the picture is most of a preset card, so a card you can only choose by
clicking its caption reads as broken. Every non-selected preview is now a button;
the selected one still is not, because it is live and a drag inside a button is a
click. Switching selection re-creates both cards, so the fetched demo maps are
memoized for the life of the page rather than re-downloaded to redraw something
the visitor was just looking at.

`data-phase` on the preview root, for reading the loading state out of the DOM:
"waiting to be scrolled into view" and "loading" draw the same skeleton, and a
background tab legitimately stays on the first one forever (IntersectionObserver
does not fire while a tab is hidden — which is the lazy path working).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pointer reports up to 120 moves a second and one render of a dense preset is
tens of milliseconds, so painting per event queued renders behind a drag that had
already moved on. The camera still moves per event — only the drawing is
coalesced through `requestAnimationFrame`, so nothing is dropped, it is drawn
once. The pending frame is cancelled on unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Pages deploy built one wasm bridge and then ran `npm run build:web`, which
imports the bindings of both — so the site build would have failed on the first
push after #899 rather than shipping a builder without previews. The demo maps
themselves need nothing here: they are committed under `builder/app/public/`, so
vite copies them into the artifact like any other static file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CLAUDE.md and firmware/README.md both enumerate what lives in `apps/`; the new
wasm bridge was missing from each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@timohueser
timohueser force-pushed the feat/bakery-b2-899 branch from 08df1be to 151b15b Compare July 29, 2026 16:17
timohueser and others added 2 commits July 29, 2026 18:19
Wheel-to-zoom applied unconditionally, so a mouse wheel anywhere over the
selected card zoomed the map instead of scrolling past it — the single most
irritating thing an embedded map does, and this one is a 260 px card halfway
down a page. It now zooms only when the visitor has aimed at the card: it holds
focus (a click or Tab puts it there), or ctrl/meta says the gesture is meant for
it. Dragging, the arrow keys and +/- are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-detail

Timo's call (2026-07-29): two really good presets, not three. Removed: the
preset config, its demo map + previews.json entry, its tagline. Tests that
copied the real minimal.json as a fixture now copy default.json; the restyle
test's version bump is no longer a pinned literal (it silently no-opped the
moment the fixture preset's version differed — now it bumps _meta.version
from wherever it stands).

The published catalog still lists bremen×minimal; the next publish drops it
via --allow-shrink (deliberate), and the orphaned objects can be deleted from
the bucket by hand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@timohueser
timohueser enabled auto-merge July 29, 2026 17:16
timohueser and others added 2 commits July 29, 2026 19:23
Missed in 2bc79cb: obc-pack's own guards walk builder/presets and pinned
minimal by name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The third and last preset-count pin (obc-desktop is workspace-excluded, so
neither the workspace test run nor obc-bake's caught it — only its own CI leg
did).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@timohueser
timohueser merged commit 3179781 into develop Jul 29, 2026
25 checks passed
@timohueser
timohueser deleted the feat/bakery-b2-899 branch July 29, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

B2 — Preset demo maps + in-browser previews

1 participant