Render Strava activity into stylized heatmap posters and inline SVG. The patina builds where you ride often: overlapping low-alpha strokes accumulate into brighter color the way actual patina builds on metal.
The gallery is fixtures/activities.json: seeded random walks over
the Grand Rapids road graph. The repo ships no real-rider data.
cargo install patinate(Or cargo install --git https://gitlab.com/dunn.dev/patinate to
build from source.) Build needs a C compiler (build-essential /
xcode-select --install). No external runtime deps.
-
Register a Strava app at https://www.strava.com/settings/api. Authorization callback domain:
localhost. -
Write
~/.config/patinate/config.toml:[general] city_name = "Your City" country = "USA" center_lat = 42.9634 # downtown center_lng = -85.6681 radius_m = 25000 [render] theme = "noir_heat" viewbox_width = 1200 viewbox_height = 1600 [privacy] home_lat = 42.9999 # YOUR home, not the map center home_lng = -85.9999 obfuscation_radius_m = 1000 # 1000m+ for anything you publish salt = "..." # openssl rand -hex 16; never change it [strava] client_id = "12345" client_secret = "your-strava-client-secret"
-
patinate auth # OAuth dance patinate fetch-osm --city "Your City, USA" --radius-m 25000 # OSM basemap patinate sync # pull activities patinate render --theme warm_beige --cycling --out heatmap.svg # render
patinate auth writes the file at chmod 0600 (parent dir 0700) and
runs the activity:read_all OAuth scope. The dashboard's "Your Access
Token" is read-only and will not work for sync.
Nothing within obfuscation_radius_m of home reaches the SVG. The
hidden zone is offset from home by a secret amount (up to offset_m,
default 750 m, floor 250, seeded by [privacy].salt), edges trimmed.
Tracks still converge toward home: a determined analyst can narrow it
to roughly 100-250 m. A larger radius adds only modest cover.
A radius needs a private, stable salt (openssl rand -hex 16).
Default-rendered SVGs embed data-rider, data-bike, data-year,
data-type on heat paths plus exact center coords in <desc>.
Useful for consumer-page filters; identifying in a public publication.
For public publication, also anonymize:
patinate render --theme cycle_heat --anonymize --obfuscation-radius-m 1000 --out heatmap.svg--anonymize drops the data-* attrs and <desc>; a PNG hides no more.
It also warns when [general] center sits within the radius plus
offset_m of home, since the frame centers on it.
| Command | What it does |
|---|---|
patinate auth |
OAuth with Strava. Tokens to ~/.config/patinate/config.toml. |
patinate sync |
Incremental pull of activities into the SQLite cache. |
patinate fetch-osm |
OSM basemap for a city via Overpass. |
patinate render |
Heatmap SVG from cached activities + OSM. |
patinate list-themes |
Show embedded themes (and any in --themes-dir). |
Six themes baked into the binary. --themes-dir <dir> adds custom
*.json themes alongside. The heat core multiplies on a light bg
and screens on a dark one; heat.blend (normal, multiply,
screen) pins it.
Render flag reference
| Flag | Effect |
|---|---|
--theme <name> |
Theme to apply. Overrides [render].theme. |
--themes-dir <dir> |
Directory of custom *.json themes. |
--cycling |
Filter to Ride and EBikeRide. |
--type <list> |
Comma-separated activity types. |
--gear <id> |
Strava gear_id filter. Repeatable. |
--activity-id <n> |
Render a single activity by Strava ID. |
--min-distance-m <m> |
Drop sub-m noise stubs. Default 1000. |
--web |
Inline-embed preset: lighter glow, compact paths, no typography, minor roads or streams. It doesn't imply --anonymize; public pages pass both. |
--transparent-bg |
Skip the background rect. For inlining over a page background. |
--heat-only |
Skip basemap; render heat plus typography only. |
--anonymize |
Strip data-* from heat paths and scrub <desc>. Use for public publication. |
--obfuscation-radius-m <m> |
Override the privacy radius for one render. |
--heat-bloom <f> |
Multiplier on the glow stack. 0 = sharp, 1 = theme default, 2+ = soft cloud. |
--heat-alpha <f> |
Multiplier on sharp-core stroke opacity. |
--radius-m <m> |
Override the configured map radius. |
--viewbox-width <px> / --viewbox-height <px> |
Override viewBox dimensions. |
--fit <contain|cover> |
contain (default) letterboxes the radius circle; cover fills the viewBox and crops. The frame never depends on rides or home. |
--no-refine |
Skip OSM filter pipeline. Diagnostic. |
--config <path> |
TOML config. Default ~/.config/patinate/config.toml. |
--osm <path> |
Pre-fetched OSM JSON. Default per-user cache. |
--activities <path> |
JSON array of activities. Default: read from cache. |
--cache <path> |
SQLite cache path. Default per-user. |
--out <path> |
Output SVG. Default heatmap.svg. |
Embed in a page
One way to embed: render once, rasterize to WebP, ship the WebP. The
consumer page provides its own typography (the --web preset skips
it).
patinate render --theme cycle_heat --cycling --web --transparent-bg \
--radius-m 12000 --viewbox-width 1200 --viewbox-height 720 --fit cover \
--out hero.svg
rsvg-convert hero.svg -w 3600 -h 2160 -o /tmp/hero.png
magick /tmp/hero.png -quality 82 hero.webpFor inline SVG (hover, JS filters, vector zoom), skip the rasterize;
it isn't a privacy step. Heat paths emit data-rider / data-bike /
data-year / data-type for consumer JS. If the page is public,
--anonymize first. See "Privacy" above.
Environment variables
PATINATE_OSM is read by patinate render and written by
patinate fetch-osm. Pass --out explicitly if you want different
paths.
| Variable | Purpose |
|---|---|
STRAVA_PATINATE_CLIENT_SECRET |
OAuth client secret. |
STRAVA_PATINATE_REFRESH_TOKEN |
Long-lived refresh token. |
STRAVA_PATINATE_ACCESS_TOKEN |
Optional 24h access token. |
PATINATE_CONFIG |
TOML config path. |
PATINATE_OSM |
OSM JSON path (read by render, written by fetch-osm). |
PATINATE_THEMES_DIR |
Custom themes directory. |
PATINATE_OVERPASS_ENDPOINT |
Overpass API URL. |
PATINATE_CACHE |
SQLite cache path. |
PATINATE_ACTIVITIES |
Activity JSON path (skips the cache). |
Generic figment override: PATINATE_<SECTION>__<KEY>. Example:
PATINATE_RENDER__VIEWBOX_WIDTH=1600.
Roadmap and known limitations
- Single-rider only. Multi-rider rendering is deferred.
- No built-in PNG. Rasterize via
rsvg-convert. - No JS interactivity layer.
data-*attrs are emitted; the markup is intentionally inert. - No tiled basemap. Re-fetch OSM when you move or change radius.
- No schema migrations. After a schema-changing bump, delete
cache.dband re-sync. It sits in~/.local/share/patinate/(Linux),~/Library/Application Support/dev.dunn.patinate/(macOS) or%APPDATA%\dunn\patinate\data\(Windows). - Sync is at-least-once on partial failure. The watermark only advances after a full successful sweep; idempotent upserts cover it.
- No antimeridian handling. Cities within
radius_m / (111km * cos(lat))of ±180° produce a degenerate frame. - No viewport culling. OSM basemap renders in full regardless of
--radius-m; rasterize for a tight file-size budget. - Rate limits: a Strava 429 honors
Retry-Afteronce (60s cap); Overpass 429/5xx bails.PATINATE_OVERPASS_ENDPOINTtakes a mirror. - No JSON-log conventions.
tracingkeys are ad-hoc.
Architecture
Rust binary, single static target. SQLite cache via rusqlite
(bundled). Figment for layered TOML and env config. geo for
polyline-circle clipping. svg crate for output. oauth2 and
reqwest for the Strava dance. Overpass API for OSM basemap fetch.
polyline crate for decode. No JS runtime, no headless browser, no
external rasterizer at render time.
Tests include a load-bearing privacy invariant
(apply_privacy_invariant_loop_through_home) and an end-to-end SVG test.
Sync your own data. Do not redistribute Strava-derived raw activity data. The rendered SVG is yours; the underlying GPS traces are not. See https://www.strava.com/legal/api.
Privacy-bypass disclosures: see SECURITY.md. Contributing: see CONTRIBUTING.md.
MIT. See LICENSE.
Built by Andrew G. Dunn.






