Skip to content

Replace CARTO raster tiles with OpenStreetMap vector tiles - #53816

Open
bramkragten wants to merge 9 commits into
devfrom
osm-vector-tiles
Open

Replace CARTO raster tiles with OpenStreetMap vector tiles#53816
bramkragten wants to merge 9 commits into
devfrom
osm-vector-tiles

Conversation

@bramkragten

@bramkragten bramkragten commented Aug 26, 2026

Copy link
Copy Markdown
Member

Proposed change

The CARTO basemap we have been using started rendering an "API KEY REQUIRED" overlay into its tiles, which breaks every map in the frontend (#53800). Rather than hunting for another free raster host, this moves the base map to vector tiles, which we wanted to do anyway.

The base map is now drawn by MapLibre GL through maplibre-gl-leaflet, on the Shortbread vector tiles the OpenStreetMap Foundation serves. Everything else stays on Leaflet — markers, clustering, zone editing, paths, the scale control — so only the base layer changes and the diff stays small. Going fully native MapLibre would mean replacing leaflet.markercluster and leaflet-draw as well, and we would still need Leaflet around for the fallback below.

Fallback. MapLibre needs WebGL2, which rules out iOS below 15 (iPad 2/3/4 and Air 1, common as wall panels) and devices whose GPU driver the browser blocklists. Those get OSM raster tiles instead, which the OSMF gives permission to use. MapLibre draws raster sources through WebGL too, so the fallback has to stay a plain Leaflet tile layer. Detection is a runtime canvas.getContext("webgl2") probe, deliberately not gated on the bundle: the legacy bundle is picked by user agent, and nearly everything in that range does have WebGL2.

Self-hosted assets. vector.openstreetmap.org sets CORS headers on the tile endpoint only, so the style, its SDF glyphs and its sprites are served from our own /static/map/. A new gulp task (build-map-assets) assembles them from pinned, sha256-verified VersaTiles releases and generates a light (colorful) and dark (eclipse) style with @versatiles/style. Self-hosting is also the better end state: it is one less host that can disappear on us, and it makes the styles ours to theme.

Shipped size is 5.4 MB (102 glyph files, 4 sprites, 2 styles). The full Noto Sans SDF set is 74 MB; CJK, kana and hangul are rendered with a font from the device via localIdeographFontFamily, which is 90% of that, and bold is limited to U+0000–04FF because the style only uses it for motorway shields. Coverage was checked against what OSM serves: the sprites are byte-identical, and the glyph codepoint sets match per range.

MapLibre is a lazily loaded chunk of ~276 KB gzip, fetched only when a map is shown on a WebGL2 device.

Referrer. The OSMF vector tile policy asks for a referrer, and we send none (index.html sets same-origin). transformRequest now sets referrerPolicy: "origin" for the tile requests only, so the OSMF sees the origin and never the page URL. What they do with it is covered by the OSMF privacy policy. Worth a conscious decision from reviewers: for Nabu Casa remote URLs the origin is a per-instance identifier.

Dark mode is now a real dark cartography instead of a CSS invert() filter. The filter is still applied, but scoped to the raster fallback.

Two things the swap forced, both easy to miss:

  • The Leaflet map gets an explicit zoom range. It used to inherit one from the raster tile layer, and markercluster throws Map has no maxZoom specified without it.
  • The style is fetched and its sprite URL made absolute — MapLibre rejects a relative one. The glyph URL stays relative so its {fontstack}/{range} placeholders are not percent-encoded.

maplibre-gl is pinned to 5.24.0 on purpose. v6 is ESM-only and derives its worker URL from import.meta.url, which rspack replaces with a build-machine file:// path; it then falls back to new Worker("") and nothing renders. v5 inlines its worker, and is also what the OSM demo runs.

Screenshots

New vector tiles:
image
image

New raster tiles (fallback for older devices):
image
image

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New feature (thank you!)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

  • This PR fixes or closes issue: fixes Map shows API KEY REQUIRED overlay #53800
  • This PR is related to issue or discussion:
  • Link to documentation pull request:
  • Link to developer documentation pull request:
  • Link to backend pull request:

Worth checking before merge:

  • CSP. MapLibre v5 creates its worker from a blob URL. If core sends a CSP without worker-src blob:, the map breaks. maplibre-gl-csp.js is the escape hatch if so.
  • WebGL contexts. Browsers keep roughly 16 per page and drop the oldest, which a dashboard full of map cards will hit. Measured with twenty cards: the first four lost their context and never got it back, because nothing frees a slot for MapLibre to reclaim. Those now fall back to raster tiles after a grace period, so a transient loss keeps its vector layer. Measured again: sixteen vector, four raster, none blank.
  • The choice of colorful/eclipse is a design decision that has not been made yet; both are one constant in build-scripts/gulp/map-assets.js.
  • Tiles resolve through shortbread_v1/tilejson.json rather than a hardcoded URL, as the OSMF asks. That means one extra request before tiles start, and no tiles at all if the TileJSON is unreachable, where a hardcoded URL would still have worked. Their stale-if-error=86400 covers most of that.

Checklist

  • I understand the code I am submitting and can explain how it works.
  • The code change is tested and works locally.
  • There is no commented out code in this PR.
  • I have followed the perfect PR recommendations
  • Any generated code has been carefully reviewed for correctness and compliance with project standards.

If user exposed functionality or configuration variables are added/changed:

To help with the load of incoming pull requests:

The CARTO basemap started rendering an "API KEY REQUIRED" overlay into
its tiles, which breaks every map in the frontend.

Draw the base map with MapLibre GL through maplibre-gl-leaflet, on the
Shortbread vector tiles the OpenStreetMap Foundation serves. Everything
else about the map stays on Leaflet - markers, clustering, zone editing,
paths and the scale control are untouched - so this swaps the base layer
and nothing else.

Browsers without WebGL2 fall back to OSM raster tiles, which the OSMF
allows us to use. MapLibre draws raster sources through WebGL as well,
so that fallback has to stay a plain Leaflet tile layer.

The style, its SDF glyphs and its sprites are served from our own
/static/map/, because vector.openstreetmap.org sets CORS headers on the
tile endpoint only. A new gulp task assembles them from pinned and
checksummed VersaTiles releases. CJK, kana and hangul are rendered with
a font from the device, which keeps the shipped glyph set at 5.4 MB
instead of 74 MB.

Dark mode is now a real dark cartography rather than a CSS invert filter
over the raster tiles.

Two details that the base layer swap forced:

- The Leaflet map gets an explicit zoom range. It used to inherit one
  from the raster tile layer, and marker clustering throws without it.
- The generated style is fetched and its sprite URL made absolute.
  MapLibre rejects a relative one, while the glyph URL has to stay
  relative so its {fontstack} and {range} placeholders survive.

Fixes #53800

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the Build Related to building the code label Aug 26, 2026
@bramkragten bramkragten added this to the 2026.9 milestone Aug 26, 2026
@maplibre/mlt, a dependency of maplibre-gl, is dual licensed. Both
halves are already on the allowlist individually, so the expression is
unambiguously permissive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Replaces the broken CARTO raster basemap with self-hosted MapLibre-styled OpenStreetMap vector tiles and a raster fallback.

Changes:

  • Adds vector/raster base-layer selection and dark-mode switching.
  • Builds verified map styles, glyphs, and sprites.
  • Adds MapLibre dependencies and license support.

Reviewed changes

Copilot reviewed 8 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
.gitignore Ignores downloaded map archives.
build-scripts/gulp/gather-static.js Copies generated map assets.
build-scripts/gulp/index.mjs Registers the map-assets task.
build-scripts/gulp/map-assets.js Generates and verifies map assets.
package.json Adds mapping dependencies.
script/check-licenses Allows a new permissive license expression.
src/common/dom/setup-leaflet-map.ts Configures zoom bounds and base layers.
src/common/map/base-layer.ts Implements vector and raster basemaps.
src/components/map/ha-map.ts Integrates base-layer lifecycle and themes.
yarn.lock Locks new dependencies.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/common/dom/setup-leaflet-map.ts Outdated
Comment thread src/common/map/base-layer.ts
Comment thread src/components/map/ha-map.ts
Comment thread src/common/map/base-layer.ts Outdated
Comment thread src/common/map/base-layer.ts Outdated
bramkragten and others added 3 commits August 26, 2026 22:27
Bold is cut to U+0000-04FF because the styles we ship only use it for
motorway shields, which carry road refs. That is half of the glyph set,
and it silently breaks the moment bold is used for a name instead: the
`neutrino` style, for one, sets country and state labels in bold, which
would turn to tofu outside Latin, Greek and Cyrillic.

Fail the build in that case rather than ship a map that loses its labels
in half the world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The OSMF asks consumers to point at
https://vector.openstreetmap.org/shortbread_v1/tilejson.json rather than
hardcode the tile URL, so they can move the tiles without every client
needing a release. It also carries the attribution, zoom range and
bounds, so those stop being baked into our style.

The style builder can only write a tile URL, so the source is repointed
afterwards, keyed on the URL it is replacing: if @versatiles/style ever
stops emitting that, the build fails instead of quietly leaving the
style on the builder's own default host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The build asserted that the generated style carried the tile URL we had
handed the style builder, before replacing it with the TileJSON. That
assertion held, but it left a hardcoded OSM tile URL in the source that
looked like it mattered at runtime while it could never ship: the tile
URL is resolved from the TileJSON, and the styles we write contain no
tile template at all.

Key the check on the shortbread styles carrying exactly one source
instead. That still catches the failure that matters - the builder's own
host ending up in a shipped style - without naming a URL we do not use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
balloob
balloob previously approved these changes Aug 26, 2026
Three problems the Copilot review found, all in the path that is meant
to guarantee a working map:

The vector layer was added to the map outside the guarded path. The
Leaflet adapter only constructs the MapLibre map in `onAdd`, so that is
where a refused WebGL context, a blocked worker or a rejected blob URL
throws - after the WebGL2 probe has already succeeded. That left no
fallback and a half initialized map. Adding the layer now happens inside
`createBaseLayer`, which tears down the partial layer and falls back to
raster tiles.

Theme changes fetch a style, so a burst of them could resolve out of
order and leave the map on the wrong theme, with the tracked mode saying
otherwise so later toggles did nothing. Only the newest request may
touch the map now.

Setting up now awaits a style fetch, so `ha-map` could disconnect while
that is pending: `disconnectedCallback` had no map to tear down, and the
continuation installed a live map, and its WebGL context, on a detached
host whose container was too initialized to set up again on reconnect.

None of this is reachable from the `ha-map` tests, which run in jsdom
and only ever take the raster branch, so the fallback contract now has
its own tests with MapLibre mocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the Tests: Unit Related to Vitest unit tests label Aug 26, 2026
@bramkragten
bramkragten requested a balanced review from Copilot August 26, 2026 21:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 11 changed files in this pull request and generated 2 comments.

Comment thread src/common/map/base-layer.ts
Comment thread src/common/map/base-layer.ts Outdated
bramkragten and others added 2 commits August 26, 2026 23:45
Twenty map cards on one page was worth measuring, and the Copilot review
was right: browsers cap live WebGL contexts at around 16 and drop the
oldest, so the first four cards lost theirs. MapLibre does call
preventDefault, so it is asking for the context back, but nothing frees
a slot and webglcontextrestored never arrives. Those cards stayed blank
for good.

Swap them to raster tiles instead, after a grace period so a transient
loss - a GPU reset, where the browser does restore - keeps its vector
layer. Measured again with the same twenty cards: sixteen vector, four
raster, none blank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more from the Copilot review, both real.

The raster tiles are plain images, so they inherited the page's
same-origin referrer policy and went out with no Referer at all. OSM's
raster tile policy asks for one, and being blocked would take out the
fallback on exactly the devices that depend on it. Leaflet takes a
per-layer `referrerPolicy`, so the fallback now sends its origin like
the vector tiles already did.

Rolling a failed theme request back to the opposite of what it asked for
is only right while a single request is in flight. Request dark, then
light, and let light fail: the map is still light, but the tracked mode
flipped to dark, so asking for dark again did nothing and the map stayed
light for good. Track what is applied separately from what was
requested, and roll back to the former.

The test for that one passed against the old code until it waited for
the rejections to land, so it now does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 11 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

build-scripts/gulp/map-assets.js:188

  • The Noto Sans glyphs are converted derivatives covered by OFL-1.1, but this archive is generated with glyph files only and this filter ships only .pbf entries. The resulting /static/map/fonts distribution therefore contains neither the required copyright notice nor the OFL text; OFL condition 2 requires every bundled copy to include both. Add the Noto license notice to the shipped map assets (and the project's third-party license inventory) before redistribution.
    extract({
      file: fontArchive,
      cwd: path.join(outputDir, "fonts"),
      filter: keepGlyph,
    }),

Comment thread src/common/map/base-layer.ts Outdated
The Copilot review is right that the adapter ignores
`options.attribution`: it overrides `getAttribution` to return either its
own `customAttribution` option or the attributions of the style's
sources. The assignment was dead.

The conclusion that the map therefore shows no copyright link is not,
though. The source attribution is displayed, and it comes from the
TileJSON:

  Leaflet | © <a href="…/copyright">OpenStreetMap</a> contributors

which is a better attribution than the one we were setting - it says
"contributors", as OSM asks - and it follows whoever ends up serving the
tiles instead of being frozen at build time. Setting `customAttribution`
would replace it with ours, so this drops the dead line instead and says
where the credit comes from.

The raster fallback is a plain Leaflet tile layer, which does read the
option, so the constant stays for that one - now with "contributors" as
well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Build Related to building the code cla-signed Tests: Unit Related to Vitest unit tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Map shows API KEY REQUIRED overlay

3 participants