gemini going wild on this

This commit is contained in:
terraputix
2026-02-15 14:51:15 +01:00
parent 298c8ef597
commit ba9834a3eb
28 changed files with 556 additions and 477 deletions
+2 -4
View File
@@ -1,5 +1,3 @@
{ {
"userWords": [ "userWords": ["ConfigInterface"]
"ConfigInterface" }
]
}
@@ -240,8 +240,8 @@
{location.country || ''} {location.country || ''}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E {location.latitude?.toFixed(2)}°N {location.longitude?.toFixed(2)}°E
{#if location.elevation}{location.elevation.toFixed(0)}m{/if} {#if location.elevation}{location.elevation?.toFixed(0)}m{/if}
</p> </p>
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils/index.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-action" data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils/index.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -10,6 +10,6 @@
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script> </script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}> <div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}>
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils/index.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<p <p
bind:this={ref} bind:this={ref}
data-slot="card-description" data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)} class={cn('text-sm text-muted-foreground', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils/index.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-footer" data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)} class={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils/index.js';
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="card-header" data-slot="card-header"
class={cn( class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className className
)} )}
{...restProps} {...restProps}
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils/index.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -13,7 +13,7 @@
<div <div
bind:this={ref} bind:this={ref}
data-slot="card-title" data-slot="card-title"
class={cn("leading-none font-semibold", className)} class={cn('leading-none font-semibold', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from "$lib/utils.js"; import { cn, type WithElementRef } from '$lib/utils/index.js';
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="card" data-slot="card"
class={cn( class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", 'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
className className
)} )}
{...restProps} {...restProps}
+8 -8
View File
@@ -1,10 +1,10 @@
import Root from "./card.svelte"; import Root from './card.svelte';
import Content from "./card-content.svelte"; import Content from './card-content.svelte';
import Description from "./card-description.svelte"; import Description from './card-description.svelte';
import Footer from "./card-footer.svelte"; import Footer from './card-footer.svelte';
import Header from "./card-header.svelte"; import Header from './card-header.svelte';
import Title from "./card-title.svelte"; import Title from './card-title.svelte';
import Action from "./card-action.svelte"; import Action from './card-action.svelte';
export { export {
Root, Root,
@@ -21,5 +21,5 @@ export {
Footer as CardFooter, Footer as CardFooter,
Header as CardHeader, Header as CardHeader,
Title as CardTitle, Title as CardTitle,
Action as CardAction, Action as CardAction
}; };
+18 -18
View File
@@ -1,24 +1,24 @@
import { persisted } from 'svelte-persisted-store'; import { persisted } from 'svelte-persisted-store';
export interface GeoLocation { export interface GeoLocation {
id: number; id?: number;
name: string; name?: string;
latitude: number; latitude?: number;
longitude: number; longitude?: number;
elevation: number; elevation?: number;
feature_code: string; feature_code?: string;
country_code: string | undefined; country_code?: string;
admin1_id: number | undefined; admin1_id?: number;
admin3_id?: number | undefined; admin3_id?: number;
admin4_id?: number | undefined; admin4_id?: number;
timezone: string; timezone?: string;
population: number | undefined; population?: number;
postcodes: string[] | undefined; postcodes?: string[];
country_id: number | undefined; country_id?: number;
country: string | undefined; country?: string;
admin1: string | undefined; admin1?: string;
admin3?: string | undefined; admin3?: string;
admin4?: string | undefined; admin4?: string;
} }
export const defaultLocation: GeoLocation = { export const defaultLocation: GeoLocation = {
+5 -5
View File
@@ -6,7 +6,7 @@ import { page } from '$app/state';
import { debounce, isNumeric } from '$lib/utils'; import { debounce, isNumeric } from '$lib/utils';
import type { Parameters } from '$lib/docs'; import type { Parameters } from '$lib/types';
export type UrlHashStore = Writable<Parameters>; export type UrlHashStore = Writable<Parameters>;
@@ -44,20 +44,20 @@ export const urlHashStore = (initialValues: Parameters): UrlHashStore => {
changedParams = true; changedParams = true;
} }
} else { } else {
let array = value; let array = value as any[];
// remove empty string when array has more then 1 values // remove empty string when array has more then 1 values
if (array.length > 1 && array.includes('')) { if (array.length > 1 && array.includes('')) {
array = value.filter((e: string) => e !== ''); array = array.filter((e: string) => e !== '');
} }
page.url.searchParams.set(key, array.join(',')); page.url.searchParams.set(key, array.join(','));
changedParams = true; changedParams = true;
} }
} else { } else {
let val: number | string = value; let val: number | string = value as number | string;
if (isNumeric(defaultValue)) { if (isNumeric(defaultValue)) {
defaultValue = Number(defaultValue); defaultValue = Number(defaultValue);
} }
if (isNumeric(value)) { if (isNumeric(value as number | string)) {
val = Number(value); val = Number(value);
} }
+30
View File
@@ -0,0 +1,30 @@
export interface Parameters {
latitude?: number | number[];
longitude?: number | number[];
hourly?: string[];
models?: string[];
daily?: string[];
current?: string[];
minutely_15?: string[];
timezone?: string;
location_mode?: string;
csv_coordinates?: string;
time_mode?: string;
past_days?: string;
forecast_days?: string;
end_date?: string;
start_date?: string;
past_hours?: string;
cell_selection?: string;
forecast_hours?: string;
past_minutely_15?: string;
temporal_resolution?: string;
forecast_minutely_15?: string;
tilt?: string;
azimuth?: string;
timeformat?: string;
wind_speed_unit?: string;
temperature_unit?: string;
precipitation_unit?: string;
[key: string]: any;
}
+3
View File
@@ -1,3 +1,6 @@
export * from './ui.js';
export * from './meteo.js';
export const isNumeric = (num: string | number) => export const isNumeric = (num: string | number) =>
(typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) && (typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) &&
!isNaN(num as number); !isNaN(num as number);
+1
View File
@@ -0,0 +1 @@
export const prerender = true;
+4 -2
View File
@@ -13,7 +13,7 @@
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let mounted = $state(false); let mounted = $state(false);
let location = get(storedLocation); let location = $derived($storedLocation);
onMount(() => { onMount(() => {
mounted = true; mounted = true;
@@ -180,7 +180,9 @@
Current Location: {location.name} Current Location: {location.name}
</CardTitle> </CardTitle>
<CardDescription class="text-center"> <CardDescription class="text-center">
{location.country}{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}° {location.country}{location.latitude?.toFixed(2)}°, {location.longitude?.toFixed(
2
)}°
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent class="text-center"> <CardContent class="text-center">
+2 -2
View File
@@ -83,8 +83,8 @@
{/if}{location.country} {/if}{location.country}
</p> </p>
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
{location.latitude.toFixed(2)}°N, {location.longitude.toFixed(2)}°E {location.latitude?.toFixed(2)}°N, {location.longitude?.toFixed(2)}°E
{#if location.elevation}{location.elevation.toFixed(0)}m{/if} {#if location.elevation}{location.elevation?.toFixed(0)}m{/if}
</p> </p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from '././$types';
const location = get(storedLocation); const location = get(storedLocation);
+3 -3
View File
@@ -1,8 +1,8 @@
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;
export const load = (async () => { export const load: PageLoad = async () => {
throw redirect(303, '/weather/week/'); throw redirect(303, '/weather/week/');
}) satisfies PageLoad; };
+1 -1
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from '././$types';
const location = get(storedLocation); const location = get(storedLocation);
+201 -186
View File
@@ -15,8 +15,9 @@
import { defaultParameters } from './options'; import { defaultParameters } from './options';
let node: HTMLElement; let node: HTMLElement;
let chart: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any
let Highcharts = $state(null); let charts: any[] = [];
let Highcharts = $state<any>(null);
let showLegend = $state(false); let showLegend = $state(false);
let averageOnly = $state(false); let averageOnly = $state(false);
@@ -36,237 +37,251 @@
/// 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(Highcharts); (more as any)(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);
const Debugger = (await import('highcharts/es-modules/Extensions/Debugger/Debugger.js')) // @ts-ignore
.default; const Debugger = (
(await import('highcharts/es-modules/Extensions/Debugger/Debugger.js')) as any
).default;
// @ts-ignore
const ErrorMessages = ( const ErrorMessages = (
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js') (await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js')) as any
).default; ).default;
Highcharts.errorMessages = ErrorMessages; Highcharts.errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart); Debugger.compose(Highcharts.Chart);
} }
}); });
$effect(async () => { $effect(() => {
count = 0; count = 0;
if (Highcharts) { if (Highcharts) {
node.replaceChildren([]); charts.forEach((c) => c.destroy());
charts = [];
// eslint-disable-next-line svelte/no-dom-manipulating
(node as any).replaceChildren();
const dataDaily = await fetch( (async () => {
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14` 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`
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();
let plotBands: any = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) { let plotBands: any = [];
let rise = wd.daily.sunrise; if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
let set = wd.daily.sunset; let rise = wd.daily.sunrise;
plotBands = rise.map(function (r, i) { let set = wd.daily.sunset;
return { plotBands = rise.map(function (r: any, i: number) {
color: 'rgba(255, 255, 194, 0.5)', return {
from: (r + data.utc_offset_seconds) * 1000, color: 'rgba(255, 255, 194, 0.5)',
to: (set[i] + data.utc_offset_seconds) * 1000 from: (r + data.utc_offset_seconds) * 1000,
}; to: (set[i] + data.utc_offset_seconds) * 1000
}); };
} });
}
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;
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000; let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000; let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
const series = []; const series = [];
let average = new Array(data.hourly.time.length).fill(0); let average = new Array(data.hourly.time.length).fill(0);
let averageCount = new Array(data.hourly.time.length).fill(0); let averageCount = new Array(data.hourly.time.length).fill(0);
for (let [model, values] of Object.entries(data.hourly)) { for (let [model, values] of Object.entries(data.hourly)) {
if (model === 'time') { if (model === 'time') {
continue; continue;
} }
if (model.startsWith(variable)) { if (model.startsWith(variable)) {
for (let [index, val] of values.entries()) { for (let [index, val] of (values as any[]).entries()) {
if (val) { if (val) {
let avVal = average[index]; let avVal = average[index];
average[index] = avVal + val; average[index] = avVal + val;
averageCount[index]++; averageCount[index]++;
if (minValues[index] > val || minValues[index] === undefined) { if (minValues[index] > val || minValues[index] === undefined) {
minValues[index] = val; minValues[index] = val;
} }
if (maxValues[index] < val || maxValues[index] === undefined) { if (maxValues[index] < val || maxValues[index] === undefined) {
maxValues[index] = val; maxValues[index] = val;
}
} }
} }
}
unit = data.hourly_units[model]; unit = data.hourly_units[model];
}
} }
}
for (let [index, val] of average.entries()) { for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10; average[index] = Math.round((val / averageCount[index]) * 10) / 10;
} }
const minMax = []; const minMax = [];
for (let [index, min] of minValues.entries()) { for (let [index, min] of minValues.entries()) {
minMax.push([min, maxValues[index]]); minMax.push([min, maxValues[index]]);
} }
series.push({ series.push({
name: 'temperature_2m_spread', name: 'temperature_2m_spread',
data: minMax, data: minMax,
type: 'arearange', type: 'arearange',
tooltip: { tooltip: {
valueSuffix: ' ' + unit valueSuffix: ' ' + unit
}, },
pointStart: hourly_starttime, pointStart: hourly_starttime,
pointInterval: pointInterval, pointInterval: pointInterval,
className: 'highcharts-spread-series' className: 'highcharts-spread-series'
}); });
series.push({ series.push({
name: variable + '_average', name: variable + '_average',
data: average, data: average,
dashStyle: 'ShortDashDot', dashStyle: 'ShortDashDot',
color: '#5e5e5e', color: '#5e5e5e',
type: type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' ? 'column' : 'spline', unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
tooltip: { ? 'column'
valueSuffix: ' ' + unit : 'spline',
}, tooltip: {
lineWidth: 4, valueSuffix: ' ' + unit
states: { },
hover: { lineWidth: 4,
lineWidth: 6 states: {
} hover: {
}, lineWidth: 6
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-average-series'
});
new Highcharts.Chart(chartDiv, {
credits: {
text: count === $params.hourly.length - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
chart: {
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: '50',
marginRight: 0
},
lang: {
locale: 'en-GB'
},
title: {
text: count === 0 ? 'Model Spread' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${$params.hourly.join(', ')}</span> in models: <span class="font-bold">` +
$params.models.join(', ') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
}
},
xAxis: {
type: 'datetime',
plotLines: [
{
value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
plotOptions: {
spline: {
lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
} }
}, },
column: { pointStart: hourly_starttime,
pointWidth: 5 pointInterval: pointInterval,
} className: 'highcharts-average-series'
}, });
legend: { const chart = new Highcharts.Chart(chartDiv, {
enabled: showLegend, credits: {
layout: 'horizontal', text: count === ($params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
align: 'center', href: 'http://open-meteo.com'
verticalAlign: 'bottom' },
},
series: series, chart: {
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: '50',
marginRight: 0
},
responsive: { lang: {
rules: [ locale: 'en-GB'
{ },
condition: {
maxWidth: 800 title: {
} text: count === 0 ? 'Model Spread' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${$params.hourly?.join(', ')}</span> in models: <span class="font-bold">` +
$params.models?.join(', ') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
} }
] },
},
tooltip: { xAxis: {
shared: true, type: 'datetime',
animation: false plotLines: [
} {
}); value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
count++; plotOptions: {
node.appendChild(chartDiv); spline: {
} lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
}
},
column: {
pointWidth: 5
}
},
legend: {
enabled: showLegend,
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
},
series: series,
responsive: {
rules: [
{
condition: {
maxWidth: 800
}
}
]
},
tooltip: {
shared: true,
animation: false
}
});
count++;
charts.push(chart);
// eslint-disable-next-line svelte/no-dom-manipulating
node.appendChild(chartDiv);
}
})();
} }
}); });
onDestroy(() => { onDestroy(() => {
if (chart) { charts.forEach((c) => c.destroy());
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 class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * $params.hourly.length + 2}px]"> <div
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * ($params.hourly?.length || 0) +
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>
<div <div
class="{count > 0 class="{count > 0
+1 -1
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from '././$types';
const location = get(storedLocation); const location = get(storedLocation);
+208 -193
View File
@@ -17,8 +17,9 @@
import { defaultParameters } from './options'; import { defaultParameters } from './options';
let node: HTMLElement; let node: HTMLElement;
let chart: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any
let Highcharts = $state(); let charts: any[] = [];
let Highcharts = $state<any>();
let showLegend = $state(false); let showLegend = $state(false);
let averageOnly = $state(false); let averageOnly = $state(false);
@@ -47,224 +48,238 @@
if (dev) { if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger'); // const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts); // HighchartsDebugger.default(Highcharts);
const Debugger = (await import('highcharts/es-modules/Extensions/Debugger/Debugger.js')) // @ts-ignore
.default; const Debugger = (
(await import('highcharts/es-modules/Extensions/Debugger/Debugger.js')) as any
).default;
// @ts-ignore
const ErrorMessages = ( const ErrorMessages = (
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js') (await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js')) as any
).default; ).default;
Highcharts.errorMessages = ErrorMessages; Highcharts.errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart); Debugger.compose(Highcharts.Chart);
} }
}); });
$effect(async () => { $effect(() => {
count = 0; count = 0;
if (Highcharts) { if (Highcharts) {
node.replaceChildren([]); charts.forEach((c) => c.destroy());
charts = [];
// eslint-disable-next-line svelte/no-dom-manipulating
(node as any).replaceChildren();
const dataReq = await fetch( (async () => {
`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 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`
const data = await dataReq.json(); );
const data = await dataReq.json();
let dailyFirstModelKey = Object.keys(data.daily)[1].split('_'); let dailyFirstModelKeyParts = Object.keys(data.daily)[1].split('_');
dailyFirstModelKey.shift(); dailyFirstModelKeyParts.shift();
dailyFirstModelKey = dailyFirstModelKey.join('_'); const dailyFirstModelKey = dailyFirstModelKeyParts.join('_');
let plotBands: any = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any
if ( let plotBands: any = [];
'daily' in data && if (
'sunrise_' + dailyFirstModelKey in data.daily && 'daily' in data &&
'sunset_' + dailyFirstModelKey in data.daily 'sunrise_' + dailyFirstModelKey in data.daily &&
) { 'sunset_' + dailyFirstModelKey in data.daily
let rise = data.daily['sunrise_' + dailyFirstModelKey]; ) {
let set = data.daily['sunset_' + dailyFirstModelKey]; let rise = data.daily['sunrise_' + dailyFirstModelKey];
plotBands = rise.map(function (r, i) { let set = data.daily['sunset_' + dailyFirstModelKey];
return { plotBands = rise.map(function (r: any, i: number) {
color: 'rgba(255, 255, 194, 0.5)', return {
from: (r + data.utc_offset_seconds) * 1000, color: 'rgba(255, 255, 194, 0.5)',
to: (set[i] + data.utc_offset_seconds) * 1000 from: (r + data.utc_offset_seconds) * 1000,
}; to: (set[i] + data.utc_offset_seconds) * 1000
}); };
} });
for (let variable of $params.hourly) {
const chartDiv = document.createElement('div');
let unit;
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
const series = [];
let average = new Array(data.hourly.time.length).fill(0);
let averageCount = new Array(data.hourly.time.length).fill(0);
for (let [model, values] of Object.entries(data.hourly)) {
if (model === 'time') {
continue;
}
if (model.startsWith(variable)) {
for (let [index, val] of values.entries()) {
if (val) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
}
}
unit = data.hourly_units[model];
if (!averageOnly) {
series.push({
name: model,
data: values,
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
pointStart: hourly_starttime,
pointInterval: pointInterval
});
}
}
} }
for (let [index, val] of average.entries()) { for (let variable of $params.hourly || []) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10; const chartDiv = document.createElement('div');
}
series.push({ let unit;
name: variable + '_average',
data: average, let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
dashStyle: 'ShortDashDot', let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
color: '#5e5e5e',
type: const series = [];
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' ? 'column' : 'spline', let average = new Array(data.hourly.time.length).fill(0);
tooltip: { let averageCount = new Array(data.hourly.time.length).fill(0);
valueSuffix: ' ' + unit
}, for (let [model, values] of Object.entries(data.hourly)) {
lineWidth: 4, if (model === 'time') {
states: { continue;
hover: {
lineWidth: 6
} }
}, if (model.startsWith(variable)) {
pointStart: hourly_starttime, for (let [index, val] of (values as any[]).entries()) {
pointInterval: pointInterval, if (val) {
className: 'highcharts-average-series' let avVal = average[index];
}); average[index] = avVal + val;
averageCount[index]++;
new Highcharts.Chart(chartDiv, {
credits: {
text: count === $params.hourly.length - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
chart: {
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: '50',
marginRight: 0
},
lang: {
locale: 'en-GB'
},
title: {
text: count === 0 ? 'Model Compare' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${$params.hourly.join(', ')}</span> in models: <span class="font-bold">` +
$params.models.join(', ') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
}
},
xAxis: {
type: 'datetime',
plotLines: [
{
value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
plotOptions: {
spline: {
lineWidth: 2,
states: {
hover: {
lineWidth: 3
} }
}, }
marker: {
enabled: false unit = data.hourly_units[model];
if (!averageOnly) {
series.push({
name: model,
data: values,
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
pointStart: hourly_starttime,
pointInterval: pointInterval
});
}
}
}
for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
}
series.push({
name: variable + '_average',
data: average,
dashStyle: 'ShortDashDot',
color: '#5e5e5e',
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
lineWidth: 4,
states: {
hover: {
lineWidth: 6
} }
}, },
column: { pointStart: hourly_starttime,
pointWidth: 5 pointInterval: pointInterval,
} className: 'highcharts-average-series'
}, });
legend: { const chart = new Highcharts.Chart(chartDiv, {
enabled: showLegend, credits: {
layout: 'horizontal', text: count === ($params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
align: 'center', href: 'http://open-meteo.com'
verticalAlign: 'bottom' },
},
series: series, chart: {
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: '50',
marginRight: 0
},
responsive: { lang: {
rules: [ locale: 'en-GB'
{ },
condition: {
maxWidth: 800 title: {
} text: count === 0 ? 'Model Compare' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${$params.hourly?.join(', ')}</span> in models: <span class="font-bold">` +
$params.models?.join(', ') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
} }
] },
},
tooltip: { xAxis: {
shared: true, type: 'datetime',
animation: false plotLines: [
} {
}); value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
count++; plotOptions: {
node.appendChild(chartDiv); spline: {
} lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
}
},
column: {
pointWidth: 5
}
},
legend: {
enabled: showLegend,
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
},
series: series,
responsive: {
rules: [
{
condition: {
maxWidth: 800
}
}
]
},
tooltip: {
shared: true,
animation: false
}
});
count++;
charts.push(chart);
// eslint-disable-next-line svelte/no-dom-manipulating
node.appendChild(chartDiv);
}
})();
} }
}); });
onDestroy(() => { onDestroy(() => {
if (chart) { charts.forEach((c) => c.destroy());
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 class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * $params.hourly.length + 2}px]"> <div
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * ($params.hourly?.length || 0) +
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>
<div <div
class="{count > 0 class="{count > 0
@@ -319,12 +334,12 @@
<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.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}&nbsp;/&nbsp;{models.flat().length}
</div> </div>
</div> </div>
{/if} {/if}
@@ -343,7 +358,7 @@
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) => { $params.models = $params.models.filter((item: string) => {
return item !== value; return item !== value;
}); });
} else if ($params.models) { } else if ($params.models) {
@@ -371,12 +386,12 @@
Hourly Weather Variables Hourly Weather Variables
</h2></a </h2></a
> >
{#if $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}&nbsp;/&nbsp;{hourly.flat().length}
</div> </div>
</div> </div>
{/if} {/if}
@@ -397,7 +412,7 @@
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) => { $params.hourly = $params.hourly.filter((item: string) => {
return item !== value; return item !== value;
}); });
} else if ($params.hourly) { } else if ($params.hourly) {
+14 -12
View File
@@ -6,18 +6,20 @@ 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: 'jma_seamless', label: 'JMA Seamless' }, { value: 'gfs_seamless', label: 'NCEP GFS Seamless' },
{ value: 'kma_seamless', label: 'KMA Seamless' }, { value: 'jma_seamless', label: 'JMA Seamless' },
{ value: 'icon_seamless', label: 'DWD ICON Seamless' }, { value: 'kma_seamless', label: 'KMA Seamless' },
{ value: 'gem_seamless', label: 'GEM Seamless' }, { value: 'icon_seamless', label: 'DWD ICON Seamless' },
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' }, { value: 'gem_seamless', label: 'GEM Seamless' },
{ value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' }, { value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' }, { value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' },
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' }, { value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' }, { value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
{ value: 'ukmo_seamless', label: 'UK Met Office Seamless' } { value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
{ value: 'ukmo_seamless', label: 'UK Met Office Seamless' }
]
]; ];
export const hourly = [ export const hourly = [
+4 -4
View File
@@ -2,13 +2,13 @@ import { get } from 'svelte/store';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings'; 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;
export const load = (async () => { export const load: PageLoad = async () => {
const location = get(storedLocation); const location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name); const locationRoute = geoLocationNameToRoute(location.name || '');
throw redirect( throw redirect(
303, 303,
'/weather/week/' + '/weather/week/' +
@@ -18,4 +18,4 @@ export const load = (async () => {
: locationRoute + '_' + location.id : locationRoute + '_' + location.id
: locationRoute + '_' + location.id) : locationRoute + '_' + location.id)
); );
}) satisfies PageLoad; };
@@ -21,7 +21,7 @@
import { getColor, textWhite } from '../../utils/colors'; import { getColor, textWhite } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes'; import weatherCodes from '../../utils/weather-codes';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { pad } from '$lib/utils'; import { pad } from '$lib/utils/index.js';
// --- Interfaces --- // --- Interfaces ---
@@ -63,9 +63,9 @@
// --- State Setup --- // --- State Setup ---
const params = urlHashStore({ const params = urlHashStore({
latitude: [$storedLocation.latitude], latitude: [$storedLocation.latitude || 0],
longitude: [$storedLocation.longitude], longitude: [$storedLocation.longitude || 0],
models: 'best_match', models: ['best_match'],
...defaultParameters ...defaultParameters
}); });
@@ -89,7 +89,9 @@
const entries = 6; const entries = 6;
const winddir = true; const winddir = true;
let modelSelected = $derived(models.find((mo) => String(mo.value) === $params.models)); let modelSelected = $derived(
models.flat().find((mo) => $params.models?.includes(String(mo.value)))
);
// --- Logic --- // --- Logic ---
@@ -654,13 +656,20 @@
<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">
<Select.Root name="model_selection" type="single" bind:value={$params.models}> <Select.Root
name="model_selection"
type="single"
value={$params.models?.[0]}
onValueChange={(v) => {
$params.models = [v];
}}
>
<Select.Trigger <Select.Trigger
aria-label="Forecast days input" aria-label="Forecast days input"
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 as mo (mo.value)} {#each models.flat() 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>
+6 -6
View File
@@ -4,11 +4,11 @@ import { type GeoLocation, 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;
export const load = (async (event) => { export const load: PageLoad = async (event) => {
const urlLocation = event.params.location; const urlLocation = event.params.location;
let urlLocationSplit, urlLocationName, urlLocationId; let urlLocationSplit, urlLocationName, urlLocationId;
@@ -31,8 +31,8 @@ export const load = (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 = urlLocationSplit[0]; const latitude = Number(urlLocationSplit[0]);
const longitude = urlLocationSplit[1]; const longitude = Number(urlLocationSplit[1]);
location = { location = {
//id: undefined, //id: undefined,
@@ -58,7 +58,7 @@ export const load = (async (event) => {
} }
} }
const locationRoute = geoLocationNameToRoute(location.name); const locationRoute = geoLocationNameToRoute(location.name || '');
if (location.population && location.population > 543000) { if (location.population && location.population > 543000) {
// 1000 biggest cities // 1000 biggest cities
@@ -74,4 +74,4 @@ export const load = (async (event) => {
storedLocation.set(location); storedLocation.set(location);
return { location: location }; return { location: location };
}) satisfies PageLoad; };
+5 -1
View File
@@ -7,7 +7,11 @@ const config = {
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { adapter: adapter() } kit: {
adapter: adapter({
fallback: '404.html'
})
}
}; };
export default config; export default config;