temperature chart and table alignments
This commit is contained in:
@@ -88,8 +88,9 @@
|
||||
{#if fullBleed}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<!-- cap the content width on very large screens -->
|
||||
<div class="mx-auto w-full max-w-[1536px]">
|
||||
<!-- cap the content width on very large screens; generous bottom room
|
||||
so the last chart/table never sits flush against the viewport edge -->
|
||||
<div class="mx-auto w-full max-w-[1536px] pb-80">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -165,6 +165,21 @@
|
||||
unit: string;
|
||||
showCredit: boolean;
|
||||
series: ChartSeries[];
|
||||
zeroBaseLeft?: boolean;
|
||||
yMin?: number;
|
||||
yMax?: number;
|
||||
}
|
||||
|
||||
// Sensible axis behaviour per variable so the y-scale stays readable (e.g.
|
||||
// pressure never anchored to zero; percentages pinned to 0-100).
|
||||
function axisForVar(v: string): { zeroBaseLeft: boolean; yMin?: number; yMax?: number } {
|
||||
if (['pressure_msl', 'surface_pressure', 'temperature_2m', 'dew_point_2m'].includes(v)) {
|
||||
return { zeroBaseLeft: false };
|
||||
}
|
||||
if (['relative_humidity_2m', 'cloud_cover', 'precipitation_probability'].includes(v)) {
|
||||
return { zeroBaseLeft: true, yMin: 0, yMax: 100 };
|
||||
}
|
||||
return { zeroBaseLeft: true };
|
||||
}
|
||||
|
||||
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
|
||||
@@ -202,33 +217,40 @@
|
||||
const isColumn = isColumnUnit(unit);
|
||||
const memberCount = varData.members.length;
|
||||
|
||||
// Trim to the valid horizon so the axis scale ignores the trailing
|
||||
// zeros the service pads past the model's range.
|
||||
const vMax = varData.max.slice(0, validLength);
|
||||
const vMin = varData.min.slice(0, validLength);
|
||||
const vAvg = varData.average.slice(0, validLength);
|
||||
|
||||
// Min/max spread band + mean, instead of every individual member
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
name: 'Max',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: varData.max,
|
||||
data: vMax,
|
||||
width: 1,
|
||||
fill: true,
|
||||
fillOpacity: 0.25,
|
||||
bandTo: varData.min,
|
||||
bandTo: vMin,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
},
|
||||
{
|
||||
name: 'Mean',
|
||||
type: isColumn ? 'bar' : 'line',
|
||||
color: CHART_COLORS.average,
|
||||
data: varData.average,
|
||||
width: 3,
|
||||
data: vAvg,
|
||||
width: 3.5,
|
||||
dashed: !isColumn,
|
||||
outline: !isColumn,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
},
|
||||
{
|
||||
name: 'Min',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: varData.min,
|
||||
data: vMin,
|
||||
width: 1,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
}
|
||||
@@ -237,6 +259,7 @@
|
||||
const isFirst = vi === 0;
|
||||
const isLast = vi === variables.length - 1;
|
||||
|
||||
const axis = axisForVar(variable);
|
||||
defs.push({
|
||||
// each chart is labelled so the variable is obvious at a glance
|
||||
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
|
||||
@@ -245,7 +268,10 @@
|
||||
: `min · mean · max (${unit})`,
|
||||
unit,
|
||||
showCredit: isLast,
|
||||
series
|
||||
series,
|
||||
zeroBaseLeft: axis.zeroBaseLeft,
|
||||
yMin: axis.yMin,
|
||||
yMax: axis.yMax
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,7 +281,7 @@
|
||||
|
||||
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
|
||||
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<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"
|
||||
@@ -275,7 +301,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center gap-3 sm:w-auto">
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
groups={ensembleModelGroups}
|
||||
@@ -322,7 +348,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300}>
|
||||
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300} bleed={false}>
|
||||
{#if fetchedData}
|
||||
{#each chartDefs as def, i (i)}
|
||||
<CanvasChart
|
||||
@@ -335,6 +361,9 @@
|
||||
title={def.title}
|
||||
subtitle={def.subtitle}
|
||||
showCredit={def.showCredit}
|
||||
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
||||
yMin={def.yMin}
|
||||
yMax={def.yMax}
|
||||
{showLegend}
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
|
||||
@@ -257,7 +257,8 @@
|
||||
color: CHART_COLORS.average,
|
||||
data: average,
|
||||
width: 4,
|
||||
dashed: !isColumn
|
||||
dashed: !isColumn,
|
||||
outline: !isColumn
|
||||
});
|
||||
|
||||
const isFirst = vi === 0;
|
||||
@@ -281,7 +282,7 @@
|
||||
|
||||
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
|
||||
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<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"
|
||||
@@ -302,7 +303,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<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
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
|
||||
@@ -362,6 +363,7 @@
|
||||
{loading}
|
||||
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
||||
chartHeight={300}
|
||||
bleed={false}
|
||||
>
|
||||
{#if fetchedData}
|
||||
{#each chartDefs as def, i (i)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import {
|
||||
storedChartLayout,
|
||||
@@ -152,7 +153,7 @@
|
||||
<div class="week-page">
|
||||
<div class="weather-content" style="min-height: 50vh">
|
||||
<!-- Page hero: prominent location + weather model selection -->
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<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"
|
||||
@@ -174,7 +175,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<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={params.models?.[0] ?? 'best_match'}
|
||||
onModelChange={(model) => {
|
||||
@@ -220,14 +221,17 @@
|
||||
/>
|
||||
{:else}
|
||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
||||
<div class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if fetchedHourly}
|
||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||
{:else}
|
||||
<!-- reserve the exact chart area height before the first fetch resolves -->
|
||||
<section class="mt-8">
|
||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -119,13 +119,13 @@
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div transition:fade={{ duration: 200 }} class="mb-6 min-h-[260px]">
|
||||
<div transition:fade={{ duration: 200 }} class="mb-1 min-h-47.5 md:mb-6 md:min-h-65">
|
||||
<!-- negative margin + matching padding: the scroll box gains room so a
|
||||
lifted/scaled/shadowed card is never clipped, while the first card still
|
||||
lines up with the page content edge -->
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
|
||||
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-2 pb-3 md:pt-5 md:pb-11"
|
||||
class:scrolling
|
||||
onscroll={onScroll}
|
||||
>
|
||||
@@ -187,7 +187,7 @@
|
||||
<button
|
||||
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
|
||||
{selected
|
||||
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60'
|
||||
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
|
||||
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => onSelectDay(time, index)}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedVariablePrefs } from '$lib/stores/settings';
|
||||
import {
|
||||
defaultVariablePrefs,
|
||||
storedHourlyInterval,
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { setGroupHover } from '$lib/charts';
|
||||
import { groupHover, setGroupHover } from '$lib/charts';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
@@ -53,11 +57,15 @@
|
||||
setGroupHover(METEOGRAM_GROUP, null);
|
||||
}
|
||||
|
||||
let hourlyInterval = $state<1 | 3>(3);
|
||||
// persisted 1h / 3h preference
|
||||
let hourlyInterval = $derived($storedHourlyInterval);
|
||||
|
||||
// Row visibility, controlled from the Variables sidebar (missing keys
|
||||
// from older stored prefs default to visible)
|
||||
let showRow = $derived((key: string): boolean => $storedVariablePrefs.table?.[key] ?? true);
|
||||
let showRow = $derived(
|
||||
(key: string): boolean =>
|
||||
$storedVariablePrefs.table?.[key] ?? defaultVariablePrefs.table[key] ?? true
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
const tempUnit = $derived(getTempUnit(units));
|
||||
@@ -152,6 +160,67 @@
|
||||
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
|
||||
}
|
||||
|
||||
// ─── Maps-project colour ramps (open-meteo/maps) ────────────────────────────
|
||||
// Same breakpoint colours the weather maps use, applied as cell backgrounds.
|
||||
function hexRgb(hex: string): [number, number, number] {
|
||||
const h = hex.replace('#', '');
|
||||
const n =
|
||||
h.length === 3
|
||||
? h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: h;
|
||||
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
|
||||
}
|
||||
function mixRgb(
|
||||
a: [number, number, number],
|
||||
b: [number, number, number],
|
||||
f: number
|
||||
): [number, number, number] {
|
||||
return [
|
||||
Math.round(a[0] + (b[0] - a[0]) * f),
|
||||
Math.round(a[1] + (b[1] - a[1]) * f),
|
||||
Math.round(a[2] + (b[2] - a[2]) * f)
|
||||
];
|
||||
}
|
||||
|
||||
// Pressure: 940 hPa blue → 1010 white → 1060 red.
|
||||
const PRESSURE_LOW = hexRgb('#4444ff');
|
||||
const PRESSURE_MID = hexRgb('#ffffff');
|
||||
const PRESSURE_HIGH = hexRgb('#ff4444');
|
||||
function getPressureBg(hpa: number | null): string {
|
||||
if (hpa == null || isNaN(hpa)) return 'transparent';
|
||||
const v = Math.max(940, Math.min(1060, hpa));
|
||||
const c =
|
||||
v <= 1010
|
||||
? mixRgb(PRESSURE_LOW, PRESSURE_MID, (v - 940) / 70)
|
||||
: mixRgb(PRESSURE_MID, PRESSURE_HIGH, (v - 1010) / 50);
|
||||
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.5)`;
|
||||
}
|
||||
|
||||
// UV index: 0 → 12 across the maps' teal→green→yellow→orange→pink ramp. The
|
||||
// opacity tracks the value so low/night hours stay faint instead of tinting
|
||||
// the whole row.
|
||||
const UV_STOPS = [
|
||||
'#009392',
|
||||
'#39b185',
|
||||
'#9ccb86',
|
||||
'#e9e29c',
|
||||
'#eeb479',
|
||||
'#e88471',
|
||||
'#cf597e'
|
||||
].map(hexRgb);
|
||||
function getUvBg(uv: number | null): string {
|
||||
if (uv == null || isNaN(uv) || uv <= 0) return 'transparent';
|
||||
const v = Math.min(12, uv);
|
||||
const p = (v / 12) * (UV_STOPS.length - 1);
|
||||
const i = Math.min(UV_STOPS.length - 2, Math.floor(p));
|
||||
const c = mixRgb(UV_STOPS[i], UV_STOPS[i + 1], p - i);
|
||||
const alpha = 0.18 + (v / 12) * 0.5;
|
||||
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha.toFixed(3)})`;
|
||||
}
|
||||
|
||||
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
|
||||
const parts: string[] = [];
|
||||
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
|
||||
@@ -211,6 +280,21 @@
|
||||
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
|
||||
: null
|
||||
);
|
||||
|
||||
// ─── Chart-hover mirror ─────────────────────────────────────────────────────
|
||||
// When the shared meteogram is hovered, highlight the matching table column
|
||||
// (only if the hovered time falls on the day the table is currently showing).
|
||||
let chartHoverTime = $derived(groupHover(METEOGRAM_GROUP)); // epoch seconds, or null
|
||||
let hoveredCol = $derived.by((): number => {
|
||||
if (chartHoverTime == null || cellData.length === 0) return -1;
|
||||
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
|
||||
const tMs = chartHoverTime * 1000;
|
||||
for (let i = 0; i < cellData.length; i++) {
|
||||
const start = cellData[i].date.getTime();
|
||||
if (tMs >= start && tMs < start + stepMs) return i;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet weatherIcon(name: string, size: number = 16)}
|
||||
@@ -292,7 +376,7 @@
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={hourlyInterval === interval}
|
||||
onclick={() => (hourlyInterval = interval as 1 | 3)}
|
||||
onclick={() => storedHourlyInterval.set(interval as 1 | 3)}
|
||||
>
|
||||
{interval}h
|
||||
</button>
|
||||
@@ -324,7 +408,7 @@
|
||||
</th>
|
||||
<td
|
||||
colspan={cellData.length}
|
||||
class="relative h-11 overflow-visible p-0"
|
||||
class="relative h-12 overflow-visible p-0"
|
||||
onmousemove={hoverTimeRow}
|
||||
onmouseleave={clearTimeRowHover}
|
||||
>
|
||||
@@ -440,7 +524,7 @@
|
||||
{#each cellData as cell, i (cell.idx)}
|
||||
{@const wCode = hourly.weather_code[cell.idx]}
|
||||
<td
|
||||
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
|
||||
class="cell h-12 leading-0"
|
||||
class:icon-day={cell.isDaytime}
|
||||
class:icon-night={!cell.isDaytime}
|
||||
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
||||
@@ -462,7 +546,7 @@
|
||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||
<td
|
||||
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||
style="background-color:{style.bg};color:{style.fg}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
@@ -471,13 +555,30 @@
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Feels Like -->
|
||||
<!-- Feels Like (short row) -->
|
||||
{#if showRow('feels')}
|
||||
<tr class="row">
|
||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||
<td
|
||||
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Dew Point (short row; off by default) -->
|
||||
{#if showRow('dew_point')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.dew_point_2m?.[cell.idx]}
|
||||
<td
|
||||
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
@@ -491,16 +592,16 @@
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||
<td class="cell h-13 align-middle leading-tight">
|
||||
<td class="relative cell h-12 align-middle leading-none">
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="inline-block leading-0"
|
||||
style="transform:{getWindArrowRotation(windDir)}"
|
||||
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
|
||||
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
|
||||
>
|
||||
{@render weatherIcon('wi-direction-down', 22)}
|
||||
{@render weatherIcon('wi-direction-down', 40)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
|
||||
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||
{formatValue(wind)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -508,13 +609,30 @@
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Humidity -->
|
||||
<!-- Wind Gusts (off by default) -->
|
||||
{#if showRow('gusts')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
|
||||
<td
|
||||
class="cell h-12 font-semibold text-foreground/80 {is3h
|
||||
? 'text-sm'
|
||||
: 'text-xs'}"
|
||||
>
|
||||
{formatValue(v)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Humidity (short row) -->
|
||||
{#if showRow('humidity')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-humidity', '%', 'Humidity')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
|
||||
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
|
||||
{formatValue(hum)}
|
||||
</td>
|
||||
{/each}
|
||||
@@ -528,7 +646,7 @@
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||
<td
|
||||
class="cell h-8"
|
||||
class="cell h-12"
|
||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||
>
|
||||
{formatValue(cloud)}
|
||||
@@ -537,6 +655,51 @@
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Pressure (off by default) -->
|
||||
{#if showRow('pressure')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.pressure_msl?.[cell.idx]}
|
||||
<td
|
||||
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
|
||||
style="background:{getPressureBg(v ?? null)}"
|
||||
>
|
||||
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- UV Index (off by default) -->
|
||||
{#if showRow('uv')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.uv_index?.[cell.idx]}
|
||||
<td
|
||||
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
|
||||
style="background:{getUvBg(v ?? null)}"
|
||||
>
|
||||
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Visibility (off by default) -->
|
||||
{#if showRow('visibility')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-fog', 'km', 'Visibility')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.visibility?.[cell.idx]}
|
||||
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
||||
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Precipitation -->
|
||||
{#if showRow('precipitation')}
|
||||
<tr class="row">
|
||||
@@ -545,7 +708,7 @@
|
||||
{@const precip = hourly.precipitation[cell.idx]}
|
||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||
<td
|
||||
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
|
||||
class="cell precip-cell h-12"
|
||||
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||
title={formatPrecipTooltip(precip, prob)}
|
||||
>
|
||||
@@ -559,9 +722,31 @@
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Snowfall (off by default) -->
|
||||
{#if showRow('snowfall')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-snow', 'cm', 'Snow')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.snowfall?.[cell.idx]}
|
||||
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
||||
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
|
||||
{#if hoveredCol >= 0 && tableWidth > 0}
|
||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
|
||||
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- "Now" column highlight + exact-time line -->
|
||||
{#if nowLeftPx != null}
|
||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||
|
||||
import { CanvasChart, groupRange } from '$lib/charts';
|
||||
|
||||
@@ -29,6 +29,17 @@
|
||||
const CHART_HEIGHT = 300;
|
||||
|
||||
let customizerOpen = $state(false);
|
||||
let downloadingPng = $state(false);
|
||||
|
||||
async function downloadPng(): Promise<void> {
|
||||
if (liveCharts.length === 0 || downloadingPng) return;
|
||||
downloadingPng = true;
|
||||
try {
|
||||
await downloadChartsPng(liveCharts, 'week-forecast');
|
||||
} finally {
|
||||
setTimeout(() => (downloadingPng = false), 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Charts persist across data refetches; entries are null while unmounted.
|
||||
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||
@@ -224,6 +235,40 @@
|
||||
</svg>
|
||||
Customize
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{#if downloadingPng}
|
||||
<svg
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
{/if}
|
||||
PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -286,10 +331,6 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -17,10 +17,16 @@
|
||||
{ key: 'icons', label: 'Weather icons' },
|
||||
{ key: 'temperature', label: 'Temperature' },
|
||||
{ key: 'feels', label: 'Feels like' },
|
||||
{ key: 'dew_point', label: 'Dew point' },
|
||||
{ key: 'wind', label: 'Wind' },
|
||||
{ key: 'gusts', label: 'Wind gusts' },
|
||||
{ key: 'humidity', label: 'Humidity' },
|
||||
{ key: 'clouds', label: 'Cloud cover' },
|
||||
{ key: 'precipitation', label: 'Precipitation' }
|
||||
{ key: 'pressure', label: 'Pressure' },
|
||||
{ key: 'uv', label: 'UV index' },
|
||||
{ key: 'visibility', label: 'Visibility' },
|
||||
{ key: 'precipitation', label: 'Precipitation' },
|
||||
{ key: 'snowfall', label: 'Snowfall' }
|
||||
];
|
||||
|
||||
function toggle(section: 'table' | 'charts', key: string) {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* (data field, render style, colour, unit family) so the panels can be
|
||||
* assembled dynamically from a user-defined layout.
|
||||
*/
|
||||
import { defaultVariablePrefs } from '$lib/stores/settings';
|
||||
|
||||
import { getColor } from '../../utils/colors';
|
||||
import {
|
||||
type WeatherUnits,
|
||||
@@ -36,9 +38,14 @@ export interface ChartVariableDef {
|
||||
dashed?: boolean;
|
||||
fill?: boolean;
|
||||
fillOpacity?: number;
|
||||
/** Area fill coloured by the value scale, fading out below the minimum */
|
||||
gradientFill?: boolean;
|
||||
width?: number;
|
||||
/** Stroke the line coloured by the temperature scale */
|
||||
colorScale?: boolean;
|
||||
/** Draw the line in the theme foreground (black/white), while colorScale still
|
||||
* drives the gradient fill */
|
||||
foregroundLine?: boolean;
|
||||
/** Draw a contrasting halo under the line */
|
||||
outline?: boolean;
|
||||
/** Annotate local minima / maxima with their value */
|
||||
@@ -66,9 +73,10 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
|
||||
type: 'line',
|
||||
kind: 'temp',
|
||||
color: '#ef6c00',
|
||||
width: 9,
|
||||
width: 5.6,
|
||||
colorScale: true,
|
||||
outline: true,
|
||||
foregroundLine: true,
|
||||
gradientFill: true,
|
||||
extrema: true
|
||||
},
|
||||
{
|
||||
@@ -312,23 +320,29 @@ export function neededHourlyApiVars(
|
||||
tablePrefs: Record<string, boolean> | undefined,
|
||||
layoutKeys: string[]
|
||||
): string[] {
|
||||
const on = (key: string): boolean => tablePrefs?.[key] ?? true;
|
||||
const on = (key: string): boolean => tablePrefs?.[key] ?? defaultVariablePrefs.table[key] ?? true;
|
||||
const s = new Set<string>();
|
||||
|
||||
// Hourly table rows
|
||||
if (on('icons')) s.add('weather_code');
|
||||
if (on('temperature')) s.add('temperature_2m');
|
||||
if (on('feels')) s.add('apparent_temperature');
|
||||
if (on('dew_point')) s.add('dew_point_2m');
|
||||
if (on('wind')) {
|
||||
s.add('wind_speed_10m');
|
||||
s.add('wind_direction_10m');
|
||||
}
|
||||
if (on('gusts')) s.add('wind_gusts_10m');
|
||||
if (on('humidity')) s.add('relative_humidity_2m');
|
||||
if (on('clouds')) s.add('cloud_cover');
|
||||
if (on('pressure')) s.add('pressure_msl');
|
||||
if (on('uv')) s.add('uv_index');
|
||||
if (on('visibility')) s.add('visibility');
|
||||
if (on('precipitation')) {
|
||||
s.add('precipitation');
|
||||
s.add('precipitation_probability');
|
||||
}
|
||||
if (on('snowfall')) s.add('snowfall');
|
||||
|
||||
// Meteogram variables
|
||||
for (const key of layoutKeys) {
|
||||
@@ -454,10 +468,12 @@ export function buildPanelDef(
|
||||
width: d.width,
|
||||
fill: d.fill,
|
||||
fillOpacity: d.fillOpacity,
|
||||
gradientFill: d.gradientFill,
|
||||
dashed: d.dashed,
|
||||
axis,
|
||||
cloudBand: d.cloudBand,
|
||||
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
|
||||
foregroundLine: d.foregroundLine,
|
||||
outline: d.outline,
|
||||
labelExtrema: d.extrema,
|
||||
labelFormat: d.extrema
|
||||
|
||||
Reference in New Issue
Block a user