fallback and 404
This commit is contained in:
@@ -31,6 +31,9 @@
|
||||
width?: number;
|
||||
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
|
||||
fill?: boolean;
|
||||
/** With `fill`, fill the area between this line and another data array
|
||||
* instead of the baseline (e.g. an ensemble min-max band) */
|
||||
bandTo?: (number | null)[];
|
||||
/** Opacity of the area fill (default 0.15) */
|
||||
fillOpacity?: number;
|
||||
/** Draw the line dashed */
|
||||
@@ -92,6 +95,8 @@
|
||||
series: ChartSeries[];
|
||||
/** Background bands (epoch seconds), e.g. daylight */
|
||||
bands?: { start: number; end: number }[];
|
||||
/** Highlighted time range (epoch seconds), e.g. the selected day */
|
||||
highlight?: { start: number; end: number };
|
||||
/** Unit label for the left y axis (also used in tooltip values) */
|
||||
unit?: string;
|
||||
/** Unit label for the right y axis; when set, right-axis labels are drawn */
|
||||
@@ -129,6 +134,7 @@
|
||||
timezone,
|
||||
series,
|
||||
bands = [],
|
||||
highlight,
|
||||
unit = '',
|
||||
unitRight,
|
||||
height = 300,
|
||||
@@ -443,6 +449,33 @@
|
||||
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
||||
}
|
||||
|
||||
// Selected-day highlight: soft tint + dashed edge lines
|
||||
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
|
||||
const accent = cssColor('--primary', '#e08a3c');
|
||||
const x1 = Math.max(PAD_LEFT, xPix(highlight.start));
|
||||
const x2 = Math.min(plotRight, xPix(highlight.end));
|
||||
if (x2 > x1) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.08;
|
||||
ctx.fillStyle = accent;
|
||||
ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([5, 4]);
|
||||
ctx.beginPath();
|
||||
for (const edge of [highlight.start, highlight.end]) {
|
||||
const x = xPix(edge);
|
||||
if (x >= PAD_LEFT && x <= plotRight) {
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, plotBottom);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal grid lines + left axis labels
|
||||
ctx.font = font;
|
||||
ctx.textAlign = 'right';
|
||||
@@ -530,17 +563,20 @@
|
||||
}
|
||||
|
||||
// Line series: draw fill and stroke per contiguous non-null run
|
||||
// (points outside the view are handled by the clip rect)
|
||||
const runs: Array<Array<[number, number]>> = [];
|
||||
let run: Array<[number, number]> = [];
|
||||
// (points outside the view are handled by the clip rect). Each point
|
||||
// is [x, y, yBand] — yBand only used when s.bandTo is set.
|
||||
const runs: Array<Array<[number, number, number]>> = [];
|
||||
let run: Array<[number, number, number]> = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
const v = s.data[i];
|
||||
if (v === null || v === undefined || !isFinite(v)) {
|
||||
const b = s.bandTo?.[i];
|
||||
const bandInvalid = s.bandTo != null && (b === null || b === undefined || !isFinite(b));
|
||||
if (v === null || v === undefined || !isFinite(v) || bandInvalid) {
|
||||
if (run.length > 0) runs.push(run);
|
||||
run = [];
|
||||
continue;
|
||||
}
|
||||
run.push([xPix(timestamps[i]), yPix(v, axis)]);
|
||||
run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0]);
|
||||
}
|
||||
if (run.length > 0) runs.push(run);
|
||||
|
||||
@@ -551,8 +587,13 @@
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||||
if (s.bandTo) {
|
||||
// close the polygon along the second line, walked backwards
|
||||
for (let i = points.length - 1; i >= 0; i--) ctx.lineTo(points[i][0], points[i][2]);
|
||||
} else {
|
||||
ctx.lineTo(points[points.length - 1][0], baseline);
|
||||
ctx.lineTo(points[0][0], baseline);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.globalAlpha = s.fillOpacity ?? 0.15;
|
||||
ctx.fillStyle = s.color;
|
||||
@@ -789,29 +830,6 @@
|
||||
</script>
|
||||
|
||||
<div bind:this={containerEl} class="relative w-full select-none {className}">
|
||||
{#if showLegend && series.length > 0}
|
||||
<div class="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1 px-1">
|
||||
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1.5 text-xs transition-opacity {legendHidden.has(
|
||||
s.name
|
||||
)
|
||||
? 'opacity-40'
|
||||
: ''}"
|
||||
onclick={() => toggleSeries(s.name)}
|
||||
title="Toggle {s.name}"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style:background-color={s.color}
|
||||
></span>
|
||||
<span class="text-muted-foreground">{s.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<canvas
|
||||
bind:this={canvasEl}
|
||||
@@ -856,4 +874,28 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Legend sits below the graph -->
|
||||
{#if showLegend && series.length > 0}
|
||||
<div class="mt-1.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 px-1">
|
||||
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1.5 text-xs transition-opacity {legendHidden.has(
|
||||
s.name
|
||||
)
|
||||
? 'opacity-40'
|
||||
: ''}"
|
||||
onclick={() => toggleSeries(s.name)}
|
||||
title="Toggle {s.name}"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style:background-color={s.color}
|
||||
></span>
|
||||
<span class="text-muted-foreground">{s.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div
|
||||
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-accent transition-opacity duration-300"
|
||||
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-background transition-opacity duration-300"
|
||||
class:pointer-events-none={!loading}
|
||||
class:opacity-0={!loading}
|
||||
class:opacity-100={loading}
|
||||
|
||||
@@ -47,3 +47,34 @@ export const storedLocation = persisted('stored_location', defaultLocation as Ge
|
||||
export type Theme = 'system' | 'light' | 'dark';
|
||||
|
||||
export const storedTheme = persisted<Theme>('theme', 'system');
|
||||
|
||||
/** Selected forecast model, shared across the whole site. */
|
||||
export const storedModel = persisted<string>('selected_model', 'best_match');
|
||||
|
||||
/** Which variables are visible in the hourly table and the meteograms. */
|
||||
export interface VariablePrefs {
|
||||
table: Record<string, boolean>;
|
||||
charts: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export const defaultVariablePrefs: VariablePrefs = {
|
||||
table: {
|
||||
icons: true,
|
||||
temperature: true,
|
||||
feels: true,
|
||||
wind: true,
|
||||
humidity: true,
|
||||
clouds: true,
|
||||
precipitation: true
|
||||
},
|
||||
charts: {
|
||||
temperature: true,
|
||||
cloud_cover: true,
|
||||
precipitation: true,
|
||||
precipitation_probability: true,
|
||||
wind: true,
|
||||
humidity: true
|
||||
}
|
||||
};
|
||||
|
||||
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
||||
|
||||
@@ -118,6 +118,14 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* color-scheme drives native widgets AND propagates into embedded
|
||||
iframes (the open-meteo map reads it via prefers-color-scheme) */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
import { storedLocation, storedModel } from '$lib/stores/settings';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
@@ -93,6 +94,11 @@
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
onMount(() => {
|
||||
// the model chosen elsewhere on the site is always part of the comparison
|
||||
const selectedModel = get(storedModel);
|
||||
if (selectedModel !== 'best_match' && !params.models.includes(selectedModel)) {
|
||||
params.models = [selectedModel, ...params.models];
|
||||
}
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
@@ -230,7 +236,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ChartContainer {loading} chartCount={chartDefs.length || 1} chartHeight={300}>
|
||||
<!-- chart count derives from the selected variables (not the fetched data),
|
||||
so the reserved height is right even before the response arrives -->
|
||||
<ChartContainer
|
||||
{loading}
|
||||
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
||||
chartHeight={300}
|
||||
>
|
||||
{#if fetchedData}
|
||||
{#each chartDefs as def, i (i)}
|
||||
<CanvasChart
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
// the embedded map understands maplibre's #zoom/lat/lng hash, so the iframe
|
||||
// opens focused on the selected location; picking a new location while on
|
||||
// this page recenters the map
|
||||
// opens focused on the selected location (zoomed out to regional scale);
|
||||
// picking a new location while on this page recenters the map
|
||||
const iframeSrc = $derived(
|
||||
`https://maps.open-meteo.com/#8/${$storedLocation.latitude.toFixed(3)}/${$storedLocation.longitude.toFixed(3)}`
|
||||
`https://maps.open-meteo.com/#6/${$storedLocation.latitude.toFixed(3)}/${$storedLocation.longitude.toFixed(3)}`
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Full-bleed map: the layout drops its padding for this route -->
|
||||
<div class="h-full w-full bg-black">
|
||||
<!-- Full-bleed map: the layout drops its padding for this route. The map
|
||||
follows our theme through the color-scheme declared on :root/.dark -->
|
||||
<div class="h-full w-full bg-background">
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
title="Open-Meteo Interactive Map"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
import { storedLocation, storedModel, storedVariablePrefs } from '$lib/stores/settings';
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
||||
|
||||
@@ -11,6 +14,7 @@
|
||||
import HourlyTable from './HourlyTable.svelte';
|
||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||
import ModelSelector from './ModelSelector.svelte';
|
||||
import VariableSidebar from './VariableSidebar.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
import type { FetchedDaily, FetchedHourly } from './types';
|
||||
@@ -22,6 +26,19 @@
|
||||
...defaultParameters
|
||||
});
|
||||
|
||||
let variableSidebarOpen = $state(false);
|
||||
|
||||
// Number of meteogram chart panels currently enabled: used to reserve the
|
||||
// exact chart area height before data arrives (no layout shift)
|
||||
let enabledChartCount = $derived.by(() => {
|
||||
const on = (key: string) => $storedVariablePrefs.charts?.[key] ?? true;
|
||||
return (
|
||||
(on('temperature') || on('cloud_cover') ? 1 : 0) +
|
||||
(on('precipitation') || on('precipitation_probability') ? 1 : 0) +
|
||||
(on('wind') || on('humidity') ? 1 : 0)
|
||||
);
|
||||
});
|
||||
|
||||
// the URL is the source of truth: location comes from the load function,
|
||||
// which is also correct on hydrated prerendered pages. The persisted store
|
||||
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||
@@ -47,6 +64,8 @@
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// preselect the persisted model (client-only so prerendered HTML stays stable)
|
||||
params.models = [get(storedModel)];
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
@@ -131,13 +150,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedModel.set(model);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
class="flex h-11 cursor-pointer items-center gap-2 rounded-xl border-2 border-border bg-card px-3.5 text-sm font-semibold text-muted-foreground shadow-sm transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
onclick={() => (variableSidebarOpen = true)}
|
||||
aria-label="Choose visible variables"
|
||||
>
|
||||
<!-- sliders icon -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden md:inline">Variables</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
||||
|
||||
{#if loadError}
|
||||
<div
|
||||
@@ -157,10 +201,18 @@
|
||||
units={params}
|
||||
locationName={location.name ?? ''}
|
||||
/>
|
||||
{:else}
|
||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
||||
<div class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||
{/if}
|
||||
|
||||
{#if fetchedHourly}
|
||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||
{:else}
|
||||
<!-- reserve the exact chart area height before the first fetch resolves -->
|
||||
<section class="mt-8">
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { storedVariablePrefs } from '$lib/stores/settings';
|
||||
|
||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
@@ -25,6 +27,10 @@
|
||||
|
||||
let hourlyInterval = $state<1 | 3>(3);
|
||||
|
||||
// Row visibility, controlled from the Variables sidebar (missing keys
|
||||
// from older stored prefs default to visible)
|
||||
let showRow = $derived((key: string): boolean => $storedVariablePrefs.table?.[key] ?? true);
|
||||
|
||||
const today = new Date();
|
||||
const tempUnit = $derived(getTempUnit(units));
|
||||
const windUnit = $derived(getWindUnit(units));
|
||||
@@ -333,6 +339,7 @@
|
||||
</tr>
|
||||
|
||||
<!-- Weather Icons -->
|
||||
{#if showRow('icons')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-day-cloudy')}
|
||||
{#each cellData as cell, i (cell.idx)}
|
||||
@@ -348,8 +355,10 @@
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Temperature -->
|
||||
{#if showRow('temperature')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
@@ -363,8 +372,10 @@
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Feels Like -->
|
||||
{#if showRow('feels')}
|
||||
<tr class="row">
|
||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
@@ -374,8 +385,10 @@
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Wind -->
|
||||
{#if showRow('wind')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
@@ -396,8 +409,10 @@
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Humidity -->
|
||||
{#if showRow('humidity')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-humidity', '%', 'Humidity')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
@@ -407,8 +422,10 @@
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Cloud Cover -->
|
||||
{#if showRow('clouds')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-cloud', '%', 'Clouds')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
@@ -421,8 +438,10 @@
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Precipitation -->
|
||||
{#if showRow('precipitation')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
@@ -442,6 +461,7 @@
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedVariablePrefs } from '$lib/stores/settings';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
@@ -68,6 +70,12 @@
|
||||
{ label: 'All', apply: () => resetZoom() }
|
||||
];
|
||||
|
||||
// Soft band drawn on every chart marking the currently selected day
|
||||
let selectedDayHighlight = $derived.by(() => {
|
||||
const start = dayStartSec(selectedDay);
|
||||
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
||||
});
|
||||
|
||||
// ─── Series Building ────────────────────────────────────────────────────────
|
||||
|
||||
let tempUnit = $derived(getTempUnit(units));
|
||||
@@ -90,17 +98,14 @@
|
||||
if (!data) return [];
|
||||
|
||||
const { hourly } = data;
|
||||
// Variables the user disabled in the sidebar are left out entirely
|
||||
const on = (key: string): boolean => $storedVariablePrefs.charts?.[key] ?? true;
|
||||
const defs: ChartDef[] = [];
|
||||
|
||||
const tempChart: ChartDef = {
|
||||
title: 'Temperature & Cloud Cover',
|
||||
unit: tempUnit,
|
||||
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
|
||||
// occupying at most the upper 40% of the plot
|
||||
yMinRight: 0,
|
||||
yMaxRight: 250,
|
||||
invertRight: true,
|
||||
series: [
|
||||
{
|
||||
if (on('temperature') || on('cloud_cover')) {
|
||||
const series: ChartSeries[] = [];
|
||||
if (on('cloud_cover')) {
|
||||
series.push({
|
||||
name: 'Cloud Cover',
|
||||
type: 'line',
|
||||
color: 'rgb(150, 150, 150)',
|
||||
@@ -110,8 +115,10 @@
|
||||
fillOpacity: 0.25,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
},
|
||||
{
|
||||
});
|
||||
}
|
||||
if (on('temperature')) {
|
||||
series.push({
|
||||
name: 'Temperature',
|
||||
type: 'line',
|
||||
color: '#ef6c00',
|
||||
@@ -120,26 +127,35 @@
|
||||
fill: true,
|
||||
fillOpacity: 0.2,
|
||||
format: (v) => `${v.toFixed(1)} ${tempUnit}`
|
||||
});
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const precipChart: ChartDef = {
|
||||
title: 'Precipitation & Probability',
|
||||
unit: precipUnit,
|
||||
unitRight: '%',
|
||||
yMin: 0,
|
||||
defs.push({
|
||||
title: [on('temperature') && 'Temperature', on('cloud_cover') && 'Cloud Cover']
|
||||
.filter(Boolean)
|
||||
.join(' & '),
|
||||
unit: tempUnit,
|
||||
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
|
||||
// occupying at most the upper 40% of the plot
|
||||
yMinRight: 0,
|
||||
yMaxRight: 100,
|
||||
series: [
|
||||
{
|
||||
yMaxRight: 250,
|
||||
invertRight: true,
|
||||
series
|
||||
});
|
||||
}
|
||||
|
||||
if (on('precipitation') || on('precipitation_probability')) {
|
||||
const series: ChartSeries[] = [];
|
||||
if (on('precipitation')) {
|
||||
series.push({
|
||||
name: 'Precipitation',
|
||||
type: 'bar',
|
||||
color: 'rgba(30, 136, 229, 0.8)',
|
||||
data: hourly.precipitation,
|
||||
format: (v) => `${v.toFixed(1)} ${precipUnit}`
|
||||
},
|
||||
{
|
||||
});
|
||||
}
|
||||
if (on('precipitation_probability')) {
|
||||
series.push({
|
||||
name: 'Precip. Probability',
|
||||
type: 'line',
|
||||
color: '#5c6bc0',
|
||||
@@ -148,20 +164,28 @@
|
||||
dashed: true,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
});
|
||||
}
|
||||
defs.push({
|
||||
title: [
|
||||
on('precipitation') && 'Precipitation',
|
||||
on('precipitation_probability') && 'Probability'
|
||||
]
|
||||
};
|
||||
|
||||
const windChart: ChartDef = {
|
||||
title: 'Wind Speed & Humidity',
|
||||
unit: windUnit,
|
||||
unitRight: '%',
|
||||
.filter(Boolean)
|
||||
.join(' & '),
|
||||
unit: precipUnit,
|
||||
unitRight: on('precipitation_probability') ? '%' : undefined,
|
||||
yMin: 0,
|
||||
yMinRight: 0,
|
||||
yMaxRight: 100,
|
||||
showCredit: true,
|
||||
series: [
|
||||
{
|
||||
series
|
||||
});
|
||||
}
|
||||
|
||||
if (on('wind') || on('humidity')) {
|
||||
const series: ChartSeries[] = [];
|
||||
if (on('wind')) {
|
||||
series.push({
|
||||
name: 'Wind Speed',
|
||||
type: 'line',
|
||||
color: '#26a69a',
|
||||
@@ -174,8 +198,10 @@
|
||||
const dirLabel = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
|
||||
return `${v.toFixed(0)} ${windUnit}${dirLabel}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
});
|
||||
}
|
||||
if (on('humidity')) {
|
||||
series.push({
|
||||
name: 'Humidity',
|
||||
type: 'line',
|
||||
color: '#8d6e63',
|
||||
@@ -184,11 +210,24 @@
|
||||
dashed: true,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
});
|
||||
}
|
||||
defs.push({
|
||||
title: [on('wind') && 'Wind Speed', on('humidity') && 'Humidity']
|
||||
.filter(Boolean)
|
||||
.join(' & '),
|
||||
unit: windUnit,
|
||||
unitRight: on('humidity') ? '%' : undefined,
|
||||
yMin: 0,
|
||||
yMinRight: 0,
|
||||
yMaxRight: 100,
|
||||
series
|
||||
});
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return [tempChart, precipChart, windChart];
|
||||
if (defs.length > 0) defs[defs.length - 1].showCredit = true;
|
||||
|
||||
return defs;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -230,7 +269,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
||||
{#if chartDefs.length > 0}
|
||||
<ChartContainer {loading} chartCount={chartDefs.length} chartHeight={300}>
|
||||
{#each chartDefs as def, i (def.title)}
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
@@ -238,6 +278,7 @@
|
||||
timezone={data.timezone}
|
||||
series={def.series}
|
||||
bands={data.daylightBands}
|
||||
highlight={selectedDayHighlight}
|
||||
unit={def.unit}
|
||||
unitRight={def.unitRight}
|
||||
yMin={def.yMin}
|
||||
@@ -256,4 +297,11 @@
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
All chart variables are hidden — enable some under “Variables”.
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
import { defaultVariablePrefs, storedVariablePrefs } from '$lib/stores/settings';
|
||||
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
const tableVariables = [
|
||||
{ key: 'icons', label: 'Weather icons' },
|
||||
{ key: 'temperature', label: 'Temperature' },
|
||||
{ key: 'feels', label: 'Feels like' },
|
||||
{ key: 'wind', label: 'Wind' },
|
||||
{ key: 'humidity', label: 'Humidity' },
|
||||
{ key: 'clouds', label: 'Cloud cover' },
|
||||
{ key: 'precipitation', label: 'Precipitation' }
|
||||
];
|
||||
|
||||
const chartVariables = [
|
||||
{ key: 'temperature', label: 'Temperature' },
|
||||
{ key: 'cloud_cover', label: 'Cloud cover' },
|
||||
{ key: 'precipitation', label: 'Precipitation' },
|
||||
{ key: 'precipitation_probability', label: 'Precipitation probability' },
|
||||
{ key: 'wind', label: 'Wind speed' },
|
||||
{ key: 'humidity', label: 'Humidity' }
|
||||
];
|
||||
|
||||
function toggle(section: 'table' | 'charts', key: string) {
|
||||
storedVariablePrefs.update((prefs) => {
|
||||
const current = { ...defaultVariablePrefs[section], ...prefs[section] };
|
||||
return { ...prefs, [section]: { ...current, [key]: !(current[key] ?? true) } };
|
||||
});
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
storedVariablePrefs.set(structuredClone(defaultVariablePrefs));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && open) onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50">
|
||||
<div
|
||||
class="absolute inset-0 bg-black/30"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onclick={onClose}
|
||||
onkeydown={onClose}
|
||||
role="presentation"
|
||||
></div>
|
||||
<aside
|
||||
class="absolute inset-y-0 right-0 flex w-80 max-w-[90vw] flex-col overflow-y-auto border-l border-border bg-card shadow-xl"
|
||||
transition:fly={{ x: 320, duration: 200, opacity: 1 }}
|
||||
aria-label="Variable selection"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 flex items-center justify-between border-b border-border bg-card px-5 py-4"
|
||||
>
|
||||
<h2 class="text-base font-bold">Variables</h2>
|
||||
<button
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={onClose}
|
||||
aria-label="Close variable selection"
|
||||
>
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 px-5 py-4">
|
||||
<section>
|
||||
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
|
||||
Hourly table
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tableVariables as variable (variable.key)}
|
||||
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
|
||||
<Checkbox
|
||||
id="table_var_{variable.key}"
|
||||
class="cursor-pointer"
|
||||
checked={$storedVariablePrefs.table?.[variable.key] ?? true}
|
||||
onCheckedChange={() => toggle('table', variable.key)}
|
||||
/>
|
||||
<Label class="flex-1 cursor-pointer text-sm" for="table_var_{variable.key}">
|
||||
{variable.label}
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
|
||||
Meteogram charts
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each chartVariables as variable (variable.key)}
|
||||
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
|
||||
<Checkbox
|
||||
id="chart_var_{variable.key}"
|
||||
class="cursor-pointer"
|
||||
checked={$storedVariablePrefs.charts?.[variable.key] ?? true}
|
||||
onCheckedChange={() => toggle('charts', variable.key)}
|
||||
/>
|
||||
<Label class="flex-1 cursor-pointer text-sm" for="chart_var_{variable.key}">
|
||||
{variable.label}
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border px-5 py-3">
|
||||
<button
|
||||
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
onclick={resetDefaults}
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
+13
-1
@@ -9,9 +9,21 @@ const config = {
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
// fallback: the SPA shell served for routes that were not prerendered
|
||||
// (unlisted cities, GPS coordinate routes); the universal load then
|
||||
// resolves the location client-side. Most static hosts serve 404.html
|
||||
// for unknown paths automatically.
|
||||
adapter: adapter({ fallback: '404.html' }),
|
||||
// Pregenerate city pages to improve SEO during static build
|
||||
prerender: {
|
||||
// dynamic per-location routes (14-day, compare, unlisted cities) are
|
||||
// served by the SPA fallback instead of being prerendered
|
||||
handleUnseenRoutes: 'ignore',
|
||||
// a transient geocoding failure for one city should skip that page
|
||||
// (the fallback still serves it), not abort the whole build
|
||||
handleHttpError: ({ path, message }) => {
|
||||
console.warn(`prerender skipped ${path}: ${message}`);
|
||||
},
|
||||
entries: (() => {
|
||||
try {
|
||||
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
|
||||
|
||||
Reference in New Issue
Block a user