read more text
This commit is contained in:
+3
-1
@@ -363,5 +363,7 @@
|
|||||||
"wmo_86": "Starke Schneeschauer",
|
"wmo_86": "Starke Schneeschauer",
|
||||||
"wmo_95": "Gewitter",
|
"wmo_95": "Gewitter",
|
||||||
"wmo_96": "Gewitter mit leichtem Hagel",
|
"wmo_96": "Gewitter mit leichtem Hagel",
|
||||||
"wmo_99": "Gewitter mit starkem Hagel"
|
"wmo_99": "Gewitter mit starkem Hagel",
|
||||||
|
"summary_read_more": "Mehr anzeigen",
|
||||||
|
"summary_read_less": "Weniger anzeigen"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -363,5 +363,7 @@
|
|||||||
"wmo_86": "Heavy snow showers",
|
"wmo_86": "Heavy snow showers",
|
||||||
"wmo_95": "Thunderstorm",
|
"wmo_95": "Thunderstorm",
|
||||||
"wmo_96": "Thunderstorm with slight hail",
|
"wmo_96": "Thunderstorm with slight hail",
|
||||||
"wmo_99": "Thunderstorm with heavy hail"
|
"wmo_99": "Thunderstorm with heavy hail",
|
||||||
|
"summary_read_more": "Read more",
|
||||||
|
"summary_read_less": "Show less"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -363,5 +363,7 @@
|
|||||||
"wmo_86": "Chubascos de nieve intensos",
|
"wmo_86": "Chubascos de nieve intensos",
|
||||||
"wmo_95": "Tormenta",
|
"wmo_95": "Tormenta",
|
||||||
"wmo_96": "Tormenta con granizo ligero",
|
"wmo_96": "Tormenta con granizo ligero",
|
||||||
"wmo_99": "Tormenta con granizo fuerte"
|
"wmo_99": "Tormenta con granizo fuerte",
|
||||||
|
"summary_read_more": "Leer más",
|
||||||
|
"summary_read_less": "Mostrar menos"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -363,5 +363,7 @@
|
|||||||
"wmo_86": "Averses de neige fortes",
|
"wmo_86": "Averses de neige fortes",
|
||||||
"wmo_95": "Orage",
|
"wmo_95": "Orage",
|
||||||
"wmo_96": "Orage avec grêle légère",
|
"wmo_96": "Orage avec grêle légère",
|
||||||
"wmo_99": "Orage avec forte grêle"
|
"wmo_99": "Orage avec forte grêle",
|
||||||
|
"summary_read_more": "Lire la suite",
|
||||||
|
"summary_read_less": "Afficher moins"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -363,5 +363,7 @@
|
|||||||
"wmo_86": "Rovesci di neve forti",
|
"wmo_86": "Rovesci di neve forti",
|
||||||
"wmo_95": "Temporale",
|
"wmo_95": "Temporale",
|
||||||
"wmo_96": "Temporale con grandine leggera",
|
"wmo_96": "Temporale con grandine leggera",
|
||||||
"wmo_99": "Temporale con grandine forte"
|
"wmo_99": "Temporale con grandine forte",
|
||||||
|
"summary_read_more": "Leggi tutto",
|
||||||
|
"summary_read_less": "Mostra meno"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,10 +85,21 @@
|
|||||||
// arrives (no layout shift)
|
// arrives (no layout shift)
|
||||||
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
||||||
|
|
||||||
// The hourly table is a header row plus one row per enabled variable, so the
|
// The hourly table is a header row, the time/daylight row, then one row per
|
||||||
// placeholder below reserves exactly that and nothing under it jumps when the
|
// enabled variable, so the placeholder below reserves exactly that and
|
||||||
// real table arrives.
|
// nothing under it jumps when the real table arrives. Rows are shorter below
|
||||||
const TABLE_ROW_PX = 57;
|
// lg, where the cells sit tighter - measured, not guessed: 36px against 55px,
|
||||||
|
// and the time row is 48px at every width.
|
||||||
|
let compactRows = $state(false);
|
||||||
|
onMount(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 1023px)');
|
||||||
|
const apply = () => (compactRows = mq.matches);
|
||||||
|
apply();
|
||||||
|
mq.addEventListener('change', apply);
|
||||||
|
return () => mq.removeEventListener('change', apply);
|
||||||
|
});
|
||||||
|
let tableRowPx = $derived(compactRows ? 36 : 55);
|
||||||
|
const TABLE_TIME_ROW_PX = 48;
|
||||||
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
|
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
|
||||||
|
|
||||||
// Request only the hourly variables the table rows and meteograms actually
|
// Request only the hourly variables the table rows and meteograms actually
|
||||||
@@ -490,8 +501,13 @@
|
|||||||
<!-- one placeholder per row the real table will render, so the body
|
<!-- one placeholder per row the real table will render, so the body
|
||||||
reads as a loading table rather than a blank panel -->
|
reads as a loading table rather than a blank panel -->
|
||||||
<div class="divide-y divide-border/50">
|
<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)}
|
{#each { length: enabledTableRows } as _, i (i)}
|
||||||
<div class="flex items-center gap-4 px-4" style="height: {TABLE_ROW_PX}px">
|
<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 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 class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -505,10 +521,16 @@
|
|||||||
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
|
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- same footprint as the written forecast, so it doesn't shove the
|
<!-- Same footprint as the written forecast, so it doesn't shove the
|
||||||
meteograms down when it arrives -->
|
meteograms down when it arrives. The heights are measured from the
|
||||||
|
real card at each breakpoint: on phones the narrative is clamped to
|
||||||
|
five lines with a fixed toggle row, so that side is exact; from md
|
||||||
|
up the text runs free and this is the typical height. The sun/moon
|
||||||
|
grid reflows at sm, md and lg, which is why all four are needed. -->
|
||||||
<section class="mt-6" in:fade={{ duration: 200 }}>
|
<section class="mt-6" in:fade={{ duration: 200 }}>
|
||||||
<div class="h-52 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-40"></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"
|
||||||
|
></div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -526,14 +548,27 @@
|
|||||||
<!-- reserve the exact chart area height before the first fetch resolves,
|
<!-- reserve the exact chart area height before the first fetch resolves,
|
||||||
header row included -->
|
header row included -->
|
||||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<!-- The real header wraps to two rows until the controls fit beside
|
||||||
|
the title, which happens at different widths than you would
|
||||||
|
expect because the sidebar takes its share from md up. These
|
||||||
|
min-heights follow the measured wrap points. -->
|
||||||
|
<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"
|
||||||
|
>
|
||||||
<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>
|
||||||
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
|
<!-- 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
|
||||||
|
everything below lands ~50px per chart too high. -->
|
||||||
|
<ChartContainer
|
||||||
|
loading
|
||||||
|
chartCount={enabledChartCount || 1}
|
||||||
|
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,35 @@
|
|||||||
return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`;
|
return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`;
|
||||||
});
|
});
|
||||||
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
|
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
|
||||||
|
|
||||||
|
// ─── Narrative clamp (phones only) ──────────────────────────────────────────
|
||||||
|
// The narrative runs anywhere from two to nine lines depending on the day,
|
||||||
|
// which on a phone means the whole page below it moves as soon as the data
|
||||||
|
// lands. Below md the text is clamped to five lines and gets a toggle, and
|
||||||
|
// the block reserves those five lines plus the toggle row whatever the
|
||||||
|
// length - so the card is the same height before and after the fetch, and the
|
||||||
|
// skeleton can match it exactly. From md up nothing is clamped.
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
let textEl = $state<HTMLParagraphElement>();
|
||||||
|
let overflows = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
sentences; // re-measure when the day (and so the text) changes
|
||||||
|
const el = textEl;
|
||||||
|
if (!el || expanded) return; // measuring while open would always say "fits"
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
// only clamped below md, where the toggle is the only way to see the rest
|
||||||
|
const clamped = window.matchMedia('(max-width: 767px)').matches;
|
||||||
|
overflows = clamped && el.scrollHeight - el.clientHeight > 1;
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="mt-6" aria-label={m.summary_heading()}>
|
<section class="mt-6" aria-label={m.summary_heading()}>
|
||||||
@@ -115,15 +144,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6">
|
<div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6">
|
||||||
{#if sentences.length > 0}
|
<div class="narrative">
|
||||||
<p class="text-[15px] leading-relaxed text-foreground">
|
{#if sentences.length > 0}
|
||||||
{sentences.join(' ')}
|
<p
|
||||||
</p>
|
bind:this={textEl}
|
||||||
{:else}
|
class="text-[15px] leading-relaxed text-foreground"
|
||||||
<p class="text-[15px] leading-relaxed text-muted-foreground">
|
class:clamped={!expanded}
|
||||||
{m.summary_no_data()}
|
>
|
||||||
</p>
|
{sentences.join(' ')}
|
||||||
{/if}
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[15px] leading-relaxed text-muted-foreground">
|
||||||
|
{m.summary_no_data()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Always occupies its row on phones, even when the text fits: a
|
||||||
|
toggle that appears only sometimes is itself a layout shift. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle mt-1 cursor-pointer text-[13px] font-semibold text-primary hover:underline"
|
||||||
|
class:invisible={!overflows && !expanded}
|
||||||
|
aria-hidden={!overflows && !expanded}
|
||||||
|
tabindex={!overflows && !expanded ? -1 : 0}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
>
|
||||||
|
{expanded ? m.summary_read_less() : m.summary_read_more()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
|
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
|
||||||
<dl
|
<dl
|
||||||
@@ -218,3 +267,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Phones: exactly five lines, reserved whether the text is long or short, so
|
||||||
|
the card's height never depends on the forecast that lands. 15px text at
|
||||||
|
leading-relaxed (1.625) → 5 lines = 8.125em. */
|
||||||
|
.clamped {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 5;
|
||||||
|
line-clamp: 5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrative p {
|
||||||
|
min-height: 8.125em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* md and up: no clamp, no reserved height, no toggle. */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.clamped {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrative p {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user