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 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 }) and dot(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 .

#

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. url is 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().

14°C South Bank
17:53 Sunset
2.1 km To the office
TRMNL Logo Map Streets
<!-- 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&deg;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.

streets
minimal
outline
TRMNL Logo Map Styles
// 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.

6.8 km Distance
24 min Ride
+41 m Climb
TRMNL Logo Map Commute
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 .

TRMNL Logo Map Sydney Harbour
// 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 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 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.

Shoreditch 4 min
Next bus 17:12
TRMNL Logo Map Floating card
<!-- 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.

Morning Run Golden Gate Park, San Francisco
9.2 km Distance
47:36 Time
5:09 /km Pace
+94 m Elev Gain
TRMNL Logo Strava Activity
<!-- 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>