Plan for extracting duplicated MapLibre init into a shared MapUtils.initEntryMap(opts). Incorporates ce-doc-review findings: card-absent fallback invariant scoped (no-op→navigate), exact isLatest predicate, and the home-map attribution restyle documented as an intentional change. Filter-hidden-card click left as a noted known limitation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
24 KiB
Map Init Consolidation — shared MapUtils.initEntryMap()
Status: 📋 Not started
Plan type:
refactor· Depth: Standard · Origin: deferred memoryproject-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)inmaplibre-utils.js+ esbuild rebuild. - Convert
trip.html.twigto call it (behavior preserved). - Convert
home.html.twigactive branch to call it + add a fullscreen button so it matches the trip page exactly. - Convert
home.html.twighighlights 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
scrollIntoViewto the grid card → navigate to the article URL. Hover-title popup is unchanged (already present). - Both home maps' attribution restyles.
initEntryMapalways constructs withattributionControl: false+ a compactAttributionControlbottom-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 inopts(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) ontoinitEntryMap. 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) ontoinitEntryMap. Dormant; navigate-only behavior is thecardPrefix: nullpath, 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 ~50–90 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 |
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(addinitEntryMap, export it on theglobal.MapUtilsobject) - Build artifact (regenerated, do not hand-edit):
user/themes/intotheeast/js/map.js
Approach:
- Add
function initEntryMap(opts) { ... }near the other public helpers; addinitEntryMap: initEntryMapto theglobal.MapUtils = { ... }export block. - Implement the full orchestration described in HTD: map construction, attribution control + collapse, the
on('load')marker loop (hover popup identical to currentmap-tippopups; marker element viacreateStoryMarker()whenopts.storyMarkers && entry.type === 'story', elsecreateDotMarker(isLatest)whereisLatest = (entry.type !== 'story') && (i === entries.length - 1)— the final-indexed entry, and only when it is not a story, mirroringtrip.html.twig:110exactly; 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 (fitBoundswithopts.fitdefaults,jumpTofor single entry),renderGpxJourneywhenopts.gpx, the fullscreen toggle IIFE whenopts.fullscreen, and the trailingresize. - Implement KTD2's unified click rule exactly: resolve card by
opts.cardPrefix + entry.slug; if absent →window.location.href = entry.url; if present → setlocation.hash, then after 350ms addis-highlightedfor 700ms; whenopts.fullscreenis 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-assetssojs/map.jsregenerates from source. Never hand-editjs/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
cardPrefixand a matching card in the DOM, clicking a marker setslocation.hashtoprefix+slugand togglesis-highlightedon the card (added after ~350ms, removed ~700ms later). - Happy path (no prefix): with
cardPrefixnull/omitted, clicking a marker setswindow.location.hreftoentry.url. - Fallback: with a
cardPrefixset but no matching card in the DOM, clicking navigates toentry.url. - Fullscreen-aware: with
fullscreenconfigured and the col.is-fullscreen, a marker click triggers the fullscreen button click first, then scrolls. - Bounds: one entry →
jumpToatfit.singleZoom; multiple entries →fitBoundswithfit.padding/fit.maxZoom. - Story markers: with
storyMarkers: trueand an entrytype === 'story', a story marker is used and that entry is never treated asisLatest. - GPX: with
gpxset,renderGpxJourneyis called with the forwarded urls/sourcePrefix/journeyId/connectMode; withgpxnull it is not called. - Empty:
entries: []constructs the map without throwing and renders no markers. - Build: after
make build-assets,js/map.jsis regenerated andwindow.MapUtils.initEntryMapis 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-tiptitle 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-coland the marker-click-while-fullscreen path still closes then scrolls. - GPX: GPX tracks + journey segments still render when
use_gpxis 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">(currentlyhome.html.twig:64), reusing the exact.feed-map-fullscreen-btnmarkup fromtrip.html.twig:61-67withid="home-map-fullscreen". No CSS changes (KTD5). - Script: keep the
HOME_ENTRIES/HOME_GPX_URLS/USE_GPX/AUTOCONNECTvar declarations; replace the inlinenew maplibregl.Map+on('load')body withMapUtils.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:
storyMarkersis 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-fullscreenbutton expands.home-map-col, and a marker click while fullscreen closes then scrolls — matching trip. - GPX: home GPX journey still renders (
home-gpx/home-journeysource 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 fromscrollIntoView). - Bounds: highlights map still fits at the wider zoom (
maxZoom/singleZoom8). - 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 U2–U4. - Stale bundle.
js/map.jsis generated; forgettingmake build-assetsships old behavior. Mitigation: U1 explicitly includes the rebuild and a runtime check thatMapUtils.initEntryMapis defined. - Duplicate element id. The new
home-map-fullscreenbutton must exist only in the active branch (highlights branch has no fullscreen). The two#home-mapcontainers 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
- After U1:
make build-assetssucceeds;js/map.jsupdated;window.MapUtils.initEntryMapdefined. - After U2: trip page (
/trips/<active_trip>) — markers, hover, click-scroll-flash, fullscreen, GPX all unchanged. - After U3: home active state — identical to trip, including the new fullscreen button and flash.
- After U4: home between-trips state — hover-title + click-to-open; wider zoom; no fullscreen/GPX.
- Run existing Playwright map tests; confirm no new failures.
- Confirm net line reduction across
trip.html.twig+home.html.twig(the duplication is gone) and thatmaplibre-utils.jsis the single source of init truth.
Sources & Research
- Origin: memory
project-map-init-refactor(deferral), re-scoped afterproject-homepage-redesign/ home-trip convergence. - Milestone 2 refactor decision that
map_entriesstays Twig-side: memoryproject-template-refactor-milestone2, plandocs/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 configpackage.jsonbuildscript. - No external research — strong local patterns; behavior is fully specified by the existing code.