From 0a59a1c845d6eeeb79ecc5b4a8087b8bb3af2b58 Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 16:35:54 +0100 Subject: [PATCH 01/12] migrate to apache echarts --- README.md | 2 +- package-lock.json | 39 +- package.json | 2 +- src/routes/+layout.server.ts | 1 + src/routes/+page.svelte | 2 +- src/routes/weather/+page.ts | 2 - src/routes/weather/14-day/+page.svelte | 445 ++++--- src/routes/weather/compare/+page.svelte | 423 ++++--- src/routes/weather/compare/echarts.css | 65 + src/routes/weather/compare/highcharts.css | 1172 ------------------- src/routes/weather/week/+page.ts | 2 - src/routes/weather/week/[location]/+page.ts | 2 - 12 files changed, 652 insertions(+), 1505 deletions(-) create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/weather/compare/echarts.css delete mode 100644 src/routes/weather/compare/highcharts.css diff --git a/README.md b/README.md index 806e050..c8993b0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Our objective is to provide a comprehensive, user-friendly weather platform for - **Framework**: [SvelteKit](https://kit.svelte.dev/) - **Language**: [TypeScript](https://www.typescriptlang.org/) - **Data Source**: [Open-Meteo API](https://open-meteo.com/) -- **Visualization**: [Highcharts](https://www.highcharts.com/) (Current, transitioning to a more flexible charting library in the future) +- **Visualization**: [Apache Echarts](https://echarts.apache.org) ## Developing diff --git a/package-lock.json b/package-lock.json index 0f088c0..f48e1cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "open-meteo-weather", "version": "0.0.1", "dependencies": { - "highcharts": "^12.4.0", + "echarts": "^6.0.0", "mode-watcher": "^1.1.0", "openmeteo": "^1.2.3" }, @@ -2594,6 +2594,22 @@ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "license": "MIT" }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -3063,12 +3079,6 @@ "node": ">=8" } }, - "node_modules/highcharts": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/highcharts/-/highcharts-12.4.0.tgz", - "integrity": "sha512-o6UxxfChSUrvrZUbWrAuqL1HO/+exhAUPcZY6nnqLsadZQlnP16d082sg7DnXKZCk1gtfkyfkp6g3qkIZ9miZg==", - "license": "https://www.highcharts.com/license" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4989,6 +4999,21 @@ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/package.json b/package.json index ee3d27d..f6dff6e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "vitest-browser-svelte": "^2.0.1" }, "dependencies": { - "highcharts": "^12.4.0", + "echarts": "^6.0.0", "mode-watcher": "^1.1.0", "openmeteo": "^1.2.3" } diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..189f71e --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1 @@ +export const prerender = true; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 57e6aad..af09426 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -209,7 +209,7 @@
- {#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Highcharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)} + {#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Apache ECharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)}
{ throw redirect(303, '/weather/week/'); }) satisfies PageLoad; diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index 85227ab..a245b7e 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -3,19 +3,20 @@ import { get } from 'svelte/store'; import { fade } from 'svelte/transition'; - import { dev } from '$app/environment'; + import * as echarts from 'echarts'; import { storedLocation } from '$lib/stores/settings'; import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; - import '../compare/highcharts.css'; + import '../compare/echarts.css'; import { defaultParameters } from './options'; let node: HTMLElement; - let chart: any; - let Highcharts = $state(null); + let charts: echarts.ECharts[] = []; + let resizeObservers: ResizeObserver[] = []; + let mounted = $state(false); let showLegend = $state(false); let averageOnly = $state(false); @@ -32,32 +33,48 @@ }); let count = $state(0); - onMount(async () => { - /// Highcharts needs to be loaded in `onMount` to work with prerendered SSG - Highcharts = (await import('highcharts')).default; - const more = (await import('highcharts/highcharts-more')).default; - // more(Highcharts); - if (dev) { - // const HighchartsDebugger = await import('highcharts/modules/debugger'); - // HighchartsDebugger.default(Highcharts); - const Debugger = ( - await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any) - ).default; - const ErrorMessages = ( - await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any) - ).default; - if (Highcharts) { - (Highcharts as any).errorMessages = ErrorMessages; - Debugger.compose(Highcharts.Chart); - } - } + function isDarkMode(): boolean { + if (typeof document === 'undefined') return false; + return ( + document.documentElement.classList.contains('dark') || + document.documentElement.getAttribute('data-theme') === 'dark' || + (window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches && + document.documentElement.getAttribute('data-theme') !== 'light') + ); + } + + function getTextColor(): string { + return isDarkMode() ? '#e5e7eb' : '#374151'; + } + + function getAxisLineColor(): string { + return isDarkMode() ? 'rgba(229, 231, 235, 0.3)' : 'rgba(55, 65, 81, 0.3)'; + } + + function getSplitLineColor(): string { + return isDarkMode() ? 'rgba(229, 231, 235, 0.1)' : 'rgba(55, 65, 81, 0.1)'; + } + + onMount(() => { + mounted = true; }); $effect(() => { const loadData = async () => { count = 0; - if (Highcharts) { + if (mounted) { + // Dispose existing charts and observers + resizeObservers.forEach((ro) => ro.disconnect()); + resizeObservers = []; + charts.forEach((chart) => { + if (chart) { + chart.dispose(); + } + }); + charts = []; + // eslint-disable-next-line svelte/no-dom-manipulating node.replaceChildren(); const dataDaily = await fetch( @@ -70,41 +87,60 @@ ); const data = await dataReq.json(); - let plotBands: any = []; + // Create day/night plot bands as markArea data + let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> = + []; if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) { let rise = wd.daily.sunrise; let set = wd.daily.sunset; - plotBands = rise.map(function (r: any, i: number) { - return { - color: 'rgba(255, 255, 194, 0.5)', - from: (r + data.utc_offset_seconds) * 1000, - to: (set[i] + data.utc_offset_seconds) * 1000 - }; + markAreas = rise.map(function (r: number, i: number) { + return [ + { + xAxis: (r + data.utc_offset_seconds) * 1000, + itemStyle: { + color: 'rgba(255, 255, 194, 0.3)' + } + }, + { + xAxis: (set[i] + data.utc_offset_seconds) * 1000 + } + ]; }); } let minValues = new Array(data.hourly.time.length).fill(undefined); let maxValues = new Array(data.hourly.time.length).fill(undefined); + const textColor = getTextColor(); + const axisLineColor = getAxisLineColor(); + const splitLineColor = getSplitLineColor(); + for (let variable of params.hourly || []) { const chartDiv = document.createElement('div'); + chartDiv.style.width = '100%'; + chartDiv.style.height = showLegend ? '400px' : '300px'; - let unit; + // Append to DOM BEFORE echarts.init so it can measure dimensions + // eslint-disable-next-line svelte/no-dom-manipulating + node.appendChild(chartDiv); - let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000; - let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000; + let unit: string = ''; - const series = []; + const series: Array> = []; let average = new Array(data.hourly.time.length).fill(0); let averageCount = new Array(data.hourly.time.length).fill(0); + const timestamps = data.hourly.time.map( + (t: number) => (t + data.utc_offset_seconds) * 1000 + ); + for (let [model, values] of Object.entries(data.hourly)) { if (model === 'time') { continue; } if (model.startsWith(variable)) { - for (let [index, val] of (values as any[]).entries()) { - if (val) { + for (let [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined) { let avVal = average[index]; average[index] = avVal + val; averageCount[index]++; @@ -122,145 +158,231 @@ } } + // Calculate average for (let [index, val] of average.entries()) { average[index] = Math.round((val / averageCount[index]) * 10) / 10; } - const minMax = []; - for (let [index, min] of minValues.entries()) { - minMax.push([min, maxValues[index]]); - } + // Create min-max area data + const spreadData: Array<[number, number, number]> = minValues.map( + (min: number, index: number) => [timestamps[index], min, maxValues[index]] + ); + const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'; + + // Add spread series (lower bound) series.push({ - name: 'temperature_2m_spread', - data: minMax, - type: 'arearange', - tooltip: { - valueSuffix: ' ' + unit + name: variable + '_spread', + type: 'line', + data: spreadData.map((d) => [d[0], d[1]]), + areaStyle: { + color: 'rgba(173, 216, 230, 0.3)', + origin: 'auto' }, - pointStart: hourly_starttime, - pointInterval: pointInterval, - className: 'highcharts-spread-series' + lineStyle: { + width: 0 + }, + showSymbol: false, + stack: 'spread', + smooth: true, + z: 1 }); + // Add spread series (upper bound delta) + series.push({ + name: variable + '_spread_max', + type: 'line', + data: spreadData.map((d) => [d[0], d[2] - d[1]]), + areaStyle: { + color: 'rgba(173, 216, 230, 0.3)', + origin: 'auto' + }, + lineStyle: { + width: 0 + }, + showSymbol: false, + stack: 'spread', + smooth: true, + z: 1 + }); + + // Add average line + const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]); + series.push({ name: variable + '_average', - data: average, - dashStyle: 'ShortDashDot', - color: '#5e5e5e', - type: - unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' - ? 'column' - : 'spline', - tooltip: { - valueSuffix: ' ' + unit + type: isColumn ? 'bar' : 'line', + data: averageData, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + type: 'dashed', + width: 4, + color: '#5e5e5e' }, - lineWidth: 4, - states: { - hover: { - lineWidth: 6 + itemStyle: { + color: '#5e5e5e' + }, + emphasis: { + lineStyle: { + width: 6 } }, - pointStart: hourly_starttime, - pointInterval: pointInterval, - className: 'highcharts-average-series' + barMaxWidth: 5, + z: 10 }); - new Highcharts!.Chart({ - chart: { - renderTo: chartDiv, - height: showLegend ? '400px' : '300px', - styledMode: true, - marginLeft: 50, - marginRight: 0 - }, - - credits: { - text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '', - href: 'http://open-meteo.com' - }, - - lang: { - locale: 'en-GB' - }, - - title: { - text: count === 0 ? 'Model Spread' : '', - align: 'left' - }, - - subtitle: { - text: - count === 0 - ? `Compare ${params.hourly?.join(', ') || ''} in models: ` + - (params.models?.join(', ') || '') + - '' - : '', - align: 'left' - }, - - yAxis: { - title: { - text: unit - } - }, - - xAxis: { - type: 'datetime', - plotLines: [ + // Add current time markLine via a helper series + series.push({ + name: 'Current Time', + type: 'line', + data: [], + markLine: { + silent: true, + symbol: 'none', + data: [ { - value: Date.now() + data.utc_offset_seconds * 1000, - color: 'red', - width: 2 - } - ], - plotBands: plotBands - }, - - plotOptions: { - spline: { - lineWidth: 2, - states: { - hover: { - lineWidth: 3 - } - }, - marker: { - enabled: false - } - }, - column: { - pointWidth: 5 - } - }, - - legend: { - enabled: showLegend, - layout: 'horizontal', - align: 'center', - verticalAlign: 'bottom' - }, - - series: series as any, - - responsive: { - rules: [ - { - condition: { - maxWidth: 800 + xAxis: Date.now() + data.utc_offset_seconds * 1000, + lineStyle: { + color: 'red', + width: 2 + }, + label: { + show: false } } ] - }, - - tooltip: { - shared: true, - animation: false } }); + // Add day/night bands via markArea + if (markAreas.length > 0) { + series.push({ + name: 'Daylight', + type: 'line', + data: [], + markArea: { + silent: true, + data: markAreas + } + }); + } + + const option: Record = { + title: { + text: count === 0 ? 'Model Spread' : '', + left: 'left', + textStyle: { + fontWeight: 'normal', + color: textColor + }, + ...(count === 0 + ? { + subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`, + subtextStyle: { + fontWeight: 'normal', + color: textColor + } + } + : {}) + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + animation: false + }, + valueFormatter: (value: number) => { + if (value === null || value === undefined) return '-'; + return value.toFixed(1) + ' ' + unit; + } + }, + legend: { + show: showLegend, + bottom: 0, + type: 'scroll', + data: [variable + '_average'], + textStyle: { + color: textColor + } + }, + grid: { + left: 60, + right: 10, + top: count === 0 ? 80 : 40, + bottom: showLegend ? 60 : 40 + }, + xAxis: { + type: 'time', + splitLine: { + show: false + }, + axisLine: { + lineStyle: { + color: axisLineColor + } + }, + axisLabel: { + color: textColor + } + }, + yAxis: { + type: 'value', + name: unit, + nameTextStyle: { + color: textColor + }, + axisLine: { + show: false + }, + axisLabel: { + color: textColor + }, + splitLine: { + lineStyle: { + color: splitLineColor + } + } + }, + series: series, + textStyle: { + color: textColor + } + }; + + // Add credits for last chart + if (count === (params.hourly?.length || 0) - 1) { + option.graphic = [ + { + type: 'text', + right: 10, + bottom: 5, + style: { + text: 'Open-Meteo.com', + fontSize: 10, + fill: textColor, + opacity: 0.5 + }, + onclick: function () { + window.open('https://open-meteo.com', '_blank'); + }, + cursor: 'pointer' + } + ]; + } + + const chart = echarts.init(chartDiv, null, { renderer: 'canvas' }); + charts.push(chart); + chart.setOption(option); + + // Handle responsive resize + const resizeObserver = new ResizeObserver(() => { + chart.resize(); + }); + resizeObserver.observe(chartDiv); + resizeObservers.push(resizeObserver); + count++; - node.appendChild(chartDiv); } } }; @@ -268,22 +390,25 @@ }); onDestroy(() => { - if (chart) { - chart.destroy(); - } + resizeObservers.forEach((ro) => ro.disconnect()); + resizeObservers = []; + charts.forEach((chart) => { + chart.dispose(); + }); + charts = []; });
- +
- +
diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index 25406ca..a8ea7cd 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -3,7 +3,7 @@ import { get } from 'svelte/store'; import { fade } from 'svelte/transition'; - import { dev } from '$app/environment'; + import * as echarts from 'echarts'; import { storedLocation } from '$lib/stores/settings'; @@ -12,15 +12,16 @@ import { Switch } from '$lib/components/ui/switch'; import { hourly, models as modelsFlat } from '../options'; - import './highcharts.css'; + import './echarts.css'; import { defaultParameters } from './options'; // Wrap models in array to match template expectation of nested arrays like hourly const models = [modelsFlat]; let node: HTMLElement; - let chart: any; - let Highcharts = $state(null); + let charts: echarts.ECharts[] = []; + let resizeObservers: ResizeObserver[] = []; + let mounted = $state(false); let showLegend = $state(false); let averageOnly = $state(false); @@ -42,30 +43,48 @@ }); let count = $state(0); - onMount(async () => { - /// Highcharts needs to be loaded in `onMount` to work with prerendered SSG - Highcharts = (await import('highcharts')).default; - if (dev) { - // const HighchartsDebugger = await import('highcharts/modules/debugger'); - // HighchartsDebugger.default(Highcharts); - const Debugger = ( - await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any) - ).default; - const ErrorMessages = ( - await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any) - ).default; - if (Highcharts) { - (Highcharts as any).errorMessages = ErrorMessages; - Debugger.compose(Highcharts.Chart); - } - } + function isDarkMode(): boolean { + if (typeof document === 'undefined') return false; + return ( + document.documentElement.classList.contains('dark') || + document.documentElement.getAttribute('data-theme') === 'dark' || + (window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches && + document.documentElement.getAttribute('data-theme') !== 'light') + ); + } + + function getTextColor(): string { + return isDarkMode() ? '#e5e7eb' : '#374151'; + } + + function getAxisLineColor(): string { + return isDarkMode() ? 'rgba(229, 231, 235, 0.3)' : 'rgba(55, 65, 81, 0.3)'; + } + + function getSplitLineColor(): string { + return isDarkMode() ? 'rgba(229, 231, 235, 0.1)' : 'rgba(55, 65, 81, 0.1)'; + } + + onMount(() => { + mounted = true; }); $effect(() => { const loadData = async () => { count = 0; - if (Highcharts) { + if (mounted) { + // Dispose existing charts and observers + resizeObservers.forEach((ro) => ro.disconnect()); + resizeObservers = []; + charts.forEach((chart) => { + if (chart) { + chart.dispose(); + } + }); + charts = []; + // eslint-disable-next-line svelte/no-dom-manipulating node.replaceChildren(); const dataReq = await fetch( @@ -77,7 +96,9 @@ dailyFirstModelKey.shift(); dailyFirstModelKey = dailyFirstModelKey.join('_'); - let plotBands: any = []; + // Create day/night plot bands as markArea data + let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> = + []; if ( 'daily' in data && 'sunrise_' + dailyFirstModelKey in data.daily && @@ -85,34 +106,51 @@ ) { let rise = data.daily['sunrise_' + dailyFirstModelKey]; let set = data.daily['sunset_' + dailyFirstModelKey]; - plotBands = rise.map(function (r: any, i: number) { - return { - color: 'rgba(255, 255, 194, 0.5)', - from: (r + data.utc_offset_seconds) * 1000, - to: (set[i] + data.utc_offset_seconds) * 1000 - }; + markAreas = rise.map(function (r: number, i: number) { + return [ + { + xAxis: (r + data.utc_offset_seconds) * 1000, + itemStyle: { + color: 'rgba(255, 255, 194, 0.3)' + } + }, + { + xAxis: (set[i] + data.utc_offset_seconds) * 1000 + } + ]; }); } + const textColor = getTextColor(); + const axisLineColor = getAxisLineColor(); + const splitLineColor = getSplitLineColor(); + for (let variable of params.hourly || []) { const chartDiv = document.createElement('div'); + chartDiv.style.width = '100%'; + chartDiv.style.height = showLegend ? '400px' : '300px'; - let unit; + // Append to DOM BEFORE echarts.init so it can measure dimensions + // eslint-disable-next-line svelte/no-dom-manipulating + node.appendChild(chartDiv); - let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000; - let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000; + let unit: string = ''; - const series = []; + const series: Array> = []; let average = new Array(data.hourly.time.length).fill(0); let averageCount = new Array(data.hourly.time.length).fill(0); + const timestamps = data.hourly.time.map( + (t: number) => (t + data.utc_offset_seconds) * 1000 + ); + for (let [model, values] of Object.entries(data.hourly)) { if (model === 'time') { continue; } if (model.startsWith(variable)) { - for (let [index, val] of (values as any[]).entries()) { - if (val) { + for (let [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined) { let avVal = average[index]; average[index] = avVal + val; averageCount[index]++; @@ -122,145 +160,213 @@ unit = data.hourly_units[model]; if (!averageOnly) { + const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'; + + const seriesData = (values as (number | null)[]).map( + (val: number | null, idx: number) => [timestamps[idx], val] + ); + series.push({ name: model, - data: values, - type: - unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' - ? 'column' - : 'spline', - tooltip: { - valueSuffix: ' ' + unit + type: isColumn ? 'bar' : 'line', + data: seriesData, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + width: 2 }, - pointStart: hourly_starttime, - pointInterval: pointInterval + emphasis: { + lineStyle: { + width: 3 + } + }, + barMaxWidth: 5 }); } } } + // Calculate average for (let [index, val] of average.entries()) { average[index] = Math.round((val / averageCount[index]) * 10) / 10; } + const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'; + const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]); + series.push({ name: variable + '_average', - data: average, - dashStyle: 'ShortDashDot', - color: '#5e5e5e', - type: - unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' - ? 'column' - : 'spline', - tooltip: { - valueSuffix: ' ' + unit + type: isColumn ? 'bar' : 'line', + data: averageData, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + type: 'dashed', + width: 4, + color: '#5e5e5e' }, - lineWidth: 4, - states: { - hover: { - lineWidth: 6 + itemStyle: { + color: '#5e5e5e' + }, + emphasis: { + lineStyle: { + width: 6 } }, - pointStart: hourly_starttime, - pointInterval: pointInterval, - className: 'highcharts-average-series' + barMaxWidth: 5, + z: 10 }); - new Highcharts!.Chart({ - chart: { - renderTo: chartDiv, - height: showLegend ? '400px' : '300px', - styledMode: true, - marginLeft: 50, - marginRight: 0 - }, - - credits: { - text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '', - href: 'http://open-meteo.com' - }, - - lang: { - locale: 'en-GB' - }, - - title: { - text: count === 0 ? 'Model Compare' : '', - align: 'left' - }, - - subtitle: { - text: - count === 0 - ? `Compare ${params.hourly?.join(', ') || ''} in models: ` + - (params.models?.join(', ') || '') + - '' - : '', - align: 'left' - }, - - yAxis: { - title: { - text: unit - } - }, - - xAxis: { - type: 'datetime', - plotLines: [ + // Add current time markLine via a helper series + series.push({ + name: 'Current Time', + type: 'line', + data: [], + markLine: { + silent: true, + symbol: 'none', + data: [ { - value: Date.now() + data.utc_offset_seconds * 1000, - color: 'red', - width: 2 - } - ], - plotBands: plotBands - }, - - plotOptions: { - spline: { - lineWidth: 2, - states: { - hover: { - lineWidth: 3 - } - }, - marker: { - enabled: false - } - }, - column: { - pointWidth: 5 - } - }, - - legend: { - enabled: showLegend, - layout: 'horizontal', - align: 'center', - verticalAlign: 'bottom' - }, - - series: series as any, - - responsive: { - rules: [ - { - condition: { - maxWidth: 800 + xAxis: Date.now() + data.utc_offset_seconds * 1000, + lineStyle: { + color: 'red', + width: 2 + }, + label: { + show: false } } ] - }, - - tooltip: { - shared: true, - animation: false } }); + // Add day/night bands via markArea + if (markAreas.length > 0) { + series.push({ + name: 'Daylight', + type: 'line', + data: [], + markArea: { + silent: true, + data: markAreas + } + }); + } + + const option: Record = { + title: { + text: count === 0 ? 'Model Compare' : '', + left: 'left', + textStyle: { + fontWeight: 'normal', + color: textColor + }, + ...(count === 0 + ? { + subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`, + subtextStyle: { + fontWeight: 'normal', + color: textColor + } + } + : {}) + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + animation: false + }, + valueFormatter: (value: number) => { + if (value === null || value === undefined) return '-'; + return value.toFixed(1) + ' ' + unit; + } + }, + legend: { + show: showLegend, + bottom: 0, + type: 'scroll', + textStyle: { + color: textColor + } + }, + grid: { + left: 60, + right: 10, + top: count === 0 ? 80 : 40, + bottom: showLegend ? 60 : 40 + }, + xAxis: { + type: 'time', + splitLine: { + show: false + }, + axisLine: { + lineStyle: { + color: axisLineColor + } + }, + axisLabel: { + color: textColor + } + }, + yAxis: { + type: 'value', + name: unit, + nameTextStyle: { + color: textColor + }, + axisLine: { + show: false + }, + axisLabel: { + color: textColor + }, + splitLine: { + lineStyle: { + color: splitLineColor + } + } + }, + series: series, + textStyle: { + color: textColor + } + }; + + // Add credits for last chart + if (count === (params.hourly?.length || 0) - 1) { + option.graphic = [ + { + type: 'text', + right: 10, + bottom: 5, + style: { + text: 'Open-Meteo.com', + fontSize: 10, + fill: textColor, + opacity: 0.5 + }, + onclick: function () { + window.open('https://open-meteo.com', '_blank'); + }, + cursor: 'pointer' + } + ]; + } + + const chart = echarts.init(chartDiv, null, { renderer: 'canvas' }); + charts.push(chart); + chart.setOption(option); + + // Handle responsive resize + const resizeObserver = new ResizeObserver(() => { + chart.resize(); + }); + resizeObserver.observe(chartDiv); + resizeObservers.push(resizeObserver); + count++; - node.appendChild(chartDiv); } } }; @@ -268,22 +374,25 @@ }); onDestroy(() => { - if (chart) { - chart.destroy(); - } + resizeObservers.forEach((ro) => ro.disconnect()); + resizeObservers = []; + charts.forEach((chart) => { + chart.dispose(); + }); + charts = []; });
- +
- +
@@ -334,7 +443,7 @@

Models

{#if params.models && params.models.length > 0} -
+
@@ -387,7 +496,7 @@ {#if params.hourly && params.hourly.length > 0} -
+
diff --git a/src/routes/weather/compare/echarts.css b/src/routes/weather/compare/echarts.css new file mode 100644 index 0000000..cc685c0 --- /dev/null +++ b/src/routes/weather/compare/echarts.css @@ -0,0 +1,65 @@ +/* ECharts theme integration for Open-Meteo Weather */ + +/* Container styling */ +.echarts-container { + width: 100%; + height: 100%; + min-height: 300px; +} + +/* Ensure ECharts respects current color scheme */ +[data-theme='light'] .echarts-container, +:root:not([data-theme='dark']) .echarts-container { + color: hsl(var(--foreground)); +} + +/* Tooltip styling to match application theme */ +.echarts-tooltip { + background: hsl(var(--popover)) !important; + border: 1px solid hsl(var(--border)) !important; + border-radius: var(--radius) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1) !important; + padding: 0.75rem !important; +} + +.echarts-tooltip-content { + color: hsl(var(--popover-foreground)) !important; +} + +/* Ensure text is readable in both themes */ +.echarts-container text { + fill: currentColor !important; +} + +/* Chart background */ +.echarts-container canvas { + background: transparent !important; +} + +/* Loading state */ +.echarts-loading-mask { + background: hsl(var(--background) / 0.8) !important; +} + +/* Color palette for series */ +:root { + --echarts-color-0: #5470c6; + --echarts-color-1: #91cc75; + --echarts-color-2: #fac858; + --echarts-color-3: #ee6666; + --echarts-color-4: #73c0de; + --echarts-color-5: #3ba272; + --echarts-color-6: #fc8452; + --echarts-color-7: #9a60b4; + --echarts-color-8: #ea7ccc; + --echarts-color-9: #5470c6; +} + +/* Responsive sizing */ +@media (max-width: 768px) { + .echarts-container { + min-height: 250px; + } +} diff --git a/src/routes/weather/compare/highcharts.css b/src/routes/weather/compare/highcharts.css deleted file mode 100644 index 3463cb2..0000000 --- a/src/routes/weather/compare/highcharts.css +++ /dev/null @@ -1,1172 +0,0 @@ -:root, -.highcharts-light { - --highcharts-color-0: #2caffe; - --highcharts-color-1: #544fc5; - --highcharts-color-2: #00e272; - --highcharts-color-3: #fe6a35; - --highcharts-color-4: #6b8abc; - --highcharts-color-5: #d568fb; - --highcharts-color-6: #2ee0ca; - --highcharts-color-7: #fa4b42; - --highcharts-color-8: #feb56a; - --highcharts-color-9: #91e8e1; - --highcharts-background-color: #ffffff; - --highcharts-tooltip-color: #f6f6f6; - --highcharts-neutral-color-100: #000000; - --highcharts-neutral-color-80: #333333; - --highcharts-neutral-color-60: #666666; - --highcharts-neutral-color-40: #999999; - --highcharts-neutral-color-20: #cccccc; - --highcharts-neutral-color-10: #e6e6e6; - --highcharts-neutral-color-5: #f2f2f2; - --highcharts-neutral-color-3: #f7f7f7; - --highcharts-highlight-color-100: #0022ff; - --highcharts-highlight-color-80: #334eff; - --highcharts-highlight-color-60: #667aff; - --highcharts-highlight-color-20: #ccd3ff; - --highcharts-highlight-color-10: #e6e9ff; - --highcharts-positive-color: #06b535; - --highcharts-negative-color: #f21313; -} - -@media (prefers-color-scheme: dark) { - :root { - --highcharts-background-color: rgb(2, 8, 23); - --highcharts-neutral-color-100: rgb(255, 255, 255); - --highcharts-neutral-color-80: rgb(248, 250, 252); - --highcharts-neutral-color-60: rgb(173, 173, 173); - --highcharts-neutral-color-40: rgb(133, 133, 133); - --highcharts-neutral-color-20: rgb(92, 92, 92); - --highcharts-neutral-color-10: rgb(71, 71, 71); - --highcharts-neutral-color-5: rgb(61, 61, 61); - --highcharts-neutral-color-3: rgb(57, 57, 57); - --highcharts-highlight-color-100: rgb(122, 167, 255); - --highcharts-highlight-color-80: rgb(108, 144, 214); - --highcharts-highlight-color-60: rgb(94, 121, 173); - --highcharts-highlight-color-20: rgb(65, 74, 92); - --highcharts-highlight-color-10: rgb(58, 63, 71); - } -} - -.highcharts-dark { - /*--highcharts-background-color: rgb(48, 48, 48);*/ - --highcharts-background-color: rgb(2, 8, 23); - --highcharts-tooltip-color: rgb(57, 64, 70); - --highcharts-neutral-color-100: rgb(255, 255, 255); - --highcharts-neutral-color-80: rgb(214, 214, 214); - --highcharts-neutral-color-60: rgb(173, 173, 173); - --highcharts-neutral-color-40: rgb(133, 133, 133); - --highcharts-neutral-color-20: rgb(92, 92, 92); - --highcharts-neutral-color-10: rgb(71, 71, 71); - --highcharts-neutral-color-5: rgb(61, 61, 61); - --highcharts-neutral-color-3: rgb(57, 57, 57); - --highcharts-highlight-color-100: rgb(122, 167, 255); - --highcharts-highlight-color-80: rgb(108, 144, 214); - --highcharts-highlight-color-60: rgb(94, 121, 173); - --highcharts-highlight-color-20: rgb(65, 74, 92); - --highcharts-highlight-color-10: rgb(58, 63, 71); -} - -.highcharts-container { - position: relative; - overflow: hidden; - width: 100%; - height: 100%; - text-align: left; - line-height: normal; - z-index: 0; - -webkit-tap-highlight-color: transparent; - font-family: Helvetica, Arial, sans-serif; - font-size: 1rem; - user-select: none; - touch-action: manipulation; - outline: none; -} - -.highcharts-root { - display: block; -} - -.highcharts-root text { - stroke-width: 0; -} - -.highcharts-strong { - font-weight: 700; -} - -.highcharts-emphasized { - font-style: italic; -} - -.highcharts-anchor { - cursor: pointer; -} - -.highcharts-background { - fill: var(--highcharts-background-color); -} - -.highcharts-plot-border, -.highcharts-plot-background { - fill: none; -} - -.highcharts-label-box { - fill: none; -} - -.highcharts-label text { - fill: var(--highcharts-neutral-color-80); - font-size: 0.8em; -} - -.highcharts-button-box { - fill: inherit; -} - -.highcharts-tracker-line { - stroke-linejoin: round; - stroke: rgba(192, 192, 192, 0.0001); - stroke-width: 22; - fill: none; -} - -.highcharts-tracker-area { - fill: rgba(192, 192, 192, 0.0001); - stroke-width: 0; -} - -.highcharts-title { - fill: var(--highcharts-neutral-color-80); - font-size: 1.2em; - font-weight: 700; -} - -.highcharts-subtitle { - fill: var(--highcharts-neutral-color-60); - font-size: 0.8em; -} - -.highcharts-axis-line { - fill: none; - stroke: var(--highcharts-neutral-color-80); -} - -.highcharts-yaxis .highcharts-axis-line { - stroke-width: 0; -} - -.highcharts-axis-title { - fill: var(--highcharts-neutral-color-60); - font-size: 0.8em; -} - -.highcharts-axis-labels { - fill: var(--highcharts-neutral-color-80); - cursor: default; - font-size: 0.65em; -} - -.highcharts-grid-line { - fill: none; - stroke: var(--highcharts-neutral-color-10); -} - -.highcharts-xaxis-grid .highcharts-grid-line { - stroke-width: 0; -} - -.highcharts-tick { - stroke: var(--highcharts-neutral-color-80); -} - -.highcharts-yaxis .highcharts-tick { - stroke-width: 0; -} - -.highcharts-minor-grid-line { - stroke: var(--highcharts-neutral-color-5); -} - -.highcharts-crosshair-thin { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-crosshair-category { - stroke: var(--highcharts-highlight-color-20); - stroke-opacity: 0.25; -} - -.highcharts-credits { - cursor: pointer; - fill: var(--highcharts-neutral-color-40); - font-size: 0.6em; - transition: - fill 250ms, - font-size 250ms; -} - -.highcharts-credits:hover { - fill: var(--highcharts-neutral-color-100); - font-size: 0.7em; -} - -.highcharts-tooltip { - cursor: default; - pointer-events: none; - white-space: nowrap; - transition: stroke 150ms; -} - -.highcharts-tooltip text { - fill: var(--highcharts-neutral-color-80); - font-size: 0.8em; -} - -.highcharts-tooltip .highcharts-header { - font-size: 0.8em; -} - -.highcharts-tooltip-box { - stroke-width: 1px; - fill: var(--highcharts-tooltip-color); -} - -.highcharts-tooltip-box { - stroke-width: 0; - fill: var(--highcharts-tooltip-color); -} - -.highcharts-tooltip-box .highcharts-label-box { - fill: var(--highcharts-tooltip-color); -} - -div.highcharts-tooltip { - filter: none; - font-size: 0.8em; -} - -.highcharts-selection-marker { - fill: var(--highcharts-highlight-color-80); - fill-opacity: 0.25; -} - -.highcharts-graph { - fill: none; - stroke-width: 1.5px; - stroke-linecap: round; - stroke-linejoin: round; -} - -.highcharts-empty-series { - stroke-width: 1px; - fill: none; - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-state-hover .highcharts-graph { - stroke-width: 3; -} - -.highcharts-point-inactive { - opacity: 0.2; - transition: opacity 50ms; -} - -.highcharts-series-inactive { - opacity: 0.2; - transition: opacity 50ms; -} - -.highcharts-state-hover path { - transition: stroke-width 50ms; -} - -.highcharts-state-normal path { - transition: stroke-width 250ms; -} - -g.highcharts-series, -.highcharts-point, -.highcharts-markers, -.highcharts-data-labels { - transition: opacity 250ms; -} - -.highcharts-legend-series-active g.highcharts-series:not(.highcharts-series-hover), -.highcharts-legend-point-active .highcharts-point:not(.highcharts-point-hover), -.highcharts-legend-series-active .highcharts-markers:not(.highcharts-series-hover), -.highcharts-legend-series-active .highcharts-data-labels:not(.highcharts-series-hover) { - opacity: 0.2; -} - -.highcharts-color-0 { - fill: var(--highcharts-color-0); - stroke: var(--highcharts-color-0); -} - -.highcharts-color-1 { - fill: var(--highcharts-color-1); - stroke: var(--highcharts-color-1); -} - -.highcharts-color-2 { - fill: var(--highcharts-color-2); - stroke: var(--highcharts-color-2); -} - -.highcharts-color-3 { - fill: var(--highcharts-color-3); - stroke: var(--highcharts-color-3); -} - -.highcharts-color-4 { - fill: var(--highcharts-color-4); - stroke: var(--highcharts-color-4); -} - -.highcharts-color-5 { - fill: var(--highcharts-color-5); - stroke: var(--highcharts-color-5); -} - -.highcharts-color-6 { - fill: var(--highcharts-color-6); - stroke: var(--highcharts-color-6); -} - -.highcharts-color-7 { - fill: var(--highcharts-color-7); - stroke: var(--highcharts-color-7); -} - -.highcharts-color-8 { - fill: var(--highcharts-color-8); - stroke: var(--highcharts-color-8); -} - -.highcharts-color-9 { - fill: var(--highcharts-color-9); - stroke: var(--highcharts-color-9); -} - -.highcharts-area { - fill-opacity: 0.75; - stroke-width: 0; -} - -.highcharts-markers { - stroke-width: 1px; - stroke: var(--highcharts-background-color); -} - -.highcharts-a11y-markers-hidden - .highcharts-point:not(.highcharts-point-hover, .highcharts-a11y-marker-visible), -.highcharts-a11y-marker-hidden { - opacity: 0; -} - -.highcharts-point { - stroke-width: 1px; -} - -.highcharts-dense-data .highcharts-point { - stroke-width: 0; -} - -.highcharts-data-label text, -text.highcharts-data-label { - font-size: 0.7em; - font-weight: 700; -} - -.highcharts-data-label-box { - fill: none; - stroke-width: 0; -} - -.highcharts-data-label text, -text.highcharts-data-label { - fill: var(--highcharts-neutral-color-80); -} - -.highcharts-data-label-connector { - fill: none; -} - -.highcharts-data-label-hidden { - pointer-events: none; -} - -.highcharts-halo { - fill-opacity: 0.25; - stroke-width: 0; -} - -.highcharts-series-label text { - fill: inherit; - font-weight: 700; -} - -.highcharts-series:not(.highcharts-pie-series) .highcharts-point-select, -.highcharts-markers .highcharts-point-select { - fill: var(--highcharts-neutral-color-20); - stroke: var(--highcharts-neutral-color-100); -} - -.highcharts-column-series rect.highcharts-point { - stroke: var(--highcharts-background-color); -} - -.highcharts-column-series .highcharts-point { - transition: fill-opacity 250ms; -} - -.highcharts-column-series .highcharts-point-hover { - fill-opacity: 0.75; - transition: fill-opacity 50ms; -} - -.highcharts-pie-series .highcharts-point { - stroke-linejoin: round; - stroke: var(--highcharts-background-color); -} - -.highcharts-pie-series .highcharts-point-hover { - fill-opacity: 0.75; - transition: fill-opacity 50ms; -} - -.highcharts-funnel-series .highcharts-point { - stroke-linejoin: round; - stroke: var(--highcharts-background-color); -} - -.highcharts-funnel-series .highcharts-point-hover { - fill-opacity: 0.75; - transition: fill-opacity 50ms; -} - -.highcharts-funnel-series .highcharts-point-select { - fill: inherit; - stroke: inherit; -} - -.highcharts-pyramid-series .highcharts-point { - stroke-linejoin: round; - stroke: var(--highcharts-background-color); -} - -.highcharts-pyramid-series .highcharts-point-hover { - fill-opacity: 0.75; - transition: fill-opacity 50ms; -} - -.highcharts-pyramid-series .highcharts-point-select { - fill: inherit; - stroke: inherit; -} - -.highcharts-solidgauge-series .highcharts-point { - stroke-width: 0; -} - -.highcharts-treemap-series .highcharts-point { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-10); - transition: - stroke 250ms, - fill 250ms, - fill-opacity 250ms; -} - -.highcharts-treemap-series .highcharts-point-hover { - stroke: var(--highcharts-neutral-color-40); - transition: - stroke 25ms, - fill 25ms, - fill-opacity 25ms; -} - -.highcharts-treemap-series .highcharts-above-level { - display: none; -} - -.highcharts-treemap-series .highcharts-internal-node { - fill: none; -} - -.highcharts-treemap-series .highcharts-internal-node-interactive { - fill-opacity: 0.15; - cursor: pointer; -} - -.highcharts-treemap-series .highcharts-internal-node-interactive:hover { - fill-opacity: 0.75; -} - -.highcharts-vector-series .highcharts-point { - fill: none; - stroke-width: 2px; -} - -.highcharts-windbarb-series .highcharts-point { - fill: none; - stroke-width: 2px; -} - -.highcharts-lollipop-stem { - stroke: var(--highcharts-neutral-color-100); -} - -.highcharts-focus-border { - fill: none; - stroke-width: 2px; -} - -.highcharts-legend-item-hidden .highcharts-focus-border { - fill: none !important; -} - -.highcharts-legend-box { - fill: none; - stroke-width: 0; -} - -.highcharts-legend-item > text { - fill: var(--highcharts-neutral-color-80); - font-weight: 700; - font-size: 0.8em; - cursor: pointer; - stroke-width: 0; -} - -.highcharts-legend-item:hover text { - fill: var(--highcharts-neutral-color-100); -} - -.highcharts-legend-item-hidden * { - fill: var(--highcharts-neutral-color-60) !important; - stroke: var(--highcharts-neutral-color-60) !important; - transition: fill 250ms; - text-decoration: line-through; -} - -.highcharts-legend-nav-active { - fill: var(--highcharts-highlight-color-100); - cursor: pointer; -} - -.highcharts-legend-nav-inactive { - fill: var(--highcharts-neutral-color-20); -} - -circle.highcharts-legend-nav-active, -circle.highcharts-legend-nav-inactive { - fill: rgba(192, 192, 192, 0.0001); -} - -.highcharts-legend-title-box { - fill: none; - stroke-width: 0; -} - -.highcharts-bubble-legend-symbol { - stroke-width: 2; - fill-opacity: 0.5; -} - -.highcharts-bubble-legend-connectors { - stroke-width: 1; -} - -.highcharts-bubble-legend-labels { - fill: var(--highcharts-neutral-color-80); - font-size: 0.7em; -} - -.highcharts-loading { - position: absolute; - background-color: var(--highcharts-background-color); - opacity: 0.5; - text-align: center; - z-index: 10; - transition: opacity 250ms; -} - -.highcharts-loading-hidden { - height: 0 !important; - opacity: 0; - overflow: hidden; - transition: - opacity 250ms, - height 250ms step-end; -} - -.highcharts-loading-inner { - font-weight: 700; - position: relative; - top: 45%; -} - -.highcharts-plot-band, -.highcharts-pane { - fill: rgba(255, 255, 194, 0.5); -} - -.highcharts-dark .highcharts-plot-band, -.highcharts-dark .highcharts-pane { - fill: rgba(255, 255, 194, 0.1); -} - -.highcharts-plot-line { - fill: none; - stroke: var(--highcharts-neutral-color-40); - stroke-width: 1px; -} - -.highcharts-plot-line-label { - font-size: 0.8em; -} - -.highcharts-boxplot-box { - fill: var(--highcharts-background-color); -} - -.highcharts-boxplot-median { - stroke-width: 2px; -} - -.highcharts-bubble-series .highcharts-point { - fill-opacity: 0.5; -} - -.highcharts-errorbar-series .highcharts-point { - stroke: var(--highcharts-neutral-color-100); -} - -.highcharts-gauge-series .highcharts-data-label-box { - stroke: var(--highcharts-neutral-color-20); - stroke-width: 1px; -} - -.highcharts-gauge-series .highcharts-dial { - fill: var(--highcharts-neutral-color-100); - stroke-width: 0; -} - -.highcharts-polygon-series .highcharts-graph { - fill: inherit; - stroke-width: 0; -} - -.highcharts-waterfall-series .highcharts-graph { - stroke: var(--highcharts-neutral-color-80); - stroke-dasharray: 1, 3; -} - -.highcharts-sankey-series .highcharts-point { - stroke-width: 0; -} - -.highcharts-sankey-series .highcharts-link { - transition: - fill 250ms, - fill-opacity 250ms; - fill-opacity: 0.5; -} - -.highcharts-sankey-series .highcharts-point-hover.highcharts-link { - transition: - fill 50ms, - fill-opacity 50ms; - fill-opacity: 1; -} - -.highcharts-venn-series .highcharts-point { - fill-opacity: 0.75; - stroke: var(--highcharts-neutral-color-20); - transition: - stroke 250ms, - fill-opacity 250ms; -} - -.highcharts-venn-series .highcharts-point-hover { - fill-opacity: 1; - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-timeline-series .highcharts-graph { - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-navigator-mask-outside { - fill-opacity: 0; -} - -.highcharts-navigator-mask-inside { - fill: var(--highcharts-highlight-color-60); - fill-opacity: 0.25; - cursor: ew-resize; -} - -.highcharts-navigator-outline { - stroke: var(--highcharts-neutral-color-40); - fill: none; -} - -.highcharts-navigator-handle { - stroke: var(--highcharts-neutral-color-40); - fill: var(--highcharts-neutral-color-5); - cursor: ew-resize; -} - -.highcharts-navigator-series { - fill: var(--highcharts-highlight-color-80); - stroke: var(--highcharts-highlight-color-80); -} - -.highcharts-navigator-series .highcharts-graph { - stroke-width: 1px; -} - -.highcharts-navigator-series .highcharts-area { - fill-opacity: 0.05; -} - -.highcharts-navigator-xaxis .highcharts-axis-line { - stroke-width: 0; -} - -.highcharts-navigator-xaxis .highcharts-grid-line { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-10); -} - -.highcharts-navigator-xaxis.highcharts-axis-labels { - fill: var(--highcharts-neutral-color-100); - font-size: 0.7em; - opacity: 0.6; -} - -.highcharts-navigator-yaxis .highcharts-grid-line { - stroke-width: 0; -} - -.highcharts-scrollbar-thumb { - fill: var(--highcharts-neutral-color-20); - stroke: var(--highcharts-neutral-color-20); - stroke-width: 0; -} - -.highcharts-scrollbar-button { - fill: var(--highcharts-neutral-color-10); - stroke: var(--highcharts-neutral-color-20); - stroke-width: 1px; -} - -.highcharts-scrollbar-arrow { - fill: var(--highcharts-neutral-color-60); -} - -.highcharts-scrollbar-rifles { - stroke: none; - stroke-width: 1px; -} - -.highcharts-scrollbar-track { - fill: rgba(255, 255, 255, 0.001); - stroke: var(--highcharts-neutral-color-20); - stroke-width: 1px; -} - -.highcharts-button { - fill: var(--highcharts-neutral-color-3); - stroke: var(--highcharts-neutral-color-20); - cursor: default; - stroke-width: 1px; - transition: fill 250ms; -} - -.highcharts-button text { - fill: var(--highcharts-neutral-color-80); - font-size: 0.8em; -} - -.highcharts-button-hover { - transition: fill 0ms; - fill: var(--highcharts-neutral-color-10); - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-button-hover text { - fill: var(--highcharts-neutral-color-80); -} - -.highcharts-button-pressed { - font-weight: 700; - fill: var(--highcharts-highlight-color-10); - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-button-pressed text { - fill: var(--highcharts-neutral-color-80); - font-weight: 700; -} - -.highcharts-button-disabled text { - fill: var(--highcharts-neutral-color-80); -} - -.highcharts-range-selector-buttons .highcharts-button { - stroke-width: 0; -} - -.highcharts-range-label rect { - fill: none; -} - -.highcharts-range-label text { - fill: var(--highcharts-neutral-color-60); -} - -.highcharts-range-input rect { - fill: none; -} - -.highcharts-range-input text { - fill: var(--highcharts-neutral-color-80); - font-size: 0.8em; -} - -.highcharts-range-input { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-20); -} - -input.highcharts-range-selector { - position: absolute; - border: 0; - width: 1px; - height: 1px; - padding: 0; - text-align: center; - left: -9em; -} - -.highcharts-crosshair-label text { - fill: var(--highcharts-background-color); - font-size: 1.7em; -} - -.highcharts-crosshair-label .highcharts-label-box { - fill: inherit; -} - -.highcharts-candlestick-series .highcharts-point { - stroke: var(--highcharts-neutral-color-100); - stroke-width: 1px; -} - -.highcharts-candlestick-series .highcharts-point-up { - fill: var(--highcharts-background-color); -} - -.highcharts-hollowcandlestick-series .highcharts-point-down { - fill: var(--highcharts-negative-color); - stroke: var(--highcharts-negative-color); -} - -.highcharts-hollowcandlestick-series .highcharts-point-down-bearish-up { - fill: var(--highcharts-positive-color); - stroke: var(--highcharts-positive-color); -} - -.highcharts-hollowcandlestick-series .highcharts-point-up { - fill: transparent; - stroke: var(--highcharts-positive-color); -} - -.highcharts-ohlc-series .highcharts-point-hover { - stroke-width: 3px; -} - -.highcharts-flags-series .highcharts-point .highcharts-label-box { - stroke: var(--highcharts-neutral-color-40); - fill: var(--highcharts-background-color); - transition: fill 250ms; -} - -.highcharts-flags-series .highcharts-point-hover .highcharts-label-box { - stroke: var(--highcharts-neutral-color-100); - fill: var(--highcharts-highlight-color-20); -} - -.highcharts-flags-series .highcharts-point text { - fill: var(--highcharts-neutral-color-100); - font-size: 0.9em; - font-weight: 700; -} - -.highcharts-map-series .highcharts-point { - transition: - fill 500ms, - fill-opacity 500ms, - stroke-width 250ms; - stroke: var(--highcharts-neutral-color-20); - stroke-width: inherit; -} - -.highcharts-map-series .highcharts-point-hover { - transition: - fill 0ms, - fill-opacity 0ms; - fill-opacity: 0.5; -} - -.highcharts-mapline-series .highcharts-point { - fill: none; -} - -.highcharts-heatmap-series .highcharts-point { - stroke-width: 0; -} - -.highcharts-map-navigation { - font-size: 1.3em; - font-weight: 700; - text-align: center; -} - -.highcharts-map-navigation.highcharts-button { - fill: var(--highcharts-background-color); - stroke: var(--highcharts-neutral-color-10); -} - -.highcharts-map-navigation.highcharts-button:hover { - fill: var(--highcharts-neutral-color-10); -} - -.highcharts-map-navigation.highcharts-button .highcharts-button-symbol { - stroke-width: 2px; -} - -.highcharts-mapview-inset-border { - stroke: var(--highcharts-neutral-color-20); - stroke-width: 1px; - fill: none; -} - -.highcharts-coloraxis { - stroke-width: 0; -} - -.highcharts-coloraxis-marker { - fill: var(--highcharts-neutral-color-40); -} - -.highcharts-null-point { - fill: var(--highcharts-neutral-color-3); -} - -.highcharts-3d-frame { - fill: transparent; -} - -.highcharts-contextbutton { - fill: var(--highcharts-background-color); - stroke: none; - stroke-linecap: round; -} - -.highcharts-contextbutton:hover { - fill: var(--highcharts-neutral-color-10); - stroke: var(--highcharts-neutral-color-10); -} - -.highcharts-button-symbol { - stroke: var(--highcharts-neutral-color-60); - stroke-width: 3px; -} - -.highcharts-menu { - border: none; - background: var(--highcharts-background-color); - border-radius: 3px; - padding: 0.5em; - box-shadow: 3px 3px 10px #888; -} - -.highcharts-menu-item { - background: 0 0; - border-radius: 3px; - color: var(--highcharts-neutral-color-80); - cursor: pointer; - font-size: 0.8em; - list-style-type: none; - padding: 0.5em; - transition: - background 250ms, - color 250ms; -} - -.highcharts-menu-item:hover { - background: var(--highcharts-neutral-color-5); -} - -.highcharts-breadcrumbs-button { - fill: none; - stroke-width: 0; - cursor: pointer; -} - -.highcharts-breadcrumbs-separator { - fill: var(--highcharts-neutral-color-60); -} - -.highcharts-drilldown-point { - cursor: pointer; -} - -.highcharts-drilldown-data-label text, -text.highcharts-drilldown-data-label, -.highcharts-drilldown-axis-label { - cursor: pointer; - fill: var(--highcharts-highlight-color-100); - font-weight: 700; - text-decoration: underline; -} - -.highcharts-no-data text { - font-weight: 700; - font-size: 0.8em; - fill: var(--highcharts-neutral-color-60); -} - -.highcharts-axis-resizer { - cursor: ns-resize; - stroke: var(--highcharts-neutral-color-100); - stroke-width: 2px; -} - -.highcharts-bullet-target { - stroke-width: 0; -} - -.highcharts-lineargauge-target { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-80); -} - -.highcharts-lineargauge-target-line { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-80); -} - -.highcharts-annotation-label-box { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-100); - fill: var(--highcharts-neutral-color-100); - fill-opacity: 0.75; -} - -.highcharts-annotation-label text { - fill: var(--highcharts-neutral-color-10); - font-size: 0.8em; -} - -.highcharts-a11y-proxy-button { - border-width: 0; - background-color: transparent; - cursor: pointer; - outline: none; - opacity: 0.001; - z-index: 999; - overflow: hidden; - padding: 0; - margin: 0; - display: block; - position: absolute; -} - -.highcharts-a11y-proxy-group li { - list-style: none; -} - -.highcharts-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - white-space: nowrap; - clip: rect(1px, 1px, 1px, 1px); - margin-top: -3px; - opacity: 0.01; -} - -.highcharts-a11y-invisible { - visibility: hidden; -} - -.highcharts-a11y-proxy-container, -.highcharts-a11y-proxy-container-before, -.highcharts-a11y-proxy-container-after { - position: absolute; - white-space: nowrap; -} - -g.highcharts-series, -.highcharts-markers, -.highcharts-point { - outline: none; -} - -.highcharts-treegrid-node-collapsed, -.highcharts-treegrid-node-expanded { - cursor: pointer; -} - -.highcharts-point-connecting-path { - fill: none; -} - -.highcharts-grid-axis .highcharts-tick { - stroke-width: 1px; -} - -.highcharts-grid-axis .highcharts-axis-line { - stroke-width: 1px; -} - -.highcharts-average-series { - fill: rgb(110, 110, 110); - stroke: rgb(110, 110, 110); -} - -.highcharts-average-series .highcharts-graph { - stroke-width: 4; - stroke-dasharray: 3, 8, 1, 8, 3; - stroke-linecap: square; - stroke-dashoffset:; -} - -.highcharts-dark .highcharts-average-series { - fill: rgb(200, 200, 200); - stroke: rgb(200, 200, 200); -} - -.highcharts-spread-series { - fill: rgb(236, 190, 41); - stroke: rgb(236, 190, 41); -} - -.highcharts-spread-series .highcharts-graph { - stroke-width: 2; -} - -.highcharts-dark .highcharts-spread-series { - fill: rgb(201, 162, 35); - stroke: rgb(201, 162, 35); -} diff --git a/src/routes/weather/week/+page.ts b/src/routes/weather/week/+page.ts index 0963c36..c891c68 100644 --- a/src/routes/weather/week/+page.ts +++ b/src/routes/weather/week/+page.ts @@ -8,8 +8,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo'; import type { PageLoad } from './$types'; -export const prerender = true; - export const load = (async () => { const location = get(storedLocation); const locationRoute = geoLocationNameToRoute(location.name); diff --git a/src/routes/weather/week/[location]/+page.ts b/src/routes/weather/week/[location]/+page.ts index e321db1..efabba4 100644 --- a/src/routes/weather/week/[location]/+page.ts +++ b/src/routes/weather/week/[location]/+page.ts @@ -6,8 +6,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo'; import type { PageLoad } from './$types'; -export const prerender = true; - export const load: PageLoad = async (event) => { const urlLocation = event.params.location; let urlLocationSplit, urlLocationName, urlLocationId; -- 2.54.0 From e12add9ac338a6457b16a5f0f4375b9238fa2de0 Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 18:35:18 +0100 Subject: [PATCH 02/12] WIP: refactoring --- .../components/charts/ChartContainer.svelte | 118 ++++ src/lib/components/charts/ChartToolbar.svelte | 278 ++++++++ src/lib/components/charts/EChart.svelte | 165 +++++ src/lib/components/charts/echarts.css | 164 +++++ src/lib/components/charts/index.ts | 12 + src/lib/utils/echarts/download.ts | 134 ++++ src/lib/utils/echarts/index.ts | 72 ++ src/lib/utils/echarts/options.ts | 392 +++++++++++ src/lib/utils/echarts/series.ts | 364 +++++++++++ src/lib/utils/echarts/theme.ts | 106 +++ src/routes/weather/14-day/+page.svelte | 617 ++++++------------ src/routes/weather/14-day/options.ts | 7 - src/routes/weather/compare/+page.svelte | 579 ++++++---------- src/routes/weather/compare/echarts.css | 65 -- src/routes/weather/compare/options.ts | 7 - 15 files changed, 2203 insertions(+), 877 deletions(-) create mode 100644 src/lib/components/charts/ChartContainer.svelte create mode 100644 src/lib/components/charts/ChartToolbar.svelte create mode 100644 src/lib/components/charts/EChart.svelte create mode 100644 src/lib/components/charts/echarts.css create mode 100644 src/lib/components/charts/index.ts create mode 100644 src/lib/utils/echarts/download.ts create mode 100644 src/lib/utils/echarts/index.ts create mode 100644 src/lib/utils/echarts/options.ts create mode 100644 src/lib/utils/echarts/series.ts create mode 100644 src/lib/utils/echarts/theme.ts delete mode 100644 src/routes/weather/14-day/options.ts delete mode 100644 src/routes/weather/compare/echarts.css delete mode 100644 src/routes/weather/compare/options.ts diff --git a/src/lib/components/charts/ChartContainer.svelte b/src/lib/components/charts/ChartContainer.svelte new file mode 100644 index 0000000..c7fa6ec --- /dev/null +++ b/src/lib/components/charts/ChartContainer.svelte @@ -0,0 +1,118 @@ + + + +
+ +
+ {#if children} + {@render children()} + {/if} +
+ + +
+
+ + Loading charts... +
+
+
+ + diff --git a/src/lib/components/charts/ChartToolbar.svelte b/src/lib/components/charts/ChartToolbar.svelte new file mode 100644 index 0000000..46d8a5e --- /dev/null +++ b/src/lib/components/charts/ChartToolbar.svelte @@ -0,0 +1,278 @@ + + + +
+ +
+ {#if controls} + {@render controls()} + {/if} +
+ + +
+ + + + + + + + {#if showDownloadAll && hasMultipleCharts} + + + {/if} +
+
+ + diff --git a/src/lib/components/charts/EChart.svelte b/src/lib/components/charts/EChart.svelte new file mode 100644 index 0000000..e56575c --- /dev/null +++ b/src/lib/components/charts/EChart.svelte @@ -0,0 +1,165 @@ + + + +
+ + diff --git a/src/lib/components/charts/echarts.css b/src/lib/components/charts/echarts.css new file mode 100644 index 0000000..1832367 --- /dev/null +++ b/src/lib/components/charts/echarts.css @@ -0,0 +1,164 @@ +/* ═══════════════════════════════════════════════════════════════════════════════ + ECharts Global Styles — Open-Meteo Weather + + Shared CSS for all ECharts chart instances across the application. + Provides consistent theming, tooltip styling, and responsive behavior + that integrates with the application's design system (Tailwind + shadcn). + ═══════════════════════════════════════════════════════════════════════════════ */ + +/* ─── Chart Wrapper ────────────────────────────────────────────────────────── */ + +.echart-wrapper { + width: 100%; + position: relative; + overflow: hidden; +} + +/* ─── Chart Container (legacy class support) ───────────────────────────────── */ + +.echarts-container { + width: 100%; + height: 100%; + min-height: 300px; +} + +/* Ensure canvas background is always transparent so our page bg shows through */ +.echart-wrapper canvas, +.echarts-container canvas { + background: transparent !important; +} + +/* ─── Tooltip Styling ──────────────────────────────────────────────────────── */ + +/* Override ECharts' default tooltip to match the application's popover design */ +.echarts-tooltip { + background: hsl(var(--popover)) !important; + border: 1px solid hsl(var(--border)) !important; + border-radius: var(--radius, 0.5rem) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.05) !important; + padding: 0.625rem 0.75rem !important; + font-size: 0.8125rem !important; + line-height: 1.4 !important; + max-width: min(90vw, 480px) !important; + pointer-events: none; +} + +.echarts-tooltip-content { + color: hsl(var(--popover-foreground)) !important; +} + +/* Tooltip marker dots — make them slightly larger and rounded */ +.echarts-tooltip .echarts-tooltip-marker, +.echarts-tooltip span[style*="border-radius"] { + display: inline-block; + vertical-align: middle; + margin-right: 0.25rem; +} + +/* ─── Loading Mask ─────────────────────────────────────────────────────────── */ + +.echarts-loading-mask { + background: hsl(var(--background) / 0.8) !important; +} + +/* ─── Light Mode Adjustments ───────────────────────────────────────────────── */ + +[data-theme='light'] .echart-wrapper, +[data-theme='light'] .echarts-container, +:root:not(.dark):not([data-theme='dark']) .echart-wrapper, +:root:not(.dark):not([data-theme='dark']) .echarts-container { + color: hsl(var(--foreground)); +} + +[data-theme='light'] .echarts-tooltip, +:root:not(.dark):not([data-theme='dark']) .echarts-tooltip { + color: hsl(var(--popover-foreground)) !important; +} + +/* ─── Dark Mode Adjustments ────────────────────────────────────────────────── */ + +.dark .echart-wrapper, +[data-theme='dark'] .echart-wrapper, +.dark .echarts-container, +[data-theme='dark'] .echarts-container { + color: hsl(var(--foreground)); +} + +.dark .echarts-tooltip, +[data-theme='dark'] .echarts-tooltip { + color: hsl(var(--popover-foreground)) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.3), + 0 2px 4px -2px rgb(0 0 0 / 0.15) !important; +} + +/* ─── Toolbox Icon Overrides ───────────────────────────────────────────────── */ + +/* Make sure the toolbox icons are visually subtle until hovered */ +.echart-wrapper [class*="toolbox"], +.echarts-container [class*="toolbox"] { + opacity: 0.6; + transition: opacity 150ms ease; +} + +.echart-wrapper:hover [class*="toolbox"], +.echarts-container:hover [class*="toolbox"] { + opacity: 1; +} + +/* ─── Chart Spacing ────────────────────────────────────────────────────────── */ + +/* Add consistent vertical spacing between stacked chart instances */ +.chart-content .echart-wrapper + .echart-wrapper { + margin-top: 0.5rem; +} + +/* ─── Responsive Sizing ────────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .echarts-container { + min-height: 240px; + } + + /* Slightly smaller tooltips on mobile */ + .echarts-tooltip { + font-size: 0.75rem !important; + padding: 0.5rem 0.625rem !important; + } +} + +@media (min-width: 641px) and (max-width: 1024px) { + .echarts-container { + min-height: 280px; + } +} + +/* ─── Print Styles ─────────────────────────────────────────────────────────── */ + +@media print { + .echart-wrapper, + .echarts-container { + break-inside: avoid; + page-break-inside: avoid; + } + + /* Hide interactive elements when printing */ + .echart-wrapper [class*="toolbox"], + .echarts-container [class*="toolbox"], + .chart-toolbar { + display: none !important; + } +} + +/* ─── Accessibility ────────────────────────────────────────────────────────── */ + +/* Respect reduced motion preferences */ +@media (prefers-reduced-motion: reduce) { + .echart-wrapper *, + .echarts-container * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/lib/components/charts/index.ts b/src/lib/components/charts/index.ts new file mode 100644 index 0000000..c67aa73 --- /dev/null +++ b/src/lib/components/charts/index.ts @@ -0,0 +1,12 @@ +/** + * Chart Components — Barrel Export + * + * Re-exports all chart-related Svelte components from a single entry point. + * + * Usage: + * import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts'; + */ + +export { default as EChart } from './EChart.svelte'; +export { default as ChartContainer } from './ChartContainer.svelte'; +export { default as ChartToolbar } from './ChartToolbar.svelte'; diff --git a/src/lib/utils/echarts/download.ts b/src/lib/utils/echarts/download.ts new file mode 100644 index 0000000..86bba24 --- /dev/null +++ b/src/lib/utils/echarts/download.ts @@ -0,0 +1,134 @@ +/** + * ECharts Download Utilities + * + * Provides programmatic chart export functionality for downloading + * charts as PNG or SVG images. These utilities wrap ECharts' built-in + * export capabilities with a convenient API and sensible defaults. + */ + +import type * as echarts from 'echarts'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ExportFormat = 'png' | 'svg'; + +export interface DownloadOptions { + /** The file name (without extension) */ + fileName?: string; + /** Export format: 'png' or 'svg' */ + format?: ExportFormat; + /** Pixel ratio for PNG exports (default: 2 for retina quality) */ + pixelRatio?: number; + /** Background color (default: '#ffffff' for PNG, 'none' for SVG) */ + backgroundColor?: string; + /** Components to exclude from the export (e.g. ['toolbox']) */ + excludeComponents?: string[]; +} + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +const DEFAULT_FILE_NAME = 'open-meteo-chart'; +const DEFAULT_PIXEL_RATIO = 2; + +// ─── Download Functions ────────────────────────────────────────────────────── + +/** + * Downloads a single ECharts instance as an image file. + * + * @param chart - The ECharts instance to export + * @param options - Download configuration options + */ +export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = {}): void { + const { + fileName = DEFAULT_FILE_NAME, + format = 'png', + pixelRatio = DEFAULT_PIXEL_RATIO, + backgroundColor, + excludeComponents = ['toolbox'] + } = options; + + const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); + + // Use ECharts' getDataURL for PNG, getConnectedDataURL for SVG + const dataUrl = chart.getDataURL({ + type: format === 'svg' ? 'svg' : 'png', + pixelRatio: format === 'png' ? pixelRatio : 1, + backgroundColor: resolvedBg, + excludeComponents + }); + + triggerDownload(dataUrl, `${fileName}.${format}`); +} + +/** + * Downloads all provided ECharts instances as separate image files. + * Each file is named with an incrementing suffix (e.g. chart-1.png, chart-2.png). + * + * @param charts - Array of ECharts instances to export + * @param options - Download configuration options (fileName is used as prefix) + */ +export function downloadAllCharts( + charts: echarts.ECharts[], + options: DownloadOptions = {} +): void { + const { fileName = DEFAULT_FILE_NAME, ...rest } = options; + + charts.forEach((chart, index) => { + if (chart && !chart.isDisposed()) { + downloadChart(chart, { + ...rest, + fileName: charts.length === 1 ? fileName : `${fileName}-${index + 1}` + }); + } + }); +} + +/** + * Returns the data URL of a chart without triggering a download. + * Useful for previewing or embedding chart images programmatically. + * + * @param chart - The ECharts instance to export + * @param options - Export configuration options + * @returns A base64-encoded data URL string + */ +export function getChartDataUrl( + chart: echarts.ECharts, + options: DownloadOptions = {} +): string { + const { + format = 'png', + pixelRatio = DEFAULT_PIXEL_RATIO, + backgroundColor, + excludeComponents = ['toolbox'] + } = options; + + const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); + + return chart.getDataURL({ + type: format === 'svg' ? 'svg' : 'png', + pixelRatio: format === 'png' ? pixelRatio : 1, + backgroundColor: resolvedBg, + excludeComponents + }); +} + +// ─── Internal Helpers ──────────────────────────────────────────────────────── + +/** + * Triggers a browser file download from a data URL. + * Creates a temporary anchor element, clicks it, and removes it. + */ +function triggerDownload(dataUrl: string, fileName: string): void { + const link = document.createElement('a'); + link.href = dataUrl; + link.download = fileName; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + + // Clean up the DOM after a brief delay to ensure the download starts + requestAnimationFrame(() => { + document.body.removeChild(link); + }); +} diff --git a/src/lib/utils/echarts/index.ts b/src/lib/utils/echarts/index.ts new file mode 100644 index 0000000..c036a1b --- /dev/null +++ b/src/lib/utils/echarts/index.ts @@ -0,0 +1,72 @@ +/** + * ECharts Utilities — Barrel Export + * + * Re-exports all ECharts-related utilities from a single entry point. + * + * Usage: + * import { getThemeColors, composeChartOption, buildModelSeries, downloadChart } from '$lib/utils/echarts'; + */ + +// Theme: dark/light detection, color palettes, theme color accessors +export { + SERIES_COLORS, + CHART_COLORS, + isDarkMode, + getThemeColors, + getTextColor, + getAxisLineColor, + getSplitLineColor +} from './theme'; +export type { ThemeColors } from './theme'; + +// Option builders: grid, title, tooltip, legend, axes, toolbox, full composer +export { + buildGrid, + buildTitle, + buildTooltip, + buildLegend, + buildTimeXAxis, + buildValueYAxis, + buildCreditGraphic, + buildToolbox, + composeChartOption, + isColumnUnit +} from './options'; +export type { + GridOptions, + TitleOptions, + LegendOptions, + TooltipOptions, + AxisOptions, + CreditOptions, + BuildGridParams, + ToolboxOptions, + ChartOptionParams +} from './options'; + +// Series builders: model lines, averages, time markers, daylight bands, ensemble spread +export { + buildModelSeries, + buildAverageSeries, + buildCurrentTimeSeries, + buildDaylightMarkAreas, + buildDaylightSeries, + buildSpreadSeries, + calculateAverage, + calculateSpread, + convertTimestamps, + findUnit +} from './series'; +export type { + ModelSeriesParams, + AverageSeriesParams, + CurrentTimeSeriesParams, + DaylightSeriesParams, + SpreadSeriesParams, + AverageResult, + SpreadResult +} from './series'; + +// Download: export charts as PNG or SVG +export { downloadChart, downloadAllCharts, getChartDataUrl } from './download'; +export type { ExportFormat, DownloadOptions } from './download'; diff --git a/src/lib/utils/echarts/options.ts b/src/lib/utils/echarts/options.ts new file mode 100644 index 0000000..bd02ace --- /dev/null +++ b/src/lib/utils/echarts/options.ts @@ -0,0 +1,392 @@ +/** + * ECharts Option Builders + * + * Shared factory functions for constructing common ECharts option fragments. + * These builders ensure visual consistency across all chart pages and reduce + * boilerplate in page-level components. + */ +import { getThemeColors } from './theme'; + +import type { ThemeColors } from './theme'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface GridOptions { + left?: number; + right?: number; + top?: number; + bottom?: number; +} + +export interface TitleOptions { + text: string; + subtext?: string; +} + +export interface LegendOptions { + show: boolean; + data?: string[]; +} + +export interface TooltipOptions { + unit: string; +} + +export interface AxisOptions { + unit?: string; +} + +export interface CreditOptions { + show: boolean; +} + +// ─── Default Constants ─────────────────────────────────────────────────────── + +const DEFAULT_GRID: GridOptions = { + left: 60, + right: 16, + top: 40, + bottom: 40 +}; + +const GRID_WITH_TITLE: Partial = { + top: 80 +}; + +const GRID_WITH_LEGEND: Partial = { + bottom: 60 +}; + +// ─── Grid ──────────────────────────────────────────────────────────────────── + +export interface BuildGridParams { + hasTitle?: boolean; + hasSubtitle?: boolean; + showLegend?: boolean; + overrides?: Partial; +} + +/** + * Builds a grid configuration with sensible defaults. + * Automatically adjusts top/bottom spacing for title and legend presence. + */ +export function buildGrid(params: BuildGridParams = {}): GridOptions { + const { hasTitle = false, hasSubtitle = false, showLegend = false, overrides } = params; + + return { + ...DEFAULT_GRID, + ...(hasTitle ? GRID_WITH_TITLE : {}), + ...(hasSubtitle ? { top: 90 } : {}), + ...(showLegend ? GRID_WITH_LEGEND : {}), + ...overrides + }; +} + +// ─── Title ─────────────────────────────────────────────────────────────────── + +/** + * Builds a title configuration. Pass `null` to hide the title. + */ +export function buildTitle( + options: TitleOptions | null, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + + if (!options) { + return { title: { show: false } }; + } + + const result: Record = { + text: options.text, + left: 'left', + textStyle: { + fontWeight: 'normal', + fontSize: 16, + color: c.text + } + }; + + if (options.subtext) { + result.subtext = options.subtext; + result.subtextStyle = { + fontWeight: 'normal', + fontSize: 12, + color: c.textMuted + }; + } + + return result; +} + +// ─── Tooltip ───────────────────────────────────────────────────────────────── + +/** + * Builds a tooltip with cross-axis pointer and unit-aware value formatting. + */ +export function buildTooltip( + options: TooltipOptions, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + const { unit } = options; + + return { + trigger: 'axis', + axisPointer: { + type: 'cross', + animation: false, + label: { + backgroundColor: c.tooltipBg, + color: c.text, + borderColor: c.tooltipBorder, + borderWidth: 1 + } + }, + backgroundColor: c.tooltipBg, + borderColor: c.tooltipBorder, + textStyle: { + color: c.text + }, + valueFormatter: (value: number) => { + if (value === null || value === undefined) return '-'; + return value.toFixed(1) + ' ' + unit; + } + }; +} + +// ─── Legend ─────────────────────────────────────────────────────────────────── + +/** + * Builds a scrollable legend configuration pinned to the bottom. + */ +export function buildLegend(options: LegendOptions, colors?: ThemeColors): Record { + const c = colors ?? getThemeColors(); + + return { + show: options.show, + bottom: 0, + type: 'scroll', + ...(options.data ? { data: options.data } : {}), + textStyle: { + color: c.text + }, + pageTextStyle: { + color: c.text + } + }; +} + +// ─── X Axis (Time) ─────────────────────────────────────────────────────────── + +/** + * Builds a time-based X axis with theme-aware styling. + */ +export function buildTimeXAxis(colors?: ThemeColors): Record { + const c = colors ?? getThemeColors(); + + return { + type: 'time', + splitLine: { + show: false + }, + axisLine: { + lineStyle: { + color: c.axisLine + } + }, + axisLabel: { + color: c.text, + hideOverlap: true + }, + axisTick: { + lineStyle: { + color: c.axisLine + } + } + }; +} + +// ─── Y Axis (Value) ───────────────────────────────────────────────────────── + +/** + * Builds a value-based Y axis with optional unit label. + */ +export function buildValueYAxis( + options: AxisOptions = {}, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + + return { + type: 'value', + ...(options.unit ? { name: options.unit } : {}), + nameTextStyle: { + color: c.text, + padding: [0, 0, 0, 4] + }, + axisLine: { + show: false + }, + axisLabel: { + color: c.text + }, + splitLine: { + lineStyle: { + color: c.splitLine + } + } + }; +} + +// ─── Credit Watermark ──────────────────────────────────────────────────────── + +/** + * Builds the Open-Meteo.com credit watermark graphic element. + */ +export function buildCreditGraphic(colors?: ThemeColors): Record[] { + const c = colors ?? getThemeColors(); + + return [ + { + type: 'text', + right: 10, + bottom: 5, + style: { + text: 'Open-Meteo.com', + fontSize: 10, + fill: c.text, + opacity: 0.4 + }, + onclick: function () { + window.open('https://open-meteo.com', '_blank'); + }, + cursor: 'pointer' + } + ]; +} + +// ─── Toolbox (Download) ───────────────────────────────────────────────────── + +export interface ToolboxOptions { + /** Show the save-as-image button */ + saveAsImage?: boolean; + /** File name prefix for downloaded images */ + fileName?: string; + /** Export format: 'png' or 'svg' */ + format?: 'png' | 'svg'; +} + +/** + * Builds the ECharts toolbox with download functionality. + */ +export function buildToolbox( + options: ToolboxOptions = {}, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + const { saveAsImage = true, fileName = 'open-meteo-chart', format = 'png' } = options; + + return { + show: true, + right: 16, + top: 4, + iconStyle: { + borderColor: c.textMuted + }, + emphasis: { + iconStyle: { + borderColor: c.text + } + }, + feature: { + ...(saveAsImage + ? { + saveAsImage: { + type: format, + name: fileName, + title: format === 'svg' ? 'Save as SVG' : 'Save as PNG', + pixelRatio: 2, + backgroundColor: 'transparent', + excludeComponents: ['toolbox'], + iconStyle: { + borderColor: c.textMuted + }, + emphasis: { + iconStyle: { + borderColor: c.text + } + } + } + } + : {}) + } + }; +} + +// ─── Full Option Composer ──────────────────────────────────────────────────── + +export interface ChartOptionParams { + title?: TitleOptions | null; + tooltip: TooltipOptions; + legend?: LegendOptions; + grid?: BuildGridParams; + yAxis?: AxisOptions; + series: Array>; + toolbox?: ToolboxOptions | false; + showCredit?: boolean; + colors?: ThemeColors; +} + +/** + * Composes a complete ECharts option object from individual builder params. + * This is the primary entry point for building chart options — it calls all + * the individual builders and merges the results into a single config object. + */ +export function composeChartOption(params: ChartOptionParams): Record { + const colors = params.colors ?? getThemeColors(); + const hasTitle = params.title != null && params.title.text !== ''; + const showLegend = params.legend?.show ?? false; + + const option: Record = { + title: buildTitle(params.title ?? null, colors), + tooltip: buildTooltip(params.tooltip, colors), + legend: buildLegend(params.legend ?? { show: false }, colors), + grid: buildGrid({ + ...params.grid, + hasTitle, + hasSubtitle: hasTitle && !!params.title?.subtext, + showLegend + }), + xAxis: buildTimeXAxis(colors), + yAxis: buildValueYAxis(params.yAxis, colors), + series: params.series, + textStyle: { + color: colors.text + } + }; + + // Add toolbox unless explicitly disabled + if (params.toolbox !== false) { + option.toolbox = buildToolbox(params.toolbox ?? {}, colors); + } + + // Add credit watermark + if (params.showCredit) { + option.graphic = buildCreditGraphic(colors); + } + + return option; +} + +// ─── Utility: Detect column-type variables ─────────────────────────────────── + +/** Units that should be rendered as bar/column charts instead of lines. */ +const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']); + +/** + * Returns true if the given unit should be rendered as a bar chart. + */ +export function isColumnUnit(unit: string): boolean { + return COLUMN_UNITS.has(unit); +} diff --git a/src/lib/utils/echarts/series.ts b/src/lib/utils/echarts/series.ts new file mode 100644 index 0000000..9a84b71 --- /dev/null +++ b/src/lib/utils/echarts/series.ts @@ -0,0 +1,364 @@ +/** + * ECharts Series Builders + * + * Factory functions for constructing common series patterns used across + * weather chart visualizations. These builders encapsulate the styling + * and configuration details so page-level code only needs to provide data. + */ + +import { CHART_COLORS } from './theme'; +import { isColumnUnit } from './options'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ModelSeriesParams { + /** The series name (typically the model key from the API response) */ + name: string; + /** Array of [timestamp, value] data points */ + data: Array<[number, number | null]>; + /** The unit string, used to determine bar vs line rendering */ + unit: string; + /** Optional line width override (default: 2) */ + lineWidth?: number; +} + +export interface AverageSeriesParams { + /** The variable name, used to construct the series name */ + variable: string; + /** Array of [timestamp, value] data points */ + data: Array<[number, number]>; + /** The unit string, used to determine bar vs line rendering */ + unit: string; +} + +export interface CurrentTimeSeriesParams { + /** UTC offset in seconds from the API response */ + utcOffsetSeconds: number; +} + +export interface DaylightSeriesParams { + /** Array of mark area pairs: [[start, end], [start, end], ...] */ + markAreas: Array< + [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }] + >; +} + +export interface SpreadSeriesParams { + /** The variable name, used to construct series names */ + variable: string; + /** Array of [timestamp, min, max] data points */ + spreadData: Array<[number, number, number]>; + /** Optional color for the spread area (default: theme spread color) */ + color?: string; +} + +export interface MarkAreaEntry { + xAxis: number; + itemStyle?: { color: string }; +} + +// ─── Model Series ──────────────────────────────────────────────────────────── + +/** + * Builds a single model series (line or bar depending on the unit). + * Used on the Model Comparison page where each weather model gets its own series. + */ +export function buildModelSeries(params: ModelSeriesParams): Record { + const { name, data, unit, lineWidth = 2 } = params; + const isColumn = isColumnUnit(unit); + + return { + name, + type: isColumn ? 'bar' : 'line', + data, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + width: lineWidth + }, + emphasis: { + lineStyle: { + width: lineWidth + 1 + } + }, + barMaxWidth: 5 + }; +} + +// ─── Average Series ────────────────────────────────────────────────────────── + +/** + * Builds the ensemble/model average series. + * Rendered as a dashed line (or bar) that stands out from individual model lines. + */ +export function buildAverageSeries(params: AverageSeriesParams): Record { + const { variable, data, unit } = params; + const isColumn = isColumnUnit(unit); + + return { + name: variable + '_average', + type: isColumn ? 'bar' : 'line', + data, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + type: 'dashed', + width: 4, + color: CHART_COLORS.average + }, + itemStyle: { + color: CHART_COLORS.average + }, + emphasis: { + lineStyle: { + width: 6 + } + }, + barMaxWidth: 5, + z: 10 + }; +} + +// ─── Current Time Marker ───────────────────────────────────────────────────── + +/** + * Builds a helper series that renders a vertical red line at the current time. + * Uses an empty data series with a markLine to overlay onto the chart. + */ +export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record { + const { utcOffsetSeconds } = params; + + return { + name: 'Current Time', + type: 'line', + data: [], + markLine: { + silent: true, + symbol: 'none', + data: [ + { + xAxis: Date.now() + utcOffsetSeconds * 1000, + lineStyle: { + color: CHART_COLORS.currentTimeLine, + width: 2, + type: 'solid' + }, + label: { + show: false + } + } + ] + } + }; +} + +// ─── Daylight Bands ────────────────────────────────────────────────────────── + +/** + * Builds mark area entries from sunrise/sunset arrays. + * Each entry is a pair of axis markers that ECharts renders as a shaded band. + * + * @param sunrise - Array of sunrise timestamps (unix seconds, without UTC offset) + * @param sunset - Array of sunset timestamps (unix seconds, without UTC offset) + * @param utcOffsetSeconds - UTC offset to apply (from the API response) + */ +export function buildDaylightMarkAreas( + sunrise: number[], + sunset: number[], + utcOffsetSeconds: number +): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> { + return sunrise.map((r: number, i: number) => [ + { + xAxis: (r + utcOffsetSeconds) * 1000, + itemStyle: { + color: CHART_COLORS.daylight + } + }, + { + xAxis: (sunset[i] + utcOffsetSeconds) * 1000 + } + ]); +} + +/** + * Builds a helper series that renders day/night shading bands via markArea. + * Returns null if no mark areas are provided (so callers can filter it out). + */ +export function buildDaylightSeries( + params: DaylightSeriesParams +): Record | null { + if (params.markAreas.length === 0) return null; + + return { + name: 'Daylight', + type: 'line', + data: [], + markArea: { + silent: true, + data: params.markAreas + } + }; +} + +// ─── Ensemble Spread (Min/Max Area) ────────────────────────────────────────── + +/** + * Builds a pair of stacked area series that visualize the ensemble spread + * (min-to-max range). The lower bound is rendered invisibly and the upper + * bound delta is stacked on top with a translucent fill. + * + * Returns an array of two series that should be spread into the series list. + */ +export function buildSpreadSeries(params: SpreadSeriesParams): Array> { + const { variable, spreadData, color = CHART_COLORS.spreadArea } = params; + + const lowerBound: Record = { + name: variable + '_spread_lower', + type: 'line', + data: spreadData.map((d) => [d[0], d[1]]), + areaStyle: { + color, + origin: 'auto' + }, + lineStyle: { + width: 0 + }, + showSymbol: false, + stack: 'spread_' + variable, + smooth: true, + z: 1, + silent: true + }; + + const upperDelta: Record = { + name: variable + '_spread_upper', + type: 'line', + data: spreadData.map((d) => [d[0], d[2] - d[1]]), + areaStyle: { + color, + origin: 'auto' + }, + lineStyle: { + width: 0 + }, + showSymbol: false, + stack: 'spread_' + variable, + smooth: true, + z: 1, + silent: true + }; + + return [lowerBound, upperDelta]; +} + +// ─── Data Processing Helpers ───────────────────────────────────────────────── + +export interface AverageResult { + average: number[]; + averageCount: number[]; +} + +export interface SpreadResult { + minValues: (number | undefined)[]; + maxValues: (number | undefined)[]; +} + +/** + * Calculates per-timestep average and count from hourly model data. + * Shared between the Model Compare and 14-Day Forecast pages. + * + * @param hourlyData - The `data.hourly` object from the API response + * @param variable - The variable prefix to filter on (e.g. 'temperature_2m') + * @param timeLength - Number of timesteps + * @returns Object containing running average and count arrays + */ +export function calculateAverage( + hourlyData: Record, + variable: string, + timeLength: number +): AverageResult { + const average = new Array(timeLength).fill(0); + const averageCount = new Array(timeLength).fill(0); + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + for (const [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined) { + average[index] += val; + averageCount[index]++; + } + } + } + + // Finalize average values + for (let i = 0; i < timeLength; i++) { + if (averageCount[i] > 0) { + average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10; + } + } + + return { average, averageCount }; +} + +/** + * Calculates per-timestep min and max values from hourly ensemble data. + * Used by the 14-Day Forecast page to render the ensemble spread. + * + * @param hourlyData - The `data.hourly` object from the API response + * @param variable - The variable prefix to filter on + * @param timeLength - Number of timesteps + * @returns Object containing min and max value arrays + */ +export function calculateSpread( + hourlyData: Record, + variable: string, + timeLength: number +): SpreadResult { + const minValues = new Array(timeLength).fill(undefined); + const maxValues = new Array(timeLength).fill(undefined); + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + for (const [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined) { + if (minValues[index] === undefined || val < minValues[index]!) { + minValues[index] = val; + } + if (maxValues[index] === undefined || val > maxValues[index]!) { + maxValues[index] = val; + } + } + } + } + + return { minValues, maxValues }; +} + +/** + * Converts raw unix timestamps (seconds) to millisecond timestamps with UTC offset applied. + */ +export function convertTimestamps(times: number[], utcOffsetSeconds: number): number[] { + return times.map((t) => (t + utcOffsetSeconds) * 1000); +} + +/** + * Finds the unit string for a given variable from the hourly_units map. + * Returns an empty string if the variable is not found. + */ +export function findUnit( + hourlyUnits: Record, + hourlyData: Record, + variable: string +): string { + for (const model of Object.keys(hourlyData)) { + if (model === 'time') continue; + if (model.startsWith(variable) && hourlyUnits[model]) { + return hourlyUnits[model]; + } + } + return ''; +} diff --git a/src/lib/utils/echarts/theme.ts b/src/lib/utils/echarts/theme.ts new file mode 100644 index 0000000..e34d3a1 --- /dev/null +++ b/src/lib/utils/echarts/theme.ts @@ -0,0 +1,106 @@ +/** + * ECharts Theme Utilities + * + * Centralized dark/light mode detection and color helpers for consistent + * chart theming across all ECharts visualizations. + */ + +// ─── Color Palette ─────────────────────────────────────────────────────────── + +/** Default series color palette matching the application's design system */ +export const SERIES_COLORS = [ + '#5470c6', + '#91cc75', + '#fac858', + '#ee6666', + '#73c0de', + '#3ba272', + '#fc8452', + '#9a60b4', + '#ea7ccc', + '#4dc9f6' +] as const; + +/** Semantic colors used for specific chart elements */ +export const CHART_COLORS = { + average: '#5e5e5e', + currentTimeLine: '#ef4444', + daylight: 'rgba(255, 255, 194, 0.3)', + spreadArea: 'rgba(173, 216, 230, 0.3)', + creditText: { light: '#374151', dark: '#e5e7eb' } +} as const; + +// ─── Dark Mode Detection ───────────────────────────────────────────────────── + +/** + * Detects whether the application is currently in dark mode. + * Checks multiple sources: HTML class, data-theme attribute, and media query. + */ +export function isDarkMode(): boolean { + if (typeof document === 'undefined') return false; + + const html = document.documentElement; + const dataTheme = html.getAttribute('data-theme'); + + // Explicit data-theme takes priority + if (dataTheme === 'dark') return true; + if (dataTheme === 'light') return false; + + // Check for dark class (e.g. Tailwind dark mode) + if (html.classList.contains('dark')) return true; + + // Fall back to system preference + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; +} + +// ─── Theme Colors ──────────────────────────────────────────────────────────── + +export interface ThemeColors { + text: string; + textMuted: string; + axisLine: string; + splitLine: string; + background: string; + tooltipBg: string; + tooltipBorder: string; +} + +const LIGHT_COLORS: ThemeColors = { + text: '#374151', + textMuted: 'rgba(55, 65, 81, 0.6)', + axisLine: 'rgba(55, 65, 81, 0.3)', + splitLine: 'rgba(55, 65, 81, 0.1)', + background: 'transparent', + tooltipBg: '#ffffff', + tooltipBorder: '#e5e7eb' +}; + +const DARK_COLORS: ThemeColors = { + text: '#e5e7eb', + textMuted: 'rgba(229, 231, 235, 0.6)', + axisLine: 'rgba(229, 231, 235, 0.3)', + splitLine: 'rgba(229, 231, 235, 0.1)', + background: 'transparent', + tooltipBg: '#1f2937', + tooltipBorder: '#374151' +}; + +/** + * Returns the full set of theme colors based on current dark/light mode. + */ +export function getThemeColors(): ThemeColors { + return isDarkMode() ? DARK_COLORS : LIGHT_COLORS; +} + +/** Shorthand helpers kept for backward compatibility and convenience */ +export function getTextColor(): string { + return getThemeColors().text; +} + +export function getAxisLineColor(): string { + return getThemeColors().axisLine; +} + +export function getSplitLineColor(): string { + return getThemeColors().splitLine; +} diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index a245b7e..61d44ca 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -1,22 +1,39 @@ - -
-
-
- - - - -
-
+ -
-
-
- { - params.hourly = params.hourly; - }} - /> - -
-
- { - params.hourly = params.hourly; - }} - /> - -
-
+ + {#each chartOptions as option, i (i)} + + {/each} + + + + +
+ + {#snippet controls()} +
+ { + params.hourly = params.hourly; + }} + /> + +
+
+ { + params.hourly = params.hourly; + }} + /> + +
+ {/snippet} +
diff --git a/src/routes/weather/14-day/options.ts b/src/routes/weather/14-day/options.ts deleted file mode 100644 index 970b98d..0000000 --- a/src/routes/weather/14-day/options.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Default configuration for 14-day ensemble forecast charts -export const defaultParameters = { - timeformat: 'iso8601', - wind_speed_unit: 'kmh', - temperature_unit: 'celsius', - precipitation_unit: 'mm' -}; diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index a8ea7cd..6875688 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -3,25 +3,42 @@ import { get } from 'svelte/store'; import { fade } from 'svelte/transition'; - import * as echarts from 'echarts'; - import { storedLocation } from '$lib/stores/settings'; + import { + buildAverageSeries, + buildCurrentTimeSeries, + buildDaylightMarkAreas, + buildDaylightSeries, + buildModelSeries, + calculateAverage, + composeChartOption, + convertTimestamps, + findUnit, + getThemeColors + } from '$lib/utils/echarts'; + + import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts'; + import '$lib/components/charts/echarts.css'; import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; import { hourly, models as modelsFlat } from '../options'; - import './echarts.css'; - import { defaultParameters } from './options'; + import { defaultParameters } from '../options'; + + import type * as echarts from 'echarts'; // Wrap models in array to match template expectation of nested arrays like hourly const models = [modelsFlat]; - let node: HTMLElement; - let charts: echarts.ECharts[] = []; - let resizeObservers: ResizeObserver[] = []; + // ─── State ────────────────────────────────────────────────────────────────── + + let chartComponents: EChart[] = $state([]); + let chartInstances: echarts.ECharts[] = $state([]); + let chartOptions: Array> = $state([]); let mounted = $state(false); + let loading = $state(true); let showLegend = $state(false); let averageOnly = $state(false); @@ -42,402 +59,203 @@ ] }); - let count = $state(0); - - function isDarkMode(): boolean { - if (typeof document === 'undefined') return false; - return ( - document.documentElement.classList.contains('dark') || - document.documentElement.getAttribute('data-theme') === 'dark' || - (window.matchMedia && - window.matchMedia('(prefers-color-scheme: dark)').matches && - document.documentElement.getAttribute('data-theme') !== 'light') - ); - } - - function getTextColor(): string { - return isDarkMode() ? '#e5e7eb' : '#374151'; - } - - function getAxisLineColor(): string { - return isDarkMode() ? 'rgba(229, 231, 235, 0.3)' : 'rgba(55, 65, 81, 0.3)'; - } - - function getSplitLineColor(): string { - return isDarkMode() ? 'rgba(229, 231, 235, 0.1)' : 'rgba(55, 65, 81, 0.1)'; - } + // ─── Lifecycle ────────────────────────────────────────────────────────────── onMount(() => { mounted = true; }); + onDestroy(() => { + chartInstances = []; + chartOptions = []; + chartComponents = []; + }); + + // ─── Chart Instance Tracking ──────────────────────────────────────────────── + + function handleChartReady(chart: echarts.ECharts): void { + chartInstances = [...chartInstances, chart]; + } + + // ─── Data Loading & Chart Building ────────────────────────────────────────── + $effect(() => { const loadData = async () => { - count = 0; - if (mounted) { - // Dispose existing charts and observers - resizeObservers.forEach((ro) => ro.disconnect()); - resizeObservers = []; - charts.forEach((chart) => { - if (chart) { - chart.dispose(); - } - }); - charts = []; - // eslint-disable-next-line svelte/no-dom-manipulating - node.replaceChildren(); + if (!mounted) return; - const dataReq = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise` - ); - const data = await dataReq.json(); + loading = true; + chartInstances = []; + chartComponents = []; + const dataReq = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise` + ); + const data = await dataReq.json(); + + // ─── Compute daylight bands ───────────────────────────────────── + + let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> = + []; + + if ('daily' in data) { + // Find the first model-suffixed key for sunrise/sunset let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_'); dailyFirstModelKey.shift(); dailyFirstModelKey = dailyFirstModelKey.join('_'); - // Create day/night plot bands as markArea data - let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> = - []; - if ( - 'daily' in data && - 'sunrise_' + dailyFirstModelKey in data.daily && - 'sunset_' + dailyFirstModelKey in data.daily - ) { - let rise = data.daily['sunrise_' + dailyFirstModelKey]; - let set = data.daily['sunset_' + dailyFirstModelKey]; - markAreas = rise.map(function (r: number, i: number) { - return [ - { - xAxis: (r + data.utc_offset_seconds) * 1000, - itemStyle: { - color: 'rgba(255, 255, 194, 0.3)' - } - }, - { - xAxis: (set[i] + data.utc_offset_seconds) * 1000 - } - ]; - }); - } + const sunriseKey = 'sunrise_' + dailyFirstModelKey; + const sunsetKey = 'sunset_' + dailyFirstModelKey; - const textColor = getTextColor(); - const axisLineColor = getAxisLineColor(); - const splitLineColor = getSplitLineColor(); - - for (let variable of params.hourly || []) { - const chartDiv = document.createElement('div'); - chartDiv.style.width = '100%'; - chartDiv.style.height = showLegend ? '400px' : '300px'; - - // Append to DOM BEFORE echarts.init so it can measure dimensions - // eslint-disable-next-line svelte/no-dom-manipulating - node.appendChild(chartDiv); - - let unit: string = ''; - - const series: Array> = []; - let average = new Array(data.hourly.time.length).fill(0); - let averageCount = new Array(data.hourly.time.length).fill(0); - - const timestamps = data.hourly.time.map( - (t: number) => (t + data.utc_offset_seconds) * 1000 + if (sunriseKey in data.daily && sunsetKey in data.daily) { + markAreas = buildDaylightMarkAreas( + data.daily[sunriseKey], + data.daily[sunsetKey], + data.utc_offset_seconds ); - - for (let [model, values] of Object.entries(data.hourly)) { - if (model === 'time') { - continue; - } - if (model.startsWith(variable)) { - for (let [index, val] of (values as number[]).entries()) { - if (val !== null && val !== undefined) { - let avVal = average[index]; - average[index] = avVal + val; - averageCount[index]++; - } - } - - unit = data.hourly_units[model]; - - if (!averageOnly) { - const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'; - - const seriesData = (values as (number | null)[]).map( - (val: number | null, idx: number) => [timestamps[idx], val] - ); - - series.push({ - name: model, - type: isColumn ? 'bar' : 'line', - data: seriesData, - smooth: !isColumn, - showSymbol: false, - lineStyle: { - width: 2 - }, - emphasis: { - lineStyle: { - width: 3 - } - }, - barMaxWidth: 5 - }); - } - } - } - - // Calculate average - for (let [index, val] of average.entries()) { - average[index] = Math.round((val / averageCount[index]) * 10) / 10; - } - - const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'; - const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]); - - series.push({ - name: variable + '_average', - type: isColumn ? 'bar' : 'line', - data: averageData, - smooth: !isColumn, - showSymbol: false, - lineStyle: { - type: 'dashed', - width: 4, - color: '#5e5e5e' - }, - itemStyle: { - color: '#5e5e5e' - }, - emphasis: { - lineStyle: { - width: 6 - } - }, - barMaxWidth: 5, - z: 10 - }); - - // Add current time markLine via a helper series - series.push({ - name: 'Current Time', - type: 'line', - data: [], - markLine: { - silent: true, - symbol: 'none', - data: [ - { - xAxis: Date.now() + data.utc_offset_seconds * 1000, - lineStyle: { - color: 'red', - width: 2 - }, - label: { - show: false - } - } - ] - } - }); - - // Add day/night bands via markArea - if (markAreas.length > 0) { - series.push({ - name: 'Daylight', - type: 'line', - data: [], - markArea: { - silent: true, - data: markAreas - } - }); - } - - const option: Record = { - title: { - text: count === 0 ? 'Model Compare' : '', - left: 'left', - textStyle: { - fontWeight: 'normal', - color: textColor - }, - ...(count === 0 - ? { - subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`, - subtextStyle: { - fontWeight: 'normal', - color: textColor - } - } - : {}) - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'cross', - animation: false - }, - valueFormatter: (value: number) => { - if (value === null || value === undefined) return '-'; - return value.toFixed(1) + ' ' + unit; - } - }, - legend: { - show: showLegend, - bottom: 0, - type: 'scroll', - textStyle: { - color: textColor - } - }, - grid: { - left: 60, - right: 10, - top: count === 0 ? 80 : 40, - bottom: showLegend ? 60 : 40 - }, - xAxis: { - type: 'time', - splitLine: { - show: false - }, - axisLine: { - lineStyle: { - color: axisLineColor - } - }, - axisLabel: { - color: textColor - } - }, - yAxis: { - type: 'value', - name: unit, - nameTextStyle: { - color: textColor - }, - axisLine: { - show: false - }, - axisLabel: { - color: textColor - }, - splitLine: { - lineStyle: { - color: splitLineColor - } - } - }, - series: series, - textStyle: { - color: textColor - } - }; - - // Add credits for last chart - if (count === (params.hourly?.length || 0) - 1) { - option.graphic = [ - { - type: 'text', - right: 10, - bottom: 5, - style: { - text: 'Open-Meteo.com', - fontSize: 10, - fill: textColor, - opacity: 0.5 - }, - onclick: function () { - window.open('https://open-meteo.com', '_blank'); - }, - cursor: 'pointer' - } - ]; - } - - const chart = echarts.init(chartDiv, null, { renderer: 'canvas' }); - charts.push(chart); - chart.setOption(option); - - // Handle responsive resize - const resizeObserver = new ResizeObserver(() => { - chart.resize(); - }); - resizeObserver.observe(chartDiv); - resizeObservers.push(resizeObserver); - - count++; } } - }; - loadData(); - }); - onDestroy(() => { - resizeObservers.forEach((ro) => ro.disconnect()); - resizeObservers = []; - charts.forEach((chart) => { - chart.dispose(); - }); - charts = []; + // ─── Build chart options for each variable ────────────────────── + + const colors = getThemeColors(); + const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); + const variableCount = params.hourly?.length || 0; + const newOptions: Array> = []; + + for (let vi = 0; vi < variableCount; vi++) { + const variable = params.hourly![vi]; + const unit = findUnit(data.hourly_units, data.hourly, variable); + const timeLength = data.hourly.time.length; + + // ─── Build individual model series ─────────────────────────── + + const series: Array> = []; + + if (!averageOnly) { + for (const [model, values] of Object.entries(data.hourly)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + const seriesData = (values as (number | null)[]).map( + (val, idx) => [timestamps[idx], val] as [number, number | null] + ); + + series.push( + buildModelSeries({ + name: model, + data: seriesData, + unit + }) + ); + } + } + + // ─── Average series ───────────────────────────────────────── + + const { average } = calculateAverage(data.hourly, variable, timeLength); + const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]); + + series.push(buildAverageSeries({ variable, data: averageData, unit })); + + // ─── Annotation series ─────────────────────────────────────── + + series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds })); + + const daylightSeries = buildDaylightSeries({ markAreas }); + if (daylightSeries) { + series.push(daylightSeries); + } + + // ─── Compose final option ─────────────────────────────────── + + const isFirst = vi === 0; + const isLast = vi === variableCount - 1; + + const option = composeChartOption({ + title: isFirst + ? { + text: 'Model Compare', + subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` + } + : null, + tooltip: { unit }, + legend: { show: showLegend }, + grid: { + hasTitle: isFirst, + hasSubtitle: isFirst, + showLegend + }, + yAxis: { unit }, + series, + toolbox: { + saveAsImage: true, + fileName: `model-compare-${variable}`, + format: 'png' + }, + showCredit: isLast, + colors + }); + + newOptions.push(option); + } + + chartOptions = newOptions; + loading = false; + }; + + loadData(); }); - -
+ + -
-
- - - - -
+ {#each chartOptions as option, i (i)} + + {/each} +
+ + + +
+ + {#snippet controls()} +
+ { + params.hourly = params.hourly; + }} + /> + +
+
+ { + params.hourly = params.hourly; + }} + /> + +
+ {/snippet} +
-
-
-
- { - params.hourly = params.hourly; - }} - /> - -
-
- { - params.hourly = params.hourly; - }} - /> - -
-
-
+
@@ -487,7 +305,8 @@ {/each}
- + +
Date: Sun, 15 Feb 2026 18:51:44 +0100 Subject: [PATCH 03/12] better download --- src/lib/components/charts/ChartToolbar.svelte | 96 ++----- src/lib/utils/echarts/download.ts | 243 ++++++++++++++++-- src/lib/utils/echarts/index.ts | 2 +- src/routes/weather/14-day/+page.svelte | 236 ++++++++--------- src/routes/weather/compare/+page.svelte | 237 +++++++++-------- 5 files changed, 469 insertions(+), 345 deletions(-) diff --git a/src/lib/components/charts/ChartToolbar.svelte b/src/lib/components/charts/ChartToolbar.svelte index 46d8a5e..ea7a079 100644 --- a/src/lib/components/charts/ChartToolbar.svelte +++ b/src/lib/components/charts/ChartToolbar.svelte @@ -2,10 +2,12 @@ ChartToolbar.svelte — Chart action bar with download and display controls Provides a toolbar row with: - - Download as PNG button - - Download as SVG button - - Download all charts button (when multiple charts exist) - - Slot for additional custom controls (e.g. legend toggle, average toggle) + - Download full meteogram as PNG button + - Download full meteogram as SVG button + - Slot for additional custom controls (e.g. legend toggle) + + When multiple charts are provided, they are stitched into a single + combined image on download. Usage: --> -
+
{#if controls} @@ -114,7 +93,7 @@ class="toolbar-btn" disabled={!hasCharts || downloadingFormat !== null} onclick={() => handleDownload('png')} - title="Download chart as PNG image" + title="Download meteogram as PNG image" > {#if downloadingFormat === 'png'} handleDownload('svg')} - title="Download chart as SVG vector image" + title="Download meteogram as SVG vector image" > {#if downloadingFormat === 'svg'} SVG - - - {#if showDownloadAll && hasMultipleCharts} - - - {/if}
diff --git a/src/lib/utils/echarts/download.ts b/src/lib/utils/echarts/download.ts index 86bba24..0f13b48 100644 --- a/src/lib/utils/echarts/download.ts +++ b/src/lib/utils/echarts/download.ts @@ -2,10 +2,9 @@ * ECharts Download Utilities * * Provides programmatic chart export functionality for downloading - * charts as PNG or SVG images. These utilities wrap ECharts' built-in - * export capabilities with a convenient API and sensible defaults. + * charts as PNG or SVG images. Supports stitching multiple chart + * instances into a single combined meteogram image. */ - import type * as echarts from 'echarts'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -49,7 +48,6 @@ export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); - // Use ECharts' getDataURL for PNG, getConnectedDataURL for SVG const dataUrl = chart.getDataURL({ type: format === 'svg' ? 'svg' : 'png', pixelRatio: format === 'png' ? pixelRatio : 1, @@ -61,25 +59,47 @@ export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = } /** - * Downloads all provided ECharts instances as separate image files. - * Each file is named with an incrementing suffix (e.g. chart-1.png, chart-2.png). + * Downloads multiple ECharts instances stitched into a single combined + * meteogram image. Charts are stacked vertically in the order provided. * - * @param charts - Array of ECharts instances to export - * @param options - Download configuration options (fileName is used as prefix) + * For a single chart, delegates to `downloadChart`. + * + * @param charts - Array of ECharts instances to combine + * @param options - Download configuration options */ -export function downloadAllCharts( - charts: echarts.ECharts[], - options: DownloadOptions = {} -): void { - const { fileName = DEFAULT_FILE_NAME, ...rest } = options; +export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void { + const validCharts = charts.filter((c) => c && !c.isDisposed()); + if (validCharts.length === 0) return; - charts.forEach((chart, index) => { - if (chart && !chart.isDisposed()) { - downloadChart(chart, { - ...rest, - fileName: charts.length === 1 ? fileName : `${fileName}-${index + 1}` - }); - } + if (validCharts.length === 1) { + downloadChart(validCharts[0], options); + return; + } + + const { + fileName = DEFAULT_FILE_NAME, + format = 'png', + pixelRatio = DEFAULT_PIXEL_RATIO, + backgroundColor, + excludeComponents = ['toolbox'] + } = options; + + const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); + + if (format === 'svg') { + downloadMeteogramSvg(validCharts, { + fileName, + backgroundColor: resolvedBg, + excludeComponents + }); + return; + } + + downloadMeteogramPng(validCharts, { + fileName, + pixelRatio, + backgroundColor: resolvedBg, + excludeComponents }); } @@ -91,10 +111,7 @@ export function downloadAllCharts( * @param options - Export configuration options * @returns A base64-encoded data URL string */ -export function getChartDataUrl( - chart: echarts.ECharts, - options: DownloadOptions = {} -): string { +export function getChartDataUrl(chart: echarts.ECharts, options: DownloadOptions = {}): string { const { format = 'png', pixelRatio = DEFAULT_PIXEL_RATIO, @@ -112,22 +129,192 @@ export function getChartDataUrl( }); } +// ─── Internal: PNG Meteogram ───────────────────────────────────────────────── + +interface PngStitchOptions { + fileName: string; + pixelRatio: number; + backgroundColor: string; + excludeComponents: string[]; +} + +/** + * Stitches multiple charts into a single PNG by rendering each chart's + * data URL onto an off-screen canvas, stacked vertically. + */ +function downloadMeteogramPng(charts: echarts.ECharts[], opts: PngStitchOptions): void { + const { fileName, pixelRatio, backgroundColor, excludeComponents } = opts; + + const dataUrls = charts.map((chart) => + chart.getDataURL({ + type: 'png', + pixelRatio, + backgroundColor: 'transparent', + excludeComponents + }) + ); + + const images: HTMLImageElement[] = []; + let loadedCount = 0; + + dataUrls.forEach((url, index) => { + const img = new Image(); + images[index] = img; + + img.onload = () => { + loadedCount++; + if (loadedCount === dataUrls.length) { + composePngAndDownload(images, fileName, backgroundColor); + } + }; + + img.onerror = () => { + loadedCount++; + if (loadedCount === dataUrls.length) { + composePngAndDownload(images, fileName, backgroundColor); + } + }; + + img.src = url; + }); +} + +function composePngAndDownload( + images: HTMLImageElement[], + fileName: string, + backgroundColor: string +): void { + const validImages = images.filter((img) => img.naturalWidth > 0); + if (validImages.length === 0) return; + + const maxWidth = Math.max(...validImages.map((img) => img.naturalWidth)); + const totalHeight = validImages.reduce((sum, img) => sum + img.naturalHeight, 0); + + const canvas = document.createElement('canvas'); + canvas.width = maxWidth; + canvas.height = totalHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + if (backgroundColor && backgroundColor !== 'transparent' && backgroundColor !== 'none') { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, maxWidth, totalHeight); + } + + let y = 0; + for (const img of validImages) { + ctx.drawImage(img, 0, y); + y += img.naturalHeight; + } + + const dataUrl = canvas.toDataURL('image/png'); + triggerDownload(dataUrl, `${fileName}.png`); +} + +// ─── Internal: SVG Meteogram ───────────────────────────────────────────────── + +interface SvgStitchOptions { + fileName: string; + backgroundColor: string; + excludeComponents: string[]; +} + +/** + * Stitches multiple charts into a single SVG by extracting each chart's + * SVG markup and embedding them as nested groups with vertical offsets. + */ +function downloadMeteogramSvg(charts: echarts.ECharts[], opts: SvgStitchOptions): void { + const { fileName, backgroundColor, excludeComponents } = opts; + + const svgStrings = charts.map((chart) => + chart.getDataURL({ + type: 'svg', + pixelRatio: 1, + backgroundColor: 'transparent', + excludeComponents + }) + ); + + const parser = new DOMParser(); + const fragments: { svg: SVGSVGElement; width: number; height: number }[] = []; + + for (const svgDataUrl of svgStrings) { + const svgContent = decodeSvgDataUrl(svgDataUrl); + if (!svgContent) continue; + + const doc = parser.parseFromString(svgContent, 'image/svg+xml'); + const svg = doc.querySelector('svg'); + if (!svg) continue; + + const width = parseFloat(svg.getAttribute('width') || '0'); + const height = parseFloat(svg.getAttribute('height') || '0'); + + if (width > 0 && height > 0) { + fragments.push({ svg, width, height }); + } + } + + if (fragments.length === 0) return; + + const maxWidth = Math.max(...fragments.map((f) => f.width)); + const totalHeight = fragments.reduce((sum, f) => sum + f.height, 0); + + let combinedSvg = ``; + + if (backgroundColor && backgroundColor !== 'none' && backgroundColor !== 'transparent') { + combinedSvg += ``; + } + + let yOffset = 0; + for (const fragment of fragments) { + combinedSvg += ``; + combinedSvg += fragment.svg.innerHTML; + combinedSvg += ``; + yOffset += fragment.height; + } + + combinedSvg += ``; + + const blob = new Blob([combinedSvg], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + triggerDownload(url, `${fileName}.svg`); + + setTimeout(() => URL.revokeObjectURL(url), 10000); +} + +function decodeSvgDataUrl(dataUrl: string): string | null { + try { + if (dataUrl.startsWith('data:image/svg+xml;charset=UTF-8,')) { + return decodeURIComponent(dataUrl.slice('data:image/svg+xml;charset=UTF-8,'.length)); + } + if (dataUrl.startsWith('data:image/svg+xml;base64,')) { + return atob(dataUrl.slice('data:image/svg+xml;base64,'.length)); + } + if (dataUrl.startsWith('data:image/svg+xml,')) { + return decodeURIComponent(dataUrl.slice('data:image/svg+xml,'.length)); + } + return null; + } catch { + return null; + } +} + // ─── Internal Helpers ──────────────────────────────────────────────────────── /** - * Triggers a browser file download from a data URL. + * Triggers a browser file download from a data URL or object URL. * Creates a temporary anchor element, clicks it, and removes it. */ -function triggerDownload(dataUrl: string, fileName: string): void { +function triggerDownload(url: string, fileName: string): void { const link = document.createElement('a'); - link.href = dataUrl; + link.href = url; link.download = fileName; link.style.display = 'none'; document.body.appendChild(link); link.click(); - // Clean up the DOM after a brief delay to ensure the download starts requestAnimationFrame(() => { document.body.removeChild(link); }); diff --git a/src/lib/utils/echarts/index.ts b/src/lib/utils/echarts/index.ts index c036a1b..22732e9 100644 --- a/src/lib/utils/echarts/index.ts +++ b/src/lib/utils/echarts/index.ts @@ -68,5 +68,5 @@ export type { } from './series'; // Download: export charts as PNG or SVG -export { downloadChart, downloadAllCharts, getChartDataUrl } from './download'; +export { downloadChart, downloadMeteogram, getChartDataUrl } from './download'; export type { ExportFormat, DownloadOptions } from './download'; diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index 61d44ca..60b6772 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -27,7 +27,11 @@ import type * as echarts from 'echarts'; - // ─── State ────────────────────────────────────────────────────────────────── + // ─── Display State (does NOT trigger data re-fetch) ───────────────────────── + + let showLegend = $state(false); + + // ─── Data Fetch State ─────────────────────────────────────────────────────── let chartComponents: EChart[] = $state([]); let chartInstances: echarts.ECharts[] = $state([]); @@ -35,12 +39,8 @@ let mounted = $state(false); let loading = $state(true); - let showLegend = $state(false); - let averageOnly = $state(false); - const location = get(storedLocation); - // Local component state for chart configuration let params = $state({ latitude: [52.52], longitude: [13.41], @@ -49,6 +49,18 @@ models: ['gfs_seamless'] }); + // ─── Cached API Response ──────────────────────────────────────────────────── + + interface FetchedData { + hourly: Record; + hourly_units: Record; + utc_offset_seconds: number; + markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + timestamps: number[]; + } + + let fetchedData: FetchedData | null = $state(null); + // ─── Lifecycle ────────────────────────────────────────────────────────────── onMount(() => { @@ -67,32 +79,31 @@ chartInstances = [...chartInstances, chart]; } - // ─── Data Loading & Chart Building ────────────────────────────────────────── + // ─── Data Fetching (only when params.hourly or params.models change) ─────── $effect(() => { - const loadData = async () => { - if (!mounted) return; + const hourlyVars = params.hourly; + const modelList = params.models; + if (!mounted || !hourlyVars?.length || !modelList?.length) return; + + const loadData = async () => { loading = true; chartInstances = []; chartComponents = []; - // Fetch sunrise/sunset from the standard forecast API - const dataDaily = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14` - ); - const wd = await dataDaily.json(); + const [dataDaily, dataReq] = await Promise.all([ + fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14` + ), + fetch( + `https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&forecast_days=14` + ) + ]); - // Fetch ensemble data - 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` - ); - const data = await dataReq.json(); + const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]); - // ─── Compute daylight bands ───────────────────────────────────── - - let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> = - []; + let markAreas: FetchedData['markAreas'] = []; if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) { markAreas = buildDaylightMarkAreas( @@ -102,90 +113,99 @@ ); } - // ─── Build chart options for each variable ────────────────────── - - const colors = getThemeColors(); const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); - const variableCount = params.hourly?.length || 0; - const newOptions: Array> = []; - for (let vi = 0; vi < variableCount; vi++) { - const variable = params.hourly![vi]; - const unit = findUnit(data.hourly_units, data.hourly, variable); - const timeLength = data.hourly.time.length; + fetchedData = { + hourly: data.hourly, + hourly_units: data.hourly_units, + utc_offset_seconds: data.utc_offset_seconds, + markAreas, + timestamps + }; - // ─── Calculate average and spread ─────────────────────────── - - const { average } = calculateAverage(data.hourly, variable, timeLength); - const { minValues, maxValues } = calculateSpread(data.hourly, variable, timeLength); - - // ─── Build series ─────────────────────────────────────────── - - const series: Array> = []; - - // Ensemble spread (min/max area) - const spreadData: Array<[number, number, number]> = minValues.map( - (min, index) => - [timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number] - ); - - series.push(...buildSpreadSeries({ variable, spreadData })); - - // Average line - const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]); - series.push(buildAverageSeries({ variable, data: averageData, unit })); - - // Current time marker - series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds })); - - // Daylight bands - const daylightSeries = buildDaylightSeries({ markAreas }); - if (daylightSeries) { - series.push(daylightSeries); - } - - // ─── Compose final option ─────────────────────────────────── - - const isFirst = vi === 0; - const isLast = vi === variableCount - 1; - - const option = composeChartOption({ - title: isFirst - ? { - text: 'Model Spread', - subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` - } - : null, - tooltip: { unit }, - legend: { - show: showLegend, - data: [variable + '_average'] - }, - grid: { - hasTitle: isFirst, - hasSubtitle: isFirst, - showLegend - }, - yAxis: { unit }, - series, - toolbox: { - saveAsImage: true, - fileName: `14-day-forecast-${variable}`, - format: 'png' - }, - showCredit: isLast, - colors - }); - - newOptions.push(option); - } - - chartOptions = newOptions; loading = false; }; loadData(); }); + + // ─── Chart Option Building (runs when fetchedData OR display toggles change) ─ + + $effect(() => { + if (!fetchedData) return; + + const { + hourly: hourlyData, + hourly_units, + utc_offset_seconds, + markAreas, + timestamps + } = fetchedData; + const _showLegend = showLegend; + + const colors = getThemeColors(); + const variableCount = params.hourly?.length || 0; + const timeLength = (hourlyData.time as number[]).length; + const newOptions: Array> = []; + + for (let vi = 0; vi < variableCount; vi++) { + const variable = params.hourly![vi]; + const unit = findUnit(hourly_units, hourlyData, variable); + + const { average } = calculateAverage(hourlyData, variable, timeLength); + const { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength); + + const series: Array> = []; + + const spreadData: Array<[number, number, number]> = minValues.map( + (min, index) => + [timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number] + ); + + series.push(...buildSpreadSeries({ variable, spreadData })); + + const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]); + series.push(buildAverageSeries({ variable, data: averageData, unit })); + + series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds })); + + const daylightSeries = buildDaylightSeries({ markAreas }); + if (daylightSeries) { + series.push(daylightSeries); + } + + const isFirst = vi === 0; + const isLast = vi === variableCount - 1; + + const option = composeChartOption({ + title: isFirst + ? { + text: 'Model Spread', + subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` + } + : null, + tooltip: { unit }, + legend: { + show: _showLegend, + data: [variable + '_average'] + }, + grid: { + hasTitle: isFirst, + hasSubtitle: isFirst, + showLegend: _showLegend + }, + yAxis: { unit }, + series, + toolbox: false, + showCredit: isLast, + colors + }); + + newOptions.push(option); + } + + chartOptions = newOptions; + }); @@ -211,27 +231,9 @@ {#snippet controls()}
- { - params.hourly = params.hourly; - }} - /> +
-
- { - params.hourly = params.hourly; - }} - /> - -
{/snippet}
diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index 6875688..6a1172d 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -29,10 +29,13 @@ import type * as echarts from 'echarts'; - // Wrap models in array to match template expectation of nested arrays like hourly const models = [modelsFlat]; - // ─── State ────────────────────────────────────────────────────────────────── + // ─── Display State (does NOT trigger data re-fetch) ───────────────────────── + + let showLegend = $state(false); + + // ─── Data Fetch State ─────────────────────────────────────────────────────── let chartComponents: EChart[] = $state([]); let chartInstances: echarts.ECharts[] = $state([]); @@ -40,9 +43,6 @@ let mounted = $state(false); let loading = $state(true); - let showLegend = $state(false); - let averageOnly = $state(false); - const location = get(storedLocation); let params = $state({ @@ -59,6 +59,18 @@ ] }); + // ─── Cached API Response ──────────────────────────────────────────────────── + + interface FetchedData { + hourly: Record; + hourly_units: Record; + utc_offset_seconds: number; + markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + timestamps: number[]; + } + + let fetchedData: FetchedData | null = $state(null); + // ─── Lifecycle ────────────────────────────────────────────────────────────── onMount(() => { @@ -77,28 +89,27 @@ chartInstances = [...chartInstances, chart]; } - // ─── Data Loading & Chart Building ────────────────────────────────────────── + // ─── Data Fetching (only when params.hourly or params.models change) ─────── $effect(() => { - const loadData = async () => { - if (!mounted) return; + const hourlyVars = params.hourly; + const modelList = params.models; + if (!mounted || !hourlyVars?.length || !modelList?.length) return; + + const loadData = async () => { loading = true; chartInstances = []; chartComponents = []; 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=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise` ); const data = await dataReq.json(); - // ─── Compute daylight bands ───────────────────────────────────── - - let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> = - []; + let markAreas: FetchedData['markAreas'] = []; if ('daily' in data) { - // Find the first model-suffixed key for sunrise/sunset let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_'); dailyFirstModelKey.shift(); dailyFirstModelKey = dailyFirstModelKey.join('_'); @@ -115,96 +126,104 @@ } } - // ─── Build chart options for each variable ────────────────────── - - const colors = getThemeColors(); const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); - const variableCount = params.hourly?.length || 0; - const newOptions: Array> = []; - for (let vi = 0; vi < variableCount; vi++) { - const variable = params.hourly![vi]; - const unit = findUnit(data.hourly_units, data.hourly, variable); - const timeLength = data.hourly.time.length; + fetchedData = { + hourly: data.hourly, + hourly_units: data.hourly_units, + utc_offset_seconds: data.utc_offset_seconds, + markAreas, + timestamps + }; - // ─── Build individual model series ─────────────────────────── - - const series: Array> = []; - - if (!averageOnly) { - for (const [model, values] of Object.entries(data.hourly)) { - if (model === 'time') continue; - if (!model.startsWith(variable)) continue; - - const seriesData = (values as (number | null)[]).map( - (val, idx) => [timestamps[idx], val] as [number, number | null] - ); - - series.push( - buildModelSeries({ - name: model, - data: seriesData, - unit - }) - ); - } - } - - // ─── Average series ───────────────────────────────────────── - - const { average } = calculateAverage(data.hourly, variable, timeLength); - const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]); - - series.push(buildAverageSeries({ variable, data: averageData, unit })); - - // ─── Annotation series ─────────────────────────────────────── - - series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds })); - - const daylightSeries = buildDaylightSeries({ markAreas }); - if (daylightSeries) { - series.push(daylightSeries); - } - - // ─── Compose final option ─────────────────────────────────── - - const isFirst = vi === 0; - const isLast = vi === variableCount - 1; - - const option = composeChartOption({ - title: isFirst - ? { - text: 'Model Compare', - subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` - } - : null, - tooltip: { unit }, - legend: { show: showLegend }, - grid: { - hasTitle: isFirst, - hasSubtitle: isFirst, - showLegend - }, - yAxis: { unit }, - series, - toolbox: { - saveAsImage: true, - fileName: `model-compare-${variable}`, - format: 'png' - }, - showCredit: isLast, - colors - }); - - newOptions.push(option); - } - - chartOptions = newOptions; loading = false; }; loadData(); }); + + // ─── Chart Option Building (runs when fetchedData OR display toggles change) ─ + + $effect(() => { + if (!fetchedData) return; + + const { + hourly: hourlyData, + hourly_units, + utc_offset_seconds, + markAreas, + timestamps + } = fetchedData; + const _showLegend = showLegend; + + const colors = getThemeColors(); + const variableCount = params.hourly?.length || 0; + const timeLength = (hourlyData.time as number[]).length; + const newOptions: Array> = []; + + for (let vi = 0; vi < variableCount; vi++) { + const variable = params.hourly![vi]; + const unit = findUnit(hourly_units, hourlyData, variable); + + const series: Array> = []; + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + const seriesData = (values as (number | null)[]).map( + (val, idx) => [timestamps[idx], val] as [number, number | null] + ); + + series.push( + buildModelSeries({ + name: model, + data: seriesData, + unit + }) + ); + } + + const { average } = calculateAverage(hourlyData, variable, timeLength); + const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]); + series.push(buildAverageSeries({ variable, data: averageData, unit })); + + series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds })); + + const daylightSeries = buildDaylightSeries({ markAreas }); + if (daylightSeries) { + series.push(daylightSeries); + } + + const isFirst = vi === 0; + const isLast = vi === variableCount - 1; + + const option = composeChartOption({ + title: isFirst + ? { + text: 'Model Compare', + subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` + } + : null, + tooltip: { unit }, + legend: { show: _showLegend }, + grid: { + hasTitle: isFirst, + hasSubtitle: isFirst, + showLegend: _showLegend + }, + yAxis: { unit }, + series, + toolbox: false, + showCredit: isLast, + colors + }); + + newOptions.push(option); + } + + chartOptions = newOptions; + }); @@ -230,27 +249,9 @@ {#snippet controls()}
- { - params.hourly = params.hourly; - }} - /> +
-
- { - params.hourly = params.hourly; - }} - /> - -
{/snippet}
@@ -289,8 +290,7 @@ return item !== value; }); } else if (params.models) { - params.models.push(value); - params.models = params.models; + params.models = [...params.models, value]; } }} /> @@ -344,8 +344,7 @@ return item !== value; }); } else if (params.hourly) { - params.hourly.push(value); - params.hourly = params.hourly; + params.hourly = [...params.hourly, value]; } }} /> -- 2.54.0 From 92e38efebba167ffef13982f4481e885260ea8be Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 20:05:31 +0100 Subject: [PATCH 04/12] WIP: Canvas to Echarts --- .../weather/week/[location]/+page.svelte | 1515 ++++++++++++----- 1 file changed, 1050 insertions(+), 465 deletions(-) diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 79799b0..3bb4081 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -1,26 +1,39 @@ Weather | Open-Meteo.com - +
+
- {#await weatherDaily then wd} - {#each wd.daily.time as time, index (index)} + {#if fetchedDaily} + {#each fetchedDaily.dailyDates as time, index (index)} {@const selected = time.getDate() === selectedDay.getDate()} - {#if wd.daily.temperature_2m_max.values(index) != null && !isNaN(Number(wd.daily.temperature_2m_max - .values(index)! - .toFixed(1)))} + {@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)}
= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`} + 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'}" > - {wd.daily.temperature_2m_max.values(index)?.toFixed(1)} + {tempMax.toFixed(1)} {params.temperature_unit === 'celsius' ? '°C' : '°F'}
= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`} + 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'}" > - {wd.daily.temperature_2m_min.values(index)?.toFixed(1)} + {tempMin.toFixed(1)} {params.temperature_unit === 'celsius' ? '°C' : '°F'}
@@ -371,8 +880,7 @@
- - {Number((wd.daily.sunshine_duration.values(index) ?? 0) / 3600).toFixed(0)}h + {Number((sunDuration ?? 0) / 3600).toFixed(0)}h
@@ -385,19 +893,17 @@
- {Number(wd.daily.precipitation_sum.values(index)).toFixed( - 1 - )}{params.precipitation_unit === 'mm' ? 'mm' : "'"} + {Number(precipSum).toFixed(1)}{params.precipitation_unit === 'mm' ? 'mm' : "'"}
{/if} {/each} - {:catch error} -

