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

25 KiB
Raw Blame History

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):

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.homeMapinitEntryMap 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.