From 3cd3025116469c4a7a533479da88890356a5e26f Mon Sep 17 00:00:00 2001 From: David Date: Sat, 3 Oct 2026 09:20:24 +0200 Subject: [PATCH] Map: dark tiles from Esri, because CARTO retired keyless access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/PlantMap.tsx | 64 +++++++++++++++++++++++++++++------ components/PlantMapLoader.tsx | 4 +-- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/components/PlantMap.tsx b/components/PlantMap.tsx index 5a2752a..def79c7 100644 --- a/components/PlantMap.tsx +++ b/components/PlantMap.tsx @@ -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: '© OpenStreetMap contributors', }, dark: { - url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", - attribution: '© OpenStreetMap © CARTO', + attribution: + 'Tiles © Esri — 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 , 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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]")} > - + {dark ? ( + + ) : ( + + )} {/* Clustering: machines sharing a point collapse into one numbered @@ -282,7 +326,7 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) { )} {/* Legend */} -
+
Active @@ -330,7 +374,7 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) {
{/* ── Side panel ──────────────────────────────────────────────── */} -