nearby
This commit is contained in:
@@ -327,8 +327,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
|
||||
trimmed to its available range.
|
||||
{m.ensemble_trimmed({ days: validDays })}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -385,7 +384,9 @@
|
||||
{#snippet controls()}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||
>{m.legend_show()}</Label
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
groupRange,
|
||||
isColumnUnit
|
||||
} from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import {
|
||||
type DaylightBand,
|
||||
type ModelCompareResult,
|
||||
@@ -315,10 +316,10 @@
|
||||
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
||||
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground lg:inline">
|
||||
drag or
|
||||
{m.meteograms_zoom_hint()}
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
|
||||
>
|
||||
+ scroll to zoom
|
||||
{m.meteograms_zoom_hint_end()}
|
||||
</span>
|
||||
{#if zoomActive}
|
||||
<button
|
||||
@@ -336,13 +337,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
||||
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
||||
</svg>
|
||||
Reset zoom
|
||||
{m.reset_zoom()}
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
aria-label="Chart time range"
|
||||
aria-label={m.range_group_aria()}
|
||||
>
|
||||
{#each rangePresets as preset (preset.label)}
|
||||
<button
|
||||
@@ -426,7 +427,9 @@
|
||||
{#snippet controls()}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||
>{m.legend_show()}</Label
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
@@ -436,7 +439,9 @@
|
||||
|
||||
<div class="mt-4 md:mt-8">
|
||||
<div class="flex">
|
||||
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
|
||||
<a href="#models"
|
||||
><h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2></a
|
||||
>
|
||||
{#if params.models && params.models.length > 0}
|
||||
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
|
||||
<div
|
||||
@@ -487,7 +492,7 @@
|
||||
<div class="flex">
|
||||
<a href="#hourly_weather_variables"
|
||||
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
|
||||
Hourly Weather Variables
|
||||
{m.compare_variables_heading()}
|
||||
</h2></a
|
||||
>
|
||||
{#if params.hourly && params.hourly.length > 0}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { formatZoned, getZonedHour } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
|
||||
interface Props {
|
||||
@@ -69,14 +71,14 @@
|
||||
{#if displayModels.length > 0}
|
||||
<div class="mt-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold">Model Comparison Timeline</h3>
|
||||
<h3 class="text-xl font-bold">{m.compare_timeline_title()}</h3>
|
||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<span class="select-none text-muted-foreground">3h</span>
|
||||
<button
|
||||
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
||||
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
||||
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
||||
title="Toggle between 1-hour and 3-hour intervals"
|
||||
title={m.interval_toggle()}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
|
||||
@@ -96,7 +98,7 @@
|
||||
<th
|
||||
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
|
||||
>
|
||||
Model
|
||||
{m.compare_model_label()}
|
||||
</th>
|
||||
{#each filteredIndices as idx, i (idx)}
|
||||
{@const ts = timestamps[idx]}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||
import {
|
||||
storedArchiveModel,
|
||||
storedChartLayout,
|
||||
storedLocation,
|
||||
storedUnits,
|
||||
@@ -13,6 +15,7 @@
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
@@ -22,8 +25,10 @@
|
||||
fetchHistoricalWeather
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { archiveModelGroups, defaultParameters } from '../../options';
|
||||
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
|
||||
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||
import { neededHourlyApiVars } from '../../week/[location]/variables';
|
||||
import DateRangeControls from './DateRangeControls.svelte';
|
||||
import HistoricalDaily from './HistoricalDaily.svelte';
|
||||
@@ -37,6 +42,8 @@
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => result != null);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
let location = $derived(data.location);
|
||||
$effect(() => {
|
||||
storedLocation.set(data.location);
|
||||
@@ -79,6 +86,11 @@
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
let archiveModel = $state('best_match');
|
||||
onMount(() => {
|
||||
archiveModel = get(storedArchiveModel);
|
||||
});
|
||||
|
||||
function onRangeChange(s: string, e: string) {
|
||||
startDate = s;
|
||||
endDate = e;
|
||||
@@ -101,6 +113,8 @@
|
||||
const s = startDate;
|
||||
const e = endDate;
|
||||
const vars = hourlyVars;
|
||||
const model = archiveModel;
|
||||
void model;
|
||||
if (!mounted || !$isSupporter || !loc || !s || !e) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
@@ -203,12 +217,27 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- the reanalysis picker rides in the layout's location row -->
|
||||
{#snippet heroActions()}
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={archiveModel}
|
||||
groups={archiveModelGroups}
|
||||
label={m.model_archive()}
|
||||
onModelChange={(model) => {
|
||||
archiveModel = model;
|
||||
storedArchiveModel.set(model);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Historical weather</title>
|
||||
<title>Drizz.li | {m.page_historical_subtitle()}</title>
|
||||
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
|
||||
</svelte:head>
|
||||
|
||||
<PaywallGate feature="Historical weather">
|
||||
<PaywallGate feature={m.page_historical_subtitle()}>
|
||||
<DateRangeControls
|
||||
start={startDate}
|
||||
end={endDate}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
start: string;
|
||||
end: string;
|
||||
@@ -34,10 +36,10 @@
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ label: '7 days', apply: () => applyLastDays(7) },
|
||||
{ label: '30 days', apply: () => applyLastDays(30) },
|
||||
{ label: '90 days', apply: () => applyLastDays(90) },
|
||||
{ label: 'Month, last year', apply: applyThisMonthLastYear }
|
||||
{ label: () => m.historical_last_days({ days: 7 }), apply: () => applyLastDays(7) },
|
||||
{ label: () => m.historical_last_days({ days: 30 }), apply: () => applyLastDays(30) },
|
||||
{ label: () => m.historical_last_days({ days: 90 }), apply: () => applyLastDays(90) },
|
||||
{ label: () => m.historical_month_last_year(), apply: applyThisMonthLastYear }
|
||||
];
|
||||
|
||||
// ─── Manual inputs ────────────────────────────────────────────────────────────
|
||||
@@ -65,7 +67,9 @@
|
||||
>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="grid gap-1">
|
||||
<label for="hist-start" class="text-xs font-semibold text-muted-foreground">From</label>
|
||||
<label for="hist-start" class="text-xs font-semibold text-muted-foreground"
|
||||
>{m.historical_from()}</label
|
||||
>
|
||||
<input
|
||||
id="hist-start"
|
||||
type="date"
|
||||
@@ -77,7 +81,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<label for="hist-end" class="text-xs font-semibold text-muted-foreground">To</label>
|
||||
<label for="hist-end" class="text-xs font-semibold text-muted-foreground"
|
||||
>{m.historical_to()}</label
|
||||
>
|
||||
<input
|
||||
id="hist-end"
|
||||
type="date"
|
||||
@@ -93,15 +99,15 @@
|
||||
<div
|
||||
class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
aria-label="Quick ranges"
|
||||
aria-label={m.historical_quick_ranges()}
|
||||
>
|
||||
{#each presets as preset (preset.label)}
|
||||
{#each presets as preset (preset.label())}
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||
onclick={preset.apply}
|
||||
>
|
||||
{preset.label}
|
||||
{preset.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { formatZoned, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type HistoricalDailyData,
|
||||
@@ -104,7 +105,7 @@
|
||||
<!-- ─── KPI tiles ────────────────────────────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-2 gap-2.5 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<p class="text-xs font-medium text-muted-foreground">Average temperature</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_avg_temperature()}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums">
|
||||
{fmtTemp(stats.avg)}<span class="text-base font-semibold text-muted-foreground"
|
||||
>{tempUnit.replace('°', '')}</span
|
||||
@@ -118,15 +119,15 @@
|
||||
class:dark:text-red-400={stats.tempAnomaly >= 0}
|
||||
class:dark:text-blue-400={stats.tempAnomaly < 0}
|
||||
>
|
||||
{fmtSigned(stats.tempAnomaly)} vs normal
|
||||
{m.anomaly_vs_normal({ value: fmtSigned(stats.tempAnomaly) })}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">1991–2020 normal loading…</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{m.normals_loading_period()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<p class="text-xs font-medium text-muted-foreground">Total precipitation</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_total_precipitation()}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtPrecip(stats.totalPrecip)}</p>
|
||||
{#if stats.normalPrecip != null}
|
||||
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||
@@ -140,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<p class="text-xs font-medium text-muted-foreground">Warmest day</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_warmest_day()}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.warm.t)}</p>
|
||||
{#if stats.warm.i >= 0}
|
||||
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||
@@ -150,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<p class="text-xs font-medium text-muted-foreground">Coldest day</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_coldest_day()}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.cold.t)}</p>
|
||||
{#if stats.cold.i >= 0}
|
||||
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||
@@ -166,12 +167,12 @@
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
||||
<h3 class="text-base font-bold">
|
||||
Daily <span class="font-semibold text-muted-foreground">– select a day for hourly detail</span
|
||||
>
|
||||
{m.historical_daily_heading()}
|
||||
<span class="font-semibold text-muted-foreground">{m.historical_daily_hint()}</span>
|
||||
</h3>
|
||||
{#if normals}
|
||||
<span class="hidden text-xs text-muted-foreground sm:inline">
|
||||
normal band = 1991–2020 mean
|
||||
{m.normal_band_legend()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -242,7 +243,7 @@
|
||||
<span
|
||||
class="mt-0.5 inline-block h-1.5 w-6 rounded-full"
|
||||
style="background:{anomalyColor(anomaly)}"
|
||||
title="{fmtSigned(anomaly)} vs normal"
|
||||
title={m.anomaly_vs_normal({ value: fmtSigned(anomaly) })}
|
||||
></span>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||
|
||||
import { CanvasChart, groupRange } from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { type FetchedHourly, type WeatherUnits } from '../../week/[location]/types';
|
||||
@@ -121,22 +122,23 @@
|
||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-bold">
|
||||
Meteograms <span class="font-semibold text-muted-foreground">– full range</span>
|
||||
{m.meteograms_heading()}
|
||||
<span class="font-semibold text-muted-foreground">{m.historical_full_range()}</span>
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||
drag or
|
||||
{m.meteograms_zoom_hint()}
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
+ scroll to zoom
|
||||
{m.meteograms_zoom_hint_end()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={liveCharts.length === 0 || downloadingPng}
|
||||
onclick={downloadPng}
|
||||
title="Download meteogram as PNG image"
|
||||
title={m.chart_download()}
|
||||
>
|
||||
{#if downloadingPng}Rendering…{:else}PNG{/if}
|
||||
</button>
|
||||
@@ -148,7 +150,7 @@
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
||||
onclick={resetZoom}
|
||||
>
|
||||
Reset zoom
|
||||
{m.reset_zoom()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -158,7 +160,7 @@
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No meteograms configured. Add variables from the 7-day forecast page.
|
||||
{m.meteograms_none_historical()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { mapsDomainForModel } from '$lib/utils/maps-domain';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Hash piping, both directions:
|
||||
// - inbound: a #zoom/lat/lng(/bearing/pitch) hash on OUR url seeds the
|
||||
// map, so positions can be bookmarked/shared via drizzli links
|
||||
@@ -74,7 +76,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Weather Map | Open-Meteo.com</title>
|
||||
<title>{m.page_maps_title()} | Open-Meteo.com</title>
|
||||
<link rel="canonical" href="https://open-meteo.com/weather/maps" />
|
||||
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
|
||||
</svelte:head>
|
||||
@@ -88,7 +90,7 @@
|
||||
<iframe
|
||||
bind:this={iframeEl}
|
||||
src={iframeSrc}
|
||||
title="Open-Meteo Interactive Map"
|
||||
title={m.maps_iframe_title()}
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
allow="cross-origin-isolated"
|
||||
|
||||
@@ -513,3 +513,48 @@ export function inDomainCity(model: string): { slug: string; label: string } | n
|
||||
const hit = IN_DOMAIN_CITY.find(([prefix]) => model.startsWith(prefix));
|
||||
return hit ? hit[1] : null;
|
||||
}
|
||||
|
||||
/** Reanalyses the archive API accepts (verified against archive-api.open-meteo.com). */
|
||||
export const archiveModelGroups: WeatherModelGroup[] = [
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Automatic',
|
||||
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'daily' }]
|
||||
},
|
||||
{
|
||||
value: 'era5',
|
||||
label: 'ECMWF reanalysis',
|
||||
models: [
|
||||
{ value: 'era5_seamless', label: 'ERA5 seamless', resolution: '9-25 km', update: 'daily' },
|
||||
{ value: 'era5', label: 'ERA5', resolution: '25 km', update: 'daily' },
|
||||
{ value: 'era5_land', label: 'ERA5-Land', resolution: '9 km', update: 'daily' },
|
||||
{ value: 'ecmwf_ifs', label: 'ECMWF IFS', resolution: '9 km', update: 'daily' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'regional',
|
||||
label: 'Regional reanalysis',
|
||||
models: [{ value: 'cerra', label: 'CERRA (Europe)', resolution: '5 km', update: 'daily' }]
|
||||
}
|
||||
];
|
||||
|
||||
/** Seasonal models the seasonal API accepts. */
|
||||
export const seasonalModelGroups: WeatherModelGroup[] = [
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Automatic',
|
||||
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'monthly' }]
|
||||
},
|
||||
{
|
||||
value: 'ecmwf',
|
||||
label: 'ECMWF',
|
||||
models: [
|
||||
{
|
||||
value: 'ecmwf_seasonal_seamless',
|
||||
label: 'ECMWF SEAS5',
|
||||
resolution: '36 km',
|
||||
update: 'monthly'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||
import { storedLocation, storedUnits } from '$lib/stores/settings';
|
||||
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
@@ -19,7 +21,8 @@
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { defaultParameters } from '../../options';
|
||||
import { defaultParameters, seasonalModelGroups } from '../../options';
|
||||
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||
import { getPrecipUnit } from '../../week/[location]/types';
|
||||
import SeasonalCharts from './SeasonalCharts.svelte';
|
||||
import SeasonalMonths from './SeasonalMonths.svelte';
|
||||
@@ -79,7 +82,9 @@
|
||||
let result = $state<SeasonalForecastResult | null>(null);
|
||||
let normals = $state<ClimateNormals | null>(null);
|
||||
|
||||
let seasonalModel = $state('best_match');
|
||||
onMount(() => {
|
||||
seasonalModel = get(storedSeasonalModel);
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
@@ -89,6 +94,7 @@
|
||||
const loc = location;
|
||||
const tempUnit = params.temperature_unit;
|
||||
const precipUnit = params.precipitation_unit;
|
||||
const model = seasonalModel;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
@@ -99,6 +105,7 @@
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
dailyVariables: SEASONAL_VARS,
|
||||
model,
|
||||
temperature_unit: tempUnit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: precipUnit as 'mm' | 'inch',
|
||||
@@ -173,7 +180,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Seasonal forecast</title>
|
||||
<title>Drizz.li | {m.page_seasonal_subtitle()}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
|
||||
@@ -182,32 +189,43 @@
|
||||
|
||||
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
|
||||
{#snippet heroActions()}
|
||||
{#if result}
|
||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
role="group"
|
||||
aria-label="Outlook range"
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
|
||||
{#if result}
|
||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
role="group"
|
||||
aria-label={m.seasonal_range_aria()}
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ModelSelector
|
||||
selectedModel={seasonalModel}
|
||||
groups={seasonalModelGroups}
|
||||
label={m.model_seasonal()}
|
||||
onModelChange={(model) => {
|
||||
seasonalModel = model;
|
||||
storedSeasonalModel.set(model);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- What a seasonal forecast is (and is not): without this the daily-looking
|
||||
@@ -226,17 +244,16 @@
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<p class="text-muted-foreground">
|
||||
A seasonal forecast shows how a whole month is likely to
|
||||
<strong class="font-semibold text-foreground">depart from its climate normal</strong> - not the
|
||||
weather on any given day. Read the monthly trend and the ensemble agreement, not the daily
|
||||
wiggles.
|
||||
{m.seasonal_explainer_before()}
|
||||
<strong class="font-semibold text-foreground">{m.seasonal_explainer_strong()}</strong>
|
||||
{m.seasonal_explainer_after()}
|
||||
{#if lastDayLabel}
|
||||
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
|
||||
{m.seasonal_runs_to({ date: lastDayLabel })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaywallGate feature="The seasonal outlook">
|
||||
<PaywallGate feature={m.page_seasonal_subtitle()}>
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
@@ -265,7 +282,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||
>Show legend</Label
|
||||
>{m.legend_show()}</Label
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getColor } from '../../utils/colors';
|
||||
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||
|
||||
@@ -50,7 +52,7 @@
|
||||
<!-- headline: mean temperature and its departure from the 1991-2020 normal -->
|
||||
<div class="mt-2.5 flex items-end gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">Mean temperature</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_mean_temperature()}</p>
|
||||
<p class="text-2xl leading-tight font-bold tabular-nums">
|
||||
{fmtTemp(month.tMean)}<span class="text-base font-semibold text-muted-foreground"
|
||||
>{tempUnit.replace('°', '')}</span
|
||||
@@ -70,9 +72,9 @@
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground">{anomalyLabel(month.anomaly)}</p>
|
||||
{:else if normals}
|
||||
<p class="text-[11px] text-muted-foreground">no normal for this month</p>
|
||||
<p class="text-[11px] text-muted-foreground">{m.seasonal_no_normal()}</p>
|
||||
{:else}
|
||||
<p class="text-[11px] text-muted-foreground">normal loading…</p>
|
||||
<p class="text-[11px] text-muted-foreground">{m.normals_loading()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +87,7 @@
|
||||
style="background:{getColor(month.tMax, units.temperature_unit)}"
|
||||
></span>
|
||||
<span class="font-semibold tabular-nums">{fmtTemp(month.tMax)}</span>
|
||||
<span class="text-muted-foreground">day</span>
|
||||
<span class="text-muted-foreground">{m.label_day()}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
@@ -93,14 +95,14 @@
|
||||
style="background:{getColor(month.tMin, units.temperature_unit)}"
|
||||
></span>
|
||||
<span class="font-semibold tabular-nums">{fmtTemp(month.tMin)}</span>
|
||||
<span class="text-muted-foreground">night</span>
|
||||
<span class="text-muted-foreground">{m.label_night()}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- precipitation against its own normal, as a share bar -->
|
||||
<div class="mt-3 border-t border-border/60 pt-2.5">
|
||||
<div class="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span class="font-medium text-muted-foreground">Precipitation</span>
|
||||
<span class="font-medium text-muted-foreground">{m.var_precipitation()}</span>
|
||||
<span class="font-semibold tabular-nums">
|
||||
{fmtPrecip(month.precip)}
|
||||
{precipUnit}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import HourlyTable from './HourlyTable.svelte';
|
||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||
import ModelSelector from './ModelSelector.svelte';
|
||||
import NearbyCities from './NearbyCities.svelte';
|
||||
import VariableSidebar from './VariableSidebar.svelte';
|
||||
import { neededHourlyApiVars } from './variables';
|
||||
|
||||
@@ -326,7 +327,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Weather</title>
|
||||
<title>Drizz.li | {m.page_week_title()}</title>
|
||||
<link rel="canonical" href="https://drizz.li/weather/week" />
|
||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||
</svelte:head>
|
||||
@@ -362,7 +363,7 @@
|
||||
class="cursor-pointer rounded-md border border-destructive/40 bg-background px-3 py-1 text-xs font-semibold text-destructive transition-colors hover:bg-destructive/10"
|
||||
onclick={() => retryNonce++}
|
||||
>
|
||||
Try again
|
||||
{m.action_try_again()}
|
||||
</button>
|
||||
{#if suggestedCity}
|
||||
<a
|
||||
@@ -383,7 +384,7 @@
|
||||
</div>
|
||||
{#if loadError.detail}
|
||||
<details class="mt-2 text-xs text-destructive/70">
|
||||
<summary class="cursor-pointer select-none">Technical details</summary>
|
||||
<summary class="cursor-pointer select-none">{m.error_technical_details()}</summary>
|
||||
<p class="mt-1 font-mono break-all">{loadError.detail}</p>
|
||||
</details>
|
||||
{/if}
|
||||
@@ -535,6 +536,15 @@
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if selectedDayKey}
|
||||
<NearbyCities
|
||||
latitude={location.latitude}
|
||||
longitude={location.longitude}
|
||||
{selectedDayKey}
|
||||
units={$storedUnits}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,15 +152,15 @@
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 class="text-base font-bold">Customize meteograms</h2>
|
||||
<h2 class="text-base font-bold">{m.customize_meteograms()}</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Drag variables between charts to build your own layout.
|
||||
{m.customizer_intro()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={m.action_close()}
|
||||
>
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
@@ -212,12 +212,12 @@
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold tracking-wider text-primary uppercase"
|
||||
>Chart {i + 1}</span
|
||||
>{m.customizer_chart_n({ number: i + 1 })}</span
|
||||
>
|
||||
<button
|
||||
class="cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||
onclick={() => deletePanel(panel.id)}
|
||||
aria-label="Delete chart {i + 1}"
|
||||
aria-label={m.customizer_delete_chart({ number: i + 1 })}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -238,7 +238,7 @@
|
||||
data-chip
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Drag {def.label}"
|
||||
aria-label={m.customizer_drag({ variable: def.label })}
|
||||
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background py-1.5 pr-1 pl-2.5 text-sm shadow-sm select-none active:cursor-grabbing {dragKey ===
|
||||
key
|
||||
? 'opacity-30'
|
||||
@@ -257,7 +257,7 @@
|
||||
class="ml-0.5 flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onclick={() => removeVar(key)}
|
||||
aria-label="Remove {def.label}"
|
||||
aria-label={m.customizer_remove({ variable: def.label })}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
@@ -274,7 +274,7 @@
|
||||
{/each}
|
||||
{#if panel.variables.length === 0}
|
||||
<span class="self-center text-xs text-muted-foreground italic"
|
||||
>Drop variables here</span
|
||||
>{m.customizer_drop_here()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -285,12 +285,12 @@
|
||||
class="w-full cursor-pointer rounded-xl border-2 border-dashed border-border py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
onclick={addPanel}
|
||||
>
|
||||
+ Add chart
|
||||
{m.customizer_add_chart()}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||
Available variables
|
||||
{m.customizer_available()}
|
||||
</h3>
|
||||
<div
|
||||
data-zone="pool"
|
||||
@@ -304,7 +304,7 @@
|
||||
data-chip
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Drag {def.label}"
|
||||
aria-label={m.customizer_drag({ variable: def.label })}
|
||||
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm text-muted-foreground shadow-sm select-none active:cursor-grabbing {dragKey ===
|
||||
def.key
|
||||
? 'opacity-30'
|
||||
@@ -321,7 +321,7 @@
|
||||
{/each}
|
||||
{#if availableVars.length === 0}
|
||||
<span class="self-center text-xs text-muted-foreground italic"
|
||||
>All variables are in use</span
|
||||
>{m.customizer_all_in_use()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -333,13 +333,13 @@
|
||||
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
onclick={resetLayout}
|
||||
>
|
||||
Reset to defaults
|
||||
{m.action_reset_defaults()}
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer rounded-lg bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||
onclick={onClose}
|
||||
>
|
||||
Done
|
||||
{m.action_done()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
|
||||
@@ -118,7 +120,7 @@
|
||||
type="button"
|
||||
class="mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
onclick={onExtendPast}
|
||||
aria-label="Load recent past days"
|
||||
aria-label={m.strip_past_aria()}
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
@@ -135,7 +137,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 13l-3 3 3 3" />
|
||||
</svg>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||
Past<br />3 days
|
||||
{m.daycards_past()}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -317,7 +319,7 @@
|
||||
type="button"
|
||||
class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:hidden"
|
||||
onclick={onExtend}
|
||||
aria-label="Load the longer-range forecast"
|
||||
aria-label={m.strip_extend_aria()}
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
@@ -334,7 +336,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||
</svg>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||
Load<br />15 days
|
||||
{m.daycards_load_15()}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -349,7 +351,7 @@
|
||||
type="button"
|
||||
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
|
||||
onclick={onExtend}
|
||||
aria-label="Load the longer-range forecast"
|
||||
aria-label={m.strip_extend_aria()}
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
@@ -365,7 +367,8 @@
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||
</svg>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold"> Load<br />15 days </span>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold">{m.daycards_load_15()}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
<button
|
||||
class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
onclick={onCustomize}
|
||||
aria-label="Customize variables"
|
||||
aria-label={m.table_customize()}
|
||||
>
|
||||
<!-- sliders icon -->
|
||||
<svg
|
||||
@@ -466,13 +466,13 @@
|
||||
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Variables</span>
|
||||
<span class="hidden sm:inline">{m.hourly_variables()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
||||
role="group"
|
||||
aria-label="Hourly interval"
|
||||
aria-label={m.table_interval_aria()}
|
||||
>
|
||||
{#each [3, 1] as interval (interval)}
|
||||
<button
|
||||
@@ -584,7 +584,7 @@
|
||||
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
|
||||
style="left:{nowPercent}%"
|
||||
>
|
||||
Now
|
||||
{m.table_now()}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Hour labels -->
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||
drag or
|
||||
{m.meteograms_zoom_hint()}
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
@@ -273,7 +273,7 @@
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={liveCharts.length === 0 || downloadingPng}
|
||||
onclick={downloadPng}
|
||||
title="Download meteogram as PNG image"
|
||||
title={m.chart_download()}
|
||||
>
|
||||
{#if downloadingPng}
|
||||
<svg
|
||||
@@ -330,9 +330,10 @@
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No meteograms configured, <button
|
||||
{m.meteograms_none_before()}
|
||||
<button
|
||||
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
|
||||
onclick={() => (customizerOpen = true)}>add some variables</button
|
||||
onclick={() => (customizerOpen = true)}>{m.meteograms_none_action()}</button
|
||||
>.
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
aria-label="{label} selection"
|
||||
aria-label={m.model_selector_aria({ label })}
|
||||
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
|
||||
>
|
||||
<div
|
||||
@@ -99,7 +99,9 @@
|
||||
{mo.resolution}{mo.update ? ` · updated ${mo.update}` : ''}
|
||||
</span>
|
||||
{:else if mo.value === 'best_match'}
|
||||
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
|
||||
<span class="text-[11px] text-muted-foreground"
|
||||
>{m.model_automatic_selection()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</Select.Item>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
|
||||
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
|
||||
import type { UnitPrefs } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
/** "yyyy-MM-dd" of the day the rest of the page is showing */
|
||||
selectedDayKey: string;
|
||||
units: UnitPrefs;
|
||||
}
|
||||
|
||||
let { latitude, longitude, selectedDayKey, units }: Props = $props();
|
||||
|
||||
const COUNT = 10;
|
||||
|
||||
let cities = $state<NearbyCity[]>([]);
|
||||
let daily = $state<(NearbyDaily | null)[]>([]);
|
||||
let failed = $state(false);
|
||||
|
||||
// Country names come free and localized from the platform; the flag images
|
||||
// are the same set the header uses.
|
||||
let countryNames = $derived(new Intl.DisplayNames([getLocale()], { type: 'region' }));
|
||||
const countryName = (code: string) => {
|
||||
try {
|
||||
return countryNames.of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
// One request covers the whole strip's date range, so clicking through the
|
||||
// days re-reads what is already here instead of refetching ten cities.
|
||||
$effect(() => {
|
||||
const lat = latitude;
|
||||
const lon = longitude;
|
||||
const unitPrefs = { ...units };
|
||||
let cancelled = false;
|
||||
|
||||
cities = [];
|
||||
daily = [];
|
||||
failed = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const found = await findNearbyCities(lat, lon, COUNT);
|
||||
if (cancelled) return;
|
||||
cities = found;
|
||||
|
||||
const snapshots = await fetchNearbyDaily({ points: found, ...unitPrefs });
|
||||
if (cancelled) return;
|
||||
daily = snapshots;
|
||||
} catch {
|
||||
if (!cancelled) failed = true;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const dayFor = (index: number) => daily[index]?.byDate[selectedDayKey];
|
||||
|
||||
const temp = (value: number | undefined) =>
|
||||
value == null || !Number.isFinite(value) ? '–' : `${Math.round(value)}°`;
|
||||
|
||||
const distance = (km: number) =>
|
||||
units.wind_speed_unit === 'mph' ? `${Math.round(km * 0.621371)} mi` : `${Math.round(km)} km`;
|
||||
</script>
|
||||
|
||||
{#if cities.length > 0 && !failed}
|
||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold">{m.nearby_cities_title()}</h2>
|
||||
<p class="text-xs text-muted-foreground">{m.nearby_cities_subtitle()}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="-mx-3 grid grid-cols-2 gap-px overflow-hidden border-y border-border/70 bg-border/70 sm:grid-cols-3 md:mx-0 md:rounded-2xl md:border lg:grid-cols-5"
|
||||
>
|
||||
{#each cities as city, i (city.id)}
|
||||
{@const day = dayFor(i)}
|
||||
<a
|
||||
href={href('/weather/week/[location]', {
|
||||
location: buildLocationRoute({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
latitude: city.latitude,
|
||||
longitude: city.longitude,
|
||||
population: city.population,
|
||||
feature_code: 'PPL'
|
||||
})
|
||||
})}
|
||||
class="group flex items-center gap-2 bg-card px-3 py-2.5 transition-colors hover:bg-muted/60"
|
||||
title="{city.name}, {countryName(city.countryCode)}"
|
||||
>
|
||||
<svg class="shrink-0 fill-foreground/80" width="34px" height="34px">
|
||||
{#if day}
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
day.weatherCode,
|
||||
true
|
||||
)}.svg#Layer_1"
|
||||
></use>
|
||||
{/if}
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium group-hover:underline">{city.name}</div>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{distance(city.distanceKm)} · {countryName(city.countryCode)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-sm tabular-nums">
|
||||
<div class="font-semibold">{temp(day?.max)}</div>
|
||||
<div class="text-[11px] text-muted-foreground">{temp(day?.min)}</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -12,6 +12,8 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -81,16 +83,16 @@
|
||||
<aside
|
||||
class="absolute inset-y-0 right-0 flex w-80 max-w-[90vw] flex-col overflow-y-auto border-l border-border bg-card shadow-xl"
|
||||
transition:fly={{ x: 320, duration: 200, opacity: 1 }}
|
||||
aria-label="Variable selection"
|
||||
aria-label={m.variables_aria()}
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 flex items-center justify-between border-b border-border bg-card px-5 py-4"
|
||||
>
|
||||
<h2 class="text-base font-bold">Variables</h2>
|
||||
<h2 class="text-base font-bold">{m.hourly_variables()}</h2>
|
||||
<button
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={onClose}
|
||||
aria-label="Close variable selection"
|
||||
aria-label={m.variables_close()}
|
||||
>
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
@@ -107,7 +109,7 @@
|
||||
<div class="flex flex-1 flex-col gap-6 px-5 py-4">
|
||||
<section>
|
||||
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
|
||||
Hourly table
|
||||
{m.variables_table_section()}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tableRowOrder as key, i (key)}
|
||||
@@ -129,7 +131,7 @@
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
onclick={() => moveRow(key, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label="Move {tableVariableLabels[key] ?? key} up"
|
||||
aria-label={m.variables_move_up({ variable: tableVariableLabels[key] ?? key })}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
@@ -145,7 +147,9 @@
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
onclick={() => moveRow(key, 1)}
|
||||
disabled={i === tableRowOrder.length - 1}
|
||||
aria-label="Move {tableVariableLabels[key] ?? key} down"
|
||||
aria-label={m.variables_move_down({
|
||||
variable: tableVariableLabels[key] ?? key
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
@@ -164,8 +168,9 @@
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Meteogram variables are configured with the <span class="font-semibold">Customize</span>
|
||||
button above the charts.
|
||||
{m.variables_charts_hint_before()}
|
||||
<span class="font-semibold">{m.meteograms_customize()}</span>
|
||||
{m.variables_charts_hint_after()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +179,7 @@
|
||||
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
onclick={resetDefaults}
|
||||
>
|
||||
Reset to defaults
|
||||
{m.action_reset_defaults()}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user