npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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's init() throws + calls events.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.mjs esbuild-bundles the worker + its shared module into src/generated/maplibre-worker.bundle.js at prebuild; engine.ts inlines it (?raw) and hands maplibre a Blob URL via setWorkerUrl(). ~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.ts external must stay the two exact specifiers maplibre-gl + pmtiles — widening to a regex would externalize the ?raw imports too.

Engine notes

  • An invisible circle layer anchors the nodes source. Without any layer referencing it, maplibre never loads the source's tiles and querySourceFeatures returns [] forever — the HTML-marker pattern needs the anchor (official cluster-HTML example does the same).
  • Markers are HTML <img> buttons managed by MarkerPool — elements created once per key, diffed on every idle/moveend/sourcedata. Re-creating per update is the PhotoPrism duplicate-listener leak.
  • Clusters: maplibre's supercluster (clusterRadius 40, clusterMaxZoom 17); cluster markers show the first leaf's thumbnail + a count badge; click → getClusterExpansionZoom.
  • Route line: time-sorted LineString, lineMetrics: true, line-gradient progression.
  • Framing: computeFramingBounds drops 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 through Date — EXIF capture times are timezone-ambiguous.
  • Theme swap uses transformStyle to carry the tripmap-* sources/layers across setStyle.
  • engine._map (@internal) exposes the maplibre Map for e2e/diagnostics only.

Playback, popups, fullscreen (behaviour worth knowing)

  • Playback preloads. playback.ts calls the driver's preload(node) for the first node before its flight and for node i+1 the moment node i is shown; the engine's ImagePreloader (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 load eager (never lazy).
  • Popups always fit the map. Playback flies the node into the upper third (flyTo offset), 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), and ensurePopupInView pans the residual overflow after layout and again after the image arrives. Maplibre alone only flips the anchor near an edge.
  • Fullscreen is maplibre's FullscreenControl over the whole component (map + filmstrip), on by default (showFullscreen: false hides it); the control hides itself where the Fullscreen API is missing (iOS Safari). Labels come from labels.fullscreen / labels.exitFullscreen via maplibre's locale.
  • 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). title names the GPX track and file. Pure builders live in src/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): largeImageUrlimageUrlthumbnailUrl, 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 sibling detailHost; without one the panel mounts inside the container, which switches to height: auto while 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 in css.ts and test/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).