Files
TREK/client/src/components/Studio/mapTiles.ts
T
Maurice 1f66ef1d32 fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile
fetched without a key since 26.08.2026, and both CartoDB presets plus the
built-in fallback are CARTO. The key is free and needs no account, so add
it as a setting rather than dropping the provider.

carto_api_key sits next to mapbox_access_token: per user in Settings/Map,
instance-wide under Admin/User Defaults, encrypted at rest, injected from
CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the
map provider, since a raster key says nothing about the renderer.

The key is appended as ?key= at render time instead of being stored in the
template, so a saved URL stays portable and survives a rotation. That is
also what reaches the four maps that pinned CARTO and ignored the user's
tile setting: atlas, collections, the public trip share and the public
journey. The two share pages read it from their token-gated payload, the
same way baseCurrency already travels.

Also lifts the release notice above page chrome. Its overlay sat at
z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither
on top nor modal. Adds a six-step layer scale to index.css and moves the
three notice shells onto it.

Closes #2054
Closes #2052
2026-08-27 12:06:55 +02:00

454 lines
18 KiB
TypeScript

/**
* Real map imagery for the book's map element.
*
* ── Why this is opt-in rather than the default ────────────────────────────
*
* The vector map draws country outlines from boundaries the server already
* bundles: nothing is fetched, nothing is licensed, and it prints sharp at any
* size because it is geometry. That is the right default for a book.
*
* But an outline of Iceland cannot show which road you took, and some books
* want that. So this exists — with the two costs stated rather than hidden:
* tiles are fetched when the page renders, and they are raster, cut for one
* zoom level. Printed much larger than they were fetched, they go soft.
*
* ── Attribution ──────────────────────────────────────────────────────────
*
* Not optional and not a setting. OpenStreetMap's licence requires credit, and
* a photo book is a published work — arguably more so than a web page, since
* nobody can add a footnote to it after it is bound. Every tiled map element
* carries the line, and it is written into the document when the element is
* placed so the renderer cannot lose it.
*/
import { withTileApiKey } from '../../utils/tileUrl'
/** A slippy-map tile, in the standard XYZ scheme. */
export interface Tile {
x: number
y: number
z: number
url: string
}
/** Web Mercator, normalised to 0..1 — the space tile indices are cut from. */
export function toMercatorUnit(lng: number, lat: number): { x: number; y: number } {
const clamped = Math.max(-85.0511, Math.min(85.0511, lat))
const rad = (clamped * Math.PI) / 180
return {
x: (lng + 180) / 360,
y: (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2,
}
}
export interface TileView {
tiles: Tile[]
/** Where the tile grid sits, in the element's millimetres. */
originX: number
originY: number
/** One tile's drawn size, in millimetres. */
size: number
zoom: number
/**
* The grid's top-left tile index.
*
* Exposed because the route has to be projected onto *this* grid: the vector
* map fits itself to country outlines, and a line placed by that fit would
* sit somewhere other than on the road it followed.
*/
tileX0: number
tileY0: number
}
/** Where a coordinate lands inside a tile view, in the element's millimetres. */
export function projectOntoTiles(view: TileView, lng: number, lat: number): { x: number; y: number } {
const u = toMercatorUnit(lng, lat)
const scale = 2 ** view.zoom
return {
x: view.originX + (u.x * scale - view.tileX0) * view.size,
y: view.originY + (u.y * scale - view.tileY0) * view.size,
}
}
/**
* How many tiles a printed map may use.
*
* Sixteen across rather than eight, and the number is chosen by arithmetic
* rather than by taste. Each zoom level quarters the ground a tile covers, so
* the grid quadruples: a page-sized map lands at 7x9 tiles, and the step that
* would make it sharp enough to print needs 13x17 — 221. A budget of 144 stops
* one level short of a printable page, every time, which is the worst place to
* stop.
*
* What it costs is requests, once, cached by the browser between the editor
* and the print run. What it buys is the element most likely to fill a whole
* page, at a resolution a print shop will take.
*/
const MAX_TILES = 16 * 16
/**
* What a thumbnail is worth fetching.
*
* The same map is drawn in three places at three sizes: the page, the sheet the
* press gets, and a 130px tile in the pages rail or the travel panel. Cutting
* the small ones to print resolution would mean a couple of hundred requests
* for a picture the size of a postage stamp, every time the panel opens — and
* against NASA or OpenStreetMap that is not merely wasteful, it is the bulk
* fetching their policies ask people not to do.
*
* `big` already travels down the render tree for exactly this kind of decision;
* the photo views use it to pick a thumbnail over a full-size image.
*/
const PREVIEW_TILES = 4 * 4
const PREVIEW_TILE_MM = 90
/**
* What a tile is worth drawing at, in millimetres.
*
* ── The bug this constant exists to fix ──────────────────────────────────
*
* The zoom used to be chosen so the route spanned the frame and nothing else,
* which by construction put about two tiles across the picture whatever its
* physical size. A route across Iceland on a 210mm page came back as 256x512
* pixels stretched over the sheet: seventeen dots per inch, on paper that
* holds three hundred. It looked exactly like what it was — a screenshot of a
* web map, enlarged.
*
* A tile is 256 pixels, or 512 when the source offers `{r}`. Holding one to
* about 22mm therefore asks for roughly 300dpi, which is what a print shop
* wants and the point past which more tiles buy nothing a reader can see.
*/
const TILE_MM_AT_PRINT = 22
/** Shard letters for templates that still carry `{s}`. */
const TILE_SHARDS = ['a', 'b', 'c']
/**
* How far in a source can actually be asked to go.
*
* Matched on the host, the same way the attribution is: a template carries no
* capabilities of its own, and asking a source for a zoom it does not have
* answers with an error image rather than with a picture. NASA's imagery stops
* at 8, which is about 600 metres to the pixel — plenty for a country and not
* enough for a city, which is the honest limit of that source.
*/
export function maxZoomFor(template: string): number {
const url = template.toLowerCase()
if (url.includes('gibs.earthdata.nasa.gov')) return 8
// Sentinel-2 is a ten-metre sensor; past 16 the tiles are enlargements of
// pixels rather than more picture.
if (url.includes('tiles.maps.eox.at')) return 16
if (url.includes('arcgisonline')) return 17
return 19
}
/**
* Which tiles cover a set of points, and where to draw them.
*
* The zoom is chosen so the route fills the frame rather than so the tiles are
* a particular size: a map of one city and a map of a continent both want to
* be the size of the box they are in, and only the zoom differs.
*/
export function tileView(
points: { lat: number; lng: number }[],
frame: { w: number; h: number },
template: string,
fixedZoom: number | null,
/**
* The exact window to cover, when the caller has already worked one out.
*
* The map element fits its own view — to the stops, to the countries, with
* whatever padding it was given — and the tiles have to be cut to *that*, not
* to a second fit computed here from the same points by different rules. Two
* fits meant the picture and the route were of slightly different places, and
* on a map cut to a country's outline it meant the country was full of the
* wrong towns.
*/
extent?: { minLng: number; minLat: number; maxLng: number; maxLat: number },
/**
* How much this drawing is worth. `preview` is a thumbnail: a handful of
* tiles, deliberately soft, because nobody prints the pages rail.
*/
quality: 'print' | 'preview' = 'print',
/**
* The instance's CARTO key, for a book still on a CARTO template.
*
* Applied here rather than stored: keyless CARTO tiles carry a watermark
* since 26.08.2026 (#2054), and a key frozen into the document would be
* printed into every export and would outlive the key itself.
*/
cartoKey?: string,
): TileView | null {
if (!template || (!points.length && !extent)) return null
const corners = extent
? [
{ lat: extent.maxLat, lng: extent.minLng },
{ lat: extent.minLat, lng: extent.maxLng },
]
: points
const unit = corners.map(p => toMercatorUnit(p.lng, p.lat))
let minX = Math.min(...unit.map(u => u.x))
let maxX = Math.max(...unit.map(u => u.x))
let minY = Math.min(...unit.map(u => u.y))
let maxY = Math.max(...unit.map(u => u.y))
// A single stop has no extent, and a route along one line has none on one
// axis. Both need a window rather than a point.
const PAD = 0.012
if (maxX - minX < PAD) { const c = (minX + maxX) / 2; minX = c - PAD / 2; maxX = c + PAD / 2 }
if (maxY - minY < PAD) { const c = (minY + maxY) / 2; minY = c - PAD / 2; maxY = c + PAD / 2 }
// A given extent is already the window the caller wants; only a fit worked
// out from bare points needs air added around it.
const AIR = extent ? 1 : 1.12
const spanX = (maxX - minX) * AIR
const spanY = (maxY - minY) * AIR
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
/*
* The zoom at which the route spans the frame.
*
* At zoom z the world is 2^z tiles across, so the span in tiles is
* `span * 2^z`; solving for the zoom where that equals the frame measured in
* tiles gives the fit. Floor rather than round, so the route is inside the
* frame rather than just outside it.
*/
const fitZoom = () => {
const wanted = Math.min(
Math.log2(1 / Math.max(spanX, 1e-9)),
Math.log2(1 / Math.max(spanY, 1e-9)),
) + 1
return Math.max(0, Math.min(19, Math.floor(wanted)))
}
let zoom = fixedZoom ?? fitZoom()
/*
* ── Which tiles, and how big ──────────────────────────────────────────
*
* The old sum went the wrong way round: it took the tiles the route happens
* to sit on, and then sized them to the frame. Whenever the route's own box
* did not line up with the tile grid — which is nearly always — the grid was
* narrower than the frame on one side and the map ran out with paper showing
* beside it.
*
* So the frame decides. First how large a tile has to be drawn for the route
* to span the frame, then which tiles are inside the frame at that size,
* with the edges rounded outwards so the last row and column overhang rather
* than stop short. The result covers the frame by construction.
*/
let scale = 2 ** zoom
let size = 0
let x0 = 0, x1 = 0, y0 = 0, y1 = 0
const layOut = () => {
scale = 2 ** zoom
// Cover, not contain: a map with a margin of paper down one side is not a
// map, it is a mistake. Padding belongs to the fit, not to the grid.
size = Math.max(
frame.w / Math.max(spanX * scale, 1e-9),
frame.h / Math.max(spanY * scale, 1e-9),
)
const halfW = frame.w / 2 / size
const halfH = frame.h / 2 / size
x0 = Math.floor(cx * scale - halfW)
x1 = Math.ceil(cx * scale + halfW) - 1
y0 = Math.floor(cy * scale - halfH)
y1 = Math.ceil(cy * scale + halfH) - 1
}
layOut()
/*
* Up, then down.
*
* Up first: while a tile is being drawn larger than it is worth, and the
* source has a level left, and the extra level still fits inside the grid
* budget, take it. Each level halves the millimetres a tile covers, so this
* converges in a handful of steps.
*
* Then the old back-off, unchanged, because a fixed zoom or a very long
* route can still ask for more tiles than are worth fetching.
*/
const budget = quality === 'preview' ? PREVIEW_TILES : MAX_TILES
const targetTileMm = quality === 'preview' ? PREVIEW_TILE_MM : TILE_MM_AT_PRINT
const ceiling = fixedZoom ?? maxZoomFor(template)
while (zoom < ceiling && size > targetTileMm) {
const before = { zoom, size, x0, x1, y0, y1 }
zoom += 1
layOut()
if ((x1 - x0 + 1) * (y1 - y0 + 1) > budget) {
// One level too far: put it back rather than leaving the grid over budget.
zoom = before.zoom
size = before.size
x0 = before.x0; x1 = before.x1; y0 = before.y0; y1 = before.y1
scale = 2 ** zoom
break
}
}
while (zoom > 0 && (x1 - x0 + 1) * (y1 - y0 + 1) > budget) {
zoom -= 1
layOut()
}
// Drawn so the route's centre is the frame's centre.
const originX = frame.w / 2 - (cx * scale - x0) * size
const originY = frame.h / 2 - (cy * scale - y0) * size
const tiles: Tile[] = []
const source = withTileApiKey(template, cartoKey)
const wrap = (n: number) => ((n % scale) + scale) % scale
for (let y = y0; y <= y1; y++) {
// Past the poles there is no tile — the row simply does not exist.
if (y < 0 || y >= scale) continue
for (let x = x0; x <= x1; x++) {
tiles.push({
x: x - x0,
y: y - y0,
z: zoom,
url: source
.replace('{z}', String(zoom))
// Longitude wraps, so a route across the antimeridian keeps its tiles.
.replace('{x}', String(wrap(x)))
.replace('{y}', String(y))
/*
* `{r}` is the retina placeholder, and a template only carries it
* when its source really serves 512px tiles. It used to be replaced
* with nothing, which threw that away and asked for a quarter of the
* pixels for the same number of requests.
*/
.replace('{r}', '@2x')
/*
* The shard letter, chosen from the tile's own coordinates.
*
* Left in the template it is not a placeholder, it is a hostname that
* does not resolve, and the map draws as a blank frame with a route
* across it. Derived from x and y rather than picked at random so a
* given tile always comes from the same host: the editor and the
* print run then hit the same cache instead of fetching the picture
* twice.
*/
.replace('{s}', TILE_SHARDS[Math.abs(x + y) % TILE_SHARDS.length]),
})
}
}
return { tiles, originX, originY, size, zoom, tileX0: x0, tileY0: y0 }
}
/**
* How sharp this grid will actually print, in dots per inch.
*
* Worth showing rather than hiding. Every raster source has a level past which
* no more detail exists — NASA's relief stops at about 600 metres to the pixel
* — and when a map is asked for a smaller area than its source can resolve,
* the picture does not fail, it just goes soft. On screen that is invisible.
* On a printed page it is the difference between a map and a smudge, and by
* then the book is bound.
*
* A print shop wants 300. Anything from about 150 is passable on matte stock.
* Below that, the honest answer is a different source or a wider view.
*/
export function printDpi(view: TileView, template: string): number {
const px = template.includes('{r}') ? 512 : 256
return Math.round(px / (view.size / 25.4))
}
/**
* The credit a tile source requires.
*
* Matched on the host rather than configured, so an instance pointing at its
* own OSM mirror still credits OpenStreetMap — and anything unrecognised gets
* no invented attribution, since a wrong credit is worse than none.
*/
export function attributionFor(template: string): string {
const url = template.toLowerCase()
if (url.includes('openstreetmap')) return '© OpenStreetMap contributors'
if (url.includes('mapbox')) return '© Mapbox © OpenStreetMap'
if (url.includes('maptiler')) return '© MapTiler © OpenStreetMap'
if (url.includes('stadiamaps')) return '© Stadia Maps © OpenStreetMap'
if (url.includes('carto')) return '© CARTO © OpenStreetMap'
if (url.includes('esri') || url.includes('arcgisonline')) return '© Esri'
if (url.includes('gibs.earthdata.nasa.gov')) return 'NASA EOSDIS GIBS'
// The wording CC BY 4.0 asks for: who made it, and what it is made of.
if (url.includes('tiles.maps.eox.at')) return 'Sentinel-2 cloudless © EOX (CC BY 4.0)'
return ''
}
/**
* A whole map as one image, from Mapbox's static API.
*
* The alternative to a tile grid for the styles that only exist as GL styles:
* one request, one picture, and the route can be drawn into it as an overlay.
* Needs the token the instance is already configured with.
*/
/**
* Styles the Static Images API cannot draw, and what to use instead.
*
* Mapbox's newer styles are 3D GL styles: they are assembled in the browser
* from vector tiles, lighting and model layers, and the static renderer simply
* refuses them — `standard` answers 400 with "Unsupported rasterarray tileset
* format", which arrives as a picture that never loads and no explanation
* anywhere.
*
* An instance configured for the planner's map is therefore usually configured
* with a style this cannot use, through no fault of whoever set it up. Falling
* back to the classic equivalent gives them a map that looks like the one they
* chose, rather than an empty frame.
*/
const STATIC_STYLE: Record<string, string> = {
'mapbox/standard': 'mapbox/streets-v12',
'mapbox/standard-satellite': 'mapbox/satellite-streets-v12',
}
/**
* Repair a static URL that was frozen into a document before the fallback
* above existed.
*
* The URL is stored rather than rebuilt at render time, on purpose, so a map
* placed last week keeps looking the way it did. That also means a map placed
* with a style the static renderer refuses stays broken for good unless it is
* fixed on the way out — and "delete it and place it again" is not a repair
* anyone should have to be told about.
*/
export function usableStaticUrl(url: string): string {
for (const [gl, classic] of Object.entries(STATIC_STYLE)) {
if (url.includes(`/styles/v1/${gl}/static/`)) {
return url.replace(`/styles/v1/${gl}/static/`, `/styles/v1/${classic}/static/`)
}
}
return url
}
export function staticMapUrl(opts: {
points: { lat: number; lng: number }[]
style: string
token: string
/** Pixels. The API caps each side at 1280. */
width: number
height: number
}): string | null {
if (!opts.token || !opts.points.length) return null
// mapbox://styles/mapbox/standard -> mapbox/standard
const asked = opts.style.replace(/^mapbox:\/\/styles\//, '') || 'mapbox/streets-v12'
const style = STATIC_STYLE[asked] ?? asked
const lats = opts.points.map(p => p.lat)
const lngs = opts.points.map(p => p.lng)
const bbox = [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)]
const w = Math.max(64, Math.min(1280, Math.round(opts.width)))
const h = Math.max(64, Math.min(1280, Math.round(opts.height)))
// @2x because this is going into print: a 1x static image at book size is
// roughly 96dpi, which is visibly soft on paper.
return `https://api.mapbox.com/styles/v1/${style}/static/`
+ `[${bbox.map(n => n.toFixed(5)).join(',')}]/${w}x${h}@2x`
+ `?padding=24&access_token=${encodeURIComponent(opts.token)}`
}