@blogmcp/tripmap-core
v0.1.8
Published
Framework-agnostic MapLibre trip-map engine for the TripMap MDX component. Photo nodes from GPS + capture time: thumbnail markers, clustering, chronological route line, filmstrip, playback. Consumed by @blogmcp/mdx-render-vue, @blogmcp/mdx-render-react an
Readme
@blogmcp/tripmap-core
Framework-agnostic MapLibre engine for the TripMap MDX component: photo nodes from GPS +
capture time → thumbnail markers (clustered), a chronological route line, a synced filmstrip,
fly-through playback, light/dark styles, and a hard-coded OSM attribution. Consumed through thin
mount wrappers by @blogmcp/mdx-render-vue, @blogmcp/mdx-render-react and the drhoubicka
storefront — one behaviour surface, three frameworks.
import { TripMap, buildTripNodes } from '@blogmcp/tripmap-core'
const map = new TripMap(container, {
styleUrl,
theme: 'auto',
title: 'Údolím Libochovky',
labels: { play: 'Přehrát trasu', stop: 'Zastavit', fullscreen: 'Celá obrazovka', exitFullscreen: 'Ukončit celou obrazovku', export: 'Export trasy' /* … see TripMapLabels */ },
})
map.setNodes(buildTripNodes(mediaNodes, handNodes)) // buffered until init resolves
await map.init() // ← the ONLY place maplibre loads
map.setActiveNode('img-a', { fly: true })
map.playSequence({ dwellMs: 2000 }).stop()
map.destroy()No framework required
The engine is plain TypeScript over the DOM and MapLibre. new TripMap(el, options) works in any
page; the Vue/React packages are thin MDX wrappers around it. With a bundler, import the package;
without one, https://esm.sh/@blogmcp/tripmap-core serves it with its dependencies resolved.
test/e2e/harness/vanilla.html in @blogmcp/mdx-render-vue is the reference plain page and is
covered by tripmap-vanilla.spec.ts.
Module split — the load-bearing design
| File | Imports maplibre? | Purpose |
|---|---|---|
| src/index.ts | no | types, TripMap facade (buffers calls until init()), DEFAULT_STYLE_URLS, pure helpers |
| src/nodes.ts (./nodes) | no | buildTripNodes merge/sort/dedupe, formatTimeHHMM, tripSummary — safe in SSR code |
| src/framing.ts, line.ts, markers.ts, filmstrip.ts, playback.ts, theme.ts, attribution.ts, css.ts | no | testable units (happy-dom) |
| src/engine.ts | yes — the only one | reached via await import('./engine.js') from the facade |
Consequences a consumer relies on:
- Every bundler emits the engine + maplibre as a lazy chunk; the facade is a few KB.
- The single-file article-review MCP-App externalizes this package: its
import()rejects in the iframe, the facade'sinit()throws + callsevents.onError, wrappers keep their static facade. Renderer packages must therefore never statically import this package — not even/nodes— a top-level bare import breaks that whole bundle at load. - maplibre v6's worker resolution is broken under relocating bundlers (vite prebundle,
Nuxt/Next builds):
new URL('./maplibre-gl-worker.mjs', import.meta.url)404s and the map renders its background while no geojson source ever loads (no markers, no line — silently).scripts/bundle-worker.mjsesbuild-bundles the worker + its shared module intosrc/generated/maplibre-worker.bundle.jsatprebuild;engine.tsinlines it (?raw) and hands maplibre a Blob URL viasetWorkerUrl(). ~490 kB raw / ~135 kB compressed, in the lazy chunk only. Zero consumer config, works in every environment. - The maplibre CSS is inlined the same way (
?raw) and injected once per document. vite.config.tsexternalmust stay the two exact specifiersmaplibre-gl+pmtiles— widening to a regex would externalize the?rawimports too.
Engine notes
- An invisible circle layer anchors the nodes source. Without any layer referencing it,
maplibre never loads the source's tiles and
querySourceFeaturesreturns[]forever — the HTML-marker pattern needs the anchor (official cluster-HTML example does the same). - Markers are HTML
<img>buttons managed byMarkerPool— elements created once per key, diffed on everyidle/moveend/sourcedata. Re-creating per update is the PhotoPrism duplicate-listener leak. - Clusters: maplibre's supercluster (
clusterRadius40,clusterMaxZoom17); cluster markers show the first leaf's thumbnail + a count badge; click →getClusterExpansionZoom. - Route line: time-sorted LineString,
lineMetrics: true,line-gradientprogression. - Framing:
computeFramingBoundsdrops transit outliers (Polarsteps heuristic: median-distance trim, only when it shrinks the frame by half) — a photo from the train doesn't zoom the walk out. - Times are rendered as HH:MM sliced from the ISO string (
formatTimeHHMM), never throughDate— EXIF capture times are timezone-ambiguous. - Theme swap uses
transformStyleto carry thetripmap-*sources/layers acrosssetStyle. engine._map(@internal) exposes the maplibre Map for e2e/diagnostics only.
Playback, popups, fullscreen (behaviour worth knowing)
- Playback preloads.
playback.tscalls the driver'spreload(node)for the first node before its flight and for node i+1 the moment node i is shown; the engine'sImagePreloader(src/preload.ts, LRU of 4) fetches + decodes the popup image and the popup then mounts that very element, so it paints complete on its first frame regardless of the thumbnail endpoint's cache headers. Popup images loadeager(neverlazy). - Popups always fit the map. Playback flies the node into the upper third (
flyTooffset), the popup image caps at 40 % of the map height and the whole popup at the map height minus its offset (--tripmap-popup-img-max,--tripmap-map-h, set by the engine on resize; the description scrolls beyond that), andensurePopupInViewpans the residual overflow after layout and again after the image arrives. Maplibre alone only flips the anchor near an edge. - Fullscreen is maplibre's
FullscreenControlover the whole component (map + filmstrip), on by default (showFullscreen: falsehides it); the control hides itself where the Fullscreen API is missing (iOS Safari). Labels come fromlabels.fullscreen/labels.exitFullscreenvia maplibre'slocale. - Route export (
showExport, default on; needs a route of ≥ 2 timed nodes): a menu with Download GPX — Web Share with the file where available, so on a phone the sheet opens Garmin Connect / Strava / Komoot / Mapy.com directly, otherwise a plain download — plus Open in Mapy.com (/fnc/v1/route, lon,lat, ≤ 15 evenly sampled waypoints,foot_hiking, opens the app on phones) and Open in Google Maps (/maps/dir/?api=1, lat,lng, walking, ≤ 3 waypoints on coarse pointers / ≤ 9 otherwise).titlenames the GPX track and file. Pure builders live insrc/export.ts(buildGpx,mapyRouteUrl,googleMapsRouteUrl). - Full-screen viewer. The popup photo (and the panel photo) is a button that opens a
lightbox over the whole viewport (
src/lightbox.ts):largeImageUrl→imageUrl→thumbnailUrl, prev/next through every node with an image (arrow keys, wrap-around, neighbours preloaded), Escape / backdrop / × to close, focus and scroll restored. Mounted inside the fullscreen element when the component is fullscreen, on<body>otherwise. Navigating in the viewer keeps the map + filmstrip on the same photo; closing reveals the photo it ended on. detail: 'panel'shows the selected photo UNDER the filmstrip at component width instead of in a map popup (src/detail.ts). Wrappers that box the map in a fixed-height element pass a siblingdetailHost; without one the panel mounts inside the container, which switches toheight: autowhile the map keeps its designed height. Same lightbox from the panel image.- Cluster click = show me these photos. Besides zooming in, a cluster click activates the cluster's earliest member, so the filmstrip scrolls to that stretch of the trip.
- Marker roots belong to maplibre. Never set
position/transform(or a transform transition) on.tripmap-marker*root selectors — see the comment incss.tsandtest/css.spec.ts; the 2026-09-06 cluster-drift bug was exactly that.
Playground (fast local iteration)
npm run dev:playground serves dev/ on http://127.0.0.1:5098 — the real engine straight from
src/ (HMR) over the real Níhov trip (69 photos, thumbnails from the drhoubicka tenant, tiles
from tiles.blogmcp.ai), with knobs for map height, theme, clustering and page width. Not part of
the build, the tests or the published package.
Observable state (for hosts, CSS and tests)
The engine mirrors what the DOM cannot otherwise see onto the container: data-tripmap-nodes
(count), data-tripmap-route (timed nodes = route points; the play/export controls need ≥ 2),
data-tripmap-line (true when the route line layer exists), data-tripmap-theme
(light/dark as resolved, also after setTheme). Filmstrip items are role="option" with
aria-selected always present; every marker/control carries an aria-label.
Tests
npm test — vitest specs (happy-dom; the facade mocks the engine through options.engineLoader;
markers, filmstrip, playback, preload, export, lightbox, detail panel, framing, line, theme, CSS
contracts are unit-tested). The REAL engine is exercised in Chromium by @blogmcp/mdx-render-vue's
Playwright harness — test/e2e/specs/tripmap.spec.ts (hand nodes: markers, clusters, popups,
playback, fullscreen, export, viewer, panel, keyboard) and tripmap-api.spec.ts (the
/api/media/geo modes through the wrapper: AUTO via article context, exclude, slugs, 404, theme,
line, no-route) and tripmap-vanilla.spec.ts (a plain HTML page, no framework) — on a desktop
project and a phone project (Pixel 7). @blogmcp/mdx-render-react has the same harness shape
(test/e2e, port 5097, esbuild JSX). The drhoubicka storefront runs its island in vitest browser
mode (TripMapLive.browser.test.tsx) and has an opt-in check of a deployed page
(tests/e2e/tripmap-live.spec.ts, E2E_TRIPMAP_URL). md-editor covers the block round-trip
(mdx-block-tripmap.spec.ts). All hermetic: local empty style, data: PNG photos.
Basemap
Default style URLs are OpenFreeMap (https://tiles.openfreemap.org/styles/{liberty,dark}, public
and keyless; its TileJSON carries its own credit, shown next to the mandatory OSM notice) — a
library default has to work for any consumer. The blogmcp platform points tenants at its own tiles.blogmcp.ai through the
style-URL chain (kb-api GET /api/media/geo config) — see
competitor-monitor/docs/internal/basemap-tiles.md. Attribution is not disableable (ODbL).
