Map
Maps render OpenStreetMap vector tiles through MapLibre GL JS, with every layer painted by the framework. The TRMNLMaps helper composes the map style from the live screen, so a map adapts to the device and themes like the rest of the screen. Maps are plotted, never satellite, and never interactive.
Usage
Maps are MapLibre GL JS
compositions over OpenStreetMap
vector tiles, and every layer is painted by the framework. The plugin runtime bundles a
TRMNLMaps helper that builds the map style from
the live screen, the way TRMNLCharts builds
Highcharts options. The methods below are the ones these examples use;
Painting Maps
Painting Maps
Map slot colors for the current device, mode, and theme, with MapLibre GL JS adapters
These functions give you the color of every part of a map: land, water, roads, parks, buildings, and labels, correct for the current device, mode, and theme. Adapters turn each answer into a MapLibre GL JS paint property, and TRMNLMaps assembles whole map styles out of them.
carries the full list and the resolvers behind it.
-
options({ el, preset, center, zoom }): the Map options for a still map, with the style for a preset, the container, and every handler and animation off. -
watch(el, buildFn): builds the map now and again when device, scale, mode, dark mode or theme changes, removing the previous one first. -
route(map, coords, { el, width })anddot(map, lngLat, { el, radius, hollow }): plot a route and its markers as crisp fills painted from the screen's chart-series ramp. -
fit(map, coords, { padding, maxZoom }): frames the coordinates on an integer zoom, with no animation. -
decodePolyline(str): a Google encoded polyline (the shape Strava returns) as[lng, lat]pairs. -
merge(base, overrides): layer your own layer ids, sources and Map options over the defaults.
{ el } is the map container id or element. Omit
it on a single-screen plugin.
Four style presets ship: streets draws roads,
water, parks, buildings and labels, minimal keeps
the land, water and main roads a route sits on, outline
is coast, water, main roads and the big place names for a small view, and
blank is the land alone for your own overlays.
Each preset is the same slot set drawn differently, so a theme restyles all four at once.
TRMNLMaps resolves no paint of its own: every color
comes from a TRMNLPaint map slot, so a map on a 1-bit
screen draws dither tiles and a map on a color panel draws solids. For anything beyond MapLibre, read the
slots directly: see
Paint API
Paint API
TRMNLPaint: read the exact colors and patterns CSS paints right now, from JavaScript
TRMNLPaint is the framework's JavaScript API for its paint: colors, border lines, and text styles. Ask it for any framework color and it returns what CSS would actually paint right now, with the device and the active theme already applied. Use it wherever JavaScript draws: charts, maps, canvases, or your own rendering.
.
Tiles and keys
A map that names no tile source fetches TRMNL's own tiles (maps.trmnl.com) itself, and
whoever renders the plugin pays nothing. Name a source when you have one:
-
options({ tiles: { url, key } }): your own source.urlis a{z}/{x}/{y}template and may carry{key}, which the key fills. -
options({ tiles: 'trmnl' }): TRMNL's own source,maps.trmnl.com, a Shortbread planet behind a CDN. The docs site and TRMNL's plugins use it. -
window.__TRMNL_MAPS__ = { tiles: { url, key } }: set by the host per plugin instance, so a plugin author's key or a user's key from the plugin settings reaches the map without a key in the markup. A source named in code wins over it.
The style speaks the Shortbread tile schema, so a source has to as well. Keep the OpenStreetMap credit whatever the source: the data is theirs either way.
Maps are built non-interactive. A device has no input and the screenshot service captures one still frame, so options() disables every handler and animation: set the camera in the options or with fit(), never with flyTo or easeTo.
OpenStreetMap data is licensed under the ODbL and needs visible credit. watch() and attach() place a framework-styled "© OpenStreetMap contributors" label on every map: keep it.
A plugin loads MapLibre GL JS from trmnl.com/js/maplibre-gl/5.24.0/, the same build the framework serves next to the runtime for these examples, and the tiles come from maps.trmnl.com, so a map renders an empty canvas without network. MapLibre is BSD licensed; the map data is © OpenStreetMap contributors.
Streets
The streets preset on its own: one place, one zoom, nothing plotted. Load MapLibre from the framework, give the map a container with the map class, and build it inside watch().
<!-- import MapLibre GL JS and its stylesheet -->
<script src="https://trmnl.com/js/maplibre-gl/5.24.0/maplibre-gl.js"></script>
<link href="https://trmnl.com/js/maplibre-gl/5.24.0/maplibre-gl.css" rel="stylesheet">
<!-- markup with an empty, ID'd .map container for the canvas -->
<div class="view view--full">
<div class="layout layout--col gap--small">
<div class="grid grid--cols-3">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums">14°C</span>
<span class="label">South Bank</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums">17:53</span>
<span class="label">Sunset</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums">2.1 km</span>
<span class="label">To the office</span>
</div>
</div>
</div>
<div id="map-streets" class="map stretch w--full"></div>
</div>
<div class="title_bar">
<img class="image image--adaptive" src="/images/plugins/trmnl--render.svg" alt="TRMNL Logo">
<span class="title">Map</span>
<span class="instance">Streets</span>
</div>
</div>
<script type="text/javascript">
// Wait for MapLibre and the framework TRMNLMaps helper (bundled in the
// plugin runtime), then build the map from the live screen.
function whenReady(cb) {
var tries = 0;
(function attempt() {
if (window.TRMNLMaps && window.maplibregl) return cb();
if (++tries > 200) return;
setTimeout(attempt, 50);
})();
}
whenReady(function () {
var el = "map-streets";
// watch() rebuilds on device/scale/mode/dark/theme change; options() carries
// the style for the preset, painted from the live screen.
TRMNLMaps.watch(el, function () {
return new maplibregl.Map(TRMNLMaps.options({
el: el, preset: "streets", center: [-0.1276, 51.5072], zoom: 13
}));
});
});
</script>
Map styles
The same view through three presets. Streets is the full map, minimal keeps the shapes a route sits on, and outline is coast, water, main roads and the big names for the smallest views.
// One watch() per container; only the preset changes.
["streets", "minimal", "outline"].forEach(function (preset) {
var el = "map-style-" + preset;
TRMNLMaps.watch(el, function () {
return new maplibregl.Map(TRMNLMaps.options({
el: el, preset: preset, center: [2.3522, 48.8566], zoom: 12
}));
});
});
Markers and routes
Plot your own data. route() and dot() draw it as crisp fills painted from the screen's chart-series ramp, and fit() frames it on an integer zoom. A second route takes the next step of the ramp (a dither on 1-bit), so the ride in and the ride home read apart.
var route = [
[13.4240, 52.5390], [13.4205, 52.5352], [13.4150, 52.5310], [13.4088, 52.5265],
[13.4010, 52.5210], [13.3930, 52.5170], [13.3840, 52.5125], [13.3759, 52.5096]
];
var home = [
[13.3759, 52.5096], [13.3850, 52.5150], [13.3960, 52.5232], [13.4060, 52.5290],
[13.4150, 52.5345], [13.4240, 52.5390]
];
whenReady(function () {
var el = "map-commute";
TRMNLMaps.watch(el, function () {
var map = new maplibregl.Map(TRMNLMaps.options({ el: el, preset: "minimal" }));
map.on("load", function () {
// route() and dot() draw crisp fills from the chart-series ramp:
// the ride in takes step 0 of 2, the ride home step 1.
TRMNLMaps.route(map, route, { el: el, width: 3, i: 0, n: 2 });
TRMNLMaps.route(map, home, { el: el, id: "home", width: 3, i: 1, n: 2 });
TRMNLMaps.dot(map, route[0], { el: el, id: "start", radius: 5 });
TRMNLMaps.dot(map, route[route.length - 1], { el: el, id: "end", radius: 5, hollow: true });
});
// fit() frames both routes on an integer zoom, no animation.
TRMNLMaps.fit(map, route.concat(home), { padding: TRMNLPaint.px(24, { el: el }), maxZoom: 14 });
return map;
});
});
Grayscale and color
The map slots default to grays tuned for 1-bit: land and the built-up blocks are the canvas, water, parks and buildings are dither tiles, roads are grays short of the ink, labels are the ink. A full-color screen re-points the same slots at chromatic tokens and a limited palette dithers them to its ink set, so the plugin changes nothing.
Labels are framework elements placed over the canvas, so they take the screen's own fonts (TRMNL pixel fonts on 1-bit and 2-bit low-density panels, Inter on 4-bit and high density) with a large text stroke, on whole pixels. The biggest names win the space: small places wait for closer zooms, and a small map holds a few names instead of a crowd.
Switch the device picker to a color device or dark mode and the map repaints. A theme restyles the map with the same slot mixins it uses for every other component: see Theme Slots Theme Slots Every part of a screen a theme can recolor, from whole-screen colors down to single components, utilities, borders, and chart series This page lists every slot: each part of a screen a theme can recolor, and the mixin that sets it. A slot takes a framework token, not a raw color, so whatever you map still renders correctly on every device. .
// Nothing here names a device or a color: the slots resolve per screen.
TRMNLMaps.watch(el, function () {
return new maplibregl.Map(TRMNLMaps.options({
el: el, preset: "streets", center: [151.2153, -33.8568], zoom: 12
}));
});
// A theme re-points a map slot like any other component slot (SCSS):
// @include theme-slots.bg-slot("map-water", "blue-65");
// @include theme-slots.bg-slot("map-road-minor", "gray-50");
Floating card
A card belongs inside the map container, which
positions its children already, so
top--3 left--3 measures from the map's own
corner. Give the card a size and clamp its text, since nothing measures an element that is out of flow.
Position
Position
Take an element out of flow and set its distance from each edge of its container
Use Position to put one element over another instead of beside it. Offsets on the spacing scale hold it a set distance from the edges of its container, and a short stacking scale says which of two overlapping elements is drawn on top.
has the offset and stacking classes.
The bottom right corner belongs to the data credit, which has to stay visible. Place names land wherever the map data puts them, so a smaller card leaves more of them legible.
Over a detailed map, outline--muted
Outline
Outline
Draw a pixel-perfect dotted rounded border on any element
The Outline utility draws a pixel-perfect dotted rounded border on any element. On 1-bit displays it places single-pixel dots at exact integer coordinates with pure CSS gradients; on 2-bit, 4-bit, and full-color displays it draws a standard CSS border with border-radius instead.
draws that edge in a mid gray. It still separates the card from the
map, with a quieter line across whatever it covers.
A card over a map takes bg--white or
bg--black and no shade in between: those two
are the only solid fills on 1-bit, and small text over a dither tile is unreadable. Its edge comes from
outline, which reads against a light and a dark
map alike, and never from a box-shadow, which ePaper renders as a smear of dither.
<!-- The map fills the layout; the cards are its positioned children. -->
<div class="view view--full">
<div class="layout layout--col">
<div id="map-overlay" class="map stretch w--full">
<div class="absolute top--3 left--3 z--2 p--2 bg--white outline">
<span class="label label--small">Shoreditch</span>
<span class="value value--xsmall value--tnums">4 min</span>
</div>
<div class="absolute bottom--3 left--3 z--2 p--2 bg--white outline">
<span class="label label--small">Next bus 17:12</span>
</div>
</div>
</div>
<div class="title_bar">
<img class="image image--adaptive" src="https://usetrmnl.com/images/plugins/trmnl--render.svg" alt="TRMNL Logo">
<span class="title">Map</span>
<span class="instance">Floating card</span>
</div>
</div>
<script type="text/javascript">
TRMNLMaps.watch("map-overlay", function () {
return new maplibregl.Map(TRMNLMaps.options({
el: "map-overlay", preset: "streets", center: [-0.0779, 51.5252], zoom: 14
}));
});
</script>
Strava activity
Strava returns each activity with map.summary_polyline,
a Google encoded polyline. Decode it, fit the camera to it, draw the route with a start dot and an end ring,
and show the activity stats in the framework layout.
In a plugin the activity object comes from your
polling URL; the example inlines one run. The roads are grays and the route takes the ink, so it reads
on top; the minimal preset keeps the streets under it quiet.
<!-- import MapLibre GL JS and its stylesheet -->
<script src="https://trmnl.com/js/maplibre-gl/5.24.0/maplibre-gl.js"></script>
<link href="https://trmnl.com/js/maplibre-gl/5.24.0/maplibre-gl.css" rel="stylesheet">
<div class="view view--full">
<div class="layout layout--col gap--small">
<div class="flex flex--col">
<span class="title title--small">{{ activity.name }}</span>
<span class="description">Golden Gate Park, San Francisco</span>
</div>
<div id="map-strava" class="map stretch w--full"></div>
<div class="grid grid--cols-4">
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums" id="strava-distance"></span>
<span class="label">Distance</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums" id="strava-time"></span>
<span class="label">Time</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums" id="strava-pace"></span>
<span class="label">Pace</span>
</div>
</div>
<div class="item">
<div class="meta"></div>
<div class="content">
<span class="value value--small value--tnums" id="strava-elevation"></span>
<span class="label">Elev Gain</span>
</div>
</div>
</div>
</div>
<div class="title_bar">
<img class="image image--adaptive" src="/images/plugins/trmnl--render.svg" alt="TRMNL Logo">
<span class="title">Strava</span>
<span class="instance">Activity</span>
</div>
</div>
<script type="text/javascript">
// One activity as the Strava API returns it (metres and seconds). In a
// plugin this is your polling data: {{ activity | json }}.
var activity = {
name: "Morning Run",
distance: 9247,
moving_time: 2856,
total_elevation_gain: 94,
map: { summary_polyline: "{lpeFz_mjVNtHt@tHNtHt@tHf@tKnAvKz@tKf@`FnAbFz@`F{@xFSxF{@xFSxFg@nK?nKSnKMpLZnLDpL?dIf@bI?bIf@dI?xRf@vRRxRvBR~CRjCR`AsIfBsI`AsIfBsIDqLn@oLXqLg@sN?sNSsNR}Iz@}IR}Iz@}IMkHZkHDkHwBqLoAoLcBqLcDuC}BuCeDuC{BuCwBkHoAkHcBkH}AuEs@sEqAuE" }
};
function whenReady(cb) {
var tries = 0;
(function attempt() {
if (window.TRMNLMaps && window.maplibregl) return cb();
if (++tries > 200) return;
setTimeout(attempt, 50);
})();
}
function clock(seconds) {
var m = Math.floor(seconds / 60), s = Math.round(seconds % 60);
return m + ":" + (s < 10 ? "0" : "") + s;
}
whenReady(function () {
var el = "map-strava";
var km = activity.distance / 1000;
document.getElementById("strava-distance").textContent = km.toFixed(1) + " km";
document.getElementById("strava-time").textContent = clock(activity.moving_time);
document.getElementById("strava-pace").textContent = clock(activity.moving_time / km) + " /km";
document.getElementById("strava-elevation").textContent = "+" + Math.round(activity.total_elevation_gain) + " m";
// decodePolyline() returns [lng, lat] pairs, ready for GeoJSON.
var coords = TRMNLMaps.decodePolyline(activity.map.summary_polyline);
TRMNLMaps.watch(el, function () {
var map = new maplibregl.Map(TRMNLMaps.options({ el: el, preset: "minimal" }));
map.on("load", function () {
TRMNLMaps.route(map, coords, { el: el, width: 4 });
TRMNLMaps.dot(map, coords[0], { el: el, id: "start", radius: 5 });
TRMNLMaps.dot(map, coords[coords.length - 1], { el: el, id: "end", radius: 5, hollow: true });
});
// fit() frames the run on an integer zoom, no animation.
TRMNLMaps.fit(map, coords, { padding: TRMNLPaint.px(20, { el: el }), maxZoom: 15 });
return map;
});
});
</script>