diff --git a/builder/app/src/components/planner/LayerDateBar.svelte b/builder/app/src/components/planner/LayerDateBar.svelte index 87441b49a..766f0c44a 100644 --- a/builder/app/src/components/planner/LayerDateBar.svelte +++ b/builder/app/src/components/planner/LayerDateBar.svelte @@ -3,7 +3,7 @@ import SeasonGrid from './SeasonGrid.svelte'; import DateCalendar from './DateCalendar.svelte'; import Segmented from './Segmented.svelte'; - import { addMonths, columnDate, dateLabel, type DataLayer, type Grid } from '../../lib/planner/layers/data-layer'; + import { addDays, addMonths, columnDate, dateLabel, type DataLayer, type Grid } from '../../lib/planner/layers/data-layer'; let { date, year, variable, onDate }: { date: string; year: Grid; variable: DataLayer['variable']; onDate: (date: string) => void } = $props(); @@ -18,8 +18,7 @@ onDate(next); } function shift(days: number) { - const [y, m, d] = date.split('-').map(Number); - step(new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10)); + step(addDays(date, days)); } function key(event: KeyboardEvent) { const year = date.slice(0, 4); diff --git a/builder/app/src/components/planner/LayerInspect.svelte b/builder/app/src/components/planner/LayerInspect.svelte index 512514471..2071fef25 100644 --- a/builder/app/src/components/planner/LayerInspect.svelte +++ b/builder/app/src/components/planner/LayerInspect.svelte @@ -27,7 +27,7 @@ {#if chart} {#each chart.grids as grid, g (g)} {#if grid.label}

{grid.label}

{/if} - + 1 ? 3 : 4) : 8} label={`${grid.label || chart.headline}. One row per year, newest at the bottom.`} /> {#if !compact && (grid.legend || g === chart.grids.length - 1)} {#if g === chart.grids.length - 1}
  • Your date
  • {/if} diff --git a/builder/app/src/components/planner/LayerStrip.svelte b/builder/app/src/components/planner/LayerStrip.svelte index 152ba6950..90ff28c43 100644 --- a/builder/app/src/components/planner/LayerStrip.svelte +++ b/builder/app/src/components/planner/LayerStrip.svelte @@ -52,7 +52,7 @@
    - {layer.label}{layer.error || (samples ? `${dateLabel(date)} · hover for past years` : 'Loading…')} + {strip.label ?? layer.label}{layer.error || (samples ? `${dateLabel(date)} · hover for past years` : 'Loading…')}
    { + if (!multi || !dataLayer?.nights || !routeSamples || !visualRoute) return []; + const points = profileSamples(visualRoute); + const ends = days.slice(0, -1); + const stops = ends.map(day => ({ index: Math.min(points.length - 1, sampleIndex(points, day.to)), date: addDays(shownDate, (dayLabels[day.number] ?? day.number) - 1) })); + return dataLayer.nights(routeSamples, stops).flatMap((text, i) => text ? [{ coordinate: coordinateAt(coordinates, ends[i].to), text }] : []); + }); // A map click with a layer shown adds the years at that spot to the callout. let pinSamples = $state.raw<{ layer: DataLayer; at: Coordinate; samples: unknown } | null>(null); $effect(() => { @@ -965,7 +973,7 @@
    gap.coordinates)} {coordinates} points={mapPoints} {selectedId} {hoveredId} onPointHover={(id) => hoveredId = id} callout={calloutCoordinate} {drawing} - {theme} {hillshade} {contours} {mapOverlays} dataLayer={{ layers: dataLayers, shown: dataLayer, date: shownDate }} bottomInset={dataLayer ? dateBarHeight + 34 : 0} accessMode={trip.bike === 'hiking' ? 'walking' : 'cycling'} {showRoute} {hoverProgress} highlightedCoordinates={highlighted} pickMode={picking} routingPackage={currentRoute?.package} + {theme} {hillshade} {contours} {mapOverlays} dataLayer={{ layers: dataLayers, shown: dataLayer, date: shownDate, notes: layerNotes }} bottomInset={dataLayer ? dateBarHeight + 34 : 0} accessMode={trip.bike === 'hiking' ? 'walking' : 'cycling'} {showRoute} {hoverProgress} highlightedCoordinates={highlighted} pickMode={picking} routingPackage={currentRoute?.package} highlightedPlaceIds={searching ? results.map(result => result.place.id) : []} shownCategories={categoryIds.filter(category => !hiddenCategories.includes(category))} {highlightedPlaces} {landmarks} onBounds={(bounds, preserveSearch) => { viewBounds = bounds; if (!preserveSearch) searchViewRevision++; }} onEmptyClick={emptyClick} onPointSelect={selectPoint} onPointMove={movePoint} onPointPreview={previewPoint} onDayEndDrag={moveDayEnd} diff --git a/builder/app/src/components/planner/PlannerIcon.svelte b/builder/app/src/components/planner/PlannerIcon.svelte index 24864ee0c..8f3f3f2e6 100644 --- a/builder/app/src/components/planner/PlannerIcon.svelte +++ b/builder/app/src/components/planner/PlannerIcon.svelte @@ -42,6 +42,7 @@ trash: 'M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6m4-6v6', mountain: 'm2 20 7-13 4 7 3-5 6 11H2Zm4-7 3 1 2-3', snow: 'M12 2v20M3.3 7l17.4 10M3.3 17 20.7 7M9.5 3.5 12 6l2.5-2.5M9.5 20.5 12 18l2.5 2.5M18.1 5.6 17.2 9l3.4.9M20.6 14.1 17.2 15l.9 3.4M3.4 14.1 6.8 15l-.9 3.4M5.9 5.6 6.8 9l-3.4.9', + weather: 'M7 19h10a4 4 0 0 0 .7-7.9A5.5 5.5 0 0 0 7.2 10 4.5 4.5 0 0 0 7 19Z', }; +
    + + +
    +
      +
    • Mean
    • +
    • Most years
    • +
    • Your date
    • +
    + + + diff --git a/builder/app/src/lib/planner/layers/climate-source.ts b/builder/app/src/lib/planner/layers/climate-source.ts index 8c2962826..4b89c537c 100644 --- a/builder/app/src/lib/planner/layers/climate-source.ts +++ b/builder/app/src/lib/planner/layers/climate-source.ts @@ -1,6 +1,6 @@ import type { Coordinate } from '../map-types'; import { OVERVIEW, cellAt, climateMeta, climateTile, locate, type ClimateMeta, type ClimateTile, type Level } from './climate'; -import type { TileArchive } from './tile-archive'; +import { openTileArchive, type TileArchive } from './tile-archive'; export type TileGetter = (level: Level, x: number, y: number) => Promise; @@ -32,6 +32,19 @@ export function climateSource(archive: TileArchive): ClimateSource { }; } +const opened = new Map>(); + +/** One source per archive URL, so the climate layers share its tiles; a failed open is retried on the next call. */ +export function openClimate(url: string): Promise { + let entry = opened.get(url); + if (!entry) { + entry = openTileArchive(url, 'Climate').then(climateSource); + opened.set(url, entry); + entry.catch(() => opened.delete(url)); + } + return entry; +} + /** A cell of a decoded tile. */ export interface CellRef { tile: ClimateTile; index: number } diff --git a/builder/app/src/lib/planner/layers/climate-terrain.test.ts b/builder/app/src/lib/planner/layers/climate-terrain.test.ts index c65b7833a..9c0b3b32b 100644 --- a/builder/app/src/lib/planner/layers/climate-terrain.test.ts +++ b/builder/app/src/lib/planner/layers/climate-terrain.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; // map-style reads the page URL when it loads. vi.stubGlobal('window', { location: { href: 'https://planner.example/plan/' } }); -const { cropHeights, demTile } = await import('./climate-terrain'); +const { cropHeights, demTile, reliefZoom } = await import('./climate-terrain'); /** A 512 px Terrarium tile whose height at (column, row) is 10 × row + column − 100. */ function terrarium(): Uint8ClampedArray { @@ -25,6 +25,10 @@ describe('climate terrain', () => { expect(demTile(15, 8 * 100 + 3, 8 * 50 + 7)).toEqual({ z: 12, x: 100, y: 50, scale: 8, left: 192, top: 448 }); }); + it('reads a map point from the DEM zoom that the relief loads', () => { + expect([reliefZoom(9.4), reliefZoom(9.6), reliefZoom(15), reliefZoom(-1)]).toEqual([9, 10, 12, 0]); + }); + it('decodes Terrarium heights of the covered part, one DEM pixel per map pixel at the view zoom', () => { const rgba = terrarium(); const quarter = cropHeights(rgba, demTile(10, 533, 355)); diff --git a/builder/app/src/lib/planner/layers/climate-terrain.ts b/builder/app/src/lib/planner/layers/climate-terrain.ts index 5b5967ab9..f210f4f50 100644 --- a/builder/app/src/lib/planner/layers/climate-terrain.ts +++ b/builder/app/src/lib/planner/layers/climate-terrain.ts @@ -14,36 +14,39 @@ export function demTile(z: number, x: number, y: number): { z: number; x: number return { z: dz, x: Math.floor(x / scale), y: Math.floor(y / scale), scale, left: (x % scale) * part, top: (y % scale) * part }; } -/** - * Heights in metres of the 256 × 256 nearest pixels of the part of a 512 px Terrarium RGBA tile that - * a map tile covers: R × 256 + G + B ÷ 256 − 32768. - */ +/** The DEM zoom that the 512 px relief loads at a map zoom: MapLibre rounds the zoom for raster sources. */ +export function reliefZoom(zoom: number): number { + return Math.min(Math.max(Math.round(zoom), 0), DEM_MAX_ZOOM); +} + +/** The height in metres of pixel (column, row) of a 512 px Terrarium RGBA tile: R × 256 + G + B ÷ 256 − 32768. */ +export function pixelHeight(rgba: ArrayLike, column: number, row: number): number { + const p = 4 * (row * DEM_TILE + column); + return rgba[p] * 256 + rgba[p + 1] + rgba[p + 2] / 256 - 32768; +} + +/** Heights in metres of the 256 × 256 nearest pixels of the part of a 512 px Terrarium RGBA tile that a map tile covers. */ export function cropHeights(rgba: ArrayLike, { scale, left, top }: { scale: number; left: number; top: number }): Float32Array { const step = DEM_TILE / scale / TILE; const out = new Float32Array(TILE * TILE); for (let py = 0; py < TILE; py++) { - const row = (top + Math.floor(py * step)) * DEM_TILE + left; - for (let px = 0; px < TILE; px++) { - const p = 4 * (row + Math.floor(px * step)); - out[py * TILE + px] = rgba[p] * 256 + rgba[p + 1] + rgba[p + 2] / 256 - 32768; - } + const row = top + Math.floor(py * step); + for (let px = 0; px < TILE; px++) out[py * TILE + px] = pixelHeight(rgba, left + Math.floor(px * step), row); } return out; } /** - * Heights in metres of the 256 × 256 pixels of map tile z/x/y, row by row from the north-west pixel; - * undefined where the terrain service has no tile. `template` is the map's Terrarium URL, so the - * browser cache serves tiles that the map has loaded. + * The RGBA pixels of DEM tile z/x/y; undefined where the terrain service has no tile. `template` is + * the map's Terrarium URL, so the browser cache serves tiles that the map has loaded. */ -export async function tileHeights(template: string, z: number, x: number, y: number, signal?: AbortSignal): Promise { - const dem = demTile(z, x, y); - const response = await fetch(template.replace('{z}', String(dem.z)).replace('{x}', String(dem.x)).replace('{y}', String(dem.y)), { signal }); +export async function demPixels(template: string, z: number, x: number, y: number, signal?: AbortSignal): Promise { + const response = await fetch(template.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)), { signal }); if (!response.ok || response.status === 204) return undefined; // Without these options the browser may convert colours or premultiply, which changes the heights. const image = await createImageBitmap(await response.blob(), { colorSpaceConversion: 'none', premultiplyAlpha: 'none' }); const context = new OffscreenCanvas(DEM_TILE, DEM_TILE).getContext('2d', { willReadFrequently: true })!; context.drawImage(image, 0, 0, DEM_TILE, DEM_TILE); image.close(); - return cropHeights(context.getImageData(0, 0, DEM_TILE, DEM_TILE).data, dem); + return context.getImageData(0, 0, DEM_TILE, DEM_TILE).data; } diff --git a/builder/app/src/lib/planner/layers/data-layer.ts b/builder/app/src/lib/planner/layers/data-layer.ts index 2afe67f28..9f0cee89b 100644 --- a/builder/app/src/lib/planner/layers/data-layer.ts +++ b/builder/app/src/lib/planner/layers/data-layer.ts @@ -64,6 +64,8 @@ export interface DataLayer { sample(line: Line, signal: AbortSignal): Promise; /** The strip under the profile; a layer without it has no strip. Each sample has a value that indexes `fills`. */ readonly strip?: { + /** The strip title; the layer label without it. */ + label?: string; legend(theme: Theme): Legend; fills(theme: Theme): Swatch[]; values(samples: Samples, date: string): Uint8Array; @@ -72,6 +74,8 @@ export interface DataLayer { chart(samples: Samples, i: number, view: View): Chart; /** The year slider of a route: its label line and one row per variable; empty rows without samples. */ year(samples: Samples | null, view: View): Grid; + /** A short label for each overnight stop, at sample `index` on the night of `date`; '' for none. A layer without it labels no stops. */ + nights?(samples: Samples, stops: { index: number; date: string }[]): string[]; } /** The layers whose archive the region has, in list order. */ @@ -122,6 +126,11 @@ export function columnDate(column: number, year: number, columns: number): strin return new Date(Date.UTC(year, day.getUTCMonth(), day.getUTCDate())).toISOString().slice(0, 10); } +export function addDays(date: string, days: number): string { + const [year, month, day] = date.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10); +} + /** The same day `months` later, or the last day of a shorter month. */ export function addMonths(date: string, months: number): string { const [year, month, day] = date.split('-').map(Number); diff --git a/builder/app/src/lib/planner/layers/registry.ts b/builder/app/src/lib/planner/layers/registry.ts index 6920e5895..07fb4b14c 100644 --- a/builder/app/src/lib/planner/layers/registry.ts +++ b/builder/app/src/lib/planner/layers/registry.ts @@ -1,8 +1,10 @@ import { DATA_URLS } from '../map-data'; import { availableLayers } from './data-layer'; import { snowLayer } from './snow-layer.svelte'; +import { weatherLayer } from './weather-layer.svelte'; /** The data layers of the layer menu, in menu order. */ export const dataLayers = availableLayers([ { archive: 'snow', create: snowLayer }, + { archive: 'climate', create: weatherLayer }, ], DATA_URLS); diff --git a/builder/app/src/lib/planner/layers/weather-layer.svelte.ts b/builder/app/src/lib/planner/layers/weather-layer.svelte.ts new file mode 100644 index 000000000..3db72834e --- /dev/null +++ b/builder/app/src/lib/planner/layers/weather-layer.svelte.ts @@ -0,0 +1,190 @@ +import { addProtocol, type Map, type RequestParameters } from 'maplibre-gl'; +import { kilometres } from '../editor'; +import { TERRAIN_URL } from '../map-data'; +import { DEM_MAX_ZOOM, DEM_TILE } from '../map-style'; +import type { Coordinate } from '../map-types'; +import { DETAIL, OVERVIEW, locate, type ClimateMeta } from './climate'; +import { openClimate, sampleLine, type CellRef, type ClimateSource } from './climate-source'; +import { cropHeights, demPixels, demTile, pixelHeight, reliefZoom } from './climate-terrain'; +import { weekOf, type DataLayer, type Line, type Theme, type View } from './data-layer'; +import { + cellBlock, mapLegend, nightLabels, paintWeather, rampWords, sampleHighs, temperatureClass, temperatureFills, tileCells, + weatherChart, weatherYear, type Samples, type Variable, +} from './weather'; + +const PROTOCOL = 'obc-weather'; +const TILE = 256; +// One DEM pixel per map pixel ends here; MapLibre scales the tiles beyond it. +const MAX_ZOOM = DEM_MAX_ZOOM + 1; +// A decoded DEM tile takes 1 MB; four map tiles share one. +const DEM_TILES = 16; + +const palettes = Object.fromEntries((['temperature', 'rain'] as const).map(variable => + [variable, { light: rampWords(variable, 'light'), dark: rampWords(variable, 'dark') }])) as Record>; + +class WeatherLayer implements DataLayer { + id = 'weather'; + label = 'Weather'; + icon = 'weather'; + description = 'Daytime highs, night lows and rain from ten past years.'; + caveat = 'Valleys can be colder on clear nights.'; + meta = $state(null); + error = $state(''); + variable = $state({ label: 'Map shows', options: [{ value: 'temperature', label: 'Temperature' }, { value: 'rain', label: 'Rain' }], value: 'temperature' }); + private archive?: Promise; + /** Decoded DEM tiles of rendered map tiles, newest last. */ + private dems = new globalThis.Map>(); + private map?: Map; + private date = ''; + private theme: Theme = 'light'; + private shown = false; + /** The week, variable and theme of the drawn tiles. */ + private drawn = ''; + private frame = 0; + + constructor(private url: string) {} + + get source() { + const meta = this.meta; + return meta ? `${meta.attribution} · ~9 km grid · ${meta.firstYear}–${meta.firstYear + meta.years - 1}` : ''; + } + + legend(theme: Theme) { + return mapLegend(this.variable.value as Variable, theme); + } + + private open(): Promise { + this.archive ??= openClimate(this.url).then(source => { + this.meta = source.meta; + this.error = ''; + return source; + }).catch(error => { + this.archive = undefined; + this.error = 'Weather data could not load for this region.'; + throw error; + }); + return this.archive; + } + + /** The pixels of a DEM tile that the map's relief has loaded, so the browser cache serves them. */ + private dem(z: number, x: number, y: number): Promise { + const key = `${z}/${x}/${y}`; + const entry = this.dems.get(key) ?? demPixels(TERRAIN_URL, z, x, y).catch(() => undefined); + this.dems.delete(key); + this.dems.set(key, entry); + for (const old of this.dems.keys()) { if (this.dems.size <= DEM_TILES) break; this.dems.delete(old); } + return entry; + } + + /** + * The height at a coordinate from the most detailed decoded DEM tile that holds it, or else from + * the DEM tile of the view zoom, which the relief has loaded; NaN without terrain. + */ + private async heightAt([longitude, latitude]: Coordinate): Promise { + const sin = Math.sin(latitude * Math.PI / 180); + const pixel = (z: number) => { + const scale = DEM_TILE * 2 ** z; + const px = Math.floor((longitude + 180) / 360 * scale), py = Math.floor((0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * scale); + return { key: [z, Math.floor(px / DEM_TILE), Math.floor(py / DEM_TILE)] as const, column: px % DEM_TILE, row: py % DEM_TILE }; + }; + for (let z = DEM_MAX_ZOOM; z >= 0; z--) { + const { key, column, row } = pixel(z); + const rgba = await this.dems.get(key.join('/')); + if (rgba) return pixelHeight(rgba, column, row); + } + const { key, column, row } = pixel(reliefZoom(this.map?.getZoom() ?? 0)); + const rgba = await this.dem(...key); + return rgba ? pixelHeight(rgba, column, row) : NaN; + } + + /** Colours one map tile for the layer week and variable. */ + private render = async (params: RequestParameters) => { + const [z, x, y] = params.url.slice(PROTOCOL.length + 3).split('/').map(Number); + const variable = this.variable.value as Variable, week = weekOf(this.date), palette = palettes[variable][this.theme]; + const source = await this.open(); + const cells = tileCells(z, x, y); + // The cells whose centres surround the tile's pixels. + const col = Math.floor(cells.cols[0]), row = Math.floor(cells.rows[0]); + const cols = Math.floor(cells.cols[TILE - 1]) - col + 2, rows = Math.floor(cells.rows[TILE - 1]) - row + 2; + const tiles = new globalThis.Map>>(); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const { x: tx, y: ty } = locate(OVERVIEW, { col: col + c, row: row + r }); + const key = `${tx}/${ty}`; + if (!tiles.has(key)) tiles.set(key, await source.tile(OVERVIEW, tx, ty)); + } + } + const cell = (c: number, r: number): CellRef | undefined => { + const { x: tx, y: ty, index } = locate(OVERVIEW, { col: c, row: r }); + const tile = tiles.get(`${tx}/${ty}`); + return tile && { tile, index }; + }; + const block = cellBlock(col, row, cols, rows, cell, variable, week); + const part = demTile(z, x, y), rgba = variable === 'temperature' ? await this.dem(part.z, part.x, part.y) : undefined; + const heights = rgba && cropHeights(rgba, part); + const image = new ImageData(TILE, TILE); + paintWeather(new Uint32Array(image.data.buffer), cells, block, heights, variable, palette); + return { data: await createImageBitmap(image) }; + }; + + sync(map: Map, { shown, date, theme }: View & { shown: boolean }) { + this.date = date; + this.theme = theme; + this.map = map; + this.shown = shown; + if (map.getLayer(this.id)) { + map.setLayoutProperty(this.id, 'visibility', shown ? 'visible' : 'none'); + // A hidden layer keeps its old tiles, so a change while hidden refreshes on show. + const look = `${weekOf(date)} ${this.variable.value} ${theme}`; + if (shown && look !== this.drawn) { + this.drawn = look; + cancelAnimationFrame(this.frame); + this.frame = requestAnimationFrame(() => map.getSource(this.id) && map.refreshTiles(this.id)); + } + return; + } + if (!shown) return; + addProtocol(PROTOCOL, this.render); + void this.open().then(({ bounds }) => { + // A theme change during the request installs into the new style instead. + if (this.map !== map || this.theme !== theme || map.getSource(this.id)) return; + this.drawn = `${weekOf(this.date)} ${this.variable.value} ${theme}`; + map.addSource(this.id, { type: 'raster', tiles: [`${PROTOCOL}://{z}/{x}/{y}`], tileSize: TILE, maxzoom: MAX_ZOOM, bounds, attribution: this.meta?.attribution }); + // Under the relief, so the hillshade shades the colours. + map.addLayer({ id: this.id, type: 'raster', source: this.id, layout: { visibility: this.shown ? 'visible' : 'none' }, paint: { 'raster-fade-duration': 0 } }, map.getLayer('relief') ? 'relief' : undefined); + }, () => {}); + } + + async sample({ coordinates, elevation }: Line, signal: AbortSignal): Promise { + const source = await this.open(); + const [overview, detail] = await Promise.all([sampleLine(coordinates, source.tile, OVERVIEW), sampleLine(coordinates, source.tile, DETAIL)]); + // A map point has no profile height, so the rendered terrain gives it one. A route keeps its profile, so its values never depend on the view. + const heights = coordinates.length === 1 && elevation[0] === null ? [await this.heightAt(coordinates[0])] : elevation.map(height => height ?? NaN); + signal.throwIfAborted(); + // Not editor's cumulative: it freezes the coordinates, and a map point is a state proxy that cannot freeze. + const km = new Float64Array(coordinates.length); + for (let i = 1; i < km.length; i++) km[i] = km[i - 1] + kilometres(coordinates[i - 1], coordinates[i]); + return { overview, detail, elevation: Float32Array.from(heights), km }; + } + + strip = { + label: 'Daytime high', + legend: (theme: Theme) => mapLegend('temperature', theme), + fills: temperatureFills, + values: (samples: Samples, date: string) => Uint8Array.from(sampleHighs(samples, date), temperatureClass), + }; + + chart(samples: Samples, i: number, { date, theme }: View) { + return weatherChart(samples, i, this.meta?.firstYear ?? 0, date, theme, this.variable.value as Variable); + } + + year(samples: Samples | null, { date, theme }: View) { + return weatherYear(samples, date, theme); + } + + nights(samples: Samples, stops: { index: number; date: string }[]) { + return nightLabels(samples, stops); + } +} + +export const weatherLayer = (url: string): DataLayer => new WeatherLayer(url); diff --git a/builder/app/src/lib/planner/layers/weather.test.ts b/builder/app/src/lib/planner/layers/weather.test.ts new file mode 100644 index 000000000..ac55062ed --- /dev/null +++ b/builder/app/src/lib/planner/layers/weather.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { DETAIL, OVERVIEW, WEEKS, climateTile, type Level } from './climate'; +import { + NO_TEMPERATURE, nightLabels, paintWeather, rainWeeks, rampColor, spread, temperatureClass, weatherChart, weatherYear, weekLabel, word, type CellBlock, type Samples, +} from './weather'; + +// Plane order and value counts of specs/planner-climate-tiles.md; orography is the only 2-byte plane. +const PLANES: Record = { + [OVERVIEW]: [['orography', 1], ['lapse_tmax', 12], ['lapse_tmin', 12], ['rose', 192], ['wet_share', 52], ['rain', 52], ['tmax', 52], ['tmin', 52], ['wind', 52]], + [DETAIL]: [['orography', 1], ['lapse_tmax', 12], ['lapse_tmin', 12], ['wet_days', 520], ['rain', 520], ['tmax', 520], ['tmin', 520], ['wind', 520]], +}; + +/** A tile with every value missing, and a writer of raw codes. */ +function tile(level: Level) { + const cells = level === OVERVIEW ? 384 : 96, starts = new Map(); + let size = 0; + for (const [name, count] of PLANES[level]) { starts.set(name, size); size += count * cells * (name === 'orography' ? 2 : 1); } + const body = new Uint8Array(size).fill(0x80); + for (let c = 0; c < cells; c++) body[2 * c + 1] = 0x80, body[2 * c] = 0; + for (const name of ['rose', 'wet_share', 'wet_days', 'rain', 'wind']) { + const at = starts.get(name); + if (at !== undefined) body.fill(255, at, at + PLANES[level].find(p => p[0] === name)![1] * cells); + } + const view = new DataView(body.buffer); + return { + set(name: string, index: number, cell: number, code: number) { + const at = starts.get(name)!; + if (name === 'orography') view.setInt16(at + 2 * cell, code, true); + else view.setUint8(at + index * cells + cell, code & 255); + }, + tile: () => climateTile(level, 0, 0, body), + }; +} + +/** Pixel words that are the scale index, so a test reads the painted value back. */ +const INDEX = Uint32Array.from({ length: 256 }, (_, i) => i); +const temperatureIndex = (celsius: number) => word('temperature', INDEX, celsius); + +describe('weather colours', () => { + it('mixes the two nearest stops and keeps the end colours outside the scale', () => { + expect(rampColor('temperature', 'light', -10)).toBe('#6f8fb3'); + expect(rampColor('temperature', 'light', 45)).toBe(rampColor('temperature', 'light', 30)); + // 5 °C is halfway between the stops at 0 and 10 °C. + expect(rampColor('temperature', 'light', 5)).toBe('#cfd7d6'); + expect(rampColor('rain', 'dark', 0)).toBe('#2d2c22'); + expect(word('rain', INDEX, NaN)).toBe(0); + }); + + it('classes temperatures in whole degrees, with a no-data class', () => { + expect([temperatureClass(-25), temperatureClass(0.4), temperatureClass(-60), temperatureClass(NaN)]).toEqual([0, 25, 0, NO_TEMPERATURE]); + }); +}); + +describe('weather map tiles', () => { + // Two by two cells: 10 and 20 °C on top at 1000 m, 0 °C below, and one cell without data. + const block: CellBlock = { + col: 100, row: 50, cols: 2, rows: 2, + value: Float32Array.of(10, 20, 0, NaN), lapse: Float32Array.of(-6.5, -6.5, -6.5, 0), orography: Float32Array.of(1000, 1000, 1000, 0), + }; + const paint = (cols: number[], rows: number[], heights?: Float32Array) => { + const words = new Uint32Array(256 * 256); + const cells = { cols: Float64Array.from({ length: 256 }, (_, i) => cols[i] ?? 0), rows: Float64Array.from({ length: 256 }, (_, i) => rows[i] ?? 0) }; + paintWeather(words, cells, block, heights, 'temperature', INDEX); + return words; + }; + + it('blends the cell centres, so the colours change smoothly between cells', () => { + const words = paint([100, 100.5, 101], [50]); + expect([words[0], words[1], words[2]]).toEqual([10, 15, 20].map(temperatureIndex)); + }); + + it('moves each cell temperature to the terrain height of the pixel', () => { + const heights = new Float32Array(256 * 256).fill(2000); + expect(paint([100], [50], heights)[0]).toBe(temperatureIndex(3.5)); + }); + + it('ends the data at the cell edge', () => { + // Below the middle of the lower row, the missing cell holds more than half the weight. + const words = paint([100.9, 100.9], [50.4, 50.9]); + expect(words[0]).not.toBe(0); + expect(words[256]).toBe(0); + }); +}); + +describe('weather along a route', () => { + const week = 19, date = '2026-05-16'; + // Two overview cells at 500 m: highs 14 and 12 °C, lows 4 °C, wet on 30 % of the days. + const overview = tile(OVERVIEW); + for (const cell of [0, 1]) { + overview.set('orography', 0, cell, 500); + overview.set('lapse_tmax', 4, cell, -65); + overview.set('lapse_tmin', 4, cell, -50); + overview.set('wet_share', week, cell, 30); + overview.set('tmin', week, cell, 8); + } + overview.set('tmax', week, 0, 28); + overview.set('tmax', week, 1, 24); + const detail = tile(DETAIL); + detail.set('orography', 0, 0, 500); + detail.set('lapse_tmin', 4, 0, -50); + // Night lows over ten years: 1 to 10 °C, so the spread without the extreme years is 2–9 °C. + for (let year = 0; year < 10; year++) detail.set('tmin', year * WEEKS + week, 0, 2 * (year + 1)); + const samples = (elevation: number[]): Samples => { + const o = overview.tile(), d = detail.tile(); + return { overview: [{ tile: o, index: 0 }, { tile: o, index: 1 }], detail: [{ tile: d, index: 0 }, undefined], elevation: Float32Array.from(elevation), km: [0, 10] }; + }; + + it('has a High, a Low and a Rain row and names the week in its label', () => { + const year = weatherYear(samples([500, 500]), date, 'light'); + expect(year.rows.map(row => row.label)).toEqual(['High', 'Low', 'Rain']); + expect(year.label).toBe('On the route, 14–20 May: highs 12–14 °C · lows 4 °C · rain on 2.1 of 7 days'); + expect(year.rows[0].cells[week]).toBe(temperatureClass(13)); + expect(year.rows[1].cells[week]).toBe(temperatureClass(4)); + expect(year.fills[year.rows[2].cells[week]].label).toBe('2 of 7 days'); + expect(year.rows[0].cells[0]).toBe(NO_TEMPERATURE); + expect(weatherYear(null, date, 'light').rows[0].cells.every(cell => cell === 255)).toBe(true); + }); + + it('charts the wet days of the week for rain, and highs and lows for temperature', () => { + const rainy = tile(DETAIL); + // Wet days 1 to 5 over the years, two years each, so the extreme years do not narrow the spread. Week 51 has 8 days, 9 in a leap year. + for (let year = 0; year < 10; year++) { + rainy.set('wet_days', year * WEEKS + week, 0, 1 + Math.floor(year / 2)); + rainy.set('wet_days', year * WEEKS + 51, 0, 8); + rainy.set('rain', year * WEEKS + week, 0, 10); + // A dry week elsewhere makes this week twice the mean week. + rainy.set('rain', year * WEEKS + 30, 0, 0); + } + const weeks = rainWeeks({ tile: rainy.tile(), index: 0 }, 2016); + expect([weeks.mean[week], weeks.low[week], weeks.high[week]]).toEqual([3, 1, 5]); + // 2016, 2020 and 2024 are leap years. + expect(weeks.mean[51]).toBeCloseTo((7 * 7 + 3 * 7 * 8 / 9) / 10); + const point = { ...samples([500]), detail: [{ tile: rainy.tile(), index: 0 }] }; + const rain = weatherChart(point, 0, 2016, date, 'light', 'rain'); + expect(rain.headline).toBe('14–20 May: rain on about 3 of 7 days (1–5 in most years)'); + expect(rain.grids).toEqual([]); + expect(rain.note).toBe('Usually more rain than in an average week here.'); + expect(weatherChart(samples([500, 500]), 0, 2016, date, 'light', 'temperature').grids.map(grid => grid.label)).toEqual(['Daytime high', 'Night low']); + }); + + it('labels each overnight stop with the night lows of its week at its height', () => { + expect(nightLabels(samples([500, 500]), [{ index: 0, date }, { index: 1, date }])).toEqual(['Night 2–9 °C', '']); + // 1000 m higher at −5 K/km: 5 °C colder. + expect(nightLabels(samples([1500, 500]), [{ index: 0, date }])).toEqual(['Night −3 to 4 °C']); + }); +}); + +describe('weather words', () => { + it('drops the extreme years from a spread of five or more', () => { + expect(spread([5, 1, 3, NaN, 9, 7])).toEqual([3, 7]); + expect(spread([2, 4])).toEqual([2, 4]); + expect(spread([NaN])).toBeNull(); + }); + + it('names the days of the climate week', () => { + expect(weekLabel('2026-05-16')).toBe('14–20 May'); + expect(weekLabel('2026-04-30')).toBe('30 Apr – 6 May'); + expect(weekLabel('2026-12-31')).toBe('24–31 Dec'); + }); +}); diff --git a/builder/app/src/lib/planner/layers/weather.ts b/builder/app/src/lib/planner/layers/weather.ts new file mode 100644 index 000000000..f6b7e33a7 --- /dev/null +++ b/builder/app/src/lib/planner/layers/weather.ts @@ -0,0 +1,274 @@ +// The Weather layer of the climate archive: daytime highs or the wet-day share on the map; highs, +// rain and night lows along a route and at a point. +import RainWeeks from '../../../components/planner/RainWeeks.svelte'; +import { RAIN_DRIER, RAIN_UNKNOWN, RAIN_WETTER, atElevation, rainClass, rainRatios, read, temperatureAt, weekMonth, wetDaysOf7, WEEKS, YEARS } from './climate'; +import type { CellRef } from './climate-source'; +import { weatherRows } from './climate-route'; +import { dateLabel, weekOf, type Chart, type Grid, type Legend, type Swatch, type Theme } from './data-layer'; + +export type Variable = 'temperature' | 'rain'; + +/** Evenly spaced colour stops from `from` to `to`; values outside take the end colours. */ +interface Ramp { from: number; to: number; colors: Record; labels: string[] } +const RAMPS: Record = { + // Daytime highs in °C: cold blue, a neutral near 10 °C, warm clay; the dark ramp is mixed toward the dark base. + temperature: { + from: -10, to: 30, + colors: { light: ['#6f8fb3', '#b3c8d9', '#ebe6d2', '#e3b07f', '#c5643d'], dark: ['#3f5f80', '#5f819c', '#8a8664', '#b5844f', '#c8603c'] }, + labels: ['−10', '0', '10', '20', '30 °C'], + }, + // Wet days of 7: dry cream to deep blue-grey. The scale ends at 6 of 7, so typical weeks get most of the colour range. + rain: { + from: 0, to: 6, + colors: { light: ['#efeadb', '#dce3d6', '#c2d6d4', '#a1c1cb', '#7fa8bf', '#5f8cae', '#46719a'], dark: ['#2d2c22', '#303a32', '#334641', '#365358', '#3b6173', '#44708b', '#507fa2'] }, + labels: ['0', '', '2', '', '4', '', '6 of 7 days'], + }, +}; + +const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)); +const hex = (channels: number[]) => '#' + channels.map(c => Math.round(c).toString(16).padStart(2, '0')).join(''); + +/** The colour of a value on the variable's scale, mixed between the two nearest stops. */ +export function rampColor(variable: Variable, theme: Theme, value: number): string { + const { from, to, colors } = RAMPS[variable], stops = colors[theme]; + const at = Math.min(1, Math.max(0, (value - from) / (to - from))) * (stops.length - 1); + const i = Math.min(stops.length - 2, Math.floor(at)), a = rgb(stops[i]), b = rgb(stops[i + 1]); + return hex(a.map((c, k) => c + (b[k] - c) * (at - i))); +} + +export function mapLegend(variable: Variable, theme: Theme): Legend { + const { colors, labels } = RAMPS[variable]; + return { scale: colors[theme].map((color, i) => ({ color, label: labels[i] })) }; +} + +/** 256 ABGR words over the scale of a variable, for ImageData pixels. */ +export function rampWords(variable: Variable, theme: Theme): Uint32Array { + const { from, to } = RAMPS[variable]; + return Uint32Array.from({ length: 256 }, (_, i) => { + const [r, g, b] = rgb(rampColor(variable, theme, from + (to - from) * i / 255)); + return ((255 << 24) | (b << 16) | (g << 8) | r) >>> 0; + }); +} + +/** The word of a value; 0 (transparent) for NaN. */ +export function word(variable: Variable, words: Uint32Array, value: number): number { + if (Number.isNaN(value)) return 0; + const { from, to } = RAMPS[variable]; + return words[Math.min(255, Math.max(0, Math.round((value - from) / (to - from) * 255)))]; +} + +const T_LOW = -25, T_HIGH = 40; +/** The no-data class; the temperature classes in 1 °C steps come before it. */ +export const NO_TEMPERATURE = T_HIGH - T_LOW + 1; +const WET_STEP = 0.25, WET_CLASSES = 7 / WET_STEP + 1; + +export function temperatureClass(celsius: number): number { + return Number.isNaN(celsius) ? NO_TEMPERATURE : Math.min(T_HIGH, Math.max(T_LOW, Math.round(celsius))) - T_LOW; +} + +const noData = (theme: Theme): Swatch => ({ label: 'No data', color: theme === 'dark' ? '#6b685c' : '#b8b5ac', hatch: true }); + +export function temperatureFills(theme: Theme): Swatch[] { + return [...Array.from({ length: NO_TEMPERATURE }, (_, i) => ({ label: `${celsius(T_LOW + i)} °C`, color: rampColor('temperature', theme, T_LOW + i) })), noData(theme)]; +} + +const wetFills = (theme: Theme): Swatch[] => + Array.from({ length: WET_CLASSES }, (_, i) => ({ label: `${i * WET_STEP} of 7 days`, color: rampColor('rain', theme, i * WET_STEP) })); +const wetClass = (days: number) => Math.round(Math.min(7, Math.max(0, days)) / WET_STEP); +/** The year slider fills: temperature classes, then wet-day classes from here. */ +const WET_BASE = NO_TEMPERATURE + 1; + +/** "−3", "12": whole degrees with a true minus sign. */ +export const celsius = (value: number) => String(Math.round(value)).replace('-', '−'); + +/** "3–8 °C", "−4 to 2 °C" or "5 °C". */ +export function range(low: number, high: number): string { + const [a, b] = [celsius(low), celsius(high)]; + return a === b ? `${a} °C` : `${a}${low < -0.5 ? ' to ' : '–'}${b} °C`; +} + +/** The spread of yearly values without the lowest and the highest year; null without values. */ +export function spread(values: ArrayLike): [number, number] | null { + const sorted = Array.from(values).filter(v => !Number.isNaN(v)).sort((a, b) => a - b); + if (!sorted.length) return null; + const drop = sorted.length >= 5 ? 1 : 0; + return [sorted[drop], sorted[sorted.length - 1 - drop]]; +} + +const DAY_MS = 86_400_000; + +/** "14–20 May" or "28 Apr – 4 May": the days of the climate week of a date. */ +export function weekLabel(date: string): string { + const year = Number(date.slice(0, 4)), week = weekOf(date); + const start = new Date(Date.UTC(year, 0, 1 + 7 * week)); + const end = week === WEEKS - 1 ? new Date(Date.UTC(year, 11, 31)) : new Date(start.getTime() + 6 * DAY_MS); + return start.getUTCMonth() === end.getUTCMonth() ? `${start.getUTCDate()}–${dateLabel(end)}` : `${dateLabel(start)} – ${dateLabel(end)}`; +} + +/** A block of global cells around a map tile, with the overview values of one week. */ +export interface CellBlock { + col: number; row: number; cols: number; rows: number; + /** Temperature at the cell orography, or wet days of 7; NaN where missing. */ + value: Float32Array; + lapse: Float32Array; + orography: Float32Array; +} + +/** The values of each cell of a block for a week, from its overview cell reference. */ +export function cellBlock(col: number, row: number, cols: number, rows: number, cell: (col: number, row: number) => CellRef | undefined, variable: Variable, week: number): CellBlock { + const n = cols * rows, block = { col, row, cols, rows, value: new Float32Array(n).fill(NaN), lapse: new Float32Array(n), orography: new Float32Array(n) }; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const ref = cell(col + c, row + r), k = r * cols + c; + if (!ref) continue; + if (variable === 'rain') block.value[k] = wetDaysOf7(ref.tile, ref.index, week); + else { + block.value[k] = read(ref.tile, 'tmax', week, ref.index); + block.lapse[k] = read(ref.tile, 'lapse_tmax', weekMonth(week), ref.index); + block.orography[k] = read(ref.tile, 'orography', 0, ref.index); + } + } + } + return block; +} + +/** The fractional global cell column and row of each pixel column and row of a 256 px map tile. */ +export function tileCells(z: number, x: number, y: number): { cols: Float64Array; rows: Float64Array } { + const scale = 256 * 2 ** z; + return { + cols: Float64Array.from({ length: 256 }, (_, px) => 10 * ((x * 256 + px + 0.5) / scale * 360)), + rows: Float64Array.from({ length: 256 }, (_, py) => 10 * (90 - Math.atan(Math.sinh(Math.PI * (1 - 2 * (y * 256 + py + 0.5) / scale))) * 180 / Math.PI)), + }; +} + +/** + * Writes a 256 px map tile: each pixel blends the four nearest cell centres. A temperature moves to + * the pixel's height from each cell's orography with the cell's lapse rate, so valleys and ridges + * show inside a cell; without `heights` it stays at the blended orography. Less than half the weight + * on cells with data leaves the pixel clear, so the data ends at the cell edges. + */ +export function paintWeather(words: Uint32Array, cells: { cols: Float64Array; rows: Float64Array }, block: CellBlock, heights: Float32Array | undefined, variable: Variable, palette: Uint32Array) { + const { value, lapse, orography, cols, rows } = block; + for (let py = 0; py < 256; py++) { + const fr = cells.rows[py] - block.row, r0 = Math.floor(fr), wy = fr - r0; + for (let px = 0; px < 256; px++) { + const fc = cells.cols[px] - block.col, c0 = Math.floor(fc), wx = fc - c0; + const height = variable === 'temperature' ? heights?.[py * 256 + px] ?? NaN : NaN; + let sum = 0, weight = 0; + for (let k = 0; k < 4; k++) { + const r = r0 + (k >> 1), c = c0 + (k & 1); + if (r < 0 || c < 0 || r >= rows || c >= cols) continue; + const i = r * cols + c; + if (Number.isNaN(value[i])) continue; + const w = (k >> 1 ? wy : 1 - wy) * (k & 1 ? wx : 1 - wx); + weight += w; + // Each cell's own lapse rate moves its temperature to the pixel height. + sum += w * (Number.isNaN(height) ? value[i] : atElevation(value[i], lapse[i], orography[i], height)); + } + words[py * 256 + px] = weight < 0.5 ? 0 : word(variable, palette, sum / weight); + } + } +} + +/** A route or a point: the overview and detail cell of each sample, its height, and the route kilometres. */ +export interface Samples { + overview: (CellRef | undefined)[]; + detail: (CellRef | undefined)[]; + /** Metres; NaN where unknown. */ + elevation: Float32Array; + km: ArrayLike; +} + +/** The daytime high of the week of `date` at each sample, at its height. */ +export function sampleHighs({ overview, elevation }: Samples, date: string): Float32Array { + const week = weekOf(date); + return Float32Array.from(overview, (ref, i) => ref ? temperatureAt(ref.tile, 'tmax', week, ref.index, elevation[i]) : NaN); +} + +const minMax = (values: ArrayLike) => { + const known = Array.from(values).filter(v => !Number.isNaN(v)); + return known.length ? [Math.min(...known), Math.max(...known)] as const : null; +}; + +const oneDecimal = (value: number) => value.toLocaleString('en-GB', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); + +/** The year slider of a route: per week, the mean high, wet days and night low along the route. */ +export function weatherYear(samples: Samples | null, date: string, theme: Theme): Grid { + const fills = [...temperatureFills(theme), ...wetFills(theme)]; + const empty = new Uint8Array(WEEKS).fill(255); + const grid = (label: string, high: Uint8Array, low: Uint8Array, rain: Uint8Array): Grid => ({ + label, columns: WEEKS, fills, + rows: [{ label: 'High', cells: high }, { label: 'Low', cells: low }, { label: 'Rain', cells: rain }], + }); + if (!samples) return grid('Plan a route to see its weather through the year.', empty, empty, empty); + const rows = weatherRows(samples.overview, samples.km, samples.elevation); + const week = weekOf(date); + const highs = minMax(sampleHighs(samples, date)); + if (!highs) return grid('No weather data along the route', empty, empty, empty); + const lows = minMax(Float32Array.from(samples.overview, (ref, i) => ref ? temperatureAt(ref.tile, 'tmin', week, ref.index, samples.elevation[i]) : NaN)); + const label = `On the route, ${weekLabel(date)}: highs ${range(...highs)} · lows ${lows ? range(...lows) : 'unknown'} · rain on ${oneDecimal(rows.rain[week])} of 7 days`; + return grid(label, Uint8Array.from(rows.high, temperatureClass), Uint8Array.from(rows.low, temperatureClass), Uint8Array.from(rows.rain, days => Number.isNaN(days) ? 255 : WET_BASE + wetClass(days))); +} + +const leap = (year: number) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +/** Days of a week of the spec: week 51 also holds the last one or two days of the year. */ +const daysOf = (week: number, year: number) => week < WEEKS - 1 ? 7 : leap(year) ? 9 : 8; + +/** Wet days of 7 in each week of the years at a detail cell: the mean, and the spread without the extreme years. */ +export function rainWeeks(ref: CellRef, firstYear: number): { mean: Float32Array; low: Float32Array; high: Float32Array } { + const mean = new Float32Array(WEEKS), low = new Float32Array(WEEKS), high = new Float32Array(WEEKS); + for (let week = 0; week < WEEKS; week++) { + const years = Array.from({ length: YEARS }, (_, year) => 7 * read(ref.tile, 'wet_days', year * WEEKS + week, ref.index) / daysOf(week, firstYear + year)); + const known = years.filter(v => !Number.isNaN(v)); + mean[week] = known.length ? known.reduce((a, b) => a + b, 0) / known.length : NaN; + [low[week], high[week]] = spread(years) ?? [NaN, NaN]; + } + return { mean, low, high }; +} + +/** The years at one sample for the map variable: highs and lows, or wet days through the year. */ +export function weatherChart(samples: Samples, i: number, firstYear: number, date: string, theme: Theme, variable: Variable): Chart { + const ref = samples.detail[i], height = samples.elevation[i]; + if (!ref) return { headline: 'No weather data here', grids: [] }; + const week = weekOf(date); + if (variable === 'rain') { + const weeks = rainWeeks(ref, firstYear); + if (Number.isNaN(weeks.mean[week])) return { headline: 'No rain data here', grids: [] }; + const most = Math.round(weeks.low[week]) === Math.round(weeks.high[week]) ? `${Math.round(weeks.low[week])}` : `${Math.round(weeks.low[week])}–${Math.round(weeks.high[week])}`; + // The rain amount of the week against the mean week of the cell, over the years. + const ratios = rainRatios(Float32Array.from({ length: YEARS * WEEKS }, (_, slot) => read(ref.tile, 'rain', slot, ref.index)), firstYear); + const ofWeek = Array.from({ length: YEARS }, (_, year) => ratios[year * WEEKS + week]).filter(v => !Number.isNaN(v)); + const amount = ofWeek.length ? rainClass(ofWeek.reduce((a, b) => a + b, 0) / ofWeek.length) : RAIN_UNKNOWN; + return { + headline: `${weekLabel(date)}: rain on about ${Math.round(weeks.mean[week])} of 7 days (${most} in most years)`, + grids: [], + extra: { component: RainWeeks, props: { ...weeks, week, colors: { line: rampColor('rain', theme, 5), band: rampColor('rain', theme, theme === 'dark' ? 3 : 1.5) } } }, + note: amount === RAIN_WETTER ? 'Usually more rain than in an average week here.' : amount === RAIN_DRIER ? 'Usually less rain than in an average week here.' : undefined, + }; + } + const temperatures = (plane: 'tmax' | 'tmin') => Float32Array.from({ length: YEARS * WEEKS }, (_, slot) => temperatureAt(ref.tile, plane, slot, ref.index, height)); + const high = temperatures('tmax'), low = temperatures('tmin'); + const ofWeek = (values: Float32Array) => spread(Array.from({ length: YEARS }, (_, year) => values[year * WEEKS + week])); + const highs = ofWeek(high), lows = ofWeek(low); + const parts = [highs && `highs ${range(...highs)}`, lows && `night lows ${range(...lows)}`].filter(Boolean); + const years = (values: Float32Array, label: string, legend?: Legend): Grid => ({ + label, columns: WEEKS, fills: temperatureFills(theme), legend, + rows: Array.from({ length: YEARS }, (_, year) => ({ label: String(firstYear + year), cells: Uint8Array.from({ length: WEEKS }, (_, w) => temperatureClass(values[year * WEEKS + w])) })), + }); + return { + headline: parts.length ? `${weekLabel(date)}: ${parts.join(', ')}` : 'No weather data here', + grids: [years(high, 'Daytime high'), years(low, 'Night low', mapLegend('temperature', theme))], + note: 'Valleys can be colder on clear nights.', + }; +} + +/** "Night 3–8 °C" at each stop: the night lows of the stop's week over the years, at its height. */ +export function nightLabels(samples: Samples, stops: { index: number; date: string }[]): string[] { + return stops.map(({ index, date }) => { + const ref = samples.detail[index], week = weekOf(date); + if (!ref) return ''; + const lows = spread(Array.from({ length: YEARS }, (_, year) => temperatureAt(ref.tile, 'tmin', year * WEEKS + week, ref.index, samples.elevation[index]))); + return lows ? `Night ${range(...lows)}` : ''; + }); +} + diff --git a/builder/app/tests/browser/package.json b/builder/app/tests/browser/package.json index 84337a5ab..8ae3682ba 100644 --- a/builder/app/tests/browser/package.json +++ b/builder/app/tests/browser/package.json @@ -7,7 +7,7 @@ "test": "playwright test", "pretest:live": "node -e \"require('node:fs').rmSync('../../../../.artifacts/web-builder-live', { recursive: true, force: true })\" && VITE_PLANNER_TILEJSON_URL=https://basemap.test/basemap.json VITE_PLANNER_SPRITES_URL=https://basemap.test/sprites VITE_PLANNER_GLYPHS_URL=https://basemap.test/fonts/{fontstack}/{range}.pbf VITE_CATALOG_URL=https://maps.openbikecomputer.com/cell-catalog/catalog.json npm run build:web --prefix ../..", "test:live": "playwright test --config playwright.live.config.js", - "pretest:planner": "VITE_PLANNER_TILEJSON_URL=https://tiles.test/basemap.json VITE_PLANNER_PLACES_URL=https://tiles.test/places.json VITE_PLANNER_OVERLAYS_URL=https://tiles.test/overlays.json VITE_PLANNER_DEM_URL=https://tiles.test/terrain/{z}/{x}/{y}.webp VITE_PLANNER_SNOW_URL=https://tiles.test/snow.json VITE_PLANNER_GLYPHS_URL=https://tiles.test/fonts/{fontstack}/{range}.pbf VITE_PLANNER_SPRITES_URL=https://tiles.test/sprites VITE_PLANNER_ROUTING_URL=https://api.test/routing VITE_PLANNER_SEARCH_URL=https://api.test/search VITE_PLANNER_MAP_BOUNDS=7.45,47.5,10.5,49.85 npm run build:planner --prefix ../..", + "pretest:planner": "VITE_PLANNER_TILEJSON_URL=https://tiles.test/basemap.json VITE_PLANNER_PLACES_URL=https://tiles.test/places.json VITE_PLANNER_OVERLAYS_URL=https://tiles.test/overlays.json VITE_PLANNER_DEM_URL=https://tiles.test/terrain/{z}/{x}/{y}.webp VITE_PLANNER_SNOW_URL=https://tiles.test/snow.json VITE_PLANNER_CLIMATE_URL=https://tiles.test/climate.json VITE_PLANNER_GLYPHS_URL=https://tiles.test/fonts/{fontstack}/{range}.pbf VITE_PLANNER_SPRITES_URL=https://tiles.test/sprites VITE_PLANNER_ROUTING_URL=https://api.test/routing VITE_PLANNER_SEARCH_URL=https://api.test/search VITE_PLANNER_MAP_BOUNDS=7.45,47.5,10.5,49.85 npm run build:planner --prefix ../..", "test:planner": "playwright test --config playwright.planner.config.js" }, "devDependencies": {