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

gpxsnap

v1.2.0

Published

Dependency-free GPX route-preview PNG renderer for Bun — no native bindings anywhere in the chain

Readme

gpxsnap

A dependency-free route-preview PNG renderer for Bun. GPX tracks (or plain coordinates) in, a static map image out — no native bindings anywhere in the chain: no sharp, no libvips, just fetch and the Web CompressionStream / DecompressionStream APIs.

Sample output: a real ~1200-point GPS track rendered near Lyon, France

That's a real recorded ride (anonymized — see test/fixtures/sample-ride.gpx and examples/real-ride.ts), not hand-picked waypoints: dense real GPS data already hugs the road network with a plain polyline stroke, no map-matching required.

Install

bun add gpxsnap

gpxsnap ships as raw TypeScript source (no build step, no compiled output) — so which runtimes can import it directly depends on their TypeScript support:

| Runtime | Support | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Bun | Any version — native TS transpilation. | | Node.js | ≥22.18 — native type-stripping, unflagged, since that version. Node ≥22.6 works with --experimental-strip-types. Node <22.6 (including 20.x LTS) has no native TS support at all; bring your own transpiler (tsx, ts-node) or bundler. | | Deno | Any version — native TS support since Deno 1.0. |

This is verified on every push, not just typechecked: test/e2e/render.e2e.ts is one script (no bun:test, no runtime-specific globals) run unmodified under Bun, Node 20/22/24, and Deno in CI. Run it yourself with bun run test:e2e:bun / test:e2e:node / test:e2e:deno.

Note this project's own tooling (bun run typecheck, bun run test, CI) uses Bun throughout — the runtime matrix above is about what a consumer of the published package can use, not what's needed to develop gpxsnap itself (see CONTRIBUTING.md, which does require Bun).

Types resolve correctly for modern resolution (bundler, node16/nodenext) — verified with @arethetypeswrong/cli. Legacy node10-style resolution and CommonJS require() are not supported (this package is ESM-only, matching the runtime matrix above); use a dynamic import() from CJS code if you need to.

Usage

import { renderRoute } from "gpxsnap";

const png = await renderRoute({
  coordinates: [
    [2.3522, 48.8566],
    [2.295, 48.8738],
    [2.2986, 48.8867],
  ], // [lon, lat][]
  width: 1200,
  height: 600,
  padding: 40,
});

await Bun.write("route.png", png);

See examples/basic.ts for a runnable example (bun examples/basic.ts).

For an actual .gpx file, use the gpxsnap/gpx convenience entry point instead of extracting coordinates yourself:

import { renderGpx } from "gpxsnap/gpx";

const gpxContents = await Bun.file("route.gpx").text();
const png = await renderGpx(gpxContents, { width: 1200, height: 600 });

renderGpx understands more of a real GPX file than a flat coordinate list can express:

  • Multiple <trk> each render as their own polyline — no spurious line connecting disconnected tracks — cycling through a small default color palette, or an embedded GPX Style extension gpx_style:color per track, unless you set line.color explicitly (which then applies to every track uniformly).
  • <rte>/<rtept> (a planned route with no GPS recording) is used as a fallback when a file has no <trk> at all.
  • <wpt> waypoints render as small dots.
  • title auto-fills from the track's or file's <name> unless you set it explicitly (false to suppress even an auto-detected name).
  • stats and elevationProfile (GPX-only — see the API tables below) use each point's <ele> elevation data, when present.

See examples/gpx.ts for a runnable example (bun examples/gpx.ts), or examples/real-ride.ts for the denser real-world track shown above.

gpxsnap only renders — it doesn't edit GPX files. It can simplify a track for rendering (see simplify below), but for trimming, merging, or other edits before rendering, gpx.studio is a great free tool for that.

API

renderRoute(options): Promise<Uint8Array>

