try strip desktop
This commit is contained in:
@@ -86,6 +86,37 @@ export const defaultVariablePrefs: VariablePrefs = {
|
|||||||
|
|
||||||
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
||||||
|
|
||||||
|
/** Order of the hourly-table rows (visibility is the separate toggle above). */
|
||||||
|
export const defaultTableRowOrder: string[] = [
|
||||||
|
'icons',
|
||||||
|
'temperature',
|
||||||
|
'feels',
|
||||||
|
'dew_point',
|
||||||
|
'wind',
|
||||||
|
'gusts',
|
||||||
|
'humidity',
|
||||||
|
'clouds',
|
||||||
|
'pressure',
|
||||||
|
'uv',
|
||||||
|
'visibility',
|
||||||
|
'precipitation',
|
||||||
|
'snowfall'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const storedTableRowOrder = persisted<string[]>('table_row_order_v1', defaultTableRowOrder);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a stored row order against the known rows: drops keys that no
|
||||||
|
* longer exist and appends new rows (added after the user last saved) at the
|
||||||
|
* end, so stored orders survive app updates.
|
||||||
|
*/
|
||||||
|
export function mergeTableRowOrder(stored: string[]): string[] {
|
||||||
|
return [
|
||||||
|
...stored.filter((k) => defaultTableRowOrder.includes(k)),
|
||||||
|
...defaultTableRowOrder.filter((k) => !stored.includes(k))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** Hourly table interval: 3-hourly (default) or 1-hourly. */
|
/** Hourly table interval: 3-hourly (default) or 1-hourly. */
|
||||||
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
|
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
|
||||||
|
|
||||||
|
|||||||
@@ -231,12 +231,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
|
<!-- The sticky day strip, the hourly table AND the meteograms share this
|
||||||
the strip stays stuck (collapsing as it goes) while scrolling the table
|
wrapper, so the strip stays stuck for the entire page (it collapses
|
||||||
and then releases exactly at the table's bottom, freeing the meteograms.
|
as it sticks on mobile; on md+ it's a slim always-compact bar under
|
||||||
The strip itself is hidden on md+ (the desktop cards above take over).
|
the topbar, complementing the full cards above). timeline-scope
|
||||||
timeline-scope hoists the strip's sentinel view-timeline so the sticky
|
hoists the strip's sentinel view-timeline so the sticky strip (a
|
||||||
strip (a sibling of the sentinel) can scrub its collapse from it. -->
|
sibling of the sentinel) can scrub its collapse from it. -->
|
||||||
<div style="timeline-scope: --daystrip-sentinel">
|
<div style="timeline-scope: --daystrip-sentinel">
|
||||||
{#if fetchedDaily}
|
{#if fetchedDaily}
|
||||||
<DailyStripSticky
|
<DailyStripSticky
|
||||||
@@ -267,15 +267,15 @@
|
|||||||
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
|
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if fetchedHourly}
|
{#if fetchedHourly}
|
||||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||||
{:else}
|
{:else}
|
||||||
<!-- reserve the exact chart area height before the first fetch resolves -->
|
<!-- reserve the exact chart area height before the first fetch resolves -->
|
||||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
{#if canExtendPast && onExtendPast}
|
{#if canExtendPast && onExtendPast}
|
||||||
<button
|
<button
|
||||||
type="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}
|
onclick={onExtendPast}
|
||||||
aria-label="Load recent past days"
|
aria-label="Load recent past days"
|
||||||
>
|
>
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
{#if daily && canExtend && onExtend}
|
{#if daily && canExtend && onExtend}
|
||||||
<button
|
<button
|
||||||
type="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}
|
onclick={onExtend}
|
||||||
aria-label="Load the longer-range forecast"
|
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) {
|
@media (max-width: 768px) {
|
||||||
.day-card {
|
.day-card {
|
||||||
zoom: 0.72;
|
zoom: 0.72;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (min-width: 768px) and (max-width: 2200px) {
|
||||||
|
.day-card,
|
||||||
|
.day-side {
|
||||||
|
zoom: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -30,10 +30,9 @@
|
|||||||
onExtendPast
|
onExtendPast
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
|
// ─── Scroll-driven collapse (full → compact, mobile only) ───────────────────
|
||||||
// The strip collapses from full cards to a compact strip as it sticks. All
|
// All sizing derives from a single registered custom property `--strip-p`
|
||||||
// sizing derives from a single registered custom property `--strip-p` (0 =
|
// (0 = full cards, 1 = compact strip):
|
||||||
// full, 1 = compact):
|
|
||||||
//
|
//
|
||||||
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
|
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
|
||||||
// scrub `--strip-p` natively from a view-timeline on the sentinel below —
|
// 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
|
// * Everything else (Firefox, older Safari) snaps between the two states
|
||||||
// with a short CSS transition instead: an IntersectionObserver on the same
|
// with a short CSS transition instead: an IntersectionObserver on the same
|
||||||
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
|
// 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 sentinelEl = $state<HTMLDivElement>();
|
||||||
let stripScrollEl = $state<HTMLDivElement>();
|
let stripScrollEl = $state<HTMLDivElement>();
|
||||||
let daysWrapEl = $state<HTMLDivElement>();
|
let daysWrapEl = $state<HTMLDivElement>();
|
||||||
@@ -83,19 +85,19 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- progress sentinel: an invisible 120px band (taking no layout space) just
|
<!-- progress sentinel: an invisible band (taking no layout space) just above
|
||||||
above the sticky strip. Its exit across the scrollport top drives the
|
the sticky strip. Its exit across the scrollport top drives the collapse —
|
||||||
collapse — via view-timeline where supported, IntersectionObserver
|
via view-timeline where supported, IntersectionObserver otherwise. It sits
|
||||||
otherwise. It sits above the strip so the strip shrinking (which reflows
|
above the strip so the strip shrinking never feeds back into the
|
||||||
the table below) never feeds back into the measurement. -->
|
measurement. -->
|
||||||
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
||||||
|
|
||||||
<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:js-snap={needsSnapFallback}
|
||||||
class:compact
|
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 daily}
|
||||||
{#if canExtendPast && onExtendPast}
|
{#if canExtendPast && onExtendPast}
|
||||||
<button
|
<button
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
aria-label="Load recent past days"
|
aria-label="Load recent past days"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5"
|
class="h-5 w-5 md:h-4 md:w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -133,21 +135,26 @@
|
|||||||
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||||
<button
|
<button
|
||||||
type="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-primary bg-primary/10 ring-1 ring-primary/50'
|
||||||
: 'border-border/60 bg-card'}"
|
: 'border-border/60 bg-card'}"
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onclick={() => onSelectDay(time, index)}
|
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
|
<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'
|
? 'text-primary'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}<span
|
<span class="dow-spacer"></span>
|
||||||
class="date-inline align-baseline font-medium tabular-nums text-muted-foreground"
|
<span>{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}</span>
|
||||||
> {formatZoned(time, daily.timezone, 'd')}</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>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -156,7 +163,7 @@
|
|||||||
{getRelativeDayLabel(time, daily.timezone)}
|
{getRelativeDayLabel(time, daily.timezone)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="icon-wrap relative">
|
||||||
<svg class="day-icon fill-foreground">
|
<svg class="day-icon fill-foreground">
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
@@ -180,7 +187,7 @@
|
|||||||
|
|
||||||
<div class="flex items-baseline gap-1 leading-none">
|
<div class="flex items-baseline gap-1 leading-none">
|
||||||
<span
|
<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}"
|
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
||||||
>
|
>
|
||||||
{tempMax.toFixed(0)}°
|
{tempMax.toFixed(0)}°
|
||||||
@@ -218,7 +225,7 @@
|
|||||||
aria-label="Load the longer-range forecast"
|
aria-label="Load the longer-range forecast"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5"
|
class="h-5 w-5 md:h-4 md:w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -246,27 +253,32 @@
|
|||||||
initial-value: 0;
|
initial-value: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Invisible 120px collapse band: the distance over which the collapse plays
|
/* Tunables, shared by the strip and its sentinel: the collapse plays out
|
||||||
out. The negative margin removes it from layout so nothing shifts. */
|
over exactly the cell-height difference (see .daystrip height below). */
|
||||||
.sentinel {
|
.sentinel,
|
||||||
height: 120px;
|
|
||||||
margin-bottom: -120px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.daystrip {
|
.daystrip {
|
||||||
/* tunables */
|
|
||||||
--cell-w-full: 76px;
|
--cell-w-full: 76px;
|
||||||
--cell-w-min: 56px;
|
--cell-w-min: 56px;
|
||||||
--cell-h-full: 146px;
|
--cell-h-full: 140px;
|
||||||
--cell-h-min: 66px;
|
--cell-h-min: 62px;
|
||||||
--icon-full: 44px;
|
--icon-full: 44px;
|
||||||
--icon-min: 21px;
|
--icon-min: 21px;
|
||||||
--pt-full: 7px;
|
--pt-full: 7px;
|
||||||
--pt-min: 2px;
|
--pt-min: 2px;
|
||||||
--gap-full: 8px;
|
--gap-full: 8px;
|
||||||
--gap-min: 4px;
|
--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;
|
--strip-p: 0;
|
||||||
|
|
||||||
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
|
/* 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));
|
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
|
||||||
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
|
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
|
||||||
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
|
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
|
||||||
/* bar background/divider fade in once it starts collapsing */
|
/* bar background turns opaque almost as soon as the strip sticks, so
|
||||||
--chrome: clamp(0, calc(var(--strip-p) / 0.35), 1);
|
content never shows through it */
|
||||||
|
--chrome: clamp(0, calc(var(--strip-p) * 6), 1);
|
||||||
|
|
||||||
/* Opaque-background fade only — no backdrop-filter blur or animated
|
/* The sticky box keeps a CONSTANT height — only its contents shrink.
|
||||||
box-shadow (both are very expensive to repaint every scroll frame on
|
The collapse therefore never resizes the document (the large table
|
||||||
mobile). Promote to its own compositor layer so repaints stay isolated. */
|
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(
|
background: color-mix(
|
||||||
in oklab,
|
in oklab,
|
||||||
var(--color-background) calc(var(--chrome) * 100%),
|
var(--color-background) calc(var(--chrome) * 100%),
|
||||||
@@ -291,7 +320,6 @@
|
|||||||
);
|
);
|
||||||
border-bottom: 1px solid
|
border-bottom: 1px solid
|
||||||
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
|
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
|
/* 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 {
|
.strip-days {
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
}
|
}
|
||||||
.strip-cell,
|
.strip-cell,
|
||||||
.strip-side {
|
.strip-side {
|
||||||
width: var(--cell-w);
|
|
||||||
height: var(--cell-h);
|
height: var(--cell-h);
|
||||||
/* size tracks the collapse exactly; only the tap highlight eases */
|
/* size tracks the collapse exactly; only the tap highlight eases */
|
||||||
transition:
|
transition:
|
||||||
@@ -341,9 +383,22 @@
|
|||||||
background-color 0.15s;
|
background-color 0.15s;
|
||||||
}
|
}
|
||||||
.strip-cell {
|
.strip-cell {
|
||||||
|
width: var(--cell-w);
|
||||||
padding-top: var(--pt);
|
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 {
|
.day-icon {
|
||||||
|
display: block;
|
||||||
width: var(--icon);
|
width: var(--icon);
|
||||||
height: var(--icon);
|
height: var(--icon);
|
||||||
}
|
}
|
||||||
@@ -352,17 +407,17 @@
|
|||||||
height: calc(var(--icon) * 0.44);
|
height: calc(var(--icon) * 0.44);
|
||||||
opacity: var(--rel);
|
opacity: var(--rel);
|
||||||
}
|
}
|
||||||
/* Day-of-month slides in next to the weekday as the cards collapse
|
/* weekday centered when full, pushed to the edges when compact */
|
||||||
("MON" → "MON 12"), replacing the relative label that fades out. */
|
.dow-spacer {
|
||||||
|
flex-grow: var(--rel);
|
||||||
|
}
|
||||||
|
.dow-mid {
|
||||||
|
flex-grow: calc(1 - var(--rel));
|
||||||
|
}
|
||||||
.date-inline {
|
.date-inline {
|
||||||
display: inline-block;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
/* width 0 while the cards are full so the weekday stays exactly centered */
|
||||||
/* overflow≠visible makes an inline-block's baseline its bottom edge, which
|
width: calc(14px * (1 - var(--rel)));
|
||||||
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)));
|
|
||||||
opacity: calc(1 - var(--rel));
|
opacity: calc(1 - var(--rel));
|
||||||
}
|
}
|
||||||
.rel-label {
|
.rel-label {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
defaultVariablePrefs,
|
defaultVariablePrefs,
|
||||||
|
mergeTableRowOrder,
|
||||||
storedHourlyInterval,
|
storedHourlyInterval,
|
||||||
|
storedTableRowOrder,
|
||||||
storedVariablePrefs
|
storedVariablePrefs
|
||||||
} from '$lib/stores/settings';
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
@@ -60,6 +62,10 @@
|
|||||||
// persisted 1h / 3h preference
|
// persisted 1h / 3h preference
|
||||||
let hourlyInterval = $derived($storedHourlyInterval);
|
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
|
// Row visibility, controlled from the Variables sidebar (missing keys
|
||||||
// from older stored prefs default to visible)
|
// from older stored prefs default to visible)
|
||||||
let showRow = $derived(
|
let showRow = $derived(
|
||||||
@@ -580,223 +586,201 @@
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Weather Icons -->
|
<!-- Weather Icons -->
|
||||||
{#if showRow('icons')}
|
{#each rowOrder as rowKey (rowKey)}
|
||||||
<tr class="row">
|
{#if rowKey === 'icons' && showRow('icons')}
|
||||||
{@render rowHeader('wi-day-cloudy')}
|
<tr class="row">
|
||||||
{#each cellData as cell, i (cell.idx)}
|
{@render rowHeader('wi-day-cloudy')}
|
||||||
{@const wCode = hourly.weather_code[cell.idx]}
|
{#each cellData as cell, i (cell.idx)}
|
||||||
<td
|
{@const wCode = hourly.weather_code[cell.idx]}
|
||||||
class="cell h-12 leading-0"
|
<td
|
||||||
class:icon-day={cell.isDaytime}
|
class="cell h-12 leading-0"
|
||||||
class:icon-night={!cell.isDaytime}
|
class:icon-day={cell.isDaytime}
|
||||||
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
class:icon-night={!cell.isDaytime}
|
||||||
class:icon-dusk={cell.isDaytime &&
|
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
||||||
cellData[i + 1] &&
|
class:icon-dusk={cell.isDaytime &&
|
||||||
!cellData[i + 1].isDaytime}
|
cellData[i + 1] &&
|
||||||
>
|
!cellData[i + 1].isDaytime}
|
||||||
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
>
|
||||||
</td>
|
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||||
{/each}
|
</td>
|
||||||
</tr>
|
{/each}
|
||||||
{/if}
|
</tr>
|
||||||
|
<!-- Temperature -->
|
||||||
<!-- Temperature -->
|
{:else if rowKey === 'temperature' && showRow('temperature')}
|
||||||
{#if showRow('temperature')}
|
<tr class="row">
|
||||||
<tr class="row">
|
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
|
||||||
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
|
{#each cellData as cell (cell.idx)}
|
||||||
{#each cellData as cell (cell.idx)}
|
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
<td
|
||||||
<td
|
class="cell h-12 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}"
|
||||||
style="background-color:{style.bg};color:{style.fg}"
|
>
|
||||||
>
|
{formatTemp(temp)}
|
||||||
{formatTemp(temp)}
|
</td>
|
||||||
</td>
|
{/each}
|
||||||
{/each}
|
</tr>
|
||||||
</tr>
|
<!-- Feels Like (short row) -->
|
||||||
{/if}
|
{:else if rowKey === 'feels' && showRow('feels')}
|
||||||
|
<tr class="row">
|
||||||
<!-- Feels Like (short row) -->
|
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||||
{#if showRow('feels')}
|
{#each cellData as cell (cell.idx)}
|
||||||
<tr class="row">
|
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
<td
|
||||||
{#each cellData as cell (cell.idx)}
|
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||||
{@const temp = hourly.apparent_temperature[cell.idx]}
|
>
|
||||||
<td
|
{formatTemp(temp)}
|
||||||
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
</td>
|
||||||
>
|
{/each}
|
||||||
{formatTemp(temp)}
|
</tr>
|
||||||
</td>
|
<!-- Dew Point (short row; off by default) -->
|
||||||
{/each}
|
{:else if rowKey === 'dew_point' && showRow('dew_point')}
|
||||||
</tr>
|
<tr class="row">
|
||||||
{/if}
|
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
<!-- Dew Point (short row; off by default) -->
|
{@const temp = hourly.dew_point_2m?.[cell.idx]}
|
||||||
{#if showRow('dew_point')}
|
<td
|
||||||
<tr class="row">
|
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||||
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
|
>
|
||||||
{#each cellData as cell (cell.idx)}
|
{formatTemp(temp)}
|
||||||
{@const temp = hourly.dew_point_2m?.[cell.idx]}
|
</td>
|
||||||
<td
|
{/each}
|
||||||
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
</tr>
|
||||||
>
|
<!-- Wind -->
|
||||||
{formatTemp(temp)}
|
{:else if rowKey === 'wind' && showRow('wind')}
|
||||||
</td>
|
<tr class="row">
|
||||||
{/each}
|
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
|
||||||
</tr>
|
{#each cellData as cell (cell.idx)}
|
||||||
{/if}
|
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||||
|
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||||
<!-- Wind -->
|
<td class="relative cell h-12 align-middle leading-none">
|
||||||
{#if showRow('wind')}
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
<tr class="row">
|
<span
|
||||||
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
|
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
|
||||||
{#each cellData as cell (cell.idx)}
|
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
|
||||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
>
|
||||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
{@render weatherIcon('wi-direction-down', 40)}
|
||||||
<td class="relative cell h-12 align-middle leading-none">
|
</span>
|
||||||
{#if windDir != null && !isNaN(windDir)}
|
{/if}
|
||||||
<span
|
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||||
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
|
{formatValue(wind)}
|
||||||
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
|
|
||||||
>
|
|
||||||
{@render weatherIcon('wi-direction-down', 40)}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
</td>
|
||||||
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
{/each}
|
||||||
{formatValue(wind)}
|
</tr>
|
||||||
</span>
|
<!-- Wind Gusts (off by default) -->
|
||||||
</td>
|
{:else if rowKey === 'gusts' && showRow('gusts')}
|
||||||
{/each}
|
<tr class="row">
|
||||||
</tr>
|
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
|
||||||
{/if}
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
|
||||||
<!-- Wind Gusts (off by default) -->
|
<td
|
||||||
{#if showRow('gusts')}
|
class="cell h-12 font-semibold text-foreground/80 {is3h
|
||||||
<tr class="row">
|
? 'text-sm'
|
||||||
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
|
: 'text-xs'}"
|
||||||
{#each cellData as cell (cell.idx)}
|
>
|
||||||
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
|
{formatValue(v)}
|
||||||
<td
|
</td>
|
||||||
class="cell h-12 font-semibold text-foreground/80 {is3h
|
{/each}
|
||||||
? 'text-sm'
|
</tr>
|
||||||
: 'text-xs'}"
|
<!-- Humidity (short row) -->
|
||||||
>
|
{:else if rowKey === 'humidity' && showRow('humidity')}
|
||||||
{formatValue(v)}
|
<tr class="row">
|
||||||
</td>
|
{@render rowHeader('wi-humidity', '%', 'Humidity')}
|
||||||
{/each}
|
{#each cellData as cell (cell.idx)}
|
||||||
</tr>
|
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||||
{/if}
|
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
|
||||||
|
{formatValue(hum)}
|
||||||
<!-- Humidity (short row) -->
|
</td>
|
||||||
{#if showRow('humidity')}
|
{/each}
|
||||||
<tr class="row">
|
</tr>
|
||||||
{@render rowHeader('wi-humidity', '%', 'Humidity')}
|
<!-- Cloud Cover -->
|
||||||
{#each cellData as cell (cell.idx)}
|
{:else if rowKey === 'clouds' && showRow('clouds')}
|
||||||
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
<tr class="row">
|
||||||
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
|
{@render rowHeader('wi-cloud', '%', 'Clouds')}
|
||||||
{formatValue(hum)}
|
{#each cellData as cell (cell.idx)}
|
||||||
</td>
|
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||||
{/each}
|
<td
|
||||||
</tr>
|
class="cell h-12"
|
||||||
{/if}
|
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||||
|
>
|
||||||
<!-- Cloud Cover -->
|
{formatValue(cloud)}
|
||||||
{#if showRow('clouds')}
|
</td>
|
||||||
<tr class="row">
|
{/each}
|
||||||
{@render rowHeader('wi-cloud', '%', 'Clouds')}
|
</tr>
|
||||||
{#each cellData as cell (cell.idx)}
|
<!-- Pressure (off by default) -->
|
||||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
{:else if rowKey === 'pressure' && showRow('pressure')}
|
||||||
<td
|
<tr class="row">
|
||||||
class="cell h-12"
|
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
|
||||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
{#each cellData as cell (cell.idx)}
|
||||||
>
|
{@const v = hourly.pressure_msl?.[cell.idx]}
|
||||||
{formatValue(cloud)}
|
<td
|
||||||
</td>
|
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
|
||||||
{/each}
|
style="background:{getPressureBg(v ?? null)}"
|
||||||
</tr>
|
>
|
||||||
{/if}
|
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
||||||
|
</td>
|
||||||
<!-- Pressure (off by default) -->
|
{/each}
|
||||||
{#if showRow('pressure')}
|
</tr>
|
||||||
<tr class="row">
|
<!-- UV Index (off by default) -->
|
||||||
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
|
{:else if rowKey === 'uv' && showRow('uv')}
|
||||||
{#each cellData as cell (cell.idx)}
|
<tr class="row">
|
||||||
{@const v = hourly.pressure_msl?.[cell.idx]}
|
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
|
||||||
<td
|
{#each cellData as cell (cell.idx)}
|
||||||
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
|
{@const v = hourly.uv_index?.[cell.idx]}
|
||||||
style="background:{getPressureBg(v ?? null)}"
|
<td
|
||||||
>
|
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
|
||||||
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
style="background:{getUvBg(v ?? null)}"
|
||||||
</td>
|
>
|
||||||
{/each}
|
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
||||||
</tr>
|
</td>
|
||||||
{/if}
|
{/each}
|
||||||
|
</tr>
|
||||||
<!-- UV Index (off by default) -->
|
<!-- Visibility (off by default) -->
|
||||||
{#if showRow('uv')}
|
{:else if rowKey === 'visibility' && showRow('visibility')}
|
||||||
<tr class="row">
|
<tr class="row">
|
||||||
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
|
{@render rowHeader('wi-fog', 'km', 'Visibility')}
|
||||||
{#each cellData as cell (cell.idx)}
|
{#each cellData as cell (cell.idx)}
|
||||||
{@const v = hourly.uv_index?.[cell.idx]}
|
{@const v = hourly.visibility?.[cell.idx]}
|
||||||
<td
|
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
||||||
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
|
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
|
||||||
style="background:{getUvBg(v ?? null)}"
|
</td>
|
||||||
>
|
{/each}
|
||||||
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
</tr>
|
||||||
</td>
|
<!-- Precipitation -->
|
||||||
{/each}
|
{:else if rowKey === 'precipitation' && showRow('precipitation')}
|
||||||
</tr>
|
<tr class="row">
|
||||||
{/if}
|
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
<!-- Visibility (off by default) -->
|
{@const precip = hourly.precipitation[cell.idx]}
|
||||||
{#if showRow('visibility')}
|
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||||
<tr class="row">
|
<td
|
||||||
{@render rowHeader('wi-fog', 'km', 'Visibility')}
|
class="cell precip-cell h-12"
|
||||||
{#each cellData as cell (cell.idx)}
|
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||||
{@const v = hourly.visibility?.[cell.idx]}
|
title={formatPrecipTooltip(precip, prob)}
|
||||||
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
>
|
||||||
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
|
{#if precip > 0}
|
||||||
</td>
|
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
|
||||||
{/each}
|
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
|
||||||
</tr>
|
{precip.toFixed(1)}
|
||||||
{/if}
|
</span>
|
||||||
|
{/if}
|
||||||
<!-- Precipitation -->
|
</td>
|
||||||
{#if showRow('precipitation')}
|
{/each}
|
||||||
<tr class="row">
|
</tr>
|
||||||
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
|
<!-- Snowfall (off by default) -->
|
||||||
{#each cellData as cell (cell.idx)}
|
{:else if rowKey === 'snowfall' && showRow('snowfall')}
|
||||||
{@const precip = hourly.precipitation[cell.idx]}
|
<tr class="row">
|
||||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
{@render rowHeader('wi-snow', 'cm', 'Snow')}
|
||||||
<td
|
{#each cellData as cell (cell.idx)}
|
||||||
class="cell precip-cell h-12"
|
{@const v = hourly.snowfall?.[cell.idx]}
|
||||||
style="background:{getPrecipProbBg(prob ?? 0)}"
|
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
||||||
title={formatPrecipTooltip(precip, prob)}
|
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
|
||||||
>
|
</td>
|
||||||
{#if precip > 0}
|
{/each}
|
||||||
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
|
</tr>
|
||||||
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
|
{/if}
|
||||||
{precip.toFixed(1)}
|
{/each}
|
||||||
</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}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, fly } from 'svelte/transition';
|
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 { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
@@ -13,21 +19,24 @@
|
|||||||
|
|
||||||
let { open, onClose }: Props = $props();
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
const tableVariables = [
|
const tableVariableLabels: Record<string, string> = {
|
||||||
{ key: 'icons', label: 'Weather icons' },
|
icons: 'Weather icons',
|
||||||
{ key: 'temperature', label: 'Temperature' },
|
temperature: 'Temperature',
|
||||||
{ key: 'feels', label: 'Feels like' },
|
feels: 'Feels like',
|
||||||
{ key: 'dew_point', label: 'Dew point' },
|
dew_point: 'Dew point',
|
||||||
{ key: 'wind', label: 'Wind' },
|
wind: 'Wind',
|
||||||
{ key: 'gusts', label: 'Wind gusts' },
|
gusts: 'Wind gusts',
|
||||||
{ key: 'humidity', label: 'Humidity' },
|
humidity: 'Humidity',
|
||||||
{ key: 'clouds', label: 'Cloud cover' },
|
clouds: 'Cloud cover',
|
||||||
{ key: 'pressure', label: 'Pressure' },
|
pressure: 'Pressure',
|
||||||
{ key: 'uv', label: 'UV index' },
|
uv: 'UV index',
|
||||||
{ key: 'visibility', label: 'Visibility' },
|
visibility: 'Visibility',
|
||||||
{ key: 'precipitation', label: 'Precipitation' },
|
precipitation: 'Precipitation',
|
||||||
{ key: 'snowfall', label: 'Snowfall' }
|
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) {
|
function toggle(section: 'table' | 'charts', key: string) {
|
||||||
storedVariablePrefs.update((prefs) => {
|
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() {
|
function resetDefaults() {
|
||||||
storedVariablePrefs.set(structuredClone(defaultVariablePrefs));
|
storedVariablePrefs.set(structuredClone(defaultVariablePrefs));
|
||||||
|
storedTableRowOrder.set([...defaultTableRowOrder]);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -88,17 +110,54 @@
|
|||||||
Hourly table
|
Hourly table
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{#each tableVariables as variable (variable.key)}
|
{#each tableRowOrder as key, i (key)}
|
||||||
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
|
<div class="group flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="table_var_{variable.key}"
|
id="table_var_{key}"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
checked={$storedVariablePrefs.table?.[variable.key] ?? true}
|
checked={$storedVariablePrefs.table?.[key] ?? true}
|
||||||
onCheckedChange={() => toggle('table', variable.key)}
|
onCheckedChange={() => toggle('table', key)}
|
||||||
/>
|
/>
|
||||||
<Label class="flex-1 cursor-pointer text-sm" for="table_var_{variable.key}">
|
<Label class="flex-1 cursor-pointer text-sm" for="table_var_{key}">
|
||||||
{variable.label}
|
{tableVariableLabels[key] ?? key}
|
||||||
</Label>
|
</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user