diff --git a/.env.example b/.env.example index d04594c..045d118 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ # Copy to .env.local for dev, or set on Vercel for prod. # Base URL of the Laravel API (no trailing slash, no /api suffix). VITE_API_BASE=http://localhost:8000 + +# CARTO basemap API key. CARTO enforces keys on basemaps.cartocdn.com since +# Aug 2026 (anonymous tiles are watermarked "API KEY REQUIRED"). Free, no +# account: https://carto.com/basemaps/apikey/ . Leave unset to fall back to +# keyless OpenStreetMap tiles (light style; dark mode inverts them via CSS). +VITE_CARTO_API_KEY= diff --git a/README.md b/README.md index 1eab2c1..b88b78f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ CI runs both on every push to `main`. Targeted at Vercel — auto-detects Vite, uses the `npm run build` command, serves `dist/`. Set `VITE_API_BASE` to the production Laravel URL in Vercel project settings. +Also set `VITE_CARTO_API_KEY` (free key from ) so the map uses CARTO's theme-matched light/dark basemaps. Without it the map falls back to OpenStreetMap's keyless tiles (dark mode inverts them via CSS) — see `src/components/mapTiles.js`. + ## License MIT diff --git a/index.html b/index.html index 5af4dad..3a70fd6 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -13,11 +13,15 @@ + - + Roastmap — Canadian specialty coffee @@ -63,8 +73,7 @@ (function () { try { var s = localStorage.getItem('roastmap_theme'); - var dark = s ? s === 'dark' - : window.matchMedia('(prefers-color-scheme: dark)').matches; + var dark = s ? s === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.style.backgroundColor = dark ? '#100c0a' : '#ece5da'; if (dark) { document.documentElement.classList.add('dark'); diff --git a/src/components/LeafletMap.jsx b/src/components/LeafletMap.jsx index 2b29184..021da08 100644 --- a/src/components/LeafletMap.jsx +++ b/src/components/LeafletMap.jsx @@ -3,16 +3,7 @@ import { MapContainer, TileLayer, useMap } from 'react-leaflet'; import 'leaflet/dist/leaflet.css'; import '../styles/leaflet-overrides.css'; // mobile#6: must load AFTER leaflet.css import ClusterLayer from './ClusterLayer.jsx'; - -// Theme-matched CARTO basemaps. Everything else on the map (popups, markers, -// clusters) themes itself via CSS tokens in leaflet-overrides.css; the raster -// tiles are the one piece that needs an explicit URL swap. -const TILE_URLS = { - light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', - dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', -}; -const TILE_ATTR = - '© OpenStreetMap contributors © CARTO'; +import { resolveTileLayer } from './mapTiles.js'; /** * Imperatively re-fits the map when the target bounds change. Must live inside @@ -53,7 +44,13 @@ function FocusOnReady({ enabled }) { * first interaction, so the landing page paints instantly and this vendor * weight stays off the critical path entirely. */ -export default function LeafletMap({ markers, targetBounds, isDark = false, focusOnMount = false }) { +export default function LeafletMap({ + markers, + targetBounds, + isDark = false, + focusOnMount = false, +}) { + const tiles = resolveTileLayer({ isDark }); return ( {/* key-remount on theme change: react-leaflet does NOT propagate a mutated `url` prop to a live tile layer, so swapping themes in - place silently no-ops. A fresh layer per theme is cheap and safe. */} + place silently no-ops. A fresh layer per theme is cheap and safe. + Provider + URL come from mapTiles.js (CARTO with an API key, else + keyless OSM) — see that file for why. */} diff --git a/src/components/mapTiles.js b/src/components/mapTiles.js new file mode 100644 index 0000000..03ad4e7 --- /dev/null +++ b/src/components/mapTiles.js @@ -0,0 +1,70 @@ +/** + * Basemap tile-layer selection for LeafletMap. + * + * CARTO started enforcing API keys on basemaps.cartocdn.com in late August + * 2026: anonymous tile requests still render but carry a repeated + * "API KEY REQUIRED" watermark. A key is free (5M tiles/month, requested by + * email at https://carto.com/basemaps/apikey/) and is passed as `?key=` on + * the same tile URL. + * + * With VITE_CARTO_API_KEY set (Vercel env for prod, .env.local for dev) we + * keep the theme-matched CARTO light/dark styles. Without it we fall back to + * the OpenStreetMap standard tiles — keyless, but light-only, so dark mode + * inverts them via the `map-tiles--inverted` CSS class in + * leaflet-overrides.css. OSM's tile usage policy is fine for this site's + * traffic but asks for attribution and a real Referer, both of which a + * browser request provides. + * + * Pure (no Leaflet import) so it stays unit-testable and out of the lazy map + * chunk's critical path. + */ + +const CARTO = { + light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', + dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + subdomains: 'abcd', + maxZoom: 20, + attribution: + '© OpenStreetMap contributors © CARTO', +}; + +// OSM retired the a/b/c subdomains; the bare host is the supported endpoint. +const OSM = { + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + maxZoom: 19, + attribution: + '© OpenStreetMap contributors', +}; + +export const INVERTED_TILES_CLASS = 'map-tiles--inverted'; + +export function readCartoKey(env = import.meta.env) { + const key = env?.VITE_CARTO_API_KEY; + return typeof key === 'string' && key.trim() !== '' ? key.trim() : null; +} + +/** + * @param {{ isDark: boolean, cartoKey?: string | null }} opts + * @returns {{ provider: 'carto' | 'osm', url: string, attribution: string, + * subdomains?: string, maxZoom: number, className?: string }} + */ +export function resolveTileLayer({ isDark, cartoKey = readCartoKey() }) { + if (cartoKey) { + const base = isDark ? CARTO.dark : CARTO.light; + return { + provider: 'carto', + url: `${base}?key=${encodeURIComponent(cartoKey)}`, + attribution: CARTO.attribution, + subdomains: CARTO.subdomains, + maxZoom: CARTO.maxZoom, + }; + } + + return { + provider: 'osm', + url: OSM.url, + attribution: OSM.attribution, + maxZoom: OSM.maxZoom, + className: isDark ? INVERTED_TILES_CLASS : undefined, + }; +} diff --git a/src/components/mapTiles.test.js b/src/components/mapTiles.test.js new file mode 100644 index 0000000..2d7dade --- /dev/null +++ b/src/components/mapTiles.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { resolveTileLayer, readCartoKey, INVERTED_TILES_CLASS } from './mapTiles.js'; + +describe('resolveTileLayer', () => { + it('uses CARTO light/dark with the key on the query string when a key is configured', () => { + const light = resolveTileLayer({ isDark: false, cartoKey: 'abc123' }); + const dark = resolveTileLayer({ isDark: true, cartoKey: 'abc123' }); + + expect(light.provider).toBe('carto'); + expect(light.url).toBe( + 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key=abc123', + ); + expect(dark.url).toBe( + 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png?key=abc123', + ); + expect(light.subdomains).toBe('abcd'); + expect(light.attribution).toContain('carto.com/attributions'); + expect(light.className).toBeUndefined(); + expect(dark.className).toBeUndefined(); + }); + + it('URL-encodes the key', () => { + expect(resolveTileLayer({ isDark: false, cartoKey: 'a b&c' }).url).toMatch(/\?key=a%20b%26c$/); + }); + + it('falls back to keyless OpenStreetMap tiles without a key', () => { + const light = resolveTileLayer({ isDark: false, cartoKey: null }); + + expect(light.provider).toBe('osm'); + expect(light.url).toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png'); + expect(light.url).not.toContain('cartocdn'); + expect(light.attribution).toContain('openstreetmap.org/copyright'); + expect(light.attribution).not.toContain('CARTO'); + expect(light.subdomains).toBeUndefined(); + expect(light.className).toBeUndefined(); + }); + + it('inverts the OSM fallback in dark mode via a CSS class', () => { + expect(resolveTileLayer({ isDark: true, cartoKey: null }).className).toBe(INVERTED_TILES_CLASS); + }); +}); + +describe('readCartoKey', () => { + it('returns the trimmed key or null', () => { + expect(readCartoKey({ VITE_CARTO_API_KEY: ' k1 ' })).toBe('k1'); + expect(readCartoKey({ VITE_CARTO_API_KEY: '' })).toBeNull(); + expect(readCartoKey({ VITE_CARTO_API_KEY: ' ' })).toBeNull(); + expect(readCartoKey({})).toBeNull(); + expect(readCartoKey(undefined)).toBeNull(); + }); +}); diff --git a/src/styles/leaflet-overrides.css b/src/styles/leaflet-overrides.css index 0d5c79e..c89f95e 100644 --- a/src/styles/leaflet-overrides.css +++ b/src/styles/leaflet-overrides.css @@ -182,12 +182,16 @@ a.leaflet-popup-close-button:hover { inset: -6px; border-radius: 50%; border: 2px solid #fef6e7; - box-shadow: 0 0 0 2px #6f4326, 0 4px 8px rgba(0, 0, 0, 0.4); + box-shadow: + 0 0 0 2px #6f4326, + 0 4px 8px rgba(0, 0, 0, 0.4); pointer-events: none; } .dark .rm-bean__ring { border-color: #15100d; - box-shadow: 0 0 0 2px #e7d3b8, 0 4px 8px rgba(0, 0, 0, 0.7); + box-shadow: + 0 0 0 2px #e7d3b8, + 0 4px 8px rgba(0, 0, 0, 0.7); } /* Cluster bubble. Size/font/halo come inline from createClusterIcon @@ -202,8 +206,13 @@ a.leaflet-popup-close-button:hover { border: 2px solid #fef6e7; border-radius: 50%; font-weight: 700; - font-family: Inter, system-ui, -apple-system, sans-serif; - box-shadow: 0 0 0 var(--rm-cluster-halo, 4px) rgba(111, 67, 38, 0.25), + font-family: + Inter, + system-ui, + -apple-system, + sans-serif; + box-shadow: + 0 0 0 var(--rm-cluster-halo, 4px) rgba(111, 67, 38, 0.25), 0 2px 4px rgba(0, 0, 0, 0.25); cursor: pointer; transition: transform 120ms ease-out; @@ -212,7 +221,8 @@ a.leaflet-popup-close-button:hover { background: #8a5a35; color: #fef6e7; border-color: #15100d; - box-shadow: 0 0 0 var(--rm-cluster-halo, 4px) rgba(231, 211, 184, 0.2), + box-shadow: + 0 0 0 var(--rm-cluster-halo, 4px) rgba(231, 211, 184, 0.2), 0 2px 4px rgba(0, 0, 0, 0.6); } @@ -237,3 +247,16 @@ a.leaflet-popup-close-button:hover { .dark .leaflet-container { background: #0e0d0c; } + +/* + * ---- Keyless basemap fallback (dark mode) ---- + * + * Without VITE_CARTO_API_KEY the map serves OpenStreetMap's standard tiles, + * which only come in a light style. mapTiles.js tags that layer with this + * class in dark mode so the raster inverts to a dark canvas; hue-rotate + * restores land/water colours after the inversion. Scoped to the tile + * container only — markers, clusters and popups are separate panes. + */ +.leaflet-layer.map-tiles--inverted { + filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.95) saturate(0.55); +}