diff --git a/builder/app/src/components/planner/LayerLegend.svelte b/builder/app/src/components/planner/LayerLegend.svelte
index 169ae7bdb..b7ce9f3a7 100644
--- a/builder/app/src/components/planner/LayerLegend.svelte
+++ b/builder/app/src/components/planner/LayerLegend.svelte
@@ -6,9 +6,16 @@
let { legend, label, columns = false, children }: { legend: Legend; label: string; columns?: boolean; children?: Snippet } = $props();
+{#snippet marks()}
+ {#each legend.marks ?? [] as mark (mark.label)}
+
{mark.label}
+ {/each}
+{/snippet}
+
{#if 'swatches' in legend}
{#each legend.swatches as swatch (swatch.label)}- {swatch.label}
{/each}
+ {@render marks()}
{@render children?.()}
{:else}
@@ -17,7 +24,7 @@
stop.color).join(', ')})`}>
{#each legend.scale as stop, i (i)}- {stop.label}
{/each}
- {#if children}{/if}
+ {#if children || legend.marks}{@render marks()}{@render children?.()}
{/if}
{/if}
@@ -30,6 +37,7 @@
.swatch { flex: 0 0 14px; height: 8px; border-radius: 2px; background: var(--swatch); box-shadow: inset 0 0 0 1px var(--line-strong); }
.columns .swatch { height: 10px; }
.hatch { background: repeating-linear-gradient(135deg, transparent 0 2px, var(--swatch) 2px 3.5px); }
+ .mark { flex: none; width: 20px; height: 20px; margin: -6px -3px; fill: none; stroke: var(--ink); stroke-linecap: round; stroke-linejoin: round; }
.scale { align-items: flex-start; }
.ramp { flex: 0 1 180px; min-width: 120px; }
.bar { display: block; height: 8px; border-radius: 2px; box-shadow: inset 0 0 0 1px var(--line-strong); }
diff --git a/builder/app/src/components/planner/PlannerIcon.svelte b/builder/app/src/components/planner/PlannerIcon.svelte
index 8f3f3f2e6..644de04b2 100644
--- a/builder/app/src/components/planner/PlannerIcon.svelte
+++ b/builder/app/src/components/planner/PlannerIcon.svelte
@@ -41,6 +41,7 @@
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1',
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',
+ wind: 'M3 8h10a3 3 0 1 0-3-3M3 12h15a3 3 0 1 1-3 3M3 16h7',
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',
};
diff --git a/builder/app/src/components/planner/WindRose.svelte b/builder/app/src/components/planner/WindRose.svelte
new file mode 100644
index 000000000..2892e28c6
--- /dev/null
+++ b/builder/app/src/components/planner/WindRose.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+
+ {month}, daytime
+ Petals point where the wind blows
+ {#if bearing !== undefined}
+ Headwind Tailwind
+ Your direction
+ {/if}
+
+
+
+
diff --git a/builder/app/src/lib/planner/layers/climate-route.ts b/builder/app/src/lib/planner/layers/climate-route.ts
index 686f779fd..39ae46a42 100644
--- a/builder/app/src/lib/planner/layers/climate-route.ts
+++ b/builder/app/src/lib/planner/layers/climate-route.ts
@@ -3,10 +3,18 @@ import type { Coordinate } from '../map-types';
import { MONTHS, SECTORS, WEEKS, headPart, temperatureAt, wetDaysOf7, windRose } from './climate';
import type { CellRef } from './climate-source';
-/** The travel direction at each point, in degrees clockwise from north, from its previous to its next point. */
-export function lineBearings(line: Coordinate[]): Float32Array {
+/**
+ * The travel direction at each point, in degrees clockwise from north: from its previous to its next
+ * point, or with `km` across the `stretch` km before and after it.
+ */
+export function lineBearings(line: Coordinate[], km?: ArrayLike, stretch = 0): Float32Array {
+ const at = (i: number) => km?.[i] ?? 0;
+ let back = 0, ahead = 0;
return Float32Array.from(line, (_, i) => {
- const [lon0, lat0] = line[Math.max(0, i - 1)], [lon1, lat1] = line[Math.min(line.length - 1, i + 1)];
+ // The nearest points at least `stretch` away, and never nearer than the neighbours.
+ while (back < i - 1 && at(i) - at(back + 1) >= stretch) back++;
+ while (ahead < line.length - 1 && (ahead <= i || at(ahead) - at(i) < stretch)) ahead++;
+ const [lon0, lat0] = line[back], [lon1, lat1] = line[ahead];
const east = (lon1 - lon0) * Math.cos((lat0 + lat1) * Math.PI / 360), north = lat1 - lat0;
return (Math.atan2(east, north) * 180 / Math.PI + 360) % 360;
});
diff --git a/builder/app/src/lib/planner/layers/climate-source.ts b/builder/app/src/lib/planner/layers/climate-source.ts
index 4b89c537c..fb192ef00 100644
--- a/builder/app/src/lib/planner/layers/climate-source.ts
+++ b/builder/app/src/lib/planner/layers/climate-source.ts
@@ -1,10 +1,17 @@
+import { SvelteMap } from 'svelte/reactivity';
import type { Coordinate } from '../map-types';
-import { OVERVIEW, cellAt, climateMeta, climateTile, locate, type ClimateMeta, type ClimateTile, type Level } from './climate';
+import { DETAIL, OVERVIEW, cellAt, climateMeta, climateTile, locate, type ClimateMeta, type ClimateTile, type Level } from './climate';
import { openTileArchive, type TileArchive } from './tile-archive';
export type TileGetter = (level: Level, x: number, y: number) => Promise;
-export interface ClimateSource { meta: ClimateMeta; bounds: [number, number, number, number]; tile: TileGetter }
+export interface ClimateSource {
+ meta: ClimateMeta;
+ bounds: [number, number, number, number];
+ tile: TileGetter;
+ /** The decoded tiles by `level/x/y`, null for an absent tile; reactive, so a reader reruns when a tile arrives. */
+ decoded: ReadonlyMap;
+}
/**
* Each tile is requested once and stays decoded: a region archive is a few megabytes, so the map,
@@ -12,6 +19,7 @@ export interface ClimateSource { meta: ClimateMeta; bounds: [number, number, num
*/
export function climateSource(archive: TileArchive): ClimateSource {
const tiles = new Map>();
+ const decoded = new SvelteMap();
const load = async (level: Level, x: number, y: number) => {
const body = await archive.get(level, x, y);
return body && climateTile(level, x, y, new Uint8Array(body));
@@ -19,13 +27,14 @@ export function climateSource(archive: TileArchive): ClimateSource {
return {
meta: climateMeta(archive.metadata),
bounds: archive.bounds,
+ decoded,
tile(level, x, y) {
const key = `${level}/${x}/${y}`;
let entry = tiles.get(key);
if (!entry) {
entry = load(level, x, y);
tiles.set(key, entry);
- entry.catch(() => tiles.delete(key));
+ entry.then(tile => decoded.set(key, tile ?? null), () => tiles.delete(key));
}
return entry;
},
@@ -48,10 +57,22 @@ export function openClimate(url: string): Promise {
/** A cell of a decoded tile. */
export interface CellRef { tile: ClimateTile; index: number }
+/**
+ * The detail cell of a coordinate for a point chart, read in reactive code: undefined while its tile
+ * loads, and the read starts the load of a missing tile. Null where the archive has no tile.
+ */
+export function detailCell(source: ClimateSource, coordinate: Coordinate): CellRef | null | undefined {
+ const { x, y, index } = locate(DETAIL, cellAt(coordinate));
+ const tile = source.decoded.get(`${DETAIL}/${x}/${y}`);
+ if (tile === undefined) source.tile(DETAIL, x, y).catch(() => {});
+ return tile && { tile, index };
+}
+
/**
* The cell of each coordinate on one level; undefined where the archive has no tile. Each tile the
* line touches is requested once. The overview level carries everything a route needs, and one
- * overview tile covers four detail tiles, so a route reads the overview and a point chart the detail.
+ * overview tile covers four detail tiles, so a route reads the overview and a point chart reads
+ * the detail of its one point with `detailCell`.
*/
export async function sampleLine(line: Coordinate[], tile: TileGetter, level: Level = OVERVIEW): Promise<(CellRef | undefined)[]> {
const spots = line.map(coordinate => locate(level, cellAt(coordinate)));
diff --git a/builder/app/src/lib/planner/layers/climate.test.ts b/builder/app/src/lib/planner/layers/climate.test.ts
index e9413b435..e7f3816e3 100644
--- a/builder/app/src/lib/planner/layers/climate.test.ts
+++ b/builder/app/src/lib/planner/layers/climate.test.ts
@@ -3,38 +3,10 @@ import type { Coordinate } from '../map-types';
import {
DETAIL, OVERVIEW, RAIN_DRIER, RAIN_TYPICAL, RAIN_UNKNOWN, RAIN_WETTER, SECTORS, WETTER_RATIO, cellAt, cellHistory, climateMeta,
climateTile, locate, rainClass, rainRatios, read, temperatureAt, weekMonth, weekValues, wetDaysOf7, windChance, windMode, windRose,
- type Level,
} from './climate';
import { lineBearings, weatherRows, windRow } from './climate-route';
-import { sampleLine, type TileGetter } from './climate-source';
-
-// The plane tables of specs/planner-climate-tiles.md: name, indexes, bytes per value.
-const SPEC: Record = {
- [OVERVIEW]: { cells: 384, planes: [['orography', 1, 2], ['lapse_tmax', 12, 1], ['lapse_tmin', 12, 1], ['rose', 192, 1], ['wet_share', 52, 1],
- ['rain', 52, 1], ['tmax', 52, 1], ['tmin', 52, 1], ['wind', 52, 1]] },
- [DETAIL]: { cells: 96, planes: [['orography', 1, 2], ['lapse_tmax', 12, 1], ['lapse_tmin', 12, 1], ['wet_days', 520, 1], ['rain', 520, 1],
- ['tmax', 520, 1], ['tmin', 520, 1], ['wind', 520, 1]] },
-};
-const SIGNED = new Set(['orography', 'lapse_tmax', 'lapse_tmin', 'tmax', 'tmin']);
-
-/** A tile body with every value missing, and a writer of raw codes. */
-function specTile(level: Level, x = 0, y = 0) {
- const { cells, planes } = SPEC[level];
- const starts = new Map();
- let size = 0;
- for (const [name, count, bytes] of planes) { starts.set(name, size); size += count * cells * bytes; }
- const body = new Uint8Array(size);
- const view = new DataView(body.buffer);
- const set = (name: string, index: number, cell: number, code: number) => {
- const [, , bytes] = planes.find(p => p[0] === name)!;
- const at = starts.get(name)! + (index * cells + cell) * bytes;
- if (bytes === 2) view.setInt16(at, code, true);
- else if (SIGNED.has(name)) view.setInt8(at, code);
- else view.setUint8(at, code);
- };
- for (const [name, count] of planes) for (let i = 0; i < count; i++) for (let c = 0; c < cells; c++) set(name, i, c, name === 'orography' ? -32768 : SIGNED.has(name) ? -128 : 255);
- return { body, set, tile: () => climateTile(level, x, y, body) };
-}
+import { climateSource, detailCell, sampleLine, type TileGetter } from './climate-source';
+import { specTile } from '../../../../test-support/planner/climate-tiles';
describe('climate tiles', () => {
it('has the body sizes of the spec', () => {
@@ -245,3 +217,22 @@ describe('climate along a route', () => {
expect(Math.round(lineBearings([[10, 60], [9, 60]])[0])).toBe(270);
});
});
+
+describe('detail tiles for a point chart', () => {
+ it('loads the one tile of a point on its first read and reads it once it has arrived', async () => {
+ const body = specTile(DETAIL, 156, 52).body;
+ const requests: string[] = [];
+ const source = climateSource({
+ metadata: { first_year: 2016, years: 10, wet_day_mm: 2.3 }, minZoom: 8, maxZoom: 9, bounds: [7.45, 47.5, 10.5, 49.85],
+ async get(z, x, y) { requests.push(`${z}/${x}/${y}`); return x === 156 ? body.slice().buffer : undefined; },
+ });
+ expect(detailCell(source, [7.86, 47.99])).toBeUndefined();
+ expect(detailCell(source, [7.86, 47.99])).toBeUndefined();
+ await source.tile(DETAIL, 156, 52);
+ expect(detailCell(source, [7.86, 47.99])?.index).toBe(4 * 12 + 7);
+ detailCell(source, [10.4, 47.99]);
+ await source.tile(DETAIL, 158, 52);
+ expect(detailCell(source, [10.4, 47.99])).toBeNull();
+ expect(requests).toEqual(['9/156/52', '9/158/52']);
+ });
+});
diff --git a/builder/app/src/lib/planner/layers/climate.ts b/builder/app/src/lib/planner/layers/climate.ts
index be832b548..c01ab64a2 100644
--- a/builder/app/src/lib/planner/layers/climate.ts
+++ b/builder/app/src/lib/planner/layers/climate.ts
@@ -88,6 +88,12 @@ export function locate(level: Level, { col, row }: Cell): { x: number; y: number
return { x: Math.floor(col / cols), y: Math.floor(row / rows), index: (row % rows) * cols + (col % cols) };
}
+/** The global cell of a tile cell; the inverse of `locate`. */
+export function cellOf({ level, x, y }: ClimateTile, index: number): Cell {
+ const { cols, rows } = LAYOUTS[level];
+ return { col: x * cols + (index % cols), row: y * rows + Math.floor(index / cols) };
+}
+
/** The month (0 = January) of the middle day of a week. */
export function weekMonth(week: number): number {
return new Date(Date.UTC(2001, 0, 1 + 7 * week + 3)).getUTCMonth();
diff --git a/builder/app/src/lib/planner/layers/data-layer.ts b/builder/app/src/lib/planner/layers/data-layer.ts
index 9f0cee89b..5a297ddd1 100644
--- a/builder/app/src/lib/planner/layers/data-layer.ts
+++ b/builder/app/src/lib/planner/layers/data-layer.ts
@@ -13,8 +13,11 @@ export interface Swatch {
hatch?: boolean;
}
-/** Discrete classes, or a continuous scale through evenly spaced stops. */
-export type Legend = { swatches: Swatch[] } | { scale: { color: string; label: string }[] };
+/** A map symbol in the legend: a stroke path in a 24 px box. */
+export interface Mark { label: string; path: string; width: number }
+
+/** Discrete classes, or a continuous scale through evenly spaced stops; `marks` explain map symbols. */
+export type Legend = ({ swatches: Swatch[] } | { scale: { color: string; label: string }[] }) & { marks?: Mark[] };
/** Rows of cells over the calendar year in `columns` steps: 183 steps of two days, or 52 weeks. */
export interface Grid {
diff --git a/builder/app/src/lib/planner/layers/registry.ts b/builder/app/src/lib/planner/layers/registry.ts
index 07fb4b14c..68d1e0b99 100644
--- a/builder/app/src/lib/planner/layers/registry.ts
+++ b/builder/app/src/lib/planner/layers/registry.ts
@@ -2,9 +2,11 @@ import { DATA_URLS } from '../map-data';
import { availableLayers } from './data-layer';
import { snowLayer } from './snow-layer.svelte';
import { weatherLayer } from './weather-layer.svelte';
+import { windLayer } from './wind-layer.svelte';
/** The data layers of the layer menu, in menu order. */
export const dataLayers = availableLayers([
{ archive: 'snow', create: snowLayer },
{ archive: 'climate', create: weatherLayer },
+ { archive: 'climate', create: windLayer },
], 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
index 3db72834e..23da1e528 100644
--- a/builder/app/src/lib/planner/layers/weather-layer.svelte.ts
+++ b/builder/app/src/lib/planner/layers/weather-layer.svelte.ts
@@ -3,8 +3,8 @@ 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 { OVERVIEW, locate, type ClimateMeta } from './climate';
+import { detailCell, 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 {
@@ -157,14 +157,15 @@ class WeatherLayer implements DataLayer {
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 route reads the overview; a chart or a night label reads the detail of its one sample.
+ const overview = await sampleLine(coordinates, source.tile, OVERVIEW);
// 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 };
+ return { overview, detail: i => detailCell(source, coordinates[i]), elevation: Float32Array.from(heights), km };
}
strip = {
diff --git a/builder/app/src/lib/planner/layers/weather.test.ts b/builder/app/src/lib/planner/layers/weather.test.ts
index ac55062ed..4aa35861f 100644
--- a/builder/app/src/lib/planner/layers/weather.test.ts
+++ b/builder/app/src/lib/planner/layers/weather.test.ts
@@ -102,7 +102,7 @@ describe('weather along a route', () => {
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] };
+ return { overview: [{ tile: o, index: 0 }, { tile: o, index: 1 }], detail: i => i ? null : { tile: d, index: 0 }, elevation: Float32Array.from(elevation), km: [0, 10] };
};
it('has a High, a Low and a Rain row and names the week in its label', () => {
@@ -130,7 +130,7 @@ describe('weather along a route', () => {
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 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([]);
diff --git a/builder/app/src/lib/planner/layers/weather.ts b/builder/app/src/lib/planner/layers/weather.ts
index f6b7e33a7..7bc4d2881 100644
--- a/builder/app/src/lib/planner/layers/weather.ts
+++ b/builder/app/src/lib/planner/layers/weather.ts
@@ -170,10 +170,11 @@ export function paintWeather(words: Uint32Array, cells: { cols: Float64Array; ro
}
}
-/** A route or a point: the overview and detail cell of each sample, its height, and the route kilometres. */
+/** A route or a point: the overview cell of each sample, its height, and the route kilometres. */
export interface Samples {
overview: (CellRef | undefined)[];
- detail: (CellRef | undefined)[];
+ /** The detail cell of a sample, loaded on the first read (`detailCell`): undefined while it loads, null without data. */
+ detail: (i: number) => CellRef | null | undefined;
/** Metres; NaN where unknown. */
elevation: Float32Array;
km: ArrayLike;
@@ -228,7 +229,8 @@ export function rainWeeks(ref: CellRef, firstYear: number): { mean: Float32Array
/** 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];
+ const ref = samples.detail(i), height = samples.elevation[i];
+ if (ref === undefined) return { headline: 'Loading the years at this point…', grids: [] };
if (!ref) return { headline: 'No weather data here', grids: [] };
const week = weekOf(date);
if (variable === 'rain') {
@@ -265,7 +267,7 @@ export function weatherChart(samples: Samples, i: number, firstYear: number, dat
/** "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);
+ 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/src/lib/planner/layers/wind-layer.svelte.ts b/builder/app/src/lib/planner/layers/wind-layer.svelte.ts
new file mode 100644
index 000000000..d2f39c745
--- /dev/null
+++ b/builder/app/src/lib/planner/layers/wind-layer.svelte.ts
@@ -0,0 +1,196 @@
+import type { GeoJSONSource, Map } from 'maplibre-gl';
+import type { FeatureCollection } from 'geojson';
+import WindRose from '../../../components/planner/WindRose.svelte';
+import { cumulative } from '../editor';
+import { OVERVIEW, weekMonth, type ClimateMeta, type ClimateTile } from './climate';
+import { lineBearings, windRow } from './climate-route';
+import { detailCell, openClimate, sampleLine, type CellRef, type ClimateSource } from './climate-source';
+import { weekOf, type DataLayer, type Line, type Theme, type View } from './data-layer';
+import { ARROW_ICONS, WIND_NOTE, arrowStride, headClasses, headFills, mapLegend, speedPaint, viewTiles, windChart, windMap, windYear } from './wind';
+
+const CELLS = 'wind', ARROWS = 'wind-arrows';
+const EMPTY: FeatureCollection = { type: 'FeatureCollection', features: [] };
+/** The travel direction of a sample spans this many km each way, so hairpins do not break the strip into specks. */
+const STRETCH_KM = 0.5;
+
+const ink = { light: { fill: '#ffffff', halo: '#2b2a22' }, dark: { fill: '#f2efe3', halo: '#14130e' } };
+const RATIO = 2, SIZE = 28;
+
+/**
+ * A slim north-pointing arrow, white on a thin dark halo: the shaft tapers from a swept head to the
+ * tail, or to the middle of a double arrow. `weight` 0–2 widens it.
+ */
+function arrowImage(icon: string, theme: Theme): ImageData {
+ const [, kind, weight] = icon.split('-'), w = Number(weight);
+ const canvas = document.createElement('canvas');
+ canvas.width = canvas.height = SIZE * RATIO;
+ const context = canvas.getContext('2d')!;
+ context.scale(RATIO, RATIO);
+ context.lineJoin = 'round';
+ const mid = SIZE / 2, top = 2.5, bottom = SIZE - 2.5, half = 3.4 + 0.8 * w, head = 7 + 0.5 * w, neck = 0.9 + 0.5 * w, tail = 0.35 + 0.25 * w;
+ // The right half from the top tip down; the left half mirrors it.
+ const right: [number, number][] = kind === 'double'
+ ? [[half, top + head], [neck, top + head - 1.5], [tail, mid], [neck, bottom - head + 1.5], [half, bottom - head], [0, bottom]]
+ : [[half, top + head], [neck, top + head - 1.5], [tail, bottom]];
+ const shape = new Path2D();
+ shape.moveTo(mid, top);
+ for (const [x, y] of right) shape.lineTo(mid + x, y);
+ for (const [x, y] of [...right].reverse()) shape.lineTo(mid - x, y);
+ shape.closePath();
+ context.strokeStyle = ink[theme].halo;
+ context.lineWidth = 2.4;
+ context.stroke(shape);
+ context.fillStyle = ink[theme].fill;
+ context.fill(shape);
+ return context.getImageData(0, 0, canvas.width, canvas.height);
+}
+
+/** The overview cell of each coordinate, the travel bearings and the headwind row of a route; a map point has neither. */
+interface Samples {
+ overview: (CellRef | undefined)[];
+ /** The detail cell of a sample, loaded on the first read (`detailCell`). */
+ detail: (i: number) => CellRef | null | undefined;
+ bearings: Float32Array | null;
+ row: Float32Array | null;
+}
+
+class WindLayer implements DataLayer {
+ id = 'wind';
+ label = 'Wind';
+ icon = 'wind';
+ description = 'Where the daytime wind blows most often, and how strong it is.';
+ caveat = `${WIND_NOTE}.`;
+ meta = $state(null);
+ error = $state('');
+ private map?: Map;
+ private listening?: Map;
+ private shown = false;
+ private date = '';
+ private theme: Theme = 'light';
+ /** The tiles, week and arrow stride of the drawn data. */
+ private drawn = '';
+ private runs = 0;
+ private frame = 0;
+
+ constructor(private url: string) {}
+
+ get source() {
+ const meta = this.meta;
+ return meta ? `${meta.attribution} · ${meta.firstYear}–${meta.firstYear + meta.years - 1}` : '';
+ }
+
+ legend(theme: Theme) {
+ return mapLegend(theme);
+ }
+
+ private open(): Promise {
+ return openClimate(this.url).then(climate => {
+ this.meta = climate.meta;
+ this.error = '';
+ return climate;
+ }, error => {
+ this.error = 'Wind data could not load for this region.';
+ throw error;
+ });
+ }
+
+ sync(map: Map, { shown, date, theme }: View & { shown: boolean }) {
+ this.map = map;
+ this.shown = shown;
+ this.date = date;
+ this.theme = theme;
+ if (map.getLayer(CELLS)) {
+ for (const id of [CELLS, ARROWS]) map.setLayoutProperty(id, 'visibility', shown ? 'visible' : 'none');
+ this.schedule();
+ return;
+ }
+ if (!shown) return;
+ void this.open().then(() => {
+ // A theme change during the request installs into the new style instead.
+ if (this.map !== map || this.theme !== theme || map.getSource(CELLS)) return;
+ this.install(map, theme);
+ this.schedule();
+ }, () => {});
+ }
+
+ private install(map: Map, theme: Theme) {
+ for (const icon of ARROW_ICONS) {
+ if (!map.hasImage(`${icon}-${theme}`)) map.addImage(`${icon}-${theme}`, arrowImage(icon, theme), { pixelRatio: RATIO });
+ }
+ const visibility = this.shown ? 'visible' : 'none';
+ map.addSource(CELLS, { type: 'geojson', data: EMPTY, attribution: this.source });
+ map.addSource(ARROWS, { type: 'geojson', data: EMPTY });
+ // Under the relief, so the hillshade shades the colours; without antialiasing, neighbour cells have no seams.
+ map.addLayer({ id: CELLS, type: 'fill', source: CELLS, layout: { visibility }, paint: { 'fill-color': speedPaint(theme), 'fill-antialias': false } }, map.getLayer('relief') ? 'relief' : undefined);
+ // Over roads and under labels.
+ map.addLayer({
+ id: ARROWS, type: 'symbol', source: ARROWS,
+ layout: {
+ visibility, 'icon-image': ['concat', ['get', 'icon'], `-${theme}`], 'icon-rotate': ['get', 'rotate'], 'icon-rotation-alignment': 'map',
+ 'icon-allow-overlap': true, 'icon-ignore-placement': true, 'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.8, 9, 1, 12, 1.2],
+ },
+ }, map.getStyle().layers.find(layer => layer.type === 'symbol')?.id);
+ this.drawn = '';
+ if (this.listening !== map) {
+ this.listening = map;
+ map.on('moveend', this.schedule);
+ }
+ }
+
+ /** One redraw per frame while the date is dragged or the map moves. */
+ private schedule = () => {
+ if (!this.shown) return;
+ cancelAnimationFrame(this.frame);
+ this.frame = requestAnimationFrame(() => void this.draw());
+ };
+
+ /** Builds the cells and arrows of the overview tiles in view; the data changes with the week, the month and the stride. */
+ private async draw() {
+ const map = this.map, run = ++this.runs;
+ if (!map?.getSource(CELLS)) return;
+ try {
+ const climate = await this.open();
+ const view = map.getBounds(), keys = viewTiles([view.getWest(), view.getSouth(), view.getEast(), view.getNorth()], climate.bounds);
+ const week = weekOf(this.date), stride = arrowStride(map.getZoom());
+ const key = `${keys.join(' ')} ${week} ${stride}`;
+ if (key === this.drawn) return;
+ const tiles = (await Promise.all(keys.map(([x, y]) => climate.tile(OVERVIEW, x, y)))).filter((tile): tile is ClimateTile => !!tile);
+ if (run !== this.runs || map !== this.map || !map.getSource(CELLS)) return;
+ this.drawn = key;
+ const { cells, arrows } = windMap(tiles, week, weekMonth(week), stride);
+ (map.getSource(CELLS) as GeoJSONSource).setData(cells);
+ (map.getSource(ARROWS) as GeoJSONSource).setData(arrows);
+ } catch {
+ // The view stays undrawn, so the next move or date change retries.
+ this.error = 'Wind data could not load for this region.';
+ }
+ }
+
+ async sample({ coordinates }: Line, signal: AbortSignal): Promise {
+ const climate = await this.open();
+ const overview = await sampleLine(coordinates, climate.tile);
+ signal.throwIfAborted();
+ const detail = (i: number) => detailCell(climate, coordinates[i]);
+ if (coordinates.length < 2) return { overview, detail, bearings: null, row: null };
+ const km = cumulative(coordinates), bearings = lineBearings(coordinates, km, STRETCH_KM);
+ return { overview, detail, bearings, row: windRow(overview, km, bearings) };
+ }
+
+ strip = {
+ legend: (theme: Theme) => ({ swatches: headFills(theme) }),
+ fills: headFills,
+ values: ({ overview, bearings }: Samples, date: string) => headClasses(overview, bearings ?? [], weekMonth(weekOf(date))),
+ };
+
+ /** The weekly grid appears when the detail tile of the point arrives; the headline and the rose read the overview. */
+ chart({ overview, detail, bearings }: Samples, i: number, { date, theme }: View) {
+ const { chart, rose } = windChart({ overview: overview[i], detail: detail(i) ?? undefined, bearing: bearings?.[i] }, this.meta?.firstYear ?? 0, date, theme);
+ return rose ? { ...chart, extra: { component: WindRose, props: { ...rose, theme } } } : chart;
+ }
+
+ year(samples: Samples | null, { date, theme }: View) {
+ return windYear(samples?.row ?? null, date, theme);
+ }
+}
+
+export const windLayer = (url: string): DataLayer => new WindLayer(url);
diff --git a/builder/app/src/lib/planner/layers/wind.test.ts b/builder/app/src/lib/planner/layers/wind.test.ts
new file mode 100644
index 000000000..43ef43ee5
--- /dev/null
+++ b/builder/app/src/lib/planner/layers/wind.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from 'vitest';
+import { specTile } from '../../../../test-support/planner/climate-tiles';
+import { DETAIL, OVERVIEW, SECTORS } from './climate';
+import { lineBearings } from './climate-route';
+import { HEADWIND_EDGES, NO_WIND, arrowStride, headClass, headClasses, viewTiles, windChart, windMap, windYear } from './wind';
+
+// Overview tile 78/26 holds Freiburg at cell 4 × 24 + 7, column 1879 and row 420.
+const FREIBURG = 4 * 24 + 7, EAST = FREIBURG + 1, SOUTH = FREIBURG + 24;
+const APRIL = 3, WEEK = 15;
+
+/** Rose codes by the sector the wind comes from, in 0.5 % steps; the rest of 200 spreads evenly. */
+function setRose(t: ReturnType, cell: number, from: Record) {
+ const rest = (200 - Object.values(from).reduce((a, b) => a + b, 0)) / (SECTORS - Object.keys(from).length);
+ for (let month = 0; month < 12; month++) for (let s = 0; s < SECTORS; s++) t.set('rose', SECTORS * month + s, cell, from[s] ?? rest);
+}
+
+function overview() {
+ const t = specTile(OVERVIEW, 78, 26);
+ for (const [cell, code] of [[FREIBURG, 5], [EAST, 4], [SOUTH, 5]]) for (let week = 0; week < 52; week++) t.set('wind', week, cell, code); // 2.5 and 2 m/s
+ setRose(t, FREIBURG, { 8: 170 }); // from the south: blows towards the north, steady
+ setRose(t, EAST, { 4: 70, 12: 60 }); // from the east and the west: two opposite winds
+ t.set('wind', WEEK, SOUTH, 255);
+ return t.tile();
+}
+
+describe('wind map', () => {
+ it('colours the cells from 2.5 m/s and points an arrow where the wind blows towards', () => {
+ const { cells, arrows } = windMap([overview()], WEEK, APRIL, 1);
+ expect(cells.features.map(f => f.properties.speed)).toEqual([2.5]);
+ // Neighbour cells share their edge exactly, so the fill has no seams.
+ const [north, south] = windMap([overview()], 0, APRIL, 1).cells.features.map(f => f.geometry.coordinates[0]);
+ expect(north[0][1]).toBe(south[2][1]);
+ expect(north[0]).toEqual([-180 + 0.1 * 1878.5, 90 - 0.1 * 420.5]);
+ expect(arrows.features.map(f => f.properties)).toEqual([{ icon: 'wind-arrow-2', rotate: 0 }, { icon: 'wind-double-1', rotate: 270 }]);
+ const [lon, lat] = arrows.features[0].geometry.coordinates;
+ expect([lon, lat].map(v => v.toFixed(6))).toEqual(['7.900000', '48.000000']);
+ });
+
+ it('keeps the arrows of every second cell when the cells are narrow on screen', () => {
+ expect([arrowStride(9), arrowStride(8.3), arrowStride(8), arrowStride(6)]).toEqual([1, 1, 2, 8]);
+ // Column 1879 is odd: the stride drops Freiburg and keeps its east neighbour.
+ expect(windMap([overview()], WEEK, APRIL, 2).arrows.features.map(f => f.properties.icon)).toEqual(['wind-double-1']);
+ });
+
+ it('requests the overview tiles of the view inside the archive', () => {
+ const bounds = [7.45, 47.5, 10.5, 49.85];
+ expect(viewTiles([7.7, 47.9, 8.1, 48.1], bounds)).toEqual([[78, 26]]);
+ expect(viewTiles([6, 47, 9.6, 48.5], bounds)).toEqual([[78, 25], [79, 25], [78, 26], [79, 26]]);
+ expect(viewTiles([11, 47, 12, 48], bounds)).toEqual([]);
+ });
+});
+
+describe('wind along a route', () => {
+ const cells = () => {
+ const tile = overview();
+ return [{ tile, index: FREIBURG }, { tile, index: FREIBURG }, undefined];
+ };
+
+ it('shades each sample by the chance of headwind for its travel direction', () => {
+ // Towards the north at 85 % plus an even rest: riding south meets it head on.
+ expect([...headClasses(cells(), [180, 0, 0], APRIL)]).toEqual([3, 0, NO_WIND]);
+ expect([0.1, 0.15, 0.3, 0.45, NaN].map(headClass)).toEqual([0, 1, 2, HEADWIND_EDGES.length, NO_WIND]);
+ });
+
+ it('takes the travel direction across a stretch, so a hairpin keeps the main direction', () => {
+ // Northbound with a 50 m kink to the east and back.
+ const line: [number, number][] = [[8, 48], [8, 48.002], [8.0007, 48.002], [8, 48.002], [8, 48.004]];
+ const km = [0, 0.22, 0.27, 0.32, 0.54];
+ expect(Math.round(lineBearings(line)[3])).toBe(347);
+ expect([...lineBearings(line, km, 0.2)].map(Math.round)).toEqual([0, 0, 0, 0, 0]);
+ });
+
+ it('shows the headwind chance of each month on the year slider', () => {
+ const row = Float32Array.from({ length: 12 }, (_, month) => month / 12);
+ const year = windYear(row, '2026-04-16', 'light');
+ expect(year.label).toBe('Chance of headwind on the route in April: 25 %');
+ expect(year.rows[0].cells[0]).toBe(0);
+ expect(year.rows[0].cells[WEEK]).toBe(1);
+ expect(year.rows[0].cells[51]).toBe(3);
+ const none = windYear(null, '2026-04-16', 'light');
+ expect(none.label).toBe('Plan a route to see the chance of headwind along it.');
+ expect(none.rows[0].cells.every(cell => cell === 255)).toBe(true);
+ });
+});
+
+describe('wind at a point', () => {
+ const detail = () => {
+ const t = specTile(DETAIL, 156, 52);
+ for (let i = 0; i < 520; i++) t.set('wind', i, 4 * 12 + 7, i % 52 === WEEK ? 9 : 4);
+ t.set('wind', 52 * 9 + WEEK, 4 * 12 + 7, 255);
+ return { tile: t.tile(), index: 4 * 12 + 7 };
+ };
+
+ it('gives each week of ten years as its daytime mean speed, and the rose of the month', () => {
+ const { chart, rose } = windChart({ overview: { tile: overview(), index: FREIBURG }, detail: detail() }, 2016, '2026-04-16', 'light');
+ expect(chart.headline).toBe('In April the wind most often blows towards N');
+ const grid = chart.grids[0];
+ expect(grid.label).toBe('Daytime wind around 16 Apr: 2.5 m/s on average');
+ expect(grid.rows.map(row => row.label)).toEqual(Array.from({ length: 10 }, (_, year) => String(2016 + year)));
+ // Codes count 0.5 m/s: 4.5 m/s is code 9, 2 m/s is code 4, a missing week is empty.
+ expect([grid.rows[0].cells[WEEK], grid.rows[0].cells[0], grid.rows[9].cells[WEEK]]).toEqual([9, 4, 255]);
+ expect(rose?.month).toBe('April');
+ expect(rose?.shares[0]).toBeCloseTo(0.85);
+ });
+
+ it('gives the headwind and tailwind chances for a travel direction', () => {
+ const { chart, rose } = windChart({ overview: { tile: overview(), index: FREIBURG }, bearing: 180 }, 2016, '2026-04-16', 'light');
+ expect(chart.headline).toBe('Headwind in 88 % of April daytime hours, tailwind in 4 %');
+ expect(chart.grids).toEqual([]);
+ expect(rose?.bearing).toBe(180);
+ expect(windChart({}, 2016, '2026-04-16', 'light')).toEqual({ chart: { headline: 'No wind data here', grids: [], note: '~9 km grid, daytime wind (09–18 h)' } });
+ });
+});
diff --git a/builder/app/src/lib/planner/layers/wind.ts b/builder/app/src/lib/planner/layers/wind.ts
new file mode 100644
index 000000000..f51728780
--- /dev/null
+++ b/builder/app/src/lib/planner/layers/wind.ts
@@ -0,0 +1,209 @@
+// The Wind layer from the climate archive: strength colours, the arrows of the map, the headwind
+// chance along a route and the years at a point. Everywhere, a direction is where the wind blows towards.
+import type { ExpressionSpecification } from 'maplibre-gl';
+import type { FeatureCollection, Point, Polygon } from 'geojson';
+import { OVERVIEW, WEEKS, YEARS, cellAt, cellCentre, cellOf, locate, read, tileCells, weekMonth, windChance, windMode, windRose, type ClimateTile } from './climate';
+import type { CellRef } from './climate-source';
+import { dateLabel, weekOf, type Chart, type Grid, type Legend, type Swatch, type Theme } from './data-layer';
+
+/**
+ * The strength colours at 1 to 5 m/s; slower winds take the first colour and faster winds the last.
+ * The ten-year weekly means of Baden-Württemberg span 1 to 4 m/s, so the steps there stay apart.
+ */
+const SPEEDS = [1, 2, 3, 4, 5];
+const RAMP = {
+ light: ['#eef0f3', '#c9d1de', '#97a3bb', '#66738f', '#3f4964'],
+ dark: ['#262a32', '#38404f', '#56607a', '#8590ac', '#c3cbe0'],
+};
+
+function mix(a: string, b: string, t: number): string {
+ const channel = (hex: string, i: number) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
+ return '#' + [0, 1, 2].map(i => Math.round(channel(a, i) + t * (channel(b, i) - channel(a, i))).toString(16).padStart(2, '0')).join('');
+}
+
+export function speedColor(speed: number, theme: Theme): string {
+ const ramp = RAMP[theme], at = Math.min(SPEEDS.length - 1, Math.max(0, speed - SPEEDS[0])), i = Math.min(SPEEDS.length - 2, Math.floor(at));
+ return mix(ramp[i], ramp[i + 1], at - i);
+}
+
+/** The map colour of a cell from its `speed` property. */
+export function speedPaint(theme: Theme): ExpressionSpecification {
+ return ['interpolate', ['linear'], ['get', 'speed'], ...SPEEDS.flatMap((speed, i) => [speed, RAMP[theme][i]])] as ExpressionSpecification;
+}
+
+export function speedScale(theme: Theme): Legend {
+ return { scale: SPEEDS.map((speed, i) => ({ color: RAMP[theme][i], label: i === SPEEDS.length - 1 ? `${speed}+ m/s` : String(speed) })) };
+}
+
+/** The map paints a cell from this mean speed (owner decision): calm weeks keep the map unpainted, with arrows only. */
+export const PAINT_FROM = 2.5;
+
+/** The map legend: the painted part of the scale in 0.5 m/s steps, the unpainted calm cells and the arrow key. */
+export function mapLegend(theme: Theme): Legend {
+ const top = SPEEDS.at(-1)!, steps = 2 * (top - PAINT_FROM);
+ return {
+ scale: Array.from({ length: steps + 1 }, (_, i) => {
+ const speed = PAINT_FROM + i / 2;
+ return { color: speedColor(speed, theme), label: i === steps ? `${speed}+ m/s` : i % 2 ? '' : String(speed) };
+ }),
+ marks: [{ label: `No colour: under ${PAINT_FROM} m/s`, path: 'M5 8h14v8H5Z', width: 1 }, ...ARROW_MARKS],
+ };
+}
+
+/** Chart codes per 0.5 m/s, the step of the archive, from 0 to 5 m/s and faster. */
+const FASTEST = 10;
+const speedCode = (speed: number) => Number.isNaN(speed) ? 255 : Math.min(FASTEST, Math.round(2 * speed));
+const speedFills = (theme: Theme): Swatch[] => Array.from({ length: FASTEST + 1 }, (_, code) => ({ label: `${code / 2} m/s`, color: speedColor(code / 2, theme) }));
+
+/** Arrows shown at most this close, in screen pixels, so they never clutter at low zoom. */
+const ARROW_GAP = 44;
+
+/** Arrows sit on every `stride`th cell column and row: a cell is 0.1° of longitude, 512 × 2^zoom ÷ 3600 px wide. */
+export function arrowStride(zoom: number): number {
+ return 2 ** Math.max(0, Math.ceil(Math.log2(ARROW_GAP * 3600 / (512 * 2 ** zoom))));
+}
+
+/**
+ * Arrow weights by the steadiness of `windMode`. Chosen on the Baden-Württemberg archive: a third of
+ * the cell-months are thin, below 0.37, and a quarter are thick, from 0.44.
+ */
+export const STEADY = [0.37, 0.44];
+export const ARROW_ICONS = ['arrow', 'double'].flatMap(kind => [0, 1, 2].map(weight => `wind-${kind}-${weight}`));
+
+export function arrowIcon({ steadiness, opposite }: { steadiness: number; opposite?: number }): string {
+ return `wind-${opposite === undefined ? 'arrow' : 'double'}-${STEADY.filter(edge => steadiness >= edge).length}`;
+}
+
+/** The arrow key of the map legend, drawn in a 24 px box. */
+const ARROW_MARKS = [
+ { label: 'Most often blows towards', path: 'M4 12h15m-4-4 4 4-4 4', width: 1.5 },
+ { label: 'Steady', path: 'M4 12h14m-4-4 4 4-4 4', width: 3 },
+ { label: 'Two opposite winds', path: 'M5 12h14M9 8l-4 4 4 4m6-8 4 4-4 4', width: 2 },
+];
+
+/** The overview tiles in view: the tiles that hold the cells of the corners of view ∩ archive. */
+export function viewTiles([west, south, east, north]: number[], bounds: number[]): [number, number][] {
+ const w = Math.max(west, bounds[0]), s = Math.max(south, bounds[1]), e = Math.min(east, bounds[2]), n = Math.min(north, bounds[3]);
+ if (w > e || s > n) return [];
+ const from = locate(OVERVIEW, cellAt([w, n])), to = locate(OVERVIEW, cellAt([e, s]));
+ const tiles: [number, number][] = [];
+ for (let y = from.y; y <= to.y; y++) for (let x = from.x; x <= to.x; x++) tiles.push([x, y]);
+ return tiles;
+}
+
+/** Cell edges from the edge index, so neighbours share exact corners and the fill has no seams. */
+const edgeLon = (col: number) => -180 + 0.1 * (col - 0.5), edgeLat = (row: number) => 90 - 0.1 * (row - 0.5);
+
+export interface WindMap {
+ cells: FeatureCollection;
+ arrows: FeatureCollection;
+}
+
+/** The cells of the tiles from `PAINT_FROM` with their mean speed of `week`, and an arrow of `month` on every `stride`th cell with data. */
+export function windMap(tiles: ClimateTile[], week: number, month: number, stride: number): WindMap {
+ const cells: WindMap['cells']['features'] = [], arrows: WindMap['arrows']['features'] = [];
+ for (const tile of tiles) {
+ for (let index = 0; index < tileCells(OVERVIEW); index++) {
+ const speed = read(tile, 'wind', week, index);
+ if (Number.isNaN(speed)) continue;
+ const cell = cellOf(tile, index);
+ const [w, e, n, s] = [edgeLon(cell.col), edgeLon(cell.col + 1), edgeLat(cell.row), edgeLat(cell.row + 1)];
+ if (speed >= PAINT_FROM) cells.push({ type: 'Feature', properties: { speed }, geometry: { type: 'Polygon', coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]] } });
+ if (cell.col % stride || cell.row % stride) continue;
+ const mode = windMode(windRose(tile, index, month));
+ if (mode) arrows.push({ type: 'Feature', properties: { icon: arrowIcon(mode), rotate: 22.5 * mode.towards }, geometry: { type: 'Point', coordinates: cellCentre(cell) } });
+ }
+ }
+ return { cells: { type: 'FeatureCollection', features: cells }, arrows: { type: 'FeatureCollection', features: arrows } };
+}
+
+/**
+ * Classes of the headwind chance. Chosen on the Baden-Württemberg archive: an even rose gives 25 %;
+ * over the cell-months and the main bearings, the median is 26 % and 95 % stay below 49 %.
+ */
+export const HEADWIND_EDGES = [0.15, 0.3, 0.45];
+export const NO_WIND = HEADWIND_EDGES.length + 1;
+
+export function headClass(chance: number): number {
+ return Number.isNaN(chance) ? NO_WIND : HEADWIND_EDGES.filter(edge => chance >= edge).length;
+}
+
+const HEAD = {
+ light: ['#e6e9ee', '#c3cad6', '#8792ab', '#414a66', '#b8b5ac'],
+ dark: ['#2c3040', '#4a5268', '#8a93ad', '#c3cbe0', '#6b685c'],
+};
+
+/** Swatches by headwind class; the last is no data. */
+export function headFills(theme: Theme): Swatch[] {
+ const c = HEAD[theme];
+ return [
+ { label: 'Headwind under 15 %', color: c[0] }, { label: '15–30 %', color: c[1] }, { label: '30–45 %', color: c[2] },
+ { label: '45 % or more', color: c[3] }, { label: 'No data', color: c[4], hatch: true },
+ ];
+}
+
+/** Rose petals: headwind and tailwind for a travel direction, the rest crosswind; `even` without a direction. */
+export function roseColors(theme: Theme) {
+ const c = HEAD[theme];
+ return { head: c[3], tail: c[1], cross: theme === 'dark' ? '#8a8670' : '#bdb7a0', even: c[2] };
+}
+
+/** The headwind class of each sample of a line for its travel bearing in `month`. */
+export function headClasses(cells: (CellRef | undefined)[], bearings: ArrayLike, month: number): Uint8Array {
+ let last: CellRef | undefined, rose: ArrayLike = [];
+ return Uint8Array.from(cells, (cell, i) => {
+ if (!cell) return NO_WIND;
+ if (cell.tile !== last?.tile || cell.index !== last.index) rose = windRose(cell.tile, cell.index, month);
+ last = cell;
+ return headClass(windChance(rose, bearings[i]).head);
+ });
+}
+
+const monthName = (month: number) => new Date(Date.UTC(2001, month, 15)).toLocaleDateString('en-GB', { month: 'long', timeZone: 'UTC' });
+const percent = (share: number) => `${Math.round(100 * share)} %`;
+const COMPASS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
+
+/** The year slider: the headwind chance of each month along the route, from `windRow`. */
+export function windYear(row: ArrayLike | null, date: string, theme: Theme): Grid {
+ const month = weekMonth(weekOf(date));
+ const label = !row ? 'Plan a route to see the chance of headwind along it.'
+ : Number.isNaN(row[month]) ? 'No wind data along the route' : `Chance of headwind on the route in ${monthName(month)}: ${percent(row[month])}`;
+ const cells = row ? Uint8Array.from({ length: WEEKS }, (_, week) => headClass(row[weekMonth(week)])) : new Uint8Array(WEEKS).fill(255);
+ return { label, columns: WEEKS, rows: [{ label: '', cells }], fills: headFills(theme) };
+}
+
+export const WIND_NOTE = '~9 km grid, daytime wind (09–18 h)';
+
+/** The rose beside a chart: the shares by towards-sector, and the travel bearing on a route. */
+export interface RoseView { shares: number[]; bearing?: number; month: string }
+
+/**
+ * The years at one point: each week of each year as its daytime mean speed, and the rose of the
+ * month. With a travel bearing, the headline gives the headwind and tailwind chances.
+ */
+export function windChart({ overview, detail, bearing }: { overview?: CellRef; detail?: CellRef; bearing?: number }, firstYear: number, date: string, theme: Theme): { chart: Chart; rose?: RoseView } {
+ const week = weekOf(date), month = weekMonth(week), name = monthName(month);
+ const shares = overview && windRose(overview.tile, overview.index, month);
+ const mode = shares && windMode(shares);
+ let headline = 'No wind data here';
+ if (mode && bearing !== undefined) {
+ const { head, tail } = windChance(shares, bearing);
+ headline = `Headwind in ${percent(head)} of ${name} daytime hours, tailwind in ${percent(tail)}`;
+ } else if (mode) {
+ headline = `In ${name} the wind most often blows towards ${COMPASS[mode.towards]}${mode.opposite === undefined ? '' : ` or ${COMPASS[mode.opposite]}`}`;
+ }
+ const mean = overview ? read(overview.tile, 'wind', week, overview.index) : NaN;
+ const rows = detail ? Array.from({ length: YEARS }, (_, year) => ({
+ label: String(firstYear + year),
+ cells: Uint8Array.from({ length: WEEKS }, (_, w) => speedCode(read(detail.tile, 'wind', WEEKS * year + w, detail.index))),
+ })) : [];
+ const chart: Chart = {
+ headline,
+ grids: rows.length ? [{
+ label: Number.isNaN(mean) ? 'Daytime wind by week' : `Daytime wind around ${dateLabel(date)}: ${mean.toFixed(1)} m/s on average`,
+ columns: WEEKS, rows, fills: speedFills(theme), legend: speedScale(theme),
+ }] : [],
+ note: WIND_NOTE,
+ };
+ return mode ? { chart, rose: { shares: Array.from(shares), bearing, month: name } } : { chart };
+}
diff --git a/builder/app/test-support/planner/climate-tiles.ts b/builder/app/test-support/planner/climate-tiles.ts
new file mode 100644
index 000000000..5d244609a
--- /dev/null
+++ b/builder/app/test-support/planner/climate-tiles.ts
@@ -0,0 +1,29 @@
+import { DETAIL, OVERVIEW, climateTile, type Level } from '../../src/lib/planner/layers/climate';
+
+// The plane tables of specs/planner-climate-tiles.md: name, indexes, bytes per value.
+const SPEC: Record = {
+ [OVERVIEW]: { cells: 384, planes: [['orography', 1, 2], ['lapse_tmax', 12, 1], ['lapse_tmin', 12, 1], ['rose', 192, 1], ['wet_share', 52, 1],
+ ['rain', 52, 1], ['tmax', 52, 1], ['tmin', 52, 1], ['wind', 52, 1]] },
+ [DETAIL]: { cells: 96, planes: [['orography', 1, 2], ['lapse_tmax', 12, 1], ['lapse_tmin', 12, 1], ['wet_days', 520, 1], ['rain', 520, 1],
+ ['tmax', 520, 1], ['tmin', 520, 1], ['wind', 520, 1]] },
+};
+const SIGNED = new Set(['orography', 'lapse_tmax', 'lapse_tmin', 'tmax', 'tmin']);
+
+/** A tile body with every value missing, and a writer of raw codes. */
+export function specTile(level: Level, x = 0, y = 0) {
+ const { cells, planes } = SPEC[level];
+ const starts = new Map();
+ let size = 0;
+ for (const [name, count, bytes] of planes) { starts.set(name, size); size += count * cells * bytes; }
+ const body = new Uint8Array(size);
+ const view = new DataView(body.buffer);
+ const set = (name: string, index: number, cell: number, code: number) => {
+ const [, , bytes] = planes.find(p => p[0] === name)!;
+ const at = starts.get(name)! + (index * cells + cell) * bytes;
+ if (bytes === 2) view.setInt16(at, code, true);
+ else if (SIGNED.has(name)) view.setInt8(at, code);
+ else view.setUint8(at, code);
+ };
+ for (const [name, count] of planes) for (let i = 0; i < count; i++) for (let c = 0; c < cells; c++) set(name, i, c, name === 'orography' ? -32768 : SIGNED.has(name) ? -128 : 255);
+ return { body, set, tile: () => climateTile(level, x, y, body) };
+}