ts-maps

Styles & theming

On this page 16

Two separate things wear the word "theme" on a map, and it helps to keep them apart:

  • the basemap style — the colours of land, water, roads and labels, which comes from a StyleSpec;
  • the map chrome — the controls, popups, tooltips, scale bar and attribution the library draws on top.

They are set independently, because they can legitimately disagree: a dark basemap on a machine set to light mode still wants dark controls.

Built-in basemap styles

styles.dark() and styles.light() build a complete StyleSpec for you:

import { styles, TsMap } from 'ts-maps'

const map = new TsMap('map', {
  center: [34.02, -118.47],
  zoom: 14,
  theme: 'dark',
  style: styles.dark({
    tiles: 'https://example.com/tiles/{z}/{x}/{y}.pbf',
    attribution: '© OpenMapTiles © OpenStreetMap contributors',
  }),
})

They are functions, not constants, because ts-maps ships no tile service: a style only means something once it is pointed at a source. What the presets save you is the part worth not hand-writing — the layer order, the zoom ramps, and a palette that has been checked against overlaid data.

Light and dark share one layer skeleton and differ only in a palette table, so the two cannot drift apart as the style grows.

Choosing a source

The presets default to the OpenMapTiles schema, which is what the public services publish. A keyless option is OpenFreeMap, whose current tile URL is published through a TileJSON:

const tilejson = await fetch('https://tiles.openfreemap.org/planet').then(r => r.json())
map.setStyle(styles.dark({
  tiles: tilejson.tiles[0],
  attribution: '© OpenFreeMap © OpenStreetMap',
}))

Check the attribution each service requires — it is a licence condition, not a courtesy, and AttributionControl renders it for you.

resolveTileJSON does that fetch with what a real page needs around it: each source raced against a timeout, the next one tried when it fails, and the answer kept in sessionStorage so only the first page of a visit waits. It resolves to null when nothing answers, which is the cue for a raster fallback:

import { resolveTileJSON, styles } from 'ts-maps'

