move settings to one icon mobile
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isPremium } from '$lib/paywall/premium';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type HistoricalForecastResult,
|
||||
@@ -97,7 +97,7 @@
|
||||
const s = startDate;
|
||||
const e = endDate;
|
||||
const vars = hourlyVars;
|
||||
if (!mounted || !$isPremium || !loc || !s || !e) return;
|
||||
if (!mounted || !$isSupporter || !loc || !s || !e) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
@@ -138,7 +138,7 @@
|
||||
$effect(() => {
|
||||
const key = normalsKey;
|
||||
const loc = location;
|
||||
if (!mounted || !$isPremium || !loc) return;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++normalsVersion;
|
||||
normals = null;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
// at build time this page knows nothing about the visitor, so the redirect
|
||||
// target (the persisted location) is resolved in the browser instead of
|
||||
// being baked to the default city during prerender
|
||||
onMount(() => {
|
||||
goto(
|
||||
resolve('/weather/seasonal/[location]', {
|
||||
location: buildLocationRoute(get(storedLocation))
|
||||
}),
|
||||
{ replaceState: true }
|
||||
);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedLocation, 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 PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type SeasonalForecastResult,
|
||||
fetchClimateNormals,
|
||||
fetchSeasonalForecast
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
import { getPrecipUnit } from '../../week/[location]/types';
|
||||
import SeasonalCharts from './SeasonalCharts.svelte';
|
||||
import SeasonalMonths from './SeasonalMonths.svelte';
|
||||
import { buildMonthOutlooks, sliceSeasonal } from './outlook';
|
||||
|
||||
import type { CanvasChart } from '$lib/charts';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the URL is the source of truth: location comes from the load function,
|
||||
// which is also correct on hydrated prerendered pages. The persisted store
|
||||
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||
let location = $derived(data.location);
|
||||
$effect(() => {
|
||||
storedLocation.set(data.location);
|
||||
});
|
||||
|
||||
let params = $state({ ...defaultParameters });
|
||||
$effect(() => {
|
||||
params.temperature_unit = $storedUnits.temperature_unit;
|
||||
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||
});
|
||||
|
||||
// Only the variables the outlook actually renders: every extra one costs a
|
||||
// full member set (50+ series) over half a year of days.
|
||||
const SEASONAL_VARS = [
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'temperature_2m_mean',
|
||||
'precipitation_sum'
|
||||
];
|
||||
|
||||
// ─── Display state (no refetch) ─────────────────────────────────────────────
|
||||
const RANGES = [
|
||||
{ label: '3 months', days: 92 },
|
||||
{ label: '6 months', days: 183 },
|
||||
{ label: 'Full range', days: Infinity }
|
||||
];
|
||||
let rangeIndex = $state(1);
|
||||
let showLegend = $state(true);
|
||||
let chartComponents: CanvasChart[] = $state([]);
|
||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||
|
||||
// ─── Fetch state ────────────────────────────────────────────────────────────
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let requestVersion = 0;
|
||||
|
||||
let result = $state<SeasonalForecastResult | null>(null);
|
||||
let normals = $state<ClimateNormals | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
// The full horizon is fetched once per location/units; the range buttons only
|
||||
// reslice it.
|
||||
$effect(() => {
|
||||
const loc = location;
|
||||
const tempUnit = params.temperature_unit;
|
||||
const precipUnit = params.precipitation_unit;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
loadError = null;
|
||||
|
||||
fetchSeasonalForecast({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
dailyVariables: SEASONAL_VARS,
|
||||
temperature_unit: tempUnit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: precipUnit as 'mm' | 'inch',
|
||||
timezone: loc.timezone
|
||||
})
|
||||
.then((r) => {
|
||||
if (version !== requestVersion) return;
|
||||
result = r;
|
||||
loading = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (version !== requestVersion) return;
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Normals are the whole point of a seasonal outlook (everything is shown as a
|
||||
// departure from them), but a failure only drops the comparison.
|
||||
let normalsKey = $derived(
|
||||
`${location?.latitude},${location?.longitude},${params.temperature_unit},${params.precipitation_unit}`
|
||||
);
|
||||
let normalsVersion = 0;
|
||||
$effect(() => {
|
||||
const key = normalsKey;
|
||||
const loc = location;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++normalsVersion;
|
||||
normals = null;
|
||||
fetchClimateNormals({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
|
||||
})
|
||||
.then((n) => {
|
||||
if (version === normalsVersion) normals = n;
|
||||
})
|
||||
.catch(() => {
|
||||
if (version === normalsVersion) normals = null;
|
||||
});
|
||||
void key;
|
||||
});
|
||||
|
||||
// ─── Derived view ───────────────────────────────────────────────────────────
|
||||
let visible = $derived(result ? sliceSeasonal(result, RANGES[rangeIndex].days) : null);
|
||||
|
||||
// 1 mm and 0.04 in are the same "measurable rain" threshold in either unit.
|
||||
let wetDayThreshold = $derived(getPrecipUnit(params) === 'in' ? 0.04 : 1);
|
||||
|
||||
let months = $derived(visible ? buildMonthOutlooks(visible, normals, { wetDayThreshold }) : []);
|
||||
|
||||
let horizonDays = $derived(result?.timestamps.length ?? 0);
|
||||
let lastDay = $derived(
|
||||
visible && visible.dailyDates.length > 0
|
||||
? visible.dailyDates[visible.dailyDates.length - 1]
|
||||
: null
|
||||
);
|
||||
// dailyDates are local wall time held as UTC instants (see the service), so
|
||||
// the label has to be formatted in UTC to read back the local date.
|
||||
let lastDayLabel = $derived(
|
||||
lastDay
|
||||
? lastDay.toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Seasonal forecast</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Page hero -->
|
||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||
alt={location.country ?? ''}
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||
{location.name}
|
||||
</h1>
|
||||
<p class="truncate text-sm text-muted-foreground">
|
||||
<span class="lg:hidden"
|
||||
>{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||
>Seasonal outlook
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
|
||||
<!-- What a seasonal forecast is (and is not): without this the daily-looking
|
||||
charts invite over-reading. -->
|
||||
<div
|
||||
class="mb-4 flex items-start gap-2.5 rounded-md border border-border bg-muted/40 px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16v-4m0-4h.01" />
|
||||
<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.
|
||||
{#if lastDayLabel}
|
||||
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaywallGate feature="The seasonal outlook">
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
Failed to load the seasonal outlook: {loadError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visible && months.length > 0}
|
||||
<SeasonalMonths {months} units={params} {normals} />
|
||||
|
||||
<div class="mt-6">
|
||||
<SeasonalCharts
|
||||
result={visible}
|
||||
{normals}
|
||||
units={params}
|
||||
{loading}
|
||||
{showLegend}
|
||||
bind:charts={chartComponents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
|
||||
{#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
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
</div>
|
||||
{:else if !loadError}
|
||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
||||
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each [0, 1, 2, 3, 4, 5] as i (i)}
|
||||
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</PaywallGate>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { resolveLocationFromRoute } from '$lib/utils/location';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async (event) => {
|
||||
const location = await resolveLocationFromRoute({
|
||||
urlLocation: event.params.location,
|
||||
routePrefix: '/weather/seasonal/',
|
||||
event
|
||||
});
|
||||
|
||||
return { location };
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import { CHART_COLORS, CanvasChart, type ChartSeries } from '$lib/charts';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type SeasonalForecastResult,
|
||||
monthDayToOrdinal
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||
import { rollingMean, rollingSum } from './outlook';
|
||||
|
||||
interface Props {
|
||||
result: SeasonalForecastResult;
|
||||
normals: ClimateNormals | null;
|
||||
units: WeatherUnits;
|
||||
loading?: boolean;
|
||||
showLegend?: boolean;
|
||||
/** Smoothing window in days, shared by both charts. */
|
||||
smoothing?: number;
|
||||
charts?: CanvasChart[];
|
||||
}
|
||||
|
||||
let {
|
||||
result,
|
||||
normals,
|
||||
units,
|
||||
loading = false,
|
||||
showLegend = true,
|
||||
smoothing = 7,
|
||||
charts = $bindable([])
|
||||
}: Props = $props();
|
||||
|
||||
const CHART_GROUP = 'seasonal-outlook';
|
||||
const SPREAD_COLOR = 'rgba(115, 192, 222, 0.75)';
|
||||
const BAND_COLOR = 'rgba(56, 132, 189, 0.9)';
|
||||
const NORMAL_COLOR = '#9ca3af';
|
||||
|
||||
const tempUnit = $derived(getTempUnit(units));
|
||||
const precipUnit = $derived(getPrecipUnit(units));
|
||||
|
||||
// CanvasChart works in epoch seconds. The axis is fed local wall time (and
|
||||
// labelled in UTC) because the seasonal series carries a single fixed offset:
|
||||
// resolving it against the IANA zone would slide every date past a DST change.
|
||||
let timestamps = $derived(result.dailyDates.map((d) => d.getTime() / 1000));
|
||||
|
||||
// Climate normal for each forecast day, so it can be drawn as a reference
|
||||
// line on the same axis as the ensemble.
|
||||
let ordinals = $derived(
|
||||
result.dateKeys.map((key) =>
|
||||
monthDayToOrdinal(Number(key.slice(5, 7)), Number(key.slice(8, 10)))
|
||||
)
|
||||
);
|
||||
let normalTemp = $derived(normals ? ordinals.map((o) => normals.tmean[o]) : null);
|
||||
let normalPrecip = $derived(normals ? ordinals.map((o) => normals.precip[o]) : null);
|
||||
|
||||
const fmt =
|
||||
(unit: string, digits = 1) =>
|
||||
(v: number) =>
|
||||
`${v.toFixed(digits)} ${unit}`;
|
||||
|
||||
let tempSeries = $derived.by((): ChartSeries[] => {
|
||||
const v = result.variables['temperature_2m_mean'];
|
||||
if (!v) return [];
|
||||
const smooth = (xs: number[]) => rollingMean(xs, smoothing);
|
||||
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
name: 'Full spread',
|
||||
type: 'line',
|
||||
color: SPREAD_COLOR,
|
||||
data: smooth(v.max),
|
||||
width: 0,
|
||||
fill: true,
|
||||
fillOpacity: 0.16,
|
||||
bandTo: smooth(v.min),
|
||||
shortName: 'spread',
|
||||
format: fmt(tempUnit)
|
||||
},
|
||||
{
|
||||
name: 'Likely range (25-75%)',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: smooth(v.p75),
|
||||
width: 0,
|
||||
fill: true,
|
||||
fillOpacity: 0.3,
|
||||
bandTo: smooth(v.p25),
|
||||
shortName: 'likely',
|
||||
format: fmt(tempUnit)
|
||||
},
|
||||
{
|
||||
name: 'Ensemble mean',
|
||||
type: 'line',
|
||||
color: CHART_COLORS.average,
|
||||
data: smooth(v.mean),
|
||||
width: 3,
|
||||
outline: true,
|
||||
format: fmt(tempUnit)
|
||||
}
|
||||
];
|
||||
|
||||
if (normalTemp) {
|
||||
series.push({
|
||||
name: 'Climate normal',
|
||||
type: 'line',
|
||||
color: NORMAL_COLOR,
|
||||
data: smooth(normalTemp),
|
||||
width: 2,
|
||||
dashed: true,
|
||||
shortName: 'normal',
|
||||
format: fmt(tempUnit)
|
||||
});
|
||||
}
|
||||
|
||||
return series;
|
||||
});
|
||||
|
||||
let precipSeries = $derived.by((): ChartSeries[] => {
|
||||
const v = result.variables['precipitation_sum'];
|
||||
if (!v) return [];
|
||||
const total = (xs: number[]) => rollingSum(xs, smoothing);
|
||||
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
name: 'Likely range (25-75%)',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: total(v.p75),
|
||||
width: 0,
|
||||
fill: true,
|
||||
fillOpacity: 0.28,
|
||||
bandTo: total(v.p25),
|
||||
shortName: 'likely',
|
||||
format: fmt(precipUnit)
|
||||
},
|
||||
{
|
||||
name: 'Ensemble mean',
|
||||
type: 'line',
|
||||
color: CHART_COLORS.average,
|
||||
data: total(v.mean),
|
||||
width: 3,
|
||||
outline: true,
|
||||
format: fmt(precipUnit)
|
||||
}
|
||||
];
|
||||
|
||||
if (normalPrecip) {
|
||||
series.push({
|
||||
name: 'Climate normal',
|
||||
type: 'line',
|
||||
color: NORMAL_COLOR,
|
||||
data: total(normalPrecip),
|
||||
width: 2,
|
||||
dashed: true,
|
||||
shortName: 'normal',
|
||||
format: fmt(precipUnit)
|
||||
});
|
||||
}
|
||||
|
||||
return series;
|
||||
});
|
||||
|
||||
let chartDefs = $derived(
|
||||
[
|
||||
{
|
||||
title: 'Temperature outlook',
|
||||
subtitle: `${smoothing}-day smoothed daily mean · ${result.memberCount} ensemble members`,
|
||||
unit: tempUnit,
|
||||
series: tempSeries,
|
||||
zeroBaseLeft: false,
|
||||
showCredit: false
|
||||
},
|
||||
{
|
||||
title: 'Precipitation outlook',
|
||||
subtitle: `${smoothing}-day rolling total (${precipUnit})`,
|
||||
unit: precipUnit,
|
||||
series: precipSeries,
|
||||
zeroBaseLeft: true,
|
||||
showCredit: true
|
||||
}
|
||||
].filter((def) => def.series.length > 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- full-bleed graphs until lg / contained card on lg+ (matches the 14-day page) -->
|
||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||
{#each chartDefs as def, i (def.title)}
|
||||
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||
<div class="mb-1 px-3 lg:px-0">
|
||||
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||
</div>
|
||||
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||
<CanvasChart
|
||||
bind:this={charts[i]}
|
||||
{timestamps}
|
||||
timezone="UTC"
|
||||
series={def.series}
|
||||
unit={def.unit}
|
||||
zeroBaseLeft={def.zeroBaseLeft}
|
||||
showCredit={def.showCredit}
|
||||
{showLegend}
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { getColor } from '../../utils/colors';
|
||||
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||
|
||||
import type { ClimateNormals } from '$lib/services/weather';
|
||||
import type { MonthOutlook } from './outlook';
|
||||
|
||||
interface Props {
|
||||
months: MonthOutlook[];
|
||||
units: WeatherUnits;
|
||||
normals: ClimateNormals | null;
|
||||
}
|
||||
|
||||
let { months, units, normals }: Props = $props();
|
||||
|
||||
const tempUnit = $derived(getTempUnit(units));
|
||||
const precipUnit = $derived(getPrecipUnit(units));
|
||||
|
||||
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
|
||||
const fmtTemp = (v: number): string => (finite(v) ? `${v.toFixed(1)}°` : '–');
|
||||
const fmtSigned = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
|
||||
const fmtPrecip = (v: number): string => `${v.toFixed(v < 10 ? 1 : 0)}`;
|
||||
|
||||
// Members agreeing on the sign of the anomaly is the honest confidence signal
|
||||
// for a seasonal outlook: a big anomaly that only half the members share means
|
||||
// nothing. Below this the card says so instead of implying a trend.
|
||||
const AGREEMENT_FLOOR = 0.6;
|
||||
|
||||
function anomalyLabel(delta: number): string {
|
||||
const a = Math.abs(delta);
|
||||
if (a < 0.3) return 'near normal';
|
||||
const word = a < 1 ? 'slightly' : a < 2.5 ? '' : 'well';
|
||||
return `${word} ${delta > 0 ? 'above' : 'below'} normal`.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each months as month (month.key)}
|
||||
{@const agree = Math.max(month.warmerShare, 1 - month.warmerShare)}
|
||||
{@const confident = agree >= AGREEMENT_FLOOR}
|
||||
<article class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<h3 class="text-base font-bold tracking-tight">{month.label}</h3>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{month.days}
|
||||
{month.days === 1 ? 'day' : 'days'}{month.partial ? ' (partial)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 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-2xl leading-tight font-bold tabular-nums">
|
||||
{fmtTemp(month.tMean)}<span class="text-base font-semibold text-muted-foreground"
|
||||
>{tempUnit.replace('°', '')}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto text-right">
|
||||
{#if finite(month.anomaly)}
|
||||
<p
|
||||
class="text-lg leading-tight font-bold tabular-nums"
|
||||
class:text-red-600={month.anomaly >= 0}
|
||||
class:text-blue-600={month.anomaly < 0}
|
||||
class:dark:text-red-400={month.anomaly >= 0}
|
||||
class:dark:text-blue-400={month.anomaly < 0}
|
||||
>
|
||||
{fmtSigned(month.anomaly)}
|
||||
</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>
|
||||
{:else}
|
||||
<p class="text-[11px] text-muted-foreground">normal loading…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- day / night means, coloured on the same temperature scale as the strip -->
|
||||
<div class="mt-2.5 flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full"
|
||||
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>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full"
|
||||
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>
|
||||
</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-semibold tabular-nums">
|
||||
{fmtPrecip(month.precip)}
|
||||
{precipUnit}
|
||||
{#if finite(month.precipNormal)}
|
||||
<span class="font-medium text-muted-foreground">
|
||||
/ normal {fmtPrecip(month.precipNormal)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if finite(month.precipShare)}
|
||||
{@const pct = Math.min(200, month.precipShare * 100)}
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style="width:{pct / 2}%;background:{month.precipShare >= 1
|
||||
? 'var(--color-sky-500, #0ea5e9)'
|
||||
: 'var(--color-amber-500, #f59e0b)'}"
|
||||
></div>
|
||||
</div>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{Math.round(month.precipShare * 100)}% of normal · {month.wetDays} wet days
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ensemble agreement: the actual confidence in the anomaly above -->
|
||||
<div class="mt-2.5 flex items-center gap-2">
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
class:bg-primary={confident}
|
||||
class:bg-muted-foreground={!confident}
|
||||
style="width:{Math.round(agree * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[11px] whitespace-nowrap text-muted-foreground">
|
||||
{#if confident}
|
||||
{Math.round(agree * 100)}% of members {month.warmerShare >= 0.5 ? 'warmer' : 'colder'}
|
||||
{:else}
|
||||
members split - low confidence
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Turns the raw seasonal ensemble into the per-calendar-month outlook the page
|
||||
* shows. Seasonal models carry no day-to-day skill, so everything here is an
|
||||
* aggregate: monthly means, the departure from the climate normal, and how much
|
||||
* of the ensemble actually agrees on that departure.
|
||||
*/
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type SeasonalForecastResult,
|
||||
monthDayToOrdinal
|
||||
} from '$lib/services/weather';
|
||||
|
||||
export interface MonthOutlook {
|
||||
/** `YYYY-MM` in the location's local calendar. */
|
||||
key: string;
|
||||
/** Month name and year, e.g. "August 2026". */
|
||||
label: string;
|
||||
/** Forecast days covered (a leading/trailing month is usually incomplete). */
|
||||
days: number;
|
||||
partial: boolean;
|
||||
tMean: number;
|
||||
tMax: number;
|
||||
tMin: number;
|
||||
/** Mean temperature minus the climate normal, or null without normals. */
|
||||
anomaly: number | null;
|
||||
precip: number;
|
||||
precipNormal: number | null;
|
||||
/** Forecast precipitation as a share of normal (1 = exactly normal). */
|
||||
precipShare: number | null;
|
||||
wetDays: number;
|
||||
/** Share of ensemble members whose monthly mean is above the normal. */
|
||||
warmerShare: number;
|
||||
}
|
||||
|
||||
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
|
||||
|
||||
// The month key already carries the local calendar month, so the label is
|
||||
// formatted in UTC - anything zone-aware would just reintroduce the shift.
|
||||
const MONTH_LABEL = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
|
||||
const mean = (xs: number[]): number =>
|
||||
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
|
||||
|
||||
/** Days in a calendar month; `month` is 1-based. */
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
interface BuildOptions {
|
||||
/** Daily total counted as a wet day, in the active precipitation unit. */
|
||||
wetDayThreshold: number;
|
||||
}
|
||||
|
||||
export function buildMonthOutlooks(
|
||||
result: SeasonalForecastResult,
|
||||
normals: ClimateNormals | null,
|
||||
{ wetDayThreshold }: BuildOptions
|
||||
): MonthOutlook[] {
|
||||
const tMeanVar = result.variables['temperature_2m_mean'];
|
||||
const tMaxVar = result.variables['temperature_2m_max'];
|
||||
const tMinVar = result.variables['temperature_2m_min'];
|
||||
const precipVar = result.variables['precipitation_sum'];
|
||||
if (!tMeanVar) return [];
|
||||
|
||||
const { dateKeys } = result;
|
||||
|
||||
// Day indices grouped by calendar month, plus the normals lookup per day.
|
||||
// Both read the API's own local dates: deriving them from the instants would
|
||||
// double-count the day a DST change falls on.
|
||||
const groups = new Map<string, number[]>();
|
||||
const ordinals = dateKeys.map((key) =>
|
||||
monthDayToOrdinal(Number(key.slice(5, 7)), Number(key.slice(8, 10)))
|
||||
);
|
||||
|
||||
for (let i = 0; i < dateKeys.length; i++) {
|
||||
const key = dateKeys[i].slice(0, 7);
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) bucket.push(i);
|
||||
else groups.set(key, [i]);
|
||||
}
|
||||
|
||||
const months: MonthOutlook[] = [];
|
||||
|
||||
for (const [key, indices] of groups) {
|
||||
const pick = (values: number[] | undefined): number =>
|
||||
values ? mean(indices.map((i) => values[i]).filter(finite)) : NaN;
|
||||
|
||||
const [year, month] = key.split('-').map(Number);
|
||||
|
||||
// Normals are per day-of-year, so the comparison uses exactly the days the
|
||||
// forecast covers - a half month is never compared against a full one.
|
||||
let normalMean = NaN;
|
||||
let normalPrecip = NaN;
|
||||
if (normals) {
|
||||
normalMean = mean(indices.map((i) => normals.tmean[ordinals[i]]).filter(finite));
|
||||
const np = indices.map((i) => normals.precip[ordinals[i]]).filter(finite);
|
||||
if (np.length) normalPrecip = np.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
const tMean = pick(tMeanVar.mean);
|
||||
|
||||
// Per-member monthly means decide the agreement share: a member counts as
|
||||
// warmer only if it beats the same normal the anomaly is measured against.
|
||||
let warmer = 0;
|
||||
let counted = 0;
|
||||
if (finite(normalMean)) {
|
||||
for (const member of tMeanVar.members) {
|
||||
const memberMean = mean(indices.map((i) => member[i]).filter(finite));
|
||||
if (!finite(memberMean)) continue;
|
||||
counted++;
|
||||
if (memberMean > normalMean) warmer++;
|
||||
}
|
||||
}
|
||||
|
||||
const precipDays = precipVar ? indices.map((i) => precipVar.mean[i]).filter(finite) : [];
|
||||
const precip = precipDays.reduce((a, b) => a + b, 0);
|
||||
|
||||
months.push({
|
||||
key,
|
||||
label: MONTH_LABEL.format(Date.UTC(year, month - 1, 1)),
|
||||
days: indices.length,
|
||||
partial: indices.length < daysInMonth(year, month),
|
||||
tMean,
|
||||
tMax: pick(tMaxVar?.mean),
|
||||
tMin: pick(tMinVar?.mean),
|
||||
anomaly: finite(normalMean) && finite(tMean) ? tMean - normalMean : null,
|
||||
precip,
|
||||
precipNormal: finite(normalPrecip) ? normalPrecip : null,
|
||||
precipShare: finite(normalPrecip) && normalPrecip > 0 ? precip / normalPrecip : null,
|
||||
wetDays: precipDays.filter((p) => p >= wetDayThreshold).length,
|
||||
warmerShare: counted > 0 ? warmer / counted : 0.5
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows an outlook to its first `days` days. The fetch always asks for the
|
||||
* model's full horizon, so the range buttons only reslice what is already in
|
||||
* memory instead of issuing another (large) request.
|
||||
*/
|
||||
export function sliceSeasonal(
|
||||
result: SeasonalForecastResult,
|
||||
days: number
|
||||
): SeasonalForecastResult {
|
||||
if (days >= result.timestamps.length) return result;
|
||||
|
||||
const variables: SeasonalForecastResult['variables'] = {};
|
||||
for (const [name, data] of Object.entries(result.variables)) {
|
||||
variables[name] = {
|
||||
members: data.members.map((m) => m.slice(0, days)),
|
||||
mean: data.mean.slice(0, days),
|
||||
min: data.min.slice(0, days),
|
||||
max: data.max.slice(0, days),
|
||||
p25: data.p25.slice(0, days),
|
||||
p75: data.p75.slice(0, days),
|
||||
unit: data.unit
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
variables,
|
||||
timestamps: result.timestamps.slice(0, days),
|
||||
dailyDates: result.dailyDates.slice(0, days),
|
||||
dateKeys: result.dateKeys.slice(0, days)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered rolling mean. Seasonal ensembles are far too noisy to read day by
|
||||
* day; smoothing shows the trend the model actually claims to resolve. Windows
|
||||
* shrink at the edges instead of dropping data.
|
||||
*/
|
||||
export function rollingMean(values: number[], window: number): number[] {
|
||||
const half = Math.floor(window / 2);
|
||||
return values.map((_, i) => {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let k = i - half; k <= i + half; k++) {
|
||||
const v = values[k];
|
||||
if (finite(v)) {
|
||||
sum += v;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : NaN;
|
||||
});
|
||||
}
|
||||
|
||||
/** Rolling total over the same window (used for precipitation). */
|
||||
export function rollingSum(values: number[], window: number): number[] {
|
||||
return rollingMean(values, window).map((v) => (finite(v) ? v * window : NaN));
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import {
|
||||
@@ -76,6 +78,10 @@
|
||||
storedLocation.set(data.location);
|
||||
});
|
||||
|
||||
// Lets the strip's side buttons hand over to the archive / seasonal outlook
|
||||
// for the same place once their range is exhausted.
|
||||
let locationRoute = $derived(buildLocationRoute(location));
|
||||
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<FriendlyWeatherError | null>(null);
|
||||
@@ -318,6 +324,7 @@
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
{locationRoute}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
@@ -23,6 +25,11 @@
|
||||
onExtend?: () => void;
|
||||
canExtendPast?: boolean;
|
||||
onExtendPast?: () => void;
|
||||
/**
|
||||
* Location segment for the hand-off links that replace the side buttons
|
||||
* once their range is exhausted. Omit to leave the slots empty.
|
||||
*/
|
||||
locationRoute?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -33,7 +40,8 @@
|
||||
canExtend = false,
|
||||
onExtend,
|
||||
canExtendPast = false,
|
||||
onExtendPast
|
||||
onExtendPast,
|
||||
locationRoute
|
||||
}: Props = $props();
|
||||
|
||||
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
|
||||
@@ -82,10 +90,16 @@
|
||||
let scrolledForRef: FetchedDaily | null = null;
|
||||
$effect(() => {
|
||||
const d = daily;
|
||||
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
||||
if (!d || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
||||
scrolledForRef = d;
|
||||
const scroll = stripScrollEl;
|
||||
const wrap = daysWrapEl;
|
||||
if (!canExtendPast) {
|
||||
// the past button has been replaced by the history link, which is a
|
||||
// destination rather than a control: it stays in view instead of parked
|
||||
scroll.scrollLeft = 0;
|
||||
return;
|
||||
}
|
||||
// the delta form is self-correcting (a no-op once right), so apply after
|
||||
// layout and once more after late-loading CSS/fonts settle — otherwise a
|
||||
// sliver of the past button can stay visible on first paint
|
||||
@@ -110,7 +124,7 @@
|
||||
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
||||
|
||||
<div
|
||||
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
|
||||
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
|
||||
class:js-snap={needsSnapFallback}
|
||||
class:compact
|
||||
class:stuck
|
||||
@@ -122,7 +136,7 @@
|
||||
match the 15-days button on the other end -->
|
||||
<button
|
||||
type="button"
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
class="strip-side strip-park flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
onclick={onExtendPast}
|
||||
aria-label="Load the past 3 days"
|
||||
title="Load the past 3 days"
|
||||
@@ -141,6 +155,31 @@
|
||||
</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">past</span>
|
||||
</button>
|
||||
{:else if locationRoute}
|
||||
<!-- the past days are already in the strip: the only way further back
|
||||
is the archive, so the button hands over to it -->
|
||||
<a
|
||||
href={resolve('/weather/historical/[location]', { location: locationRoute })}
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
aria-label="Open the historical weather archive"
|
||||
title="Historical weather: any past date back to 1940"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 2" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[9px] leading-tight font-semibold">history</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="strip-days flex" bind:this={daysWrapEl}>
|
||||
@@ -322,6 +361,27 @@
|
||||
</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">days</span>
|
||||
</button>
|
||||
{:else if locationRoute}
|
||||
<!-- the strip is at the model's limit: anything further out is a
|
||||
seasonal outlook, so the button hands over to it -->
|
||||
<a
|
||||
href={resolve('/weather/seasonal/[location]', { location: locationRoute })}
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
aria-label="Open the seasonal outlook"
|
||||
title="Seasonal outlook: monthly trends for the months ahead"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 17l6-6 4 4 7-7" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16 8h5v5" />
|
||||
</svg>
|
||||
<span class="text-[9px] leading-tight font-semibold">seasonal</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -514,7 +574,7 @@
|
||||
(Browsers too old to register --strip-p simply switch instantly.) */
|
||||
.daystrip.js-snap {
|
||||
transition:
|
||||
--strip-p 0.28s ease,
|
||||
--strip-p 0.32s ease,
|
||||
--stuck 0.15s ease;
|
||||
}
|
||||
.daystrip.js-snap.compact {
|
||||
@@ -527,7 +587,7 @@
|
||||
/* larger cards need a touch longer to feel smooth */
|
||||
.daystrip.js-snap {
|
||||
transition:
|
||||
--strip-p 0.55s ease,
|
||||
--strip-p 0.42s ease,
|
||||
--stuck 0.15s ease;
|
||||
}
|
||||
}
|
||||
@@ -549,13 +609,14 @@
|
||||
}
|
||||
/* Scroll containers clip at the PADDING edge, so a parked past button would
|
||||
always leak a sliver across the row's left padding. Extending the days
|
||||
group's left edge (only while the past button exists) moves max-scroll so
|
||||
the button parks fully beyond the clip edge. */
|
||||
.strip-side + .strip-days {
|
||||
group's left edge (only while the parked past button exists) moves
|
||||
max-scroll so the button parks fully beyond the clip edge. The history
|
||||
link that replaces it is never parked, so it keeps the normal gap. */
|
||||
.strip-park + .strip-days {
|
||||
padding-left: calc(12px - var(--gap-min));
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.strip-side + .strip-days {
|
||||
.strip-park + .strip-days {
|
||||
padding-left: calc(32px - var(--gap-min));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user