This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/routes/weather/seasonal/[location]/SeasonalCharts.svelte
T

212 lines
5.3 KiB
Svelte

<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>