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} {:else} @@ -17,7 +24,7 @@ stop.color).join(', ')})`}> - {#if children}{/if} + {#if children || legend.marks}{/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 @@ + + +
    + + + N + {#each petals as petal, s (s)}{/each} + {#if bearing !== undefined} + + + + + {/if} + +
    + {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) }; +}