const found = await resolveTileJSON([
  'https://tiles.example.com/tiles.json', // your own, first
  'https://tiles.openfreemap.org/planet',
])
map.setStyle(found
  ? styles.light({ tiles: found.tiles, maxzoom: found.maxzoom, attribution: found.attribution })
  : styles.light({ mode: 'raster', tiles: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png' }))

For a source on a different schema, remap the layer names rather than forking the style:

styles.dark({
  tiles,
  sourceLayers: { transportation: 'road', transportationName: 'road_label' },
})

For a service that only publishes rendered images, mode: 'raster' wraps them in a one-layer style. None of the palette applies in that mode — the colours are baked into the pictures.

Adjusting the palette

Override individual entries without rebuilding the style:

styles.dark({ tiles, palette: { water: '#0b1f38', roadMajor: '#4a5160' } })

Fonts and offline tiles

Labels use three faces: regular for street and POI names, semibold for places, italic for water. Give any of them your app's own font stack; a face left out keeps the default:

styles.light({ tiles, fonts: { regular: ['Geist Medium'], semibold: ['Geist Semibold'] } })

offlineCache: true reads the basemap's tiles through the shared offline cache, so a page that saved an area with saveOfflineRegion draws it with no connection.

Satellite, hybrid and Driving

Apple's other map types are built the same way:

styles.satellite()                       // imagery alone
styles.hybrid({ tiles })                 // imagery, with the basemap's roads and names over it
styles.light({ tiles, emphasis: 'driving' }) // roads first: wider, major ones in amber, only the places a driver stops at

Imagery comes from a raster tile service. Esri World Imagery is the default because it needs no key; check its terms for your use, or pass your own with its credit: styles.hybrid({ tiles, imagery: 'https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=…', imageryAttribution: '© MapTiler © Maxar' }). A downloaded map keeps the imagery too, since it is a tile layer like any other.

Points of interest

The built-in styles show points of interest the way Apple Maps does: a round badge in the category's colour with a white glyph, and the name beside it in the same colour — a deeper shade on the light map, a lighter one on the dark. Food is orange, shopping gold, parks green, transit blue, health red, and so on across fifteen categories, mapped from the OpenMapTiles poi classes. More appear as you zoom in, most important first; bus and tram stops wait until street level (zoom 18) so they do not bury everything else downtown.

The badges ship with the library — no sprite sheet needed — and are drawn at high density the first time they are used. Any style can use them by name:

{
  id: 'my-cafes',
  type: 'symbol',
  source: 'places',
  layout: { 'icon-image': 'tsmap-poi-cafe', 'text-field': ['get', 'name'], 'text-anchor': 'left', 'text-offset': [1.15, 0] },
}

Names are tsmap-poi- plus one of food, cafe, nightlife, shopping, grocery, park, transit, health, education, lodging, culture, sports, civic, worship, car and place. A style's own sprite sheet always wins over a built-in of the same name.

Loading a style from a URL

setStyle accepts a URL as well as an object:

map.setStyle('https://example.com/style.json')

The map keeps its current style until the document arrives, fires styledata when it is applied, and error if the fetch fails. Out-of-order responses are discarded, so a slow first request cannot overwrite a later style.

Theming the chrome

const map = new TsMap('map', { theme: 'dark' })
map.setTheme('light')
map.getTheme() // 'light'

'auto' follows the operating system's prefers-color-scheme and keeps following it until the theme is changed again or the map is removed.

Under the hood this toggles one class on the container. Every colour the chrome draws with resolves through CSS custom properties, so a host page can retheme the controls to its own palette without fighting selector specificity:

.tsmap-container {
  --tsmap-accent: #e0245e;
  --tsmap-surface: #14161a;
  --tsmap-fg: #f2f3f5;
}

The full set is defined at the top of ts-maps.css: --tsmap-accent, --tsmap-surface, --tsmap-surface-hover, --tsmap-fg, --tsmap-fg-muted, --tsmap-fg-disabled, --tsmap-divider, --tsmap-scrim, --tsmap-hairline, --tsmap-scale-line, --tsmap-shadow, --tsmap-shadow-lg and --tsmap-tile-bg.

Because they live on the container rather than on :root, two maps on one page can carry different themes.

Switching both together

A theme switch in a real application usually moves all three surfaces at once:

function setMode(mode: 'dark' | 'light') {
  map.setStyle(mode === 'dark' ? styles.dark({ tiles }) : styles.light({ tiles }))
  map.setTheme(mode)
  document.body.dataset.theme = mode
}

Swapping between two styles that share source and layer ids takes the incremental path: paint properties are updated in place and the tiles already downloaded are re-rasterised, rather than being thrown away and fetched again.

See playground/incident-map for this wired up end to end.

Density fields and terrain shading

heatmap and hillshade layers are reachable from a style document, not only as layer instances.

{
  sources: {
    quakes: { type: 'geojson', data: '/quakes.geojson' },
    terrain: { type: 'raster-dem', tiles: ['https://example.com/dem/{z}/{x}/{y}.png'] },
  },
  layers: [
    { id: 'shade', type: 'hillshade', source: 'terrain',
      paint: { 'hillshade-exaggeration': 0.6, 'hillshade-shadow-color': '#000044' } },
    { id: 'heat', type: 'heatmap', source: 'quakes',
      paint: {
        'heatmap-radius': 24,
        'heatmap-weight': ['get', 'mag'],
        'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'],
          0, 'rgba(0, 0, 255, 0)', 0.5, 'lime', 1, 'red'],
      } },
  ],
}

heatmap-color is sampled into a colour ramp, so any expression the spec can write works rather than only the shapes anticipated here. A heatmap over a geojson source follows setSourceData, which is what makes it usable for a live feed.

A raster-dem source with no hillshade layer over it draws nothing. Its RGB encodes elevation rather than colour, so painting it directly gives coloured noise; the source is still registered for setTerrain and for elevation queries.

Sprites and glyphs

A style's sprite and glyphs URLs are now loaded rather than merely validated.

Sprites

{ "sprite": "https://example.com/sprites/basic" }

Two files are derived from that base — basic.json (an index of named icons) and basic.png (their pixels) — and on a retina display the @2x pair is preferred, falling back to 1x when a style publishes only one density. The icons land in each vector layer's icon atlas, which is what makes icon-image draw something:

{ id: 'poi', type: 'symbol', source: 'basemap', 'source-layer': 'poi',
  layout: { 'icon-image': ['get', 'class'], 'icon-size': 1 } }

Loading is asynchronous and setStyle does not wait for it: the basemap draws as soon as its tiles arrive, and tiles are repainted when the sheet lands. The map fires spriteload, or error if the sheet cannot be fetched — a missing sprite costs icons, not the map.

Several sheets

sprite also takes an array, which is how a style layers its own icons over a vendor sheet without either having to know the other's names:

{
  "sprite": [
    { "id": "base", "url": "https://example.com/sprites/basic" },
    { "id": "brand", "url": "https://example.com/sprites/ours" }
  ]
}

Ids are namespaced by sheet, so icon-image names an icon as "base:marker" or "brand:marker". Sheets load independently and land as they arrive, so one slow or missing sheet costs its own icons rather than everyone's.

SDF icons

An entry marked "sdf": true stores distance from the shape's edge in its alpha channel instead of the icon's own colours. Two things follow, and they are why the format is worth the trouble: one grey shape can be drawn in any colour a style asks for, and the edge is recovered by thresholding rather than resampled, so it stays sharp however far the icon is scaled.

{ id: 'pins', type: 'symbol', source: 'incidents',
  layout: { 'icon-image': 'pin', 'icon-size': 24 },
  paint: {
    'icon-color': ['match', ['get', 'category'], 'fire', '#ff5a36', '#3b82f6'],
    'icon-halo-color': '#0b0d10',
    'icon-halo-width': 2,
  } }

icon-color, icon-halo-color, icon-halo-width and icon-opacity are all honoured, and all take expressions. A halo needs a width as well as a colour — the spec's default colour is transparent black, so honouring the colour alone would ring every icon in the style. Ordinary picture sprites ignore icon-color; they carry their own colours.

Glyphs

{ "glyphs": "https://example.com/fonts/{fontstack}/{range}.pbf" }

Labels are drawn with the browser's own text engine, which is sharper on a canvas than resampling a distance field and needs no network — so this is not how text normally reaches the screen. The glyph server answers the case local fonts cannot: a style whose typeface the viewer does not have installed.

That check now happens automatically. When a text-font names a stack the viewer does not have and the style published a glyphs URL, the renderer draws from the server's distance fields instead of letting the font silently fall back to something else. The ranges a label needs are requested on first sight; the label is skipped until they arrive and then drawn, because a label appearing a frame late is better than one in the wrong typeface.

Ranges are fetched on demand and cached, and a range already in flight is shared rather than fetched twice — a font stack is 65,536 code points and a map shows a handful of blocks. Availability is checked once per stack rather than per label, and coloured glyph bitmaps are cached across frames.

The source is reachable directly too, for preloading:

const glyphs = map.getGlyphSource()
map.isFontAvailable(['Noto Sans Regular']) // false → the server is the answer
await glyphs?.loadForText('Noto Sans Regular', 'Santa Monica')

One limitation: a format label with per-section styling always uses local fonts, since its sections may each name a different one.

text-font

text-font is honoured. Style-spec font names carry weight and slant in the name — "Noto Sans Bold Italic" — because the SDK they were written for looks them up in a glyph server; those modifiers are split out and applied as CSS font properties, with the family stack falling back to the system font so a style naming an unavailable font still renders in something sensible.