|
|
|
@@ -1,10 +1,11 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onMount } from 'svelte';
|
|
|
|
|
import { onMount, tick } from 'svelte';
|
|
|
|
|
import { fade } from 'svelte/transition';
|
|
|
|
|
|
|
|
|
|
import { fetchWeatherApi } from 'openmeteo';
|
|
|
|
|
|
|
|
|
|
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
|
|
|
|
import { storedLocation } from '$lib/stores/settings';
|
|
|
|
|
import type { WeatherApiResponse } from '@openmeteo/sdk/weather-api-response';
|
|
|
|
|
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
|
|
|
|
import { urlHashStore } from '$lib/stores/url-hash-store';
|
|
|
|
|
|
|
|
|
|
import { Label } from '$lib/components/ui/label';
|
|
|
|
@@ -15,12 +16,52 @@
|
|
|
|
|
import precip from '../../canvas/precip';
|
|
|
|
|
import raster from '../../canvas/raster';
|
|
|
|
|
import tempGradient from '../../canvas/temp-gradient';
|
|
|
|
|
import type { ConfigInterface } from '../../config';
|
|
|
|
|
import { defaultParameters, models } from '../../options';
|
|
|
|
|
import { getColor } from '../../utils/colors';
|
|
|
|
|
import { getColor, textWhite } from '../../utils/colors';
|
|
|
|
|
import weatherCodes from '../../utils/weather-codes';
|
|
|
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
|
|
|
import { pad } from '$lib/utils';
|
|
|
|
|
|
|
|
|
|
// --- Interfaces ---
|
|
|
|
|
|
|
|
|
|
interface WeatherEntry {
|
|
|
|
|
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],
|
|
|
|
|
longitude: [$storedLocation.longitude],
|
|
|
|
@@ -28,266 +69,279 @@
|
|
|
|
|
...defaultParameters
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let location = $state($storedLocation);
|
|
|
|
|
storedLocation.subscribe((value) => {
|
|
|
|
|
location = value;
|
|
|
|
|
});
|
|
|
|
|
let location = $derived($storedLocation);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
// UI State
|
|
|
|
|
let selectedDay = $state(new Date());
|
|
|
|
|
let selectedDayIndex = $state(1);
|
|
|
|
|
let scrollDiv: HTMLElement | undefined = $state();
|
|
|
|
|
let canvasElement: HTMLCanvasElement | undefined = $state();
|
|
|
|
|
|
|
|
|
|
let entries = $state(0);
|
|
|
|
|
// Data State
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
let weather = $derived(
|
|
|
|
|
(async (location: GeoLocation) => {
|
|
|
|
|
const reqParams = {
|
|
|
|
|
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()!;
|
|
|
|
|
// Constants
|
|
|
|
|
const today = new Date();
|
|
|
|
|
const entries = 6;
|
|
|
|
|
const winddir = true;
|
|
|
|
|
|
|
|
|
|
weatherCodesHourly = hourly.variables(3)?.valuesArray();
|
|
|
|
|
let modelSelected = $derived(models.find((mo) => String(mo.value) === $params.models));
|
|
|
|
|
|
|
|
|
|
let hourlyTime = [
|
|
|
|
|
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
|
|
|
|
|
].map(
|
|
|
|
|
(_, i) =>
|
|
|
|
|
new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
|
|
|
|
|
);
|
|
|
|
|
const hourlyTemps = hourly.variables(2)?.valuesArray();
|
|
|
|
|
const hourlyCloudCover = hourly.variables(6)?.valuesArray();
|
|
|
|
|
const hourlyPrecip = hourly.variables(0)?.valuesArray();
|
|
|
|
|
const indexes = [];
|
|
|
|
|
if (hourlyTemps) {
|
|
|
|
|
for (const index of hourlyTemps.keys()) {
|
|
|
|
|
indexes.push(index);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// --- Logic ---
|
|
|
|
|
|
|
|
|
|
const maxX = 10000;
|
|
|
|
|
const maxY = 500;
|
|
|
|
|
const deltaX = 10000 / hourly.variables(0)?.valuesArray()?.length;
|
|
|
|
|
async function loadWeatherData() {
|
|
|
|
|
if (!location) return;
|
|
|
|
|
|
|
|
|
|
const ctx = canvasElement?.getContext('2d');
|
|
|
|
|
if (ctx) {
|
|
|
|
|
ctx.clearRect(0, 0, maxX, maxY);
|
|
|
|
|
const commonParams = {
|
|
|
|
|
latitude: location.latitude,
|
|
|
|
|
longitude: location.longitude,
|
|
|
|
|
elevation: location.elevation,
|
|
|
|
|
models: [$params.models],
|
|
|
|
|
forecast_days: 6,
|
|
|
|
|
past_days: 1,
|
|
|
|
|
temperature_unit: $params.temperature_unit,
|
|
|
|
|
wind_speed_unit: $params.wind_speed_unit,
|
|
|
|
|
precipitation_unit: $params.precipitation_unit
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 url = 'https://api.open-meteo.com/v1/forecast';
|
|
|
|
|
|
|
|
|
|
const config: ConfigInterface = {
|
|
|
|
|
maxX: maxX,
|
|
|
|
|
maxY: maxY,
|
|
|
|
|
deltaX: deltaX,
|
|
|
|
|
minTemp: minTemp,
|
|
|
|
|
maxTemp: maxTemp,
|
|
|
|
|
diffTemp: diffTemp
|
|
|
|
|
};
|
|
|
|
|
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'
|
|
|
|
|
})
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// create canvas
|
|
|
|
|
daylight(ctx, config, hourlyTime);
|
|
|
|
|
raster(ctx, config, hourlyTime, today, canvasElement);
|
|
|
|
|
tempGradient(ctx, config, hourlyTemps, $params.temperature_unit);
|
|
|
|
|
cloudCover(ctx, config, hourlyCloudCover, canvasElement);
|
|
|
|
|
precip(ctx, config, hourlyPrecip, canvasElement);
|
|
|
|
|
}
|
|
|
|
|
processHourly(hourlyRes[0]);
|
|
|
|
|
processDaily(dailyRes[0]);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Weather fetch error:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
entries: [
|
|
|
|
|
{
|
|
|
|
|
id: 0,
|
|
|
|
|
name: 'temperature_2m',
|
|
|
|
|
title: 'Temperature',
|
|
|
|
|
values: hourly
|
|
|
|
|
.variables(2)
|
|
|
|
|
?.valuesArray()
|
|
|
|
|
?.map((t) => t.toFixed(1))
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 1,
|
|
|
|
|
name: 'precipitation',
|
|
|
|
|
title: 'Precipitation',
|
|
|
|
|
values: hourly
|
|
|
|
|
.variables(0)
|
|
|
|
|
?.valuesArray()
|
|
|
|
|
?.map((p) => p.toFixed(1))
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 2,
|
|
|
|
|
name: 'precipitation_probability',
|
|
|
|
|
title: 'Precip Prob.',
|
|
|
|
|
values: hourly
|
|
|
|
|
.variables(1)
|
|
|
|
|
?.valuesArray()
|
|
|
|
|
?.map((p) => p.toFixed(0))
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 3,
|
|
|
|
|
name: 'windspeed_10m',
|
|
|
|
|
title: 'Wind',
|
|
|
|
|
values: hourly
|
|
|
|
|
.variables(4)
|
|
|
|
|
?.valuesArray()
|
|
|
|
|
?.map((p) => p.toFixed(0))
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 4,
|
|
|
|
|
name: 'relative_humidity_2m',
|
|
|
|
|
title: 'Rel. Hum.',
|
|
|
|
|
values: hourly
|
|
|
|
|
.variables(7)
|
|
|
|
|
?.valuesArray()
|
|
|
|
|
?.map((p) => p.toFixed(0))
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
entriesLength: hourly.variables(0)?.valuesArray()?.length,
|
|
|
|
|
hourlyTime: hourlyTime,
|
|
|
|
|
windDirections: hourly.variables(5)?.valuesArray(),
|
|
|
|
|
indexes: indexes
|
|
|
|
|
};
|
|
|
|
|
})(location)
|
|
|
|
|
);
|
|
|
|
|
function processHourly(response: WeatherApiResponse) {
|
|
|
|
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
|
|
|
const hourly = response.hourly()!;
|
|
|
|
|
|
|
|
|
|
let weatherDaily = $derived(
|
|
|
|
|
(async (location: GeoLocation) => {
|
|
|
|
|
const reqParams = {
|
|
|
|
|
latitude: location.latitude,
|
|
|
|
|
longitude: location.longitude,
|
|
|
|
|
elevation: location.elevation,
|
|
|
|
|
// timezone: location.timezone, ???
|
|
|
|
|
models: [$params.models],
|
|
|
|
|
daily: [
|
|
|
|
|
'weather_code',
|
|
|
|
|
'temperature_2m_max',
|
|
|
|
|
'temperature_2m_min',
|
|
|
|
|
'sunrise',
|
|
|
|
|
'sunset',
|
|
|
|
|
'sunshine_duration',
|
|
|
|
|
'precipitation_sum',
|
|
|
|
|
'windspeed_10m_max',
|
|
|
|
|
'windgusts_10m_max',
|
|
|
|
|
'winddirection_10m_dominant'
|
|
|
|
|
].join(','),
|
|
|
|
|
forecast_days: 6,
|
|
|
|
|
past_days: 1,
|
|
|
|
|
temperature_unit: $params.temperature_unit,
|
|
|
|
|
wind_speed_unit: $params.wind_speed_unit,
|
|
|
|
|
precipitation_unit: $params.precipitation_unit
|
|
|
|
|
};
|
|
|
|
|
const url = 'https://api.open-meteo.com/v1/forecast';
|
|
|
|
|
const responses = await fetchWeatherApi(url, reqParams);
|
|
|
|
|
const response = responses[0];
|
|
|
|
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
|
|
|
const daily = response.daily()!;
|
|
|
|
|
weatherCodesHourly = hourly.variables(3)?.valuesArray() ?? undefined;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
daily: {
|
|
|
|
|
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)!
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
})(location)
|
|
|
|
|
);
|
|
|
|
|
const hourlyTime = [
|
|
|
|
|
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
|
|
|
|
|
].map(
|
|
|
|
|
(_, i) => new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let winddir = true;
|
|
|
|
|
entries = 6;
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
let scrollDiv: HTMLElement = $state();
|
|
|
|
|
let tableCells;
|
|
|
|
|
|
|
|
|
|
const switchDay = (date: SvelteDate, index: number) => {
|
|
|
|
|
selectedDay = date;
|
|
|
|
|
|
|
|
|
|
tableCells = document.querySelectorAll('td.time');
|
|
|
|
|
|
|
|
|
|
for (let tableCell of tableCells) {
|
|
|
|
|
if (Number(tableCell.dataset['date']) === selectedDay.getDate()) {
|
|
|
|
|
scrollDiv.scrollTo({ left: tableCell.offsetLeft - 110, behavior: 'smooth' });
|
|
|
|
|
break;
|
|
|
|
|
const indexes: number[] = [];
|
|
|
|
|
if (hourlyTemps) {
|
|
|
|
|
for (let i = 0; i < hourlyTemps.length; i++) {
|
|
|
|
|
indexes.push(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
selectedDayIndex = index;
|
|
|
|
|
// 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 maxY = 500;
|
|
|
|
|
const deltaX = maxX / (weather.entriesLength || 1); // prevent division by zero
|
|
|
|
|
|
|
|
|
|
const computedStyle = getComputedStyle(canvasElement);
|
|
|
|
|
const styleConfig = {
|
|
|
|
|
mutedForeground: computedStyle.getPropertyValue('--muted-foreground').split(' ').join(','),
|
|
|
|
|
primary: computedStyle.getPropertyValue('--primary').split(' ').join(','),
|
|
|
|
|
border: computedStyle.getPropertyValue('--border').split(' ').join(',')
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ctx.clearRect(0, 0, maxX, maxY);
|
|
|
|
|
|
|
|
|
|
const config: ConfigInterface = {
|
|
|
|
|
maxX,
|
|
|
|
|
maxY,
|
|
|
|
|
deltaX,
|
|
|
|
|
minTemp: weather.raw.minTemp,
|
|
|
|
|
maxTemp: maxTemp,
|
|
|
|
|
diffTemp: diffTemp || 1, // prevent division by zero in canvas
|
|
|
|
|
styles: styleConfig
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
daylight(ctx, config, weather.hourlyTime);
|
|
|
|
|
raster(ctx, config, weather.hourlyTime, today, canvasElement);
|
|
|
|
|
tempGradient(ctx, config, weather.raw.hourlyTemps, $params.temperature_unit);
|
|
|
|
|
cloudCover(ctx, config, weather.raw.hourlyCloudCover);
|
|
|
|
|
precip(ctx, config, weather.raw.hourlyPrecip);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const switchDay = async (date: Date, index?: number) => {
|
|
|
|
|
selectedDay = date;
|
|
|
|
|
if (index !== undefined) selectedDayIndex = index;
|
|
|
|
|
|
|
|
|
|
await tick();
|
|
|
|
|
|
|
|
|
|
if (!scrollDiv) return;
|
|
|
|
|
const tableCells = document.querySelectorAll('td.time');
|
|
|
|
|
for (const tableCell of tableCells) {
|
|
|
|
|
if (Number((tableCell as HTMLElement).dataset['date']) === selectedDay.getDate()) {
|
|
|
|
|
scrollDiv.scrollTo({
|
|
|
|
|
left: (tableCell as HTMLElement).offsetLeft - 110,
|
|
|
|
|
behavior: 'smooth'
|
|
|
|
|
});
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMount(() => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
tableCells = document.querySelectorAll('td.time');
|
|
|
|
|
for (let tableCell of tableCells) {
|
|
|
|
|
if (Number(tableCell.dataset['date']) === selectedDay.getDate()) {
|
|
|
|
|
scrollDiv.scrollTo({ left: tableCell.offsetLeft - 110 });
|
|
|
|
|
if (!scrollDiv) return;
|
|
|
|
|
const tableCells = document.querySelectorAll('td.time');
|
|
|
|
|
for (const tableCell of tableCells) {
|
|
|
|
|
if (Number((tableCell as HTMLElement).dataset['date']) === selectedDay.getDate()) {
|
|
|
|
|
scrollDiv.scrollTo({ left: (tableCell as HTMLElement).offsetLeft - 110 });
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, 150);
|
|
|
|
|
|
|
|
|
|
document.onkeydown = (e) => {
|
|
|
|
|
if (!scrollDiv === document.activeElement || !scrollDiv.contains(document.activeElement)) {
|
|
|
|
|
if (e.key === 'ArrowLeft') {
|
|
|
|
|
if (selectedDay.getDate() >= today.getDate()) {
|
|
|
|
|
let newDate = new SvelteDate();
|
|
|
|
|
newDate.setDate(selectedDay.getDate() - 1);
|
|
|
|
|
switchDay(newDate);
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
!scrollDiv ||
|
|
|
|
|
scrollDiv === document.activeElement ||
|
|
|
|
|
scrollDiv.contains(document.activeElement)
|
|
|
|
|
)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
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 (selectedDay.getDate() <= today.getDate() + 4) {
|
|
|
|
|
let newDate = new SvelteDate();
|
|
|
|
|
newDate.setDate(selectedDay.getDate() + 1);
|
|
|
|
|
switchDay(newDate);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'ArrowRight') {
|
|
|
|
|
if (selectedDay.getDate() <= today.getDate() + 4) {
|
|
|
|
|
let newDate = new SvelteDate(selectedDay);
|
|
|
|
|
newDate.setDate(selectedDay.getDate() + 1);
|
|
|
|
|
switchDay(newDate);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let modelSelected = $derived(models.find((mo) => String(mo.value) === $params.models));
|
|
|
|
|
// let modelSelectedValue = $derived($params.models[0]);
|
|
|
|
|
//
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<svelte:head>
|
|
|
|
@@ -304,10 +358,10 @@
|
|
|
|
|
style="min-height: 256px"
|
|
|
|
|
class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row"
|
|
|
|
|
>
|
|
|
|
|
{#await weatherDaily then wd}
|
|
|
|
|
{#each wd.daily.time as time, index (index)}
|
|
|
|
|
{#if weatherDaily}
|
|
|
|
|
{#each weatherDaily.time as time, index (index)}
|
|
|
|
|
{@const selected = time.getDate() === selectedDay.getDate()}
|
|
|
|
|
{#if !isNaN(wd.daily.temperature_2m_max.values(index).toFixed(1))}
|
|
|
|
|
{#if !isNaN(weatherDaily.temperature_2m_max.values(index)!)}
|
|
|
|
|
<button
|
|
|
|
|
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
|
|
|
|
|
class="cursor-pointer"
|
|
|
|
@@ -337,23 +391,23 @@
|
|
|
|
|
<svg class="fill-foreground" width="60px" height="60px">
|
|
|
|
|
<use
|
|
|
|
|
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
|
|
|
|
wd.daily.weather_code.values(index)
|
|
|
|
|
weatherDaily.weather_code.values(index)!
|
|
|
|
|
]}.svg#Layer_1"
|
|
|
|
|
></use>
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
class="weather-temp-max flex min-w-[65px] justify-center rounded-t p-1 text-sm"
|
|
|
|
|
style={`background-color: ${getColor(wd.daily.temperature_2m_max.values(index).toFixed(0), $params.temperature_unit)}; color: ${wd.daily.temperature_2m_min.values(index) < ($params.temperature_unit === 'celsius' ? 4 : 7) || wd.daily.temperature_2m_min.values(index) >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
|
|
|
|
|
class="weather-temp-max flex min-w-16.25 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'}`}
|
|
|
|
|
>
|
|
|
|
|
{wd.daily.temperature_2m_max.values(index).toFixed(1)}
|
|
|
|
|
{weatherDaily.temperature_2m_max.values(index)!.toFixed(1)}
|
|
|
|
|
{$params.temperature_unit === 'celsius' ? '°C' : '°F'}
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
class="weather-temp-min flex min-w-[65px] justify-center rounded-b p-1 text-sm"
|
|
|
|
|
style={`background: ${getColor(wd.daily.temperature_2m_min.values(index).toFixed(0), $params.temperature_unit)}; color: ${wd.daily.temperature_2m_min.values(index) < ($params.temperature_unit === 'celsius' ? 4 : 7) || wd.daily.temperature_2m_min.values(index) >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
|
|
|
|
|
class="weather-temp-min flex min-w-16.25 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'}`}
|
|
|
|
|
>
|
|
|
|
|
{wd.daily.temperature_2m_min.values(index).toFixed(1)}
|
|
|
|
|
{weatherDaily.temperature_2m_min.values(index)!.toFixed(1)}
|
|
|
|
|
{$params.temperature_unit === 'celsius' ? '°C' : '°F'}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-2 flex items-center justify-center gap-1">
|
|
|
|
@@ -368,7 +422,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{Number(wd.daily.sunshine_duration.values(index) / 3600).toFixed(0)}h
|
|
|
|
|
{Number(weatherDaily.sunshine_duration.values(index)! / 3600).toFixed(0)}h
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-1 flex items-center justify-center">
|
|
|
|
|
<div class="relative flex h-6 w-6 items-center justify-center">
|
|
|
|
@@ -381,7 +435,7 @@
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{Number(wd.daily.precipitation_sum.values(index)).toFixed(
|
|
|
|
|
{Number(weatherDaily.precipitation_sum.values(index)).toFixed(
|
|
|
|
|
1
|
|
|
|
|
)}{$params.precipitation_unit === 'mm' ? 'mm' : "'"}
|
|
|
|
|
</div>
|
|
|
|
@@ -389,9 +443,9 @@
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
{/each}
|
|
|
|
|
{:catch error}
|
|
|
|
|
<p style="color: red">{error.message}</p>
|
|
|
|
|
{/await}
|
|
|
|
|
{:else}
|
|
|
|
|
<p>Loading...</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ml-22 md:ml-0">
|
|
|
|
|
<h3 class="text-xl font-bold">
|
|
|
|
@@ -410,7 +464,7 @@
|
|
|
|
|
<div
|
|
|
|
|
bind:this={scrollDiv}
|
|
|
|
|
style=" height: {218 + entries * 27.5}px; "
|
|
|
|
|
class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-[110px]"
|
|
|
|
|
class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-27.5"
|
|
|
|
|
>
|
|
|
|
|
<canvas
|
|
|
|
|
bind:this={canvasElement}
|
|
|
|
@@ -423,7 +477,7 @@
|
|
|
|
|
<table in:fade class="absolute bottom-0 border-b border-border">
|
|
|
|
|
<caption style="display:none"> Weather Week {location.name} </caption>
|
|
|
|
|
<tbody>
|
|
|
|
|
{#await weather then weather}
|
|
|
|
|
{#if weather && weather.entries[0] && weather.entries[0].values}
|
|
|
|
|
<tr>
|
|
|
|
|
<th
|
|
|
|
|
scope="row"
|
|
|
|
@@ -460,13 +514,13 @@
|
|
|
|
|
{@const now =
|
|
|
|
|
weather.hourlyTime[index].getDate() === today.getDate() &&
|
|
|
|
|
weather.hourlyTime[index].getHours() === today.getHours()}
|
|
|
|
|
<!-- Safe access to values -->
|
|
|
|
|
{@const tempVal = weather.entries[0].values?.[index] ?? '0'}
|
|
|
|
|
<td
|
|
|
|
|
style="position: absolute; bottom: {27.5 * entries -
|
|
|
|
|
24 +
|
|
|
|
|
0.8 * 200 -
|
|
|
|
|
0.54 *
|
|
|
|
|
200 *
|
|
|
|
|
((maxTemp - weather.entries[0].values[index]) / diffTemp)}px; left:{116 +
|
|
|
|
|
0.54 * 200 * ((maxTemp - Number(tempVal)) / diffTemp)}px; left:{116 +
|
|
|
|
|
(5000 / weather.entriesLength) * index}px; min-width: {5000 /
|
|
|
|
|
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;"
|
|
|
|
|
><svg class="fill-foreground {now ? 'scale-125' : ''}" width="20px" height="20px">
|
|
|
|
@@ -475,7 +529,7 @@
|
|
|
|
|
xlink:href="/images/weather-icons/wi-{weather.hourlyTime[index].getHours() >
|
|
|
|
|
6 && weather.hourlyTime[index].getHours() < 21
|
|
|
|
|
? 'day'
|
|
|
|
|
: 'night'}-{weatherCodes[weatherCodesHourly[index]]}.svg#Layer_1"
|
|
|
|
|
: 'night'}-{weatherCodes[weatherCodesHourly?.[index] ?? 0]}.svg#Layer_1"
|
|
|
|
|
></use>
|
|
|
|
|
</svg></td
|
|
|
|
|
>
|
|
|
|
@@ -490,7 +544,7 @@
|
|
|
|
|
>Temp graph</th
|
|
|
|
|
>
|
|
|
|
|
{#each weather.indexes as index, j (j)}
|
|
|
|
|
{@const temp = weather.entries[0].values[index]}
|
|
|
|
|
{@const temp = Number(weather.entries[0].values?.[index])}
|
|
|
|
|
|
|
|
|
|
{#if !isNaN(temp)}
|
|
|
|
|
<td
|
|
|
|
@@ -519,7 +573,11 @@
|
|
|
|
|
>
|
|
|
|
|
|
|
|
|
|
{#each weather.indexes as index, j (j)}
|
|
|
|
|
{#if !isNaN(entry.values[index])}
|
|
|
|
|
{@const val = 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
|
|
|
|
|
class="border-r border-border {weather.hourlyTime[index].getDate() ===
|
|
|
|
|
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
|
|
|
|
@@ -527,41 +585,17 @@
|
|
|
|
|
: ''}"
|
|
|
|
|
style="min-width: {5000 / weather.entriesLength}px; max-width: {5000 /
|
|
|
|
|
weather.entriesLength}px;
|
|
|
|
|
{entry.name === 'temperature_2m'
|
|
|
|
|
? 'background: ' +
|
|
|
|
|
getColor(
|
|
|
|
|
weather.entries[0].values[index].toFixed(0),
|
|
|
|
|
$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 === 'temperature_2m' ? 'background: ' + bgColor : ''};
|
|
|
|
|
{entry.name === 'temperature_2m' ? ('color: ' + textWhite(bgColor) ? 'white' : 'black') : ''};
|
|
|
|
|
{entry.name === 'precipitation_probability'
|
|
|
|
|
? 'background: rgba(0, 0, 230,' + valNum / 120 + ')'
|
|
|
|
|
: ''};
|
|
|
|
|
{entry.name === 'precipitation_probability'
|
|
|
|
|
? '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)')
|
|
|
|
|
? 'color: ' + (valNum > 50 ? 'white' : 'hsl(var(--foreground)')
|
|
|
|
|
: ''};
|
|
|
|
|
{entry.name === 'relative_humidity_2m'
|
|
|
|
|
? 'background: rgba(0, 240, 240,' +
|
|
|
|
|
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
|
|
|
|
|
? 'background: rgba(0, 240, 240,' + valNum ** 3.8 / 10 ** 8.2 + ')'
|
|
|
|
|
: ''};">{val}</td
|
|
|
|
|
>
|
|
|
|
|
{/if}
|
|
|
|
|
{/each}
|
|
|
|
@@ -577,7 +611,7 @@
|
|
|
|
|
>Wind Dir.</th
|
|
|
|
|
>
|
|
|
|
|
{#each weather.indexes as index, j (j)}
|
|
|
|
|
{#if !isNaN(weather.windDirections[index])}
|
|
|
|
|
{#if weather.windDirections && !isNaN(weather.windDirections[index])}
|
|
|
|
|
<td
|
|
|
|
|
class="border-r border-border {weather.hourlyTime[index].getDate() ===
|
|
|
|
|
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
|
|
|
|
@@ -595,14 +629,14 @@
|
|
|
|
|
{/each}
|
|
|
|
|
</tr>
|
|
|
|
|
{/if}
|
|
|
|
|
{/await}
|
|
|
|
|
{/if}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{#await weatherDaily then wd}
|
|
|
|
|
{@const sunrise = new Date(Number(wd.daily.sunrise.valuesInt64(selectedDayIndex)) * 1000)}
|
|
|
|
|
{@const sunset = new Date(Number(wd.daily.sunset.valuesInt64(selectedDayIndex)) * 1000)}
|
|
|
|
|
{#if weatherDaily}
|
|
|
|
|
{@const sunrise = new Date(Number(weatherDaily.sunrise.valuesInt64(selectedDayIndex)) * 1000)}
|
|
|
|
|
{@const sunset = new Date(Number(weatherDaily.sunset.valuesInt64(selectedDayIndex)) * 1000)}
|
|
|
|
|
<div class="mt-6">
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
<svg class="fill-foreground" width="28px" height="28px">
|
|
|
|
@@ -616,7 +650,7 @@
|
|
|
|
|
Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/await}
|
|
|
|
|
{/if}
|
|
|
|
|
<div>
|
|
|
|
|
<div class="mt-6 flex gap-6 md:mt-12">
|
|
|
|
|
<div class="relative w-1/2">
|
|
|
|
|