Merge main into refactor-weather-weekly - resolve type safety conflicts

This commit is contained in:
terraputix
2026-02-15 15:59:44 +01:00
12 changed files with 551 additions and 685 deletions
-127
View File
@@ -1,127 +0,0 @@
import { type Writable, writable } from 'svelte/store';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { debounce, isNumeric } from '$lib/utils';
import type { Parameters } from '$lib/types';
export type UrlHashStore = Writable<Parameters>;
export const urlHashStore = (initialValues: Parameters): UrlHashStore => {
const urlHashes: Writable<Parameters> = writable({});
const defaultValues = JSON.parse(JSON.stringify(initialValues));
urlHashes.set(JSON.parse(JSON.stringify(defaultValues)));
function updateURL() {
const searchParams = page.url.searchParams.toString().replaceAll('%2C', ',');
const link = `?${searchParams}${page.url.hash ?? ''}`;
if (page.url.search !== window.location.search) {
goto(link, {
noScroll: true,
keepFocus: true
});
}
}
const processURLParamsUpdate = debounce(() => updateURL());
const updateURLParams = (values: Parameters) => {
if (browser) {
let changedParams = false;
for (const [key, value] of Object.entries(values)) {
let defaultValue = defaultValues[key];
// params key is array
if (defaultValue && Array === defaultValue.constructor) {
if (JSON.stringify(value) === JSON.stringify(defaultValue)) {
if (page.url.searchParams.has(key) && page.url.searchParams.get(key) !== value) {
page.url.searchParams.delete(key);
changedParams = true;
}
} else {
let array = value as any[];
// remove empty string when array has more then 1 values
if (array.length > 1 && array.includes('')) {
array = array.filter((e: string) => e !== '');
}
page.url.searchParams.set(key, array.join(','));
changedParams = true;
}
} else {
let val: number | string = value as number | string;
if (isNumeric(defaultValue)) {
defaultValue = Number(defaultValue);
}
if (isNumeric(value as number | string)) {
val = Number(value);
}
if (val != defaultValue) {
page.url.searchParams.set(key, String(val));
changedParams = true;
} else {
if (page.url.searchParams.has(key) && page.url.searchParams.get(key) !== val) {
page.url.searchParams.delete(key);
changedParams = true;
}
}
}
if (page.url.searchParams.has(key) && page.url.searchParams.get(key) === '') {
if (
defaultValue === undefined ||
(defaultValue && Array === defaultValue.constructor && defaultValue.length === 0) ||
defaultValue === '0'
) {
page.url.searchParams.delete(key);
changedParams = true;
}
}
}
if (changedParams) {
processURLParamsUpdate();
}
}
};
// check if urlParams overrides any default values OR stored values
if (browser && page.url.search) {
for (const [key, value] of page.url.searchParams.entries()) {
let defaultValue = defaultValues[key];
if (defaultValue && defaultValue.constructor === Array) {
if (JSON.stringify(defaultValue) !== JSON.stringify(value)) {
urlHashes.update((urlValues) => {
urlValues[key] = value.split(/,|%2C/);
return urlValues;
});
}
} else {
let val: number | string = value;
if (isNumeric(defaultValue)) {
defaultValue = Number(defaultValue);
}
if (isNumeric(value)) {
val = Number(value);
}
if (defaultValue !== val) {
urlHashes.update((urlValues) => {
urlValues[key] = val;
return urlValues;
});
}
}
}
}
urlHashes.subscribe((values) => {
updateURLParams(values);
});
return urlHashes;
};
+62
View File
@@ -16,18 +16,66 @@
let { children }: Props = $props(); let { children }: Props = $props();
interface CurrentWeather {
current: {
temperature_2m: number;
weather_code: number;
};
}
let location = $state(get(storedLocation)); let location = $state(get(storedLocation));
let mounted = $state(false); let mounted = $state(false);
let currentWeather = $state<CurrentWeather | null>(null);
// Subscribe to location changes // Subscribe to location changes
storedLocation.subscribe((value) => { storedLocation.subscribe((value) => {
location = value; location = value;
if (mounted) {
loadCurrentWeather();
}
}); });
onMount(() => { onMount(() => {
mounted = true; mounted = true;
loadCurrentWeather();
}); });
const loadCurrentWeather = async () => {
if (!location?.latitude) return;
try {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,weather_code&forecast_days=1`
);
const data = await response.json();
currentWeather = data;
} catch (error) {
console.error('Failed to load current weather:', error);
}
};
const getWeatherIcon = (code: number): string => {
const iconMap: Record<number, string> = {
0: '☀️',
1: '🌤️',
2: '⛅',
3: '☁️',
45: '🌫️',
48: '🌫️',
51: '🌦️',
53: '🌦️',
55: '🌦️',
61: '🌧️',
63: '🌧️',
65: '🌧️',
71: '🌨️',
73: '🌨️',
75: '❄️',
95: '⛈️'
};
return iconMap[code] || '☁️';
};
const getPageTitle = () => { const getPageTitle = () => {
const path = $page.url.pathname; const path = $page.url.pathname;
if (path.includes('/compare')) return 'Model Comparison'; if (path.includes('/compare')) return 'Model Comparison';
@@ -91,6 +139,20 @@
</p> </p>
</div> </div>
</div> </div>
<!-- Current Weather -->
{#if currentWeather}
<div class="flex items-center space-x-3" in:fade={{ delay: 800 }}>
<div class="text-center">
<div class="mb-1 text-3xl">
{getWeatherIcon(currentWeather.current.weather_code)}
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">
{Math.round(currentWeather.current.temperature_2m)}°C
</div>
</div>
</div>
{/if}
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageLoad } from '$types'; import type { PageLoad } from './$types';
export const prerender = true; export const prerender = true;
+33 -36
View File
@@ -6,7 +6,6 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { urlHashStore } from '$lib/stores/url-hash-store';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
@@ -15,16 +14,16 @@
import { defaultParameters } from './options'; import { defaultParameters } from './options';
let node: HTMLElement; let node: HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any let chart: any;
let charts: any[] = []; let Highcharts = $state<typeof import('highcharts') | null>(null);
let Highcharts = $state<any>(null);
let showLegend = $state(false); let showLegend = $state(false);
let averageOnly = $state(false); let averageOnly = $state(false);
const location = get(storedLocation); const location = get(storedLocation);
const params = urlHashStore({ // Local component state for chart configuration
let params = $state({
latitude: [52.52], latitude: [52.52],
longitude: [13.41], longitude: [13.41],
...defaultParameters, ...defaultParameters,
@@ -37,44 +36,40 @@
/// Highcharts needs to be loaded in `onMount` to work with prerendered SSG /// Highcharts needs to be loaded in `onMount` to work with prerendered SSG
Highcharts = (await import('highcharts')).default; Highcharts = (await import('highcharts')).default;
const more = (await import('highcharts/highcharts-more')).default; const more = (await import('highcharts/highcharts-more')).default;
(more as any)(Highcharts); // more(Highcharts);
if (dev) { if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger'); // const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts); // HighchartsDebugger.default(Highcharts);
// @ts-ignore
const Debugger = ( const Debugger = (
(await import('highcharts/es-modules/Extensions/Debugger/Debugger.js')) as any await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any)
).default; ).default;
// @ts-ignore
const ErrorMessages = ( const ErrorMessages = (
(await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js')) as any await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any)
).default; ).default;
Highcharts.errorMessages = ErrorMessages; if (Highcharts) {
(Highcharts as any).errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart); Debugger.compose(Highcharts.Chart);
} }
}
}); });
$effect(() => { $effect(() => {
const loadData = async () => {
count = 0; count = 0;
if (Highcharts) { if (Highcharts) {
charts.forEach((c) => c.destroy()); node.replaceChildren();
charts = [];
// eslint-disable-next-line svelte/no-dom-manipulating
(node as any).replaceChildren();
(async () => {
const dataDaily = await fetch( const dataDaily = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14` `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
); );
const wd = await dataDaily.json(); const wd = await dataDaily.json();
const dataReq = await fetch( const dataReq = await fetch(
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${$params.hourly?.join(',')}&models=${$params.models?.join(',')}&timeformat=unixtime&forecast_days=14` `https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&forecast_days=14`
); );
const data = await dataReq.json(); const data = await dataReq.json();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let plotBands: any = []; let plotBands: any = [];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) { if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
let rise = wd.daily.sunrise; let rise = wd.daily.sunrise;
@@ -91,7 +86,7 @@
let minValues = new Array(data.hourly.time.length).fill(undefined); let minValues = new Array(data.hourly.time.length).fill(undefined);
let maxValues = new Array(data.hourly.time.length).fill(undefined); let maxValues = new Array(data.hourly.time.length).fill(undefined);
for (let variable of $params.hourly || []) { for (let variable of params.hourly || []) {
const chartDiv = document.createElement('div'); const chartDiv = document.createElement('div');
let unit; let unit;
@@ -171,19 +166,20 @@
className: 'highcharts-average-series' className: 'highcharts-average-series'
}); });
const chart = new Highcharts.Chart(chartDiv, { new Highcharts!.Chart({
credits: {
text: count === ($params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
chart: { chart: {
renderTo: chartDiv,
height: showLegend ? '400px' : '300px', height: showLegend ? '400px' : '300px',
styledMode: true, styledMode: true,
marginLeft: '50', marginLeft: 50,
marginRight: 0 marginRight: 0
}, },
credits: {
text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
lang: { lang: {
locale: 'en-GB' locale: 'en-GB'
}, },
@@ -196,8 +192,8 @@
subtitle: { subtitle: {
text: text:
count === 0 count === 0
? `Compare <span class="font-bold">${$params.hourly?.join(', ')}</span> in models: <span class="font-bold">` + ? `Compare <span class="font-bold">${params.hourly?.join(', ') || ''}</span> in models: <span class="font-bold">` +
$params.models?.join(', ') + (params.models?.join(', ') || '') +
'</span>' '</span>'
: '', : '',
align: 'left' align: 'left'
@@ -245,7 +241,7 @@
verticalAlign: 'bottom' verticalAlign: 'bottom'
}, },
series: series, series: series as any,
responsive: { responsive: {
rules: [ rules: [
@@ -264,22 +260,23 @@
}); });
count++; count++;
charts.push(chart);
// eslint-disable-next-line svelte/no-dom-manipulating
node.appendChild(chartDiv); node.appendChild(chartDiv);
} }
})();
} }
};
loadData();
}); });
onDestroy(() => { onDestroy(() => {
charts.forEach((c) => c.destroy()); if (chart) {
chart.destroy();
}
}); });
</script> </script>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] --> <!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div <div
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * ($params.hourly?.length || 0) + class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * (params.hourly?.length || 0) +
2}px]" 2}px]"
> >
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div> <div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
@@ -314,7 +311,7 @@
name="Show legend" name="Show legend"
bind:checked={showLegend} bind:checked={showLegend}
onCheckedChange={() => { onCheckedChange={() => {
$params.hourly = $params.hourly; params.hourly = params.hourly;
}} }}
/> />
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label> <Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
@@ -325,7 +322,7 @@
name="Average only" name="Average only"
bind:checked={averageOnly} bind:checked={averageOnly}
onCheckedChange={() => { onCheckedChange={() => {
$params.hourly = $params.hourly; params.hourly = params.hourly;
}} }}
/> />
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label> <Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
+1 -27
View File
@@ -1,31 +1,5 @@
// Default configuration for 14-day ensemble forecast charts
export const defaultParameters = { export const defaultParameters = {
daily: [],
hourly: [],
models: [],
current: [],
minutely_15: [],
timezone: 'UTC',
location_mode: 'location_search',
csv_coordinates: undefined,
time_mode: 'forecast_days',
past_days: '0',
forecast_days: '14',
end_date: undefined,
start_date: undefined,
past_hours: undefined,
cell_selection: undefined,
forecast_hours: undefined,
past_minutely_15: undefined,
temporal_resolution: undefined,
forecast_minutely_15: undefined,
tilt: '0',
azimuth: '0',
timeformat: 'iso8601', timeformat: 'iso8601',
wind_speed_unit: 'kmh', wind_speed_unit: 'kmh',
temperature_unit: 'celsius', temperature_unit: 'celsius',
+51 -57
View File
@@ -6,7 +6,6 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { urlHashStore } from '$lib/stores/url-hash-store';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
@@ -17,16 +16,15 @@
import { defaultParameters } from './options'; import { defaultParameters } from './options';
let node: HTMLElement; let node: HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any let chart: any;
let charts: any[] = []; let Highcharts = $state<typeof import('highcharts') | null>(null);
let Highcharts = $state<any>();
let showLegend = $state(false); let showLegend = $state(false);
let averageOnly = $state(false); let averageOnly = $state(false);
const location = get(storedLocation); const location = get(storedLocation);
const params = urlHashStore({ let params = $state({
latitude: [52.52], latitude: [52.52],
longitude: [13.41], longitude: [13.41],
...defaultParameters, ...defaultParameters,
@@ -48,38 +46,34 @@
if (dev) { if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger'); // const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts); // HighchartsDebugger.default(Highcharts);
// @ts-ignore
const Debugger = ( const Debugger = (
(await import('highcharts/es-modules/Extensions/Debugger/Debugger.js')) as any await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any)
).default; ).default;
// @ts-ignore
const ErrorMessages = ( const ErrorMessages = (
(await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js')) as any await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any)
).default; ).default;
Highcharts.errorMessages = ErrorMessages; if (Highcharts) {
(Highcharts as any).errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart); Debugger.compose(Highcharts.Chart);
} }
}
}); });
$effect(() => { $effect(() => {
const loadData = async () => {
count = 0; count = 0;
if (Highcharts) { if (Highcharts) {
charts.forEach((c) => c.destroy()); node.replaceChildren();
charts = [];
// eslint-disable-next-line svelte/no-dom-manipulating
(node as any).replaceChildren();
(async () => {
const dataReq = await fetch( const dataReq = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${$params.hourly?.join(',')}&models=${$params.models?.join(',')}&timeformat=unixtime&daily=sunset,sunrise` `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise`
); );
const data = await dataReq.json(); const data = await dataReq.json();
let dailyFirstModelKeyParts = Object.keys(data.daily)[1].split('_'); let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
dailyFirstModelKeyParts.shift(); dailyFirstModelKey.shift();
const dailyFirstModelKey = dailyFirstModelKeyParts.join('_'); dailyFirstModelKey = dailyFirstModelKey.join('_');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let plotBands: any = []; let plotBands: any = [];
if ( if (
'daily' in data && 'daily' in data &&
@@ -97,7 +91,7 @@
}); });
} }
for (let variable of $params.hourly || []) { for (let variable of params.hourly || []) {
const chartDiv = document.createElement('div'); const chartDiv = document.createElement('div');
let unit; let unit;
@@ -169,19 +163,20 @@
className: 'highcharts-average-series' className: 'highcharts-average-series'
}); });
const chart = new Highcharts.Chart(chartDiv, { new Highcharts!.Chart({
credits: {
text: count === ($params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
chart: { chart: {
renderTo: chartDiv,
height: showLegend ? '400px' : '300px', height: showLegend ? '400px' : '300px',
styledMode: true, styledMode: true,
marginLeft: '50', marginLeft: 50,
marginRight: 0 marginRight: 0
}, },
credits: {
text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
lang: { lang: {
locale: 'en-GB' locale: 'en-GB'
}, },
@@ -194,8 +189,8 @@
subtitle: { subtitle: {
text: text:
count === 0 count === 0
? `Compare <span class="font-bold">${$params.hourly?.join(', ')}</span> in models: <span class="font-bold">` + ? `Compare <span class="font-bold">${params.hourly?.join(', ') || ''}</span> in models: <span class="font-bold">` +
$params.models?.join(', ') + (params.models?.join(', ') || '') +
'</span>' '</span>'
: '', : '',
align: 'left' align: 'left'
@@ -243,7 +238,7 @@
verticalAlign: 'bottom' verticalAlign: 'bottom'
}, },
series: series, series: series as any,
responsive: { responsive: {
rules: [ rules: [
@@ -262,22 +257,23 @@
}); });
count++; count++;
charts.push(chart);
// eslint-disable-next-line svelte/no-dom-manipulating
node.appendChild(chartDiv); node.appendChild(chartDiv);
} }
})();
} }
};
loadData();
}); });
onDestroy(() => { onDestroy(() => {
charts.forEach((c) => c.destroy()); if (chart) {
chart.destroy();
}
}); });
</script> </script>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] --> <!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div <div
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * ($params.hourly?.length || 0) + class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * (params.hourly?.length || 0) +
2}px]" 2}px]"
> >
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div> <div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
@@ -312,7 +308,7 @@
name="Show legend" name="Show legend"
bind:checked={showLegend} bind:checked={showLegend}
onCheckedChange={() => { onCheckedChange={() => {
$params.hourly = $params.hourly; params.hourly = params.hourly;
}} }}
/> />
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label> <Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
@@ -323,7 +319,7 @@
name="Average only" name="Average only"
bind:checked={averageOnly} bind:checked={averageOnly}
onCheckedChange={() => { onCheckedChange={() => {
$params.hourly = $params.hourly; params.hourly = params.hourly;
}} }}
/> />
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label> <Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
@@ -334,36 +330,35 @@
<div class="mt-4 md:mt-8"> <div class="mt-4 md:mt-8">
<div class="flex"> <div class="flex">
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a> <a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
{#if $params.models && $params.models.length > 0} {#if params.models && params.models.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]"> <div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
<div <div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline" class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
> >
{$params.models?.length}&nbsp;/&nbsp;{models.flat().length} {params.models?.length || 0}&nbsp;/&nbsp;{models.length}
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
<div class="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"> <div class="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{#each models as group, i (i)}
<div class="mb-3"> <div class="mb-3">
{#each group as { value, label } (value)} {#each models as { value, label } (value)}
<div class="group flex items-center" title={label}> <div class="group flex items-center" title={label}>
<Checkbox <Checkbox
id="{value}_model" id="{value}_model"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]" class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value} {value}
checked={$params.models?.includes(value)} checked={params.models?.includes(value)}
aria-labelledby="{value}_label" aria-labelledby="{value}_label"
onCheckedChange={() => { onCheckedChange={() => {
if ($params.models?.includes(value)) { if (params.models?.includes(value)) {
$params.models = $params.models.filter((item: string) => { params.models = params.models.filter((item) => {
return item !== value; return item !== value;
}); });
} else if ($params.models) { } else if (params.models) {
$params.models.push(value); params.models.push(value);
$params.models = $params.models; params.models = params.models;
} }
}} }}
/> />
@@ -375,7 +370,6 @@
</div> </div>
{/each} {/each}
</div> </div>
{/each}
</div> </div>
<!-- HOURLY --> <!-- HOURLY -->
@@ -386,12 +380,12 @@
Hourly Weather Variables Hourly Weather Variables
</h2></a </h2></a
> >
{#if $params.hourly && $params.hourly.length > 0} {#if params.hourly && params.hourly.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]"> <div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
<div <div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline" class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
> >
{$params.hourly?.length}&nbsp;/&nbsp;{hourly.flat().length} {params.hourly?.length || 0}&nbsp;/&nbsp;{hourly.flat().length}
</div> </div>
</div> </div>
{/if} {/if}
@@ -408,16 +402,16 @@
id="{value}_hourly" id="{value}_hourly"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]" class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value} {value}
checked={$params.hourly?.includes(value)} checked={params.hourly?.includes(value)}
aria-labelledby="{value}_label" aria-labelledby="{value}_label"
onCheckedChange={() => { onCheckedChange={() => {
if ($params.hourly?.includes(value)) { if (params.hourly?.includes(value)) {
$params.hourly = $params.hourly.filter((item: string) => { params.hourly = params.hourly.filter((item) => {
return item !== value; return item !== value;
}); });
} else if ($params.hourly) { } else if (params.hourly) {
$params.hourly.push(value); params.hourly.push(value);
$params.hourly = $params.hourly; params.hourly = params.hourly;
} }
}} }}
/> />
+1 -27
View File
@@ -1,31 +1,5 @@
// Default configuration for weather comparison charts
export const defaultParameters = { export const defaultParameters = {
daily: [],
hourly: [],
models: [],
current: [],
minutely_15: [],
timezone: 'UTC',
location_mode: 'location_search',
csv_coordinates: undefined,
time_mode: 'forecast_days',
past_days: '0',
forecast_days: '7',
end_date: undefined,
start_date: undefined,
past_hours: undefined,
cell_selection: undefined,
forecast_hours: undefined,
past_minutely_15: undefined,
temporal_resolution: undefined,
forecast_minutely_15: undefined,
tilt: '0',
azimuth: '0',
timeformat: 'iso8601', timeformat: 'iso8601',
wind_speed_unit: 'kmh', wind_speed_unit: 'kmh',
temperature_unit: 'celsius', temperature_unit: 'celsius',
+1 -3
View File
@@ -6,8 +6,7 @@ export const defaultParameters = {
}; };
export const models = [ export const models = [
[{ value: 'best_match', label: 'Best match' }], { value: 'best_match', label: 'Best match' },
[
{ value: 'gfs_seamless', label: 'NCEP GFS Seamless' }, { value: 'gfs_seamless', label: 'NCEP GFS Seamless' },
{ value: 'jma_seamless', label: 'JMA Seamless' }, { value: 'jma_seamless', label: 'JMA Seamless' },
{ value: 'kma_seamless', label: 'KMA Seamless' }, { value: 'kma_seamless', label: 'KMA Seamless' },
@@ -19,7 +18,6 @@ export const models = [
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' }, { value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' }, { value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
{ value: 'ukmo_seamless', label: 'UK Met Office Seamless' } { value: 'ukmo_seamless', label: 'UK Met Office Seamless' }
]
]; ];
export const hourly = [ export const hourly = [
+3 -2
View File
@@ -1,4 +1,4 @@
const map: Record<number, string> = { const weatherCodes: Record<number, string> = {
0: 'clear', 0: 'clear',
1: 'clear', 1: 'clear',
2: 'cloudy', 2: 'cloudy',
@@ -79,4 +79,5 @@ const map: Record<number, string> = {
96: 'thunderstorm', 96: 'thunderstorm',
99: 'tornado' 99: 'tornado'
}; };
export default map;
export default weatherCodes;
+1 -1
View File
@@ -6,7 +6,7 @@ import { storedLocation } from '$lib/stores/settings';
import { geoLocationNameToRoute } from '$lib/utils/meteo'; import { geoLocationNameToRoute } from '$lib/utils/meteo';
import type { PageLoad } from '$types'; import type { PageLoad } from './$types';
export const prerender = true; export const prerender = true;
+256 -277
View File
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { fetchWeatherApi } from 'openmeteo'; import { fetchWeatherApi } from 'openmeteo';
import { storedLocation } from '$lib/stores/settings'; import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { urlHashStore } from '$lib/stores/url-hash-store';
import { pad } from '$lib/utils/index'; import { pad } from '$lib/utils/index';
@@ -19,221 +18,212 @@
import raster from '../../canvas/raster'; import raster from '../../canvas/raster';
import tempGradient from '../../canvas/temp-gradient'; import tempGradient from '../../canvas/temp-gradient';
import { defaultParameters, models } from '../../options'; import { defaultParameters, models } from '../../options';
import { getColor, textWhite } from '../../utils/colors'; import { getColor } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes'; import weatherCodes from '../../utils/weather-codes';
import type { ConfigInterface } from '../../config'; import type { ConfigInterface } from '../../config';
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
import type { WeatherApiResponse } from '@openmeteo/sdk/weather-api-response';
// --- Interfaces --- let params = $state({
latitude: [$storedLocation.latitude],
interface WeatherEntry { longitude: [$storedLocation.longitude],
id: number;
name: string;
title: string;
values: string[] | undefined;
}
interface WeatherHourly {
entries: WeatherEntry[];
entriesLength: number;
hourlyTime: Date[];
windDirections: Float32Array | undefined;
indexes: number[];
raw: {
hourlyTemps: Float32Array | undefined;
hourlyCloudCover: Float32Array | undefined;
hourlyPrecip: Float32Array | undefined;
minTemp: number;
};
}
interface WeatherDaily {
time: Date[];
weather_code: VariableWithValues;
temperature_2m_max: VariableWithValues;
temperature_2m_min: VariableWithValues;
sunrise: VariableWithValues;
sunset: VariableWithValues;
sunshine_duration: VariableWithValues;
precipitation_sum: VariableWithValues;
windspeed_10m_max: VariableWithValues;
windgusts_10m_max: VariableWithValues;
winddirection_10m_dominant: VariableWithValues;
}
// --- State Setup ---
const params = urlHashStore({
latitude: [$storedLocation.latitude || 0],
longitude: [$storedLocation.longitude || 0],
models: ['best_match'], models: ['best_match'],
...defaultParameters ...defaultParameters
}); });
let location = $derived($storedLocation); let location = $state($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
// UI State let diffTemp: number | undefined = $state();
let maxTemp: number | undefined = $state();
let weatherCodesHourly: Float32Array | null | undefined = $state();
let canvasElement: HTMLCanvasElement | null | undefined = $state();
const today = new Date();
let selectedDay = $state(new Date()); let selectedDay = $state(new Date());
let selectedDayIndex = $state(1); let selectedDayIndex = $state(1);
let scrollDiv: HTMLElement | undefined = $state();
let canvasElement: HTMLCanvasElement | undefined = $state();
// Data State let entries = $state(0);
let weather: WeatherHourly | null = $state(null);
let weatherDaily: WeatherDaily | null = $state(null);
let diffTemp: number = $state(0);
let maxTemp: number = $state(0);
let weatherCodesHourly: Float32Array | undefined = $state();
// Constants let weather = $derived(
const today = new Date(); (async (location: GeoLocation) => {
const entries = 6; const reqParams = {
const winddir = true;
let modelSelected = $derived(
models.flat().find((mo) => $params.models?.includes(String(mo.value)))
);
// --- Logic ---
async function loadWeatherData() {
if (!location) return;
const commonParams = {
latitude: location.latitude, latitude: location.latitude,
longitude: location.longitude, longitude: location.longitude,
elevation: location.elevation, elevation: location.elevation,
models: [$params.models], // timezone: location.timezone, ???
models: [params.models],
hourly: [
'precipitation',
'precipitation_probability',
'temperature_2m',
'weather_code',
'windspeed_10m',
'winddirection_10m',
'cloud_cover',
'relative_humidity_2m'
].join(','),
forecast_days: 6, forecast_days: 6,
past_days: 1, past_days: 1,
temperature_unit: $params.temperature_unit, temperature_unit: params.temperature_unit,
wind_speed_unit: $params.wind_speed_unit, wind_speed_unit: params.wind_speed_unit,
precipitation_unit: $params.precipitation_unit precipitation_unit: params.precipitation_unit
}; };
const url = 'https://api.open-meteo.com/v1/forecast'; const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
try { const response = responses[0];
const [hourlyRes, dailyRes] = await Promise.all([
fetchWeatherApi(url, {
...commonParams,
hourly:
'precipitation,precipitation_probability,temperature_2m,weather_code,windspeed_10m,winddirection_10m,cloud_cover,relative_humidity_2m'
}),
fetchWeatherApi(url, {
...commonParams,
daily:
'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,sunshine_duration,precipitation_sum,windspeed_10m_max,windgusts_10m_max,winddirection_10m_dominant'
})
]);
processHourly(hourlyRes[0]);
processDaily(dailyRes[0]);
} catch (e) {
console.error('Weather fetch error:', e);
}
}
function processHourly(response: WeatherApiResponse) {
const utcOffsetSeconds = response.utcOffsetSeconds(); const utcOffsetSeconds = response.utcOffsetSeconds();
const hourly = response.hourly()!; const hourly = response.hourly()!;
weatherCodesHourly = hourly.variables(3)?.valuesArray() ?? undefined; weatherCodesHourly = hourly.variables(3)?.valuesArray();
const hourlyTime = [ let hourlyTime = [
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval()) ...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
].map( ].map(
(_, i) => new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000) (_, i) =>
new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
); );
const hourlyTemps = hourly.variables(2)?.valuesArray();
// Note: The SDK returns Float32Array | null. We use ?? undefined for safer Svelte prop passing const hourlyCloudCover = hourly.variables(6)?.valuesArray();
const hourlyTemps = hourly.variables(2)?.valuesArray() ?? undefined; const hourlyPrecip = hourly.variables(0)?.valuesArray();
const hourlyCloudCover = hourly.variables(6)?.valuesArray() ?? undefined; const indexes = [];
const hourlyPrecip = hourly.variables(0)?.valuesArray() ?? undefined;
const hourlyPrecipProb = hourly.variables(1)?.valuesArray() ?? undefined;
const hourlyWindSpeed = hourly.variables(4)?.valuesArray() ?? undefined;
const hourlyHumidity = hourly.variables(7)?.valuesArray() ?? undefined;
const hourlyWindDir = hourly.variables(5)?.valuesArray() ?? undefined;
const indexes: number[] = [];
if (hourlyTemps) { if (hourlyTemps) {
for (let i = 0; i < hourlyTemps.length; i++) { for (const index of hourlyTemps.keys()) {
indexes.push(i); indexes.push(index);
} }
} }
// Calculate Min/Max const maxX = 10000;
// Convert Float32Array to regular array for Math operations, filter NaNs const maxY = 500;
const tempArray = hourlyTemps ? Array.from(hourlyTemps) : []; const deltaX = 10000 / (hourly.variables(0)?.valuesArray()?.length || 1);
const validTemps = tempArray.filter((t) => !isNaN(t));
const minT = validTemps.length ? Math.min(...validTemps) : 0; const ctx = canvasElement?.getContext('2d');
const maxT = validTemps.length ? Math.max(...validTemps) : 0; if (ctx) {
ctx.clearRect(0, 0, maxX, maxY);
maxTemp = maxT; const minTemp = Math.min(
diffTemp = maxT - minT; ...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t))
);
maxTemp = Math.max(...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t)));
diffTemp = maxTemp - minTemp;
// Map helper const config: ConfigInterface = {
const formatValues = (arr: Float32Array | undefined, digits: number): string[] => { maxX: maxX,
if (!arr) return []; maxY: maxY,
return Array.from(arr).map((v) => v.toFixed(digits)); deltaX: deltaX,
minTemp: minTemp,
maxTemp: maxTemp,
diffTemp: diffTemp,
styles: {
mutedForeground: '240 3.7% 15.9%',
primary: '222.2 47.4% 11.2%',
border: '214.3 31.8% 91.4%'
}
}; };
weather = { // create canvas
daylight(ctx, config, hourlyTime);
raster(ctx, config, hourlyTime, today, canvasElement!);
tempGradient(ctx, config, hourlyTemps, params.temperature_unit);
cloudCover(ctx, config, hourlyCloudCover);
precip(ctx, config, hourlyPrecip);
}
return {
entries: [ entries: [
{ {
id: 0, id: 0,
name: 'temperature_2m', name: 'temperature_2m',
title: 'Temperature', title: 'Temperature',
values: formatValues(hourlyTemps, 1) values: hourly
.variables(2)
?.valuesArray()
?.map((t) => Number(t.toFixed(1)))
}, },
{ {
id: 1, id: 1,
name: 'precipitation', name: 'precipitation',
title: 'Precipitation', title: 'Precipitation',
values: formatValues(hourlyPrecip, 1) values: hourly
.variables(0)
?.valuesArray()
?.map((p) => Number(p.toFixed(1)))
}, },
{ {
id: 2, id: 2,
name: 'precipitation_probability', name: 'precipitation_probability',
title: 'Precip Prob.', title: 'Precip Prob.',
values: formatValues(hourlyPrecipProb, 0) values: hourly
.variables(1)
?.valuesArray()
?.map((p) => Number(p.toFixed(0)))
}, },
{ {
id: 3, id: 3,
name: 'windspeed_10m', name: 'windspeed_10m',
title: 'Wind', title: 'Wind',
values: formatValues(hourlyWindSpeed, 0) values: hourly
.variables(4)
?.valuesArray()
?.map((p) => Number(p.toFixed(0)))
}, },
{ {
id: 4, id: 4,
name: 'relative_humidity_2m', name: 'relative_humidity_2m',
title: 'Rel. Hum.', title: 'Rel. Hum.',
values: formatValues(hourlyHumidity, 0) values: hourly
.variables(7)
?.valuesArray()
?.map((p) => Number(p.toFixed(0)))
} }
], ],
entriesLength: hourly.variables(0)?.valuesArray()?.length ?? 0, entriesLength: hourly.variables(0)?.valuesArray()?.length,
hourlyTime: hourlyTime, hourlyTime: hourlyTime,
windDirections: hourlyWindDir, windDirections: hourly.variables(5)?.valuesArray(),
indexes: indexes, indexes: indexes
raw: {
hourlyTemps,
hourlyCloudCover,
hourlyPrecip,
minTemp: minT
}
}; };
} })(location)
);
function processDaily(response: WeatherApiResponse) { let weatherDaily = $derived(
(async (location: GeoLocation) => {
const reqParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
// timezone: location.timezone, ???
models: [params.models],
daily: [
'weather_code',
'temperature_2m_max',
'temperature_2m_min',
'sunrise',
'sunset',
'sunshine_duration',
'precipitation_sum',
'windspeed_10m_max',
'windgusts_10m_max',
'winddirection_10m_dominant'
].join(','),
forecast_days: 6,
past_days: 1,
temperature_unit: params.temperature_unit,
wind_speed_unit: params.wind_speed_unit,
precipitation_unit: params.precipitation_unit
};
const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds(); const utcOffsetSeconds = response.utcOffsetSeconds();
const daily = response.daily()!; const daily = response.daily()!;
weatherDaily = { return {
daily: {
time: [...Array((Number(daily.timeEnd()) - Number(daily.time())) / daily.interval())].map( time: [...Array((Number(daily.timeEnd()) - Number(daily.time())) / daily.interval())].map(
(_, i) => new Date((Number(daily.time()) + i * daily.interval() + utcOffsetSeconds) * 1000) (_, i) =>
new Date((Number(daily.time()) + i * daily.interval() + utcOffsetSeconds) * 1000)
), ),
weather_code: daily.variables(0)!, weather_code: daily.variables(0)!,
temperature_2m_max: daily.variables(1)!, temperature_2m_max: daily.variables(1)!,
@@ -245,108 +235,68 @@
windspeed_10m_max: daily.variables(7)!, windspeed_10m_max: daily.variables(7)!,
windgusts_10m_max: daily.variables(8)!, windgusts_10m_max: daily.variables(8)!,
winddirection_10m_dominant: daily.variables(9)! winddirection_10m_dominant: daily.variables(9)!
};
} }
// --- Effects ---
$effect(() => {
if (location && $params) {
loadWeatherData();
}
});
$effect(() => {
if (!weather || !canvasElement) return;
const ctx = canvasElement.getContext('2d');
if (ctx) {
const maxX = 10000;
const maxY = 500;
const deltaX = maxX / (weather.entriesLength || 1); // prevent division by zero
const computedStyle = getComputedStyle(canvasElement);
const styleConfig = {
mutedForeground: computedStyle.getPropertyValue('--muted-foreground').split(' ').join(','),
primary: computedStyle.getPropertyValue('--primary').split(' ').join(','),
border: computedStyle.getPropertyValue('--border').split(' ').join(',')
}; };
})(location)
);
ctx.clearRect(0, 0, maxX, maxY); let winddir = true;
entries = 6;
const config: ConfigInterface = { let scrollDiv: HTMLElement | undefined = $state();
maxX, let tableCells;
maxY,
deltaX,
minTemp: weather.raw.minTemp,
maxTemp: maxTemp,
diffTemp: diffTemp || 1, // prevent division by zero in canvas
styles: styleConfig
};
daylight(ctx, config, weather.hourlyTime); const switchDay = (date: Date, index: number) => {
raster(ctx, config, weather.hourlyTime, today, canvasElement);
tempGradient(ctx, config, weather.raw.hourlyTemps, $params.temperature_unit);
cloudCover(ctx, config, weather.raw.hourlyCloudCover);
precip(ctx, config, weather.raw.hourlyPrecip);
}
});
const switchDay = async (date: Date, index?: number) => {
selectedDay = date; selectedDay = date;
if (index !== undefined) selectedDayIndex = index;
await tick(); tableCells = document.querySelectorAll('td.time');
if (!scrollDiv) return; for (let tableCell of tableCells) {
const tableCells = document.querySelectorAll('td.time'); const htmlCell = tableCell as HTMLElement;
for (const tableCell of tableCells) { if (Number(htmlCell.dataset['date']) === selectedDay.getDate()) {
if (Number((tableCell as HTMLElement).dataset['date']) === selectedDay.getDate()) { scrollDiv?.scrollTo({ left: htmlCell.offsetLeft - 110, behavior: 'smooth' });
scrollDiv.scrollTo({
left: (tableCell as HTMLElement).offsetLeft - 110,
behavior: 'smooth'
});
break; break;
} }
} }
selectedDayIndex = index;
}; };
onMount(() => { onMount(() => {
setTimeout(() => { setTimeout(() => {
if (!scrollDiv) return; tableCells = document.querySelectorAll('td.time');
const tableCells = document.querySelectorAll('td.time'); for (let tableCell of tableCells) {
for (const tableCell of tableCells) { const htmlCell = tableCell as HTMLElement;
if (Number((tableCell as HTMLElement).dataset['date']) === selectedDay.getDate()) { if (Number(htmlCell.dataset['date']) === selectedDay.getDate()) {
scrollDiv.scrollTo({ left: (tableCell as HTMLElement).offsetLeft - 110 }); scrollDiv?.scrollTo({ left: htmlCell.offsetLeft - 110 });
break; break;
} }
} }
}, 150); }, 150);
document.onkeydown = (e) => { document.onkeydown = (e) => {
if ( if (scrollDiv !== document.activeElement && !scrollDiv?.contains(document.activeElement)) {
!scrollDiv ||
scrollDiv === document.activeElement ||
scrollDiv.contains(document.activeElement)
)
return;
if (e.key === 'ArrowLeft') { if (e.key === 'ArrowLeft') {
if (selectedDay.getDate() >= today.getDate()) { if (selectedDay.getDate() >= today.getDate()) {
let newDate = new SvelteDate(selectedDay); let newDate = new Date();
newDate.setDate(selectedDay.getDate() - 1); newDate.setDate(selectedDay.getDate() - 1);
switchDay(newDate); switchDay(newDate, selectedDayIndex - 1);
} }
} }
if (e.key === 'ArrowRight') { if (e.key === 'ArrowRight') {
if (selectedDay.getDate() <= today.getDate() + 4) { if (selectedDay.getDate() <= today.getDate() + 4) {
let newDate = new SvelteDate(selectedDay); let newDate = new Date();
newDate.setDate(selectedDay.getDate() + 1); newDate.setDate(selectedDay.getDate() + 1);
switchDay(newDate); switchDay(newDate, selectedDayIndex + 1);
}
} }
} }
}; };
}); });
let modelSelected = $derived(models.find((mo) => String(mo.value) === params.models?.[0]));
// let modelSelectedValue = $derived(params.models[0]);
//
</script> </script>
<svelte:head> <svelte:head>
@@ -363,10 +313,12 @@
style="min-height: 256px" style="min-height: 256px"
class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row" class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row"
> >
{#if weatherDaily} {#await weatherDaily then wd}
{#each weatherDaily.time as time, index (index)} {#each wd.daily.time as time, index (index)}
{@const selected = time.getDate() === selectedDay.getDate()} {@const selected = time.getDate() === selectedDay.getDate()}
{#if !isNaN(weatherDaily.temperature_2m_max.values(index)!)} {#if wd.daily.temperature_2m_max.values(index) != null && !isNaN(Number(wd.daily.temperature_2m_max
.values(index)!
.toFixed(1)))}
<button <button
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}" style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
class="cursor-pointer" class="cursor-pointer"
@@ -396,24 +348,24 @@
<svg class="fill-foreground" width="60px" height="60px"> <svg class="fill-foreground" width="60px" height="60px">
<use <use
xlink:href="/images/weather-icons/wi-day-{weatherCodes[ xlink:href="/images/weather-icons/wi-day-{weatherCodes[
weatherDaily.weather_code.values(index)! (wd.daily.weather_code.values(index) ?? 0) as number
]}.svg#Layer_1" ]}.svg#Layer_1"
></use> ></use>
</svg> </svg>
</div> </div>
<div <div
class="weather-temp-max flex min-w-16.25 justify-center rounded-t p-1 text-sm" class="weather-temp-max flex min-w-[65px] justify-center rounded-t p-1 text-sm"
style={`background-color: ${getColor(weatherDaily.temperature_2m_max.values(index)!, $params.temperature_unit)}; color: ${weatherDaily.temperature_2m_min.values(index)! < ($params.temperature_unit === 'celsius' ? 4 : 7) || weatherDaily.temperature_2m_min.values(index)! >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`} style={`background-color: ${getColor(Math.round(wd.daily.temperature_2m_max.values(index) ?? 0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
> >
{weatherDaily.temperature_2m_max.values(index)!.toFixed(1)} {wd.daily.temperature_2m_max.values(index)?.toFixed(1)}
{$params.temperature_unit === 'celsius' ? '°C' : '°F'} {params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div> </div>
<div <div
class="weather-temp-min flex min-w-16.25 justify-center rounded-b p-1 text-sm" class="weather-temp-min flex min-w-[65px] justify-center rounded-b p-1 text-sm"
style={`background: ${getColor(weatherDaily.temperature_2m_min.values(index)!, $params.temperature_unit)}; color: ${weatherDaily.temperature_2m_min.values(index)! < ($params.temperature_unit === 'celsius' ? 4 : 7) || weatherDaily.temperature_2m_min.values(index)! >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`} style={`background: ${getColor(Math.round(wd.daily.temperature_2m_min.values(index) ?? 0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
> >
{weatherDaily.temperature_2m_min.values(index)!.toFixed(1)} {wd.daily.temperature_2m_min.values(index)?.toFixed(1)}
{$params.temperature_unit === 'celsius' ? '°C' : '°F'} {params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div> </div>
<div class="mt-2 flex items-center justify-center gap-1"> <div class="mt-2 flex items-center justify-center gap-1">
<div class="relative flex h-6 w-6 items-center justify-center"> <div class="relative flex h-6 w-6 items-center justify-center">
@@ -427,7 +379,7 @@
</div> </div>
</div> </div>
{Number(weatherDaily.sunshine_duration.values(index)! / 3600).toFixed(0)}h {Number((wd.daily.sunshine_duration.values(index) ?? 0) / 3600).toFixed(0)}h
</div> </div>
<div class="mt-1 flex items-center justify-center"> <div class="mt-1 flex items-center justify-center">
<div class="relative flex h-6 w-6 items-center justify-center"> <div class="relative flex h-6 w-6 items-center justify-center">
@@ -440,17 +392,17 @@
</svg> </svg>
</div> </div>
</div> </div>
{Number(weatherDaily.precipitation_sum.values(index)).toFixed( {Number(wd.daily.precipitation_sum.values(index)).toFixed(
1 1
)}{$params.precipitation_unit === 'mm' ? 'mm' : "'"} )}{params.precipitation_unit === 'mm' ? 'mm' : "'"}
</div> </div>
</div> </div>
</button> </button>
{/if} {/if}
{/each} {/each}
{:else} {:catch error}
<p>Loading...</p> <p style="color: red">{error.message}</p>
{/if} {/await}
</div> </div>
<div class="ml-22 md:ml-0"> <div class="ml-22 md:ml-0">
<h3 class="text-xl font-bold"> <h3 class="text-xl font-bold">
@@ -469,7 +421,7 @@
<div <div
bind:this={scrollDiv} bind:this={scrollDiv}
style=" height: {218 + entries * 27.5}px; " style=" height: {218 + entries * 27.5}px; "
class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-27.5" class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-[110px]"
> >
<canvas <canvas
bind:this={canvasElement} bind:this={canvasElement}
@@ -482,7 +434,7 @@
<table in:fade class="absolute bottom-0 border-b border-border"> <table in:fade class="absolute bottom-0 border-b border-border">
<caption style="display:none"> Weather Week {location.name} </caption> <caption style="display:none"> Weather Week {location.name} </caption>
<tbody> <tbody>
{#if weather && weather.entries[0] && weather.entries[0].values} {#await weather then weather}
<tr> <tr>
<th <th
scope="row" scope="row"
@@ -500,8 +452,9 @@
data-time={weather.hourlyTime[index].getHours() + ':00'} data-time={weather.hourlyTime[index].getHours() + ':00'}
style="font-size: 11px; position: absolute; bottom: {188 + style="font-size: 11px; position: absolute; bottom: {188 +
27 * entries}px; left:{111 + 27 * entries}px; left:{111 +
(5000 / weather.entriesLength) * index}px; min-width: {5000 / (5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;" (weather.entriesLength || 1)}px; max-width: {5000 /
(weather.entriesLength || 1)}px;"
>{weather.hourlyTime[index].getHours() < 10 ? '0' : ''}{weather.hourlyTime[ >{weather.hourlyTime[index].getHours() < 10 ? '0' : ''}{weather.hourlyTime[
index index
].getHours()}</td ].getHours()}</td
@@ -519,22 +472,23 @@
{@const now = {@const now =
weather.hourlyTime[index].getDate() === today.getDate() && weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()} weather.hourlyTime[index].getHours() === today.getHours()}
<!-- Safe access to values -->
{@const tempVal = weather.entries[0].values?.[index] ?? '0'}
<td <td
style="position: absolute; bottom: {27.5 * entries - style="position: absolute; bottom: {27.5 * entries -
24 + 24 +
0.8 * 200 - 0.8 * 200 -
0.54 * 200 * ((maxTemp - Number(tempVal)) / diffTemp)}px; left:{116 + 0.54 *
(5000 / weather.entriesLength) * index}px; min-width: {5000 / 200 *
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;" ((maxTemp! - weather.entries[0].values![index]) / diffTemp!)}px; left:{116 +
(5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
(weather.entriesLength || 1)}px; max-width: {5000 /
(weather.entriesLength || 1)}px;"
><svg class="fill-foreground {now ? 'scale-125' : ''}" width="20px" height="20px"> ><svg class="fill-foreground {now ? 'scale-125' : ''}" width="20px" height="20px">
<use <use
class="stroke-2" class="stroke-2"
xlink:href="/images/weather-icons/wi-{weather.hourlyTime[index].getHours() > xlink:href="/images/weather-icons/wi-{weather.hourlyTime[index].getHours() >
6 && weather.hourlyTime[index].getHours() < 21 6 && weather.hourlyTime[index].getHours() < 21
? 'day' ? 'day'
: 'night'}-{weatherCodes[weatherCodesHourly?.[index] ?? 0]}.svg#Layer_1" : 'night'}-{weatherCodes[weatherCodesHourly![index] as number]}.svg#Layer_1"
></use> ></use>
</svg></td </svg></td
> >
@@ -549,9 +503,9 @@
>Temp graph</th >Temp graph</th
> >
{#each weather.indexes as index, j (j)} {#each weather.indexes as index, j (j)}
{@const temp = Number(weather.entries[0].values?.[index])} {@const temp = weather.entries?.[0]?.values?.[index]}
{#if !isNaN(temp)} {#if temp !== undefined && !isNaN(temp)}
<td <td
class={weather.hourlyTime[index].getDate() === today.getDate() && class={weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours() weather.hourlyTime[index].getHours() === today.getHours()
@@ -560,10 +514,10 @@
style="position: absolute; bottom: {27.5 * entries - style="position: absolute; bottom: {27.5 * entries -
49 + 49 +
0.8 * 200 - 0.8 * 200 -
0.55 * 200 * ((maxTemp - temp) / diffTemp)}px; left:{111 + 0.55 * 200 * ((maxTemp! - temp!) / diffTemp!)}px; left:{111 +
(5000 / weather.entriesLength) * index}px; min-width: {5000 / (5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;" (weather.entriesLength || 1)}px; max-width: {5000 /
>{temp.toFixed(0)}</td (weather.entriesLength || 1)}px;">{temp?.toFixed(0)}</td
> >
{/if} {/if}
{/each} {/each}
@@ -578,29 +532,49 @@
> >
{#each weather.indexes as index, j (j)} {#each weather.indexes as index, j (j)}
{@const val = entry.values?.[index]} {#if entry.values && !isNaN(entry.values[index])}
{@const valNum = Number(val)}
{@const bgColor = getColor(valNum, $params.temperature_unit)}
{#if val !== undefined && !isNaN(valNum)}
<!-- Note: We use values?.[index] access here. -->
<td <td
class="border-r border-border {weather.hourlyTime[index].getDate() === class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours() today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
? 'now' ? 'now'
: ''}" : ''}"
style="min-width: {5000 / weather.entriesLength}px; max-width: {5000 / style="min-width: {5000 / (weather.entriesLength || 1)}px; max-width: {5000 /
weather.entriesLength}px; (weather.entriesLength || 1)}px;
{entry.name === 'temperature_2m' ? 'background: ' + bgColor : ''}; {entry.name === 'temperature_2m'
{entry.name === 'temperature_2m' ? ('color: ' + textWhite(bgColor) ? 'white' : 'black') : ''}; ? 'background: ' +
{entry.name === 'precipitation_probability' getColor(
? 'background: rgba(0, 0, 230,' + valNum / 120 + ')' Math.round(weather.entries[0].values![index]),
params.temperature_unit
)
: ''};
{entry.name === 'temperature_2m'
? 'color: ' +
(weather.entries[0].values![index] <
(params.temperature_unit === 'celsius' ? -13 : 7) ||
weather.entries[0].values![index] >=
(params.temperature_unit === 'celsius' ? 40 : 104)
? 'white'
: 'black')
: ''}; : ''};
{entry.name === 'precipitation_probability' {entry.name === 'precipitation_probability'
? 'color: ' + (valNum > 50 ? 'white' : 'hsl(var(--foreground)') ? 'background: rgba(0, 0, 230,' +
weather.entries[2].values![index] / 120 +
')'
: ''};
{entry.name === 'precipitation_probability'
? 'color: ' +
(weather.entries[2].values![index] > 50
? 'white'
: 'hsl(var(--foreground)')
: ''}; : ''};
{entry.name === 'relative_humidity_2m' {entry.name === 'relative_humidity_2m'
? 'background: rgba(0, 240, 240,' + valNum ** 3.8 / 10 ** 8.2 + ')' ? 'background: rgba(0, 240, 240,' +
: ''};">{val}</td weather.entries[4].values![index] ** 3.8 / 10 ** 8.2 +
')'
: ''};"
>{entry.name === 'precipitation' || entry.name === 'temperature_2m'
? entry.values![index].toFixed(1)
: entry.values![index]}</td
> >
{/if} {/if}
{/each} {/each}
@@ -622,10 +596,10 @@
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours() today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
? 'now' ? 'now'
: ''}" : ''}"
style="transform: rotate({weather.windDirections[ style="transform: rotate({weather.windDirections![
index index
]}deg);min-width: {5000 / weather.entriesLength}px; max-width: {5000 / ]}deg);min-width: {5000 / (weather.entriesLength || 1)}px; max-width: {5000 /
weather.entriesLength}px;" (weather.entriesLength || 1)}px;"
><svg class="fill-foreground" width="25px" height="25px"> ><svg class="fill-foreground" width="25px" height="25px">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg></td </svg></td
@@ -634,14 +608,14 @@
{/each} {/each}
</tr> </tr>
{/if} {/if}
{/if} {/await}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{#if weatherDaily} {#await weatherDaily then wd}
{@const sunrise = new Date(Number(weatherDaily.sunrise.valuesInt64(selectedDayIndex)) * 1000)} {@const sunrise = new Date(Number(wd.daily.sunrise.valuesInt64(selectedDayIndex)) * 1000)}
{@const sunset = new Date(Number(weatherDaily.sunset.valuesInt64(selectedDayIndex)) * 1000)} {@const sunset = new Date(Number(wd.daily.sunset.valuesInt64(selectedDayIndex)) * 1000)}
<div class="mt-6"> <div class="mt-6">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<svg class="fill-foreground" width="28px" height="28px"> <svg class="fill-foreground" width="28px" height="28px">
@@ -655,16 +629,20 @@
Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())} Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())}
</div> </div>
</div> </div>
{/if} {/await}
<div> <div>
<div class="mt-6 flex gap-6 md:mt-12"> <div class="mt-6 flex gap-6 md:mt-12">
<div class="relative w-1/2"> <div class="relative w-1/2">
{#if params.models && params.models.length > 0}
{@const modelValue = params.models[0]}
<Select.Root <Select.Root
name="model_selection" name="model_selection"
type="single" type="single"
value={$params.models?.[0]} value={modelValue}
onValueChange={(v) => { onValueChange={(val) => {
$params.models = [v]; if (params.models && val) {
params.models = [val];
}
}} }}
> >
<Select.Trigger <Select.Trigger
@@ -672,7 +650,7 @@
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
> >
<Select.Content preventScroll={false} class="border-border"> <Select.Content preventScroll={false} class="border-border">
{#each models.flat() as mo (mo.value)} {#each models as mo (mo.value)}
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item> <Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
{/each} {/each}
</Select.Content> </Select.Content>
@@ -680,6 +658,7 @@
>Weather model</Label >Weather model</Label
> >
</Select.Root> </Select.Root>
{/if}
</div> </div>
</div> </div>
</div> </div>
+18 -4
View File
@@ -31,14 +31,28 @@ export const load: PageLoad = async (event) => {
// lat, long coordinates // lat, long coordinates
if (urlLocation.includes('N') && urlLocation.includes('E')) { if (urlLocation.includes('N') && urlLocation.includes('E')) {
urlLocationSplit = urlLocation.split(/N|E/); urlLocationSplit = urlLocation.split(/N|E/);
const latitude = Number(urlLocationSplit[0]); const latitude = parseFloat(urlLocationSplit[0]);
const longitude = Number(urlLocationSplit[1]); const longitude = parseFloat(urlLocationSplit[1]);
location = { location = {
//id: undefined, id: 0,
name: `${latitude}${longitude}`, name: `${latitude}${longitude}`,
latitude: latitude, latitude: latitude,
longitude: longitude longitude: longitude,
elevation: 0,
feature_code: 'COORD',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: 'UTC',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
admin1: undefined,
admin3: undefined,
admin4: undefined
}; };
} else { } else {
if (urlLocationId) { if (urlLocationId) {