{error.message}

- {/await} + {/if}
-
+ + +

{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} @@ -410,219 +916,217 @@ : ''}

+
-
- - - - - {#await weather then weather} + + + + + {#each chartOptions as option, i (i)} + + {/each} + + + + {#if fetchedHourly} + {@const hourly = fetchedHourly.hourly} + {@const dates = fetchedHourly.hourlyDates} +
+

Hourly Details

+
+
+
Weather Week {location.name}
+ + + - - {#each weather.indexes as index, j (j)} + + {#each dates as date, i (i)} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} - {/each} - - - - - {#each weather.indexes as index, j (j)} - {@const now = - weather.hourlyTime[index].getDate() === today.getDate() && - weather.hourlyTime[index].getHours() === today.getHours()} - + {pad(date.getHours())} + {/each} - + - - {#each weather.indexes as index, j (j)} - {@const temp = weather.entries?.[0]?.values?.[index]} - - {#if temp !== undefined && !isNaN(temp)} - + + + + + {#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} + {/each} - {#each weather.entries as entry, i (i)} - - - {#each weather.indexes as index, j (j)} - {#if entry.values && !isNaN(entry.values[index])} - - {/if} - {/each} - - {/each} - {#if winddir} - - - + + + {#each dates as date, i (i)} + {@const temp = hourly.temperature_2m[i]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each dates as date, i (i)} + {@const val = hourly.precipitation[i]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each dates as date, i (i)} + {@const prob = hourly.precipitation_probability[i]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each dates as date, i (i)} + {@const wind = hourly.windspeed_10m[i]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each dates as date, i (i)} + {@const hum = hourly.relative_humidity_2m[i]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each dates as date, i (i)} + {@const windDir = hourly.winddirection_10m[i]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + + + {:else} + - {/if} - {/each} - - {/if} - {/await} - -
Hourly weather details for {location.name}
TimeTime{weather.hourlyTime[index].getHours() < 10 ? '0' : ''}{weather.hourlyTime[ - index - ].getHours()}
Icons - -
Temp graph{temp?.toFixed(0)} + - {/if} + + +
{entry.title}{entry.name === 'precipitation' || entry.name === 'temperature_2m' - ? entry.values![index].toFixed(1) - : entry.values![index]}
Wind Dir.
Temperature - {#each weather.indexes as index, j (j)} - {#if weather.windDirections && !isNaN(weather.windDirections[index])} - + {temp?.toFixed(1) ?? '-'} +
Precipitation + {val?.toFixed(1) ?? '-'} +
Precip Prob. + {prob?.toFixed(0) ?? '-'} +
Wind + {wind?.toFixed(0) ?? '-'} +
Rel. Hum. + {hum?.toFixed(0) ?? '-'} +
Wind Dir. + {#if windDir != null && !isNaN(windDir)} +
+ -
+ + {/each} + + + +
+ {/if} + + + +
+
- {#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)} -
-
- - - Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} + + + {#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)} +
+
+ + + Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} +
+
+ + + + Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())} +
-
- - - - Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())} -
-
- {/await} + {/if} + {/if} + +
@@ -639,7 +1143,7 @@ }} > {modelSelected?.label} @@ -658,15 +1162,6 @@
-- 2.54.0 From 71c8a5751538fde315a78012a4804c4767e9dc0d Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 20:29:39 +0100 Subject: [PATCH 05/12] unify weather data fetching --- src/lib/services/index.ts | 28 + src/lib/services/weather.ts | 736 ++++++++++++++++++ src/routes/weather/14-day/+page.svelte | 79 +- src/routes/weather/compare/+page.svelte | 56 +- .../weather/week/[location]/+page.svelte | 127 +-- 5 files changed, 845 insertions(+), 181 deletions(-) create mode 100644 src/lib/services/index.ts create mode 100644 src/lib/services/weather.ts diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts new file mode 100644 index 0000000..1d5b913 --- /dev/null +++ b/src/lib/services/index.ts @@ -0,0 +1,28 @@ +export { + fetchWeekForecast, + fetchModelComparison, + fetchEnsembleForecast, + range, + getTimestamps, + getDates, + getValues, + getInt64Values, + extractValues, + unitToDisplayString +} from './weather'; + +export type { + WeatherLocation, + WeatherUnitParams, + MarkArea, + WeekForecastParams, + WeekHourlyData, + WeekDailyData, + WeekForecastResult, + ModelCompareParams, + ModelSeriesData, + ModelCompareResult, + EnsembleForecastParams, + EnsembleVariableData, + EnsembleForecastResult +} from './weather'; diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts new file mode 100644 index 0000000..8c09492 --- /dev/null +++ b/src/lib/services/weather.ts @@ -0,0 +1,736 @@ +/** + * Weather Data Service + * + * Centralized, type-safe weather data fetching using the Open-Meteo SDK + * with protobuf (FlatBuffers) transport for efficient data transfer. + * + * All weather data fetching flows through this service, providing: + * - Type-safe request parameters and response structures + * - Automatic retries with exponential backoff (via the SDK) + * - Efficient binary protobuf transport instead of JSON + * - Consistent timestamp and unit handling + */ +import { Unit } from '@openmeteo/sdk/unit'; +import { fetchWeatherApi } from 'openmeteo'; + +import { buildDaylightMarkAreas } from '$lib/utils/echarts'; + +import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values'; +import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time'; + +// ─── Constants ────────────────────────────────────────────────────────────────── + +const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast'; +const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble'; + +// ─── Core Helpers ─────────────────────────────────────────────────────────────── + +/** + * Generates an array of numbers from start (inclusive) to stop (exclusive) with the given step. + * Used to reconstruct timestamp arrays from the protobuf time/timeEnd/interval fields. + */ +export function range(start: number, stop: number, step: number): number[] { + return Array.from( + { length: Math.max(0, Math.ceil((stop - start) / step)) }, + (_, i) => start + i * step + ); +} + +/** + * Extracts timestamp array (in milliseconds, with UTC offset applied) from a VariablesWithTime block. + */ +export function getTimestamps(timeBlock: VariablesWithTime, utcOffsetSeconds: number): number[] { + const start = Number(timeBlock.time()); + const end = Number(timeBlock.timeEnd()); + const interval = timeBlock.interval(); + return range(start, end, interval).map((t) => (t + utcOffsetSeconds) * 1000); +} + +/** + * Extracts Date array (with UTC offset applied) from a VariablesWithTime block. + */ +export function getDates(timeBlock: VariablesWithTime, utcOffsetSeconds: number): Date[] { + return getTimestamps(timeBlock, utcOffsetSeconds).map((t) => new Date(t)); +} + +/** + * Extracts a Float32Array of values from a VariableWithValues, returning a regular number[]. + * Falls back to an empty array if no values are present. + */ +export function getValues(variable: VariableWithValues): number[] { + const arr = variable.valuesArray(); + if (!arr) return []; + return Array.from(arr); +} + +/** + * Extracts Int64 (BigInt) values from a VariableWithValues, converting to number[]. + * Used for variables stored as unix timestamps (e.g. sunrise, sunset). + */ +export function getInt64Values(variable: VariableWithValues): number[] { + const len = variable.valuesInt64Length(); + const result: number[] = []; + for (let i = 0; i < len; i++) { + const val = variable.valuesInt64(i); + result.push(val !== null ? Number(val) : 0); + } + return result; +} + +/** + * Converts the SDK Unit enum to a human-readable display string. + */ +export function unitToDisplayString(unit: Unit): string { + switch (unit) { + case Unit.celsius: + return '°C'; + case Unit.fahrenheit: + return '°F'; + case Unit.millimetre: + return 'mm'; + case Unit.inch: + return 'in'; + case Unit.kilometres_per_hour: + return 'km/h'; + case Unit.metre_per_second: + return 'm/s'; + case Unit.miles_per_hour: + return 'mph'; + case Unit.knots: + return 'kn'; + case Unit.percentage: + return '%'; + case Unit.hectopascal: + return 'hPa'; + case Unit.degree_direction: + return '°'; + case Unit.wmo_code: + return 'wmo code'; + case Unit.seconds: + return 's'; + case Unit.hours: + return 'h'; + case Unit.watt_per_square_metre: + return 'W/m²'; + case Unit.megajoule_per_square_metre: + return 'MJ/m²'; + case Unit.joule_per_kilogram: + return 'J/kg'; + case Unit.metre: + return 'm'; + case Unit.centimetre: + return 'cm'; + case Unit.kilogram_per_square_metre: + return 'kg/m²'; + case Unit.kilopascal: + return 'kPa'; + case Unit.pascal: + return 'Pa'; + case Unit.fraction: + return ''; + case Unit.dimensionless: + return ''; + case Unit.dimensionless_integer: + return ''; + case Unit.unix_time: + return 'unixtime'; + case Unit.grains_per_cubic_metre: + return 'grains/m³'; + case Unit.micrograms_per_cubic_metre: + return 'µg/m³'; + default: + return ''; + } +} + +// ─── Shared Types ─────────────────────────────────────────────────────────────── + +export interface WeatherLocation { + latitude: number; + longitude: number; +} + +export interface WeatherUnitParams { + temperature_unit?: 'celsius' | 'fahrenheit'; + wind_speed_unit?: 'kmh' | 'ms' | 'mph' | 'kn'; + precipitation_unit?: 'mm' | 'inch'; +} + +export type MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]; + +// ─── Week Forecast Types ──────────────────────────────────────────────────────── + +export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams { + model?: string; + forecast_days?: number; + past_days?: number; +} + +export interface WeekHourlyData { + temperature_2m: number[]; + precipitation: number[]; + precipitation_probability: number[]; + weather_code: number[]; + windspeed_10m: number[]; + winddirection_10m: number[]; + cloud_cover: number[]; + relative_humidity_2m: number[]; +} + +export interface WeekDailyData { + weather_code: number[]; + temperature_2m_max: number[]; + temperature_2m_min: number[]; + sunrise: number[]; + sunset: number[]; + sunshine_duration: number[]; + precipitation_sum: number[]; + windspeed_10m_max: number[]; + windgusts_10m_max: number[]; + winddirection_10m_dominant: number[]; +} + +export interface WeekForecastResult { + hourly: WeekHourlyData; + daily: WeekDailyData; + utcOffsetSeconds: number; + hourlyTimestamps: number[]; + hourlyDates: Date[]; + dailyDates: Date[]; + markAreas: MarkArea[]; +} + +// ─── Model Comparison Types ───────────────────────────────────────────────────── + +export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams { + hourlyVariables: string[]; + models: string[]; +} + +export interface ModelSeriesData { + modelName: string; + variables: Record; +} + +export interface ModelCompareResult { + models: ModelSeriesData[]; + timestamps: number[]; + utcOffsetSeconds: number; + markAreas: MarkArea[]; + units: Record; + /** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */ + hourlyFlat: Record; + hourlyUnitsFlat: Record; +} + +// ─── Ensemble Forecast Types ──────────────────────────────────────────────────── + +export interface EnsembleForecastParams extends WeatherLocation, WeatherUnitParams { + hourlyVariables: string[]; + models: string[]; + forecast_days?: number; +} + +export interface EnsembleVariableData { + members: number[][]; + average: number[]; + min: number[]; + max: number[]; + unit: string; +} + +export interface EnsembleForecastResult { + variables: Record; + timestamps: number[]; + utcOffsetSeconds: number; + markAreas: MarkArea[]; + /** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */ + hourlyFlat: Record; + hourlyUnitsFlat: Record; +} + +// ─── Week Forecast Fetch ──────────────────────────────────────────────────────── + +const WEEK_HOURLY_VARS = [ + 'temperature_2m', + 'precipitation', + 'precipitation_probability', + 'weather_code', + 'wind_speed_10m', + 'wind_direction_10m', + 'cloud_cover', + 'relative_humidity_2m' +] as const; + +const WEEK_DAILY_VARS = [ + 'weather_code', + 'temperature_2m_max', + 'temperature_2m_min', + 'sunrise', + 'sunset', + 'sunshine_duration', + 'precipitation_sum', + 'wind_speed_10m_max', + 'wind_gusts_10m_max', + 'wind_direction_10m_dominant' +] as const; + +/** + * Fetches the 7-day (week) weather forecast for a single location and model. + * Returns typed hourly and daily data structures. + */ +export async function fetchWeekForecast(params: WeekForecastParams): Promise { + const forecastDays = params.forecast_days ?? 6; + const pastDays = params.past_days ?? 1; + const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined; + + const apiParams: Record = { + latitude: params.latitude, + longitude: params.longitude, + hourly: WEEK_HOURLY_VARS.join(','), + daily: WEEK_DAILY_VARS.join(','), + temperature_unit: params.temperature_unit ?? 'celsius', + wind_speed_unit: params.wind_speed_unit ?? 'kmh', + precipitation_unit: params.precipitation_unit ?? 'mm', + forecast_days: forecastDays, + past_days: pastDays, + models: modelParam + }; + + // Remove undefined values + const cleanParams: Record = {}; + for (const [key, value] of Object.entries(apiParams)) { + if (value !== undefined) { + cleanParams[key] = String(value); + } + } + + const responses = await fetchWeatherApi(FORECAST_URL, cleanParams); + const response = responses[0]; + const utcOffsetSeconds = response.utcOffsetSeconds(); + + const hourlyBlock = response.hourly()!; + const dailyBlock = response.daily()!; + + // Hourly: variables are in the same order as WEEK_HOURLY_VARS + const hourlyTimestamps = getTimestamps(hourlyBlock, utcOffsetSeconds); + const hourlyDates = hourlyTimestamps.map((t) => new Date(t)); + + const hourly: WeekHourlyData = { + temperature_2m: getValues(hourlyBlock.variables(0)!), + precipitation: getValues(hourlyBlock.variables(1)!), + precipitation_probability: getValues(hourlyBlock.variables(2)!), + weather_code: getValues(hourlyBlock.variables(3)!), + windspeed_10m: getValues(hourlyBlock.variables(4)!), + winddirection_10m: getValues(hourlyBlock.variables(5)!), + cloud_cover: getValues(hourlyBlock.variables(6)!), + relative_humidity_2m: getValues(hourlyBlock.variables(7)!) + }; + + // Daily: variables are in the same order as WEEK_DAILY_VARS + const dailyDates = getDates(dailyBlock, utcOffsetSeconds); + + const sunriseVar = dailyBlock.variables(3)!; + const sunsetVar = dailyBlock.variables(4)!; + + const daily: WeekDailyData = { + weather_code: getValues(dailyBlock.variables(0)!), + temperature_2m_max: getValues(dailyBlock.variables(1)!), + temperature_2m_min: getValues(dailyBlock.variables(2)!), + sunrise: getInt64Values(sunriseVar), + sunset: getInt64Values(sunsetVar), + sunshine_duration: getValues(dailyBlock.variables(5)!), + precipitation_sum: getValues(dailyBlock.variables(6)!), + windspeed_10m_max: getValues(dailyBlock.variables(7)!), + windgusts_10m_max: getValues(dailyBlock.variables(8)!), + winddirection_10m_dominant: getValues(dailyBlock.variables(9)!) + }; + + const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset, utcOffsetSeconds); + + return { + hourly, + daily, + utcOffsetSeconds, + hourlyTimestamps, + hourlyDates, + dailyDates, + markAreas + }; +} + +// ─── Model Comparison Fetch ───────────────────────────────────────────────────── + +/** + * Fetches forecast data for multiple models for comparison. + * Also fetches daily sunrise/sunset for daylight mark areas. + * + * Returns both a typed model array structure and a flat record structure + * compatible with existing chart utilities. + */ +export async function fetchModelComparison( + params: ModelCompareParams +): Promise { + const forecastApiParams: Record = { + latitude: String(params.latitude), + longitude: String(params.longitude), + hourly: params.hourlyVariables.join(','), + models: params.models.join(','), + daily: 'sunrise,sunset', + temperature_unit: params.temperature_unit ?? 'celsius', + wind_speed_unit: params.wind_speed_unit ?? 'kmh', + precipitation_unit: params.precipitation_unit ?? 'mm' + }; + + const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams); + + // With multiple models, we get one response per model + const firstResponse = responses[0]; + const utcOffsetSeconds = firstResponse.utcOffsetSeconds(); + + // Extract timestamps from the first response's hourly block + const hourlyBlock = firstResponse.hourly()!; + const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds); + + // Extract sunrise/sunset from the first response's daily block + let markAreas: MarkArea[] = []; + const dailyBlock = firstResponse.daily(); + if (dailyBlock) { + const sunriseVar = dailyBlock.variables(0)!; + const sunsetVar = dailyBlock.variables(1)!; + const sunrise = getInt64Values(sunriseVar); + const sunset = getInt64Values(sunsetVar); + markAreas = buildDaylightMarkAreas(sunrise, sunset, utcOffsetSeconds); + } + + // Process each model's response + const models: ModelSeriesData[] = []; + const hourlyFlat: Record = {}; + const hourlyUnitsFlat: Record = {}; + const units: Record = {}; + + // Add time to flat record + const timeInUnixSeconds = range( + Number(hourlyBlock.time()), + Number(hourlyBlock.timeEnd()), + hourlyBlock.interval() + ); + hourlyFlat['time'] = timeInUnixSeconds; + + for (const response of responses) { + const modelHourly = response.hourly(); + if (!modelHourly) continue; + + // Determine model name from the response + const modelEnum = response.model(); + const modelName = modelEnumToString(modelEnum); + + const modelData: ModelSeriesData = { + modelName, + variables: {} + }; + + for (let vi = 0; vi < params.hourlyVariables.length; vi++) { + const varName = params.hourlyVariables[vi]; + const variable = modelHourly.variables(vi); + if (!variable) continue; + + const values = getValues(variable); + modelData.variables[varName] = values; + + // Build flat key like "temperature_2m_icon_seamless" + const flatKey = `${varName}_${modelName}`; + hourlyFlat[flatKey] = values; + + // Record unit + const unitStr = unitToDisplayString(variable.unit()); + units[varName] = unitStr; + hourlyUnitsFlat[flatKey] = unitStr; + } + + models.push(modelData); + } + + return { + models, + timestamps, + utcOffsetSeconds, + markAreas, + units, + hourlyFlat, + hourlyUnitsFlat + }; +} + +// ─── Ensemble Forecast Fetch ──────────────────────────────────────────────────── + +/** + * Fetches ensemble forecast data from the ensemble API. + * Separately fetches daily sunrise/sunset from the standard forecast API. + * + * Returns typed ensemble data with per-variable member arrays, averages, and spreads, + * plus a flat record structure for compatibility with existing chart utilities. + */ +export async function fetchEnsembleForecast( + params: EnsembleForecastParams +): Promise { + const forecastDays = params.forecast_days ?? 14; + + const ensembleParams: Record = { + latitude: String(params.latitude), + longitude: String(params.longitude), + hourly: params.hourlyVariables.join(','), + models: params.models.join(','), + forecast_days: String(forecastDays), + temperature_unit: params.temperature_unit ?? 'celsius', + wind_speed_unit: params.wind_speed_unit ?? 'kmh', + precipitation_unit: params.precipitation_unit ?? 'mm' + }; + + const dailyParams: Record = { + latitude: String(params.latitude), + longitude: String(params.longitude), + daily: 'sunrise,sunset', + forecast_days: String(forecastDays), + temperature_unit: params.temperature_unit ?? 'celsius' + }; + + // Fetch ensemble and daily data in parallel + const [ensembleResponses, dailyResponses] = await Promise.all([ + fetchWeatherApi(ENSEMBLE_URL, ensembleParams), + fetchWeatherApi(FORECAST_URL, dailyParams) + ]); + + const ensembleResponse = ensembleResponses[0]; + const utcOffsetSeconds = ensembleResponse.utcOffsetSeconds(); + + const hourlyBlock = ensembleResponse.hourly()!; + const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds); + const timeLength = timestamps.length; + + // Extract sunrise/sunset for mark areas + let markAreas: MarkArea[] = []; + if (dailyResponses.length > 0) { + const dailyResponse = dailyResponses[0]; + const dailyBlock = dailyResponse.daily(); + if (dailyBlock) { + const sunrise = getInt64Values(dailyBlock.variables(0)!); + const sunset = getInt64Values(dailyBlock.variables(1)!); + markAreas = buildDaylightMarkAreas(sunrise, sunset, dailyResponse.utcOffsetSeconds()); + } + } + + // Process ensemble variables + // Each requested variable will have multiple entries in the variables list (one per ensemble member) + const variables: Record = {}; + const hourlyFlat: Record = {}; + const hourlyUnitsFlat: Record = {}; + + // Add time to flat record + const timeInUnixSeconds = range( + Number(hourlyBlock.time()), + Number(hourlyBlock.timeEnd()), + hourlyBlock.interval() + ); + hourlyFlat['time'] = timeInUnixSeconds; + + // Group variables by their requested variable name + // The SDK provides variables indexed sequentially: + // For N requested variables and M ensemble members, we get N*M variables + // ordered as: var0_member0, var0_member1, ..., var0_memberM-1, var1_member0, ... + const totalVariables = hourlyBlock.variablesLength(); + const numRequestedVars = params.hourlyVariables.length; + + if (totalVariables > 0 && numRequestedVars > 0) { + const membersPerVar = Math.floor(totalVariables / numRequestedVars); + + for (let vi = 0; vi < numRequestedVars; vi++) { + const varName = params.hourlyVariables[vi]; + const members: number[][] = []; + let unitStr = ''; + + for (let mi = 0; mi < membersPerVar; mi++) { + const varIdx = vi * membersPerVar + mi; + const variable = hourlyBlock.variables(varIdx); + if (!variable) continue; + + const values = getValues(variable); + members.push(values); + + if (mi === 0) { + unitStr = unitToDisplayString(variable.unit()); + } + + // Build flat key compatible with JSON API format + const memberStr = String(mi).padStart(2, '0'); + const flatKey = `${varName}_member${memberStr}`; + hourlyFlat[flatKey] = values; + hourlyUnitsFlat[flatKey] = unitStr; + } + + // Calculate average, min, max across members + const average = new Array(timeLength).fill(0); + const min = new Array(timeLength).fill(Infinity); + const max = new Array(timeLength).fill(-Infinity); + + for (let t = 0; t < timeLength; t++) { + let count = 0; + for (const memberValues of members) { + const val = memberValues[t]; + if (val !== null && val !== undefined && !isNaN(val)) { + average[t] += val; + count++; + if (val < min[t]) min[t] = val; + if (val > max[t]) max[t] = val; + } + } + if (count > 0) { + average[t] = Math.round((average[t] / count) * 10) / 10; + } + if (min[t] === Infinity) min[t] = 0; + if (max[t] === -Infinity) max[t] = 0; + } + + variables[varName] = { + members, + average, + min, + max, + unit: unitStr + }; + } + } + + return { + variables, + timestamps, + utcOffsetSeconds, + markAreas, + hourlyFlat, + hourlyUnitsFlat + }; +} + +// ─── Model Enum Mapping ───────────────────────────────────────────────────────── + +/** + * Maps the SDK Model enum integer to a string model name. + * This table must stay in sync with the @openmeteo/sdk Model enum. + */ +function modelEnumToString(modelEnum: number): string { + const modelMap: Record = { + 0: 'undefined', + 1: 'best_match', + 2: 'gfs_seamless', + 3: 'gfs_global', + 4: 'gfs_hrrr', + 5: 'meteofrance_seamless', + 6: 'meteofrance_arpege_seamless', + 7: 'meteofrance_arpege_world', + 8: 'meteofrance_arpege_europe', + 9: 'meteofrance_arome_seamless', + 10: 'meteofrance_arome_france', + 11: 'meteofrance_arome_france_hd', + 12: 'jma_seamless', + 13: 'jma_msm', + 14: 'jms_gsm', + 15: 'jma_gsm', + 16: 'gem_seamless', + 17: 'gem_global', + 18: 'gem_regional', + 19: 'gem_hrdps_continental', + 20: 'icon_seamless', + 21: 'icon_global', + 22: 'icon_eu', + 23: 'icon_d2', + 24: 'ecmwf_ifs04', + 25: 'metno_nordic', + 26: 'era5_seamless', + 27: 'era5', + 28: 'cerra', + 29: 'era5_land', + 30: 'ecmwf_ifs', + 31: 'gwam', + 32: 'ewam', + 33: 'glofas_seamless_v3', + 34: 'glofas_forecast_v3', + 35: 'glofas_consolidated_v3', + 36: 'glofas_seamless_v4', + 37: 'glofas_forecast_v4', + 38: 'glofas_consolidated_v4', + 39: 'gfs025', + 40: 'gfs05', + 41: 'CMCC_CM2_VHR4', + 42: 'FGOALS_f3_H_highresSST', + 43: 'FGOALS_f3_H', + 44: 'HiRAM_SIT_HR', + 45: 'MRI_AGCM3_2_S', + 46: 'EC_Earth3P_HR', + 47: 'MPI_ESM1_2_XR', + 48: 'NICAM16_8S', + 49: 'cams_europe', + 50: 'cams_global', + 51: 'cfsv2', + 52: 'era5_ocean', + 53: 'cma_grapes_global', + 54: 'bom_access_global', + 55: 'bom_access_global_ensemble', + 56: 'arpae_cosmo_seamless', + 57: 'arpae_cosmo_2i', + 58: 'arpae_cosmo_2i_ruc', + 59: 'arpae_cosmo_5m', + 60: 'ecmwf_ifs025', + 61: 'ecmwf_aifs025', + 62: 'gfs013', + 63: 'gfs_graphcast025', + 64: 'ecmwf_wam025', + 65: 'meteofrance_wave', + 66: 'meteofrance_currents', + 67: 'ecmwf_wam025_ensemble', + 68: 'ncep_gfswave025', + 69: 'ncep_gefswave025', + 70: 'knmi_seamless', + 71: 'knmi_harmonie_arome_europe', + 72: 'knmi_harmonie_arome_netherlands', + 73: 'dmi_seamless', + 74: 'dmi_harmonie_arome_europe', + 75: 'metno_seamless', + 76: 'era5_ensemble', + 77: 'ecmwf_ifs_analysis', + 78: 'ecmwf_ifs_long_window', + 79: 'ecmwf_ifs_analysis_long_window', + 80: 'ukmo_global_deterministic_10km', + 81: 'ukmo_uk_deterministic_2km', + 82: 'ukmo_seamless', + 83: 'ncep_gfswave016', + 84: 'ncep_nbm_conus', + 85: 'ukmo_global_ensemble_20km', + 86: 'ecmwf_aifs025_single', + 87: 'jma_jaxa_himawari', + 88: 'eumetsat_sarah3', + 89: 'eumetsat_lsa_saf_msg', + 90: 'eumetsat_lsa_saf_iodc', + 91: 'satellite_radiation_seamless', + 92: 'kma_gdps', + 93: 'kma_ldps', + 94: 'kma_seamless', + 95: 'italia_meteo_arpae_icon_2i', + 96: 'ukmo_uk_ensemble_2km', + 97: 'meteofrance_arome_france_hd_15min', + 98: 'meteofrance_arome_france_15min', + 99: 'meteoswiss_icon_ch1', + 100: 'meteoswiss_icon_ch2', + 101: 'meteoswiss_icon_ch1_ensemble', + 102: 'meteoswiss_icon_ch2_ensemble', + 103: 'meteoswiss_icon_seamless', + 104: 'ncep_nam_conus', + 105: 'icon_d2_ruc', + 106: 'ecmwf_seas5', + 107: 'ecmwf_ec46', + 108: 'ecmwf_seasonal_seamless', + 109: 'ecmwf_ifs_seamless', + 110: 'jma_jaxa_mtg_fci', + 111: 'gem_hrdps_west' + }; + return modelMap[modelEnum] ?? `model_${modelEnum}`; +} diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index 60b6772..1500798 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -7,14 +7,9 @@ import { buildAverageSeries, buildCurrentTimeSeries, - buildDaylightMarkAreas, buildDaylightSeries, buildSpreadSeries, - calculateAverage, - calculateSpread, composeChartOption, - convertTimestamps, - findUnit, getThemeColors } from '$lib/utils/echarts'; @@ -23,6 +18,12 @@ import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; + import { + type EnsembleForecastResult, + type MarkArea, + fetchEnsembleForecast + } from '$lib/services/weather'; + import { defaultParameters } from '../options'; import type * as echarts from 'echarts'; @@ -52,11 +53,10 @@ // ─── Cached API Response ──────────────────────────────────────────────────── interface FetchedData { - hourly: Record; - hourly_units: Record; - utc_offset_seconds: number; - markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + ensembleResult: EnsembleForecastResult; timestamps: number[]; + utc_offset_seconds: number; + markAreas: MarkArea[]; } let fetchedData: FetchedData | null = $state(null); @@ -92,35 +92,22 @@ chartInstances = []; chartComponents = []; - const [dataDaily, dataReq] = await Promise.all([ - fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14` - ), - fetch( - `https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&forecast_days=14` - ) - ]); - - const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]); - - let markAreas: FetchedData['markAreas'] = []; - - if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) { - markAreas = buildDaylightMarkAreas( - wd.daily.sunrise, - wd.daily.sunset, - data.utc_offset_seconds - ); - } - - const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); + const result: EnsembleForecastResult = await fetchEnsembleForecast({ + latitude: location.latitude!, + longitude: location.longitude!, + hourlyVariables: hourlyVars, + models: modelList, + forecast_days: 14, + 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' + }); fetchedData = { - hourly: data.hourly, - hourly_units: data.hourly_units, - utc_offset_seconds: data.utc_offset_seconds, - markAreas, - timestamps + ensembleResult: result, + timestamps: result.timestamps, + utc_offset_seconds: result.utcOffsetSeconds, + markAreas: result.markAreas }; loading = false; @@ -134,32 +121,26 @@ $effect(() => { if (!fetchedData) return; - const { - hourly: hourlyData, - hourly_units, - utc_offset_seconds, - markAreas, - timestamps - } = fetchedData; + const { ensembleResult, timestamps, utc_offset_seconds, markAreas } = fetchedData; const _showLegend = showLegend; const colors = getThemeColors(); const variableCount = params.hourly?.length || 0; - const timeLength = (hourlyData.time as number[]).length; const newOptions: Array> = []; for (let vi = 0; vi < variableCount; vi++) { const variable = params.hourly![vi]; - const unit = findUnit(hourly_units, hourlyData, variable); + const varData = ensembleResult.variables[variable]; + if (!varData) continue; - const { average } = calculateAverage(hourlyData, variable, timeLength); - const { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength); + const unit = varData.unit; + const { average, min: minValues, max: maxValues } = varData; const series: Array> = []; const spreadData: Array<[number, number, number]> = minValues.map( - (min, index) => - [timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number] + (minVal, index) => + [timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number] ); series.push(...buildSpreadSeries({ variable, spreadData })); diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index 6a1172d..9d39cd8 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -8,12 +8,10 @@ import { buildAverageSeries, buildCurrentTimeSeries, - buildDaylightMarkAreas, buildDaylightSeries, buildModelSeries, calculateAverage, composeChartOption, - convertTimestamps, findUnit, getThemeColors } from '$lib/utils/echarts'; @@ -24,6 +22,12 @@ import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; + import { + type MarkArea, + type ModelCompareResult, + fetchModelComparison + } from '$lib/services/weather'; + import { hourly, models as modelsFlat } from '../options'; import { defaultParameters } from '../options'; @@ -65,7 +69,7 @@ hourly: Record; hourly_units: Record; utc_offset_seconds: number; - markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + markAreas: MarkArea[]; timestamps: number[]; } @@ -102,38 +106,22 @@ chartInstances = []; chartComponents = []; - const dataReq = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise` - ); - const data = await dataReq.json(); - - let markAreas: FetchedData['markAreas'] = []; - - if ('daily' in data) { - let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_'); - dailyFirstModelKey.shift(); - dailyFirstModelKey = dailyFirstModelKey.join('_'); - - const sunriseKey = 'sunrise_' + dailyFirstModelKey; - const sunsetKey = 'sunset_' + dailyFirstModelKey; - - if (sunriseKey in data.daily && sunsetKey in data.daily) { - markAreas = buildDaylightMarkAreas( - data.daily[sunriseKey], - data.daily[sunsetKey], - data.utc_offset_seconds - ); - } - } - - const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); + const result: ModelCompareResult = await fetchModelComparison({ + latitude: location.latitude!, + longitude: location.longitude!, + hourlyVariables: hourlyVars, + models: modelList, + 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' + }); fetchedData = { - hourly: data.hourly, - hourly_units: data.hourly_units, - utc_offset_seconds: data.utc_offset_seconds, - markAreas, - timestamps + hourly: result.hourlyFlat, + hourly_units: result.hourlyUnitsFlat, + utc_offset_seconds: result.utcOffsetSeconds, + markAreas: result.markAreas, + timestamps: result.timestamps }; loading = false; @@ -158,7 +146,7 @@ const colors = getThemeColors(); const variableCount = params.hourly?.length || 0; - const timeLength = (hourlyData.time as number[]).length; + const timeLength = timestamps.length; const newOptions: Array> = []; for (let vi = 0; vi < variableCount; vi++) { diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 3bb4081..515ef06 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -7,13 +7,7 @@ import { storedLocation } from '$lib/stores/settings'; - import { - buildCurrentTimeSeries, - buildDaylightMarkAreas, - buildDaylightSeries, - convertTimestamps, - getThemeColors - } from '$lib/utils/echarts'; + import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts'; import { pad } from '$lib/utils/index'; import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts'; @@ -21,6 +15,14 @@ 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'; @@ -61,42 +63,16 @@ // ─── Fetched Data ─────────────────────────────────────────────────────────── - interface HourlyData { - time: number[]; - temperature_2m: number[]; - precipitation: number[]; - precipitation_probability: number[]; - weather_code: number[]; - windspeed_10m: number[]; - winddirection_10m: number[]; - cloud_cover: number[]; - relative_humidity_2m: number[]; - } - - interface DailyData { - time: string[]; - weather_code: number[]; - temperature_2m_max: number[]; - temperature_2m_min: number[]; - sunrise: number[]; - sunset: number[]; - sunshine_duration: number[]; - precipitation_sum: number[]; - windspeed_10m_max: number[]; - windgusts_10m_max: number[]; - winddirection_10m_dominant: number[]; - } - interface FetchedHourly { - hourly: HourlyData; + hourly: WeekHourlyData; utc_offset_seconds: number; timestamps: number[]; hourlyDates: Date[]; - markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + markAreas: MarkArea[]; } interface FetchedDaily { - daily: DailyData; + daily: WeekDailyData; dailyDates: Date[]; } @@ -276,73 +252,28 @@ chartInstances = []; chartComponents = []; - const model = modelList[0]; - const hourlyVars = [ - 'temperature_2m', - 'precipitation', - 'precipitation_probability', - 'weather_code', - 'windspeed_10m', - 'winddirection_10m', - 'cloud_cover', - 'relative_humidity_2m' - ].join(','); - - const dailyVars = [ - 'weather_code', - 'temperature_2m_max', - 'temperature_2m_min', - 'sunrise', - 'sunset', - 'sunshine_duration', - 'precipitation_sum', - 'windspeed_10m_max', - 'windgusts_10m_max', - 'winddirection_10m_dominant' - ].join(','); - - const baseParams = `latitude=${loc.latitude}&longitude=${loc.longitude}&temperature_unit=${params.temperature_unit}&wind_speed_unit=${params.wind_speed_unit}&precipitation_unit=${params.precipitation_unit}`; - const modelParam = model === 'best_match' ? '' : `&models=${model}`; - - const [hourlyResp, dailyResp] = await Promise.all([ - fetch( - `https://api.open-meteo.com/v1/forecast?${baseParams}&hourly=${hourlyVars}${modelParam}&timeformat=unixtime&forecast_days=6&past_days=1&daily=sunrise,sunset` - ), - fetch( - `https://api.open-meteo.com/v1/forecast?${baseParams}&daily=${dailyVars}${modelParam}&timeformat=unixtime&forecast_days=6&past_days=1` - ) - ]); - - const [hourlyJson, dailyJson] = await Promise.all([hourlyResp.json(), dailyResp.json()]); - - const utcOffset = hourlyJson.utc_offset_seconds ?? 0; - const timestamps = convertTimestamps(hourlyJson.hourly.time, utcOffset); - const hourlyDates = timestamps.map((t: number) => new Date(t)); - - let markAreas: FetchedHourly['markAreas'] = []; - if (hourlyJson.daily?.sunrise && hourlyJson.daily?.sunset) { - markAreas = buildDaylightMarkAreas( - hourlyJson.daily.sunrise, - hourlyJson.daily.sunset, - utcOffset - ); - } + 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: hourlyJson.hourly, - utc_offset_seconds: utcOffset, - timestamps, - hourlyDates, - markAreas + hourly: result.hourly, + utc_offset_seconds: result.utcOffsetSeconds, + timestamps: result.hourlyTimestamps, + hourlyDates: result.hourlyDates, + markAreas: result.markAreas }; - const dailyDates = (dailyJson.daily.time as number[]).map( - (t: number) => new Date((t + (dailyJson.utc_offset_seconds ?? 0)) * 1000) - ); - fetchedDaily = { - daily: dailyJson.daily, - dailyDates + daily: result.daily, + dailyDates: result.dailyDates }; loading = false; -- 2.54.0 From 5180c121c4be779cbf6c343f7ed456af538c0824 Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 20:46:17 +0100 Subject: [PATCH 06/12] wip improvements --- src/lib/services/index.ts | 1 - src/lib/services/weather.ts | 10 +- src/routes/weather/canvas/temp-gradient.ts | 8 +- src/routes/weather/utils/colors.ts | 11 +- .../weather/week/[location]/+page.svelte | 1406 ++++++++++++----- 5 files changed, 1033 insertions(+), 403 deletions(-) diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 1d5b913..a159018 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -7,7 +7,6 @@ export { getDates, getValues, getInt64Values, - extractValues, unitToDisplayString } from './weather'; diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index 8c09492..417b470 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -175,6 +175,8 @@ export interface WeekHourlyData { winddirection_10m: number[]; cloud_cover: number[]; relative_humidity_2m: number[]; + apparent_temperature: number[]; + dew_point_2m: number[]; } export interface WeekDailyData { @@ -259,7 +261,9 @@ const WEEK_HOURLY_VARS = [ 'wind_speed_10m', 'wind_direction_10m', 'cloud_cover', - 'relative_humidity_2m' + 'relative_humidity_2m', + 'apparent_temperature', + 'dew_point_2m' ] as const; const WEEK_DAILY_VARS = [ @@ -324,7 +328,9 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise { if (ctx && series) { const tempGradientFill = ctx.createLinearGradient(0, 0, 0, config.maxY); - tempGradientFill.addColorStop(0, getColor(config.maxTemp.toFixed(0), unit) + '5c'); - tempGradientFill.addColorStop(0.25, getColor(config.maxTemp.toFixed(0), unit) + '5c'); - tempGradientFill.addColorStop(0.85, getColor(config.minTemp.toFixed(0), unit) + '06'); - tempGradientFill.addColorStop(1, getColor(config.minTemp.toFixed(0), unit) + '00'); + tempGradientFill.addColorStop(0, getColor(config.maxTemp, unit) + '5c'); + tempGradientFill.addColorStop(0.25, getColor(config.maxTemp, unit) + '5c'); + tempGradientFill.addColorStop(0.85, getColor(config.minTemp, unit) + '06'); + tempGradientFill.addColorStop(1, getColor(config.minTemp, unit) + '00'); ctx.beginPath(); ctx.moveTo( diff --git a/src/routes/weather/utils/colors.ts b/src/routes/weather/utils/colors.ts index 777f8fc..7ad1963 100644 --- a/src/routes/weather/utils/colors.ts +++ b/src/routes/weather/utils/colors.ts @@ -25,19 +25,18 @@ export function rgbToHex(rgb: string) { return hex; } -export const getColor = (tempString: string, unit = 'celsius'): string => { +export const getColor = (temperature: number, unit = 'celsius'): string => { let index = 0; - const temp = Number(tempString); if (unit === 'celsius') { - if (temp <= -40) { + if (temperature <= -40) { index = 0; - } else if (temp >= 60) { + } else if (temperature >= 60) { index = colorScaleHex.length - 1; } else { - index = temp + 40; + index = temperature + 40; } } else { - const tempInCelsius = Math.round(((temp - 32) * 5) / 9); + const tempInCelsius = Math.round(((temperature - 32) * 5) / 9); if (tempInCelsius <= -40) { index = 0; } else if (tempInCelsius >= 60) { diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 515ef06..2ace8c2 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -29,12 +29,10 @@ import type { GeoLocation } from '$lib/stores/settings'; - // ─── Constants ────────────────────────────────────────────────────────────── - const CHART_GROUP = 'week-meteogram'; const MS_PER_DAY = 24 * 3600 * 1000; - - // ─── State ────────────────────────────────────────────────────────────────── + const CELL_W = 38; + const CURVE_H = 120; let params = $state({ latitude: [$storedLocation.latitude], @@ -60,8 +58,8 @@ let selectedDayIndex = $state(1); let tableScrollDiv: HTMLElement | undefined = $state(); - - // ─── Fetched Data ─────────────────────────────────────────────────────────── + let hourlyInterval = $state<1 | 3>(3); + let showDetailedCharts = $state(false); interface FetchedHourly { hourly: WeekHourlyData; @@ -79,8 +77,6 @@ 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; @@ -138,8 +134,6 @@ } } - // ─── Lifecycle ────────────────────────────────────────────────────────────── - onMount(() => { mounted = true; @@ -171,8 +165,6 @@ chartComponents = []; }); - // ─── Helpers ──────────────────────────────────────────────────────────────── - function handleChartReady(chart: echarts.ECharts): void { chart.group = CHART_GROUP; chartInstances = [...chartInstances, chart]; @@ -239,7 +231,69 @@ return `rgba(0, 240, 240, ${hum ** 3.8 / 10 ** 8.2})`; } - // ─── Data Fetching ────────────────────────────────────────────────────────── + function getWindArrowRotation(deg: number): string { + return `rotate(${deg}deg)`; + } + + function getDayLabel(date: Date): string { + const diff = Math.round( + (new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() - + new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) / + MS_PER_DAY + ); + if (diff === 0) return 'Today'; + if (diff === 1) return 'Tomorrow'; + if (diff === -1) return 'Yesterday'; + return `${date.getMonth() + 1}-${date.getDate()}`; + } + + function getFilteredIndices(dates: Date[]): number[] { + if (hourlyInterval === 1) { + return dates.map((_, i) => i); + } + return dates.reduce((acc, date, i) => { + if (date.getHours() % 3 === 0) acc.push(i); + return acc; + }, []); + } + + function getPrecipBarHeight(val: number, maxVal: number): number { + if (!val || val <= 0 || !maxVal) return 0; + return Math.min(100, (val / Math.max(maxVal, 1)) * 100); + } + + function buildCurvePath(temps: number[], indices: number[], minT: number, maxT: number): string { + if (indices.length < 2) return ''; + const rangeT = maxT - minT || 1; + const points: [number, number][] = indices.map((idx, i) => { + const x = i * CELL_W + CELL_W / 2; + const t = temps[idx] ?? minT; + const y = CURVE_H - 20 - ((t - minT) / rangeT) * (CURVE_H - 40); + return [x, y]; + }); + + let d = `M ${points[0][0]},${points[0][1]}`; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const curr = points[i]; + const cpx1 = prev[0] + (curr[0] - prev[0]) * 0.4; + const cpx2 = prev[0] + (curr[0] - prev[0]) * 0.6; + d += ` C ${cpx1},${prev[1]} ${cpx2},${curr[1]} ${curr[0]},${curr[1]}`; + } + return d; + } + + function buildAreaPath(temps: number[], indices: number[], minT: number, maxT: number): string { + const curvePath = buildCurvePath(temps, indices, minT, maxT); + if (!curvePath) return ''; + const lastX = (indices.length - 1) * CELL_W + CELL_W / 2; + const firstX = CELL_W / 2; + return `${curvePath} L ${lastX},${CURVE_H} L ${firstX},${CURVE_H} Z`; + } + + function getCloudOpacity(cover: number): number { + return Math.min(0.7, (cover ?? 0) / 120); + } $effect(() => { const loc = location; @@ -259,7 +313,7 @@ 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, + forecast_days: 7, past_days: 1 }); @@ -282,8 +336,6 @@ loadData(); }); - // ─── Chart Option Building ────────────────────────────────────────────────── - $effect(() => { if (!fetchedHourly) return; @@ -361,8 +413,6 @@ } ]; - // ── Chart 1: Temperature + Cloud Cover ────────────────────────────── - const tempOption: Record = { title: { text: 'Temperature & Cloud Cover', @@ -481,8 +531,6 @@ textStyle: { color: colors.text } }; - // ── Chart 2: Precipitation + Probability ──────────────────────────── - const precipOption: Record = { title: { text: 'Precipitation & Probability', @@ -585,8 +633,6 @@ textStyle: { color: colors.text } }; - // ── Chart 3: Wind & Humidity ───────────────────────────────────────── - const windOption: Record = { title: { text: 'Wind Speed & Humidity', @@ -722,51 +768,36 @@ -
+
- -
- {#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)} - - {/if} - {/each} - {/if} -
- -
-

- {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} - - {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)' - : ''} - -

- -
- - - - - {#each chartOptions as option, i (i)} - - {/each} - - - - {#if fetchedHourly} - {@const hourly = fetchedHourly.hourly} - {@const dates = fetchedHourly.hourlyDates} -
-

Hourly Details

-
-
- - - - - - - {#each dates as date, i (i)} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - - - - {#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} - - {/each} - + {windMax?.toFixed(0) ?? '-'}-{gustMax?.toFixed(0) ?? '-'} + - - - - {#each dates as date, i (i)} - {@const temp = hourly.temperature_2m[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - +
+ + + + + {Number(precipSum ?? 0).toFixed(0)}{params.precipitation_unit === 'mm' + ? ' mm' + : "'"} + +
- - - - {#each dates as date, i (i)} - {@const val = hourly.precipitation[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - +
+ + + + {Number((sunDuration ?? 0) / 3600).toFixed(0)} h +
- - - - {#each dates as date, i (i)} - {@const prob = hourly.precipitation_probability[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - - - - {#each dates as date, i (i)} - {@const wind = hourly.windspeed_10m[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - - - - {#each dates as date, i (i)} - {@const hum = hourly.relative_humidity_2m[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - - - - {#each dates as date, i (i)} - {@const windDir = hourly.winddirection_10m[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - -
Hourly weather details for {location.name}
Time - {pad(date.getHours())} -
- - - - - - +
+ + -
Temperature - {temp?.toFixed(1) ?? '-'} -
Precipitation - {val?.toFixed(1) ?? '-'} -
Precip Prob. - {prob?.toFixed(0) ?? '-'} -
Wind - {wind?.toFixed(0) ?? '-'} -
Rel. Hum. - {hum?.toFixed(0) ?? '-'} -
Wind Dir. - {#if windDir != null && !isNaN(windDir)} -
- + {#if windDir != null && !isNaN(windDir)} +
+
+
- {:else} - - - {/if} -
+
+ {/if} + + {/if} + {/each} + {/if} +
+
+ + + {#if fetchedHourly} + {@const hourly = fetchedHourly.hourly} + {@const dates = fetchedHourly.hourlyDates} + {@const filteredIdx = getFilteredIndices(dates)} + {@const maxPrecip = Math.max( + ...hourly.precipitation.filter((v) => v != null && !isNaN(v)), + 0.1 + )} + {@const allTemps = hourly.temperature_2m.filter((t) => t != null && !isNaN(t))} + {@const minTemp = Math.min(...allTemps)} + {@const maxTemp = Math.max(...allTemps)} + {@const totalWidth = filteredIdx.length * CELL_W} + +
+

+ {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} - Hourly +

+
+ 3h + + 1h +
+
+ + +
+
+ +
+
+
+ + + + + {#each filteredIdx as idx, i (idx)} + {@const t = hourly.temperature_2m[idx]} + + {/each} + + + + + {#each filteredIdx as idx, i (idx)} + {@const cloud = hourly.cloud_cover[idx]} + + {/each} + + + + + + + + + {#each filteredIdx as idx, i (idx)} + {@const t = hourly.temperature_2m[idx]} + {@const rangeT = maxTemp - minTemp || 1} + {@const y = CURVE_H - 20 - ((t - minTemp) / rangeT) * (CURVE_H - 40)} + + {t?.toFixed(0) ?? '-'}° + + {/each} + + + +
+ {#each filteredIdx as idx, i (idx)} + {@const wCode = hourly.weather_code[idx]} + {@const daytime = isDaytimeHour(dates[idx].getHours())} +
+ + + +
+ {/each} +
+
+
+ + + + + + + + + {#each filteredIdx as idx (idx)} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const val = hourly.precipitation[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const prob = hourly.precipitation_probability[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const val = hourly.precipitation[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const _wind = hourly.windspeed_10m[idx]} + {@const windDir = hourly.winddirection_10m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const temp = hourly.apparent_temperature[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const hum = hourly.relative_humidity_2m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const windDir = hourly.winddirection_10m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const wind = hourly.windspeed_10m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const dp = hourly.dew_point_2m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + +
Hourly weather details for {location.name}
+ + + + + {pad(date.getHours())}00 +
+
+ {hourlyInterval}h + {params.precipitation_unit === 'mm' ? 'mm' : 'in'} +
+
+
+ {#if val > 0} +
+ {/if} +
+ {val > 0 ? val.toFixed(1) : ''} +
+ % + + {prob != null ? prob.toFixed(0) + '%' : '-'} +
+ + + + + {#if val > 0} + + + + {/if} +
+ + + + + {#if windDir != null && !isNaN(windDir)} +
+ + + +
+ {/if} +
+ + + + + {temp != null ? temp.toFixed(0) + '°' : '-'} +
+ % + + {hum != null ? hum.toFixed(0) + '%' : '-'} +
+ + + + + {#if windDir != null && !isNaN(windDir)} +
+ + + +
+ {:else} + - + {/if} +
+
+ + + +
+
+ {wind?.toFixed(0) ?? '-'} +
+ + + + + {dp != null ? dp.toFixed(0) + '°' : '-'} +
+
{/if} - + + {#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)} +
+
+ + + + {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} +
+
+ + + + {pad(sunset.getHours())}:{pad(sunset.getMinutes())} +
+
+ {/if} + {/if} -
- + +
+
+ + {#if showDetailedCharts} +
+
+

+ {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} + + {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)' + : ''} + +

+ +
+ + + {#each chartOptions as option, i (i)} + + {/each} + + +
+ +
+
+ {/if}
- - {#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)} -
-
- - - Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} -
-
- - - - Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())} -
-
- {/if} - {/if} - - +
@@ -1093,17 +1356,426 @@
-- 2.54.0 From 1c4979e12ee7134890a15796eff8cddabafd75fa Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 20:57:45 +0100 Subject: [PATCH 07/12] single day overview --- .../weather/week/[location]/+page.svelte | 630 +++++++++--------- 1 file changed, 325 insertions(+), 305 deletions(-) diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 2ace8c2..ec2bb8e 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -31,8 +31,8 @@ const CHART_GROUP = 'week-meteogram'; const MS_PER_DAY = 24 * 3600 * 1000; - const CELL_W = 38; - const CURVE_H = 120; + const CURVE_H = 140; + const SVG_UNIT = 100; let params = $state({ latitude: [$storedLocation.latitude], @@ -57,7 +57,6 @@ const selectedDay = new SvelteDate(); let selectedDayIndex = $state(1); - let tableScrollDiv: HTMLElement | undefined = $state(); let hourlyInterval = $state<1 | 3>(3); let showDetailedCharts = $state(false); @@ -104,22 +103,10 @@ } } - function scrollTableToDay(day: Date): void { - if (!tableScrollDiv) return; - const cells = tableScrollDiv.querySelectorAll('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 { @@ -172,7 +159,6 @@ echarts.connect(CHART_GROUP); requestAnimationFrame(() => { scrollChartsToDay(selectedDay); - scrollTableToDay(selectedDay); }); } } @@ -247,14 +233,20 @@ return `${date.getMonth() + 1}-${date.getDate()}`; } - function getFilteredIndices(dates: Date[]): number[] { - if (hourlyInterval === 1) { - return dates.map((_, i) => i); + function getDayIndices(dates: Date[], day: Date): number[] { + const dayDate = day.getDate(); + const dayMonth = day.getMonth(); + const dayYear = day.getFullYear(); + const indices: number[] = []; + for (let i = 0; i < dates.length; i++) { + const d = dates[i]; + if (d.getDate() === dayDate && d.getMonth() === dayMonth && d.getFullYear() === dayYear) { + if (hourlyInterval === 1 || d.getHours() % 3 === 0) { + indices.push(i); + } + } } - return dates.reduce((acc, date, i) => { - if (date.getHours() % 3 === 0) acc.push(i); - return acc; - }, []); + return indices; } function getPrecipBarHeight(val: number, maxVal: number): number { @@ -266,9 +258,9 @@ if (indices.length < 2) return ''; const rangeT = maxT - minT || 1; const points: [number, number][] = indices.map((idx, i) => { - const x = i * CELL_W + CELL_W / 2; + const x = i * SVG_UNIT + SVG_UNIT / 2; const t = temps[idx] ?? minT; - const y = CURVE_H - 20 - ((t - minT) / rangeT) * (CURVE_H - 40); + const y = CURVE_H - 24 - ((t - minT) / rangeT) * (CURVE_H - 48); return [x, y]; }); @@ -286,8 +278,9 @@ function buildAreaPath(temps: number[], indices: number[], minT: number, maxT: number): string { const curvePath = buildCurvePath(temps, indices, minT, maxT); if (!curvePath) return ''; - const lastX = (indices.length - 1) * CELL_W + CELL_W / 2; - const firstX = CELL_W / 2; + const n = indices.length; + const lastX = (n - 1) * SVG_UNIT + SVG_UNIT / 2; + const firstX = SVG_UNIT / 2; return `${curvePath} L ${lastX},${CURVE_H} L ${firstX},${CURVE_H} Z`; } @@ -295,6 +288,10 @@ return Math.min(0.7, (cover ?? 0) / 120); } + function getIconSize(interval: 1 | 3): number { + return interval === 3 ? 40 : 26; + } + $effect(() => { const loc = location; const modelList = params.models; @@ -870,15 +867,19 @@ {#if fetchedHourly} {@const hourly = fetchedHourly.hourly} {@const dates = fetchedHourly.hourlyDates} - {@const filteredIdx = getFilteredIndices(dates)} + {@const dayIdx = getDayIndices(dates, selectedDay)} + {@const numCols = dayIdx.length} + {@const svgW = numCols * SVG_UNIT} {@const maxPrecip = Math.max( - ...hourly.precipitation.filter((v) => v != null && !isNaN(v)), + ...dayIdx.map((i) => hourly.precipitation[i]).filter((v) => v != null && !isNaN(v)), 0.1 )} - {@const allTemps = hourly.temperature_2m.filter((t) => t != null && !isNaN(t))} - {@const minTemp = Math.min(...allTemps)} - {@const maxTemp = Math.max(...allTemps)} - {@const totalWidth = filteredIdx.length * CELL_W} + {@const dayTemps = dayIdx + .map((i) => hourly.temperature_2m[i]) + .filter((t) => t != null && !isNaN(t))} + {@const minTemp = dayTemps.length ? Math.min(...dayTemps) : 0} + {@const maxTemp = dayTemps.length ? Math.max(...dayTemps) : 10} + {@const iconPx = getIconSize(hourlyInterval)}

@@ -897,86 +898,44 @@

- -
-
- -
-
-
- - - - - {#each filteredIdx as idx, i (idx)} - {@const t = hourly.temperature_2m[idx]} - - {/each} - - - - - {#each filteredIdx as idx, i (idx)} - {@const cloud = hourly.cloud_cover[idx]} - + {#if numCols > 0} + +
+ + + + + {#each dayIdx as _ (_.toString())} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {/each} + - - - - - - - - {#each filteredIdx as idx, i (idx)} - {@const t = hourly.temperature_2m[idx]} - {@const rangeT = maxTemp - minTemp || 1} - {@const y = CURVE_H - 20 - ((t - minTemp) / rangeT) * (CURVE_H - 40)} - - {t?.toFixed(0) ?? '-'}° - - {/each} - - - -
- {#each filteredIdx as idx, i (idx)} + +
+ + {#each dayIdx as idx (idx)} {@const wCode = hourly.weather_code[idx]} - {@const daytime = isDaytimeHour(dates[idx].getHours())} -
- + {@const date = dates[idx]} + {@const daytime = isDaytimeHour(date.getHours())} + {@const isNow = isCurrentHour(date)} + + -
- {/each} - - - - - -
Hourly weather details for {location.name}
+ {pad(date.getHours())}00 +
+ + + +
- - - - - - {#each filteredIdx as idx (idx)} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - {/each} + + + + + + - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const val = hourly.precipitation[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const val = hourly.precipitation[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - + - {#each filteredIdx as idx (idx)} - {@const _wind = hourly.windspeed_10m[idx]} + {#each dayIdx as idx (idx)} {@const windDir = hourly.winddirection_10m[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - - - - {#each filteredIdx as idx (idx)} - {@const windDir = hourly.winddirection_10m[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const wind = hourly.windspeed_10m[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - {/each} @@ -1212,16 +1184,15 @@ - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const dp = hourly.dew_point_2m[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - {/each} @@ -1229,7 +1200,7 @@
Hourly weather details for {location.name}
- - - - - {pad(date.getHours())}00
+ {params.temperature_unit === 'celsius' ? '°C' : '°F'} + +
+ + + + {#each dayIdx as idx, i (idx)} + {@const t = hourly.temperature_2m[idx]} + + {/each} + + + + + {#each dayIdx as idx, i (idx)} + {@const cloud = hourly.cloud_cover[idx]} + + {/each} + + + + + + + + + +
+ {#each dayIdx as idx, i (idx)} + {@const t = hourly.temperature_2m[idx]} + {@const rangeT = maxTemp - minTemp || 1} + {@const yPct = + ((1 - ((t ?? minTemp) - minTemp) / rangeT) * 0.7 + 0.05) * 100} + + {t?.toFixed(0) ?? '-'}° + + {/each} +
+
+
@@ -1022,16 +1034,11 @@ {params.precipitation_unit === 'mm' ? 'mm' : 'in'} +
{#if val > 0}
% - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const prob = hourly.precipitation_probability[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0}
- + + {#if val > 0} @@ -1095,25 +1098,21 @@ {/each}
- + + {#if windDir != null && !isNaN(windDir)}
- + @@ -1126,16 +1125,15 @@
- + - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const temp = hourly.apparent_temperature[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - + {temp != null ? temp.toFixed(0) + '°' : '-'} {/each} @@ -1144,15 +1142,16 @@
-
% + + + - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const hum = hourly.relative_humidity_2m[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0}
{hum != null ? hum.toFixed(0) + '%' : '-'} @@ -1160,50 +1159,23 @@ {/each}
- - - - - {#if windDir != null && !isNaN(windDir)} -
- - - -
- {:else} - - - {/if} -
- + + {params.wind_speed_unit === 'kmh' ? 'km/h' : params.wind_speed_unit}
+ {wind?.toFixed(0) ?? '-'}
- + + {dp != null ? dp.toFixed(0) + '°' : '-'}
-
+ {/if} {/if} @@ -1520,86 +1491,25 @@ left: 22px; } - /* ═══ Horizontal Scroll Container ═══════════════════════════════════════════ */ + /* ═══ Hourly Table Container ═══════════════════════════════════════════════ */ - .hourly-scroll { - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; - margin-left: -1.25rem; - margin-right: -1.25rem; + .hourly-container { border: 1px solid hsl(var(--border)); border-radius: 8px; - } - - @media (min-width: 768px) { - .hourly-scroll { - margin-left: 0; - margin-right: 0; - } - } - - .hourly-inner { - display: flex; - flex-direction: column; - } - - /* ═══ Temperature Curve Area ═══════════════════════════════════════════════ */ - - .curve-area { - display: flex; - border-bottom: 2px solid hsl(var(--border)); - background: hsl(var(--card) / 0.5); - } - - .curve-row-header { - position: sticky; - left: 0; - z-index: 10; - min-width: 90px; - max-width: 90px; - background: hsl(var(--background)); - border-right: 2px solid hsl(var(--border)); - } - - .curve-container { - position: relative; overflow: hidden; } - .curve-svg { - display: block; - } - - .curve-temp-label { - font-size: 10px; - font-weight: 600; - } - - .curve-icons { - position: absolute; - top: 2px; - left: 0; - display: flex; - pointer-events: none; - } - - .curve-icon-cell { - position: absolute; - top: 0; - display: flex; - align-items: center; - justify-content: center; - height: 22px; - } - /* ═══ Hourly Data Table ═══════════════════════════════════════════════════ */ .hourly-table { border-collapse: collapse; white-space: nowrap; - font-size: 11px; width: 100%; + table-layout: fixed; + } + + .hourly-table .col-header { + width: 64px; } .hourly-table tr { @@ -1611,18 +1521,15 @@ } .row-header { - position: sticky; - left: 0; - z-index: 10; - min-width: 90px; - max-width: 90px; - padding: 3px 6px; + width: 64px; + padding: 4px 4px; text-align: center; font-weight: 600; font-size: 11px; background: hsl(var(--background)); border-right: 2px solid hsl(var(--border)); white-space: nowrap; + overflow: hidden; } .row-header-stack { @@ -1630,7 +1537,13 @@ flex-direction: column; align-items: center; gap: 0; - line-height: 1.1; + line-height: 1.2; + } + + .row-label { + font-size: 11px; + font-weight: 600; + color: hsl(var(--muted-foreground)); } .row-unit { @@ -1639,17 +1552,24 @@ color: hsl(var(--muted-foreground)); } + /* ═══ Hour Cells ═══════════════════════════════════════════════════════════ */ + .hour-cell { - min-width: 38px; - max-width: 38px; - padding: 3px 1px; + padding: 6px 2px; text-align: center; - font-size: 11px; - border-right: 1px solid hsl(var(--border) / 0.4); + font-size: 13px; + font-weight: 500; + border-right: 1px solid hsl(var(--border) / 0.3); + overflow: hidden; } - .hour-cell.midnight { - border-left: 2px solid hsl(var(--primary) / 0.5); + .interval-3h .hour-cell { + font-size: 15px; + padding: 8px 4px; + } + + .hour-cell:last-child { + border-right: none; } .hour-cell.now { @@ -1659,32 +1579,111 @@ .time-cell { font-weight: 700; - font-size: 10px; background: hsl(var(--muted) / 0.3); + padding: 5px 2px; + } + + .interval-3h .time-cell { + font-size: 14px; + padding: 6px 4px; + } + + .interval-1h .time-cell { + font-size: 11px; } .time-sup { - font-size: 7px; + font-size: 8px; vertical-align: super; } + /* ═══ Weather Icon Row ═══════════════════════════════════════════════════ */ + + .icon-row { + background: hsl(var(--muted) / 0.15); + } + + .weather-icon-cell { + padding: 6px 2px; + text-align: center; + vertical-align: middle; + line-height: 0; + border-right: 1px solid hsl(var(--border) / 0.3); + } + + .weather-icon-cell:last-child { + border-right: none; + } + + .interval-3h .weather-icon-cell { + padding: 10px 4px; + } + + /* ═══ Temperature Curve ═══════════════════════════════════════════════════ */ + + .curve-row { + border-top: none !important; + } + + .curve-header { + vertical-align: middle; + } + + .curve-cell { + padding: 0 !important; + border-right: none !important; + } + + .curve-container { + position: relative; + overflow: hidden; + width: 100%; + } + + .curve-svg { + display: block; + width: 100%; + } + + .curve-labels { + position: absolute; + inset: 0; + pointer-events: none; + } + + .curve-label { + position: absolute; + transform: translateX(-50%); + font-size: 11px; + font-weight: 700; + white-space: nowrap; + } + + .interval-3h .curve-label { + font-size: 14px; + } + /* ═══ Precipitation Bar ═══════════════════════════════════════════════════ */ .precip-cell { vertical-align: bottom; - padding: 1px; + padding: 2px !important; } .precip-bar-container { width: 100%; - height: 22px; + height: 28px; display: flex; align-items: flex-end; justify-content: center; } + .interval-3h .precip-bar-container { + height: 36px; + } + .precip-bar { - width: 60%; + width: 55%; min-height: 2px; background: linear-gradient(to top, rgba(30, 136, 229, 0.5), rgba(30, 136, 229, 0.9)); border-radius: 2px 2px 0 0; @@ -1692,16 +1691,22 @@ .precip-val { display: block; - font-size: 9px; - line-height: 1; + font-size: 10px; + line-height: 1.2; color: hsl(var(--muted-foreground)); text-align: center; + margin-top: 1px; + } + + .interval-3h .precip-val { + font-size: 12px; } .icon-cell { - padding: 2px; + padding: 4px 2px; vertical-align: middle; line-height: 0; + text-align: center; } .icon-cell > div, @@ -1709,12 +1714,7 @@ display: inline-block; } - /* ═══ Wind Cell ═══════════════════════════════════════════════════════════ */ - - .wind-cell { - vertical-align: middle; - line-height: 0; - } + /* ═══ Wind Arrow ═══════════════════════════════════════════════════════════ */ .wind-arrow { display: inline-block; @@ -1817,5 +1817,25 @@ .card-temp-min { font-size: 13px; } + + .hourly-table .col-header { + width: 48px; + } + + .row-header { + width: 48px; + font-size: 10px; + padding: 3px 2px; + } + + .hour-cell { + font-size: 11px; + padding: 4px 1px; + } + + .interval-3h .hour-cell { + font-size: 13px; + padding: 6px 2px; + } } -- 2.54.0 From f4009c4aa138cf3e97d231f7629c3d1fe6769e55 Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 22:02:51 +0100 Subject: [PATCH 08/12] separate components --- .../weather/week/[location]/+page.svelte | 1772 +---------------- .../weather/week/[location]/DailyCards.svelte | 232 +++ .../week/[location]/HourlyTable.svelte | 625 ++++++ .../week/[location]/MeteogramCharts.svelte | 595 ++++++ .../week/[location]/ModelSelector.svelte | 45 + .../weather/week/[location]/SunInfo.svelte | 57 + src/routes/weather/week/[location]/types.ts | 89 + 7 files changed, 1682 insertions(+), 1733 deletions(-) create mode 100644 src/routes/weather/week/[location]/DailyCards.svelte create mode 100644 src/routes/weather/week/[location]/HourlyTable.svelte create mode 100644 src/routes/weather/week/[location]/MeteogramCharts.svelte create mode 100644 src/routes/weather/week/[location]/ModelSelector.svelte create mode 100644 src/routes/weather/week/[location]/SunInfo.svelte create mode 100644 src/routes/weather/week/[location]/types.ts diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index ec2bb8e..df8e566 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -1,38 +1,20 @@ @@ -767,1075 +114,34 @@
- -
-
- {#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]} - {@const windMax = fetchedDaily.daily.windspeed_10m_max[index]} - {@const gustMax = fetchedDaily.daily.windgusts_10m_max[index]} - {@const windDir = fetchedDaily.daily.winddirection_10m_dominant[index]} - {#if tempMax != null && !isNaN(tempMax)} - - {/if} - {/each} - {/if} -
-
- - {#if fetchedHourly} - {@const hourly = fetchedHourly.hourly} - {@const dates = fetchedHourly.hourlyDates} - {@const dayIdx = getDayIndices(dates, selectedDay)} - {@const numCols = dayIdx.length} - {@const svgW = numCols * SVG_UNIT} - {@const maxPrecip = Math.max( - ...dayIdx.map((i) => hourly.precipitation[i]).filter((v) => v != null && !isNaN(v)), - 0.1 - )} - {@const dayTemps = dayIdx - .map((i) => hourly.temperature_2m[i]) - .filter((t) => t != null && !isNaN(t))} - {@const minTemp = dayTemps.length ? Math.min(...dayTemps) : 0} - {@const maxTemp = dayTemps.length ? Math.max(...dayTemps) : 10} - {@const iconPx = getIconSize(hourlyInterval)} - -
-

- {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} - Hourly -

-
- 3h - - 1h -
-
- - {#if numCols > 0} - -
- - - - - {#each dayIdx as _ (_.toString())} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const wCode = hourly.weather_code[idx]} - {@const date = dates[idx]} - {@const daytime = isDaytimeHour(date.getHours())} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - - - - - - - {#each dayIdx as idx (idx)} - {@const val = hourly.precipitation[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const prob = hourly.precipitation_probability[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const val = hourly.precipitation[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const windDir = hourly.winddirection_10m[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const temp = hourly.apparent_temperature[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const hum = hourly.relative_humidity_2m[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const wind = hourly.windspeed_10m[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const dp = hourly.dew_point_2m[idx]} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - - {/each} - - -
Hourly weather details for {location.name}
- {pad(date.getHours())}00 -
- - - - - - - -
- {params.temperature_unit === 'celsius' ? '°C' : '°F'} - -
- - - - {#each dayIdx as idx, i (idx)} - {@const t = hourly.temperature_2m[idx]} - - {/each} - - - - - {#each dayIdx as idx, i (idx)} - {@const cloud = hourly.cloud_cover[idx]} - - {/each} - - - - - - - - - -
- {#each dayIdx as idx, i (idx)} - {@const t = hourly.temperature_2m[idx]} - {@const rangeT = maxTemp - minTemp || 1} - {@const yPct = - ((1 - ((t ?? minTemp) - minTemp) / rangeT) * 0.7 + 0.05) * 100} - - {t?.toFixed(0) ?? '-'}° - - {/each} -
-
-
-
- {hourlyInterval}h - {params.precipitation_unit === 'mm' ? 'mm' : 'in'} -
-
-
- {#if val > 0} -
- {/if} -
- {val > 0 ? val.toFixed(1) : ''} -
- % - - {prob != null ? prob.toFixed(0) + '%' : '-'} -
- - - - - {#if val > 0} - - - - {/if} -
- - - - - {#if windDir != null && !isNaN(windDir)} -
- - - -
- {/if} -
- - - - - {temp != null ? temp.toFixed(0) + '°' : '-'} -
- - - - - {hum != null ? hum.toFixed(0) + '%' : '-'} -
-
- - - - {params.wind_speed_unit === 'kmh' ? 'km/h' : params.wind_speed_unit} -
-
- {wind?.toFixed(0) ?? '-'} -
- - - - - {dp != null ? dp.toFixed(0) + '°' : '-'} -
-
- {/if} + {/if} - - {#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)} -
-
- - - - {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} -
-
- - - - {pad(sunset.getHours())}:{pad(sunset.getMinutes())} -
-
- {/if} + + + {#if fetchedHourly} + {/if} - -
- -
- - {#if showDetailedCharts} -
-
-

- {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} - - {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)' - : ''} - -

- -
- - - {#each chartOptions as option, i (i)} - - {/each} - - -
- -
-
- {/if} -
- - -
-
-
- {#if params.models && params.models.length > 0} - {@const modelValue = params.models[0]} - { - if (params.models && val) { - params.models = [val]; - } - }} - > - {modelSelected?.label} - - {#each models as mo (mo.value)} - {mo.label} - {/each} - - - - {/if} -
-
+ { + params.models = [model]; + }} + />
- - diff --git a/src/routes/weather/week/[location]/DailyCards.svelte b/src/routes/weather/week/[location]/DailyCards.svelte new file mode 100644 index 0000000..c640aec --- /dev/null +++ b/src/routes/weather/week/[location]/DailyCards.svelte @@ -0,0 +1,232 @@ + + +
+
+ {#if daily} + {#each daily.dailyDates as time, index (index)} + {@const selected = time.getDate() === selectedDay.getDate()} + {@const tempMax = daily.daily.temperature_2m_max[index]} + {@const tempMin = daily.daily.temperature_2m_min[index]} + {@const wCode = daily.daily.weather_code[index]} + {@const sunDuration = daily.daily.sunshine_duration[index]} + {@const precipSum = daily.daily.precipitation_sum[index]} + {@const windMax = daily.daily.windspeed_10m_max[index]} + {@const gustMax = daily.daily.windgusts_10m_max[index]} + {@const windDir = daily.daily.winddirection_10m_dominant[index]} + {#if tempMax != null && !isNaN(tempMax)} + + {/if} + {/each} + {/if} +
+
+ + diff --git a/src/routes/weather/week/[location]/HourlyTable.svelte b/src/routes/weather/week/[location]/HourlyTable.svelte new file mode 100644 index 0000000..4297c95 --- /dev/null +++ b/src/routes/weather/week/[location]/HourlyTable.svelte @@ -0,0 +1,625 @@ + + +
+

+ {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} – Hourly +

+
+ 3h + + 1h +
+
+ +{#if numCols > 0} + {@const hourly = data.hourly} + {@const dates = data.hourlyDates} +
+ + + + + {#each dayIdx as _ (_.toString())} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const date = dates[idx]} + {@const now = isCurrentHour(date, today)} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const wCode = hourly.weather_code[idx]} + {@const date = dates[idx]} + {@const daytime = isDaytimeHour(date.getHours())} + {@const now = isCurrentHour(date, today)} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const temp = hourly.temperature_2m[idx]} + {@const date = dates[idx]} + {@const now = isCurrentHour(date, today)} + {@const bg = getColor(temp, String(units.temperature_unit))} + {@const fg = getTextColorForTemp(temp, String(units.temperature_unit))} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const temp = hourly.apparent_temperature[idx]} + {@const date = dates[idx]} + {@const now = isCurrentHour(date, today)} + {@const bg = getColor(temp, String(units.temperature_unit))} + {@const fg = getTextColorForTemp(temp ?? 0, String(units.temperature_unit))} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const cloud = hourly.cloud_cover[idx]} + {@const date = dates[idx]} + {@const now = isCurrentHour(date, today)} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const precip = hourly.precipitation[idx]} + {@const prob = hourly.precipitation_probability[idx]} + {@const date = dates[idx]} + {@const now = isCurrentHour(date, today)} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const wind = hourly.windspeed_10m[idx]} + {@const windDir = hourly.winddirection_10m[idx]} + {@const date = dates[idx]} + {@const now = isCurrentHour(date, today)} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const hum = hourly.relative_humidity_2m[idx]} + {@const date = dates[idx]} + {@const now = isCurrentHour(date, today)} + + {/each} + + +
Hourly weather details for {locationName}
+ {pad(date.getHours())}00 +
+ + + + + + + +
+
+ + + + {tempUnit} +
+
+ {temp != null ? temp.toFixed(0) + '°' : '-'} +
+
+ Feels + {tempUnit} +
+
+ {temp != null ? temp.toFixed(0) + '°' : '-'} +
+
+ + + + % +
+
+ {cloud != null ? cloud.toFixed(0) : '-'} +
+
+ + + + {precipUnit} +
+
+
+ {#if precip > 0} +
+ {/if} + + {#if precip > 0} + {precip.toFixed(1)} + {:else if prob != null && prob > 0} + {prob}% + {/if} + +
+
+ + + + {windUnit} +
+
+ {#if windDir != null && !isNaN(windDir)} +
+ + + +
+ {/if} + {wind?.toFixed(0) ?? '-'} +
+
+ + + + % +
+
+ {hum != null ? hum.toFixed(0) : '-'} +
+
+{/if} + + diff --git a/src/routes/weather/week/[location]/MeteogramCharts.svelte b/src/routes/weather/week/[location]/MeteogramCharts.svelte new file mode 100644 index 0000000..0c87627 --- /dev/null +++ b/src/routes/weather/week/[location]/MeteogramCharts.svelte @@ -0,0 +1,595 @@ + + +
+ +
+ +{#if showCharts} +
+
+

+ {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} + + {getDayLabel(selectedDay, today) !== + selectedDay.toLocaleDateString('en-GB', { weekday: 'long' }) + ? ` (${getDayLabel(selectedDay, today)})` + : ''} + +

+ +
+ + + {#each chartOptions as option, i (i)} + + {/each} + + +
+ +
+
+{/if} + + diff --git a/src/routes/weather/week/[location]/ModelSelector.svelte b/src/routes/weather/week/[location]/ModelSelector.svelte new file mode 100644 index 0000000..5ef3c89 --- /dev/null +++ b/src/routes/weather/week/[location]/ModelSelector.svelte @@ -0,0 +1,45 @@ + + +
+
+ { + if (val) onModelChange(val); + }} + > + + {modelLabel} + + + {#each models as mo (mo.value)} + {mo.label} + {/each} + + + +
+
diff --git a/src/routes/weather/week/[location]/SunInfo.svelte b/src/routes/weather/week/[location]/SunInfo.svelte new file mode 100644 index 0000000..f93a009 --- /dev/null +++ b/src/routes/weather/week/[location]/SunInfo.svelte @@ -0,0 +1,57 @@ + + +{#if sunrise && sunset} +
+
+ + + + {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} +
+
+ + + + {pad(sunset.getHours())}:{pad(sunset.getMinutes())} +
+
+{/if} + + diff --git a/src/routes/weather/week/[location]/types.ts b/src/routes/weather/week/[location]/types.ts new file mode 100644 index 0000000..e4f4396 --- /dev/null +++ b/src/routes/weather/week/[location]/types.ts @@ -0,0 +1,89 @@ +import type { MarkArea, WeekDailyData, WeekHourlyData } from '$lib/services/weather'; + +export interface WeatherUnits { + temperature_unit: string; + wind_speed_unit: string; + precipitation_unit: string; +} + +export interface FetchedHourly { + hourly: WeekHourlyData; + utc_offset_seconds: number; + timestamps: number[]; + hourlyDates: Date[]; + markAreas: MarkArea[]; +} + +export interface FetchedDaily { + daily: WeekDailyData; + dailyDates: Date[]; +} + +export function getTempUnit(units: WeatherUnits): string { + return units.temperature_unit === 'celsius' ? '°C' : '°F'; +} + +export function getWindUnit(units: WeatherUnits): string { + return units.wind_speed_unit === 'kmh' ? 'km/h' : units.wind_speed_unit; +} + +export function getPrecipUnit(units: WeatherUnits): string { + return units.precipitation_unit === 'mm' ? 'mm' : 'in'; +} + +export 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'; +} + +export function getWindArrowRotation(deg: number): string { + return `rotate(${deg}deg)`; +} + +export 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]; +} + +export function getDayLabel(date: Date, today: Date): string { + const MS_PER_DAY = 24 * 3600 * 1000; + const diff = Math.round( + (new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() - + new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) / + MS_PER_DAY + ); + if (diff === 0) return 'Today'; + if (diff === 1) return 'Tomorrow'; + if (diff === -1) return 'Yesterday'; + return `${date.getMonth() + 1}-${date.getDate()}`; +} + +export function isDaytimeHour(hour: number): boolean { + return hour >= 6 && hour < 21; +} + +export function isCurrentHour(date: Date, now: Date): boolean { + return ( + date.getDate() === now.getDate() && + date.getMonth() === now.getMonth() && + date.getFullYear() === now.getFullYear() && + date.getHours() === now.getHours() + ); +} -- 2.54.0 From ed4492c779cf1bef028e8c6df131f101cd520df9 Mon Sep 17 00:00:00 2001 From: terraputix Date: Mon, 16 Feb 2026 00:22:40 +0100 Subject: [PATCH 09/12] improve hourly table --- src/routes/weather/options.ts | 1 - src/routes/weather/utils/colors.ts | 70 +- .../weather/utils/colour-gradient.ipynb | 191 ---- .../weather/week/[location]/+page.svelte | 6 +- .../weather/week/[location]/DailyCards.svelte | 292 +++--- .../week/[location]/HourlyTable.svelte | 847 ++++++++---------- .../week/[location]/MeteogramCharts.svelte | 8 +- src/routes/weather/week/[location]/types.ts | 37 +- 8 files changed, 561 insertions(+), 891 deletions(-) delete mode 100644 src/routes/weather/utils/colour-gradient.ipynb diff --git a/src/routes/weather/options.ts b/src/routes/weather/options.ts index a58f330..7e52522 100644 --- a/src/routes/weather/options.ts +++ b/src/routes/weather/options.ts @@ -13,7 +13,6 @@ export const models = [ { value: 'icon_seamless', label: 'DWD ICON Seamless' }, { value: 'gem_seamless', label: 'GEM Seamless' }, { value: 'meteofrance_seamless', label: 'Météo-France Seamless' }, - { value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' }, { value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' }, { value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' }, { value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' }, diff --git a/src/routes/weather/utils/colors.ts b/src/routes/weather/utils/colors.ts index 7ad1963..d10374e 100644 --- a/src/routes/weather/utils/colors.ts +++ b/src/routes/weather/utils/colors.ts @@ -1,11 +1,11 @@ import colorScaleHex from './color-scale-hex'; -function componentFromStr(numStr: string, percent: number) { +const componentFromStr = (numStr: string, percent: number) => { const num = Math.max(0, parseInt(numStr, 10)); return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num); -} +}; -export function rgbToHex(rgb: string) { +export const rgbToHex = (rgb: string): string => { const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/; let result, r, @@ -23,28 +23,58 @@ export function rgbToHex(rgb: string) { return '355522'; } return hex; -} +}; + +export const hexToRgb = (hex: string): [number, number, number] => { + const h = hex.replace('#', ''); + return [ + parseInt(h.substring(0, 2), 16), + parseInt(h.substring(2, 4), 16), + parseInt(h.substring(4, 6), 16) + ]; +}; export const getColor = (temperature: number, unit = 'celsius'): string => { + if (unit !== 'celsius') { + temperature = Math.round(((temperature - 32) * 5) / 9); + } + let index = 0; - if (unit === 'celsius') { - if (temperature <= -40) { - index = 0; - } else if (temperature >= 60) { - index = colorScaleHex.length - 1; - } else { - index = temperature + 40; - } + if (temperature <= -40) { + index = 0; + } else if (temperature >= 60) { + index = colorScaleHex.length - 1; } else { - const tempInCelsius = Math.round(((temperature - 32) * 5) / 9); - if (tempInCelsius <= -40) { - index = 0; - } else if (tempInCelsius >= 60) { - index = colorScaleHex.length - 1; - } else { - index = tempInCelsius + 40; - } + index = Math.round(temperature) + 45; } return colorScaleHex[index]; }; + +export interface TempStyle { + bg: string; + fg: 'white' | 'black'; +} + +export const getTempStyle = (temp: number, unit: string): TempStyle => { + const bg = getColor(temp, unit); + const fg = textWhite(hexToRgb(bg)) ? 'white' : 'black'; + return { bg, fg }; +}; + +export const textWhite = ( + [r, g, b, a]: [number, number, number, number] | [number, number, number], + dark?: boolean, + globalOpacity?: number +): boolean => { + const alpha = ((a || 1) * (globalOpacity || 100)) / 100; + if (alpha < 0.65) { + if (dark) { + return true; + } else { + return false; + } + } + // check luminance + return r * 0.299 + g * 0.587 + b * 0.114 <= 150; +}; diff --git a/src/routes/weather/utils/colour-gradient.ipynb b/src/routes/weather/utils/colour-gradient.ipynb deleted file mode 100644 index 3f25f7d..0000000 --- a/src/routes/weather/utils/colour-gradient.ipynb +++ /dev/null @@ -1,191 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "8c554ac3-6dae-4561-b493-32943137a3ea", - "metadata": {}, - "source": [ - "## Generating colours" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "id": "ba97e928-85be-46bd-9429-e69f89c3ce13", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import json\n", - "from colour import Color\n", - "\n", - "def flatten(xss):\n", - " return [x for xs in xss for x in xs]\n", - "\n", - "deep_purple = Color(\"#4F00A3\")\n", - "dark_blue = Color(\"#0117DB\")\n", - "light_blue = Color(\"#01B1FF\")\n", - "light_green = Color(\"#00FFC2\")\n", - "dark_green = Color(\"#00D139\")\n", - "warm_green = Color(\"#FFFF00\")\n", - "light_orange = Color(\"#FFC600\")\n", - "middle_orange = Color(\"#FFA200\")\n", - "dark_orange = Color(\"#FF640A\")\n", - "deep_red = Color(\"#9E1500\")\n", - "\n", - "colors = [\n", - " list(deep_purple.range_to(dark_blue,15)),\n", - " list(dark_blue.range_to(light_blue,13)),\n", - " list(light_blue.range_to(light_green,13)),\n", - " list(light_green.range_to(dark_green,8)),\n", - " list(dark_green.range_to(warm_green,14)),\n", - " list(warm_green.range_to(light_orange,8)),\n", - " list(light_orange.range_to(middle_orange,9)),\n", - " list(middle_orange.range_to(dark_orange,10)),\n", - " list(dark_orange.range_to(deep_red,11)),\n", - "]\n", - "\n", - "colors = flatten(colors)\n", - "\n", - "color_list = []\n", - "rgb_list = []\n", - "hsl_list = []\n", - "hex_list = []\n", - "\n", - "temp_x= []\n", - "temp_height= []\n", - "\n", - "for [ind, color] in enumerate(colors):\n", - " x = -40 + ind\n", - " color_list.append(color.hex)\n", - " hex_list.append(color.hex)\n", - " rgb_list.append(color.rgb)\n", - " hsl_list.append(color.hsl)\n", - " temp_x.append(x)\n", - " temp_height.append(1)" - ] - }, - { - "cell_type": "markdown", - "id": "248a5151-772d-43e7-8fde-5d58511f30e8", - "metadata": {}, - "source": [ - "## Visualisation" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "id": "40b96c47-6025-41d1-a2cc-a46d9233efcf", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 37, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "4bf8047db9cf4060b54ccfff7857fbcb", - "version_major": 2, - "version_minor": 0 - }, - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAPYQAAD2EBqD+naQAAGh9JREFUeJzt3X+MXXX5J/Bn2s7c6a+Z/qDM0O2ABVkKooItwogQwAk1cTcSKuqCkR9NUTNFoSZARUvGECpIKD8iLbBS0YWVEAMaCQhbCIk4CpYgNNgKi4Sm/c5QFeaWukw7nbt/LMx3R6rfQc70eu/zeiUn4Z5z5txnbkjuu8/zOWcaKpVKJQAASGNCtQsAAGD/EgABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCSEQABAJKZVO0Catnw8HBs3749pk+fHg0NDdUuBwAYg0qlEjt37oy5c+fGhAk5e2EC4Huwffv26OjoqHYZAMA/YevWrTFv3rxql1EVAuB7MH369Ii3/gdqaWmpdjkAwBiUy+Xo6OgY+R7PSAB8D94e+7a0tAiAAFBjMi/fyjn4BgBITAAEAEhGAAQASEYABABIRgAEAEhGAAQASEYABABIRgAEAEhGAAQASEYABABIRgAEAEhGAAQASKauA+C2bdviC1/4QsyePTsmT54cH/zgB+O3v/3tyPFKpRKrVq2Kgw46KCZPnhxdXV3xwgsvVLVmAIDxNqnaBYyX1157LU488cQ49dRT48EHH4w5c+bECy+8EDNnzhw559prr42bbrop7rzzzpg/f35861vfisWLF8fzzz8fzc3NY36v/3Foa0yu6ygNAPvX+a9Wql1CXavbAHjNNddER0dHrF+/fmTf/PnzR/67UqnEDTfcEN/85jfj05/+dERE/PCHP4y2tra4//774/Of/3xV6gYAGG9127f62c9+FosWLYqzzjorDjzwwDj22GPj9ttvHzn+xz/+Mfr6+qKrq2tkX2traxx//PHR29tbpaoBAMZf3QbAl156KdauXRuHH354/OIXv4ivfOUr8dWvfjXuvPPOiIjo6+uLiIi2trZRP9fW1jZy7G8NDg5GuVwetQEA1Jq6HQEPDw/HokWL4uqrr46IiGOPPTY2bdoU69ati3PPPfefuubq1aujp6fnHfunNEZMqdsoDQDUm7qNLQcddFAcddRRo/YdeeSR8corr0RERHt7e0RE9Pf3jzqnv79/5NjfWrlyZQwMDIxsW7duHbf6AQDGS90GwBNPPDG2bNkyat8f/vCHOOSQQyLeuiGkvb09NmzYMHK8XC7Hb37zm+js7NznNUulUrS0tIzaAABqTd2OgC+55JL42Mc+FldffXV89rOfjSeffDJuu+22uO222yIioqGhIS6++OK46qqr4vDDDx95DMzcuXPjjDPOeFfvNaXJCBgAqB11GwCPO+64uO+++2LlypXx7W9/O+bPnx833HBDnHPOOSPnXHrppbFr16648MIL4/XXX4+Pf/zj8dBDD72rZwACANSahkql4kmL/6RyuRytra1xz8E6gABQpP/yx/GLJ29/fw8MDKRdzlW3HcD9aUpTxNSJ1a4CAGBs9K0AAJIRAAEAkhEAAQCSsQawAFNK1gACALVDBxAAIBkBEAAgGSPgAkwpRUwxAgYAaoQOIABAMgIgAEAyRsAFmFKKmOqTBABqhA4gAEAyAiAAQDICIABAMlauFWBqszWAAEDt0AEEAEhGAAQASMbgsgBTmiOmNla7CgCAsdEBBABIRgAEAEjGCLgAUyZHTDECBgBqhA4gAEAyAiAAQDICIABAMtYAFqBh2uRoaGqodhkAAGOiAwgAkIwACACQjAAIAJCMAAgAkIwACACQjLuAi1CqRDRVuwgAgLHRAQQASEYABABIRgAEAEjGGsAiNFoDCADUDh1AAIBkBEAAgGSMgItQqkSUql0EAMDY6AACACQjAAIAJGMEXAQjYACghugAAgAkIwACACQjAAIAJGMNYBGahiNKDdWuAgBgTHQAAQCSEQABAJIxAi6Cx8AAADVEBxAAIBkBEAAgGSPgIjQZAQMAtUMHEAAgGQEQACAZARAAIBlrAIvgMTAAQA3RAQQASCZFAPzOd74TDQ0NcfHFF4/se/PNN6O7uztmz54d06ZNiyVLlkR/f39V6wQA2B/qfgT81FNPxa233hof+tCHRu2/5JJL4oEHHoh77703WltbY/ny5XHmmWfGE0888e7fpPTWGBgAoAbUdQfwjTfeiHPOOSduv/32mDlz5sj+gYGB+P73vx/XX399nHbaabFw4cJYv359/OpXv4pf//rXVa0ZAGC81XUA7O7ujk996lPR1dU1av/GjRtjz549o/YvWLAgDj744Ojt7f271xscHIxyuTxqAwCoNXU7Av7xj38cTz/9dDz11FPvONbX1xdNTU0xY8aMUfvb2tqir6/v715z9erV0dPTMy71AgDsL3XZAdy6dWt87Wtfi7vuuiuam5sLu+7KlStjYGBgZNu6dWth1wYA2F/qMgBu3LgxXn311fjIRz4SkyZNikmTJsXjjz8eN910U0yaNCna2tpi9+7d8frrr4/6uf7+/mhvb/+71y2VStHS0jJqAwCoNXU5Av7EJz4Rzz333Kh9559/fixYsCAuu+yy6OjoiMbGxtiwYUMsWbIkIiK2bNkSr7zySnR2dlapagCA/aMuA+D06dPj6KOPHrVv6tSpMXv27JH9S5cujRUrVsSsWbOipaUlLrrooujs7IwTTjjh3b/hxHr9JAGAepQ2tqxZsyYmTJgQS5YsicHBwVi8eHHccsst1S4LAGDcNVQqFU8w/ieVy+VobW2Ngf8e0TKl2tUAQB35b+MXT0a+vwcG0q7nT9sBLFTjWxsAQA2oy7uAAQD4+wRAAIBkjICLMMknCQDUDh1AAIBkBEAAgGQEQACAZKxcK4LHwAAANUQHEAAgGQEQACAZI+AiTPRJAgC1QwcQACAZARAAIBmDyyK4CxgAqCE6gAAAyQiAAADJCIAAAMlYA1iEST5JAKB26AACACQjAAIAJGNwWQSPgQEAaogOIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDIeA1OEUkQ0V7sIAICx0QEEAEhGAAQASMYIuAjNRsAAQO3QAQQASEYABABIxgi4CO4CBgBqiA4gAEAyAiAAQDICIABAMtYAFmHyWxsAQA3QAQQASEYABABIxgi4CB4DAwDUEB1AAIBkBEAAgGSMgIvQbAQMANQOHUAAgGQEQACAZIyAi2AEDADUEB1AAIBkBEAAgGQEQACAZARAAIBkBEAAgGQEQACAZDwGpgDbhiLKQ9WuAgDqxzwJZVzpAAIAJFO3AXD16tVx3HHHxfTp0+PAAw+MM844I7Zs2TLqnDfffDO6u7tj9uzZMW3atFiyZEn09/dXrWYAgP2hbhusjz/+eHR3d8dxxx0XQ0ND8Y1vfCNOP/30eP7552Pq1KkREXHJJZfEAw88EPfee2+0trbG8uXL48wzz4wnnnjiXb3X9r0RU/eO0y8CAAkZAY+vhkqlUql2EfvDjh074sADD4zHH388Tj755BgYGIg5c+bE3XffHZ/5zGciImLz5s1x5JFHRm9vb5xwwgn/4TXL5XK0trbG/3o1YmrLfvglACCJE0rjF0/e/v4eGBiIlpacX+B1OwL+WwMDAxERMWvWrIiI2LhxY+zZsye6urpGzlmwYEEcfPDB0dvbW7U6AQDGW4oG6/DwcFx88cVx4oknxtFHHx0REX19fdHU1BQzZswYdW5bW1v09fXt8zqDg4MxODg48rpcLo9z5QAAxUsRALu7u2PTpk3xy1/+8j1dZ/Xq1dHT0/OO/f82FDHFY2AAoDilahdQ3+p+BLx8+fL4+c9/Ho899ljMmzdvZH97e3vs3r07Xn/99VHn9/f3R3t7+z6vtXLlyhgYGBjZtm7dOu71AwAUrW4DYKVSieXLl8d9990Xjz76aMyfP3/U8YULF0ZjY2Ns2LBhZN+WLVvilVdeic7Ozn1es1QqRUtLy6gNAKDW1O0IuLu7O+6+++746U9/GtOnTx9Z19fa2hqTJ0+O1tbWWLp0aaxYsSJmzZoVLS0tcdFFF0VnZ+eY7gD+//3b3ojJHgMDANSIug2Aa9eujYiIU045ZdT+9evXx3nnnRcREWvWrIkJEybEkiVLYnBwMBYvXhy33HJLVeoFANhf0jwHcDy8/Ryha7dGTDYNBoDCLG/xHMDxVLcdwP2pbyii5C5gAKBG1O1NIAAA7JsACACQjAAIAJCMNYAF6BuOaPIYGACgRugAAgAkIwACACRjBFyAV4ciJnkMDABQI3QAAQCSEQABAJIxAi7Aq3sjJroLGACoETqAAADJCIAAAMkIgAAAyVgDWIAdQxETPAYGAKgROoAAAMkIgAAAyRgBF2DH3ogGj4EBAGqEDiAAQDICIABAMkbABRjaOyNiqKHaZQAAjIkOIABAMgIgAEAyAiAAQDLWABZh76yIvROrXQUAwJjoAAIAJCMAAgAkYwRchKHZEUM+SgCgNugAAgAkIwACACQjAAIAJCMAAgAkIwACACQjAAIAJOPZJUUozYgoNVa7CgCAMdEBBABIRgAEAEjGCLgIjTMjmpqqXQUAwJjoAAIAJCMAAgAkYwRchNLMiFKp2lUAAIyJDiAAQDICIABAMgIgAEAy1gAWoWl2RFNztasAABgTHUAAgGQEQACAZIyAi9A0O6I0udpVAACMiQ4gAEAyAiAAQDJGwEVomhPRNKXaVQAAjIkOIABAMgIgAEAyAiAAQDLWABahNCeiNLXaVQAAjIkOIABAMukD4Pe+97143/veF83NzXH88cfHk08+We2SAADGVeoAeM8998SKFSviyiuvjKeffjo+/OEPx+LFi+PVV1+tdmkAAOMmdQC8/vrrY9myZXH++efHUUcdFevWrYspU6bEHXfcUe3SAADGTdoAuHv37ti4cWN0dXWN7JswYUJ0dXVFb2/vPn9mcHAwyuXyqA0AoNakvQv4T3/6U+zduzfa2tpG7W9ra4vNmzfv82dWr14dPT09+zgyNSKmjVOlAADFStsB/GesXLkyBgYGRratW7dWuyQAgHctbQfwgAMOiIkTJ0Z/f/+o/f39/dHe3r7PnymVSlEqlfZThQAA4yNtB7CpqSkWLlwYGzZsGNk3PDwcGzZsiM7OzqrWBgAwntJ2ACMiVqxYEeeee24sWrQoPvrRj8YNN9wQu3btivPPP/9dXmnqWxsAwL++1AHwc5/7XOzYsSNWrVoVfX19ccwxx8RDDz30jhtDAADqSUOlUqlUu4haVS6Xo7W1NWJgY0SLu4ABoCiV+M/jdu23v78HBgaipaVl3N7nX1nqDmBxPAYGAKgdaW8CAQDISgAEAEjGCLgQU9wFDADUDB1AAIBkBEAAgGQEQACAZKwBLMQ0j4EBAGqGDiAAQDICIABAMkbARfg/EyMaJ1a7CgCoH5OrXUB90wEEAEhGAAQASMYIuAh/jWgwAQaA4hgBjysdQACAZARAAIBkBEAAgGSsASzCGxHRUO0iAKCOHFDtAuqbDiAAQDICIABAMkbARfirKA0A1A6xBQAgGQEQACAZI+Ai7Kp2AQAAY6cDCACQjAAIAJCMAAgAkIw1gEWwBhAAqCE6gAAAyQiAAADJGAEXYVdEVKpdBADA2OgAAgAkIwACACQjAAIAJCMAAgAkIwACACQjAAIAJOMxMEXYORyVoeFqVwEAdUSPajz5dAEAkhEAAQCSMQIuQnlvxJ691a4CAOqIHtV48ukCACQjAAIAJGMEXISdRsAAUKzGahdQ13QAAQCSEQABAJIRAAEAkrEGsAg790bstgYQAKgNOoAAAMkIgAAAyRgBF+ENI2AAoHboAAIAJCMAAgAkYwRchJ1D0dA0VO0qAADGRAcQACCZugyAL7/8cixdujTmz58fkydPjsMOOyyuvPLK2L1796jznn322TjppJOiubk5Ojo64tprr61azQAA+0tdjoA3b94cw8PDceutt8b73//+2LRpUyxbtix27doV1113XURElMvlOP3006OrqyvWrVsXzz33XFxwwQUxY8aMuPDCC6v9KwAAjJuGSqVSqXYR+8N3v/vdWLt2bbz00ksREbF27dq44ooroq+vL5qamiIi4vLLL4/7778/Nm/ePKZrlsvlaG1tjfiv/zsaGqePa/0AkMnwT+aM27Xf/v4eGBiIlpaWcXuff2V1OQLel4GBgZg1a9bI697e3jj55JNHwl9ExOLFi2PLli3x2muvValKAIDxlyIAvvjii3HzzTfHl770pZF9fX190dbWNuq8t1/39fXt8zqDg4NRLpdHbQAAtaam1gBefvnlcc011/zDc37/+9/HggULRl5v27YtPvnJT8ZZZ50Vy5Yte0/vv3r16ujp6XnngTf2Rkzyl0AAgNpQU2sAd+zYEX/+85//4TmHHnroyFh3+/btccopp8QJJ5wQP/jBD2LChH9veH7xi1+Mcrkc999//8i+xx57LE477bT4y1/+EjNnznzHtQcHB2NwcHDkdblcjo6OjohT/xANk6wBBICiDD/cPm7XtgawxjqAc+bMiTlzxrYodNu2bXHqqafGwoULY/369aPCX0REZ2dnXHHFFbFnz55obGyMiIhHHnkkjjjiiH2Gv4iIUqkUpVKpgN8EAKB6aioAjtW2bdvilFNOiUMOOSSuu+662LFjx8ix9vb/9y+Ks88+O3p6emLp0qVx2WWXxaZNm+LGG2+MNWvWvPs3NAIGAGpIXQbARx55JF588cV48cUXY968eaOOvT3xbm1tjYcffji6u7tj4cKFccABB8SqVas8AxAAqHs1tQbwX83IcwCP+701gABQoOFf/adxu7Y1gEkeAwMAwL+ryxHwfvfXvRETrQEEAGqDDiAAQDICIABAMkbARdi1N2KCETAAUBt0AAEAkhEAAQCSMQIughEwAFBDdAABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCS8RiYIry5N6JhqNpVAACMiQ4gAEAyAiAAQDJGwEX465ARMABQM3QAAQCSEQABAJIxAi5A496haDACBgBqhA4gAEAyAiAAQDICIABAMtYAFqA5hqIhrAEEAGqDDiAAQDICIABAMkbABSjF3phgBAwA1AgdQACAZARAAIBkjIAL0BxDRsAAQM3QAQQASEYABABIRgAEAEjGGsACNMVQTKxYAwgA1AYdQACAZARAAIBkjIALUIqhmOgxMABAjdABBABIRgAEAEjGCLgARsAAQC3RAQQASEYABABIRgAEAEjGGsACNMbemGQNIABQI3QAAQCSEQABAJIxAi5AUwwZAQMANUMHEAAgGQEQACAZI+ACGAEDALVEBxAAIBkBEAAgGQEQACAZawALMCmGotEaQACgRugAAgAkU/cBcHBwMI455phoaGiIZ555ZtSxZ599Nk466aRobm6Ojo6OuPbaa6tWJwDA/lL3I+BLL7005s6dG7/73e9G7S+Xy3H66adHV1dXrFu3Lp577rm44IILYsaMGXHhhRe+q/dojL1GwABAzajrAPjggw/Gww8/HD/5yU/iwQcfHHXsrrvuit27d8cdd9wRTU1N8YEPfCCeeeaZuP766991AAQAqCV1OwLu7++PZcuWxY9+9KOYMmXKO4739vbGySefHE1NTSP7Fi9eHFu2bInXXnttn9ccHByMcrk8agMAqDV12QGsVCpx3nnnxZe//OVYtGhRvPzyy+84p6+vL+bPnz9qX1tb28ixmTNnvuNnVq9eHT09Pe/Y/z8HPhctLS2F/g4AAOOlpjqAl19+eTQ0NPzDbfPmzXHzzTfHzp07Y+XKlYW+/8qVK2NgYGBk27p1a6HXBwDYH2qqA/j1r389zjvvvH94zqGHHhqPPvpo9Pb2RqlUGnVs0aJFcc4558Sdd94Z7e3t0d/fP+r426/b29v3ee1SqfSOawIA1JqaCoBz5syJOXPm/Ifn3XTTTXHVVVeNvN6+fXssXrw47rnnnjj++OMjIqKzszOuuOKK2LNnTzQ2NkZExCOPPBJHHHHEPse/AAD1oqYC4FgdfPDBo15PmzYtIiIOO+ywmDdvXkREnH322dHT0xNLly6Nyy67LDZt2hQ33nhjrFmzpio1AwDsL3UZAMeitbU1Hn744eju7o6FCxfGAQccEKtWrfIIGACg7jVUKpVKtYuoVeVyOVpbW2NgYMBdwABQI3x/19hdwAAAvHcCIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyaf8UXBHe/iMq5XK52qUAAGP09vd25j+GJgC+Bzt37oyIiI6OjmqXAgC8Szt37ozW1tZql1EV/hbwezA8PBzbt2+P6dOnR0NDQ7XLAQDGoFKpxM6dO2Pu3LkxYULO1XACIABAMjljLwBAYgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAy/xdOvzqG8oXciwAAAABJRU5ErkJggg==", - "text/html": [ - "\n", - "
\n", - "
\n", - " Figure\n", - "
\n", - " \n", - "
\n", - " " - ], - "text/plain": [ - "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%matplotlib ipympl\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "\n", - "fig, ax = plt.subplots()\n", - "ax.axes.get_xaxis().set_visible(False)\n", - "ax.barh(temp_x, temp_height, color=list(color_list), align='edge', height=1)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "id": "5f0ba609-0885-42d3-9c6c-a1f3921aa5d7", - "metadata": {}, - "outputs": [], - "source": [ - "plt.close()" - ] - }, - { - "cell_type": "markdown", - "id": "7d242bca-18b1-4548-8118-c82110b56b57", - "metadata": {}, - "source": [ - "## Exporting" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "id": "9f338b99-a190-4025-8491-388094ced8f2", - "metadata": {}, - "outputs": [], - "source": [ - "# os.chdir(pwd)\n", - "\n", - "with open(\"color-scale-rgb.ts\", \"w+\") as f:\n", - " f.write(\"export default \")\n", - " f.write(json.dumps(hsl_list))\n", - "\n", - "with open(\"color-scale-hsl.ts\", \"w+\") as f:\n", - " f.write(\"export default \")\n", - " f.write(json.dumps(rgb_list))\n", - " \n", - "with open(\"color-scale-hex.ts\", \"w+\") as f:\n", - " f.write(\"export default \")\n", - " f.write(json.dumps(hex_list))" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.2" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index df8e566..18a1cca 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -11,7 +11,6 @@ import HourlyTable from './HourlyTable.svelte'; import MeteogramCharts from './MeteogramCharts.svelte'; import ModelSelector from './ModelSelector.svelte'; - import SunInfo from './SunInfo.svelte'; import type { GeoLocation } from '$lib/stores/settings'; import type { FetchedDaily, FetchedHourly } from './types'; @@ -116,17 +115,16 @@
- {#if fetchedHourly} + {#if fetchedHourly && fetchedDaily} {/if} - - {#if fetchedHourly} import { fade } from 'svelte/transition'; - import { getColor } from '../../utils/colors'; + import { getTempStyle } from '../../utils/colors'; import weatherCodes from '../../utils/weather-codes'; - import { - type FetchedDaily, - type WeatherUnits, - getDayLabel, - getTextColorForTemp, - getWindArrowRotation - } from './types'; + import { type FetchedDaily, type WeatherUnits, getDayLabel, getWindArrowRotation } from './types'; interface Props { daily: FetchedDaily | null; @@ -21,10 +15,32 @@ let { daily, selectedDay, units, onSelectDay }: Props = $props(); const today = new Date(); + + function getDaylightSeconds(index: number): number { + if (!daily) return 0; + const sunriseTs = daily.daily.sunrise[index]; + const sunsetTs = daily.daily.sunset[index]; + if (!sunriseTs || !sunsetTs) return 0; + return Math.max(0, sunsetTs - sunriseTs); + } + + function getSunshinePercent(sunshineSeconds: number | null, daylightSeconds: number): number { + if (!sunshineSeconds || daylightSeconds <= 0) return 0; + return Math.min(100, (sunshineSeconds / daylightSeconds) * 100); + } + + function getSunshineColor(sunshineSeconds: number | null, daylightSeconds: number): string { + if (daylightSeconds <= 0) return '#d1d5db'; + const ratio = (sunshineSeconds ?? 0) / daylightSeconds; + if (ratio >= 0.7) return '#f59e0b'; + if (ratio >= 0.45) return '#fbbf24'; + if (ratio >= 0.2) return '#fcd34d'; + return '#d1d5db'; + } -
-
+
+
{#if daily} {#each daily.dailyDates as time, index (index)} {@const selected = time.getDate() === selectedDay.getDate()} @@ -32,22 +48,38 @@ {@const tempMin = daily.daily.temperature_2m_min[index]} {@const wCode = daily.daily.weather_code[index]} {@const sunDuration = daily.daily.sunshine_duration[index]} + {@const daylightSec = getDaylightSeconds(index)} + {@const sunColor = getSunshineColor(sunDuration, daylightSec)} + {@const sunPct = getSunshinePercent(sunDuration, daylightSec)} {@const precipSum = daily.daily.precipitation_sum[index]} {@const windMax = daily.daily.windspeed_10m_max[index]} {@const gustMax = daily.daily.windgusts_10m_max[index]} {@const windDir = daily.daily.winddirection_10m_dominant[index]} + {@const unit = String(units.temperature_unit)} + {@const maxStyle = getTempStyle(tempMax, unit)} + {@const minStyle = getTempStyle(tempMin, unit)} {#if tempMax != null && !isNaN(tempMax)} {/if} {/each} @@ -115,118 +165,14 @@
diff --git a/src/routes/weather/week/[location]/HourlyTable.svelte b/src/routes/weather/week/[location]/HourlyTable.svelte index 4297c95..1a99ba2 100644 --- a/src/routes/weather/week/[location]/HourlyTable.svelte +++ b/src/routes/weather/week/[location]/HourlyTable.svelte @@ -1,28 +1,28 @@ -
-

+{#snippet weatherIcon(name: string, size: number = 16)} + + + +{/snippet} + +{#snippet rowHeader(iconName?: string, unit?: string, label?: string)} + +
+ {#if iconName} + {@render weatherIcon(iconName)} + {/if} + {#if label} + {label} + {/if} + {#if unit} + {unit} + {/if} +
+ +{/snippet} + + +
+

{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} – Hourly + ({timezoneLabel})

-
- 3h +
+ 3h - 1h + 1h
-{#if numCols > 0} +{#if cellData.length > 0} {@const hourly = data.hourly} - {@const dates = data.hourlyDates} -
- + {@const iconPx = is3h ? 40 : 26} +
+
- - {#each dayIdx as _ (_.toString())} + + {#each cellData as _ (_.idx)} {/each} - - - - {#each dayIdx as idx (idx)} - {@const date = dates[idx]} - {@const now = isCurrentHour(date, today)} - - {/each} + + + + - - - {#each dayIdx as idx (idx)} - {@const wCode = hourly.weather_code[idx]} - {@const date = dates[idx]} - {@const daytime = isDaytimeHour(date.getHours())} - {@const now = isCurrentHour(date, today)} - + {@render rowHeader('wi-day-cloudy')} + {#each cellData as cell, i (cell.idx)} + {@const wCode = hourly.weather_code[cell.idx]} + {/each} - - {#each dayIdx as idx (idx)} - {@const temp = hourly.temperature_2m[idx]} - {@const date = dates[idx]} - {@const now = isCurrentHour(date, today)} - {@const bg = getColor(temp, String(units.temperature_unit))} - {@const fg = getTextColorForTemp(temp, String(units.temperature_unit))} + {@render rowHeader('wi-thermometer', tempUnit)} + {#each cellData as cell (cell.idx)} + {@const temp = hourly.temperature_2m[cell.idx]} + {@const style = getTempStyle(temp, String(units.temperature_unit))} {/each} - - {#each dayIdx as idx (idx)} - {@const temp = hourly.apparent_temperature[idx]} - {@const date = dates[idx]} - {@const now = isCurrentHour(date, today)} - {@const bg = getColor(temp, String(units.temperature_unit))} - {@const fg = getTextColorForTemp(temp ?? 0, String(units.temperature_unit))} + {@render rowHeader(undefined, tempUnit, 'Feels')} + {#each cellData as cell (cell.idx)} + {@const temp = hourly.apparent_temperature[cell.idx]} {/each} - + - - {#each dayIdx as idx (idx)} - {@const cloud = hourly.cloud_cover[idx]} - {@const date = dates[idx]} - {@const now = isCurrentHour(date, today)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const precip = hourly.precipitation[idx]} - {@const prob = hourly.precipitation_probability[idx]} - {@const date = dates[idx]} - {@const now = isCurrentHour(date, today)} - - {/each} - - - - - - {#each dayIdx as idx (idx)} - {@const wind = hourly.windspeed_10m[idx]} - {@const windDir = hourly.winddirection_10m[idx]} - {@const date = dates[idx]} - {@const now = isCurrentHour(date, today)} - {/each} - - {#each dayIdx as idx (idx)} - {@const hum = hourly.relative_humidity_2m[idx]} - {@const date = dates[idx]} - {@const now = isCurrentHour(date, today)} - + {/each} + + + + + {@render rowHeader('wi-cloud', '%')} + {#each cellData as cell (cell.idx)} + {@const cloud = hourly.cloud_cover[cell.idx]} + + {/each} + + + + + {@render rowHeader('wi-raindrop', precipUnit)} + {#each cellData as cell (cell.idx)} + {@const precip = hourly.precipitation[cell.idx]} + {@const prob = hourly.precipitation_probability[cell.idx]} + {/each} @@ -316,94 +431,31 @@ {/if} diff --git a/src/routes/weather/week/[location]/MeteogramCharts.svelte b/src/routes/weather/week/[location]/MeteogramCharts.svelte index 0c87627..a5640f7 100644 --- a/src/routes/weather/week/[location]/MeteogramCharts.svelte +++ b/src/routes/weather/week/[location]/MeteogramCharts.svelte @@ -240,17 +240,15 @@ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, - color: getColor(Math.round(maxTemp), String(units.temperature_unit)) + '88' + color: getColor(maxTemp, String(units.temperature_unit)) + '88' }, { offset: 0.5, - color: - getColor(Math.round((maxTemp + minTemp) / 2), String(units.temperature_unit)) + - '44' + color: getColor((maxTemp + minTemp) / 2, String(units.temperature_unit)) + '44' }, { offset: 1, - color: getColor(Math.round(minTemp), String(units.temperature_unit)) + '08' + color: getColor(minTemp, String(units.temperature_unit)) + '08' } ]) }, diff --git a/src/routes/weather/week/[location]/types.ts b/src/routes/weather/week/[location]/types.ts index e4f4396..cc2be44 100644 --- a/src/routes/weather/week/[location]/types.ts +++ b/src/routes/weather/week/[location]/types.ts @@ -19,28 +19,23 @@ export interface FetchedDaily { dailyDates: Date[]; } -export function getTempUnit(units: WeatherUnits): string { +export const getTempUnit = (units: WeatherUnits): '°C' | '°F' => { return units.temperature_unit === 'celsius' ? '°C' : '°F'; -} +}; -export function getWindUnit(units: WeatherUnits): string { +export const getWindUnit = (units: WeatherUnits): string => { return units.wind_speed_unit === 'kmh' ? 'km/h' : units.wind_speed_unit; -} +}; -export function getPrecipUnit(units: WeatherUnits): string { +export const getPrecipUnit = (units: WeatherUnits): 'mm' | 'in' => { return units.precipitation_unit === 'mm' ? 'mm' : 'in'; -} +}; -export 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'; -} - -export function getWindArrowRotation(deg: number): string { +export const getWindArrowRotation = (deg: number): string => { return `rotate(${deg}deg)`; -} +}; -export function getWindDirectionLabel(deg: number): string { +export const getWindDirectionLabel = (deg: number): string => { const dirs = [ 'N', 'NNE', @@ -60,9 +55,9 @@ export function getWindDirectionLabel(deg: number): string { 'NNW' ]; return dirs[Math.round(deg / 22.5) % 16]; -} +}; -export function getDayLabel(date: Date, today: Date): string { +export const getDayLabel = (date: Date, today: Date): string => { const MS_PER_DAY = 24 * 3600 * 1000; const diff = Math.round( (new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() - @@ -73,17 +68,13 @@ export function getDayLabel(date: Date, today: Date): string { if (diff === 1) return 'Tomorrow'; if (diff === -1) return 'Yesterday'; return `${date.getMonth() + 1}-${date.getDate()}`; -} +}; -export function isDaytimeHour(hour: number): boolean { - return hour >= 6 && hour < 21; -} - -export function isCurrentHour(date: Date, now: Date): boolean { +export const isCurrentHour = (date: Date, now: Date): boolean => { return ( date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear() && date.getHours() === now.getHours() ); -} +}; -- 2.54.0 From d0c4dde7940cd927d3ddd78b010994c43017042a Mon Sep 17 00:00:00 2001 From: terraputix Date: Mon, 16 Feb 2026 00:25:27 +0100 Subject: [PATCH 10/12] no last day as default and prepare timezone --- src/lib/services/weather.ts | 6 ++++-- src/routes/weather/week/[location]/+page.svelte | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index 417b470..1cffa3e 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -164,6 +164,7 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams { model?: string; forecast_days?: number; past_days?: number; + timezone?: string; } export interface WeekHourlyData { @@ -285,7 +286,7 @@ const WEEK_DAILY_VARS = [ */ export async function fetchWeekForecast(params: WeekForecastParams): Promise { const forecastDays = params.forecast_days ?? 6; - const pastDays = params.past_days ?? 1; + const pastDays = params.past_days ?? 0; const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined; const apiParams: Record = { @@ -298,7 +299,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise Date: Mon, 16 Feb 2026 00:32:26 +0100 Subject: [PATCH 11/12] max width scaling --- src/routes/weather/week/[location]/DailyCards.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/weather/week/[location]/DailyCards.svelte b/src/routes/weather/week/[location]/DailyCards.svelte index 1d226e7..688cc31 100644 --- a/src/routes/weather/week/[location]/DailyCards.svelte +++ b/src/routes/weather/week/[location]/DailyCards.svelte @@ -60,7 +60,7 @@ {@const minStyle = getTempStyle(tempMin, unit)} {#if tempMax != null && !isNaN(tempMax)}
Hourly weather details for {locationName}
- {pad(date.getHours())}00 -
+ {timezoneLabel} + + + {#if sunTimes && sunrisePercent != null && sunsetPercent != null} +
+
+
+ +
+ + + {formatTime(sunTimes.sunrise)} + +
+ +
+ + + {formatTime(sunTimes.sunset)} + +
+ {/if} + + {#each cellData as cell, i (cell.idx)} + {@const leftPct = (i / cellData.length) * 100} + {@const widthPct = 100 / cellData.length} + + {#if is3h} + {pad(cell.date.getHours())} + {:else} + + {pad(cell.date.getHours())} + 00 + + {/if} + + {/each} +
- - - - - - - +
+ {@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
-
- - - - {tempUnit} -
-
- {temp != null ? temp.toFixed(0) + '°' : '-'} + {formatTemp(temp)}
-
- Feels - {tempUnit} -
-
- {temp != null ? temp.toFixed(0) + '°' : '-'} + {formatTemp(temp)}
-
- - - - % -
-
- {cloud != null ? cloud.toFixed(0) : '-'} -
-
- - - - {precipUnit} -
-
-
- {#if precip > 0} -
- {/if} - - {#if precip > 0} - {precip.toFixed(1)} - {:else if prob != null && prob > 0} - {prob}% - {/if} - -
-
- - - - {windUnit} -
-
+ {@render rowHeader('wi-strong-wind', windUnit)} + {#each cellData as cell (cell.idx)} + {@const wind = hourly.windspeed_10m[cell.idx]} + {@const windDir = hourly.winddirection_10m[cell.idx]} + {#if windDir != null && !isNaN(windDir)} -
- - - -
+ + {@render weatherIcon('wi-direction-down', 24)} + {/if} - {wind?.toFixed(0) ?? '-'} + + {formatValue(wind)} +
-
- - - - % -
-
- {hum != null ? hum.toFixed(0) : '-'} + {@render rowHeader('wi-humidity', '%')} + {#each cellData as cell (cell.idx)} + {@const hum = hourly.relative_humidity_2m[cell.idx]} + + {formatValue(hum)} +
+ {formatValue(cloud)} +
+ {#if precip > 0} +
+ + {precip.toFixed(1)} + + {/if}