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
5 changes: 2 additions & 3 deletions builder/app/src/components/planner/LayerDateBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion builder/app/src/components/planner/LayerInspect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
{#if chart}
{#each chart.grids as grid, g (g)}
{#if grid.label}<p class="grid-label">{grid.label}</p>{/if}
<SeasonGrid {grid} {date} rowHeight={compact ? 4 : 8} label={`${grid.label || chart.headline}. One row per year, newest at the bottom.`} />
<SeasonGrid {grid} {date} rowHeight={compact ? (chart.grids.length > 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)}
<LayerLegend legend={grid.legend ?? { swatches: [] }} label={`${grid.label || 'Chart'} colours`}>
{#if g === chart.grids.length - 1}<li><span class="line"></span>Your date</li>{/if}
Expand Down
2 changes: 1 addition & 1 deletion builder/app/src/components/planner/LayerStrip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@

<div class="strip">
<div class="label">
<span><strong>{layer.label}</strong><span class="hint">{layer.error || (samples ? `${dateLabel(date)} · hover for past years` : 'Loading…')}</span></span>
<span><strong>{strip.label ?? layer.label}</strong><span class="hint">{layer.error || (samples ? `${dateLabel(date)} · hover for past years` : 'Loading…')}</span></span>
<span class="key"><LayerLegend legend={strip.legend(theme)} label={`${layer.label} colours`} /></span>
</div>
<div class="bar" bind:this={bar} role="slider" tabindex={samples ? 0 : -1} aria-label={`${layer.label} along the route on ${dateLabel(date)}`}
Expand Down
14 changes: 11 additions & 3 deletions builder/app/src/components/planner/PlannerApp.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import LayerInspect from './LayerInspect.svelte';
import LayerStrip from './LayerStrip.svelte';
import { dataLayers } from '../../lib/planner/layers/registry';
import type { DataLayer } from '../../lib/planner/layers/data-layer';
import { addDays, type DataLayer } from '../../lib/planner/layers/data-layer';
import type { OverlayOptions } from '../../lib/planner/route-overlays';
import NearbyLandmark from './NearbyLandmark.svelte';
import { presetName } from '../../lib/planner/riding-profiles';
Expand All @@ -39,7 +39,7 @@
import { coordinateName, visitName } from '../../lib/planner/point-names';
import { SEARCH_URL, HOSTED_SEARCH, SEARCH_REGIONS, REGION_NAME } from '../../lib/planner/search/config';
import { dayColor } from '../../lib/planner/day-colors';
import { profileAscent, profileDescent } from '../../lib/planner/profile-data';
import { profileAscent, profileDescent, profileSamples, sampleIndex } from '../../lib/planner/profile-data';
import { searchPlaces, type SearchState, type SearchContext, type Where } from '../../lib/planner/search/types';
import { asPlace } from '../../lib/planner/search/presentation';
import { buildQueryRoute } from '../../lib/planner/search/route-client';
Expand Down Expand Up @@ -354,6 +354,14 @@
});
const routeSamples = $derived(dataLayer && visualRoute && lineSamples?.layer === dataLayer && lineSamples.line === visualRoute ? lineSamples.samples : null);
const layerYear = $derived(dataLayer?.year(routeSamples, { date: shownDate, theme }));
// The night after day n falls n − 1 calendar days after the layer date.
const layerNotes = $derived.by(() => {
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(() => {
Expand Down Expand Up @@ -965,7 +973,7 @@
<div class="map-area" bind:clientHeight={mapHeight} style:--map-height={`${mapHeight}px`}>
<PlannerMap
bind:this={map} {segments} gaps={gaps.map(gap => 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}
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 @@ -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',
};
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{#if bike}
Expand Down
15 changes: 13 additions & 2 deletions builder/app/src/components/planner/PlannerMap.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@
/** Places to ride over, drawn from zoom 10. */
landmarks?: Place[];
mapOverlays?: OverlayOptions;
/** The region's data layers, the shown one if any, and the layer date. */
dataLayer?: { layers: DataLayer[]; shown?: DataLayer; date: string };
/** The region's data layers, the shown one if any, the layer date, and the shown layer's labels beside the overnight stops. */
dataLayer?: { layers: DataLayer[]; shown?: DataLayer; date: string; notes?: { coordinate: Coordinate; text: string }[] };
/** Height of a bar over the bottom of the map that fits and callouts keep clear of. */
bottomInset?: number;
/** Travel mode, independent of the network chosen for display. */
Expand Down Expand Up @@ -515,6 +515,16 @@
shown?.sync(map, { shown: true, date, theme });
}
$effect(() => { void [dataLayer?.shown, dataLayer?.date, dataLayer?.shown?.variable?.value]; if (ready) untrack(syncDataLayer); });
const notes: maplibregl.Marker[] = [];
$effect(() => {
const list = showRoute ? dataLayer?.notes ?? [] : [];
if (!map) return;
for (const marker of notes.splice(0)) marker.remove();
for (const { coordinate, text } of list) {
const element = Object.assign(document.createElement('div'), { className: 'planner-map-note', textContent: text });
notes.push(new maplibregl.Marker({ element, anchor: 'left', offset: [16, 0] }).setLngLat(coordinate).addTo(map));
}
});
$effect(() => { const routing = routingPackage; if (ready) overlayLayer?.verify(routing); });
$effect(() => {
highlightedCoordinates;
Expand Down Expand Up @@ -724,6 +734,7 @@
:global(.planner-map-pin.highlighted) { outline: 3px solid var(--ink, #1c1b14); outline-offset: 3px; }
:global(.planner-map-pin:hover) { filter: brightness(1.08); }
:global(.planner-map-pin:focus-visible) { outline: 3px solid var(--amber, #f4a81d); outline-offset: 3px; }
:global(.planner-map-note) { padding: 2px 8px; border-radius: 6px; color: var(--ink, #1c1b14); background: var(--panel, #fff); font: 600 12px/20px var(--sans, sans-serif); font-variant-numeric: tabular-nums; white-space: nowrap; box-shadow: var(--planner-shadow, 0 6px 18px rgba(28, 27, 20, .12)); pointer-events: none; }
:global(.planner-hover-dot) { width: 12px; height: 12px; border: 3px solid var(--panel, #fff); border-radius: 50%; background: var(--ink, #1c1b14); pointer-events: none; }
:global(.planner-insert-dot) { width: 14px; height: 14px; border: 2.5px solid var(--route, #cc2a93); border-radius: 50%; background: var(--panel, #fff); pointer-events: none; }
.map-frame :global(.maplibregl-popup-content) { padding: 0; border-radius: 8px; color: var(--ink, #1c1b14); background: var(--panel, white); font-family: var(--sans, sans-serif); box-shadow: var(--planner-shadow, 0 6px 18px rgba(28, 27, 20, .12)); }
Expand Down
65 changes: 65 additions & 0 deletions builder/app/src/components/planner/RainWeeks.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script lang="ts">
import { columnDay, monthColumns } from '../../lib/planner/layers/data-layer';

/** Wet days of 7 per week: the mean line, the band of most years, and the layer week marked. */
let { mean, low, high, week, colors }: {
mean: Float32Array;
low: Float32Array;
high: Float32Array;
week: number;
colors: { line: string; band: string };
} = $props();

const WEEKS = 52, DAYS = 7;
const y = (days: number) => DAYS - Math.min(DAYS, Math.max(0, days));
/** Runs of weeks with data, so a missing week leaves a gap. */
const runs = $derived.by(() => {
const list: number[][] = [];
for (let w = 0; w < WEEKS; w++) {
if (Number.isNaN(mean[w])) continue;
if (w === 0 || Number.isNaN(mean[w - 1])) list.push([]);
list.at(-1)!.push(w);
}
return list;
});
const band = $derived(runs.map(run => [...run.map(w => `${w + 0.5},${y(high[w])}`), ...[...run].reverse().map(w => `${w + 0.5},${y(low[w])}`)].join(' ')));
const line = $derived(runs.map(run => run.map(w => `${w + 0.5},${y(mean[w])}`).join(' ')));
const monthName = (column: number) => columnDay(column, WEEKS).toLocaleDateString('en-GB', { month: 'narrow', timeZone: 'UTC' });
</script>

<figure class="rain-weeks" role="img" aria-label="Wet days of 7 in each week of the year: the mean line and the band of most years, with your week marked">
<ol class="axis" aria-hidden="true"><li>7 days</li><li>0</li></ol>
<div class="plot">
<svg viewBox="0 0 {WEEKS} {DAYS}" preserveAspectRatio="none" aria-hidden="true">
<line class="grid" x1="0" x2={WEEKS} y1={DAYS / 2} y2={DAYS / 2} />
{#each band as points, i (i)}<polygon {points} fill={colors.band} />{/each}
{#each line as points, i (i)}<polyline {points} stroke={colors.line} />{/each}
<line class="week" x1={week + 0.5} x2={week + 0.5} y1="0" y2={DAYS} />
</svg>
<ol class="months" aria-hidden="true">
{#each monthColumns(WEEKS) as column (column)}<li style:left={`${column / WEEKS * 100}%`}>{monthName(column)}</li>{/each}
</ol>
</div>
<ul class="key">
<li><span class="mean" style:background={colors.line}></span>Mean</li>
<li><span class="most" style:background={colors.band}></span>Most years</li>
<li><span class="marker"></span>Your date</li>
</ul>
</figure>

<style>
.rain-weeks { display: grid; grid-template-columns: 48px minmax(0, 1fr); margin: 0; }
.axis { display: flex; flex-direction: column; justify-content: space-between; height: 56px; margin: 0; padding: 0 6px 0 0; list-style: none; text-align: right; font-size: 11px; line-height: 1; color: var(--ink-soft); }
.plot { position: relative; min-width: 0; padding-bottom: 16px; }
svg { display: block; width: 100%; height: 56px; overflow: visible; border-bottom: 1px solid var(--line-strong); }
polyline { fill: none; stroke-width: 2; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; vector-effect: non-scaling-stroke; }
.week { stroke: var(--ink); stroke-width: 2; vector-effect: non-scaling-stroke; }
.months { position: absolute; left: 0; right: 0; bottom: 0; height: 14px; margin: 0; padding: 0; list-style: none; }
.months li { position: absolute; padding-left: 3px; border-left: 1px solid var(--line-strong); font-size: 11px; line-height: 14px; color: var(--ink-soft); }
.key { grid-column: 2; display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 4px 0 0; padding: 0; list-style: none; font-size: 12px; color: var(--ink-soft); }
.key li { display: flex; align-items: center; gap: 6px; }
.mean { width: 14px; height: 2px; }
.most { width: 14px; height: 8px; border-radius: 2px; }
.marker { width: 2px; height: 12px; background: var(--ink); }
</style>
15 changes: 14 additions & 1 deletion builder/app/src/lib/planner/layers/climate-source.ts
Original file line number Diff line number Diff line change
@@ -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<ClimateTile | undefined>;

Expand Down Expand Up @@ -32,6 +32,19 @@ export function climateSource(archive: TileArchive): ClimateSource {
};
}

const opened = new Map<string, Promise<ClimateSource>>();

/** 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<ClimateSource> {
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 }

Expand Down
6 changes: 5 additions & 1 deletion builder/app/src/lib/planner/layers/climate-terrain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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));
Expand Down
35 changes: 19 additions & 16 deletions builder/app/src/lib/planner/layers/climate-terrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,39 @@ 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 };
}

/**
* Heights in metres of the 256 × 256 nearest pixels of the part of a 512 px Terrarium RGBA tile that
* a map tile covers: R × 256 + G + B ÷ 256 − 32768.
*/
/** 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<number>, column: number, row: number): number {
const p = 4 * (row * DEM_TILE + column);
return rgba[p] * 256 + rgba[p + 1] + rgba[p + 2] / 256 - 32768;
}

/** Heights in metres of the 256 × 256 nearest pixels of the part of a 512 px Terrarium RGBA tile that a map tile covers. */
export function cropHeights(rgba: ArrayLike<number>, { scale, left, top }: { scale: number; left: number; top: number }): Float32Array {
const step = DEM_TILE / scale / TILE;
const out = new Float32Array(TILE * TILE);
for (let py = 0; py < TILE; py++) {
const row = (top + Math.floor(py * step)) * DEM_TILE + left;
for (let px = 0; px < TILE; px++) {
const p = 4 * (row + Math.floor(px * step));
out[py * TILE + px] = rgba[p] * 256 + rgba[p + 1] + rgba[p + 2] / 256 - 32768;
}
const row = top + Math.floor(py * step);
for (let px = 0; px < TILE; px++) out[py * TILE + px] = pixelHeight(rgba, left + Math.floor(px * step), row);
}
return out;
}

/**
* Heights in metres of the 256 × 256 pixels of map tile z/x/y, row by row from the north-west pixel;
* undefined where the terrain service has no tile. `template` is the map's Terrarium URL, so the
* browser cache serves tiles that the map has loaded.
* The RGBA pixels of DEM tile z/x/y; undefined where the terrain service has no tile. `template` is
* the map's Terrarium URL, so the browser cache serves tiles that the map has loaded.
*/
export async function tileHeights(template: string, z: number, x: number, y: number, signal?: AbortSignal): Promise<Float32Array | undefined> {
const dem = demTile(z, x, y);
const response = await fetch(template.replace('{z}', String(dem.z)).replace('{x}', String(dem.x)).replace('{y}', String(dem.y)), { signal });
export async function demPixels(template: string, z: number, x: number, y: number, signal?: AbortSignal): Promise<Uint8ClampedArray | undefined> {
const response = await fetch(template.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)), { signal });
if (!response.ok || response.status === 204) return undefined;
// Without these options the browser may convert colours or premultiply, which changes the heights.
const image = await createImageBitmap(await response.blob(), { colorSpaceConversion: 'none', premultiplyAlpha: 'none' });
const context = new OffscreenCanvas(DEM_TILE, DEM_TILE).getContext('2d', { willReadFrequently: true })!;
context.drawImage(image, 0, 0, DEM_TILE, DEM_TILE);
image.close();
return cropHeights(context.getImageData(0, 0, DEM_TILE, DEM_TILE).data, dem);
return context.getImageData(0, 0, DEM_TILE, DEM_TILE).data;
}
Loading
Loading