past weather
This commit is contained in:
@@ -250,11 +250,15 @@
|
|||||||
let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand));
|
let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand));
|
||||||
let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right'));
|
let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right'));
|
||||||
|
|
||||||
// Tighter left gutter on narrow screens so axis labels sit near the edge
|
// Minimal gutters on narrow screens so the plot uses nearly the full width
|
||||||
let padLeft = $derived(width > 0 && width < 520 ? 38 : 60);
|
// (just enough to keep the axis tick labels legible).
|
||||||
|
let isNarrow = $derived(width > 0 && width < 520);
|
||||||
|
let padLeft = $derived(isNarrow ? 26 : 60);
|
||||||
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
||||||
// has a right axis, so a stacked row of charts share the same plot width.
|
// has a right axis, so a stacked row of charts share the same plot width.
|
||||||
let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20);
|
let padRight = $derived(
|
||||||
|
hasRightAxis || reserveRightAxis ? (isNarrow ? 34 : 56) : isNarrow ? 6 : 20
|
||||||
|
);
|
||||||
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
||||||
// a stacked row of charts the same height even if some have fewer icon rows.
|
// a stacked row of charts the same height even if some have fewer icon rows.
|
||||||
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
|
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
|
||||||
@@ -1131,7 +1135,7 @@
|
|||||||
<!-- Weather pictograms: a bordered band across the top of the plot -->
|
<!-- Weather pictograms: a bordered band across the top of the plot -->
|
||||||
{#if visiblePictograms.length > 0}
|
{#if visiblePictograms.length > 0}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
|
class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
|
||||||
style:left="{iconBandLeft}px"
|
style:left="{iconBandLeft}px"
|
||||||
style:top="{pictoRowTop}px"
|
style:top="{pictoRowTop}px"
|
||||||
style:width="{iconBandWidth}px"
|
style:width="{iconBandWidth}px"
|
||||||
@@ -1153,7 +1157,7 @@
|
|||||||
<!-- Wind-direction arrows: a matching band -->
|
<!-- Wind-direction arrows: a matching band -->
|
||||||
{#if visibleWindArrows.length > 0}
|
{#if visibleWindArrows.length > 0}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
|
class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
|
||||||
style:left="{iconBandLeft}px"
|
style:left="{iconBandLeft}px"
|
||||||
style:top="{windRowTop}px"
|
style:top="{windRowTop}px"
|
||||||
style:width="{iconBandWidth}px"
|
style:width="{iconBandWidth}px"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
{location.name}
|
{location.name}
|
||||||
{#if location.admin1 || location.country}
|
{#if location.admin1 || location.country}
|
||||||
<span class="font-normal text-muted-foreground">
|
<span class="font-normal text-muted-foreground">
|
||||||
· {#if location.admin1}{location.admin1},
|
· {#if location.admin1}{location.admin1},
|
||||||
{/if}{location.country ?? ''}
|
{/if}{location.country ?? ''}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -154,6 +154,11 @@
|
|||||||
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
|
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Surface a note when the chosen model's ensemble stops short of the request.
|
||||||
|
let fullHours = $derived.by(() => (fetchedData ? fetchedData.timestamps.length : 0));
|
||||||
|
let validDays = $derived(Math.max(0, Math.round(validLength / 24)));
|
||||||
|
let isTrimmed = $derived(fetchedData != null && validLength > 0 && validLength < fullHours - 1);
|
||||||
|
|
||||||
interface ChartDef {
|
interface ChartDef {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
@@ -210,14 +215,6 @@
|
|||||||
bandTo: varData.min,
|
bandTo: varData.min,
|
||||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Min',
|
|
||||||
type: 'line',
|
|
||||||
color: BAND_COLOR,
|
|
||||||
data: varData.min,
|
|
||||||
width: 1,
|
|
||||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Mean',
|
name: 'Mean',
|
||||||
type: isColumn ? 'bar' : 'line',
|
type: isColumn ? 'bar' : 'line',
|
||||||
@@ -226,6 +223,14 @@
|
|||||||
width: 3,
|
width: 3,
|
||||||
dashed: !isColumn,
|
dashed: !isColumn,
|
||||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Min',
|
||||||
|
type: 'line',
|
||||||
|
color: BAND_COLOR,
|
||||||
|
data: varData.min,
|
||||||
|
width: 1,
|
||||||
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -285,6 +290,30 @@
|
|||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
|
{#if isTrimmed}
|
||||||
|
<div
|
||||||
|
class="mb-4 flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="mt-0.5 h-4 w-4 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead — the spread is
|
||||||
|
trimmed to its available range.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
<div
|
<div
|
||||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
SERIES_COLORS,
|
SERIES_COLORS,
|
||||||
calculateAverage,
|
calculateAverage,
|
||||||
findUnit,
|
findUnit,
|
||||||
|
groupRange,
|
||||||
isColumnUnit
|
isColumnUnit
|
||||||
} from '$lib/charts';
|
} from '$lib/charts';
|
||||||
import {
|
import {
|
||||||
@@ -99,6 +102,40 @@
|
|||||||
// components persist across refetches; entries are null while unmounted
|
// components persist across refetches; entries are null while unmounted
|
||||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||||
|
|
||||||
|
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
|
||||||
|
|
||||||
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
|
|
||||||
|
function dayStartSec(day: Date): number | null {
|
||||||
|
if (!fetchedData) return null;
|
||||||
|
const tz = fetchedData.timezone;
|
||||||
|
const target = formatZoned(day, tz, 'yyyy-MM-dd');
|
||||||
|
const idx = fetchedData.timestamps.findIndex(
|
||||||
|
(t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target
|
||||||
|
);
|
||||||
|
return idx === -1 ? null : fetchedData.timestamps[idx] / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRangeDays(from: Date, days: number): void {
|
||||||
|
const start = dayStartSec(from);
|
||||||
|
if (start == null || liveCharts.length === 0) return;
|
||||||
|
// charts share the group, so setting the range on one syncs all of them
|
||||||
|
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom(): void {
|
||||||
|
liveCharts[0]?.resetRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangePresets = [
|
||||||
|
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||||
|
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||||
|
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||||
|
{ label: 'All', apply: () => resetZoom() }
|
||||||
|
];
|
||||||
|
|
||||||
|
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
||||||
|
|
||||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -161,6 +198,24 @@
|
|||||||
series: ChartSeries[];
|
series: ChartSeries[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human labels for the compared variables (API names → readable title)
|
||||||
|
const VAR_LABELS: Record<string, string> = {
|
||||||
|
temperature_2m: 'Temperature',
|
||||||
|
apparent_temperature: 'Feels like',
|
||||||
|
dew_point_2m: 'Dew point',
|
||||||
|
precipitation: 'Precipitation',
|
||||||
|
rain: 'Rain',
|
||||||
|
showers: 'Showers',
|
||||||
|
snowfall: 'Snowfall',
|
||||||
|
wind_speed_10m: 'Wind speed',
|
||||||
|
wind_gusts_10m: 'Wind gusts',
|
||||||
|
relative_humidity_2m: 'Relative humidity',
|
||||||
|
cloud_cover: 'Cloud cover',
|
||||||
|
pressure_msl: 'Pressure (MSL)'
|
||||||
|
};
|
||||||
|
const varLabel = (v: string): string =>
|
||||||
|
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
let chartDefs = $derived.by((): ChartDef[] => {
|
let chartDefs = $derived.by((): ChartDef[] => {
|
||||||
if (!fetchedData) return [];
|
if (!fetchedData) return [];
|
||||||
|
|
||||||
@@ -209,10 +264,11 @@
|
|||||||
const isLast = vi === variableCount - 1;
|
const isLast = vi === variableCount - 1;
|
||||||
|
|
||||||
defs.push({
|
defs.push({
|
||||||
title: isFirst ? 'Model Compare' : undefined,
|
// label every chart with its variable so each is identifiable
|
||||||
|
title: varLabel(variable),
|
||||||
subtitle: isFirst
|
subtitle: isFirst
|
||||||
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
? `${params.models?.length ?? 0} models · dashed = average`
|
||||||
: undefined,
|
: `across ${params.models?.length ?? 0} models`,
|
||||||
unit,
|
unit,
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
series
|
series
|
||||||
@@ -223,6 +279,27 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
|
||||||
|
|
||||||
|
<div class="mb-5 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
|
||||||
|
>Model comparison
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
@@ -233,6 +310,49 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Range / zoom controls (same as the 7-day meteograms) -->
|
||||||
|
<div class="mb-3 flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||||
|
drag or
|
||||||
|
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd>
|
||||||
|
+ scroll to zoom
|
||||||
|
</span>
|
||||||
|
{#if zoomActive}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{#each rangePresets as preset (preset.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||||
|
onclick={preset.apply}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- chart count derives from the selected variables (not the fetched data),
|
<!-- chart count derives from the selected variables (not the fetched data),
|
||||||
so the reserved height is right even before the response arrives -->
|
so the reserved height is right even before the response arrives -->
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
|
|||||||
@@ -35,18 +35,31 @@
|
|||||||
// of view — the user reveals it by scrolling left. Re-hide only when the
|
// of view — the user reveals it by scrolling left. Re-hide only when the
|
||||||
// dataset (location) changes, not on every re-render.
|
// dataset (location) changes, not on every re-render.
|
||||||
let scrollEl = $state<HTMLDivElement>();
|
let scrollEl = $state<HTMLDivElement>();
|
||||||
let pastBtnEl = $state<HTMLButtonElement>();
|
let cardsWrapEl = $state<HTMLDivElement>();
|
||||||
let hiddenForRef: FetchedDaily | null = null;
|
let hiddenForRef: FetchedDaily | null = null;
|
||||||
|
|
||||||
|
// Show the scrollbar only briefly while actively scrolling (a constant thin
|
||||||
|
// gutter is reserved so revealing the thumb never shifts layout).
|
||||||
|
let scrolling = $state(false);
|
||||||
|
let scrollHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
function onScroll() {
|
||||||
|
scrolling = true;
|
||||||
|
clearTimeout(scrollHideTimer);
|
||||||
|
scrollHideTimer = setTimeout(() => (scrolling = false), 700);
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const d = daily;
|
const d = daily;
|
||||||
if (!d || !canExtendPast || !scrollEl || !pastBtnEl || hiddenForRef === d) return;
|
if (!d || !canExtendPast || !scrollEl || !cardsWrapEl || hiddenForRef === d) return;
|
||||||
hiddenForRef = d;
|
hiddenForRef = d;
|
||||||
const btn = pastBtnEl.getBoundingClientRect();
|
const el = scrollEl;
|
||||||
const cont = scrollEl.getBoundingClientRect();
|
const wrap = cardsWrapEl;
|
||||||
// scroll so the button's right edge sits just past the left edge (a small
|
// defer to after layout so the measured positions and scroll width are final
|
||||||
// gap of extra margin keeps the first day card from hugging the edge)
|
requestAnimationFrame(() => {
|
||||||
scrollEl.scrollLeft += btn.right - cont.left + 6;
|
// scroll so the first day card sits exactly at the content edge (aligned
|
||||||
|
// with the page hero), leaving the "past days" button off to the left
|
||||||
|
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function getDaylightSeconds(index: number): number {
|
function getDaylightSeconds(index: number): number {
|
||||||
@@ -112,13 +125,13 @@
|
|||||||
lines up with the page content edge -->
|
lines up with the page content edge -->
|
||||||
<div
|
<div
|
||||||
bind:this={scrollEl}
|
bind:this={scrollEl}
|
||||||
class="-mx-3 flex gap-2 overflow-x-auto px-3 pt-4 pb-8"
|
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
|
||||||
style="scrollbar-width: thin"
|
class:scrolling
|
||||||
|
onscroll={onScroll}
|
||||||
>
|
>
|
||||||
{#if daily}
|
{#if daily}
|
||||||
{#if canExtendPast && onExtendPast}
|
{#if canExtendPast && onExtendPast}
|
||||||
<button
|
<button
|
||||||
bind:this={pastBtnEl}
|
|
||||||
type="button"
|
type="button"
|
||||||
class="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"
|
class="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}
|
onclick={onExtendPast}
|
||||||
@@ -143,6 +156,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- the cards fill at least the viewport so the row overflows past the
|
||||||
|
"past days" button (letting it scroll out of view even on wide
|
||||||
|
screens). On md+ we also reserve room so the "load more" button stays
|
||||||
|
visible; on mobile it's simply reached by scrolling (never clipped). -->
|
||||||
|
<div
|
||||||
|
bind:this={cardsWrapEl}
|
||||||
|
class="cards-fill flex gap-2"
|
||||||
|
style="--fill-reserve: {canExtend ? '6.5rem' : '0rem'}"
|
||||||
|
>
|
||||||
{#each daily.dailyDates as time, index (index)}
|
{#each daily.dailyDates as time, index (index)}
|
||||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
@@ -171,7 +193,9 @@
|
|||||||
onclick={() => onSelectDay(time, index)}
|
onclick={() => onSelectDay(time, index)}
|
||||||
>
|
>
|
||||||
<!-- Day label -->
|
<!-- Day label -->
|
||||||
<span class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}">
|
<span
|
||||||
|
class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
|
||||||
|
>
|
||||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -200,7 +224,10 @@
|
|||||||
height="42px"
|
height="42px"
|
||||||
>
|
>
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, false)}.svg#Layer_1"
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
wCode,
|
||||||
|
false
|
||||||
|
)}.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +273,9 @@
|
|||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="shrink-0 {lowPrecip ? 'fill-muted-foreground/40' : 'fill-foreground/70'}"
|
class="shrink-0 {lowPrecip
|
||||||
|
? 'fill-muted-foreground/40'
|
||||||
|
: 'fill-foreground/70'}"
|
||||||
width="23px"
|
width="23px"
|
||||||
height="23px"
|
height="23px"
|
||||||
>
|
>
|
||||||
@@ -271,7 +300,8 @@
|
|||||||
width="40px"
|
width="40px"
|
||||||
height="40px"
|
height="40px"
|
||||||
>
|
>
|
||||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
|
||||||
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -294,6 +324,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if canExtend && onExtend}
|
{#if canExtend && onExtend}
|
||||||
<button
|
<button
|
||||||
@@ -326,6 +357,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Reserve a constant thin scrollbar gutter (no layout shift), but keep the
|
||||||
|
thumb invisible until the user is actively scrolling. */
|
||||||
|
.day-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
.day-scroll.scrolling {
|
||||||
|
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
|
||||||
|
}
|
||||||
|
.day-scroll::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
.day-scroll::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.day-scroll.scrolling::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in oklab, var(--color-border) 85%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cards group fills the viewport so the row overflows past the side
|
||||||
|
buttons. On md+ we also reserve room so the "load more" button stays in
|
||||||
|
view; on mobile it keeps its full width after the cards (reached by
|
||||||
|
scrolling) and is never clipped. */
|
||||||
|
.cards-fill {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.cards-fill {
|
||||||
|
min-width: calc(100% - var(--fill-reserve, 0rem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
|
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.day-card {
|
.day-card {
|
||||||
|
|||||||
@@ -237,14 +237,18 @@
|
|||||||
>.
|
>.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-6">
|
<!-- one full-bleed card on mobile / contained card on md+, graphs stacked
|
||||||
|
tightly so they read as one fluent meteogram -->
|
||||||
|
<div class="-mx-5 border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border">
|
||||||
{#each renderPanels as panel, i (panel.id)}
|
{#each renderPanels as panel, i (panel.id)}
|
||||||
<!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
|
|
||||||
<div
|
<div
|
||||||
class="-mx-5 border-y border-border/70 bg-card px-0 py-3 shadow-sm md:mx-0 md:rounded-2xl md:border md:px-4 md:py-4"
|
class="px-0 pt-2 pb-1 md:px-4 {i > 0 ? 'border-t border-border/50' : 'md:pt-4'} {i ===
|
||||||
|
renderPanels.length - 1
|
||||||
|
? 'pb-3 md:pb-4'
|
||||||
|
: ''}"
|
||||||
>
|
>
|
||||||
<div class="mb-1 flex items-center justify-between px-3 md:px-1">
|
<div class="mb-0.5 flex items-center justify-between px-3 md:px-0">
|
||||||
<h4 class="truncate text-sm font-bold text-muted-foreground">
|
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
||||||
<span class="hidden md:inline">{panel.title}</span>
|
<span class="hidden md:inline">{panel.title}</span>
|
||||||
<span class="md:hidden">{panel.titleShort}</span>
|
<span class="md:hidden">{panel.titleShort}</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
>
|
>
|
||||||
<Select.Trigger
|
<Select.Trigger
|
||||||
aria-label="{label} selection"
|
aria-label="{label} selection"
|
||||||
class="group h-auto min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 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-w-72 sm:flex-none"
|
class="group h-auto min-h-14 min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 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-w-72 sm:flex-none"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
|
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
|
||||||
|
|||||||
Reference in New Issue
Block a user