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.