| Option | Type | Default | Notes | | ----------------- | --------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | | coordinates | [number, number][] | required | [lon, lat] pairs | | width | number | required | output PNG width in pixels | | height | number | required | output PNG height in pixels | | padding | number | 40 | margin kept between the fitted route bbox and canvas edge | | simplify | number | 0 (off) | Ramer-Douglas-Peucker tolerance in meters; drops points that deviate less than this from their neighbors | | title | string \| false | undefined (off) | stamped as a badge in the top-left corner; unsupported characters throw (see the font's character set) | | line | LineStyle | see below | route stroke styling | | markers | boolean \| MarkersStyle | true | start/end pins; false to omit | | distanceMarkers | boolean \| DistanceMarkersStyle | false | tick marks every interval (default 5) unit (default "km") of real-world distance, perpendicular to the route | | tileUrl | string | https://tile.openstreetmap.org/{z}/{x}/{y}.png | any {z}/{x}/{y} XYZ template — see below for other styles | | pixelRatio | number | 1 | output resolution multiplier (e.g. 2 for retina); same framing, width * pixelRatio x height * pixelRatio physical pixels — see below | | format | "png" \| "webp" \| "jpeg" | "png" | webp/jpeg need Bun (Bun.Image) — see below | | quality | number | undefined | 1–100, only meaningful with format: "webp" \| "jpeg" | | attribution | boolean \| string | true (OSM text) | pass a string for a non-OSM tile source's required wording | | concurrency | number | 8 | max simultaneous tile fetches | | userAgent | string | gpxsnap (https://github.com/Slashgear/gpxsnap) | sent on every tile request | | fetchImpl | FetchLike | global fetch | injection point for tests / custom networking |

LineStyle (the line option)

| Field | Type | Default | Notes | | ---------- | ------------------------------------ | ----------- | ---------------------------------------------------------------------------- | | color | string | "#E74C3C" | ignored when gradient is set | | width | number | 3 | | | opacity | number | 1 | | | gradient | readonly string[] \| "rainbow" | undefined | overrides color — interpolated along colorBy; see below | | colorBy | "length" \| "elevation" \| "speed" | "length" | what gradient is interpolated along — GPX-only for "elevation"/"speed" |

const png = await renderRoute({
  coordinates,
  width: 1200,
  height: 600,
  line: { gradient: "rainbow" }, // or e.g. gradient: ["#2ECC71", "#F39C12", "#E74C3C"]
});

By default (colorBy: "length") the gradient is resolved per rendered segment, at that segment's position along the route's total on-canvas length — smoothest on dense tracks (a real recorded ride with hundreds of points), visibly "chunky" on a route with only a handful of points, since each segment between two points can only carry one interpolated color.

colorBy: "elevation" or "speed" instead position each point along gradient by its own value, normalized to this route's min/max (the lowest point gets one end of the gradient, the highest the other — not a fixed absolute scale, so the same colors mean different actual values on different routes). Both need data renderRoute's bare coordinates don't carry, so they only take effect via renderGpx, and only when there's enough of it — 2+ points with <ele> for "elevation", 2+ points with a usable consecutive <time> delta for "speed" (instantaneous speed between each pair of points, not a moving-time-aware average) — otherwise this silently falls back to "length", same "not enough data" convention as stats/elevationProfile.

MarkersStyle (the markers option, as { start?, end? })

Each of start / end is a MarkerStyle:

| Field | Type | Default | | ----------- | ------------- | ----------------------------------- | | radius | number | 6 | | color | string | "#2ECC71" start / "#E74C3C" end | | ringColor | string | "#ffffff" | | ringWidth | number | 2 | | opacity | number | 1 | | shape | MarkerShape | "circle" |

MarkerShape is "circle" | "square" | "diamond" | "triangle" — built-in vector shapes (no image assets, same "hand-drawn, dependency-free" spirit as the bitmap font), sized to roughly the same visual weight as a circle of the same radius. The ring, if any, is drawn in the same shape, slightly larger.

DistanceMarkersStyle (the distanceMarkers option)

Placed per track — no marker spans the gap between disconnected tracks in a renderGpx render.

| Field | Type | Default | | ------------ | -------------- | ------------------------------------ | | interval | number | 5 | | unit | "km" \| "mi" | "km" | | showLabels | boolean | false (a plain tick, no text) | | color | string | that track's own resolved line color | | tickLength | number | 10 | | tickWidth | number | 2 | | labelScale | number | 1 |

renderGpx(gpxContents, options): Promise<Uint8Array>

Same options as renderRoute, minus coordinates (extracted from the GPX data for you), plus title auto-filled from the GPX's own <name> (see above) and three GPX-only options that need data (per-point elevation, or multiple named tracks) renderRoute doesn't have. Exported from gpxsnap/gpx.

| Option | Type | Default | Notes | | ------------------ | ---------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------- | | stats | boolean \| BadgeStyle | false | badge (top-right) with distance, and — if at least half the points have <ele> — smoothed elevation gain/loss | | elevationProfile | boolean \| ElevationProfileStyle | false | mini filled line chart along the bottom strip; silently omitted with fewer than 2 elevation points | | legend | boolean \| LegendStyle | false | color-swatch-and-name key (bottom-left), one row per track; only drawn with more than one track |

BadgeStyle is { scale?, padding?, textColor?, backgroundColor?, backgroundOpacity? }. ElevationProfileStyle is { height?, lineColor?, fillColor?, fillOpacity?, backgroundColor?, backgroundOpacity? }. LegendStyle is { scale?, padding?, textColor?, backgroundColor?, backgroundOpacity?, swatchSize?, maxEntries? }maxEntries (default 6) caps how many track rows are shown before the rest collapse into a single "+N more" row.

GPX parsing, without rendering

gpxsnap/gpx also exports the parsing step on its own, for callers that want track/waypoint data rather than (or in addition to) a rendered PNG:

import { parseGpxDocument, parseGpxTrackPoints, extractGpxName } from "gpxsnap/gpx";

const gpxContents = await Bun.file("route.gpx").text();

parseGpxDocument(gpxContents); // full structure: tracks, waypoints, names, colors, elevation, timestamps
parseGpxTrackPoints(gpxContents); // just [lon, lat][], flattened across tracks — what renderGpx feeds to renderRoute
extractGpxName(gpxContents); // the same name renderGpx auto-fills into `title`

| Function | Returns | Notes | | -------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | parseGpxDocument(gpx) | GpxDocument | { name?, tracks: GpxTrack[], waypoints: GpxWaypoint[] }. Each GpxTrack is { name?, color?, points: GpxPoint[] }; each GpxPoint is { lon, lat, elevation?, time? } (time is the raw ISO 8601 string from <time>, kept only when it parses as a valid date; not yet used by rendering). | | parseGpxTrackPoints(gpx) | [number, number][] | Flattened [lon, lat] pairs across every track/route. Throws if there are no <trkpt>/<rtept> elements at all. | | extractGpxName(gpx) | string \| undefined | First track's own name, falling back to <metadata><name>. |

These are a targeted extraction, not a general-purpose XML parser: only the elements a route preview needs (<trk>/<rte>, <trkpt>/<rtept>, <wpt>, <name>, <ele>, <time>, and the gpx_style:color extension), matched with indexOf-based scanning kept deliberately linear in input size — including on malformed input (unclosed tags, missing >) — rather than a backtracking regex, so a truncated or malicious upload can't pin the CPU. External entities/DOCTYPE aren't processed at all (only the five predefined XML entities &amp; &lt; &gt; &quot; &apos; are decoded), so there's no XXE surface. Numeric lat/lon are validated with Number.isFinite and throw on garbage rather than silently producing NaN. If you need full XML fidelity (CDATA, comments, arbitrary nesting), reach for a real XML parser instead.

Why

staticmaps works, but it pulls in a native sharp/libvips binary plus a transitive modern-asynccore-js-pure chain of ES5 polyfills, just to stitch some tiles and draw a line. None of that is essential — tile fetch, PNG decode/encode, and line rasterization are small, boundable, well-documented pieces of code, built here using only Web-standard APIs that Bun (and modern Node) already ship.

Development

bun install
bun test          # run tests
bun run typecheck  # tsc --noEmit
bun run lint       # oxlint
bun run fmt:check  # oxfmt --check

Test fixtures in test/fixtures/ are real tiles pulled from tile.openstreetmap.org, checked in so the test suite never touches the network.

See CONTRIBUTING.md before opening a PR, and CODE_OF_CONDUCT.md for community expectations. Report security issues per SECURITY.md rather than in a public issue.

Legal

The default tile source, tile.openstreetmap.org, is volunteer-funded and governed by a strict usage policy: a descriptive User-Agent is required, heavy/production automated use is not welcome, and attribution is required on every rendered image. Set userAgent to something that identifies your app, and for production use consider self-hosting tiles or a paid provider (MapTiler, Stadia, Thunderforest).

Other tile styles

The default style is intentionally "busy" — it's the only one OSM's own infrastructure serves. Every alternative is a third-party tile provider on top of OSM's data, each with its own terms.

No API key needed — same volunteer-run, "descriptive User-Agent, don't hammer it" etiquette as OSM's own default (both used in the website demo's basemap picker):

tileUrl: "https://a.tile.opentopomap.org/{z}/{x}/{y}.png", // OpenTopoMap — topographic (contour lines, terrain shading)
attribution: "© OpenStreetMap contributors, SRTM - © OpenTopoMap (CC-BY-SA)",
tileUrl: "https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png", // CyclOSM — cycling-focused
attribution: "© OpenStreetMap contributors - Map style: © CyclOSM",

Needs an API key/account — bring your own, held by your own code (never commit a key, and note the attribution badge's font doesn't support every punctuation character — see font.ts — so keep | etc. out of custom attribution strings, as the two recipes above already do):

  • CARTO's Positron/Voyager tiles don't need an API key to fetch, but their free tier is scoped to "CARTO grantees" (nonprofit/education) — check their current terms before shipping it in a product.
  • MapTiler's "Topo" style is a clean alternative to OpenTopoMap; 100k map loads/month free, no credit card, but needs an account + API key.
  • Stadia Maps' "Alidade Smooth" is clean and minimal, but needs a free API key for anything beyond limited local testing.
  • Thunderforest's "Outdoors"/"Landscape" styles are particularly relevant to GPS routes, but need a key.

See examples/custom-style.ts for a Stadia-based example — same tileUrl/attribution options, just pointed at a different provider.

Retina / high-DPI output (pixelRatio)

const png = await renderRoute({
  coordinates,
  width: 1200,
  height: 600,
  pixelRatio: 2, // 2400x1200 physical pixels, same framing as pixelRatio: 1
});

The route line, markers, badges, and elevation profile all render natively at the higher resolution — not just upscaled — so they stay crisp. The basemap does too, if tileUrl has a {r} token (substituted with @2x/ @3x, matching Leaflet's retina URL convention) and the provider serves a matching tile: CARTO and Stadia (see above) both do, e.g. https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png. OSM's own default tile source has no retina tiles and no {r} token to substitute, so at pixelRatio > 1 its tiles get nearest-neighbor upscaled instead — output resolution still increases, but the basemap itself stays blocky.

WebP/JPEG output (format, Bun only)

const webp = await renderRoute({
  coordinates,
  width: 1200,
  height: 600,
  format: "webp",
  quality: 80, // 1–100, defaults to Bun.Image's own default
});

This isn't a second render path — gpxsnap still renders and encodes the same dependency-free PNG it always does (png/encode.ts, identical on Bun, Node, and Deno), then re-encodes those PNG bytes to WebP/JPEG through Bun.Image, a native codec built into the Bun binary itself (libjpeg-turbo/spng/libwebp — no npm dependency, no addon build step; see Bun's own docs for the full Bun.Image API). That makes format the one Bun-exclusive option in this package: omit it (or pass "png") and every runtime behaves exactly as before; pass "webp"/"jpeg" outside Bun and it throws immediately, before any tile fetching, rather than silently falling back.

Social-media presets

width/height are already fully free-form — no dedicated option needed — but common social formats have no obvious starting point, so examples/social-square.ts (1080x1080, feed post) and examples/social-story.ts (1080x1920, Story/Reel) show sensible width/height/padding for each.

License

MIT