1196 lines
37 KiB
Svelte
1196 lines
37 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
import { fade } from 'svelte/transition';
|
|
|
|
import * as echarts from 'echarts';
|
|
|
|
import { storedLocation } from '$lib/stores/settings';
|
|
|
|
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
|
import { pad } from '$lib/utils/index';
|
|
|
|
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
|
import '$lib/components/charts/echarts.css';
|
|
import { Label } from '$lib/components/ui/label';
|
|
import * as Select from '$lib/components/ui/select';
|
|
|
|
import {
|
|
type MarkArea,
|
|
type WeekDailyData,
|
|
type WeekForecastResult,
|
|
type WeekHourlyData,
|
|
fetchWeekForecast
|
|
} from '$lib/services/weather';
|
|
|
|
import { defaultParameters, models } from '../../options';
|
|
import { getColor } from '../../utils/colors';
|
|
import weatherCodes from '../../utils/weather-codes';
|
|
|
|
import type { GeoLocation } from '$lib/stores/settings';
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
|
|
const CHART_GROUP = 'week-meteogram';
|
|
const MS_PER_DAY = 24 * 3600 * 1000;
|
|
|
|
// ─── State ──────────────────────────────────────────────────────────────────
|
|
|
|
let params = $state({
|
|
latitude: [$storedLocation.latitude],
|
|
longitude: [$storedLocation.longitude],
|
|
models: ['best_match'],
|
|
...defaultParameters
|
|
});
|
|
|
|
let location = $state<GeoLocation>($storedLocation);
|
|
storedLocation.subscribe((value) => {
|
|
location = value;
|
|
});
|
|
|
|
let mounted = $state(false);
|
|
let loading = $state(true);
|
|
|
|
let chartComponents: EChart[] = $state([]);
|
|
let chartInstances: echarts.ECharts[] = $state([]);
|
|
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
|
|
const today = new Date();
|
|
const selectedDay = new SvelteDate();
|
|
let selectedDayIndex = $state(1);
|
|
|
|
let tableScrollDiv: HTMLElement | undefined = $state();
|
|
|
|
// ─── Fetched Data ───────────────────────────────────────────────────────────
|
|
|
|
interface FetchedHourly {
|
|
hourly: WeekHourlyData;
|
|
utc_offset_seconds: number;
|
|
timestamps: number[];
|
|
hourlyDates: Date[];
|
|
markAreas: MarkArea[];
|
|
}
|
|
|
|
interface FetchedDaily {
|
|
daily: WeekDailyData;
|
|
dailyDates: Date[];
|
|
}
|
|
|
|
let fetchedHourly: FetchedHourly | null = $state(null);
|
|
let fetchedDaily: FetchedDaily | null = $state(null);
|
|
|
|
// ─── Scroll-to-Day ──────────────────────────────────────────────────────────
|
|
|
|
function scrollChartsToDay(day: Date): void {
|
|
if (!fetchedHourly || chartInstances.length === 0) return;
|
|
|
|
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
|
|
const dayEnd = dayStart + MS_PER_DAY;
|
|
|
|
const timestamps = fetchedHourly.timestamps;
|
|
const rangeStart = timestamps[0];
|
|
const rangeEnd = timestamps[timestamps.length - 1];
|
|
const totalRange = rangeEnd - rangeStart;
|
|
|
|
if (totalRange <= 0) return;
|
|
|
|
const startPct = Math.max(0, ((dayStart - rangeStart) / totalRange) * 100);
|
|
const endPct = Math.min(100, ((dayEnd - rangeStart) / totalRange) * 100);
|
|
|
|
for (const chart of chartInstances) {
|
|
if (chart && !chart.isDisposed()) {
|
|
chart.dispatchAction({
|
|
type: 'dataZoom',
|
|
start: startPct,
|
|
end: endPct
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function scrollTableToDay(day: Date): void {
|
|
if (!tableScrollDiv) return;
|
|
const cells = tableScrollDiv.querySelectorAll<HTMLElement>('td.hour-cell[data-date]');
|
|
for (const cell of cells) {
|
|
if (Number(cell.dataset['date']) === day.getDate()) {
|
|
tableScrollDiv.scrollTo({ left: cell.offsetLeft - 120, behavior: 'smooth' });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const switchDay = (date: Date, index: number) => {
|
|
selectedDay.setTime(date.getTime());
|
|
selectedDayIndex = index;
|
|
scrollChartsToDay(date);
|
|
requestAnimationFrame(() => scrollTableToDay(date));
|
|
};
|
|
|
|
function resetZoom(): void {
|
|
for (const chart of chartInstances) {
|
|
if (chart && !chart.isDisposed()) {
|
|
chart.dispatchAction({
|
|
type: 'dataZoom',
|
|
start: 0,
|
|
end: 100
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
|
|
onMount(() => {
|
|
mounted = true;
|
|
|
|
document.onkeydown = (e) => {
|
|
if (!fetchedDaily) return;
|
|
const days = fetchedDaily.dailyDates;
|
|
if (e.key === 'ArrowLeft') {
|
|
const newIndex = selectedDayIndex - 1;
|
|
if (newIndex >= 0 && newIndex < days.length) {
|
|
switchDay(days[newIndex], newIndex);
|
|
}
|
|
}
|
|
if (e.key === 'ArrowRight') {
|
|
const newIndex = selectedDayIndex + 1;
|
|
if (newIndex >= 0 && newIndex < days.length) {
|
|
switchDay(days[newIndex], newIndex);
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
resetZoom();
|
|
}
|
|
};
|
|
});
|
|
|
|
onDestroy(() => {
|
|
document.onkeydown = null;
|
|
chartInstances = [];
|
|
chartOptions = [];
|
|
chartComponents = [];
|
|
});
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function handleChartReady(chart: echarts.ECharts): void {
|
|
chart.group = CHART_GROUP;
|
|
chartInstances = [...chartInstances, chart];
|
|
if (chartInstances.length === 3) {
|
|
echarts.connect(CHART_GROUP);
|
|
requestAnimationFrame(() => {
|
|
scrollChartsToDay(selectedDay);
|
|
scrollTableToDay(selectedDay);
|
|
});
|
|
}
|
|
}
|
|
|
|
let modelSelected = $derived(models.find((mo) => String(mo.value) === params.models?.[0]));
|
|
|
|
function getWindDirectionLabel(deg: number): string {
|
|
const dirs = [
|
|
'N',
|
|
'NNE',
|
|
'NE',
|
|
'ENE',
|
|
'E',
|
|
'ESE',
|
|
'SE',
|
|
'SSE',
|
|
'S',
|
|
'SSW',
|
|
'SW',
|
|
'WSW',
|
|
'W',
|
|
'WNW',
|
|
'NW',
|
|
'NNW'
|
|
];
|
|
return dirs[Math.round(deg / 22.5) % 16];
|
|
}
|
|
|
|
function isDaytimeHour(hour: number): boolean {
|
|
return hour >= 6 && hour < 21;
|
|
}
|
|
|
|
function isCurrentHour(date: Date): boolean {
|
|
return (
|
|
date.getDate() === today.getDate() &&
|
|
date.getMonth() === today.getMonth() &&
|
|
date.getFullYear() === today.getFullYear() &&
|
|
date.getHours() === today.getHours()
|
|
);
|
|
}
|
|
|
|
function getTextColorForTemp(temp: number, unit: string): string {
|
|
const threshold = unit === 'celsius' ? { low: -13, high: 40 } : { low: 7, high: 104 };
|
|
return temp < threshold.low || temp >= threshold.high ? 'white' : 'black';
|
|
}
|
|
|
|
function getPrecipProbBg(prob: number): string {
|
|
return `rgba(0, 0, 230, ${prob / 120})`;
|
|
}
|
|
|
|
function getPrecipProbColor(prob: number): string {
|
|
return prob > 50 ? 'white' : 'inherit';
|
|
}
|
|
|
|
function getHumidityBg(hum: number): string {
|
|
return `rgba(0, 240, 240, ${hum ** 3.8 / 10 ** 8.2})`;
|
|
}
|
|
|
|
// ─── Data Fetching ──────────────────────────────────────────────────────────
|
|
|
|
$effect(() => {
|
|
const loc = location;
|
|
const modelList = params.models;
|
|
|
|
if (!mounted || !loc || !modelList?.length) return;
|
|
|
|
const loadData = async () => {
|
|
loading = true;
|
|
chartInstances = [];
|
|
chartComponents = [];
|
|
|
|
const result: WeekForecastResult = await fetchWeekForecast({
|
|
latitude: loc.latitude!,
|
|
longitude: loc.longitude!,
|
|
model: modelList[0],
|
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
|
forecast_days: 6,
|
|
past_days: 1
|
|
});
|
|
|
|
fetchedHourly = {
|
|
hourly: result.hourly,
|
|
utc_offset_seconds: result.utcOffsetSeconds,
|
|
timestamps: result.hourlyTimestamps,
|
|
hourlyDates: result.hourlyDates,
|
|
markAreas: result.markAreas
|
|
};
|
|
|
|
fetchedDaily = {
|
|
daily: result.daily,
|
|
dailyDates: result.dailyDates
|
|
};
|
|
|
|
loading = false;
|
|
};
|
|
|
|
loadData();
|
|
});
|
|
|
|
// ─── Chart Option Building ──────────────────────────────────────────────────
|
|
|
|
$effect(() => {
|
|
if (!fetchedHourly) return;
|
|
|
|
const { hourly, utc_offset_seconds, timestamps, markAreas } = fetchedHourly;
|
|
const colors = getThemeColors();
|
|
const tempUnit = params.temperature_unit === 'celsius' ? '°C' : '°F';
|
|
const precipUnit = params.precipitation_unit === 'mm' ? 'mm' : 'in';
|
|
const windUnit = params.wind_speed_unit === 'kmh' ? 'km/h' : params.wind_speed_unit;
|
|
|
|
const temps = hourly.temperature_2m;
|
|
const precip = hourly.precipitation;
|
|
const precipProb = hourly.precipitation_probability;
|
|
const cloudCov = hourly.cloud_cover;
|
|
const windSpeed = hourly.windspeed_10m;
|
|
const humidity = hourly.relative_humidity_2m;
|
|
|
|
const validTemps = temps.filter((t: number) => t !== null && !isNaN(t));
|
|
const minTemp = Math.min(...validTemps);
|
|
const maxTemp = Math.max(...validTemps);
|
|
|
|
const tempData = temps.map((v: number, i: number) => [timestamps[i], v]);
|
|
const cloudData = cloudCov.map((v: number, i: number) => [timestamps[i], v]);
|
|
const precipData = precip.map((v: number, i: number) => [timestamps[i], v]);
|
|
const precipProbData = precipProb.map((v: number, i: number) => [timestamps[i], v]);
|
|
const windData = windSpeed.map((v: number, i: number) => [timestamps[i], v]);
|
|
const humidityData = humidity.map((v: number, i: number) => [timestamps[i], v]);
|
|
|
|
const buildAnnotations = (): Array<Record<string, unknown>> => {
|
|
const series: Array<Record<string, unknown>> = [];
|
|
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
|
const dl = buildDaylightSeries({ markAreas });
|
|
if (dl) series.push(dl);
|
|
return series;
|
|
};
|
|
|
|
const timeXAxis = (showLabel: boolean): Record<string, unknown> => ({
|
|
type: 'time',
|
|
splitLine: { show: false },
|
|
axisLine: { lineStyle: { color: colors.axisLine } },
|
|
axisLabel: { color: colors.text, hideOverlap: true, show: showLabel },
|
|
axisTick: { lineStyle: { color: colors.axisLine } }
|
|
});
|
|
|
|
const dataZoomBase = (): Array<Record<string, unknown>> => [
|
|
{
|
|
type: 'inside',
|
|
xAxisIndex: 0,
|
|
filterMode: 'none',
|
|
zoomOnMouseWheel: true,
|
|
moveOnMouseMove: true,
|
|
moveOnMouseWheel: false
|
|
}
|
|
];
|
|
|
|
const dataZoomWithSlider = (): Array<Record<string, unknown>> => [
|
|
...dataZoomBase(),
|
|
{
|
|
type: 'slider',
|
|
xAxisIndex: 0,
|
|
filterMode: 'none',
|
|
height: 20,
|
|
bottom: 4,
|
|
borderColor: colors.axisLine,
|
|
fillerColor: 'rgba(100, 140, 200, 0.2)',
|
|
handleStyle: { color: colors.text },
|
|
textStyle: { color: colors.text, fontSize: 10 },
|
|
dataBackground: {
|
|
lineStyle: { color: colors.axisLine },
|
|
areaStyle: { color: colors.splitLine }
|
|
},
|
|
selectedDataBackground: {
|
|
lineStyle: { color: colors.axisLine },
|
|
areaStyle: { color: 'rgba(100, 140, 200, 0.15)' }
|
|
}
|
|
}
|
|
];
|
|
|
|
// ── Chart 1: Temperature + Cloud Cover ──────────────────────────────
|
|
|
|
const tempOption: Record<string, unknown> = {
|
|
title: {
|
|
text: 'Temperature & Cloud Cover',
|
|
left: 'left',
|
|
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: {
|
|
type: 'cross',
|
|
animation: false,
|
|
label: {
|
|
backgroundColor: colors.tooltipBg,
|
|
color: colors.text,
|
|
borderColor: colors.tooltipBorder,
|
|
borderWidth: 1
|
|
}
|
|
},
|
|
backgroundColor: colors.tooltipBg,
|
|
borderColor: colors.tooltipBorder,
|
|
textStyle: { color: colors.text },
|
|
formatter: (params: Record<string, unknown>[]) => {
|
|
if (!params || !params.length) return '';
|
|
const p = params[0] as Record<string, unknown>;
|
|
const date = new Date(p.axisValue as number);
|
|
let html = `<b>${date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} ${pad(date.getHours())}:${pad(date.getMinutes())}</b><br/>`;
|
|
for (const item of params as Record<string, unknown>[]) {
|
|
const name = item.seriesName as string;
|
|
if (name === 'Daylight' || name === 'Current Time') continue;
|
|
const val = (item.value as [number, number])?.[1];
|
|
if (val === undefined || val === null) continue;
|
|
const marker = item.marker as string;
|
|
if (name === 'Temperature') {
|
|
html += `${marker} ${name}: <b>${val.toFixed(1)} ${tempUnit}</b><br/>`;
|
|
} else if (name === 'Cloud Cover') {
|
|
html += `${marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
}
|
|
}
|
|
return html;
|
|
}
|
|
},
|
|
legend: {
|
|
show: true,
|
|
bottom: 0,
|
|
textStyle: { color: colors.text },
|
|
data: ['Temperature', 'Cloud Cover']
|
|
},
|
|
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
|
dataZoom: dataZoomBase(),
|
|
xAxis: timeXAxis(false),
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: tempUnit,
|
|
nameTextStyle: { color: colors.text },
|
|
axisLine: { show: false },
|
|
axisLabel: { color: colors.text },
|
|
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: '',
|
|
min: 0,
|
|
max: 250,
|
|
inverse: true,
|
|
show: false
|
|
}
|
|
],
|
|
series: [
|
|
{
|
|
name: 'Temperature',
|
|
type: 'line',
|
|
data: tempData,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
lineStyle: { width: 3, color: '#ef6c00' },
|
|
itemStyle: { color: '#ef6c00' },
|
|
areaStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{
|
|
offset: 0,
|
|
color: getColor(Math.round(maxTemp), String(params.temperature_unit)) + '88'
|
|
},
|
|
{
|
|
offset: 0.5,
|
|
color:
|
|
getColor(Math.round((maxTemp + minTemp) / 2), String(params.temperature_unit)) +
|
|
'44'
|
|
},
|
|
{
|
|
offset: 1,
|
|
color: getColor(Math.round(minTemp), String(params.temperature_unit)) + '08'
|
|
}
|
|
])
|
|
},
|
|
z: 5
|
|
},
|
|
{
|
|
name: 'Cloud Cover',
|
|
type: 'line',
|
|
data: cloudData,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
yAxisIndex: 1,
|
|
lineStyle: { width: 0 },
|
|
itemStyle: { color: colors.text },
|
|
areaStyle: {
|
|
color: 'rgba(150, 150, 150, 0.25)',
|
|
origin: 'start'
|
|
},
|
|
z: 1,
|
|
silent: true
|
|
},
|
|
...buildAnnotations()
|
|
],
|
|
textStyle: { color: colors.text }
|
|
};
|
|
|
|
// ── Chart 2: Precipitation + Probability ────────────────────────────
|
|
|
|
const precipOption: Record<string, unknown> = {
|
|
title: {
|
|
text: 'Precipitation & Probability',
|
|
left: 'left',
|
|
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: {
|
|
type: 'cross',
|
|
animation: false,
|
|
label: {
|
|
backgroundColor: colors.tooltipBg,
|
|
color: colors.text,
|
|
borderColor: colors.tooltipBorder,
|
|
borderWidth: 1
|
|
}
|
|
},
|
|
backgroundColor: colors.tooltipBg,
|
|
borderColor: colors.tooltipBorder,
|
|
textStyle: { color: colors.text },
|
|
formatter: (params: Record<string, unknown>[]) => {
|
|
if (!params || !params.length) return '';
|
|
const p = params[0] as Record<string, unknown>;
|
|
const date = new Date(p.axisValue as number);
|
|
let html = `<b>${date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} ${pad(date.getHours())}:${pad(date.getMinutes())}</b><br/>`;
|
|
for (const item of params as Record<string, unknown>[]) {
|
|
const name = item.seriesName as string;
|
|
if (name === 'Daylight' || name === 'Current Time') continue;
|
|
const val = (item.value as [number, number])?.[1];
|
|
if (val === undefined || val === null) continue;
|
|
const marker = item.marker as string;
|
|
if (name === 'Precipitation') {
|
|
html += `${marker} ${name}: <b>${val.toFixed(1)} ${precipUnit}</b><br/>`;
|
|
} else if (name === 'Precip. Probability') {
|
|
html += `${marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
}
|
|
}
|
|
return html;
|
|
}
|
|
},
|
|
legend: {
|
|
show: true,
|
|
bottom: 0,
|
|
textStyle: { color: colors.text },
|
|
data: ['Precipitation', 'Precip. Probability']
|
|
},
|
|
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
|
dataZoom: dataZoomBase(),
|
|
xAxis: timeXAxis(false),
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: precipUnit,
|
|
min: 0,
|
|
nameTextStyle: { color: colors.text },
|
|
axisLine: { show: false },
|
|
axisLabel: { color: colors.text },
|
|
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: '%',
|
|
min: 0,
|
|
max: 100,
|
|
nameTextStyle: { color: colors.text },
|
|
axisLine: { show: false },
|
|
axisLabel: { color: colors.text },
|
|
splitLine: { show: false }
|
|
}
|
|
],
|
|
series: [
|
|
{
|
|
name: 'Precipitation',
|
|
type: 'bar',
|
|
data: precipData,
|
|
barMaxWidth: 8,
|
|
itemStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: 'rgba(30, 136, 229, 0.9)' },
|
|
{ offset: 1, color: 'rgba(30, 136, 229, 0.4)' }
|
|
])
|
|
},
|
|
yAxisIndex: 0,
|
|
z: 5
|
|
},
|
|
{
|
|
name: 'Precip. Probability',
|
|
type: 'line',
|
|
data: precipProbData,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
lineStyle: { width: 2, type: 'dashed', color: '#5c6bc0' },
|
|
itemStyle: { color: '#5c6bc0' },
|
|
yAxisIndex: 1,
|
|
z: 4
|
|
},
|
|
...buildAnnotations()
|
|
],
|
|
textStyle: { color: colors.text }
|
|
};
|
|
|
|
// ── Chart 3: Wind & Humidity ─────────────────────────────────────────
|
|
|
|
const windOption: Record<string, unknown> = {
|
|
title: {
|
|
text: 'Wind Speed & Humidity',
|
|
left: 'left',
|
|
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: {
|
|
type: 'cross',
|
|
animation: false,
|
|
label: {
|
|
backgroundColor: colors.tooltipBg,
|
|
color: colors.text,
|
|
borderColor: colors.tooltipBorder,
|
|
borderWidth: 1
|
|
}
|
|
},
|
|
backgroundColor: colors.tooltipBg,
|
|
borderColor: colors.tooltipBorder,
|
|
textStyle: { color: colors.text },
|
|
formatter: (params: Record<string, unknown>[]) => {
|
|
if (!params || !params.length) return '';
|
|
const p = params[0] as Record<string, unknown>;
|
|
const date = new Date(p.axisValue as number);
|
|
const idx = timestamps.indexOf(p.axisValue as number);
|
|
let html = `<b>${date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} ${pad(date.getHours())}:${pad(date.getMinutes())}</b><br/>`;
|
|
for (const item of params as Record<string, unknown>[]) {
|
|
const name = item.seriesName as string;
|
|
if (name === 'Daylight' || name === 'Current Time') continue;
|
|
const val = (item.value as [number, number])?.[1];
|
|
if (val === undefined || val === null) continue;
|
|
const marker = item.marker as string;
|
|
if (name === 'Wind Speed') {
|
|
const windDir = idx >= 0 ? hourly.winddirection_10m[idx] : null;
|
|
html += `${marker} ${name}: <b>${val.toFixed(0)} ${windUnit}</b>`;
|
|
if (windDir !== null && windDir !== undefined && !isNaN(windDir)) {
|
|
html += ` (${getWindDirectionLabel(windDir)})`;
|
|
}
|
|
html += '<br/>';
|
|
} else if (name === 'Humidity') {
|
|
html += `${marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
}
|
|
}
|
|
return html;
|
|
}
|
|
},
|
|
legend: {
|
|
show: true,
|
|
bottom: 28,
|
|
textStyle: { color: colors.text },
|
|
data: ['Wind Speed', 'Humidity']
|
|
},
|
|
grid: { left: 60, right: 60, top: 50, bottom: 60 },
|
|
dataZoom: dataZoomWithSlider(),
|
|
xAxis: timeXAxis(true),
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: windUnit,
|
|
min: 0,
|
|
nameTextStyle: { color: colors.text },
|
|
axisLine: { show: false },
|
|
axisLabel: { color: colors.text },
|
|
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: '%',
|
|
min: 0,
|
|
max: 100,
|
|
nameTextStyle: { color: colors.text },
|
|
axisLine: { show: false },
|
|
axisLabel: { color: colors.text },
|
|
splitLine: { show: false }
|
|
}
|
|
],
|
|
series: [
|
|
{
|
|
name: 'Wind Speed',
|
|
type: 'line',
|
|
data: windData,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
lineStyle: { width: 2, color: '#26a69a' },
|
|
itemStyle: { color: '#26a69a' },
|
|
areaStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: 'rgba(38, 166, 154, 0.25)' },
|
|
{ offset: 1, color: 'rgba(38, 166, 154, 0.02)' }
|
|
])
|
|
},
|
|
yAxisIndex: 0,
|
|
z: 5
|
|
},
|
|
{
|
|
name: 'Humidity',
|
|
type: 'line',
|
|
data: humidityData,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
lineStyle: { width: 2, type: 'dotted', color: '#8d6e63' },
|
|
itemStyle: { color: '#8d6e63' },
|
|
yAxisIndex: 1,
|
|
z: 4
|
|
},
|
|
...buildAnnotations()
|
|
],
|
|
graphic: [
|
|
{
|
|
type: 'text',
|
|
right: 10,
|
|
bottom: 30,
|
|
style: {
|
|
text: 'Open-Meteo.com',
|
|
fontSize: 10,
|
|
fill: colors.text,
|
|
opacity: 0.4
|
|
},
|
|
cursor: 'pointer'
|
|
}
|
|
],
|
|
textStyle: { color: colors.text }
|
|
};
|
|
|
|
chartOptions = [tempOption, precipOption, windOption];
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Weather | Open-Meteo.com</title>
|
|
<link rel="canonical" href="https://open-meteo.com/weather" />
|
|
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
|
</svelte:head>
|
|
|
|
<div class="">
|
|
<div class="weather-content" style="min-height: 50vh">
|
|
<!-- ─── Daily Summary Cards ────────────────────────────────────────── -->
|
|
<div
|
|
in:fade
|
|
out:fade
|
|
style="min-height: 256px"
|
|
class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row"
|
|
>
|
|
{#if fetchedDaily}
|
|
{#each fetchedDaily.dailyDates as time, index (index)}
|
|
{@const selected = time.getDate() === selectedDay.getDate()}
|
|
{@const tempMax = fetchedDaily.daily.temperature_2m_max[index]}
|
|
{@const tempMin = fetchedDaily.daily.temperature_2m_min[index]}
|
|
{@const wCode = fetchedDaily.daily.weather_code[index]}
|
|
{@const sunDuration = fetchedDaily.daily.sunshine_duration[index]}
|
|
{@const precipSum = fetchedDaily.daily.precipitation_sum[index]}
|
|
{#if tempMax != null && !isNaN(tempMax)}
|
|
<button
|
|
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
|
|
class="cursor-pointer"
|
|
onclick={() => {
|
|
switchDay(time, index);
|
|
}}
|
|
>
|
|
<div
|
|
class="gap-md-1 flex flex-row items-center justify-center rounded-xl p-1 md:flex-col md:justify-center md:p-3 {selected
|
|
? 'bg-accent'
|
|
: ''}"
|
|
>
|
|
<div class="weather-week-date">
|
|
<b>{time.getDate()} - {time.getMonth() + 1}</b>
|
|
</div>
|
|
|
|
<div
|
|
data-text={time.toLocaleDateString('en-GB', { weekday: 'long' })}
|
|
class="grow-text relative mx-auto inline-flex flex-col {selected
|
|
? 'font-bold'
|
|
: ''}"
|
|
>
|
|
{time.toLocaleDateString('en-GB', { weekday: 'long' })}
|
|
</div>
|
|
|
|
<div class="weather-week-icon pe-none py-2">
|
|
<svg class="fill-foreground" width="60px" height="60px">
|
|
<use
|
|
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
|
wCode as keyof typeof weatherCodes
|
|
] ?? 'clear'}.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(
|
|
tempMax,
|
|
String(params.temperature_unit)
|
|
)}; color: {tempMin < (params.temperature_unit === 'celsius' ? 4 : 7) ||
|
|
tempMin >= (params.temperature_unit === 'celsius' ? 30 : 104)
|
|
? 'white'
|
|
: 'black'}"
|
|
>
|
|
{tempMax.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(
|
|
tempMin,
|
|
String(params.temperature_unit)
|
|
)}; color: {tempMin < (params.temperature_unit === 'celsius' ? 4 : 7) ||
|
|
tempMin >= (params.temperature_unit === 'celsius' ? 30 : 104)
|
|
? 'white'
|
|
: 'black'}"
|
|
>
|
|
{tempMin.toFixed(1)}
|
|
{params.temperature_unit === 'celsius' ? '°C' : '°F'}
|
|
</div>
|
|
<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="absolute">
|
|
<svg class="fill-foreground" width="26px" height="26px">
|
|
<use
|
|
class="stroke-2"
|
|
xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"
|
|
></use>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{Number((sunDuration ?? 0) / 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">
|
|
<div class="absolute">
|
|
<svg class="fill-foreground" width="28px" height="28px">
|
|
<use
|
|
class="stroke-2"
|
|
xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"
|
|
></use>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{Number(precipSum).toFixed(1)}{params.precipitation_unit === 'mm' ? 'mm' : "'"}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- ─── Selected Day Header ───────────────────────────────────────── -->
|
|
<div class="mb-2 flex items-center gap-3">
|
|
<h3 class="text-xl font-bold">
|
|
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
|
|
<small>
|
|
{selectedDay.getDate() === new Date(today.getTime() - 24 * 60 * 60 * 1000).getDate()
|
|
? ' (Yesterday)'
|
|
: ''}
|
|
{selectedDay.getDate() === today.getDate() ? ' (Today)' : ''}
|
|
{selectedDay.getDate() === new Date(today.getTime() + 24 * 60 * 60 * 1000).getDate()
|
|
? ' (Tomorrow)'
|
|
: ''}
|
|
</small>
|
|
</h3>
|
|
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}>
|
|
Show All
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ─── ECharts Meteogram ─────────────────────────────────────────── -->
|
|
|
|
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
|
{#each chartOptions as option, i (i)}
|
|
<EChart
|
|
{option}
|
|
height={i === 2 ? '320px' : '300px'}
|
|
onChartReady={handleChartReady}
|
|
bind:this={chartComponents[i]}
|
|
/>
|
|
{/each}
|
|
</ChartContainer>
|
|
|
|
<!-- ─── Hourly Data Table ──────────────────────────────────────────── -->
|
|
{#if fetchedHourly}
|
|
{@const hourly = fetchedHourly.hourly}
|
|
{@const dates = fetchedHourly.hourlyDates}
|
|
<div class="mt-4">
|
|
<h4 class="mb-2 text-lg font-semibold">Hourly Details</h4>
|
|
</div>
|
|
<div
|
|
bind:this={tableScrollDiv}
|
|
class="hourly-table-scroll -mx-5 overflow-x-auto overflow-y-hidden md:mx-0"
|
|
>
|
|
<table class="hourly-table">
|
|
<caption class="sr-only">Hourly weather details for {location.name}</caption>
|
|
<tbody>
|
|
<!-- Time row -->
|
|
<tr>
|
|
<th class="row-header" scope="row">Time</th>
|
|
{#each dates as date, i (i)}
|
|
{@const isNow = isCurrentHour(date)}
|
|
{@const isMidnight = date.getHours() === 0}
|
|
<td
|
|
class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
|
|
data-date={date.getDate()}
|
|
>
|
|
{pad(date.getHours())}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
|
|
<!-- Weather icons row -->
|
|
<tr>
|
|
<th class="row-header" scope="row">
|
|
<svg class="fill-foreground inline-block" width="18px" height="18px">
|
|
<use xlink:href="/images/weather-icons/wi-day-cloudy.svg#Layer_1"></use>
|
|
</svg>
|
|
</th>
|
|
{#each dates as date, i (i)}
|
|
{@const wCode = hourly.weather_code[i]}
|
|
{@const isNow = isCurrentHour(date)}
|
|
{@const daytime = isDaytimeHour(date.getHours())}
|
|
{@const isMidnight = date.getHours() === 0}
|
|
<td class="hour-cell icon-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
|
|
<svg
|
|
class="fill-foreground {isNow ? 'scale-125' : ''}"
|
|
width="22px"
|
|
height="22px"
|
|
>
|
|
<use
|
|
xlink:href="/images/weather-icons/wi-{daytime
|
|
? 'day'
|
|
: 'night'}-{weatherCodes[wCode as keyof typeof weatherCodes] ??
|
|
'clear'}.svg#Layer_1"
|
|
></use>
|
|
</svg>
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
|
|
<!-- Temperature row -->
|
|
<tr>
|
|
<th class="row-header" scope="row">Temperature</th>
|
|
{#each dates as date, i (i)}
|
|
{@const temp = hourly.temperature_2m[i]}
|
|
{@const isNow = isCurrentHour(date)}
|
|
{@const isMidnight = date.getHours() === 0}
|
|
<td
|
|
class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
|
|
style="background: {getColor(
|
|
temp,
|
|
String(params.temperature_unit)
|
|
)}; color: {getTextColorForTemp(temp ?? 0, String(params.temperature_unit))}"
|
|
>
|
|
{temp?.toFixed(1) ?? '-'}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
|
|
<!-- Precipitation row -->
|
|
<tr>
|
|
<th class="row-header" scope="row">Precipitation</th>
|
|
{#each dates as date, i (i)}
|
|
{@const val = hourly.precipitation[i]}
|
|
{@const isNow = isCurrentHour(date)}
|
|
{@const isMidnight = date.getHours() === 0}
|
|
<td class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
|
|
{val?.toFixed(1) ?? '-'}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
|
|
<!-- Precipitation Probability row -->
|
|
<tr>
|
|
<th class="row-header" scope="row">Precip Prob.</th>
|
|
{#each dates as date, i (i)}
|
|
{@const prob = hourly.precipitation_probability[i]}
|
|
{@const isNow = isCurrentHour(date)}
|
|
{@const isMidnight = date.getHours() === 0}
|
|
<td
|
|
class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
|
|
style="background: {getPrecipProbBg(prob ?? 0)}; color: {getPrecipProbColor(
|
|
prob ?? 0
|
|
)}"
|
|
>
|
|
{prob?.toFixed(0) ?? '-'}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
|
|
<!-- Wind Speed row -->
|
|
<tr>
|
|
<th class="row-header" scope="row">Wind</th>
|
|
{#each dates as date, i (i)}
|
|
{@const wind = hourly.windspeed_10m[i]}
|
|
{@const isNow = isCurrentHour(date)}
|
|
{@const isMidnight = date.getHours() === 0}
|
|
<td class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
|
|
{wind?.toFixed(0) ?? '-'}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
|
|
<!-- Relative Humidity row -->
|
|
<tr>
|
|
<th class="row-header" scope="row">Rel. Hum.</th>
|
|
{#each dates as date, i (i)}
|
|
{@const hum = hourly.relative_humidity_2m[i]}
|
|
{@const isNow = isCurrentHour(date)}
|
|
{@const isMidnight = date.getHours() === 0}
|
|
<td
|
|
class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
|
|
style="background: {getHumidityBg(hum ?? 0)}"
|
|
>
|
|
{hum?.toFixed(0) ?? '-'}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
|
|
<!-- Wind Direction row -->
|
|
<tr>
|
|
<th class="row-header" scope="row">Wind Dir.</th>
|
|
{#each dates as date, i (i)}
|
|
{@const windDir = hourly.winddirection_10m[i]}
|
|
{@const isNow = isCurrentHour(date)}
|
|
{@const isMidnight = date.getHours() === 0}
|
|
<td class="hour-cell icon-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
|
|
{#if windDir != null && !isNaN(windDir)}
|
|
<div style="transform: rotate({windDir}deg); display: inline-block">
|
|
<svg class="fill-foreground" width="22px" height="22px">
|
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
|
</svg>
|
|
</div>
|
|
{:else}
|
|
-
|
|
{/if}
|
|
</td>
|
|
{/each}
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ─── Toolbar ───────────────────────────────────────────────────── -->
|
|
|
|
<div class="mt-6 md:mt-10">
|
|
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Sunrise / Sunset ──────────────────────────────────────────────── -->
|
|
{#if fetchedDaily}
|
|
{@const sunriseTs = fetchedDaily.daily.sunrise[selectedDayIndex]}
|
|
{@const sunsetTs = fetchedDaily.daily.sunset[selectedDayIndex]}
|
|
{#if sunriseTs && sunsetTs}
|
|
{@const sunrise = new Date(sunriseTs * 1000)}
|
|
{@const sunset = new Date(sunsetTs * 1000)}
|
|
<div class="mt-6">
|
|
<div class="flex items-center gap-1">
|
|
<svg class="fill-foreground" width="28px" height="28px">
|
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
|
|
</svg>Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())}
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<svg class="fill-foreground" width="28px" height="28px">
|
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
|
|
</svg>
|
|
Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- ─── Model Selector ────────────────────────────────────────────────── -->
|
|
<div>
|
|
<div class="mt-6 flex gap-6 md:mt-12">
|
|
<div class="relative w-1/2">
|
|
{#if params.models && params.models.length > 0}
|
|
{@const modelValue = params.models[0]}
|
|
<Select.Root
|
|
name="model_selection"
|
|
type="single"
|
|
value={modelValue}
|
|
onValueChange={(val) => {
|
|
if (params.models && val) {
|
|
params.models = [val];
|
|
}
|
|
}}
|
|
>
|
|
<Select.Trigger
|
|
aria-label="Forecast model selection"
|
|
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
|
|
>
|
|
<Select.Content preventScroll={false} class="border-border">
|
|
{#each models as mo (mo.value)}
|
|
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
|
{/each}
|
|
</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>
|
|
|
|
<style>
|
|
.weather-week-icon {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
background: #0061a5;
|
|
margin: 5px 0;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
/* ─── Zoom Reset Button ─────────────────────────────────────────────── */
|
|
|
|
.zoom-reset-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.25rem 0.625rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: hsl(var(--muted-foreground));
|
|
background: hsl(var(--muted) / 0.5);
|
|
border: 1px solid hsl(var(--border));
|
|
border-radius: var(--radius, 0.375rem);
|
|
cursor: pointer;
|
|
transition:
|
|
color 150ms ease,
|
|
background-color 150ms ease;
|
|
white-space: nowrap;
|
|
user-select: none;
|
|
}
|
|
|
|
.zoom-reset-btn:hover {
|
|
color: hsl(var(--foreground));
|
|
background: hsl(var(--muted));
|
|
}
|
|
|
|
/* ─── Hourly Data Table ─────────────────────────────────────────────── */
|
|
|
|
.hourly-table-scroll {
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.hourly-table {
|
|
border-collapse: collapse;
|
|
white-space: nowrap;
|
|
font-size: 12px;
|
|
border: 1px solid hsl(var(--border));
|
|
}
|
|
|
|
.hourly-table tr {
|
|
border-top: 1px solid hsl(var(--border));
|
|
}
|
|
|
|
.hourly-table tr:first-child {
|
|
border-top: none;
|
|
}
|
|
|
|
.row-header {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 10;
|
|
min-width: 90px;
|
|
max-width: 90px;
|
|
padding: 4px 8px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
font-size: 11px;
|
|
background: hsl(var(--background));
|
|
border-right: 2px solid hsl(var(--border));
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.hour-cell {
|
|
min-width: 36px;
|
|
max-width: 36px;
|
|
padding: 3px 2px;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
border-right: 1px solid hsl(var(--border) / 0.5);
|
|
}
|
|
|
|
.hour-cell.midnight {
|
|
border-left: 2px solid hsl(var(--border));
|
|
}
|
|
|
|
.hour-cell.now {
|
|
font-weight: bold;
|
|
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.5);
|
|
}
|
|
|
|
.icon-cell {
|
|
padding: 2px;
|
|
vertical-align: middle;
|
|
line-height: 0;
|
|
}
|
|
|
|
.icon-cell > div,
|
|
.icon-cell > svg {
|
|
display: inline-block;
|
|
}
|
|
</style>
|