Map: dark tiles from Esri, because CARTO retired keyless access

CARTO now answers every keyless basemap request with HTTP 200 and a valid
256x256 PNG that is just a dark square watermarked "API KEY REQUIRED".
Leaflet accepts it as a tile and paints it, so the map silently filled with
the warning — no console error, nothing in the network tab to catch it.

Esri's World Dark Gray Canvas is a genuine dark basemap and needs no key.
Its ArcGIS REST service orders the tile path {z}/{y}/{x}, the reverse of
what Leaflet knows how to substitute, so a getTileUrl override is the whole
fix. react-leaflet always instantiates Leaflet's own TileLayer, so the
custom class is added to the map directly and torn down on unmount rather
than passed as a prop.
This commit is contained in:
David 2026-10-03 09:20:24 +02:00
parent 813c187272
commit 3cd3025116
2 changed files with 56 additions and 12 deletions

View file

@ -33,20 +33,61 @@ export function hasCoords(a: MapAsset): boolean {
const DEFAULT_CENTER: [number, number] = [-26.05, 28.05];
const DEFAULT_ZOOM = 10;
// OSM's standard tile servers have a usage policy that forbids heavy/commercial
// traffic. This is fine while a handful of staff use the dashboard, but move to
// a proper tile provider before the site count grows.
// Tile providers. Light mode stays on OpenStreetMap's standard tiles. Dark mode
// uses Esri's World Dark Gray Canvas, which is a genuine dark basemap and needs
// no API key.
//
// Do not switch dark mode back to CARTO (basemaps.cartocdn.com). CARTO retired
// keyless basemap access: it now answers every tile request with HTTP 200 and a
// 256x256 PNG that is just a dark square watermarked "API KEY REQUIRED". Leaflet
// treats that as a valid tile and paints it, so the map fills with the warning
// and nothing appears in the console or the network tab as an error.
//
// OSM's usage policy still forbids heavy/commercial traffic. That is fine for a
// handful of staff; move both providers to a paid tier before the site count grows.
const TILES = {
light: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
dark: {
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
attribution:
'Tiles &copy; <a href="https://www.esri.com/">Esri</a> &mdash; Esri, DeLorme, NAVTEQ',
},
} as const;
const ESRI_DARK_SERVICE =
"https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile";
/**
* Esri's ArcGIS REST service is keyless but orders the tile path {z}/{y}/{x} —
* the reverse of the {z}/{x}/{y} that every other provider uses and the only
* order Leaflet knows how to substitute. Overriding getTileUrl is the whole fix.
*/
const EsriDarkLayer = L.TileLayer.extend({
getTileUrl(coords: L.Coords) {
return `${ESRI_DARK_SERVICE}/${coords.z}/${coords.y}/${coords.x}`;
},
}) as unknown as typeof L.TileLayer;
/**
* react-leaflet always instantiates Leaflet's own TileLayer for <TileLayer>, so
* the Esri class cannot be slotted in as a prop. Adding it to the map directly
* and tearing it down on unmount is the supported way to swap the class.
*/
function DarkTiles({ attribution }: { attribution: string }) {
const map = useMap();
useEffect(() => {
// The url argument is never read: getTileUrl above fully overrides it.
const layer = new EsriDarkLayer(ESRI_DARK_SERVICE, { attribution });
layer.addTo(map);
return () => {
map.removeLayer(layer);
};
}, [map, attribution]);
return null;
}
const escapeHtml = (s: string) =>
s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
@ -208,7 +249,6 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) {
return () => window.clearTimeout(t);
}, [notice]);
const tiles = dark ? TILES.dark : TILES.light;
const placing = placingId ? assetsState.find((a) => a.id === placingId) : null;
return (
@ -221,7 +261,11 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) {
scrollWheelZoom
className={cn("w-full", "h-[52vh] min-h-[320px]", "lg:h-[calc(100vh-15rem)] lg:min-h-[560px]")}
>
<TileLayer url={tiles.url} attribution={tiles.attribution} />
{dark ? (
<DarkTiles attribution={TILES.dark.attribution} />
) : (
<TileLayer url={TILES.light.url} attribution={TILES.light.attribution} />
)}
<MapController focus={focus} />
{/* Clustering: machines sharing a point collapse into one numbered
@ -282,7 +326,7 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) {
)}
{/* Legend */}
<div className="absolute left-3 bottom-3 z-[500] flex flex-col gap-1.5 rounded-xl bg-white/95 dark:bg-[#1e2732]/95 backdrop-blur border border-gray-200 dark:border-white/10 px-2.5 py-2 text-xs">
<div className="absolute left-3 bottom-3 z-[500] flex flex-col gap-1.5 rounded-xl bg-white/95 dark:bg-surface/95 backdrop-blur border border-gray-200 dark:border-white/10 px-2.5 py-2 text-xs">
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#00798C] border-2 border-white shadow" />
<span className="text-gray-600 dark:text-gray-300">Active</span>
@ -330,7 +374,7 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) {
</div>
{/* ── Side panel ──────────────────────────────────────────────── */}
<aside className="rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-[#131a21] shadow-sm overflow-hidden">
<aside className="rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-surface shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 dark:border-white/5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Machines</h2>
@ -345,7 +389,7 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) {
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter code, name, type…"
className="w-full min-h-[44px] pl-9 pr-3 rounded-lg text-sm bg-gray-50 dark:bg-white/5 border border-transparent focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none"
className="w-full min-h-[44px] pl-9 pr-3 rounded-lg text-sm bg-gray-50 dark:bg-white/5 border border-transparent focus:bg-white dark:focus:bg-white/10 focus:border-[#30638E] focus:outline-none"
/>
</div>

View file

@ -17,13 +17,13 @@ const PlantMap = dynamic(() => import("./PlantMap").then((m) => m.PlantMap), {
function MapSkeleton() {
return (
<div className="grid lg:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
<div className="relative rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm bg-gray-100 dark:bg-[#1e2732] h-[52vh] min-h-[320px] lg:h-[calc(100vh-15rem)] lg:min-h-[560px] grid place-items-center">
<div className="relative rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm bg-gray-100 dark:bg-surface h-[52vh] min-h-[320px] lg:h-[calc(100vh-15rem)] lg:min-h-[560px] grid place-items-center">
<div className="text-center">
<Loader2 className="w-6 h-6 mx-auto text-[#30638E] animate-spin" />
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">Loading map…</p>
</div>
</div>
<div className="rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-[#131a21] shadow-sm overflow-hidden">
<div className="rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-surface shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 dark:border-white/5">
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-white/10 animate-pulse" />
<div className="mt-3 h-11 rounded-lg bg-gray-100 dark:bg-white/5 animate-pulse" />