From d38d7063acbc824357d790e6ce2820a59b4f5e36 Mon Sep 17 00:00:00 2001 From: timohueser Date: Sat, 3 Oct 2026 23:54:51 +0200 Subject: [PATCH 1/2] planner: add the Wind data layer The Wind layer reads the shared climate archive. The map colours each cell by the mean daytime speed of the week and draws one arrow per cell for the most frequent direction of the month: thicker when the wind is steady, double-headed for two opposite winds. Arrows thin out at low zoom. The year slider and the profile strip show the chance of headwind for the travel direction; the point chart shows ten years of weekly speeds and the wind rose. Co-Authored-By: Claude Opus 5.5 --- .../src/components/planner/LayerLegend.svelte | 10 +- .../src/components/planner/PlannerIcon.svelte | 1 + .../src/components/planner/WindRose.svelte | 62 ++++++ .../src/lib/planner/layers/climate-route.ts | 14 +- .../src/lib/planner/layers/climate-source.ts | 15 +- .../src/lib/planner/layers/climate.test.ts | 30 +-- builder/app/src/lib/planner/layers/climate.ts | 6 + .../app/src/lib/planner/layers/data-layer.ts | 7 +- .../app/src/lib/planner/layers/registry.ts | 2 + .../lib/planner/layers/wind-layer.svelte.ts | 188 +++++++++++++++++ .../app/src/lib/planner/layers/wind.test.ts | 113 ++++++++++ builder/app/src/lib/planner/layers/wind.ts | 194 ++++++++++++++++++ .../app/test-support/planner/climate-tiles.ts | 29 +++ 13 files changed, 635 insertions(+), 36 deletions(-) create mode 100644 builder/app/src/components/planner/WindRose.svelte create mode 100644 builder/app/src/lib/planner/layers/wind-layer.svelte.ts create mode 100644 builder/app/src/lib/planner/layers/wind.test.ts create mode 100644 builder/app/src/lib/planner/layers/wind.ts create mode 100644 builder/app/test-support/planner/climate-tiles.ts 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 24864ee0c..7865b45d1 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', }; 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 8c2962826..4b89c537c 100644 --- a/builder/app/src/lib/planner/layers/climate-source.ts +++ b/builder/app/src/lib/planner/layers/climate-source.ts @@ -1,6 +1,6 @@ import type { Coordinate } from '../map-types'; import { OVERVIEW, cellAt, climateMeta, climateTile, locate, type ClimateMeta, type ClimateTile, type Level } from './climate'; -import type { TileArchive } from './tile-archive'; +import { openTileArchive, type TileArchive } from './tile-archive'; export type TileGetter = (level: Level, x: number, y: number) => Promise; @@ -32,6 +32,19 @@ export function climateSource(archive: TileArchive): ClimateSource { }; } +const opened = new Map>(); + +/** One source per archive URL, so the climate layers share its tiles; a failed open is retried on the next call. */ +export function openClimate(url: string): Promise { + let entry = opened.get(url); + if (!entry) { + entry = openTileArchive(url, 'Climate').then(climateSource); + opened.set(url, entry); + entry.catch(() => opened.delete(url)); + } + return entry; +} + /** A cell of a decoded tile. */ export interface CellRef { tile: ClimateTile; index: number } diff --git a/builder/app/src/lib/planner/layers/climate.test.ts b/builder/app/src/lib/planner/layers/climate.test.ts index e9413b435..85e2776ba 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 { specTile } from '../../../../test-support/planner/climate-tiles'; describe('climate tiles', () => { it('has the body sizes of the spec', () => { 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 2afe67f28..b6e3ba6bf 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 6920e5895..bf3cf1aa9 100644 --- a/builder/app/src/lib/planner/layers/registry.ts +++ b/builder/app/src/lib/planner/layers/registry.ts @@ -1,8 +1,10 @@ import { DATA_URLS } from '../map-data'; import { availableLayers } from './data-layer'; import { snowLayer } from './snow-layer.svelte'; +import { 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: windLayer }, ], DATA_URLS); 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..bce00d8e6 --- /dev/null +++ b/builder/app/src/lib/planner/layers/wind-layer.svelte.ts @@ -0,0 +1,188 @@ +import type { GeoJSONSource, Map } from 'maplibre-gl'; +import type { FeatureCollection } from 'geojson'; +import WindRose from '../../../components/planner/WindRose.svelte'; +import { cumulative } from '../editor'; +import { DETAIL, OVERVIEW, weekMonth, type ClimateMeta, type ClimateTile } from './climate'; +import { lineBearings, windRow } from './climate-route'; +import { 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, ARROW_MARKS, WIND_NOTE, arrowStride, headClasses, headFills, speedPaint, speedScale, 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: { line: '#1c1b14', halo: '#ffffff' }, dark: { line: '#f2efe3', halo: '#201f17' } }; +const RATIO = 2, SIZE = 28; + +/** A north-pointing arrow on a halo; `weight` 0–2 sets the shaft, and a double arrow has a head at each end. */ +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.lineCap = context.lineJoin = 'round'; + const mid = SIZE / 2, tip = 2.5, half = 3.6 + 0.7 * w, length = 6.5 + w; + const heads = kind === 'double' ? [1, -1] : [1]; + const draw = (color: string, extra: number) => { + context.strokeStyle = context.fillStyle = color; + context.lineWidth = 1.3 + 1.1 * w + extra; + context.beginPath(); + context.moveTo(mid, SIZE - tip - (heads.length > 1 ? length - 1 : 0)); + context.lineTo(mid, tip + length - 1); + context.stroke(); + for (const end of heads) { + const y = end > 0 ? tip : SIZE - tip; + context.lineWidth = 1 + extra; + context.beginPath(); + context.moveTo(mid, y); + context.lineTo(mid - half, y + end * length); + context.lineTo(mid + half, y + end * length); + context.closePath(); + context.fill(); + context.stroke(); + } + }; + draw(ink[theme].halo, 3); + draw(ink[theme].line, 0); + return context.getImageData(0, 0, canvas.width, canvas.height); +} + +/** The cells of each coordinate on both levels, the travel bearings and the headwind row of a route; a map point has neither. */ +interface Samples { overview: (CellRef | undefined)[]; detail: (CellRef | 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 { ...speedScale(theme), marks: ARROW_MARKS }; + } + + 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; + 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); + } + + async sample({ coordinates }: Line, signal: AbortSignal): Promise { + const climate = await this.open(); + const [overview, detail] = await Promise.all([sampleLine(coordinates, climate.tile), sampleLine(coordinates, climate.tile, DETAIL)]); + signal.throwIfAborted(); + 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))), + }; + + chart({ overview, detail, bearings }: Samples, i: number, { date, theme }: View) { + const { chart, rose } = windChart({ overview: overview[i], detail: detail[i], 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..1ffa4c673 --- /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 of [FREIBURG, EAST, SOUTH]) for (let week = 0; week < 52; week++) t.set('wind', week, cell, 5); // 2.5 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 every cell with data 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, 2.5]); + // Neighbour cells share their edge exactly, so the fill has no seams. + const [west, east] = cells.features.map(f => f.geometry.coordinates[0]); + expect(west[1][0]).toBe(east[0][0]); + expect(west[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..18c23b391 --- /dev/null +++ b/builder/app/src/lib/planner/layers/wind.ts @@ -0,0 +1,194 @@ +// 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) })) }; +} + +/** 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. */ +export 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; +} + +/** Every cell of the tiles with its mean speed of `week`, and an arrow of `month` on every `stride`th cell. */ +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)]; + 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) }; +} From 5c597b1b176b66ca005685eb98d80e3021493e9d Mon Sep 17 00:00:00 2001 From: timohueser Date: Sun, 4 Oct 2026 00:07:44 +0200 Subject: [PATCH 2/2] planner: load wind detail tiles on demand, paint only windy cells A route now reads only overview tiles. The point chart reads the detail tile of its point through a reactive cache in the shared climate source, and its weekly grid appears when the tile arrives. A failed tile load while drawing the map sets the layer error and retries on the next move. The map paints a cell only from a mean of 2.5 m/s, so calm weeks show the arrows on the plain map. The arrows are slim and white on a thin dark halo. Co-Authored-By: Claude Opus 5.5 --- .../src/lib/planner/layers/climate-source.ts | 29 +++++- .../src/lib/planner/layers/climate.test.ts | 21 +++- .../lib/planner/layers/wind-layer.svelte.ts | 98 ++++++++++--------- .../app/src/lib/planner/layers/wind.test.ts | 12 +-- builder/app/src/lib/planner/layers/wind.ts | 21 +++- 5 files changed, 119 insertions(+), 62 deletions(-) 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 85e2776ba..e7f3816e3 100644 --- a/builder/app/src/lib/planner/layers/climate.test.ts +++ b/builder/app/src/lib/planner/layers/climate.test.ts @@ -5,7 +5,7 @@ import { climateTile, locate, rainClass, rainRatios, read, temperatureAt, weekMonth, weekValues, wetDaysOf7, windChance, windMode, windRose, } from './climate'; import { lineBearings, weatherRows, windRow } from './climate-route'; -import { sampleLine, type TileGetter } from './climate-source'; +import { climateSource, detailCell, sampleLine, type TileGetter } from './climate-source'; import { specTile } from '../../../../test-support/planner/climate-tiles'; describe('climate tiles', () => { @@ -217,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/wind-layer.svelte.ts b/builder/app/src/lib/planner/layers/wind-layer.svelte.ts index bce00d8e6..026704970 100644 --- a/builder/app/src/lib/planner/layers/wind-layer.svelte.ts +++ b/builder/app/src/lib/planner/layers/wind-layer.svelte.ts @@ -2,56 +2,51 @@ import type { GeoJSONSource, Map } from 'maplibre-gl'; import type { FeatureCollection } from 'geojson'; import WindRose from '../../../components/planner/WindRose.svelte'; import { cumulative } from '../editor'; -import { DETAIL, OVERVIEW, weekMonth, type ClimateMeta, type ClimateTile } from './climate'; +import { OVERVIEW, weekMonth, type ClimateMeta, type ClimateTile } from './climate'; import { lineBearings, windRow } from './climate-route'; -import { openClimate, sampleLine, type CellRef, type ClimateSource } from './climate-source'; +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, ARROW_MARKS, WIND_NOTE, arrowStride, headClasses, headFills, speedPaint, speedScale, viewTiles, windChart, windMap, windYear } from './wind'; +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: { line: '#1c1b14', halo: '#ffffff' }, dark: { line: '#f2efe3', halo: '#201f17' } }; +const ink = { light: { fill: '#ffffff', halo: '#2b2a22' }, dark: { fill: '#f2efe3', halo: '#14130e' } }; const RATIO = 2, SIZE = 28; -/** A north-pointing arrow on a halo; `weight` 0–2 sets the shaft, and a double arrow has a head at each end. */ +/** + * 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.lineCap = context.lineJoin = 'round'; - const mid = SIZE / 2, tip = 2.5, half = 3.6 + 0.7 * w, length = 6.5 + w; - const heads = kind === 'double' ? [1, -1] : [1]; - const draw = (color: string, extra: number) => { - context.strokeStyle = context.fillStyle = color; - context.lineWidth = 1.3 + 1.1 * w + extra; - context.beginPath(); - context.moveTo(mid, SIZE - tip - (heads.length > 1 ? length - 1 : 0)); - context.lineTo(mid, tip + length - 1); - context.stroke(); - for (const end of heads) { - const y = end > 0 ? tip : SIZE - tip; - context.lineWidth = 1 + extra; - context.beginPath(); - context.moveTo(mid, y); - context.lineTo(mid - half, y + end * length); - context.lineTo(mid + half, y + end * length); - context.closePath(); - context.fill(); - context.stroke(); - } - }; - draw(ink[theme].halo, 3); - draw(ink[theme].line, 0); + 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 cells of each coordinate on both levels, the travel bearings and the headwind row of a route; a map point has neither. */ -interface Samples { overview: (CellRef | undefined)[]; detail: (CellRef | undefined)[]; bearings: Float32Array | null; row: Float32Array | null } +/** The overview cell of each coordinate, the travel bearings and the headwind row of a route; a map point has neither. */ +interface Samples { climate: ClimateSource; coordinates: Line['coordinates']; overview: (CellRef | undefined)[]; bearings: Float32Array | null; row: Float32Array | null } class WindLayer implements DataLayer { id = 'wind'; @@ -79,7 +74,7 @@ class WindLayer implements DataLayer { } legend(theme: Theme) { - return { ...speedScale(theme), marks: ARROW_MARKS }; + return mapLegend(theme); } private open(): Promise { @@ -147,26 +142,31 @@ class WindLayer implements DataLayer { private async draw() { const map = this.map, run = ++this.runs; if (!map?.getSource(CELLS)) return; - 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); + 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, detail] = await Promise.all([sampleLine(coordinates, climate.tile), sampleLine(coordinates, climate.tile, DETAIL)]); + const overview = await sampleLine(coordinates, climate.tile); signal.throwIfAborted(); - if (coordinates.length < 2) return { overview, detail, bearings: null, row: null }; + if (coordinates.length < 2) return { climate, coordinates, overview, bearings: null, row: null }; const km = cumulative(coordinates), bearings = lineBearings(coordinates, km, STRETCH_KM); - return { overview, detail, bearings, row: windRow(overview, km, bearings) }; + return { climate, coordinates, overview, bearings, row: windRow(overview, km, bearings) }; } strip = { @@ -175,8 +175,10 @@ class WindLayer implements DataLayer { values: ({ overview, bearings }: Samples, date: string) => headClasses(overview, bearings ?? [], weekMonth(weekOf(date))), }; - chart({ overview, detail, bearings }: Samples, i: number, { date, theme }: View) { - const { chart, rose } = windChart({ overview: overview[i], detail: detail[i], bearing: bearings?.[i] }, this.meta?.firstYear ?? 0, date, theme); + /** The weekly grid appears when the detail tile of the point arrives; the headline and the rose read the overview. */ + chart({ climate, coordinates, overview, bearings }: Samples, i: number, { date, theme }: View) { + const detail = detailCell(climate, coordinates[i]) ?? undefined; + const { chart, rose } = windChart({ overview: overview[i], detail, bearing: bearings?.[i] }, climate.meta.firstYear, date, theme); return rose ? { ...chart, extra: { component: WindRose, props: { ...rose, theme } } } : chart; } diff --git a/builder/app/src/lib/planner/layers/wind.test.ts b/builder/app/src/lib/planner/layers/wind.test.ts index 1ffa4c673..43ef43ee5 100644 --- a/builder/app/src/lib/planner/layers/wind.test.ts +++ b/builder/app/src/lib/planner/layers/wind.test.ts @@ -16,7 +16,7 @@ function setRose(t: ReturnType, cell: number, from: Record { - it('colours every cell with data and points an arrow where the wind blows towards', () => { + 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, 2.5]); + expect(cells.features.map(f => f.properties.speed)).toEqual([2.5]); // Neighbour cells share their edge exactly, so the fill has no seams. - const [west, east] = cells.features.map(f => f.geometry.coordinates[0]); - expect(west[1][0]).toBe(east[0][0]); - expect(west[0]).toEqual([-180 + 0.1 * 1878.5, 90 - 0.1 * 420.5]); + 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']); diff --git a/builder/app/src/lib/planner/layers/wind.ts b/builder/app/src/lib/planner/layers/wind.ts index 18c23b391..f51728780 100644 --- a/builder/app/src/lib/planner/layers/wind.ts +++ b/builder/app/src/lib/planner/layers/wind.ts @@ -35,6 +35,21 @@ 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)); @@ -60,7 +75,7 @@ export function arrowIcon({ steadiness, opposite }: { steadiness: number; opposi } /** The arrow key of the map legend, drawn in a 24 px box. */ -export const ARROW_MARKS = [ +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 }, @@ -84,7 +99,7 @@ export interface WindMap { arrows: FeatureCollection; } -/** Every cell of the tiles with its mean speed of `week`, and an arrow of `month` on every `stride`th cell. */ +/** 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) { @@ -93,7 +108,7 @@ export function windMap(tiles: ClimateTile[], week: number, month: number, strid 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)]; - cells.push({ type: 'Feature', properties: { speed }, geometry: { type: 'Polygon', coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]] } }); + 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) } });