Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion builder/app/src/components/planner/LayerLegend.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@
let { legend, label, columns = false, children }: { legend: Legend; label: string; columns?: boolean; children?: Snippet } = $props();
</script>

{#snippet marks()}
{#each legend.marks ?? [] as mark (mark.label)}
<li><svg class="mark" viewBox="0 0 24 24" aria-hidden="true"><path d={mark.path} stroke-width={mark.width} /></svg>{mark.label}</li>
{/each}
{/snippet}

{#if 'swatches' in legend}
<ul class="legend" class:columns aria-label={label}>
{#each legend.swatches as swatch (swatch.label)}<li><span class="swatch" class:hatch={swatch.hatch} style:--swatch={swatch.color}></span>{swatch.label}</li>{/each}
{@render marks()}
{@render children?.()}
</ul>
{:else}
Expand All @@ -17,7 +24,7 @@
<span class="bar" style:background={`linear-gradient(to right, ${legend.scale.map(stop => stop.color).join(', ')})`}></span>
<ol aria-hidden="true">{#each legend.scale as stop, i (i)}<li>{stop.label}</li>{/each}</ol>
</div>
{#if children}<ul>{@render children()}</ul>{/if}
{#if children || legend.marks}<ul>{@render marks()}{@render children?.()}</ul>{/if}
</div>
{/if}

Expand All @@ -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); }
Expand Down
1 change: 1 addition & 0 deletions builder/app/src/components/planner/PlannerIcon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down
62 changes: 62 additions & 0 deletions builder/app/src/components/planner/WindRose.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script lang="ts">
import { dayColor } from '../../lib/planner/day-colors';
import { headPart } from '../../lib/planner/layers/climate';
import { roseColors, type RoseView } from '../../lib/planner/layers/wind';
import type { Theme } from '../../lib/planner/layers/data-layer';

/** The daytime wind rose of a month: petals point where the wind blows; the route's travel direction crosses it. */
let { shares, bearing, month, theme }: RoseView & { theme: Theme } = $props();

const RING = 40;
const colors = $derived(roseColors(theme));
const polar = (radius: number, degrees: number) => {
const a = degrees * Math.PI / 180;
return `${(radius * Math.sin(a)).toFixed(2)} ${(-radius * Math.cos(a)).toFixed(2)}`;
};
// Petal area follows the share, so the radius follows its square root.
const petals = $derived.by(() => {
const most = Math.max(...shares);
return shares.map((share, s) => {
const r = RING * Math.sqrt(share / most), a = 22.5 * s;
const fill = bearing === undefined ? colors.even : headPart(s, bearing) >= 0.5 ? colors.head : headPart(s, bearing + 180) >= 0.5 ? colors.tail : colors.cross;
return { d: `M0 0L${polar(r, a - 10)}A${r.toFixed(2)} ${r.toFixed(2)} 0 0 1 ${polar(r, a + 10)}Z`, fill };
});
});
const travel = $derived(dayColor(1, theme));
</script>

<figure class="rose">
<svg viewBox="-54 -54 108 108" role="img" aria-label={`Wind rose for ${month} daytime hours${bearing === undefined ? '' : ', with your direction of travel'}`}>
<circle r={RING} class="ring" />
<text y={-RING - 4} class="north">N</text>
{#each petals as petal, s (s)}<path d={petal.d} fill={petal.fill} class="petal" />{/each}
{#if bearing !== undefined}
<g transform={`rotate(${bearing.toFixed(1)})`} stroke={travel} fill={travel}>
<line y1={RING - 2} y2={-RING - 4} stroke-width="2.5" stroke-linecap="round" />
<path d={`M0 ${-RING - 11}l-5.5 9h11Z`} stroke-width="1" stroke-linejoin="round" />
</g>
{/if}
</svg>
<figcaption>
<strong>{month}, daytime</strong>
<span>Petals point where the wind blows</span>
{#if bearing !== undefined}
<span><i class="key" style:--key={colors.head}></i>Headwind <i class="key" style:--key={colors.tail}></i>Tailwind</span>
<span><i class="travel" style:--key={travel}></i>Your direction</span>
{/if}
</figcaption>
</figure>

<style>
.rose { display: flex; align-items: center; gap: 12px; margin: 10px 0 0; }
svg { flex: none; width: 96px; height: 96px; overflow: visible; }
.ring { fill: none; stroke: var(--line-strong); stroke-width: 1; }
.north { font-size: 9px; font-weight: 600; text-anchor: middle; fill: var(--ink-soft); }
.petal { stroke: var(--panel); stroke-width: 0.75; }
figcaption { display: grid; gap: 3px; font-size: 12px; line-height: 1.3; color: var(--ink-soft); }
strong { font-weight: 600; color: var(--ink); }
figcaption span { display: flex; align-items: center; gap: 5px; }
.key { flex: none; width: 10px; height: 10px; border-radius: 2px; background: var(--key); box-shadow: inset 0 0 0 1px var(--line-strong); }
.key:not(:first-child) { margin-left: 6px; }
.travel { flex: none; width: 14px; height: 2.5px; border-radius: 2px; background: var(--key); }
</style>
14 changes: 11 additions & 3 deletions builder/app/src/lib/planner/layers/climate-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>, 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;
});
Expand Down
29 changes: 25 additions & 4 deletions builder/app/src/lib/planner/layers/climate-source.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,40 @@
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<ClimateTile | undefined>;

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<string, ClimateTile | null>;
}

/**
* Each tile is requested once and stays decoded: a region archive is a few megabytes, so the map,
* the route and the point chart share every tile without a cache limit.
*/
export function climateSource(archive: TileArchive): ClimateSource {
const tiles = new Map<string, Promise<ClimateTile | undefined>>();
const decoded = new SvelteMap<string, ClimateTile | null>();
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));
};
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;
},
Expand All @@ -48,10 +57,22 @@ export function openClimate(url: string): Promise<ClimateSource> {
/** 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)));
Expand Down
51 changes: 21 additions & 30 deletions builder/app/src/lib/planner/layers/climate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Level, { cells: number; planes: [string, number, number][] }> = {
[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<string, number>();
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', () => {
Expand Down Expand Up @@ -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']);
});
});
6 changes: 6 additions & 0 deletions builder/app/src/lib/planner/layers/climate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 5 additions & 2 deletions builder/app/src/lib/planner/layers/data-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions builder/app/src/lib/planner/layers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
9 changes: 5 additions & 4 deletions builder/app/src/lib/planner/layers/weather-layer.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -157,14 +157,15 @@ class WeatherLayer implements DataLayer<Samples> {

async sample({ coordinates, elevation }: Line, signal: AbortSignal): Promise<Samples> {
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 = {
Expand Down
4 changes: 2 additions & 2 deletions builder/app/src/lib/planner/layers/weather.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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([]);
Expand Down
Loading
Loading