Outdoor Style

A comprehensive topographic map style built for hiking, cycling, skiing, and general outdoor navigation. Based on OpenStreetMap data, rendered with MapLibre GL.

Style URLhttps://api.mapriot.com/styles/outdoor.json?apiKey=<key>

Usage with MapLibre GL JS

const MAPRIOT_APIKEY = 'YOUR_API_KEY';

const map = new maplibregl.Map({
  container: 'map',
  style: 'https://api.mapriot.com/styles/outdoor.json',
  center: [14.42, 50.08],
  zoom: 13,
  transformRequest: (url) => {
    if (url.startsWith('https://api.mapriot.com') && !url.includes('apiKey')) {
      return {
        url: url + (url.includes('?') ? '&' : '?') + 'apiKey=' + MAPRIOT_APIKEY,
      };
    }
    return { url };
  },
});

The transformRequest callback is required to append your API key to all tile, font, and sprite requests that the style triggers. See the MapLibre GL JS example for a complete setup including 3D terrain.


Label language

By default, labels show the local name from OpenStreetMap (name:latin). The mapriot vector tiles also carry:

AttributeWhat it holds
name:cs, name:en, name:sk, name:pl, name:de, name:esThe translation, only when it differs from the local name
name_translitFor a name in a non-Latin script, its romanization: Athína for Αθήνα
name_nonlatintrue on a non-Latin name that has no romanization
name_localFor a name that mixes a Latin form with another script (Casablanca ⵜⴰⴷⴷⴰⵔⵜ الدار البيضاء), the non-Latin part (الدار البيضاء); name_translit then holds the Latin part

A simple translated label falls back to the local name:

['coalesce', ['get', 'name:en'], ['get', 'name:latin']]

The style already labels this way: every name in a non-Latin script is followed by its romanization, smaller — beneath it for places, beside it for streets. You get that without any code.

To switch the map to a language, rewrite those labels: a name in a non-Latin script keeps its own script first, with the translation (else the romanization) after it; a Latin name and a country name show the translation. The snippet below handles both the style’s own labels and a plain {name:latin} one:

const LOCAL = new Map(); // original text-fields, so 'default' restores local names

function setLabelLanguage(map, lang) {
  const local = ['coalesce', ['get', 'name_local'], ['get', 'name:latin']];
  const translit = ['get', 'name_translit'];
  const translation = ['get', 'name:' + lang];
  const translated = ['has', 'name:' + lang];
  const nonLatin = ['any', ['has', 'name_translit'], ['has', 'name_nonlatin']];
  // [first name, second name]; the second starts with sep, or is ''
  const parts = (sep, country) => country
    ? [['coalesce', translation, local],
       ['case', ['all', ['!', translated], ['has', 'name_translit']], ['concat', sep, translit], '']]
    : [['case', nonLatin, local, ['coalesce', translation, local]],
       ['case', ['!', nonLatin], '', translated, ['concat', sep, translation],
         ['has', 'name_translit'], ['concat', sep, translit], '']];
  // the style's own name sections: [local, {}, romanization, { 'font-scale': 0.8 }]
  const localJson = JSON.stringify(local);
  // format can't sit inside concat: there, use the same two names as one string
  const localize = (tf, sep, country, inString = false) => {
    const [first, second] = parts(sep, country);
    const sections = [first, {}, second, { 'font-scale': 0.8 }];
    if (Array.isArray(tf) && tf[0] === 'format') {
      const out = [...tf];
      let swapped = false;
      for (let i = 1; i + 3 < out.length; i += 2) {
        if (JSON.stringify(out[i]) !== localJson) continue;
        out[i] = first; out[i + 2] = second; out[i + 3] = { 'font-scale': 0.8 };
        swapped = true;
      }
      if (swapped) return out;
    }
    if (typeof tf === 'string') {
      if (!tf.includes('{name:latin}')) return tf;
      // '{name:latin}' templates -> format expression
      return ['format', ...tf.split(/(\{[^{}]+\})/).filter(Boolean).flatMap((p) =>
        p === '{name:latin}' ? sections : [p.startsWith('{') ? ['coalesce', ['get', p.slice(1, -1)], ''] : p, {}])];
    }
    if (Array.isArray(tf)) {
      if (tf[0] === 'get' && tf[1] === 'name:latin') return inString ? ['concat', first, second] : ['format', ...sections];
      const str = inString || !['case', 'step', 'match', 'coalesce'].includes(tf[0]);
      return tf.map((x) => (Array.isArray(x) ? localize(x, sep, country, str) : x));
    }
    if (tf && tf.stops) { // legacy zoom function -> step expression
      return tf.stops.reduce((e, [z, v], i) =>
        (i ? [...e, z, localize(v, sep, country)] : [...e, localize(v, sep, country)]), ['step', ['zoom']]);
    }
    return tf;
  };
  for (const layer of map.getStyle().layers) {
    const tf = LOCAL.get(layer.id) ?? layer.layout?.['text-field'];
    if (tf === undefined || !JSON.stringify(tf).includes('name:latin')) continue;
    LOCAL.set(layer.id, tf);
    const placement = layer.layout?.['symbol-placement'];
    const sep = placement === 'line' || placement === 'line-center' ? '   ' : '\n';
    const country = /"class","(country|continent)"/.test(JSON.stringify(layer.filter ?? ''));
    map.setLayoutProperty(layer.id, 'text-field', lang === 'default' ? tf : localize(tf, sep, country));
  }
}

map.on('load', () => setLabelLanguage(map, 'en'));

Call it again after map.setStyle() (on style.load), because that resets the layers. With MapLibre Native (iOS/Android), set the same expressions as each symbol layer’s text-field.


Data sources

The style references four tile sources. Each source tile request costs 1 unit.

Source IDTypeZoom rangeDescription
mapriotvector0–14+Worldwide OSM map data→
reliefraster0–4Shaded relief (Natural Earth Data)→
hillshadingraster-dem5–12 (overzoomed beyond)DEM for hillshading and 3D terrain→
contoursvector11+Elevation contour lines→

The terrain layers transition smoothly: relief provides broad terrain shape at low zoom (z0–4), then hillshading takes over from z5 for higher-resolution slope shading. Contour lines appear from z11.


What’s included

Terrain — contour lines (10 m or 5 m intervals depending on region), hillshading, 3D terrain support, low-zoom shaded relief

Trails and routes — color-coded hiking trails parsed from OSM osmc:symbol tags with proper stacking when routes share a path, cycling routes, hiking difficulty grading (SAC scale)

Winter sports — ski lifts, chairlifts, gondolas

Transportation — full road hierarchy from motorways to footpaths, with surface type distinction (paved/unpaved), bridges, tunnels

Places — city, town, village, hamlet, and neighbourhood labels with population-based ranking

Water — rivers, streams, lakes, coastlines, intermittent waterways, springs, waterfalls

Land cover and use — forests, parks, residential areas, farmland, glaciers, bare rock, scree, cliffs, military areas

Points of interest — mountain peaks (with prominence ranking), shelters, campsites, viewpoints, restaurants, public transport stops, parking areas, and more


Sprites and fonts

The style references sprite (icon) and font (glyph) assets hosted on the same API. These require your API key and are authenticated automatically via transformRequest. They are free — no billing.


Custom styles

If you build your own style using the mapriot vector source, you will need to supply your own sprite and font (glyph) assets. The ones served by our API are designed specifically for the outdoor style.

We can create custom map styles tailored to your project — contact us to discuss.


Style version

https://api.mapriot.com/styles/style-versions.json?apiKey=<key>

Returns the current version of each style. Poll this to check for updates without fetching the full style JSON.