From 3334ae8683c4b3e98694ba0affc5e2769e58496e Mon Sep 17 00:00:00 2001 From: timohueser Date: Sat, 3 Oct 2026 23:52:35 +0200 Subject: [PATCH 1/3] planner: add the Weather data layer with temperature, rain and night lows The Weather layer reads the climate archive. A switch in the date bar selects the map: daytime highs, corrected to the terrain height of each pixel, or the ten-year wet-day share. Both blend the four nearest cells. The year slider has High, Rain and Low rows. The profile strip shows the daytime high. The point chart shows the years of highs, lows and rain against a typical week. A label at each overnight stop shows the night lows of its week. DataLayer gets one optional method, nights(), for the overnight labels, and an optional strip label. The terrain helper caches decoded DEM tiles per DEM tile, so four map tiles share one fetch. Co-Authored-By: Claude Opus 5.5 --- .../components/planner/LayerDateBar.svelte | 5 +- .../components/planner/LayerInspect.svelte | 2 +- .../src/components/planner/LayerStrip.svelte | 2 +- .../src/components/planner/PlannerApp.svelte | 14 +- .../src/components/planner/PlannerIcon.svelte | 1 + .../src/components/planner/PlannerMap.svelte | 15 +- .../src/lib/planner/layers/climate-terrain.ts | 30 +- .../app/src/lib/planner/layers/data-layer.ts | 9 + .../app/src/lib/planner/layers/registry.ts | 2 + .../planner/layers/weather-layer.svelte.ts | 184 +++++++++++++ .../src/lib/planner/layers/weather.test.ts | 138 ++++++++++ builder/app/src/lib/planner/layers/weather.ts | 260 ++++++++++++++++++ builder/app/tests/browser/package.json | 2 +- 13 files changed, 637 insertions(+), 27 deletions(-) create mode 100644 builder/app/src/lib/planner/layers/weather-layer.svelte.ts create mode 100644 builder/app/src/lib/planner/layers/weather.test.ts create mode 100644 builder/app/src/lib/planner/layers/weather.ts diff --git a/builder/app/src/components/planner/LayerDateBar.svelte b/builder/app/src/components/planner/LayerDateBar.svelte index 87441b49a..766f0c44a 100644 --- a/builder/app/src/components/planner/LayerDateBar.svelte +++ b/builder/app/src/components/planner/LayerDateBar.svelte @@ -3,7 +3,7 @@ import SeasonGrid from './SeasonGrid.svelte'; import DateCalendar from './DateCalendar.svelte'; import Segmented from './Segmented.svelte'; - import { addMonths, columnDate, dateLabel, type DataLayer, type Grid } from '../../lib/planner/layers/data-layer'; + import { addDays, addMonths, columnDate, dateLabel, type DataLayer, type Grid } from '../../lib/planner/layers/data-layer'; let { date, year, variable, onDate }: { date: string; year: Grid; variable: DataLayer['variable']; onDate: (date: string) => void } = $props(); @@ -18,8 +18,7 @@ onDate(next); } function shift(days: number) { - const [y, m, d] = date.split('-').map(Number); - step(new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10)); + step(addDays(date, days)); } function key(event: KeyboardEvent) { const year = date.slice(0, 4); diff --git a/builder/app/src/components/planner/LayerInspect.svelte b/builder/app/src/components/planner/LayerInspect.svelte index 512514471..2071fef25 100644 --- a/builder/app/src/components/planner/LayerInspect.svelte +++ b/builder/app/src/components/planner/LayerInspect.svelte @@ -27,7 +27,7 @@ {#if chart} {#each chart.grids as grid, g (g)} {#if grid.label}

{grid.label}

{/if} - + 1 ? 3 : 4) : 8} label={`${grid.label || chart.headline}. One row per year, newest at the bottom.`} /> {#if !compact && (grid.legend || g === chart.grids.length - 1)} {#if g === chart.grids.length - 1}
  • Your date
  • {/if} diff --git a/builder/app/src/components/planner/LayerStrip.svelte b/builder/app/src/components/planner/LayerStrip.svelte index 152ba6950..90ff28c43 100644 --- a/builder/app/src/components/planner/LayerStrip.svelte +++ b/builder/app/src/components/planner/LayerStrip.svelte @@ -52,7 +52,7 @@
    - {layer.label}{layer.error || (samples ? `${dateLabel(date)} · hover for past years` : 'Loading…')} + {strip.label ?? layer.label}{layer.error || (samples ? `${dateLabel(date)} · hover for past years` : 'Loading…')}
    { + if (!multi || !dataLayer?.nights || !routeSamples || !visualRoute) return []; + const points = profileSamples(visualRoute); + const ends = days.slice(0, -1); + const stops = ends.map(day => ({ index: Math.min(points.length - 1, sampleIndex(points, day.to)), date: addDays(shownDate, (dayLabels[day.number] ?? day.number) - 1) })); + return dataLayer.nights(routeSamples, stops).flatMap((text, i) => text ? [{ coordinate: coordinateAt(coordinates, ends[i].to), text }] : []); + }); // A map click with a layer shown adds the years at that spot to the callout. let pinSamples = $state.raw<{ layer: DataLayer; at: Coordinate; samples: unknown } | null>(null); $effect(() => { @@ -965,7 +973,7 @@
    gap.coordinates)} {coordinates} points={mapPoints} {selectedId} {hoveredId} onPointHover={(id) => hoveredId = id} callout={calloutCoordinate} {drawing} - {theme} {hillshade} {contours} {mapOverlays} dataLayer={{ layers: dataLayers, shown: dataLayer, date: shownDate }} bottomInset={dataLayer ? dateBarHeight + 34 : 0} accessMode={trip.bike === 'hiking' ? 'walking' : 'cycling'} {showRoute} {hoverProgress} highlightedCoordinates={highlighted} pickMode={picking} routingPackage={currentRoute?.package} + {theme} {hillshade} {contours} {mapOverlays} dataLayer={{ layers: dataLayers, shown: dataLayer, date: shownDate, notes: layerNotes }} bottomInset={dataLayer ? dateBarHeight + 34 : 0} accessMode={trip.bike === 'hiking' ? 'walking' : 'cycling'} {showRoute} {hoverProgress} highlightedCoordinates={highlighted} pickMode={picking} routingPackage={currentRoute?.package} highlightedPlaceIds={searching ? results.map(result => result.place.id) : []} shownCategories={categoryIds.filter(category => !hiddenCategories.includes(category))} {highlightedPlaces} {landmarks} onBounds={(bounds, preserveSearch) => { viewBounds = bounds; if (!preserveSearch) searchViewRevision++; }} onEmptyClick={emptyClick} onPointSelect={selectPoint} onPointMove={movePoint} onPointPreview={previewPoint} onDayEndDrag={moveDayEnd} diff --git a/builder/app/src/components/planner/PlannerIcon.svelte b/builder/app/src/components/planner/PlannerIcon.svelte index 24864ee0c..8f3f3f2e6 100644 --- a/builder/app/src/components/planner/PlannerIcon.svelte +++ b/builder/app/src/components/planner/PlannerIcon.svelte @@ -42,6 +42,7 @@ trash: 'M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6m4-6v6', mountain: 'm2 20 7-13 4 7 3-5 6 11H2Zm4-7 3 1 2-3', snow: 'M12 2v20M3.3 7l17.4 10M3.3 17 20.7 7M9.5 3.5 12 6l2.5-2.5M9.5 20.5 12 18l2.5 2.5M18.1 5.6 17.2 9l3.4.9M20.6 14.1 17.2 15l.9 3.4M3.4 14.1 6.8 15l-.9 3.4M5.9 5.6 6.8 9l-3.4.9', + weather: 'M7 19h10a4 4 0 0 0 .7-7.9A5.5 5.5 0 0 0 7.2 10 4.5 4.5 0 0 0 7 19Z', }; +
    + + +
    +
      +
    • Mean
    • +
    • Most years
    • +
    • Your date
    • +
    + + + diff --git a/builder/app/src/lib/planner/layers/climate-source.ts b/builder/app/src/lib/planner/layers/climate-source.ts index 8c2962826..4b89c537c 100644 --- a/builder/app/src/lib/planner/layers/climate-source.ts +++ b/builder/app/src/lib/planner/layers/climate-source.ts @@ -1,6 +1,6 @@ import type { Coordinate } from '../map-types'; import { OVERVIEW, cellAt, climateMeta, climateTile, locate, type ClimateMeta, type ClimateTile, type Level } from './climate'; -import type { TileArchive } from './tile-archive'; +import { openTileArchive, type TileArchive } from './tile-archive'; export type TileGetter = (level: Level, x: number, y: number) => Promise; @@ -32,6 +32,19 @@ export function climateSource(archive: TileArchive): ClimateSource { }; } +const opened = new Map>(); + +/** One source per archive URL, so the climate layers share its tiles; a failed open is retried on the next call. */ +export function openClimate(url: string): Promise { + let entry = opened.get(url); + if (!entry) { + entry = openTileArchive(url, 'Climate').then(climateSource); + opened.set(url, entry); + entry.catch(() => opened.delete(url)); + } + return entry; +} + /** A cell of a decoded tile. */ export interface CellRef { tile: ClimateTile; index: number } diff --git a/builder/app/src/lib/planner/layers/weather-layer.svelte.ts b/builder/app/src/lib/planner/layers/weather-layer.svelte.ts index 87b2ff360..37df8810a 100644 --- a/builder/app/src/lib/planner/layers/weather-layer.svelte.ts +++ b/builder/app/src/lib/planner/layers/weather-layer.svelte.ts @@ -4,10 +4,9 @@ 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 { climateSource, sampleLine, type CellRef, type ClimateSource } from './climate-source'; +import { openClimate, sampleLine, type CellRef, type ClimateSource } from './climate-source'; import { cropHeights, demPixels, demTile, pixelHeight } from './climate-terrain'; import { weekOf, type DataLayer, type Line, type Theme, type View } from './data-layer'; -import { openTileArchive } from './tile-archive'; import { cellBlock, mapLegend, nightLabels, paintWeather, rampWords, sampleHighs, temperatureClass, temperatureFills, tileCells, weatherChart, weatherYear, type Samples, type Variable, @@ -55,8 +54,7 @@ class WeatherLayer implements DataLayer { } private open(): Promise { - this.archive ??= openTileArchive(this.url, 'Climate').then(archive => { - const source = climateSource(archive); + this.archive ??= openClimate(this.url).then(source => { this.meta = source.meta; this.error = ''; return source; @@ -78,17 +76,25 @@ class WeatherLayer implements DataLayer { return entry; } - /** The height at a coordinate from the most detailed decoded DEM tile that holds it; NaN when none does. */ + /** + * The height at a coordinate from the most detailed decoded DEM tile that holds it, or else from + * the DEM tile of the view zoom, which the relief has loaded; NaN without terrain. + */ private async heightAt([longitude, latitude]: Coordinate): Promise { const sin = Math.sin(latitude * Math.PI / 180); - for (let z = DEM_MAX_ZOOM; z >= 0; z--) { + const pixel = (z: number) => { const scale = DEM_TILE * 2 ** z; const px = Math.floor((longitude + 180) / 360 * scale), py = Math.floor((0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * scale); - const entry = this.dems.get(`${z}/${Math.floor(px / DEM_TILE)}/${Math.floor(py / DEM_TILE)}`); - const rgba = entry && await entry; - if (rgba) return pixelHeight(rgba, px % DEM_TILE, py % DEM_TILE); + return { key: [z, Math.floor(px / DEM_TILE), Math.floor(py / DEM_TILE)] as const, column: px % DEM_TILE, row: py % DEM_TILE }; + }; + for (let z = DEM_MAX_ZOOM; z >= 0; z--) { + const { key, column, row } = pixel(z); + const rgba = await this.dems.get(key.join('/')); + if (rgba) return pixelHeight(rgba, column, row); } - return NaN; + const { key, column, row } = pixel(demTile(Math.floor(this.map?.getZoom() ?? 0), 0, 0).z); + const rgba = await this.dem(...key); + return rgba ? pixelHeight(rgba, column, row) : NaN; } /** Colours one map tile for the layer week and variable. */ @@ -169,7 +175,7 @@ class WeatherLayer implements DataLayer { }; chart(samples: Samples, i: number, { date, theme }: View) { - return weatherChart(samples, i, this.meta?.firstYear ?? 0, date, theme); + return weatherChart(samples, i, this.meta?.firstYear ?? 0, date, theme, this.variable.value as Variable); } year(samples: Samples | null, { date, theme }: View) { diff --git a/builder/app/src/lib/planner/layers/weather.test.ts b/builder/app/src/lib/planner/layers/weather.test.ts index 4b4a23d16..ac55062ed 100644 --- a/builder/app/src/lib/planner/layers/weather.test.ts +++ b/builder/app/src/lib/planner/layers/weather.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { DETAIL, OVERVIEW, WEEKS, climateTile, type Level } from './climate'; import { - NO_TEMPERATURE, nightLabels, paintWeather, rampColor, spread, temperatureClass, weatherYear, weekLabel, word, type CellBlock, type Samples, + NO_TEMPERATURE, nightLabels, paintWeather, rainWeeks, rampColor, spread, temperatureClass, weatherChart, weatherYear, weekLabel, word, type CellBlock, type Samples, } from './weather'; // Plane order and value counts of specs/planner-climate-tiles.md; orography is the only 2-byte plane. @@ -105,17 +105,39 @@ describe('weather along a route', () => { return { overview: [{ tile: o, index: 0 }, { tile: o, index: 1 }], detail: [{ tile: d, index: 0 }, undefined], elevation: Float32Array.from(elevation), km: [0, 10] }; }; - it('has a High, a Rain and a Low row and names the week in its label', () => { + it('has a High, a Low and a Rain row and names the week in its label', () => { const year = weatherYear(samples([500, 500]), date, 'light'); - expect(year.rows.map(row => row.label)).toEqual(['High', 'Rain', 'Low']); - expect(year.label).toBe('On the route, 14–20 May: highs 12–14 °C · rain on 2.1 of 7 days · lows 4 °C'); + expect(year.rows.map(row => row.label)).toEqual(['High', 'Low', 'Rain']); + expect(year.label).toBe('On the route, 14–20 May: highs 12–14 °C · lows 4 °C · rain on 2.1 of 7 days'); expect(year.rows[0].cells[week]).toBe(temperatureClass(13)); - expect(year.rows[2].cells[week]).toBe(temperatureClass(4)); - expect(year.fills[year.rows[1].cells[week]].label).toBe('2 of 7 days'); + expect(year.rows[1].cells[week]).toBe(temperatureClass(4)); + expect(year.fills[year.rows[2].cells[week]].label).toBe('2 of 7 days'); expect(year.rows[0].cells[0]).toBe(NO_TEMPERATURE); expect(weatherYear(null, date, 'light').rows[0].cells.every(cell => cell === 255)).toBe(true); }); + it('charts the wet days of the week for rain, and highs and lows for temperature', () => { + const rainy = tile(DETAIL); + // Wet days 1 to 5 over the years, two years each, so the extreme years do not narrow the spread. Week 51 has 8 days, 9 in a leap year. + for (let year = 0; year < 10; year++) { + rainy.set('wet_days', year * WEEKS + week, 0, 1 + Math.floor(year / 2)); + rainy.set('wet_days', year * WEEKS + 51, 0, 8); + rainy.set('rain', year * WEEKS + week, 0, 10); + // A dry week elsewhere makes this week twice the mean week. + rainy.set('rain', year * WEEKS + 30, 0, 0); + } + const weeks = rainWeeks({ tile: rainy.tile(), index: 0 }, 2016); + expect([weeks.mean[week], weeks.low[week], weeks.high[week]]).toEqual([3, 1, 5]); + // 2016, 2020 and 2024 are leap years. + expect(weeks.mean[51]).toBeCloseTo((7 * 7 + 3 * 7 * 8 / 9) / 10); + const point = { ...samples([500]), detail: [{ tile: rainy.tile(), index: 0 }] }; + const rain = weatherChart(point, 0, 2016, date, 'light', 'rain'); + expect(rain.headline).toBe('14–20 May: rain on about 3 of 7 days (1–5 in most years)'); + expect(rain.grids).toEqual([]); + expect(rain.note).toBe('Usually more rain than in an average week here.'); + expect(weatherChart(samples([500, 500]), 0, 2016, date, 'light', 'temperature').grids.map(grid => grid.label)).toEqual(['Daytime high', 'Night low']); + }); + it('labels each overnight stop with the night lows of its week at its height', () => { expect(nightLabels(samples([500, 500]), [{ index: 0, date }, { index: 1, date }])).toEqual(['Night 2–9 °C', '']); // 1000 m higher at −5 K/km: 5 °C colder. diff --git a/builder/app/src/lib/planner/layers/weather.ts b/builder/app/src/lib/planner/layers/weather.ts index f6373155d..f6b7e33a7 100644 --- a/builder/app/src/lib/planner/layers/weather.ts +++ b/builder/app/src/lib/planner/layers/weather.ts @@ -1,6 +1,7 @@ // The Weather layer of the climate archive: daytime highs or the wet-day share on the map; highs, // rain and night lows along a route and at a point. -import { atElevation, rainClass, rainRatios, read, temperatureAt, weekMonth, wetDaysOf7, WEEKS, YEARS } from './climate'; +import RainWeeks from '../../../components/planner/RainWeeks.svelte'; +import { RAIN_DRIER, RAIN_UNKNOWN, RAIN_WETTER, atElevation, rainClass, rainRatios, read, temperatureAt, weekMonth, wetDaysOf7, WEEKS, YEARS } from './climate'; import type { CellRef } from './climate-source'; import { weatherRows } from './climate-route'; import { dateLabel, weekOf, type Chart, type Grid, type Legend, type Swatch, type Theme } from './data-layer'; @@ -13,7 +14,7 @@ const RAMPS: Record = { // Daytime highs in °C: cold blue, a neutral near 10 °C, warm clay; the dark ramp is mixed toward the dark base. temperature: { from: -10, to: 30, - colors: { light: ['#6f8fb3', '#b3c8d9', '#ebe6d2', '#e3b07f', '#c5643d'], dark: ['#3a5674', '#4e6a80', '#57543f', '#86613f', '#a5543a'] }, + colors: { light: ['#6f8fb3', '#b3c8d9', '#ebe6d2', '#e3b07f', '#c5643d'], dark: ['#3f5f80', '#5f819c', '#8a8664', '#b5844f', '#c8603c'] }, labels: ['−10', '0', '10', '20', '30 °C'], }, // Wet days of 7: dry cream to deep blue-grey. The scale ends at 6 of 7, so typical weeks get most of the colour range. @@ -77,17 +78,6 @@ const wetClass = (days: number) => Math.round(Math.min(7, Math.max(0, days)) / W /** The year slider fills: temperature classes, then wet-day classes from here. */ const WET_BASE = NO_TEMPERATURE + 1; -const RAIN_COLORS = { - light: ['#e2c9a0', '#e6e3d6', '#6f97b6'], - dark: ['#7a6544', '#3f3e33', '#5f88a8'], -}; - -/** Swatches by `rainClass`: drier, typical, wetter, no data. */ -export function rainFills(theme: Theme): Swatch[] { - const [drier, typical, wetter] = RAIN_COLORS[theme]; - return [{ label: 'Drier', color: drier }, { label: 'Typical', color: typical }, { label: 'Wetter', color: wetter }, noData(theme)]; -} - /** "−3", "12": whole degrees with a true minus sign. */ export const celsius = (value: number) => String(Math.round(value)).replace('-', '−'); @@ -206,9 +196,9 @@ const oneDecimal = (value: number) => value.toLocaleString('en-GB', { minimumFra export function weatherYear(samples: Samples | null, date: string, theme: Theme): Grid { const fills = [...temperatureFills(theme), ...wetFills(theme)]; const empty = new Uint8Array(WEEKS).fill(255); - const grid = (label: string, high: Uint8Array, rain: Uint8Array, low: Uint8Array): Grid => ({ + const grid = (label: string, high: Uint8Array, low: Uint8Array, rain: Uint8Array): Grid => ({ label, columns: WEEKS, fills, - rows: [{ label: 'High', cells: high }, { label: 'Rain', cells: rain }, { label: 'Low', cells: low }], + rows: [{ label: 'High', cells: high }, { label: 'Low', cells: low }, { label: 'Rain', cells: rain }], }); if (!samples) return grid('Plan a route to see its weather through the year.', empty, empty, empty); const rows = weatherRows(samples.overview, samples.km, samples.elevation); @@ -216,34 +206,58 @@ export function weatherYear(samples: Samples | null, date: string, theme: Theme) const highs = minMax(sampleHighs(samples, date)); if (!highs) return grid('No weather data along the route', empty, empty, empty); const lows = minMax(Float32Array.from(samples.overview, (ref, i) => ref ? temperatureAt(ref.tile, 'tmin', week, ref.index, samples.elevation[i]) : NaN)); - const label = `On the route, ${weekLabel(date)}: highs ${range(...highs)} · rain on ${oneDecimal(rows.rain[week])} of 7 days · lows ${lows ? range(...lows) : 'unknown'}`; - return grid(label, Uint8Array.from(rows.high, temperatureClass), Uint8Array.from(rows.rain, days => Number.isNaN(days) ? 255 : WET_BASE + wetClass(days)), Uint8Array.from(rows.low, temperatureClass)); + const label = `On the route, ${weekLabel(date)}: highs ${range(...highs)} · lows ${lows ? range(...lows) : 'unknown'} · rain on ${oneDecimal(rows.rain[week])} of 7 days`; + return grid(label, Uint8Array.from(rows.high, temperatureClass), Uint8Array.from(rows.low, temperatureClass), Uint8Array.from(rows.rain, days => Number.isNaN(days) ? 255 : WET_BASE + wetClass(days))); } -/** The years at one sample: yearly weeks of highs, lows and rain against a typical week of the cell. */ -export function weatherChart(samples: Samples, i: number, firstYear: number, date: string, theme: Theme): Chart { +const leap = (year: number) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +/** Days of a week of the spec: week 51 also holds the last one or two days of the year. */ +const daysOf = (week: number, year: number) => week < WEEKS - 1 ? 7 : leap(year) ? 9 : 8; + +/** Wet days of 7 in each week of the years at a detail cell: the mean, and the spread without the extreme years. */ +export function rainWeeks(ref: CellRef, firstYear: number): { mean: Float32Array; low: Float32Array; high: Float32Array } { + const mean = new Float32Array(WEEKS), low = new Float32Array(WEEKS), high = new Float32Array(WEEKS); + for (let week = 0; week < WEEKS; week++) { + const years = Array.from({ length: YEARS }, (_, year) => 7 * read(ref.tile, 'wet_days', year * WEEKS + week, ref.index) / daysOf(week, firstYear + year)); + const known = years.filter(v => !Number.isNaN(v)); + mean[week] = known.length ? known.reduce((a, b) => a + b, 0) / known.length : NaN; + [low[week], high[week]] = spread(years) ?? [NaN, NaN]; + } + return { mean, low, high }; +} + +/** The years at one sample for the map variable: highs and lows, or wet days through the year. */ +export function weatherChart(samples: Samples, i: number, firstYear: number, date: string, theme: Theme, variable: Variable): Chart { const ref = samples.detail[i], height = samples.elevation[i]; if (!ref) return { headline: 'No weather data here', grids: [] }; const week = weekOf(date); + if (variable === 'rain') { + const weeks = rainWeeks(ref, firstYear); + if (Number.isNaN(weeks.mean[week])) return { headline: 'No rain data here', grids: [] }; + const most = Math.round(weeks.low[week]) === Math.round(weeks.high[week]) ? `${Math.round(weeks.low[week])}` : `${Math.round(weeks.low[week])}–${Math.round(weeks.high[week])}`; + // The rain amount of the week against the mean week of the cell, over the years. + const ratios = rainRatios(Float32Array.from({ length: YEARS * WEEKS }, (_, slot) => read(ref.tile, 'rain', slot, ref.index)), firstYear); + const ofWeek = Array.from({ length: YEARS }, (_, year) => ratios[year * WEEKS + week]).filter(v => !Number.isNaN(v)); + const amount = ofWeek.length ? rainClass(ofWeek.reduce((a, b) => a + b, 0) / ofWeek.length) : RAIN_UNKNOWN; + return { + headline: `${weekLabel(date)}: rain on about ${Math.round(weeks.mean[week])} of 7 days (${most} in most years)`, + grids: [], + extra: { component: RainWeeks, props: { ...weeks, week, colors: { line: rampColor('rain', theme, 5), band: rampColor('rain', theme, theme === 'dark' ? 3 : 1.5) } } }, + note: amount === RAIN_WETTER ? 'Usually more rain than in an average week here.' : amount === RAIN_DRIER ? 'Usually less rain than in an average week here.' : undefined, + }; + } const temperatures = (plane: 'tmax' | 'tmin') => Float32Array.from({ length: YEARS * WEEKS }, (_, slot) => temperatureAt(ref.tile, plane, slot, ref.index, height)); const high = temperatures('tmax'), low = temperatures('tmin'); - const rain = rainRatios(Float32Array.from({ length: YEARS * WEEKS }, (_, slot) => read(ref.tile, 'rain', slot, ref.index)), firstYear); const ofWeek = (values: Float32Array) => spread(Array.from({ length: YEARS }, (_, year) => values[year * WEEKS + week])); const highs = ofWeek(high), lows = ofWeek(low); - const wet = samples.overview[i] ? wetDaysOf7(samples.overview[i]!.tile, samples.overview[i]!.index, week) : NaN; - const parts = [highs && `highs ${range(...highs)}`, lows && `night lows ${range(...lows)}`, !Number.isNaN(wet) && `rain on ${oneDecimal(wet)} of 7 days`].filter(Boolean); - const years = (cells: (slot: number) => number, fills: Swatch[], label: string, legend?: Legend): Grid => ({ - label, columns: WEEKS, fills, legend, - rows: Array.from({ length: YEARS }, (_, year) => ({ label: String(firstYear + year), cells: Uint8Array.from({ length: WEEKS }, (_, w) => cells(year * WEEKS + w)) })), + const parts = [highs && `highs ${range(...highs)}`, lows && `night lows ${range(...lows)}`].filter(Boolean); + const years = (values: Float32Array, label: string, legend?: Legend): Grid => ({ + label, columns: WEEKS, fills: temperatureFills(theme), legend, + rows: Array.from({ length: YEARS }, (_, year) => ({ label: String(firstYear + year), cells: Uint8Array.from({ length: WEEKS }, (_, w) => temperatureClass(values[year * WEEKS + w])) })), }); - const temperature = temperatureFills(theme), rainSwatches = rainFills(theme); return { headline: parts.length ? `${weekLabel(date)}: ${parts.join(', ')}` : 'No weather data here', - grids: [ - years(slot => temperatureClass(high[slot]), temperature, 'Daytime high'), - years(slot => temperatureClass(low[slot]), temperature, 'Night low', mapLegend('temperature', theme)), - years(slot => rainClass(rain[slot]), rainSwatches, 'Rain against a typical week here', { swatches: rainSwatches }), - ], + grids: [years(high, 'Daytime high'), years(low, 'Night low', mapLegend('temperature', theme))], note: 'Valleys can be colder on clear nights.', }; } From a03678b6618c6824444045e6f3e82319fb8fbbce Mon Sep 17 00:00:00 2001 From: timohueser Date: Sun, 4 Oct 2026 00:10:19 +0200 Subject: [PATCH 3/3] planner: read a map point from the DEM zoom the relief loads MapLibre rounds the zoom for raster sources, so the 512 px relief loads round(zoom). The point height now reads that tile, which the browser cache holds. Co-Authored-By: Claude Opus 5.5 --- builder/app/src/lib/planner/layers/climate-terrain.test.ts | 6 +++++- builder/app/src/lib/planner/layers/climate-terrain.ts | 5 +++++ builder/app/src/lib/planner/layers/weather-layer.svelte.ts | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/builder/app/src/lib/planner/layers/climate-terrain.test.ts b/builder/app/src/lib/planner/layers/climate-terrain.test.ts index c65b7833a..9c0b3b32b 100644 --- a/builder/app/src/lib/planner/layers/climate-terrain.test.ts +++ b/builder/app/src/lib/planner/layers/climate-terrain.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; // map-style reads the page URL when it loads. vi.stubGlobal('window', { location: { href: 'https://planner.example/plan/' } }); -const { cropHeights, demTile } = await import('./climate-terrain'); +const { cropHeights, demTile, reliefZoom } = await import('./climate-terrain'); /** A 512 px Terrarium tile whose height at (column, row) is 10 × row + column − 100. */ function terrarium(): Uint8ClampedArray { @@ -25,6 +25,10 @@ describe('climate terrain', () => { expect(demTile(15, 8 * 100 + 3, 8 * 50 + 7)).toEqual({ z: 12, x: 100, y: 50, scale: 8, left: 192, top: 448 }); }); + it('reads a map point from the DEM zoom that the relief loads', () => { + expect([reliefZoom(9.4), reliefZoom(9.6), reliefZoom(15), reliefZoom(-1)]).toEqual([9, 10, 12, 0]); + }); + it('decodes Terrarium heights of the covered part, one DEM pixel per map pixel at the view zoom', () => { const rgba = terrarium(); const quarter = cropHeights(rgba, demTile(10, 533, 355)); diff --git a/builder/app/src/lib/planner/layers/climate-terrain.ts b/builder/app/src/lib/planner/layers/climate-terrain.ts index 1b6304b6a..f210f4f50 100644 --- a/builder/app/src/lib/planner/layers/climate-terrain.ts +++ b/builder/app/src/lib/planner/layers/climate-terrain.ts @@ -14,6 +14,11 @@ export function demTile(z: number, x: number, y: number): { z: number; x: number return { z: dz, x: Math.floor(x / scale), y: Math.floor(y / scale), scale, left: (x % scale) * part, top: (y % scale) * part }; } +/** The DEM zoom that the 512 px relief loads at a map zoom: MapLibre rounds the zoom for raster sources. */ +export function reliefZoom(zoom: number): number { + return Math.min(Math.max(Math.round(zoom), 0), DEM_MAX_ZOOM); +} + /** The height in metres of pixel (column, row) of a 512 px Terrarium RGBA tile: R × 256 + G + B ÷ 256 − 32768. */ export function pixelHeight(rgba: ArrayLike, column: number, row: number): number { const p = 4 * (row * DEM_TILE + column); 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 37df8810a..3db72834e 100644 --- a/builder/app/src/lib/planner/layers/weather-layer.svelte.ts +++ b/builder/app/src/lib/planner/layers/weather-layer.svelte.ts @@ -5,7 +5,7 @@ import { DEM_MAX_ZOOM, DEM_TILE } from '../map-style'; import type { Coordinate } from '../map-types'; import { DETAIL, OVERVIEW, locate, type ClimateMeta } from './climate'; import { openClimate, sampleLine, type CellRef, type ClimateSource } from './climate-source'; -import { cropHeights, demPixels, demTile, pixelHeight } from './climate-terrain'; +import { cropHeights, demPixels, demTile, pixelHeight, reliefZoom } from './climate-terrain'; import { weekOf, type DataLayer, type Line, type Theme, type View } from './data-layer'; import { cellBlock, mapLegend, nightLabels, paintWeather, rampWords, sampleHighs, temperatureClass, temperatureFills, tileCells, @@ -92,7 +92,7 @@ class WeatherLayer implements DataLayer { const rgba = await this.dems.get(key.join('/')); if (rgba) return pixelHeight(rgba, column, row); } - const { key, column, row } = pixel(demTile(Math.floor(this.map?.getZoom() ?? 0), 0, 0).z); + const { key, column, row } = pixel(reliefZoom(this.map?.getZoom() ?? 0)); const rgba = await this.dem(...key); return rgba ? pixelHeight(rgba, column, row) : NaN; }