diff --git a/docs/development.md b/docs/development.md index 519385b..76f9ce2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,6 +53,14 @@ **Mapillary (required)**: - `MAPILLARY_ACCESS_TOKEN` - Mapillary API token for image fetching +**Map tiles (optional)**: +- `NEXT_PUBLIC_GEOAPIFY_KEY` - Geoapify API key. When set (at build time), all + Leaflet maps serve tiles from Geoapify, whose free tier permits commercial + use; unset, they fall back to the OSM public tile server (fine for dev/e2e, + not for a commercial deployment). The key is public by design — restrict it + to the production domain in the Geoapify dashboard. Provider choice lives in + `src/lib/map-tiles.js` + For local Redis without an Upstash account, see *Running Upstash locally* below. ### Testing & Completion diff --git a/docs/project-structure.md b/docs/project-structure.md index 6dac332..1e2301b 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -27,6 +27,7 @@ Next.js 16 App Router structure: #### Game Pages - `game/page.js` - Main game interface +- `credits/page.js` - Data sources, licenses, and open-source credits - `debug/page.js` - Debug hub: lists every debug tool as a peer - `debug/layout.js` - Shared shell for all debug pages: app bar, DebugNav, theme - `debug/DebugNav.js` - Segmented peer navigation shown on every debug page @@ -93,6 +94,8 @@ Neon Postgres, which is what the app queries at runtime. - `utils.js` - Utility functions including `cn()` for class name merging - `regions.js` - Client-safe region tree traversal. Imports nothing from `pano-index.js` or `pano-db.js`; `tests/regions.test.js` enforces that +- `map-tiles.js` - Client-safe tile provider choice: Geoapify when + `NEXT_PUBLIC_GEOAPIFY_KEY` is set at build time, OSM public server otherwise - `pano-index.js` - **Server-side only.** Picks a panorama for a region from Postgres and reports which district it landed in - `pano-db.js` - **Server-side only.** Neon HTTP adapter behind pano-index.js diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 05c77e9..310f24e 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -16,8 +16,12 @@ `api/debug/mapillary/route.js` keeps the search as a diagnostic, and will reproduce those 500s - **Leaflet**: Interactive mapping library for guess placement -- **OpenStreetMap**: Map tile provider for base maps; Nominatim supplies the - administrative boundaries via `scripts/build-region-boundaries.mjs` +- **Map tiles**: `src/lib/map-tiles.js` picks the provider at build time — + Geoapify (`NEXT_PUBLIC_GEOAPIFY_KEY` set; free tier permits commercial use) + or the OSM public server as the keyless fallback for dev, e2e, and forks +- **OpenStreetMap**: Map data (© OpenStreetMap contributors, ODbL); Nominatim + supplies the administrative boundaries via + `scripts/build-region-boundaries.mjs` - **@photo-sphere-viewer/core**: 360° panorama viewer ## Geographic Processing diff --git a/src/app/components/LeafletMap.js b/src/app/components/LeafletMap.js index 14c8116..e29dcb4 100644 --- a/src/app/components/LeafletMap.js +++ b/src/app/components/LeafletMap.js @@ -8,6 +8,7 @@ import 'leaflet/dist/leaflet.css'; import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; import markerIcon from 'leaflet/dist/images/marker-icon.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png'; +import { getTileConfig } from '../../lib/map-tiles'; // A static image import is a {src} object under webpack but a bare URL string // under Turbopack dev; Leaflet needs the string either way. @@ -65,10 +66,8 @@ export default function LeafletMap({ } // Add tile layer - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap contributors' - }).addTo(map); + const tiles = getTileConfig(); + L.tileLayer(tiles.url, tiles.options).addTo(map); // Handle click events map.on('click', (e) => { diff --git a/src/app/components/ResultMap.js b/src/app/components/ResultMap.js index 43c4c7f..b3d3d88 100644 --- a/src/app/components/ResultMap.js +++ b/src/app/components/ResultMap.js @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef } from 'react'; +import { getTileConfig } from '../../lib/map-tiles'; // The reveal map inside the result dialog: the guess pin, the actual location, // and the line between them. Imperative Leaflet rather than LeafletMap because @@ -33,10 +34,8 @@ export default function ResultMap({ guessCoordinates, exactLocation }) { attributionControl: true }); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap contributors' - }).addTo(map); + const tiles = getTileConfig(); + L.tileLayer(tiles.url, tiles.options).addTo(map); const markers = []; diff --git a/src/app/debug/coverage/CoverageMap.js b/src/app/debug/coverage/CoverageMap.js index b20c063..a3dd440 100644 --- a/src/app/debug/coverage/CoverageMap.js +++ b/src/app/debug/coverage/CoverageMap.js @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import { getTileConfig } from '../../../lib/map-tiles'; // Panorama dots are drawn on a canvas layer, not as DOM markers. Twelve // thousand DOM elements would stall the browser; a canvas renderer draws the @@ -43,10 +44,8 @@ export default function CoverageMap({ if (!containerRef.current || mapRef.current) return; const map = L.map(containerRef.current, { preferCanvas: true }); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap contributors', - }).addTo(map); + const tiles = getTileConfig(); + L.tileLayer(tiles.url, tiles.options).addTo(map); map.setView([16.0, 107.0], 6); // One renderer for the life of the map. Creating it per data refresh leaked diff --git a/src/lib/map-tiles.js b/src/lib/map-tiles.js new file mode 100644 index 0000000..096b299 --- /dev/null +++ b/src/lib/map-tiles.js @@ -0,0 +1,38 @@ +// The only place a tile URL may live: every Leaflet map (guess, result, +// debug coverage) renders whichever provider this module picks. +// +// Client-safe: imported by client components, so nothing server-only may be +// imported here (tests/regions.test.js walks the import graph to enforce it). +// +// NEXT_PUBLIC_* is inlined at build time, so the choice is a build-time +// constant: with a Geoapify key the maps use Geoapify (free tier explicitly +// allows commercial use); without one they fall back to the OSM public +// server, which tolerates low-traffic non-commercial use — fine for local +// dev, e2e, and forks, but production must set the key before ads ship. +const geoapifyKey = process.env.NEXT_PUBLIC_GEOAPIFY_KEY; + +/** + * Resolve the tile layer for every map in the app. + * + * @returns {{url: string, options: {maxZoom: number, attribution: string}}} + * Arguments for L.tileLayer(url, options). + */ +export function getTileConfig() { + if (geoapifyKey) { + return { + url: `https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}.png?apiKey=${geoapifyKey}`, + options: { + maxZoom: 19, + attribution: + 'Powered by Geoapify | © OpenStreetMap contributors', + }, + }; + } + return { + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }, + }; +} diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js index 1a3d714..63722c1 100644 --- a/tests/e2e/helpers.js +++ b/tests/e2e/helpers.js @@ -131,10 +131,15 @@ export async function stubGameApis(page, username) { }); // Leaflet's base tiles are the one request the app makes to a third party. // Stub them too, or every run hammers openstreetmap.org and the suite is not - // actually offline. + // actually offline. Both providers from src/lib/map-tiles.js are stubbed: + // the dev server loads .env, so a filled-in NEXT_PUBLIC_GEOAPIFY_KEY would + // otherwise send every run to Geoapify and spend metered free-tier credits. await page.route('**/tile.openstreetmap.org/**', async (route) => { await route.fulfill({ contentType: 'image/png', body: readFileSync(PANO_FIXTURE) }); }); + await page.route('**/maps.geoapify.com/**', async (route) => { + await route.fulfill({ contentType: 'image/png', body: readFileSync(PANO_FIXTURE) }); + }); } /** diff --git a/tests/map-tiles.test.js b/tests/map-tiles.test.js new file mode 100644 index 0000000..8e83fb3 --- /dev/null +++ b/tests/map-tiles.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// The module captures NEXT_PUBLIC_GEOAPIFY_KEY at import time (Next.js inlines +// it at build time, so runtime mutation is impossible in the app). Each test +// therefore stubs the env first and imports a fresh copy of the module. +const importFresh = async () => { + vi.resetModules(); + return import('../src/lib/map-tiles.js'); +}; + +beforeEach(() => { + vi.unstubAllEnvs(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('getTileConfig', () => { + it('falls back to the OSM public server, byte-identical to the pre-migration values', async () => { + vi.stubEnv('NEXT_PUBLIC_GEOAPIFY_KEY', ''); + const { getTileConfig } = await importFresh(); + + // These exact values were hardcoded in all three maps before the + // migration; the keyless mode must never drift from them. + expect(getTileConfig()).toEqual({ + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }, + }); + }); + + it('serves Geoapify with its required attribution when a key is set', async () => { + vi.stubEnv('NEXT_PUBLIC_GEOAPIFY_KEY', 'test-key'); + const { getTileConfig } = await importFresh(); + + const tiles = getTileConfig(); + expect(tiles.url).toBe( + 'https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}.png?apiKey=test-key' + ); + expect(tiles.options.maxZoom).toBe(19); + // Geoapify's free tier requires a visible "Powered by Geoapify" credit; + // OSM data attribution must survive the provider swap. + expect(tiles.options.attribution).toContain('Powered by'); + expect(tiles.options.attribution).toContain('https://www.geoapify.com/'); + expect(tiles.options.attribution).toContain('© OpenStreetMap contributors'); + }); + + it('returns a fresh object per call, so Leaflet cannot mutate shared state', async () => { + vi.stubEnv('NEXT_PUBLIC_GEOAPIFY_KEY', ''); + const { getTileConfig } = await importFresh(); + + const first = getTileConfig(); + const second = getTileConfig(); + expect(first).not.toBe(second); + expect(first.options).not.toBe(second.options); + }); +});