gpxsnap
v1.2.0
Published
Dependency-free GPX route-preview PNG renderer for Bun — no native bindings anywhere in the chain
Maintainers
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.

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 gpxsnapgpxsnap 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 extensiongpx_style:colorper track, unless you setline.colorexplicitly (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.titleauto-fills from the track's or file's<name>unless you set it explicitly (falseto suppress even an auto-detected name).statsandelevationProfile(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 & < > " ' 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-async →
core-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 --checkTest 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
