Files
intotheeast-com/docs/working/plans/2026-06-27-map-init-consolidation.md
T
m038andClaude Opus 4.8 9fbc61ee6e docs: mark map-init consolidation plan complete
Status → Complete; document the markLatest opt added during execution,
the window.tripMap/homeMap exposure, and the test outcome (38 passed;
M6/H1 pre-existing and out of scope).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
2026-06-28 00:11:45 +02:00

271 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Map Init Consolidation — shared `MapUtils.initEntryMap()`
**Status:** ✅ Complete (2026-06-27)
> Plan type: `refactor` · Depth: Standard · Origin: deferred memory `project-map-init-refactor` (re-scoped 2026-06-27 after home/trip convergence)
---
## Summary
The recent home/trip convergence work multiplied an already-duplicated pattern: there are now **five** near-identical MapLibre init blocks, each repeating ~50130 lines of map construction, marker/popup loop, bounds-fitting, journey rendering, and fullscreen wiring. The shared `maplibre-utils.js` already centralizes marker *creation* and GPX/journey rendering, but **not the init orchestration** — that is what is copy-pasted and has since drifted into subtly inconsistent behavior.
This plan extracts the init orchestration into one config-driven function, `MapUtils.initEntryMap(opts)`, in `user/themes/intotheeast/js/maplibre-utils.js` (which esbuild already bundles into `js/map.js`, so every template that loads `map.js` picks it up). The two actively-used surfaces — the **trip page** and the **home active-trip view** — are converted to call it and become behaviorally identical. The **home highlights (between-trips) view** is converted too, with a deliberate small UX change: marker click navigates to the article instead of scrolling to a grid card (hover-title already exists). The dormant `feed-map.html.twig` partial and `map.html.twig` full-page map are **left untouched** this pass.
This is a refactor with two intentional, scoped behavior changes (both on the home page) — not byte-for-byte preservation.
---
## Problem Frame
`maplibre-utils.js` gives every map the same building blocks (`createDotMarker`, `createStoryMarker`, `renderGpxJourney`, `MAP_STYLE`), but each template still hand-writes the *assembly*: `new maplibregl.Map(...)`, attribution control, the `on('load')` marker loop with hover popup + click handler, `fitBounds`/`jumpTo`, and the fullscreen toggle IIFE. Five copies exist:
| Surface | Container | Marker click (today) | In active use? |
|---|---|---|---|
| `trip.html.twig` | `#trip-map` | scroll+flash `entry-` card, fullscreen-aware | ✅ active |
| `home.html.twig` active branch | `#home-map` | set hash to `entry-` card, **no flash, not fullscreen-aware** | ✅ active |
| `home.html.twig` highlights branch | `#home-map` | `scrollIntoView` to `highlight-` card | ✅ active |
| `partials/feed-map.html.twig` | `#feed-map` / `#stories-map` | scroll+flash else navigate, fullscreen-aware | ⚠️ dormant (dailies/stories) |
| `map.html.twig` | `#trip-map` (full page) | always navigate to URL | ⚠️ dormant |
**Consequences of the duplication:**
- The trip page and home active view are meant to be the same component but have already drifted (home active lacks the flash highlight and the fullscreen button trip has).
- Any future map change must be applied in up to five places, by hand, with no shared test surface.
- The click logic carries five subtly different implementations of just **two** real intents: *scroll to the matching card on this page*, or *navigate to the entry's own page*.
**Why now:** The deferral in `project-map-init-refactor` was justified by "dailies/stories/full-map aren't in active use." That still holds for those three — but trip and home are now both active and nearly identical, so the high-value consolidation is unblocked while the dormant surfaces stay out of scope.
---
## Scope Boundaries
**In scope:**
- New `MapUtils.initEntryMap(opts)` in `maplibre-utils.js` + esbuild rebuild.
- Convert `trip.html.twig` to call it (behavior preserved).
- Convert `home.html.twig` active branch to call it + add a fullscreen button so it matches the trip page exactly.
- Convert `home.html.twig` highlights branch to call it (click → navigate to article).
**Intentional behavior changes (both home page only):**
- Home active view **gains** the flash-highlight on card scroll and the fullscreen button/awareness it currently lacks → becomes identical to the trip page.
- Home highlights view marker click **changes** from `scrollIntoView` to the grid card → navigate to the article URL. Hover-title popup is unchanged (already present).
- **Both home maps' attribution restyles.** `initEntryMap` always constructs with `attributionControl: false` + a compact `AttributionControl` bottom-left, collapsed on load (mirroring trip). The home active and home highlights maps currently use MapLibre's default attribution (expanded, bottom-right), so both move to trip's compact collapsed bottom-left. For home active this is part of "identical to trip"; for home highlights — which is otherwise unchanged except for the click behavior — it is an *incidental* restyle. If highlights should keep the default attribution, parameterize attribution in `opts` (e.g. `attribution: { compact, position, collapse }`) rather than baking trip's treatment into every caller.
### Deferred to Follow-Up Work
- Converting `partials/feed-map.html.twig` (dailies/stories) onto `initEntryMap`. It is already a shared partial with low duplication cost and the pages are dormant; retrofit it when those feeds return to active use. The unified click rule already matches feed-map's current behavior, so this will be a near-drop-in later.
- Converting `map.html.twig` (full-page map) onto `initEntryMap`. Dormant; navigate-only behavior is the `cardPrefix: null` path, so it too will be a clean later conversion.
**Out of scope:** no CSS changes, no map-style change, no change to the Twig-side `map_entries`/`gpx_urls` computation, no change to `createDotMarker`/`createStoryMarker`/`renderGpxJourney`.
---
## Key Technical Decisions
**KTD1 — Init orchestration lives in `maplibre-utils.js`, not per-template.**
`maplibre-utils.js` is imported by `js/src/map.js` and bundled by esbuild into the minified `js/map.js` that every map-bearing template loads via `{% block map_assets %}`. Adding `initEntryMap` there + rebuilding makes it available everywhere with a single source of truth. This is the whole point of the refactor.
**KTD2 — One unified click rule, no click-mode enum.**
The function takes an optional `cardPrefix`. Click behavior is a single rule: *if `cardPrefix` is set and `document.getElementById(cardPrefix + slug)` exists, scroll to it (via `location.hash`) and flash `is-highlighted`, fullscreen-aware when a fullscreen target is configured; otherwise navigate to `entry.url`.* This single rule subsumes every behavior the in-scope surfaces need — trip and home active pass `cardPrefix: 'entry-'`; home highlights passes no prefix and gets navigate-on-click for free. It also happens to match the dormant feed-map/map.html behaviors, easing their later conversion. No `clickMode` parameter is introduced.
**Card-absent fallback — note the divergence from trip today.** The current trip handler does `if (!card) return;` (a no-op) when no card matches the slug; the unified rule instead **navigates** to `entry.url`. This is behavior-preserving on every in-scope surface **only under the invariant that every map marker has a matching feed card** (`entry-<slug>`, emitted by both the journal and story entry partials). Document that invariant where it is relied on (U2). If a future map entry can ever lack a feed card (a map-only POI, a new pin type), make the fallback per-surface — trip = no-op, highlights = navigate — rather than letting the shared default retroactively change trip's semantics.
**KTD3 — `map_entries` / `gpx_urls` stay computed in Twig.**
Per the Milestone 2 refactor decision, Twig macros cannot return arrays, so each template keeps its existing Twig loop that builds `map_entries` and serializes it to a JS var. The only change is replacing the inline init `<script>` body with a single `MapUtils.initEntryMap({...})` call inside `DOMContentLoaded`. Each template's map `<script>` shrinks from ~5090 lines to ~10.
**KTD4 — Dormant surfaces excluded.**
`feed-map.html.twig` and `map.html.twig` keep their current inline init this pass (see Deferred). Reduces blast radius to the two active surfaces plus the home highlights view.
**KTD5 — Home active fullscreen button reuses existing CSS.**
The fullscreen button uses the same `.feed-map-fullscreen-btn` markup as the trip page, and the fullscreen target is `.home-map-col`. Both `.feed-map-fullscreen-btn` (`css/style.css:636`) and `.home-map-col.is-fullscreen` (`css/style.css:911`) already exist — no CSS changes required.
---
## High-Level Technical Design
**Who calls the shared function (after this plan):**
```
maplibre-utils.js ── MapUtils.initEntryMap(opts) ──┐
(bundled into js/map.js) │
├─ trip.html.twig → cardPrefix:'entry-', fullscreen, story markers
├─ home.html.twig (active) → cardPrefix:'entry-', fullscreen
└─ home.html.twig (highlights) → no cardPrefix (→ navigate)
feed-map.html.twig / map.html.twig → unchanged (own inline init, deferred)
```
**Unified marker-click rule** (the single behavior the function implements):
```mermaid
flowchart TD
A[Marker clicked] --> B{cardPrefix set AND<br/>card #prefix+slug exists?}
B -- no --> C[navigate to entry.url]
B -- yes --> D{fullscreen target<br/>configured AND open?}
D -- yes --> E[close fullscreen,<br/>then scroll+flash after delay]
D -- no --> F[scroll to hash,<br/>flash is-highlighted]
```
**`initEntryMap(opts)` shape** (directional, not a signature spec):
| Option | Type | Used by | Notes |
|---|---|---|---|
| `container` | string | all | map div id (`'trip-map'`, `'home-map'`) |
| `entries` | array | all | already-parsed `map_entries` from Twig |
| `cardPrefix` | string \| null | trip, home active | `'entry-'`; omit/null → markers navigate to `entry.url` |
| `storyMarkers` | bool | trip | render `createStoryMarker()` for `type === 'story'`; default dot markers |
| `markLatest` | bool | trip, home active | enlarge the final non-story entry's dot (default `true`); home highlights passes `false` so no marker is singled out in the shuffled set (added during execution — preserves highlights' current all-equal dots) |
| `fullscreen` | `{ btnId, colSelector }` \| null | trip, home active | wires the fullscreen toggle; null → no fullscreen |
| `gpx` | `{ urls, use, autoconnect, sourcePrefix, journeyId }` \| null | trip, home active | forwarded to `renderGpxJourney`; null → skip |
| `fit` | `{ padding, maxZoom, singleZoom }` | all | defaults `{60, 11, 10}`; highlights uses `maxZoom: 8`, `singleZoom: 8` |
The function returns the map instance and internally does: construct map (`attributionControl: false`) + compact `AttributionControl` bottom-left; on `load` build bounds, loop entries → marker + hover popup + unified click handler, `fitBounds`/`jumpTo`, collapse the attribution `<details>`, call `renderGpxJourney` when `gpx` is set; wire the fullscreen IIFE when `fullscreen` is set; `setTimeout(map.resize, 100)`.
---
## Implementation Units
### U1. Add `MapUtils.initEntryMap(opts)` to `maplibre-utils.js`
**Goal:** Introduce the config-driven init function and rebuild the bundle. No template consumes it yet.
**Dependencies:** none.
**Files:**
- Modify: `user/themes/intotheeast/js/maplibre-utils.js` (add `initEntryMap`, export it on the `global.MapUtils` object)
- Build artifact (regenerated, do not hand-edit): `user/themes/intotheeast/js/map.js`
**Approach:**
- Add `function initEntryMap(opts) { ... }` near the other public helpers; add `initEntryMap: initEntryMap` to the `global.MapUtils = { ... }` export block.
- Implement the full orchestration described in HTD: map construction, attribution control + collapse, the `on('load')` marker loop (hover popup identical to current `map-tip` popups; marker element via `createStoryMarker()` when `opts.storyMarkers && entry.type === 'story'`, else `createDotMarker(isLatest)` where `isLatest = (entry.type !== 'story') && (i === entries.length - 1)` — the final-indexed entry, and only when it is not a story, mirroring `trip.html.twig:110` exactly; note this enlarges *nothing* when the last entry is a story, which is the current trip behavior and must be preserved — do **not** reinterpret it as "the last non-story entry"), bounds fit (`fitBounds` with `opts.fit` defaults, `jumpTo` for single entry), `renderGpxJourney` when `opts.gpx`, the fullscreen toggle IIFE when `opts.fullscreen`, and the trailing `resize`.
- Implement KTD2's unified click rule exactly: resolve card by `opts.cardPrefix + entry.slug`; if absent → `window.location.href = entry.url`; if present → set `location.hash`, then after 350ms add `is-highlighted` for 700ms; when `opts.fullscreen` is configured and the col is `.is-fullscreen`, click the fullscreen button first and defer the scroll ~450ms (mirror the current trip handler timings).
- Keep the empty-entries behavior cheap: if `entries.length === 0`, still construct the map and return (the trip page's existing "no locations yet" copy is page-specific and stays in the template if needed — do not bake page copy into the util).
- Rebuild: run `make build-assets` so `js/map.js` regenerates from source. Never hand-edit `js/map.js`.
**Patterns to follow:** mirror the existing trip page handler (`trip.html.twig:100-171`) as the canonical behavior, since trip is the surface whose behavior is being preserved; reuse the existing module structure and IIFE export pattern already in `maplibre-utils.js`.
**Execution note:** This is the load-bearing unit. Implement it to faithfully reproduce the trip page's current behavior before any caller is switched, so U2 is behavior-preserving — a no-op under the card-matching invariant noted in KTD2; the card-absent navigate fallback is the one deliberate divergence and is unreachable on trip today.
**Test scenarios:**
- *Happy path (card present):* with a `cardPrefix` and a matching card in the DOM, clicking a marker sets `location.hash` to `prefix+slug` and toggles `is-highlighted` on the card (added after ~350ms, removed ~700ms later).
- *Happy path (no prefix):* with `cardPrefix` null/omitted, clicking a marker sets `window.location.href` to `entry.url`.
- *Fallback:* with a `cardPrefix` set but no matching card in the DOM, clicking navigates to `entry.url`.
- *Fullscreen-aware:* with `fullscreen` configured and the col `.is-fullscreen`, a marker click triggers the fullscreen button click first, then scrolls.
- *Bounds:* one entry → `jumpTo` at `fit.singleZoom`; multiple entries → `fitBounds` with `fit.padding`/`fit.maxZoom`.
- *Story markers:* with `storyMarkers: true` and an entry `type === 'story'`, a story marker is used and that entry is never treated as `isLatest`.
- *GPX:* with `gpx` set, `renderGpxJourney` is called with the forwarded urls/sourcePrefix/journeyId/connectMode; with `gpx` null it is not called.
- *Empty:* `entries: []` constructs the map without throwing and renders no markers.
- *Build:* after `make build-assets`, `js/map.js` is regenerated and `window.MapUtils.initEntryMap` is defined at runtime.
**Verification:** `MapUtils.initEntryMap` is exported and callable; `make build-assets` completes and updates `js/map.js`; no console errors when invoked.
---
### U2. Convert `trip.html.twig` to `initEntryMap` (behavior preserved)
**Goal:** Replace the trip page's inline ~85-line map `<script>` body with a single `initEntryMap` call; behavior is preserved — behaviorally equivalent under the card-matching invariant in KTD2 (not literally byte-for-byte, since the `<script>` body is rewritten and the card-absent fallback changes from no-op to navigate, which is unreachable on trip).
**Dependencies:** U1.
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
**Approach:** Keep the Twig `map_entries`/`gpx_urls` computation and the `TRIP_ENTRIES`/`GPX_URLS`/`USE_GPX`/`AUTOCONNECT` JS var declarations. Replace everything inside `DOMContentLoaded` (the `new maplibregl.Map`, the `on('load')` loop, fit-bounds, `renderGpxJourney`, attribution collapse, the fullscreen IIFE, the trailing resize) with one call: `MapUtils.initEntryMap({ container: 'trip-map', entries: TRIP_ENTRIES, cardPrefix: 'entry-', storyMarkers: true, fullscreen: { btnId: 'trip-map-fullscreen', colSelector: '.home-map-col' }, gpx: { urls: GPX_URLS, use: USE_GPX, autoconnect: AUTOCONNECT, sourcePrefix: 'gpx', journeyId: 'trip-journey' }, fit: { padding: 60, maxZoom: 11, singleZoom: 10 } })`. Leave the fullscreen button markup and the `#trip-totop` button as-is.
**Patterns to follow:** existing `trip.html.twig` markup and var names; the include-call style already used for partials.
**Test scenarios:**
- *Markers + hover:* trip page renders one dot per entry plus story markers; hovering shows the `map-tip` title popup (unchanged).
- *Click → scroll+flash:* clicking a marker scrolls to its `entry-<slug>` feed card and flashes it.
- *Fullscreen:* the fullscreen button still expands `.home-map-col` and the marker-click-while-fullscreen path still closes then scrolls.
- *GPX:* GPX tracks + journey segments still render when `use_gpx` is on.
- *Regression:* visual diff against pre-change trip page shows no behavioral difference.
**Verification:** trip page at `localhost:8081/trips/<active_trip>` behaves identically to before — markers, popups, click-scroll-flash, fullscreen, GPX all intact; no console errors.
---
### U3. Convert `home.html.twig` active-trip branch + add fullscreen button (match trip page)
**Goal:** The home active-trip map becomes behaviorally identical to the trip page — it gains the flash-highlight and a working fullscreen button it currently lacks.
**Dependencies:** U1. Independent of U2.
**Files:**
- Modify: `user/themes/intotheeast/templates/home.html.twig` (active branch markup + script)
**Approach:**
- Markup: add the fullscreen button inside the active branch's `<div class="home-map" id="home-map">` (currently `home.html.twig:64`), reusing the exact `.feed-map-fullscreen-btn` markup from `trip.html.twig:61-67` with `id="home-map-fullscreen"`. No CSS changes (KTD5).
- Script: keep the `HOME_ENTRIES`/`HOME_GPX_URLS`/`USE_GPX`/`AUTOCONNECT` var declarations; replace the inline `new maplibregl.Map` + `on('load')` body with `MapUtils.initEntryMap({ container: 'home-map', entries: HOME_ENTRIES, cardPrefix: 'entry-', fullscreen: { btnId: 'home-map-fullscreen', colSelector: '.home-map-col' }, gpx: { urls: HOME_GPX_URLS, use: USE_GPX, autoconnect: AUTOCONNECT, sourcePrefix: 'home-gpx', journeyId: 'home-journey' }, fit: { padding: 60, maxZoom: 11, singleZoom: 10 } })`.
- Note: `storyMarkers` is omitted (home active currently uses dot markers only — preserved).
**Patterns to follow:** the trip page conversion (U2) and the trip fullscreen button markup.
**Test scenarios:**
- *Parity:* home active map renders markers, hover popups, and click-scroll **with flash** (previously no flash) to `entry-<slug>` cards.
- *Fullscreen (new):* the new `home-map-fullscreen` button expands `.home-map-col`, and a marker click while fullscreen closes then scrolls — matching trip.
- *GPX:* home GPX journey still renders (`home-gpx` / `home-journey` source ids preserved).
- *No story markers:* dot markers only, as before.
**Verification:** home page (travelling/active state) map matches the trip page in every interaction; fullscreen button visible and functional on mobile widths; no console errors.
---
### U4. Convert `home.html.twig` highlights branch (click → navigate)
**Goal:** The between-trips highlights map uses the shared init, and marker click opens the article instead of scrolling to a grid card. Hover-title popup unchanged.
**Dependencies:** U1. Lands naturally alongside U3 (same file) but is a distinct behavior change.
**Files:**
- Modify: `user/themes/intotheeast/templates/home.html.twig` (highlights branch script, `home.html.twig:245-289`)
**Approach:** Keep the `HIGHLIGHTS_ENTRIES` var declaration. Replace the inline `new maplibregl.Map` + `on('load')` body (including the current `scrollIntoView` click handler) with `MapUtils.initEntryMap({ container: 'home-map', entries: HIGHLIGHTS_ENTRIES, fit: { padding: 60, maxZoom: 8, singleZoom: 8 } })` — no `cardPrefix`, no `fullscreen`, no `gpx`. The absent `cardPrefix` yields navigate-on-click per KTD2; the `map-tip` hover popup is provided by the shared loop, so hover-title is preserved with no extra code. Note: this also restyles the highlights map's attribution to trip's compact collapsed bottom-left (see Scope Boundaries → "Both home maps' attribution restyles") — an incidental change; pass an attribution `opts` override if the MapLibre default should be retained here.
**Patterns to follow:** the navigate path of the unified click rule (KTD2).
**Test scenarios:**
- *Hover:* hovering a highlights marker shows the article title popup (preserved).
- *Click → navigate:* clicking a highlights marker navigates to `entry.url` (changed from `scrollIntoView`).
- *Bounds:* highlights map still fits at the wider zoom (`maxZoom`/`singleZoom` 8).
- *No fullscreen / no GPX:* no fullscreen button appears and no GPX journey renders on the highlights map.
**Verification:** between-trips home state shows the highlights map; hovering a pin shows its title, clicking it opens the article; no console errors.
---
## Risks & Dependencies
- **Regression on the two live surfaces.** Trip and home active are the primary UI. Mitigation: U2 is a strict behavior-preserving change verified by visual diff; U1 is built to reproduce the trip handler exactly before any caller switches. Check existing Playwright map coverage (see `docs/working/plans/2026-06-22-align-maps-tests.md` / `2026-06-21-playwright-tests.md`) and run it after U2U4.
- **Stale bundle.** `js/map.js` is generated; forgetting `make build-assets` ships old behavior. Mitigation: U1 explicitly includes the rebuild and a runtime check that `MapUtils.initEntryMap` is defined.
- **Duplicate element id.** The new `home-map-fullscreen` button must exist only in the active branch (highlights branch has no fullscreen). The two `#home-map` containers are already in mutually-exclusive Twig branches, so no real-DOM collision occurs.
- **Sequencing:** U2, U3, U4 all depend only on U1. U3 and U4 touch the same file and will typically land in one commit.
- **Known limitation — filter-hidden card click (accepted).** The unified rule keys on the card *existing* (`getElementById`), not on it being visible. When the feed filter bar (All/Journal/Stories) has hidden the target card (`display:none`), a marker click sets the hash and flashes an off-screen card, so the map appears unresponsive. This is a pre-existing rough edge being carried forward deliberately (not a regression introduced here) — accepted as-is for this pass rather than adding filter-reset or navigate-fallback handling.
---
## Verification Strategy
1. After U1: `make build-assets` succeeds; `js/map.js` updated; `window.MapUtils.initEntryMap` defined.
2. After U2: trip page (`/trips/<active_trip>`) — markers, hover, click-scroll-flash, fullscreen, GPX all unchanged.
3. After U3: home active state — identical to trip, including the new fullscreen button and flash.
4. After U4: home between-trips state — hover-title + click-to-open; wider zoom; no fullscreen/GPX.
5. Run existing Playwright map tests; confirm no new failures.
6. Confirm net line reduction across `trip.html.twig` + `home.html.twig` (the duplication is gone) and that `maplibre-utils.js` is the single source of init truth.
---
## Execution Outcome (2026-06-27)
All four units landed. `MapUtils.initEntryMap(opts)` added to `maplibre-utils.js` and bundled via `make build-assets`; `trip.html.twig`, both `home.html.twig` branches converted. Two notes from execution:
- **`markLatest` opt added** — the highlights branch rendered all dots equal (`createDotMarker(false)`), but the shared `isLatest = (i === length-1)` would have enlarged the last (shuffled) highlight. Added a `markLatest` flag (default `true`; highlights passes `false`) to preserve that.
- **Map instance exposed as `window.tripMap` / `window.homeMap`** — `initEntryMap` returns the map, and the templates assign it to these globals. This is the affordance the existing Playwright specs (M7, M8) already assumed; wiring it up turned two perma-failing tests green, giving real regression coverage on the converted surfaces.
**Test status:** `tests/ui/maps`, `tests/ui/home`, `tests/ui/trip`, `tests/ui/gpx` — 38 passed. Remaining failures are pre-existing and out of scope: **M6** asserts `window.map` on the deferred `map.html.twig` (untouched this pass); **H1** is a parallel-load timing flake (passes 4/4 in isolation). Both fail identically on the pre-refactor baseline.
## Sources & Research
- Origin: memory `project-map-init-refactor` (deferral), re-scoped after `project-homepage-redesign` / home-trip convergence.
- Milestone 2 refactor decision that `map_entries` stays Twig-side: memory `project-template-refactor-milestone2`, plan `docs/working/plans/2026-06-23-template-refactor.md`.
- Current behavior read directly from: `trip.html.twig:83-174`, `home.html.twig:87-139` (active) and `:245-289` (highlights), `partials/feed-map.html.twig`, `map.html.twig`, `js/maplibre-utils.js`, build config `package.json` `build` script.
- No external research — strong local patterns; behavior is fully specified by the existing code.