strip more seamless and margin improvements
This commit is contained in:
@@ -152,6 +152,10 @@
|
||||
group?: string;
|
||||
/** Fixed left-axis minimum (otherwise derived from data, including 0) */
|
||||
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 */
|
||||
yMax?: number;
|
||||
/** Force the derived left axis to include zero (default true) */
|
||||
@@ -196,6 +200,8 @@
|
||||
group,
|
||||
yMin,
|
||||
yMax,
|
||||
yPadTop,
|
||||
yPadBottom,
|
||||
zeroBaseLeft = true,
|
||||
yMinRight,
|
||||
yMaxRight,
|
||||
@@ -302,9 +308,11 @@
|
||||
// and the right-axis labels are drawn overlaid on top instead (see below).
|
||||
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
|
||||
// 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 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
|
||||
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
|
||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
|
||||
@@ -353,16 +361,38 @@
|
||||
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 min = loFixed ? lo : Math.floor(lo / step) * step;
|
||||
const max = hiFixed ? hi : Math.ceil(hi / step) * step;
|
||||
// Padded axes (e.g. temperature) may end on HALF steps — 5° when ticks
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/** 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 => {
|
||||
const [dLo, dHi] = dataExtent('left', zeroBaseLeft);
|
||||
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
|
||||
let [dLo, dHi] = dataExtent('left', zeroBaseLeft);
|
||||
// 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 => {
|
||||
@@ -839,7 +869,11 @@
|
||||
ctx.font = font;
|
||||
ctx.textAlign = 'right';
|
||||
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');
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
@@ -858,7 +892,7 @@
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = textColor;
|
||||
for (
|
||||
let v = rightScale.min;
|
||||
let v = firstTick(rightScale);
|
||||
v <= rightScale.max + rightScale.step / 2;
|
||||
v += rightScale.step
|
||||
) {
|
||||
@@ -1153,7 +1187,7 @@
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineJoin = 'round';
|
||||
for (
|
||||
let v = rightScale.min;
|
||||
let v = firstTick(rightScale);
|
||||
v <= rightScale.max + rightScale.step / 2;
|
||||
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;
|
||||
});
|
||||
|
||||
// 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 themeTitles: Record<Theme, string> = {
|
||||
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"
|
||||
>
|
||||
<img
|
||||
bind:this={flagEl}
|
||||
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
|
||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||
alt={location.country}
|
||||
|
||||
@@ -131,35 +131,7 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- About / legal links (hidden when collapsed; the pages stay reachable
|
||||
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}
|
||||
<!-- About / legal links moved to the page footer -->
|
||||
|
||||
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
||||
{#if onToggle}
|
||||
|
||||
@@ -273,6 +273,65 @@ export interface EnsembleForecastResult {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
// Fallback set when the caller does not specify which hourly variables it
|
||||
|
||||
Reference in New Issue
Block a user