Merge main into refactor-weather-weekly - resolve type safety conflicts
This commit is contained in:
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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}¤t=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,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
Debugger.compose(Highcharts.Chart);
|
(Highcharts as any).errorMessages = ErrorMessages;
|
||||||
|
Debugger.compose(Highcharts.Chart);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
count = 0;
|
const loadData = async () => {
|
||||||
if (Highcharts) {
|
count = 0;
|
||||||
charts.forEach((c) => c.destroy());
|
if (Highcharts) {
|
||||||
charts = [];
|
node.replaceChildren();
|
||||||
// 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,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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
Debugger.compose(Highcharts.Chart);
|
(Highcharts as any).errorMessages = ErrorMessages;
|
||||||
|
Debugger.compose(Highcharts.Chart);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
count = 0;
|
const loadData = async () => {
|
||||||
if (Highcharts) {
|
count = 0;
|
||||||
charts.forEach((c) => c.destroy());
|
if (Highcharts) {
|
||||||
charts = [];
|
node.replaceChildren();
|
||||||
// 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,48 +330,46 @@
|
|||||||
<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} / {models.flat().length}
|
{params.models?.length || 0} / {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 models as { value, label } (value)}
|
||||||
{#each group 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) => {
|
||||||
$params.models = $params.models.filter((item: string) => {
|
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;
|
}
|
||||||
}
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Label
|
||||||
<Label
|
id="{value}_model_label"
|
||||||
id="{value}_model_label"
|
for="{value}_model"
|
||||||
for="{value}_model"
|
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
|
||||||
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
|
>
|
||||||
>
|
</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} / {hourly.flat().length}
|
{params.hourly?.length || 0} / {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,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',
|
||||||
|
|||||||
@@ -6,20 +6,18 @@ 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' },
|
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
|
||||||
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
|
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
||||||
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
|
||||||
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
|
{ value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' },
|
||||||
{ value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' },
|
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
|
||||||
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
|
{ 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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,334 +18,285 @@
|
|||||||
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;
|
latitude: location.latitude,
|
||||||
|
longitude: location.longitude,
|
||||||
|
elevation: location.elevation,
|
||||||
|
// 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,
|
||||||
|
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 hourly = response.hourly()!;
|
||||||
|
|
||||||
let modelSelected = $derived(
|
weatherCodesHourly = hourly.variables(3)?.valuesArray();
|
||||||
models.flat().find((mo) => $params.models?.includes(String(mo.value)))
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Logic ---
|
let hourlyTime = [
|
||||||
|
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
|
||||||
async function loadWeatherData() {
|
].map(
|
||||||
if (!location) return;
|
(_, i) =>
|
||||||
|
new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
|
||||||
const commonParams = {
|
);
|
||||||
latitude: location.latitude,
|
const hourlyTemps = hourly.variables(2)?.valuesArray();
|
||||||
longitude: location.longitude,
|
const hourlyCloudCover = hourly.variables(6)?.valuesArray();
|
||||||
elevation: location.elevation,
|
const hourlyPrecip = hourly.variables(0)?.valuesArray();
|
||||||
models: [$params.models],
|
const indexes = [];
|
||||||
forecast_days: 6,
|
if (hourlyTemps) {
|
||||||
past_days: 1,
|
for (const index of hourlyTemps.keys()) {
|
||||||
temperature_unit: $params.temperature_unit,
|
indexes.push(index);
|
||||||
wind_speed_unit: $params.wind_speed_unit,
|
|
||||||
precipitation_unit: $params.precipitation_unit
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = 'https://api.open-meteo.com/v1/forecast';
|
|
||||||
|
|
||||||
try {
|
|
||||||
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 hourly = response.hourly()!;
|
|
||||||
|
|
||||||
weatherCodesHourly = hourly.variables(3)?.valuesArray() ?? undefined;
|
|
||||||
|
|
||||||
const hourlyTime = [
|
|
||||||
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
|
|
||||||
].map(
|
|
||||||
(_, i) => new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Note: The SDK returns Float32Array | null. We use ?? undefined for safer Svelte prop passing
|
|
||||||
const hourlyTemps = hourly.variables(2)?.valuesArray() ?? undefined;
|
|
||||||
const hourlyCloudCover = hourly.variables(6)?.valuesArray() ?? undefined;
|
|
||||||
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) {
|
|
||||||
for (let i = 0; i < hourlyTemps.length; i++) {
|
|
||||||
indexes.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate Min/Max
|
|
||||||
// Convert Float32Array to regular array for Math operations, filter NaNs
|
|
||||||
const tempArray = hourlyTemps ? Array.from(hourlyTemps) : [];
|
|
||||||
const validTemps = tempArray.filter((t) => !isNaN(t));
|
|
||||||
|
|
||||||
const minT = validTemps.length ? Math.min(...validTemps) : 0;
|
|
||||||
const maxT = validTemps.length ? Math.max(...validTemps) : 0;
|
|
||||||
|
|
||||||
maxTemp = maxT;
|
|
||||||
diffTemp = maxT - minT;
|
|
||||||
|
|
||||||
// Map helper
|
|
||||||
const formatValues = (arr: Float32Array | undefined, digits: number): string[] => {
|
|
||||||
if (!arr) return [];
|
|
||||||
return Array.from(arr).map((v) => v.toFixed(digits));
|
|
||||||
};
|
|
||||||
|
|
||||||
weather = {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
name: 'temperature_2m',
|
|
||||||
title: 'Temperature',
|
|
||||||
values: formatValues(hourlyTemps, 1)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'precipitation',
|
|
||||||
title: 'Precipitation',
|
|
||||||
values: formatValues(hourlyPrecip, 1)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'precipitation_probability',
|
|
||||||
title: 'Precip Prob.',
|
|
||||||
values: formatValues(hourlyPrecipProb, 0)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'windspeed_10m',
|
|
||||||
title: 'Wind',
|
|
||||||
values: formatValues(hourlyWindSpeed, 0)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'relative_humidity_2m',
|
|
||||||
title: 'Rel. Hum.',
|
|
||||||
values: formatValues(hourlyHumidity, 0)
|
|
||||||
}
|
}
|
||||||
],
|
|
||||||
entriesLength: hourly.variables(0)?.valuesArray()?.length ?? 0,
|
|
||||||
hourlyTime: hourlyTime,
|
|
||||||
windDirections: hourlyWindDir,
|
|
||||||
indexes: indexes,
|
|
||||||
raw: {
|
|
||||||
hourlyTemps,
|
|
||||||
hourlyCloudCover,
|
|
||||||
hourlyPrecip,
|
|
||||||
minTemp: minT
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processDaily(response: WeatherApiResponse) {
|
|
||||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
||||||
const daily = response.daily()!;
|
|
||||||
|
|
||||||
weatherDaily = {
|
|
||||||
time: [...Array((Number(daily.timeEnd()) - Number(daily.time())) / daily.interval())].map(
|
|
||||||
(_, i) => new Date((Number(daily.time()) + i * daily.interval() + utcOffsetSeconds) * 1000)
|
|
||||||
),
|
|
||||||
weather_code: daily.variables(0)!,
|
|
||||||
temperature_2m_max: daily.variables(1)!,
|
|
||||||
temperature_2m_min: daily.variables(2)!,
|
|
||||||
sunrise: daily.variables(3)!,
|
|
||||||
sunset: daily.variables(4)!,
|
|
||||||
sunshine_duration: daily.variables(5)!,
|
|
||||||
precipitation_sum: daily.variables(6)!,
|
|
||||||
windspeed_10m_max: daily.variables(7)!,
|
|
||||||
windgusts_10m_max: daily.variables(8)!,
|
|
||||||
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 maxX = 10000;
|
||||||
const maxY = 500;
|
const maxY = 500;
|
||||||
const deltaX = maxX / (weather.entriesLength || 1); // prevent division by zero
|
const deltaX = 10000 / (hourly.variables(0)?.valuesArray()?.length || 1);
|
||||||
|
|
||||||
const computedStyle = getComputedStyle(canvasElement);
|
const ctx = canvasElement?.getContext('2d');
|
||||||
const styleConfig = {
|
if (ctx) {
|
||||||
mutedForeground: computedStyle.getPropertyValue('--muted-foreground').split(' ').join(','),
|
ctx.clearRect(0, 0, maxX, maxY);
|
||||||
primary: computedStyle.getPropertyValue('--primary').split(' ').join(','),
|
|
||||||
border: computedStyle.getPropertyValue('--border').split(' ').join(',')
|
const minTemp = Math.min(
|
||||||
|
...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t))
|
||||||
|
);
|
||||||
|
maxTemp = Math.max(...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t)));
|
||||||
|
diffTemp = maxTemp - minTemp;
|
||||||
|
|
||||||
|
const config: ConfigInterface = {
|
||||||
|
maxX: maxX,
|
||||||
|
maxY: maxY,
|
||||||
|
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%'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
name: 'temperature_2m',
|
||||||
|
title: 'Temperature',
|
||||||
|
values: hourly
|
||||||
|
.variables(2)
|
||||||
|
?.valuesArray()
|
||||||
|
?.map((t) => Number(t.toFixed(1)))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'precipitation',
|
||||||
|
title: 'Precipitation',
|
||||||
|
values: hourly
|
||||||
|
.variables(0)
|
||||||
|
?.valuesArray()
|
||||||
|
?.map((p) => Number(p.toFixed(1)))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'precipitation_probability',
|
||||||
|
title: 'Precip Prob.',
|
||||||
|
values: hourly
|
||||||
|
.variables(1)
|
||||||
|
?.valuesArray()
|
||||||
|
?.map((p) => Number(p.toFixed(0)))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'windspeed_10m',
|
||||||
|
title: 'Wind',
|
||||||
|
values: hourly
|
||||||
|
.variables(4)
|
||||||
|
?.valuesArray()
|
||||||
|
?.map((p) => Number(p.toFixed(0)))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'relative_humidity_2m',
|
||||||
|
title: 'Rel. Hum.',
|
||||||
|
values: hourly
|
||||||
|
.variables(7)
|
||||||
|
?.valuesArray()
|
||||||
|
?.map((p) => Number(p.toFixed(0)))
|
||||||
|
}
|
||||||
|
],
|
||||||
|
entriesLength: hourly.variables(0)?.valuesArray()?.length,
|
||||||
|
hourlyTime: hourlyTime,
|
||||||
|
windDirections: hourly.variables(5)?.valuesArray(),
|
||||||
|
indexes: indexes
|
||||||
};
|
};
|
||||||
|
})(location)
|
||||||
|
);
|
||||||
|
|
||||||
ctx.clearRect(0, 0, maxX, maxY);
|
let weatherDaily = $derived(
|
||||||
|
(async (location: GeoLocation) => {
|
||||||
const config: ConfigInterface = {
|
const reqParams = {
|
||||||
maxX,
|
latitude: location.latitude,
|
||||||
maxY,
|
longitude: location.longitude,
|
||||||
deltaX,
|
elevation: location.elevation,
|
||||||
minTemp: weather.raw.minTemp,
|
// timezone: location.timezone, ???
|
||||||
maxTemp: maxTemp,
|
models: [params.models],
|
||||||
diffTemp: diffTemp || 1, // prevent division by zero in canvas
|
daily: [
|
||||||
styles: styleConfig
|
'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 daily = response.daily()!;
|
||||||
|
|
||||||
daylight(ctx, config, weather.hourlyTime);
|
return {
|
||||||
raster(ctx, config, weather.hourlyTime, today, canvasElement);
|
daily: {
|
||||||
tempGradient(ctx, config, weather.raw.hourlyTemps, $params.temperature_unit);
|
time: [...Array((Number(daily.timeEnd()) - Number(daily.time())) / daily.interval())].map(
|
||||||
cloudCover(ctx, config, weather.raw.hourlyCloudCover);
|
(_, i) =>
|
||||||
precip(ctx, config, weather.raw.hourlyPrecip);
|
new Date((Number(daily.time()) + i * daily.interval() + utcOffsetSeconds) * 1000)
|
||||||
}
|
),
|
||||||
});
|
weather_code: daily.variables(0)!,
|
||||||
|
temperature_2m_max: daily.variables(1)!,
|
||||||
|
temperature_2m_min: daily.variables(2)!,
|
||||||
|
sunrise: daily.variables(3)!,
|
||||||
|
sunset: daily.variables(4)!,
|
||||||
|
sunshine_duration: daily.variables(5)!,
|
||||||
|
precipitation_sum: daily.variables(6)!,
|
||||||
|
windspeed_10m_max: daily.variables(7)!,
|
||||||
|
windgusts_10m_max: daily.variables(8)!,
|
||||||
|
winddirection_10m_dominant: daily.variables(9)!
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})(location)
|
||||||
|
);
|
||||||
|
|
||||||
const switchDay = async (date: Date, index?: number) => {
|
let winddir = true;
|
||||||
|
entries = 6;
|
||||||
|
|
||||||
|
let scrollDiv: HTMLElement | undefined = $state();
|
||||||
|
let tableCells;
|
||||||
|
|
||||||
|
const switchDay = (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 ||
|
if (e.key === 'ArrowLeft') {
|
||||||
scrollDiv === document.activeElement ||
|
if (selectedDay.getDate() >= today.getDate()) {
|
||||||
scrollDiv.contains(document.activeElement)
|
let newDate = new Date();
|
||||||
)
|
newDate.setDate(selectedDay.getDate() - 1);
|
||||||
return;
|
switchDay(newDate, selectedDayIndex - 1);
|
||||||
|
}
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
if (selectedDay.getDate() >= today.getDate()) {
|
|
||||||
let newDate = new SvelteDate(selectedDay);
|
|
||||||
newDate.setDate(selectedDay.getDate() - 1);
|
|
||||||
switchDay(newDate);
|
|
||||||
}
|
}
|
||||||
}
|
if (e.key === 'ArrowRight') {
|
||||||
if (e.key === 'ArrowRight') {
|
if (selectedDay.getDate() <= today.getDate() + 4) {
|
||||||
if (selectedDay.getDate() <= today.getDate() + 4) {
|
let newDate = new Date();
|
||||||
let newDate = new SvelteDate(selectedDay);
|
newDate.setDate(selectedDay.getDate() + 1);
|
||||||
newDate.setDate(selectedDay.getDate() + 1);
|
switchDay(newDate, selectedDayIndex + 1);
|
||||||
switchDay(newDate);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,31 +629,36 @@
|
|||||||
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">
|
||||||
<Select.Root
|
{#if params.models && params.models.length > 0}
|
||||||
name="model_selection"
|
{@const modelValue = params.models[0]}
|
||||||
type="single"
|
<Select.Root
|
||||||
value={$params.models?.[0]}
|
name="model_selection"
|
||||||
onValueChange={(v) => {
|
type="single"
|
||||||
$params.models = [v];
|
value={modelValue}
|
||||||
}}
|
onValueChange={(val) => {
|
||||||
>
|
if (params.models && val) {
|
||||||
<Select.Trigger
|
params.models = [val];
|
||||||
aria-label="Forecast days input"
|
}
|
||||||
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
|
}}
|
||||||
>
|
>
|
||||||
<Select.Content preventScroll={false} class="border-border">
|
<Select.Trigger
|
||||||
{#each models.flat() as mo (mo.value)}
|
aria-label="Forecast days input"
|
||||||
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
|
||||||
{/each}
|
>
|
||||||
</Select.Content>
|
<Select.Content preventScroll={false} class="border-border">
|
||||||
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground"
|
{#each models as mo (mo.value)}
|
||||||
>Weather model</Label
|
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
||||||
>
|
{/each}
|
||||||
</Select.Root>
|
</Select.Content>
|
||||||
|
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground"
|
||||||
|
>Weather model</Label
|
||||||
|
>
|
||||||
|
</Select.Root>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}N° ${longitude}E°`,
|
name: `${latitude}N° ${longitude}E°`,
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user