beter crossfade
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
import type { TransitionConfig } from 'svelte/transition';
|
||||||
|
|
||||||
|
const prefersReducedMotion = () =>
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fades a loading placeholder out *over* the content that replaces it.
|
||||||
|
*
|
||||||
|
* A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the
|
||||||
|
* layout for the length of the transition, so the real content stacks below the
|
||||||
|
* placeholder and the whole page jumps up the moment the placeholder finally
|
||||||
|
* unmounts. That is why these placeholders only ever faded in - the swap was
|
||||||
|
* abrupt, but at least nothing moved.
|
||||||
|
*
|
||||||
|
* This pins the placeholder to the box it already occupies and takes it out of
|
||||||
|
* flow for the fade instead. The content settles into its final position
|
||||||
|
* immediately and the skeleton dissolves on top of it.
|
||||||
|
*
|
||||||
|
* Requirements at the call site: the placeholder's parent must be positioned
|
||||||
|
* (`class="relative"`) and must be the same element that renders the real
|
||||||
|
* content, or the overlay lands somewhere else on the page.
|
||||||
|
*/
|
||||||
|
export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig {
|
||||||
|
if (prefersReducedMotion()) return { duration: 0 };
|
||||||
|
|
||||||
|
// measured while the node is still in flow, which is when Svelte builds the
|
||||||
|
// transition - the values below are what freeze it in place
|
||||||
|
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node;
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
css: (t) => `
|
||||||
|
opacity: ${t};
|
||||||
|
position: absolute;
|
||||||
|
top: ${offsetTop}px;
|
||||||
|
left: ${offsetLeft}px;
|
||||||
|
width: ${offsetWidth}px;
|
||||||
|
height: ${offsetHeight}px;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||||
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
|
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
@@ -342,42 +344,52 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if fetchedData}
|
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
|
||||||
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
<div class="relative">
|
||||||
the page margins (padded), the graphs bleed to the edges -->
|
{#if fetchedData}
|
||||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||||
{#each chartDefs as def, i (i)}
|
the page margins (padded), the graphs bleed to the edges -->
|
||||||
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
<div class="mb-1 px-3 lg:px-0">
|
{#each chartDefs as def, i (i)}
|
||||||
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
{#if def.subtitle}
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||||
{/if}
|
{#if def.subtitle}
|
||||||
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
||||||
|
yMin={def.yMin}
|
||||||
|
yMax={def.yMax}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
{/each}
|
||||||
<CanvasChart
|
</div>
|
||||||
bind:this={chartComponents[i]}
|
{:else}
|
||||||
timestamps={timestampsSec}
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
timezone={fetchedData.timezone}
|
<div in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
series={def.series}
|
<ChartContainer
|
||||||
bands={fetchedData.daylightBands}
|
loading
|
||||||
unit={def.unit}
|
chartCount={params.hourly?.length || 1}
|
||||||
showCredit={def.showCredit}
|
chartHeight={340}
|
||||||
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
bleed={false}
|
||||||
yMin={def.yMin}
|
/>
|
||||||
yMax={def.yMax}
|
</div>
|
||||||
{showLegend}
|
{/if}
|
||||||
height={300}
|
</div>
|
||||||
group={CHART_GROUP}
|
|
||||||
/>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
|
||||||
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { formatZoned } from '$lib/utils/date';
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||||
import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state';
|
import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state';
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
@@ -379,44 +380,49 @@
|
|||||||
|
|
||||||
<!-- 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 -->
|
||||||
{#if fetchedData}
|
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
|
||||||
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
<div class="relative">
|
||||||
the page margins (padded), the graphs bleed to the edges -->
|
{#if fetchedData}
|
||||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||||
{#each chartDefs as def, i (i)}
|
the page margins (padded), the graphs bleed to the edges -->
|
||||||
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
<div class="mb-1 px-3 lg:px-0">
|
{#each chartDefs as def, i (i)}
|
||||||
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
{#if def.subtitle}
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||||
{/if}
|
{#if def.subtitle}
|
||||||
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
{/each}
|
||||||
<CanvasChart
|
</div>
|
||||||
bind:this={chartComponents[i]}
|
{:else}
|
||||||
timestamps={timestampsSec}
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
timezone={fetchedData.timezone}
|
<div in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
series={def.series}
|
<ChartContainer
|
||||||
bands={fetchedData.daylightBands}
|
loading
|
||||||
unit={def.unit}
|
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
||||||
showCredit={def.showCredit}
|
chartHeight={340}
|
||||||
{showLegend}
|
bleed={false}
|
||||||
height={300}
|
/>
|
||||||
group={CHART_GROUP}
|
</div>
|
||||||
/>
|
{/if}
|
||||||
</ChartContainer>
|
</div>
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
|
||||||
<ChartContainer
|
|
||||||
loading
|
|
||||||
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
|
||||||
chartHeight={340}
|
|
||||||
bleed={false}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if fetchedData && !loading}
|
{#if fetchedData && !loading}
|
||||||
<ModelPictogramTimeline
|
<ModelPictogramTimeline
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
storedVariablePrefs
|
storedVariablePrefs
|
||||||
} from '$lib/stores/settings';
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||||
|
|
||||||
import { ChartContainer } from '$lib/components/charts';
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
@@ -258,7 +260,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="relative mt-4">
|
||||||
{#if result && fetchedHourly && fetchedDaily}
|
{#if result && fetchedHourly && fetchedDaily}
|
||||||
<HistoricalDaily
|
<HistoricalDaily
|
||||||
daily={result.daily}
|
daily={result.daily}
|
||||||
@@ -282,7 +284,7 @@
|
|||||||
|
|
||||||
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
|
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
|
||||||
{:else}
|
{:else}
|
||||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
|
||||||
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||||
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
|
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
|
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
@@ -270,40 +272,44 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if visible && months.length > 0}
|
<!-- `relative` anchors the placeholder while it fades out over the real
|
||||||
<SeasonalMonths {months} units={params} {normals} />
|
outlook rather than holding its own slot in the layout (skeletonOut). -->
|
||||||
|
<div class="relative">
|
||||||
|
{#if visible && months.length > 0}
|
||||||
|
<SeasonalMonths {months} units={params} {normals} />
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<SeasonalCharts
|
<SeasonalCharts
|
||||||
result={visible}
|
result={visible}
|
||||||
{normals}
|
{normals}
|
||||||
units={params}
|
units={params}
|
||||||
{loading}
|
{loading}
|
||||||
{showLegend}
|
{showLegend}
|
||||||
bind:charts={chartComponents}
|
bind:charts={chartComponents}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
|
||||||
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
|
|
||||||
{#snippet controls()}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
|
||||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
|
||||||
>{m.legend_show()}</Label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</ChartToolbar>
|
|
||||||
</div>
|
|
||||||
{:else if !loadError}
|
|
||||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
|
||||||
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each [0, 1, 2, 3, 4, 5] as i (i)}
|
|
||||||
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
|
|
||||||
</div>
|
<div class="mt-6 md:mt-10">
|
||||||
{/if}
|
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
|
||||||
|
{#snippet controls()}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||||
|
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||||
|
>{m.legend_show()}</Label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</ChartToolbar>
|
||||||
|
</div>
|
||||||
|
{:else if !loadError}
|
||||||
|
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
|
||||||
|
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each [0, 1, 2, 3, 4, 5] as i (i)}
|
||||||
|
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</PaywallGate>
|
</PaywallGate>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { formatZoned } from '$lib/utils/date';
|
import { formatZoned } from '$lib/utils/date';
|
||||||
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
|
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
import { skeletonOut } from '$lib/utils/skeleton-fade';
|
||||||
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
|
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
|
||||||
|
|
||||||
import { ChartContainer } from '$lib/components/charts';
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
@@ -473,109 +474,115 @@
|
|||||||
{locationRoute}
|
{locationRoute}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if fetchedHourly && fetchedDaily}
|
<!-- `relative` is what lets the placeholder fade out on top of the table
|
||||||
<div class="day-region-table" use:daySwap={selectedDayKey}>
|
instead of holding a second slot in the layout (see skeletonOut). -->
|
||||||
<HourlyTable
|
<div class="relative">
|
||||||
data={fetchedHourly}
|
{#if fetchedHourly && fetchedDaily}
|
||||||
daily={fetchedDaily}
|
<div class="day-region-table" use:daySwap={selectedDayKey}>
|
||||||
{selectedDay}
|
<HourlyTable
|
||||||
units={params}
|
data={fetchedHourly}
|
||||||
locationName={location.name ?? ''}
|
daily={fetchedDaily}
|
||||||
onCustomize={() => (variableSidebarOpen = true)}
|
{selectedDay}
|
||||||
/>
|
units={params}
|
||||||
</div>
|
locationName={location.name ?? ''}
|
||||||
{:else}
|
onCustomize={() => (variableSidebarOpen = true)}
|
||||||
<!-- Mirrors the real table: same header bar and the same body height,
|
/>
|
||||||
so the heading doesn't pop in and nothing below moves.
|
</div>
|
||||||
Placeholders only fade IN - a fade-out would keep them in the
|
{:else}
|
||||||
layout while the real content mounts below, and everything below
|
<!-- Mirrors the real table: same header bar and the same body height,
|
||||||
would jump the moment they finally unmount. -->
|
so the heading doesn't pop in and nothing below moves. -->
|
||||||
<div
|
|
||||||
in:fade={{ duration: 200 }}
|
|
||||||
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
|
in:fade={{ duration: 200 }}
|
||||||
|
out:skeletonOut
|
||||||
|
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||||
>
|
>
|
||||||
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
|
<div
|
||||||
<div class="flex items-center gap-2">
|
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
|
||||||
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
|
>
|
||||||
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
|
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
</div>
|
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||||
<!-- one placeholder per row the real table will render, so the body
|
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
|
||||||
reads as a loading table rather than a blank panel -->
|
|
||||||
<div class="divide-y divide-border/50">
|
|
||||||
<!-- the time + daylight row, which is taller than the variable rows -->
|
|
||||||
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
|
|
||||||
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
|
||||||
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
|
||||||
</div>
|
|
||||||
{#each { length: enabledTableRows } as _, i (i)}
|
|
||||||
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
|
|
||||||
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
|
||||||
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
|
<!-- one placeholder per row the real table will render, so the body
|
||||||
|
reads as a loading table rather than a blank panel -->
|
||||||
|
<div class="divide-y divide-border/50">
|
||||||
|
<!-- the time + daylight row, which is taller than the variable rows -->
|
||||||
|
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
|
||||||
|
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
||||||
|
</div>
|
||||||
|
{#each { length: enabledTableRows } as _, i (i)}
|
||||||
|
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
|
||||||
|
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
{#if fetchedHourly && fetchedDaily}
|
<div class="relative">
|
||||||
<div class="day-region-summary" use:daySwap={selectedDayKey}>
|
{#if fetchedHourly && fetchedDaily}
|
||||||
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
|
<div class="day-region-summary" use:daySwap={selectedDayKey}>
|
||||||
</div>
|
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
|
||||||
{:else}
|
</div>
|
||||||
<!-- Same footprint as the written forecast, so it doesn't shove the
|
{:else}
|
||||||
meteograms down when it arrives. The heights are measured from the
|
<!-- Same footprint as the written forecast, so it doesn't shove the
|
||||||
real card at each breakpoint: on phones the narrative is clamped to
|
meteograms down when it arrives. The heights are measured from the
|
||||||
five lines with a fixed toggle row, so that side is exact; from md
|
real card at each breakpoint: on phones the narrative is clamped to
|
||||||
up the text runs free and this is the typical height. The sun/moon
|
five lines with a fixed toggle row, so that side is exact; from md
|
||||||
grid reflows at sm, md and lg, which is why all four are needed. -->
|
up the text runs free and this is the typical height. The sun/moon
|
||||||
<section class="mt-6" in:fade={{ duration: 200 }}>
|
grid reflows at sm, md and lg, which is why all four are needed. -->
|
||||||
<div
|
<section class="mt-6" in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
|
<div
|
||||||
></div>
|
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
|
||||||
</section>
|
></div>
|
||||||
{/if}
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if fetchedHourly}
|
<div class="relative">
|
||||||
<div class="day-region-charts" use:daySwap={selectedDayKey}>
|
{#if fetchedHourly}
|
||||||
<MeteogramCharts
|
<div class="day-region-charts" use:daySwap={selectedDayKey}>
|
||||||
data={fetchedHourly}
|
<MeteogramCharts
|
||||||
{selectedDay}
|
data={fetchedHourly}
|
||||||
units={params}
|
{selectedDay}
|
||||||
{loading}
|
units={params}
|
||||||
{chartHeight}
|
{loading}
|
||||||
/>
|
{chartHeight}
|
||||||
</div>
|
/>
|
||||||
{:else}
|
</div>
|
||||||
<!-- reserve the exact chart area height before the first fetch resolves,
|
{:else}
|
||||||
header row included -->
|
<!-- reserve the exact chart area height before the first fetch resolves,
|
||||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
header row included -->
|
||||||
<!-- The real header wraps to two rows until the controls fit beside
|
<section class="mt-8" in:fade={{ duration: 200 }} out:skeletonOut>
|
||||||
|
<!-- The real header wraps to two rows until the controls fit beside
|
||||||
the title, which happens at different widths than you would
|
the title, which happens at different widths than you would
|
||||||
expect because the sidebar takes its share from md up. These
|
expect because the sidebar takes its share from md up. These
|
||||||
min-heights follow the measured wrap points. -->
|
min-heights follow the measured wrap points. -->
|
||||||
<div
|
<div
|
||||||
class="mb-3 flex min-h-27 flex-wrap items-center justify-between gap-2 sm:min-h-16.5 md:min-h-27 lg:min-h-16.5 xl:min-h-7.5"
|
class="mb-3 flex min-h-27 flex-wrap items-center justify-between gap-2 sm:min-h-16.5 md:min-h-27 lg:min-h-16.5 xl:min-h-7.5"
|
||||||
>
|
>
|
||||||
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
|
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
|
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
|
||||||
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
|
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Each meteogram is its plot plus a title row and axis labels, so
|
||||||
<!-- Each meteogram is its plot plus a title row and axis labels, so
|
|
||||||
the reserved box needs that chrome on top of the plot height or
|
the reserved box needs that chrome on top of the plot height or
|
||||||
everything below lands ~50px per chart too high. -->
|
everything below lands ~50px per chart too high. -->
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
loading
|
loading
|
||||||
chartCount={enabledChartCount || 1}
|
chartCount={enabledChartCount || 1}
|
||||||
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
|
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if selectedDayKey}
|
{#if selectedDayKey}
|
||||||
<NearbyCities
|
<NearbyCities
|
||||||
|
|||||||
Reference in New Issue
Block a user