try strip desktop
This commit is contained in:
@@ -231,12 +231,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
|
||||
the strip stays stuck (collapsing as it goes) while scrolling the table
|
||||
and then releases exactly at the table's bottom, freeing the meteograms.
|
||||
The strip itself is hidden on md+ (the desktop cards above take over).
|
||||
timeline-scope hoists the strip's sentinel view-timeline so the sticky
|
||||
strip (a sibling of the sentinel) can scrub its collapse from it. -->
|
||||
<!-- The sticky day strip, the hourly table AND the meteograms share this
|
||||
wrapper, so the strip stays stuck for the entire page (it collapses
|
||||
as it sticks on mobile; on md+ it's a slim always-compact bar under
|
||||
the topbar, complementing the full cards above). timeline-scope
|
||||
hoists the strip's sentinel view-timeline so the sticky strip (a
|
||||
sibling of the sentinel) can scrub its collapse from it. -->
|
||||
<div style="timeline-scope: --daystrip-sentinel">
|
||||
{#if fetchedDaily}
|
||||
<DailyStripSticky
|
||||
@@ -267,15 +267,15 @@
|
||||
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#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" transition:fade={{ duration: 200 }}>
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||
</section>
|
||||
{/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" transition:fade={{ duration: 200 }}>
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
{#if canExtendPast && onExtendPast}
|
||||
<button
|
||||
type="button"
|
||||
class="mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
class="day-side mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
onclick={onExtendPast}
|
||||
aria-label="Load recent past days"
|
||||
>
|
||||
@@ -347,7 +347,7 @@
|
||||
{#if daily && canExtend && onExtend}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
|
||||
class="day-side mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
|
||||
onclick={onExtend}
|
||||
aria-label="Load the longer-range forecast"
|
||||
>
|
||||
@@ -409,10 +409,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
|
||||
/* Keep the exact same layout at every size, just scale it: small screens get
|
||||
the compact scale, and mid-size desktops (anything below ~1440p) a gentler
|
||||
one so the cards don't look oversized on 1080p displays. */
|
||||
@media (max-width: 768px) {
|
||||
.day-card {
|
||||
zoom: 0.72;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) and (max-width: 2200px) {
|
||||
.day-card,
|
||||
.day-side {
|
||||
zoom: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,10 +30,9 @@
|
||||
onExtendPast
|
||||
}: Props = $props();
|
||||
|
||||
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
|
||||
// The strip collapses from full cards to a compact strip as it sticks. All
|
||||
// sizing derives from a single registered custom property `--strip-p` (0 =
|
||||
// full, 1 = compact):
|
||||
// ─── Scroll-driven collapse (full → compact, mobile only) ───────────────────
|
||||
// All sizing derives from a single registered custom property `--strip-p`
|
||||
// (0 = full cards, 1 = compact strip):
|
||||
//
|
||||
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
|
||||
// scrub `--strip-p` natively from a view-timeline on the sentinel below —
|
||||
@@ -41,6 +40,9 @@
|
||||
// * Everything else (Firefox, older Safari) snaps between the two states
|
||||
// with a short CSS transition instead: an IntersectionObserver on the same
|
||||
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
|
||||
//
|
||||
// On md+ the strip is pinned to the compact state (no animation) and docks
|
||||
// under the topbar as a slim always-sticky day picker.
|
||||
let sentinelEl = $state<HTMLDivElement>();
|
||||
let stripScrollEl = $state<HTMLDivElement>();
|
||||
let daysWrapEl = $state<HTMLDivElement>();
|
||||
@@ -83,19 +85,19 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- progress sentinel: an invisible 120px band (taking no layout space) just
|
||||
above the sticky strip. Its exit across the scrollport top drives the
|
||||
collapse — via view-timeline where supported, IntersectionObserver
|
||||
otherwise. It sits above the strip so the strip shrinking (which reflows
|
||||
the table below) never feeds back into the measurement. -->
|
||||
<!-- progress sentinel: an invisible band (taking no layout space) just above
|
||||
the sticky strip. Its exit across the scrollport top drives the collapse —
|
||||
via view-timeline where supported, IntersectionObserver otherwise. It sits
|
||||
above the strip so the strip shrinking never feeds back into the
|
||||
measurement. -->
|
||||
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
||||
|
||||
<div
|
||||
class="daystrip sticky -top-3 z-30 -mx-3 md:hidden"
|
||||
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
|
||||
class:js-snap={needsSnapFallback}
|
||||
class:compact
|
||||
>
|
||||
<div class="strip-row flex overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
|
||||
<div class="strip-row flex overflow-x-auto px-3 py-2 md:py-1 lg:px-8" bind:this={stripScrollEl}>
|
||||
{#if daily}
|
||||
{#if canExtendPast && onExtendPast}
|
||||
<button
|
||||
@@ -105,7 +107,7 @@
|
||||
aria-label="Load recent past days"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
class="h-5 w-5 md:h-4 md:w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -133,21 +135,26 @@
|
||||
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected
|
||||
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 md:gap-0 md:pb-0 {selected
|
||||
? 'border-primary bg-primary/10 ring-1 ring-primary/50'
|
||||
: 'border-border/60 bg-card'}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => onSelectDay(time, index)}
|
||||
>
|
||||
<!-- weekday drifts to the left edge and the date fades in at the
|
||||
right edge as the cards collapse (flex spacers driven by --rel) -->
|
||||
<span
|
||||
class="text-[11px] font-semibold tracking-wide whitespace-nowrap {selected
|
||||
class="dow-row flex w-full items-baseline px-0.5 text-[11px] font-semibold tracking-wide whitespace-nowrap md:text-[10px] {selected
|
||||
? 'text-primary'
|
||||
: ''}"
|
||||
>
|
||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}<span
|
||||
class="date-inline align-baseline font-medium tabular-nums text-muted-foreground"
|
||||
> {formatZoned(time, daily.timezone, 'd')}</span
|
||||
<span class="dow-spacer"></span>
|
||||
<span>{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}</span>
|
||||
<span class="dow-mid"></span>
|
||||
<span class="date-inline text-right font-medium tabular-nums text-muted-foreground"
|
||||
>{formatZoned(time, daily.timezone, 'd')}</span
|
||||
>
|
||||
<span class="dow-spacer"></span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
@@ -156,7 +163,7 @@
|
||||
{getRelativeDayLabel(time, daily.timezone)}
|
||||
</span>
|
||||
|
||||
<div class="relative">
|
||||
<div class="icon-wrap relative">
|
||||
<svg class="day-icon fill-foreground">
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
@@ -180,7 +187,7 @@
|
||||
|
||||
<div class="flex items-baseline gap-1 leading-none">
|
||||
<span
|
||||
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums"
|
||||
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums md:py-px"
|
||||
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
||||
>
|
||||
{tempMax.toFixed(0)}°
|
||||
@@ -218,7 +225,7 @@
|
||||
aria-label="Load the longer-range forecast"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
class="h-5 w-5 md:h-4 md:w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -246,27 +253,32 @@
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
/* Invisible 120px collapse band: the distance over which the collapse plays
|
||||
out. The negative margin removes it from layout so nothing shifts. */
|
||||
.sentinel {
|
||||
height: 120px;
|
||||
margin-bottom: -120px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Tunables, shared by the strip and its sentinel: the collapse plays out
|
||||
over exactly the cell-height difference (see .daystrip height below). */
|
||||
.sentinel,
|
||||
.daystrip {
|
||||
/* tunables */
|
||||
--cell-w-full: 76px;
|
||||
--cell-w-min: 56px;
|
||||
--cell-h-full: 146px;
|
||||
--cell-h-min: 66px;
|
||||
--cell-h-full: 140px;
|
||||
--cell-h-min: 62px;
|
||||
--icon-full: 44px;
|
||||
--icon-min: 21px;
|
||||
--pt-full: 7px;
|
||||
--pt-min: 2px;
|
||||
--gap-full: 8px;
|
||||
--gap-min: 4px;
|
||||
--collapse: calc(var(--cell-h-full) - var(--cell-h-min));
|
||||
}
|
||||
|
||||
/* Invisible collapse band: the scroll distance over which the collapse
|
||||
plays. The negative margin removes it from layout so nothing shifts. */
|
||||
.sentinel {
|
||||
height: var(--collapse);
|
||||
margin-bottom: calc(-1 * var(--collapse));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.daystrip {
|
||||
--strip-p: 0;
|
||||
|
||||
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
|
||||
@@ -278,12 +290,29 @@
|
||||
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
|
||||
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
|
||||
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
|
||||
/* bar background/divider fade in once it starts collapsing */
|
||||
--chrome: clamp(0, calc(var(--strip-p) / 0.35), 1);
|
||||
/* bar background turns opaque almost as soon as the strip sticks, so
|
||||
content never shows through it */
|
||||
--chrome: clamp(0, calc(var(--strip-p) * 6), 1);
|
||||
|
||||
/* Opaque-background fade only — no backdrop-filter blur or animated
|
||||
box-shadow (both are very expensive to repaint every scroll frame on
|
||||
mobile). Promote to its own compositor layer so repaints stay isolated. */
|
||||
/* The sticky box keeps a CONSTANT height — only its contents shrink.
|
||||
The collapse therefore never resizes the document (the large table
|
||||
below would otherwise reflow on every scroll frame: the main source
|
||||
of scroll jank on mobile Chromium), and because the collapse distance
|
||||
equals the height difference, the table slides up under the shrinking
|
||||
bar in exact sync. The empty lower part is click-through. */
|
||||
height: calc(var(--cell-h-full) + 16px);
|
||||
pointer-events: none;
|
||||
contain: layout style;
|
||||
/* own compositor layer: per-frame repaints stay isolated to the strip */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* The visible bar: hugs the (shrinking) content and carries the chrome.
|
||||
No backdrop-filter blur or animated box-shadow — both are very expensive
|
||||
to repaint every scroll frame on mobile. */
|
||||
.strip-row {
|
||||
pointer-events: auto;
|
||||
gap: var(--gap);
|
||||
background: color-mix(
|
||||
in oklab,
|
||||
var(--color-background) calc(var(--chrome) * 100%),
|
||||
@@ -291,7 +320,6 @@
|
||||
);
|
||||
border-bottom: 1px solid
|
||||
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Scrub path: the sentinel's exit across the scrollport top maps directly to
|
||||
@@ -327,13 +355,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.strip-row,
|
||||
/* md+: no collapse — the strip is a slim, always-compact day picker that
|
||||
docks under the topbar, matching its h-14 (56px) height exactly. */
|
||||
@media (min-width: 768px) {
|
||||
.sentinel,
|
||||
.daystrip {
|
||||
--cell-h-min: 48px;
|
||||
--icon-min: 20px;
|
||||
--gap-min: 6px;
|
||||
}
|
||||
.daystrip {
|
||||
--strip-p: 1;
|
||||
animation: none;
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.strip-days {
|
||||
gap: var(--gap);
|
||||
}
|
||||
.strip-cell,
|
||||
.strip-side {
|
||||
width: var(--cell-w);
|
||||
height: var(--cell-h);
|
||||
/* size tracks the collapse exactly; only the tap highlight eases */
|
||||
transition:
|
||||
@@ -341,9 +383,22 @@
|
||||
background-color 0.15s;
|
||||
}
|
||||
.strip-cell {
|
||||
width: var(--cell-w);
|
||||
padding-top: var(--pt);
|
||||
}
|
||||
/* Side buttons keep the compact width in BOTH states, so almost nothing to
|
||||
the left of the first day changes size during the collapse — the first
|
||||
day stays put instead of drifting off-screen. */
|
||||
.strip-side {
|
||||
width: var(--cell-w-min);
|
||||
}
|
||||
.icon-wrap {
|
||||
/* tuck the icon into its line box: the glyphs carry generous built-in
|
||||
padding, so pulling the neighbours in keeps the cells tight */
|
||||
margin-block: -2px -3px;
|
||||
}
|
||||
.day-icon {
|
||||
display: block;
|
||||
width: var(--icon);
|
||||
height: var(--icon);
|
||||
}
|
||||
@@ -352,17 +407,17 @@
|
||||
height: calc(var(--icon) * 0.44);
|
||||
opacity: var(--rel);
|
||||
}
|
||||
/* Day-of-month slides in next to the weekday as the cards collapse
|
||||
("MON" → "MON 12"), replacing the relative label that fades out. */
|
||||
/* weekday centered when full, pushed to the edges when compact */
|
||||
.dow-spacer {
|
||||
flex-grow: var(--rel);
|
||||
}
|
||||
.dow-mid {
|
||||
flex-grow: calc(1 - var(--rel));
|
||||
}
|
||||
.date-inline {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
/* overflow≠visible makes an inline-block's baseline its bottom edge, which
|
||||
would render the digits superscript; text-bottom restores baseline
|
||||
alignment (same font size as the weekday, so descents match). */
|
||||
vertical-align: text-bottom;
|
||||
max-width: calc(20px * (1 - var(--rel)));
|
||||
/* width 0 while the cards are full so the weekday stays exactly centered */
|
||||
width: calc(14px * (1 - var(--rel)));
|
||||
opacity: calc(1 - var(--rel));
|
||||
}
|
||||
.rel-label {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
import {
|
||||
defaultVariablePrefs,
|
||||
mergeTableRowOrder,
|
||||
storedHourlyInterval,
|
||||
storedTableRowOrder,
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
@@ -60,6 +62,10 @@
|
||||
// persisted 1h / 3h preference
|
||||
let hourlyInterval = $derived($storedHourlyInterval);
|
||||
|
||||
// Row order, controlled from the Variables sidebar (stored orders are
|
||||
// normalized so rows added in app updates still appear).
|
||||
let rowOrder = $derived(mergeTableRowOrder($storedTableRowOrder));
|
||||
|
||||
// Row visibility, controlled from the Variables sidebar (missing keys
|
||||
// from older stored prefs default to visible)
|
||||
let showRow = $derived(
|
||||
@@ -580,223 +586,201 @@
|
||||
</tr>
|
||||
|
||||
<!-- Weather Icons -->
|
||||
{#if showRow('icons')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-day-cloudy')}
|
||||
{#each cellData as cell, i (cell.idx)}
|
||||
{@const wCode = hourly.weather_code[cell.idx]}
|
||||
<td
|
||||
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}
|
||||
class:icon-dusk={cell.isDaytime &&
|
||||
cellData[i + 1] &&
|
||||
!cellData[i + 1].isDaytime}
|
||||
>
|
||||
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Temperature -->
|
||||
{#if showRow('temperature')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||
<td
|
||||
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||
style="background-color:{style.bg};color:{style.fg}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- 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-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}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Wind -->
|
||||
{#if showRow('wind')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||
<td class="relative cell h-12 align-middle leading-none">
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
|
||||
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
|
||||
>
|
||||
{@render weatherIcon('wi-direction-down', 40)}
|
||||
{#each rowOrder as rowKey (rowKey)}
|
||||
{#if rowKey === 'icons' && showRow('icons')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-day-cloudy')}
|
||||
{#each cellData as cell, i (cell.idx)}
|
||||
{@const wCode = hourly.weather_code[cell.idx]}
|
||||
<td
|
||||
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}
|
||||
class:icon-dusk={cell.isDaytime &&
|
||||
cellData[i + 1] &&
|
||||
!cellData[i + 1].isDaytime}
|
||||
>
|
||||
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Temperature -->
|
||||
{:else if rowKey === 'temperature' && showRow('temperature')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||
<td
|
||||
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||
style="background-color:{style.bg};color:{style.fg}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Feels Like (short row) -->
|
||||
{:else if rowKey === 'feels' && 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-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Dew Point (short row; off by default) -->
|
||||
{:else if rowKey === 'dew_point' && 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}
|
||||
</tr>
|
||||
<!-- Wind -->
|
||||
{:else if rowKey === 'wind' && showRow('wind')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||
<td class="relative cell h-12 align-middle leading-none">
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
|
||||
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
|
||||
>
|
||||
{@render weatherIcon('wi-direction-down', 40)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||
{formatValue(wind)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||
{formatValue(wind)}
|
||||
</span>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- 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-12" style="background:{getHumidityBg(hum ?? 0)}">
|
||||
{formatValue(hum)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Cloud Cover -->
|
||||
{#if showRow('clouds')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-cloud', '%', 'Clouds')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||
<td
|
||||
class="cell h-12"
|
||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||
>
|
||||
{formatValue(cloud)}
|
||||
</td>
|
||||
{/each}
|
||||
</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">
|
||||
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const precip = hourly.precipitation[cell.idx]}
|
||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||
<td
|
||||
class="cell precip-cell h-12"
|
||||
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||
title={formatPrecipTooltip(precip, prob)}
|
||||
>
|
||||
{#if precip > 0}
|
||||
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
|
||||
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
|
||||
{precip.toFixed(1)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/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}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Wind Gusts (off by default) -->
|
||||
{:else if rowKey === 'gusts' && 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>
|
||||
<!-- Humidity (short row) -->
|
||||
{:else if rowKey === 'humidity' && 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-12" style="background:{getHumidityBg(hum ?? 0)}">
|
||||
{formatValue(hum)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Cloud Cover -->
|
||||
{:else if rowKey === 'clouds' && showRow('clouds')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-cloud', '%', 'Clouds')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||
<td
|
||||
class="cell h-12"
|
||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||
>
|
||||
{formatValue(cloud)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Pressure (off by default) -->
|
||||
{:else if rowKey === 'pressure' && 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>
|
||||
<!-- UV Index (off by default) -->
|
||||
{:else if rowKey === 'uv' && 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>
|
||||
<!-- Visibility (off by default) -->
|
||||
{:else if rowKey === 'visibility' && 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>
|
||||
<!-- Precipitation -->
|
||||
{:else if rowKey === 'precipitation' && showRow('precipitation')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const precip = hourly.precipitation[cell.idx]}
|
||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||
<td
|
||||
class="cell precip-cell h-12"
|
||||
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||
title={formatPrecipTooltip(precip, prob)}
|
||||
>
|
||||
{#if precip > 0}
|
||||
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
|
||||
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
|
||||
{precip.toFixed(1)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<!-- Snowfall (off by default) -->
|
||||
{:else if rowKey === 'snowfall' && 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}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
import { defaultVariablePrefs, storedVariablePrefs } from '$lib/stores/settings';
|
||||
import {
|
||||
defaultTableRowOrder,
|
||||
defaultVariablePrefs,
|
||||
mergeTableRowOrder,
|
||||
storedTableRowOrder,
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
@@ -13,21 +19,24 @@
|
||||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
const tableVariables = [
|
||||
{ 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: 'pressure', label: 'Pressure' },
|
||||
{ key: 'uv', label: 'UV index' },
|
||||
{ key: 'visibility', label: 'Visibility' },
|
||||
{ key: 'precipitation', label: 'Precipitation' },
|
||||
{ key: 'snowfall', label: 'Snowfall' }
|
||||
];
|
||||
const tableVariableLabels: Record<string, string> = {
|
||||
icons: 'Weather icons',
|
||||
temperature: 'Temperature',
|
||||
feels: 'Feels like',
|
||||
dew_point: 'Dew point',
|
||||
wind: 'Wind',
|
||||
gusts: 'Wind gusts',
|
||||
humidity: 'Humidity',
|
||||
clouds: 'Cloud cover',
|
||||
pressure: 'Pressure',
|
||||
uv: 'UV index',
|
||||
visibility: 'Visibility',
|
||||
precipitation: 'Precipitation',
|
||||
snowfall: 'Snowfall'
|
||||
};
|
||||
|
||||
// Listed in the same (customizable) order the table renders its rows.
|
||||
let tableRowOrder = $derived(mergeTableRowOrder($storedTableRowOrder));
|
||||
|
||||
function toggle(section: 'table' | 'charts', key: string) {
|
||||
storedVariablePrefs.update((prefs) => {
|
||||
@@ -36,8 +45,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Moves a table row up (-1) or down (+1) in the rendered order. */
|
||||
function moveRow(key: string, dir: -1 | 1) {
|
||||
storedTableRowOrder.update((stored) => {
|
||||
const order = mergeTableRowOrder(stored);
|
||||
const i = order.indexOf(key);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= order.length) return order;
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
return order;
|
||||
});
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
storedVariablePrefs.set(structuredClone(defaultVariablePrefs));
|
||||
storedTableRowOrder.set([...defaultTableRowOrder]);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,17 +110,54 @@
|
||||
Hourly table
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tableVariables as variable (variable.key)}
|
||||
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
|
||||
{#each tableRowOrder as key, i (key)}
|
||||
<div class="group flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
|
||||
<Checkbox
|
||||
id="table_var_{variable.key}"
|
||||
id="table_var_{key}"
|
||||
class="cursor-pointer"
|
||||
checked={$storedVariablePrefs.table?.[variable.key] ?? true}
|
||||
onCheckedChange={() => toggle('table', variable.key)}
|
||||
checked={$storedVariablePrefs.table?.[key] ?? true}
|
||||
onCheckedChange={() => toggle('table', key)}
|
||||
/>
|
||||
<Label class="flex-1 cursor-pointer text-sm" for="table_var_{variable.key}">
|
||||
{variable.label}
|
||||
<Label class="flex-1 cursor-pointer text-sm" for="table_var_{key}">
|
||||
{tableVariableLabels[key] ?? key}
|
||||
</Label>
|
||||
<!-- reorder: rows render in this exact order in the table -->
|
||||
<div
|
||||
class="flex items-center opacity-40 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<button
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
onclick={() => moveRow(key, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label="Move {tableVariableLabels[key] ?? key} up"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.25"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 14l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
onclick={() => moveRow(key, 1)}
|
||||
disabled={i === tableRowOrder.length - 1}
|
||||
aria-label="Move {tableVariableLabels[key] ?? key} down"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.25"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 10l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user