strip more seamless and margin improvements
This commit is contained in:
@@ -152,6 +152,10 @@
|
|||||||
group?: string;
|
group?: string;
|
||||||
/** Fixed left-axis minimum (otherwise derived from data, including 0) */
|
/** Fixed left-axis minimum (otherwise derived from data, including 0) */
|
||||||
yMin?: number;
|
yMin?: number;
|
||||||
|
/** Minimum breathing room (axis units) above the left-axis data range. */
|
||||||
|
yPadTop?: number;
|
||||||
|
/** Minimum breathing room (axis units) below the left-axis data range. */
|
||||||
|
yPadBottom?: number;
|
||||||
/** Fixed left-axis maximum */
|
/** Fixed left-axis maximum */
|
||||||
yMax?: number;
|
yMax?: number;
|
||||||
/** Force the derived left axis to include zero (default true) */
|
/** Force the derived left axis to include zero (default true) */
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
group,
|
group,
|
||||||
yMin,
|
yMin,
|
||||||
yMax,
|
yMax,
|
||||||
|
yPadTop,
|
||||||
|
yPadBottom,
|
||||||
zeroBaseLeft = true,
|
zeroBaseLeft = true,
|
||||||
yMinRight,
|
yMinRight,
|
||||||
yMaxRight,
|
yMaxRight,
|
||||||
@@ -302,9 +308,11 @@
|
|||||||
// and the right-axis labels are drawn overlaid on top instead (see below).
|
// and the right-axis labels are drawn overlaid on top instead (see below).
|
||||||
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
|
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
|
||||||
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
||||||
// a stacked row of charts the same height even if some have fewer icon rows.
|
// a stacked row of charts the same height even if some have fewer icon rows —
|
||||||
|
// but only on wide screens: on mobile that uniform band wastes precious
|
||||||
|
// vertical space, so each chart reserves just its own rows there.
|
||||||
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
|
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
|
||||||
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
|
let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows));
|
||||||
// tighter top/bottom gutters on mobile so charts don't waste vertical space
|
// tighter top/bottom gutters on mobile so charts don't waste vertical space
|
||||||
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
|
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
|
||||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
|
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
|
||||||
@@ -353,16 +361,38 @@
|
|||||||
return [lo, hi];
|
return [lo, hi];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScale(lo: number, hi: number, loFixed: boolean, hiFixed: boolean): Scale {
|
function buildScale(
|
||||||
|
lo: number,
|
||||||
|
hi: number,
|
||||||
|
loFixed: boolean,
|
||||||
|
hiFixed: boolean,
|
||||||
|
halfStepBounds = false
|
||||||
|
): Scale {
|
||||||
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
|
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
|
||||||
const min = loFixed ? lo : Math.floor(lo / step) * step;
|
// Padded axes (e.g. temperature) may end on HALF steps — 5° when ticks
|
||||||
const max = hiFixed ? hi : Math.ceil(hi / step) * step;
|
// are every 10° — so the requested margin isn't inflated to a whole step.
|
||||||
|
// Tick drawing starts at the first full-step multiple, so a half-step
|
||||||
|
// bound gets no label or gridline of its own.
|
||||||
|
const snap = halfStepBounds ? step / 2 : step;
|
||||||
|
const min = loFixed ? lo : Math.floor(lo / snap) * snap;
|
||||||
|
const max = hiFixed ? hi : Math.ceil(hi / snap) * snap;
|
||||||
return { min, max: max > min ? max : min + step, step };
|
return { min, max: max > min ? max : min + step, step };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First tick at or above the scale minimum (bounds may sit on half steps). */
|
||||||
|
function firstTick(scale: Scale): number {
|
||||||
|
return Math.ceil((scale.min - 1e-9) / scale.step) * scale.step;
|
||||||
|
}
|
||||||
|
|
||||||
let leftScale = $derived.by((): Scale => {
|
let leftScale = $derived.by((): Scale => {
|
||||||
const [dLo, dHi] = dataExtent('left', zeroBaseLeft);
|
let [dLo, dHi] = dataExtent('left', zeroBaseLeft);
|
||||||
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
|
// requested breathing room around the data (e.g. temperature): at least
|
||||||
|
// the given units, growing with wide ranges so it stays proportionate
|
||||||
|
const span = dHi - dLo;
|
||||||
|
const padded = yPadTop != null || yPadBottom != null;
|
||||||
|
if (yMax === undefined && yPadTop) dHi += Math.max(yPadTop, span * 0.08);
|
||||||
|
if (yMin === undefined && yPadBottom) dLo -= Math.max(yPadBottom, span * 0.12);
|
||||||
|
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined, padded);
|
||||||
});
|
});
|
||||||
|
|
||||||
let rightScale = $derived.by((): Scale => {
|
let rightScale = $derived.by((): Scale => {
|
||||||
@@ -839,7 +869,11 @@
|
|||||||
ctx.font = font;
|
ctx.font = font;
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
for (let v = leftScale.min; v <= leftScale.max + leftScale.step / 2; v += leftScale.step) {
|
for (
|
||||||
|
let v = firstTick(leftScale);
|
||||||
|
v <= leftScale.max + leftScale.step / 2;
|
||||||
|
v += leftScale.step
|
||||||
|
) {
|
||||||
const y = yPix(v, 'left');
|
const y = yPix(v, 'left');
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
@@ -858,7 +892,7 @@
|
|||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
for (
|
for (
|
||||||
let v = rightScale.min;
|
let v = firstTick(rightScale);
|
||||||
v <= rightScale.max + rightScale.step / 2;
|
v <= rightScale.max + rightScale.step / 2;
|
||||||
v += rightScale.step
|
v += rightScale.step
|
||||||
) {
|
) {
|
||||||
@@ -1153,7 +1187,7 @@
|
|||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
for (
|
for (
|
||||||
let v = rightScale.min;
|
let v = firstTick(rightScale);
|
||||||
v <= rightScale.max + rightScale.step / 2;
|
v <= rightScale.max + rightScale.step / 2;
|
||||||
v += rightScale.step
|
v += rightScale.step
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
// A hand-picked set of the prerendered city pages (see
|
||||||
|
// routes/weather/locations/city-names100.json) — instant navigation targets
|
||||||
|
// that double as SEO entry points.
|
||||||
|
const popularCities: { slug: string; label: string }[] = [
|
||||||
|
{ slug: 'london', label: 'London' },
|
||||||
|
{ slug: 'tokyo', label: 'Tokyo' },
|
||||||
|
{ slug: 'berlin', label: 'Berlin' },
|
||||||
|
{ slug: 'sydney', label: 'Sydney' },
|
||||||
|
{ slug: 'singapore', label: 'Singapore' },
|
||||||
|
{ slug: 'dubai', label: 'Dubai' },
|
||||||
|
{ slug: 'los-angeles', label: 'Los Angeles' },
|
||||||
|
{ slug: 'hong-kong', label: 'Hong Kong' },
|
||||||
|
{ slug: 'istanbul', label: 'Istanbul' },
|
||||||
|
{ slug: 'seoul', label: 'Seoul' },
|
||||||
|
{ slug: 'mexico-city', label: 'Mexico City' },
|
||||||
|
{ slug: 'sao-paulo', label: 'São Paulo' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const forecastLinks = [
|
||||||
|
{ href: resolve('/weather/week'), label: '7-Day Forecast' },
|
||||||
|
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
|
||||||
|
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' },
|
||||||
|
{ href: resolve('/weather/historical'), label: 'Historical Weather' },
|
||||||
|
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="mt-16 border-t border-border bg-card/60">
|
||||||
|
<div class="mx-auto w-full max-w-[1536px] px-4 py-10 lg:px-8">
|
||||||
|
<div class="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="max-w-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-lg"
|
||||||
|
>☔</span
|
||||||
|
>
|
||||||
|
<span class="text-lg font-bold tracking-tight">Drizz.li</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Fast, no-nonsense weather forecasts built on open data.
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-xs text-muted-foreground">
|
||||||
|
Weather data by
|
||||||
|
<a
|
||||||
|
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href="https://open-meteo.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">Open-Meteo</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forecasts -->
|
||||||
|
<nav aria-label="Forecasts">
|
||||||
|
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||||
|
Forecasts
|
||||||
|
</h3>
|
||||||
|
<ul class="mt-3 flex flex-col gap-2 text-sm">
|
||||||
|
{#each forecastLinks as link (link.href)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={link.href}>{link.label}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Popular locations (spans two columns of links) -->
|
||||||
|
<nav aria-label="Popular locations" class="lg:col-span-2">
|
||||||
|
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||||
|
Popular locations
|
||||||
|
</h3>
|
||||||
|
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
|
||||||
|
{#each popularCities as city (city.slug)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={resolve('/weather/week/[location]', { location: city.slug })}
|
||||||
|
>{city.label} weather</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom bar: legal -->
|
||||||
|
<div
|
||||||
|
class="mt-10 flex flex-col items-start justify-between gap-3 border-t border-border/70 pt-5 text-xs text-muted-foreground sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<span>© {year} Drizz.li</span>
|
||||||
|
<nav aria-label="Legal" class="flex flex-wrap items-center gap-x-5 gap-y-1">
|
||||||
|
<a class="underline-offset-2 hover:text-foreground hover:underline" href={resolve('/about')}
|
||||||
|
>About</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={resolve('/legal/imprint')}>Imprint</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={resolve('/legal/privacy')}>Privacy</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href={resolve('/legal/terms')}>Terms</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -26,6 +26,16 @@
|
|||||||
location = value;
|
location = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prerendered pages bake the DEFAULT location's flag into the HTML, and
|
||||||
|
// Svelte's hydration repairs text but not attributes — so on pages that
|
||||||
|
// never update the store (legal pages etc.) the stale flag would stick
|
||||||
|
// around next to the correct location name. Re-sync the src after mount.
|
||||||
|
let flagEl = $state<HTMLImageElement>();
|
||||||
|
$effect(() => {
|
||||||
|
const src = `/images/country-flags/${(location.country_code || 'united_nations').toLowerCase()}.svg`;
|
||||||
|
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src;
|
||||||
|
});
|
||||||
|
|
||||||
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
||||||
const themeTitles: Record<Theme, string> = {
|
const themeTitles: Record<Theme, string> = {
|
||||||
system: 'Theme: follow system',
|
system: 'Theme: follow system',
|
||||||
@@ -76,6 +86,7 @@
|
|||||||
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
|
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
bind:this={flagEl}
|
||||||
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
|
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
|
||||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
alt={location.country}
|
alt={location.country}
|
||||||
|
|||||||
@@ -131,35 +131,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- About / legal links (hidden when collapsed; the pages stay reachable
|
<!-- About / legal links moved to the page footer -->
|
||||||
via any expanded sidebar and the signup page footer) -->
|
|
||||||
{#if !collapsed}
|
|
||||||
<div class="px-4 py-3 text-[11px] leading-relaxed text-sidebar-foreground/60">
|
|
||||||
<a
|
|
||||||
class="hover:text-sidebar-foreground hover:underline"
|
|
||||||
href={resolve('/about')}
|
|
||||||
onclick={onMobileClose}>About</a
|
|
||||||
>
|
|
||||||
<span aria-hidden="true"> · </span>
|
|
||||||
<a
|
|
||||||
class="hover:text-sidebar-foreground hover:underline"
|
|
||||||
href={resolve('/legal/imprint')}
|
|
||||||
onclick={onMobileClose}>Imprint</a
|
|
||||||
>
|
|
||||||
<span aria-hidden="true"> · </span>
|
|
||||||
<a
|
|
||||||
class="hover:text-sidebar-foreground hover:underline"
|
|
||||||
href={resolve('/legal/privacy')}
|
|
||||||
onclick={onMobileClose}>Privacy</a
|
|
||||||
>
|
|
||||||
<span aria-hidden="true"> · </span>
|
|
||||||
<a
|
|
||||||
class="hover:text-sidebar-foreground hover:underline"
|
|
||||||
href={resolve('/legal/terms')}
|
|
||||||
onclick={onMobileClose}>Terms</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
||||||
{#if onToggle}
|
{#if onToggle}
|
||||||
|
|||||||
@@ -273,6 +273,65 @@ export interface EnsembleForecastResult {
|
|||||||
hourlyUnitsFlat: Record<string, string>;
|
hourlyUnitsFlat: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Error Humanizing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FriendlyWeatherError {
|
||||||
|
/** Short, plain-language headline. */
|
||||||
|
title: string;
|
||||||
|
/** What the user can actually do about it. */
|
||||||
|
hint?: string;
|
||||||
|
/** The raw underlying message, for a collapsed "technical details" block. */
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a fetch/API error into something a person can act on. The raw message
|
||||||
|
* (often API-speak like "No data is available for this location") is kept as
|
||||||
|
* `detail` so it can be shown collapsed.
|
||||||
|
*/
|
||||||
|
export function humanizeWeatherError(err: unknown): FriendlyWeatherError {
|
||||||
|
const raw = err instanceof Error ? err.message : String(err);
|
||||||
|
const msg = raw.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
err instanceof TypeError ||
|
||||||
|
msg.includes('failed to fetch') ||
|
||||||
|
msg.includes('networkerror') ||
|
||||||
|
msg.includes('load failed') ||
|
||||||
|
msg.includes('network request failed')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: "Couldn't reach the weather service",
|
||||||
|
hint: 'Check your internet connection and try again.',
|
||||||
|
detail: raw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
msg.includes('no data is available') ||
|
||||||
|
msg.includes('not available for this location') ||
|
||||||
|
msg.includes('out of allowed range') ||
|
||||||
|
msg.includes('coordinates')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: 'No data for this location with the selected model',
|
||||||
|
hint: 'Regional weather models only cover their own area — "Best match" picks a suitable model automatically.',
|
||||||
|
detail: raw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (msg.includes('invalid') || msg.includes('cannot be') || msg.includes('bad request')) {
|
||||||
|
return {
|
||||||
|
title: 'The weather service rejected the request',
|
||||||
|
hint: 'Try different settings, or switch the model back to "Best match".',
|
||||||
|
detail: raw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: 'Loading the weather data failed',
|
||||||
|
hint: 'Try again in a moment. If it keeps happening, switch the model to "Best match".',
|
||||||
|
detail: raw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Fallback set when the caller does not specify which hourly variables it
|
// Fallback set when the caller does not specify which hourly variables it
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { storedTheme } from '$lib/stores/settings';
|
import { storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import Footer from '$lib/components/navigation/footer.svelte';
|
||||||
import Header from '$lib/components/navigation/header.svelte';
|
import Header from '$lib/components/navigation/header.svelte';
|
||||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||||
|
|
||||||
@@ -88,11 +89,16 @@
|
|||||||
{#if fullBleed}
|
{#if fullBleed}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- cap the content width on very large screens; generous bottom room
|
<!-- cap the content width on very large screens; the footer below
|
||||||
so the last chart/table never sits flush against the viewport edge -->
|
gives the page its ending, so only modest bottom room is needed -->
|
||||||
<div class="mx-auto w-full max-w-[1536px] pb-80">
|
<div class="mx-auto w-full max-w-[1536px] pb-24">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- full-bleed footer inside the scroll area (its own inner max-w),
|
||||||
|
cancelling main's padding so it sits flush with the edges -->
|
||||||
|
<div class="-mx-3 -mb-3 lg:-mx-8 lg:-mb-6">
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -195,6 +195,8 @@
|
|||||||
unit={panel.def.unit}
|
unit={panel.def.unit}
|
||||||
unitRight={panel.def.unitRight}
|
unitRight={panel.def.unitRight}
|
||||||
yMin={panel.def.yMin}
|
yMin={panel.def.yMin}
|
||||||
|
yPadTop={panel.def.yPadTop}
|
||||||
|
yPadBottom={panel.def.yPadBottom}
|
||||||
zeroBaseLeft={panel.def.zeroBaseLeft}
|
zeroBaseLeft={panel.def.zeroBaseLeft}
|
||||||
yMinRight={panel.def.yMinRight}
|
yMinRight={panel.def.yMinRight}
|
||||||
yMaxRight={panel.def.yMaxRight}
|
yMaxRight={panel.def.yMaxRight}
|
||||||
|
|||||||
@@ -14,11 +14,15 @@
|
|||||||
|
|
||||||
import { ChartContainer } from '$lib/components/charts';
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
import {
|
||||||
|
type FriendlyWeatherError,
|
||||||
|
type WeekForecastResult,
|
||||||
|
fetchWeekForecast,
|
||||||
|
humanizeWeatherError
|
||||||
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
||||||
import DailyCards from './DailyCards.svelte';
|
|
||||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||||
import HourlyTable from './HourlyTable.svelte';
|
import HourlyTable from './HourlyTable.svelte';
|
||||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||||
@@ -74,8 +78,29 @@
|
|||||||
|
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<FriendlyWeatherError | null>(null);
|
||||||
let requestVersion = 0;
|
let requestVersion = 0;
|
||||||
|
// bumped by the "Try again" button to re-run the fetch effect
|
||||||
|
let retryNonce = $state(0);
|
||||||
|
|
||||||
|
/** Back to the model that always has data (also what ModelSelector does). */
|
||||||
|
function resetToBestMatch() {
|
||||||
|
params.models = ['best_match'];
|
||||||
|
storedModel.set('best_match');
|
||||||
|
forecastDays = 7;
|
||||||
|
pastDays = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request can succeed yet contain nothing usable: regional models return
|
||||||
|
// all-NaN outside their coverage area. Detect that so the page can say so
|
||||||
|
// instead of silently rendering an empty strip.
|
||||||
|
let noData = $derived.by((): boolean => {
|
||||||
|
const fd = fetchedDaily;
|
||||||
|
if (loading || !fd) return false;
|
||||||
|
return !fd.daily.temperature_2m_max.some(
|
||||||
|
(v, i) => v != null && !isNaN(v) && !(v === 0 && fd.daily.temperature_2m_min[i] === 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 7 by default; the user can extend to the model's longer range (up to 16 days)
|
// 7 by default; the user can extend to the model's longer range (up to 16 days)
|
||||||
let forecastDays = $state(7);
|
let forecastDays = $state(7);
|
||||||
@@ -103,6 +128,7 @@
|
|||||||
const loc = location;
|
const loc = location;
|
||||||
const modelList = params.models;
|
const modelList = params.models;
|
||||||
const requestVars = hourlyVars;
|
const requestVars = hourlyVars;
|
||||||
|
void retryNonce; // re-run on "Try again"
|
||||||
|
|
||||||
if (!mounted || !loc || !modelList?.length) return;
|
if (!mounted || !loc || !modelList?.length) return;
|
||||||
|
|
||||||
@@ -156,7 +182,7 @@
|
|||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
if (version !== requestVersion) return;
|
if (version !== requestVersion) return;
|
||||||
loadError = err instanceof Error ? err.message : String(err);
|
loadError = humanizeWeatherError(err);
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -210,33 +236,77 @@
|
|||||||
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
<div
|
<div class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm">
|
||||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
<p class="font-semibold text-destructive">{loadError.title}</p>
|
||||||
|
{#if loadError.hint}
|
||||||
|
<p class="mt-0.5 text-destructive/90">{loadError.hint}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-2.5 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-md border border-destructive/40 bg-background px-3 py-1 text-xs font-semibold text-destructive transition-colors hover:bg-destructive/10"
|
||||||
|
onclick={() => retryNonce++}
|
||||||
>
|
>
|
||||||
Failed to load weather data: {loadError}
|
Try again
|
||||||
|
</button>
|
||||||
|
{#if params.models?.[0] !== 'best_match'}
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-md border border-border bg-background px-3 py-1 text-xs font-semibold text-foreground transition-colors hover:bg-muted"
|
||||||
|
onclick={resetToBestMatch}
|
||||||
|
>
|
||||||
|
Switch to Best match
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if loadError.detail}
|
||||||
|
<details class="mt-2 text-xs text-destructive/70">
|
||||||
|
<summary class="cursor-pointer select-none">Technical details</summary>
|
||||||
|
<p class="mt-1 font-mono break-all">{loadError.detail}</p>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Desktop: the full day cards -->
|
{#if noData && !loadError}
|
||||||
<div class="hidden md:block">
|
<!-- the request succeeded but every value is NaN: the selected (regional)
|
||||||
<DailyCards
|
model doesn't cover this location -->
|
||||||
daily={fetchedDaily}
|
<div
|
||||||
{selectedDay}
|
class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
|
||||||
units={params}
|
>
|
||||||
onSelectDay={switchDay}
|
<svg
|
||||||
canExtend={forecastDays < 15}
|
class="h-4 w-4 shrink-0"
|
||||||
onExtend={() => (forecastDays = 15)}
|
fill="none"
|
||||||
canExtendPast={pastDays < 3}
|
stroke="currentColor"
|
||||||
onExtendPast={() => (pastDays = 3)}
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v4m0 4h.01M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z"
|
||||||
/>
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-semibold">No forecast data for this model here</p>
|
||||||
|
<p class="text-[13px] opacity-90">
|
||||||
|
The selected weather model doesn't cover {location.name} — regional models only provide data
|
||||||
|
inside their own area.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background"
|
||||||
|
onclick={resetToBestMatch}
|
||||||
|
>
|
||||||
|
Switch to Best match
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- The sticky day strip, the hourly table AND the meteograms share this
|
<!-- The sticky day strip, the hourly table AND the meteograms share this
|
||||||
wrapper, so the strip stays stuck for the entire page (it collapses
|
wrapper, so the strip stays stuck for the entire page: the full day
|
||||||
as it sticks on mobile; on md+ it's a slim always-compact bar under
|
cards collapse into the compact strip as it sticks (on md+ the bar
|
||||||
the topbar, complementing the full cards above). timeline-scope
|
docks under the topbar at its exact height). timeline-scope hoists
|
||||||
hoists the strip's sentinel view-timeline so the sticky strip (a
|
the strip's sentinel view-timeline so the sticky strip (a sibling of
|
||||||
sibling of the sentinel) can scrub its collapse from it. -->
|
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
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
{#if canExtendPast && onExtendPast}
|
{#if canExtendPast && onExtendPast}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
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"
|
||||||
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="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"
|
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"
|
||||||
onclick={onExtend}
|
onclick={onExtend}
|
||||||
aria-label="Load the longer-range forecast"
|
aria-label="Load the longer-range forecast"
|
||||||
>
|
>
|
||||||
@@ -409,18 +409,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep the exact same layout at every size, just scale it: small screens get
|
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
|
||||||
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>
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
import { precipIsSignificant, windIsSignificant } from './significance';
|
import {
|
||||||
import { type FetchedDaily, type WeatherUnits } from './types';
|
getSunshineColor,
|
||||||
|
getSunshinePercent,
|
||||||
|
precipIsSignificant,
|
||||||
|
sunIsSignificant,
|
||||||
|
windIsSignificant
|
||||||
|
} from './significance';
|
||||||
|
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
daily: FetchedDaily | null;
|
daily: FetchedDaily | null;
|
||||||
@@ -30,9 +36,11 @@
|
|||||||
onExtendPast
|
onExtendPast
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// ─── Scroll-driven collapse (full → compact, mobile only) ───────────────────
|
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
|
||||||
// All sizing derives from a single registered custom property `--strip-p`
|
// All sizing derives from a single registered custom property `--strip-p`
|
||||||
// (0 = full cards, 1 = compact strip):
|
// (0 = full cards, 1 = compact strip). Mobile and desktop share the exact
|
||||||
|
// same mechanics — desktop just uses larger "full" tunables and collapses
|
||||||
|
// into a bar that matches the topbar height.
|
||||||
//
|
//
|
||||||
// * 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 —
|
||||||
@@ -40,36 +48,37 @@
|
|||||||
// * 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>();
|
||||||
let needsSnapFallback = $state(false);
|
let needsSnapFallback = $state(false);
|
||||||
let compact = $state(false);
|
let compact = $state(false);
|
||||||
|
let stuck = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const scrubSupported =
|
const scrubSupported =
|
||||||
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
|
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
|
||||||
if (scrubSupported || !sentinelEl) return;
|
if (scrubSupported || !sentinelEl) return;
|
||||||
needsSnapFallback = true;
|
needsSnapFallback = true;
|
||||||
// Collapse once more than half of the sentinel band has scrolled past the
|
// `stuck` fires the moment the strip pins (turns the bar opaque before any
|
||||||
// top; expands again on the same boundary (the transition smooths both).
|
// content can slide under it); `compact` snaps the cells once more than
|
||||||
|
// half of the sentinel band has scrolled past. The transitions smooth both.
|
||||||
const io = new IntersectionObserver(
|
const io = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const e = entries[entries.length - 1];
|
const e = entries[entries.length - 1];
|
||||||
|
stuck = e.intersectionRatio < 0.97;
|
||||||
compact = e.intersectionRatio < 0.5;
|
compact = e.intersectionRatio < 0.5;
|
||||||
},
|
},
|
||||||
{ threshold: [0.25, 0.5, 0.75] }
|
{ threshold: [0.25, 0.5, 0.75, 0.97] }
|
||||||
);
|
);
|
||||||
io.observe(sentinelEl);
|
io.observe(sentinelEl);
|
||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start scrolled so the "Past" button sits just off the left edge (revealed by
|
// Start scrolled so the "Past" button sits just off the left edge (revealed by
|
||||||
// scrolling left), with the first day flush to the content edge — same as the
|
// scrolling left), with the first day flush to the content edge. The days
|
||||||
// desktop cards. Runs once per dataset.
|
// group has min-width:100%, so the row always overflows by exactly the past
|
||||||
|
// button — this works at every viewport size. Runs once per dataset.
|
||||||
let scrolledForRef: FetchedDaily | null = null;
|
let scrolledForRef: FetchedDaily | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const d = daily;
|
const d = daily;
|
||||||
@@ -77,10 +86,18 @@
|
|||||||
scrolledForRef = d;
|
scrolledForRef = d;
|
||||||
const scroll = stripScrollEl;
|
const scroll = stripScrollEl;
|
||||||
const wrap = daysWrapEl;
|
const wrap = daysWrapEl;
|
||||||
requestAnimationFrame(() => {
|
// the delta form is self-correcting (a no-op once right), so apply after
|
||||||
// 12px = the strip's px-3 left padding, so the first day lands on the edge
|
// layout and once more after late-loading CSS/fonts settle — otherwise a
|
||||||
|
// sliver of the past button can stay visible on first paint
|
||||||
|
const apply = () => {
|
||||||
|
const padLeft = parseFloat(getComputedStyle(scroll).paddingLeft) || 0;
|
||||||
|
const firstCell = wrap.querySelector('.strip-cell') ?? wrap;
|
||||||
scroll.scrollLeft +=
|
scroll.scrollLeft +=
|
||||||
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
|
firstCell.getBoundingClientRect().left - scroll.getBoundingClientRect().left - padLeft;
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
apply();
|
||||||
|
setTimeout(apply, 250);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -96,26 +113,33 @@
|
|||||||
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
|
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
|
||||||
|
class:stuck
|
||||||
>
|
>
|
||||||
<div class="strip-row flex overflow-x-auto px-3 py-2 md:py-1 lg:px-8" bind:this={stripScrollEl}>
|
<div class="strip-row flex overflow-x-auto px-3 lg:px-8" bind:this={stripScrollEl}>
|
||||||
{#if daily}
|
{#if daily}
|
||||||
{#if canExtendPast && onExtendPast}
|
{#if canExtendPast && onExtendPast}
|
||||||
|
<!-- starts scrolled out of view (revealed by scrolling left); styled to
|
||||||
|
match the 15-days button on the other end -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
|
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||||
onclick={onExtendPast}
|
onclick={onExtendPast}
|
||||||
aria-label="Load recent past days"
|
aria-label="Load the past 3 days"
|
||||||
|
title="Load the past 3 days"
|
||||||
>
|
>
|
||||||
|
<span class="inline-flex items-baseline gap-0.5">
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5 md:h-4 md:w-4"
|
class="h-3 w-3 translate-y-px"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1.75"
|
stroke-width="2.5"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 6l-6 6 6 6" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 5l-7 7 7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[9px] leading-tight font-semibold">Past</span>
|
<span class="text-sm leading-none font-extrabold">3</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-[9px] leading-tight font-semibold">past</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -129,13 +153,23 @@
|
|||||||
{@const nightCode = daily.nightCodes?.[index] ?? wCode}
|
{@const nightCode = daily.nightCodes?.[index] ?? wCode}
|
||||||
{@const precipSum = daily.daily.precipitation_sum[index]}
|
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||||
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||||
|
{@const gustMax = daily.daily.windgusts_10m_max[index]}
|
||||||
|
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||||
|
{@const sunDuration = daily.daily.sunshine_duration[index]}
|
||||||
|
{@const daylightSec = Math.max(
|
||||||
|
0,
|
||||||
|
(daily.daily.sunset[index] ?? 0) - (daily.daily.sunrise[index] ?? 0)
|
||||||
|
)}
|
||||||
|
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
|
||||||
|
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
|
||||||
|
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
|
||||||
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
|
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
|
||||||
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
|
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
|
||||||
{@const lowWind = !windIsSignificant(windMax, null, String(units.wind_speed_unit))}
|
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
|
||||||
{#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 md:gap-0 md:pb-0 {selected
|
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 {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}
|
||||||
@@ -144,7 +178,7 @@
|
|||||||
<!-- weekday drifts to the left edge and the date fades in at the
|
<!-- weekday drifts to the left edge and the date fades in at the
|
||||||
right edge as the cards collapse (flex spacers driven by --rel) -->
|
right edge as the cards collapse (flex spacers driven by --rel) -->
|
||||||
<span
|
<span
|
||||||
class="dow-row flex w-full items-baseline px-0.5 text-[11px] font-semibold tracking-wide whitespace-nowrap md:text-[10px] {selected
|
class="dow-row flex w-full items-baseline px-0.5 font-semibold tracking-wide whitespace-nowrap {selected
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
@@ -158,12 +192,17 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="rel-label overflow-hidden text-[9px] leading-[1.25] whitespace-nowrap text-muted-foreground"
|
class="rel-label overflow-hidden leading-[1.25] whitespace-nowrap text-muted-foreground"
|
||||||
>
|
>
|
||||||
{getRelativeDayLabel(time, daily.timezone)}
|
{getRelativeDayLabel(time, daily.timezone)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="icon-wrap relative">
|
<!-- Two centered rows: day + night icons, then day + night temps.
|
||||||
|
Both stay perfectly centered when compact (the night icon melts
|
||||||
|
away and the day icon re-centers on its own); when full, small
|
||||||
|
k-driven nudges line each temp up under its icon. -->
|
||||||
|
<div class="flex w-full items-center justify-center">
|
||||||
|
<div class="icon-wrap">
|
||||||
<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(
|
||||||
@@ -172,10 +211,8 @@
|
|||||||
)}.svg#Layer_1"
|
)}.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
<!-- night companion icon, present in the full view, fades as it collapses -->
|
</div>
|
||||||
<svg
|
<svg class="night-icon self-end fill-foreground/60">
|
||||||
class="night-badge absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60"
|
|
||||||
>
|
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
nightCode,
|
nightCode,
|
||||||
@@ -185,9 +222,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-baseline gap-1 leading-none">
|
<div class="flex w-full items-baseline justify-center gap-0.5 leading-none">
|
||||||
<span
|
<span
|
||||||
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums md:py-px"
|
class="temp-max rounded-md font-extrabold tabular-nums"
|
||||||
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
||||||
>
|
>
|
||||||
{tempMax.toFixed(0)}°
|
{tempMax.toFixed(0)}°
|
||||||
@@ -199,6 +236,30 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
|
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
|
||||||
|
>
|
||||||
|
<!-- sunshine bar (desktop full cards only, like the old day cards) -->
|
||||||
|
<div
|
||||||
|
class="hidden w-full items-center gap-1 px-0.5 md:my-1 md:flex {lowSun
|
||||||
|
? 'opacity-45'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<svg class="shrink-0" width="14" height="14" style="fill:{sunColor}">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style="width:{sunPct}%;background-color:{sunColor}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium">
|
||||||
|
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- precip + wind: stacked on mobile, one row on md+ (old card style) -->
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col items-center gap-0.5 md:flex-row md:justify-center md:gap-2"
|
||||||
>
|
>
|
||||||
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
|
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
|
||||||
<svg class="fill-sky-500" width="13" height="13">
|
<svg class="fill-sky-500" width="13" height="13">
|
||||||
@@ -207,33 +268,59 @@
|
|||||||
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1 {lowWind ? 'opacity-40' : ''}">
|
<span class="inline-flex items-center gap-1 {lowWind ? 'opacity-40' : ''}">
|
||||||
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
|
<span
|
||||||
|
class="hidden shrink-0 md:inline-flex md:-mr-1"
|
||||||
|
style="transform: {getWindArrowRotation(windDir)}"
|
||||||
|
>
|
||||||
|
<svg class="fill-muted-foreground" width="20" height="20">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<svg class="fill-muted-foreground md:hidden" width="13" height="13">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
<svg class="fill-muted-foreground" width="13" height="13">
|
<svg class="fill-muted-foreground" width="13" height="13">
|
||||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
{windMax?.toFixed(0) ?? '-'}
|
{/if}
|
||||||
|
<span class="whitespace-nowrap"
|
||||||
|
>{windMax?.toFixed(0) ?? '-'}<span class="hidden opacity-70 md:inline"
|
||||||
|
>-{gustMax?.toFixed(0) ?? '-'}</span
|
||||||
|
></span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if canExtend && onExtend}
|
{#if canExtend && onExtend}
|
||||||
|
<!-- shows the RESULTING range, not an increment: tapping switches the
|
||||||
|
whole forecast from 7 to 15 days -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
|
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||||
onclick={onExtend}
|
onclick={onExtend}
|
||||||
aria-label="Load the longer-range forecast"
|
aria-label="Show the full 15-day forecast"
|
||||||
|
title="Show the full 15-day forecast"
|
||||||
>
|
>
|
||||||
|
<span class="inline-flex items-baseline gap-0.5">
|
||||||
|
<span class="text-sm leading-none font-extrabold">15</span>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5 md:h-4 md:w-4"
|
class="h-3 w-3 translate-y-px"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1.75"
|
stroke-width="2.5"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[9px] leading-tight font-semibold">15d</span>
|
</span>
|
||||||
|
<span class="text-[9px] leading-tight font-semibold">days</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,21 +340,90 @@
|
|||||||
initial-value: 0;
|
initial-value: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 1 as soon as the strip pins — drives the bar chrome independently of the
|
||||||
|
collapse progress so nothing ever shows through a still-expanding bar. */
|
||||||
|
@property --stuck {
|
||||||
|
syntax: '<number>';
|
||||||
|
inherits: true;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tunables, shared by the strip and its sentinel: the collapse plays out
|
/* Tunables, shared by the strip and its sentinel: the collapse plays out
|
||||||
over exactly the cell-height difference (see .daystrip height below). */
|
over exactly the bar-height difference (see .daystrip height below). */
|
||||||
.sentinel,
|
.sentinel,
|
||||||
.daystrip {
|
.daystrip {
|
||||||
--cell-w-full: 76px;
|
--cell-w-full: 76px;
|
||||||
--cell-w-min: 56px;
|
--cell-w-min: 58px;
|
||||||
--cell-h-full: 140px;
|
--cell-h-full: 140px;
|
||||||
--cell-h-min: 62px;
|
--cell-h-min: 64px;
|
||||||
--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));
|
/* bar (strip-row) vertical padding */
|
||||||
|
--pad-full: 8px;
|
||||||
|
--pad-min: 8px;
|
||||||
|
/* fonts */
|
||||||
|
--dow-font-full: 11px;
|
||||||
|
--dow-font-min: 11px;
|
||||||
|
--rel-font-full: 9px;
|
||||||
|
--rel-font-min: 9px;
|
||||||
|
--tmax-font-full: 12px;
|
||||||
|
--tmax-font-min: 11px;
|
||||||
|
--tmin-font-full: 11px;
|
||||||
|
--tmin-font-min: 10px;
|
||||||
|
--tmax-padx-full: 6px;
|
||||||
|
--tmax-padx-min: 4px;
|
||||||
|
/* full state only: nudge each temp to sit under "its" icon */
|
||||||
|
--tmax-nudge: 0px;
|
||||||
|
--tmin-nudge: 5px;
|
||||||
|
/* cell bottom padding (roomy when full, tight when compact) */
|
||||||
|
--pb-full: 6px;
|
||||||
|
--pb-min: 6px;
|
||||||
|
/* extra scrub distance beyond the height difference — slows the collapse
|
||||||
|
down; the surplus just slides content under the (opaque) bar */
|
||||||
|
--collapse-extra: 0px;
|
||||||
|
|
||||||
|
--collapse: calc(
|
||||||
|
var(--cell-h-full) + 2 * var(--pad-full) - var(--cell-h-min) - 2 * var(--pad-min) +
|
||||||
|
var(--collapse-extra)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* md+: same collapse, but the full state keeps the old desktop day-card
|
||||||
|
proportions, plays out over a longer scroll distance, and the compact
|
||||||
|
bar docks under the topbar (slightly taller than its 56px). */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sentinel,
|
||||||
|
.daystrip {
|
||||||
|
--cell-w-full: 120px;
|
||||||
|
--cell-h-full: 208px;
|
||||||
|
--cell-w-min: 64px;
|
||||||
|
--cell-h-min: 56px;
|
||||||
|
--icon-full: 80px;
|
||||||
|
--icon-min: 22px;
|
||||||
|
--pt-full: 10px;
|
||||||
|
--gap-full: 10px;
|
||||||
|
--gap-min: 6px;
|
||||||
|
--pad-min: 4px;
|
||||||
|
--dow-font-full: 13px;
|
||||||
|
--dow-font-min: 10px;
|
||||||
|
--rel-font-full: 11px;
|
||||||
|
--tmax-font-full: 17px;
|
||||||
|
--tmin-font-full: 15px;
|
||||||
|
--tmax-padx-full: 12px;
|
||||||
|
--tmax-nudge: 0px;
|
||||||
|
--tmin-nudge: 8px;
|
||||||
|
--pb-full: 10px;
|
||||||
|
--pb-min: 2px;
|
||||||
|
--collapse-extra: 60px;
|
||||||
|
}
|
||||||
|
.daystrip {
|
||||||
|
/* breathing room between the full cards and the table */
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Invisible collapse band: the scroll distance over which the collapse
|
/* Invisible collapse band: the scroll distance over which the collapse
|
||||||
@@ -280,6 +436,7 @@
|
|||||||
|
|
||||||
.daystrip {
|
.daystrip {
|
||||||
--strip-p: 0;
|
--strip-p: 0;
|
||||||
|
--stuck: 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) */
|
||||||
--k: calc(1 - var(--strip-p));
|
--k: calc(1 - var(--strip-p));
|
||||||
@@ -288,11 +445,13 @@
|
|||||||
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
|
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
|
||||||
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
|
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
|
||||||
--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));
|
||||||
|
--pad: calc(var(--pad-min) + (var(--pad-full) - var(--pad-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 turns opaque almost as soon as the strip sticks, so
|
/* bar background turns opaque the moment the strip sticks (via --stuck),
|
||||||
content never shows through it */
|
so content never shows through it — even while the cells are still
|
||||||
--chrome: clamp(0, calc(var(--strip-p) * 6), 1);
|
large and the collapse has barely started */
|
||||||
|
--chrome: clamp(0, calc(var(--strip-p) * 6 + var(--stuck)), 1);
|
||||||
|
|
||||||
/* The sticky box keeps a CONSTANT height — only its contents shrink.
|
/* The sticky box keeps a CONSTANT height — only its contents shrink.
|
||||||
The collapse therefore never resizes the document (the large table
|
The collapse therefore never resizes the document (the large table
|
||||||
@@ -300,7 +459,7 @@
|
|||||||
of scroll jank on mobile Chromium), and because the collapse distance
|
of scroll jank on mobile Chromium), and because the collapse distance
|
||||||
equals the height difference, the table slides up under the shrinking
|
equals the height difference, the table slides up under the shrinking
|
||||||
bar in exact sync. The empty lower part is click-through. */
|
bar in exact sync. The empty lower part is click-through. */
|
||||||
height: calc(var(--cell-h-full) + 16px);
|
height: calc(var(--cell-h-full) + 2 * var(--pad-full));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
/* own compositor layer: per-frame repaints stay isolated to the strip */
|
/* own compositor layer: per-frame repaints stay isolated to the strip */
|
||||||
@@ -313,6 +472,7 @@
|
|||||||
.strip-row {
|
.strip-row {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
|
padding-block: var(--pad);
|
||||||
background: color-mix(
|
background: color-mix(
|
||||||
in oklab,
|
in oklab,
|
||||||
var(--color-background) calc(var(--chrome) * 100%),
|
var(--color-background) calc(var(--chrome) * 100%),
|
||||||
@@ -330,9 +490,13 @@
|
|||||||
view-timeline: --daystrip-sentinel block;
|
view-timeline: --daystrip-sentinel block;
|
||||||
}
|
}
|
||||||
.daystrip {
|
.daystrip {
|
||||||
animation: strip-collapse linear both;
|
animation:
|
||||||
animation-timeline: --daystrip-sentinel;
|
strip-collapse linear both,
|
||||||
animation-range: exit 0% exit 100%;
|
strip-stuck linear both;
|
||||||
|
animation-timeline: --daystrip-sentinel, --daystrip-sentinel;
|
||||||
|
animation-range:
|
||||||
|
exit 0% exit 100%,
|
||||||
|
exit 0% exit 3%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes strip-collapse {
|
@keyframes strip-collapse {
|
||||||
@@ -340,39 +504,57 @@
|
|||||||
--strip-p: 1;
|
--strip-p: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@keyframes strip-stuck {
|
||||||
|
to {
|
||||||
|
--stuck: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Snap fallback: ease between the two end states instead of scrubbing.
|
/* Snap fallback: ease between the two end states instead of scrubbing.
|
||||||
(Browsers too old to register --strip-p simply switch instantly.) */
|
(Browsers too old to register --strip-p simply switch instantly.) */
|
||||||
.daystrip.js-snap {
|
.daystrip.js-snap {
|
||||||
transition: --strip-p 0.28s ease;
|
transition:
|
||||||
|
--strip-p 0.28s ease,
|
||||||
|
--stuck 0.15s ease;
|
||||||
}
|
}
|
||||||
.daystrip.js-snap.compact {
|
.daystrip.js-snap.compact {
|
||||||
--strip-p: 1;
|
--strip-p: 1;
|
||||||
}
|
}
|
||||||
|
.daystrip.js-snap.stuck {
|
||||||
|
--stuck: 1;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
/* larger cards need a touch longer to feel smooth */
|
||||||
|
.daystrip.js-snap {
|
||||||
|
transition:
|
||||||
|
--strip-p 0.55s ease,
|
||||||
|
--stuck 0.15s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.daystrip.js-snap {
|
.daystrip.js-snap {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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);
|
||||||
|
/* fill the row so it overflows by exactly the past button, which starts
|
||||||
|
scrolled out of view and is revealed by scrolling left — even when the
|
||||||
|
day cells alone wouldn't overflow (wide desktop viewports) */
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
/* Scroll containers clip at the PADDING edge, so a parked past button would
|
||||||
|
always leak a sliver across the row's left padding. Extending the days
|
||||||
|
group's left edge (only while the past button exists) moves max-scroll so
|
||||||
|
the button parks fully beyond the clip edge. */
|
||||||
|
.strip-side + .strip-days {
|
||||||
|
padding-left: calc(12px - var(--gap-min));
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.strip-side + .strip-days {
|
||||||
|
padding-left: calc(32px - var(--gap-min));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.strip-cell,
|
.strip-cell,
|
||||||
.strip-side {
|
.strip-side {
|
||||||
@@ -380,11 +562,32 @@
|
|||||||
/* size tracks the collapse exactly; only the tap highlight eases */
|
/* size tracks the collapse exactly; only the tap highlight eases */
|
||||||
transition:
|
transition:
|
||||||
border-color 0.15s,
|
border-color 0.15s,
|
||||||
background-color 0.15s;
|
background-color 0.15s,
|
||||||
|
translate 0.2s ease-out,
|
||||||
|
scale 0.2s ease-out,
|
||||||
|
box-shadow 0.2s ease-out;
|
||||||
|
}
|
||||||
|
/* The old day-card lift: hover raises the card slightly, the selected day a
|
||||||
|
touch more. Scaled by --k so the effect melts away as the strip compacts
|
||||||
|
(and never disturbs the slim bar); hover only where hover exists. */
|
||||||
|
.strip-cell[aria-pressed='true'] {
|
||||||
|
z-index: 10;
|
||||||
|
translate: 0 calc(-4px * var(--k));
|
||||||
|
scale: calc(1 + 0.04 * var(--k));
|
||||||
|
box-shadow: 0 5px 14px -4px rgba(0, 0, 0, calc(0.4 * var(--k)));
|
||||||
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
.strip-cell:hover:not([aria-pressed='true']) {
|
||||||
|
z-index: 10;
|
||||||
|
translate: 0 calc(-3px * var(--k));
|
||||||
|
scale: calc(1 + 0.02 * var(--k));
|
||||||
|
box-shadow: 0 4px 10px -4px rgba(0, 0, 0, calc(0.3 * var(--k)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.strip-cell {
|
.strip-cell {
|
||||||
width: var(--cell-w);
|
width: var(--cell-w);
|
||||||
padding-top: var(--pt);
|
padding-top: var(--pt);
|
||||||
|
padding-bottom: calc(var(--pb-min) + (var(--pb-full) - var(--pb-min)) * var(--k));
|
||||||
}
|
}
|
||||||
/* Side buttons keep the compact width in BOTH states, so almost nothing to
|
/* 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
|
the left of the first day changes size during the collapse — the first
|
||||||
@@ -402,14 +605,24 @@
|
|||||||
width: var(--icon);
|
width: var(--icon);
|
||||||
height: var(--icon);
|
height: var(--icon);
|
||||||
}
|
}
|
||||||
.night-badge {
|
/* The night icon sits beside the day icon (slightly low, like a companion)
|
||||||
width: calc(var(--icon) * 0.44);
|
and melts away completely when compact so the day icon re-centers. */
|
||||||
height: calc(var(--icon) * 0.44);
|
.night-icon {
|
||||||
|
display: block;
|
||||||
|
width: calc(var(--icon) * 0.42 * var(--rel));
|
||||||
|
height: calc(var(--icon) * 0.42 * var(--rel));
|
||||||
opacity: var(--rel);
|
opacity: var(--rel);
|
||||||
|
margin-left: calc(-4px * var(--rel));
|
||||||
|
margin-bottom: calc(6px * var(--rel));
|
||||||
}
|
}
|
||||||
/* weekday centered when full, pushed to the edges when compact */
|
/* weekday centered when full, pushed to the edges when compact */
|
||||||
|
.dow-row {
|
||||||
|
font-size: calc(var(--dow-font-min) + (var(--dow-font-full) - var(--dow-font-min)) * var(--k));
|
||||||
|
}
|
||||||
.dow-spacer {
|
.dow-spacer {
|
||||||
flex-grow: var(--rel);
|
/* keep some outer share when compact so weekday + date sit near-centered
|
||||||
|
with a modest gap instead of being pushed to the cell edges */
|
||||||
|
flex-grow: calc(0.6 + 0.4 * var(--rel));
|
||||||
}
|
}
|
||||||
.dow-mid {
|
.dow-mid {
|
||||||
flex-grow: calc(1 - var(--rel));
|
flex-grow: calc(1 - var(--rel));
|
||||||
@@ -421,19 +634,31 @@
|
|||||||
opacity: calc(1 - var(--rel));
|
opacity: calc(1 - var(--rel));
|
||||||
}
|
}
|
||||||
.rel-label {
|
.rel-label {
|
||||||
|
font-size: calc(var(--rel-font-min) + (var(--rel-font-full) - var(--rel-font-min)) * var(--k));
|
||||||
opacity: var(--rel);
|
opacity: var(--rel);
|
||||||
max-height: calc(14px * var(--rel));
|
max-height: calc(14px * var(--rel));
|
||||||
}
|
}
|
||||||
.detail-row {
|
.detail-row {
|
||||||
opacity: var(--detail);
|
opacity: var(--detail);
|
||||||
max-height: calc(42px * var(--detail));
|
max-height: calc(52px * var(--detail));
|
||||||
}
|
}
|
||||||
.temp-max {
|
.temp-max {
|
||||||
font-size: calc(11px + 1px * var(--k));
|
font-size: calc(
|
||||||
padding-inline: calc(3px + 3px * var(--k));
|
var(--tmax-font-min) + (var(--tmax-font-full) - var(--tmax-font-min)) * var(--k)
|
||||||
|
);
|
||||||
|
padding-inline: calc(
|
||||||
|
var(--tmax-padx-min) + (var(--tmax-padx-full) - var(--tmax-padx-min)) * var(--k)
|
||||||
|
);
|
||||||
|
padding-block: calc(2px + 1px * var(--k));
|
||||||
|
/* full: line up under the day icon */
|
||||||
|
transform: translateX(calc(var(--tmax-nudge) * var(--k)));
|
||||||
}
|
}
|
||||||
.temp-min {
|
.temp-min {
|
||||||
font-size: calc(10px + 1px * var(--k));
|
font-size: calc(
|
||||||
|
var(--tmin-font-min) + (var(--tmin-font-full) - var(--tmin-font-min)) * var(--k)
|
||||||
|
);
|
||||||
|
/* full: line up under the night icon */
|
||||||
|
transform: translateX(calc(var(--tmin-nudge) * var(--k)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.daystrip :global(.overflow-x-auto) {
|
.daystrip :global(.overflow-x-auto) {
|
||||||
|
|||||||
@@ -300,8 +300,9 @@
|
|||||||
? 'border-t border-border/50'
|
? 'border-t border-border/50'
|
||||||
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
|
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
|
||||||
>
|
>
|
||||||
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
|
<!-- same title treatment as the compare / 14-day pages -->
|
||||||
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
<div class="mb-1 flex items-center justify-between px-3 lg:px-0">
|
||||||
|
<h4 class="truncate text-sm font-bold tracking-tight">
|
||||||
<span class="hidden lg:inline">{panel.title}</span>
|
<span class="hidden lg:inline">{panel.title}</span>
|
||||||
<span class="lg:hidden">{panel.titleShort}</span>
|
<span class="lg:hidden">{panel.titleShort}</span>
|
||||||
</h4>
|
</h4>
|
||||||
@@ -327,6 +328,8 @@
|
|||||||
unit={panel.def.unit}
|
unit={panel.def.unit}
|
||||||
unitRight={panel.def.unitRight}
|
unitRight={panel.def.unitRight}
|
||||||
yMin={panel.def.yMin}
|
yMin={panel.def.yMin}
|
||||||
|
yPadTop={panel.def.yPadTop}
|
||||||
|
yPadBottom={panel.def.yPadBottom}
|
||||||
zeroBaseLeft={panel.def.zeroBaseLeft}
|
zeroBaseLeft={panel.def.zeroBaseLeft}
|
||||||
yMinRight={panel.def.yMinRight}
|
yMinRight={panel.def.yMinRight}
|
||||||
yMaxRight={panel.def.yMaxRight}
|
yMaxRight={panel.def.yMaxRight}
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ export function precipIsSignificant(sum: number | null, unit: string): boolean {
|
|||||||
return (sum ?? 0) >= min;
|
return (sum ?? 0) >= min;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sunshine as a share of daylight, for the sun progress bar (0-100). */
|
||||||
|
export function getSunshinePercent(
|
||||||
|
sunshineSeconds: number | null,
|
||||||
|
daylightSeconds: number
|
||||||
|
): number {
|
||||||
|
if (!sunshineSeconds || daylightSeconds <= 0) return 0;
|
||||||
|
return Math.min(100, (sunshineSeconds / daylightSeconds) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sun icon / bar colour by sunshine ratio: grey → pale gold → amber. */
|
||||||
|
export function getSunshineColor(sunshineSeconds: number | null, daylightSeconds: number): string {
|
||||||
|
if (daylightSeconds <= 0) return '#d1d5db';
|
||||||
|
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
|
||||||
|
if (ratio >= 0.7) return '#f59e0b';
|
||||||
|
if (ratio >= 0.45) return '#fbbf24';
|
||||||
|
if (ratio >= 0.1) return '#fcd34d';
|
||||||
|
return '#d1d5db';
|
||||||
|
}
|
||||||
|
|
||||||
export function windIsSignificant(
|
export function windIsSignificant(
|
||||||
speed: number | null,
|
speed: number | null,
|
||||||
gust: number | null,
|
gust: number | null,
|
||||||
|
|||||||
@@ -419,6 +419,9 @@ export interface PanelDef {
|
|||||||
yMin?: number;
|
yMin?: number;
|
||||||
yMinRight?: number;
|
yMinRight?: number;
|
||||||
yMaxRight?: number;
|
yMaxRight?: number;
|
||||||
|
/** Breathing room (in axis units) above / below the left-axis data range. */
|
||||||
|
yPadTop?: number;
|
||||||
|
yPadBottom?: number;
|
||||||
/** Whether the left axis should include zero (false for pressure) */
|
/** Whether the left axis should include zero (false for pressure) */
|
||||||
zeroBaseLeft: boolean;
|
zeroBaseLeft: boolean;
|
||||||
hasPictograms: boolean;
|
hasPictograms: boolean;
|
||||||
@@ -496,6 +499,10 @@ export function buildPanelDef(
|
|||||||
unit: leftKind ? unitForKind(leftKind, units) : '',
|
unit: leftKind ? unitForKind(leftKind, units) : '',
|
||||||
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
|
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
|
||||||
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
|
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
|
||||||
|
// temperature curves shouldn't touch the frame: guarantee headroom above
|
||||||
|
// the max and extra space below the min (extrema labels live there too)
|
||||||
|
yPadTop: leftKind === 'temp' ? 3 : undefined,
|
||||||
|
yPadBottom: leftKind === 'temp' ? 5 : undefined,
|
||||||
// temperature and pressure sit far from zero, so their axis is derived from
|
// temperature and pressure sit far from zero, so their axis is derived from
|
||||||
// the data range (a forced 0 baseline just wastes vertical space)
|
// the data range (a forced 0 baseline just wastes vertical space)
|
||||||
zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true,
|
zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true,
|
||||||
|
|||||||
Reference in New Issue
Block a user