# 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 ~50–130 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-`, 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 `