Skip to content

Commit 2e17e04

Browse files
authored
fix(frontend): replace watermarked CARTO basemap with Esri Gray Canvas (#1034)
CARTO now requires an API key for basemaps.cartocdn.com. Keyless requests still return HTTP 200, so nothing appeared broken, but the tiles come back with an "API KEY REQUIRED" watermark burned into the PNG — the rain map has been rendering that across the whole viewport. A CARTO key is free (5M tiles/month, no account needed), but the request form wants a domain and halo is LAN-only, and CARTO says the raster PNG tier is being retired. Moving off it is the better fix. Esri's Gray Canvas is keyless, has matching dark and light variants, and needs no new dependencies. Two differences from CARTO worth noting: - Canvas splits geometry and place labels into separate services, so the base-layer effect now builds two layers. The labels get a dedicated pane at z-index 450 — above overlayPane so the 0.6-opacity precipitation wash doesn't smear them, below markerPane so they don't bury the home marker. - No {s} subdomains, the tile path is {z}/{y}/{x} with y before x, and there is no @2x tile, so detectRetina replaces the old {r} suffix. Attribution updated to Esri, HERE, Garmin, OpenStreetMap. The FMI credit is unchanged. Caveat: Esri deprecated this endpoint for open-source clients in 2022 and its terms now point at a free ArcGIS account plus a key. It serves fine today and the swap is confined to one URL template, so replacing it later is a small edit. OpenFreeMap (vector, explicitly no-key) and filtered OSM standard tiles are the keyless fallbacks if it goes away.
1 parent 63d6ab5 commit 2e17e04

1 file changed

Lines changed: 34 additions & 4 deletions

File tree

‎frontend/src/components/RainMap.tsx‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ const RainMap = ({ className }: { className?: string }) => {
204204
const containerRef = useRef<HTMLDivElement | null>(null);
205205
const mapRef = useRef<L.Map | null>(null);
206206
const baseRef = useRef<L.TileLayer | null>(null);
207+
const labelsRef = useRef<L.TileLayer | null>(null);
207208
const framesRef = useRef<Frame[]>([]);
208209
const [ready, setReady] = useState(false);
209210

@@ -235,6 +236,14 @@ const RainMap = ({ className }: { className?: string }) => {
235236
map.attributionControl.addAttribution(
236237
'Sää © <a href="https://en.ilmatieteenlaitos.fi/open-data">FMI</a>',
237238
);
239+
// Esri's Canvas basemap splits geometry and place labels into two services,
240+
// so the labels need a pane of their own: above `overlayPane` (400) or the
241+
// 0.6-opacity precipitation wash smears them, below `markerPane` (600) or
242+
// they bury the home marker.
243+
map.createPane("labels");
244+
const labelPane = map.getPane("labels")!;
245+
labelPane.style.zIndex = "450";
246+
labelPane.style.pointerEvents = "none";
238247
mapRef.current = map;
239248
// Leaflet mis-sizes if the container grew after init; defer sizing and the
240249
// readiness signal so dependent effects see a correctly-sized map.
@@ -247,26 +256,47 @@ const RainMap = ({ className }: { className?: string }) => {
247256
map.remove();
248257
mapRef.current = null;
249258
baseRef.current = null;
259+
labelsRef.current = null;
250260
framesRef.current = [];
251261
setReady(false);
252262
};
253263
}, [location]);
254264

255265
// --- base map, swapped with the colour scheme ---
266+
//
267+
// Esri's Gray Canvas, not CARTO: the keyless cartocdn tiles now come back with
268+
// an "API KEY REQUIRED" watermark burned into the PNG, and CARTO is retiring
269+
// the raster tier anyway. Canvas ships geometry and place labels as separate
270+
// services, so this builds two layers — `Base` at the bottom, `Reference`
271+
// (transparent labels) in the `labels` pane created with the map.
272+
//
273+
// No `{s}` subdomains and the path is `{z}/{y}/{x}`, y before x. There is no
274+
// `@2x` tile either, so `detectRetina` fetches one zoom deeper instead to keep
275+
// the wall panel sharp.
256276
useEffect(() => {
257277
const map = mapRef.current;
258278
if (!map || !ready) return;
259279
if (baseRef.current) map.removeLayer(baseRef.current);
260-
const variant = theme.mode === "dark" ? "dark_all" : "light_all";
261-
const base = L.tileLayer(`https://{s}.basemaps.cartocdn.com/${variant}/{z}/{x}/{y}{r}.png`, {
262-
subdomains: "abcd",
280+
if (labelsRef.current) map.removeLayer(labelsRef.current);
281+
const tone = theme.mode === "dark" ? "Dark" : "Light";
282+
const esri = (service: string) =>
283+
`https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/${service}/MapServer/tile/{z}/{y}/{x}`;
284+
const base = L.tileLayer(esri(`World_${tone}_Gray_Base`), {
263285
maxZoom: 19,
286+
detectRetina: true,
264287
attribution:
265-
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
288+
'Esri, HERE, Garmin, © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
289+
});
290+
const labels = L.tileLayer(esri(`World_${tone}_Gray_Reference`), {
291+
pane: "labels",
292+
maxZoom: 19,
293+
detectRetina: true,
266294
});
267295
base.addTo(map);
268296
base.bringToBack();
297+
labels.addTo(map);
269298
baseRef.current = base;
299+
labelsRef.current = labels;
270300
}, [theme.mode, ready]);
271301

272302
// --- saved-location marker, so the centre point stays findable under the

0 commit comments

Comments
 (0)