feat: migrate to apache echarts #4
@@ -11,7 +11,7 @@ Our objective is to provide a comprehensive, user-friendly weather platform for
|
|||||||
- **Framework**: [SvelteKit](https://kit.svelte.dev/)
|
- **Framework**: [SvelteKit](https://kit.svelte.dev/)
|
||||||
- **Language**: [TypeScript](https://www.typescriptlang.org/)
|
- **Language**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
- **Data Source**: [Open-Meteo API](https://open-meteo.com/)
|
- **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
|
## Developing
|
||||||
|
|
||||||
|
|||||||
Generated
+32
-7
@@ -8,7 +8,7 @@
|
|||||||
"name": "open-meteo-weather",
|
"name": "open-meteo-weather",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"highcharts": "^12.4.0",
|
"echarts": "^6.0.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"openmeteo": "^1.2.3"
|
"openmeteo": "^1.2.3"
|
||||||
},
|
},
|
||||||
@@ -2594,6 +2594,22 @@
|
|||||||
"integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==",
|
"integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.4",
|
"version": "5.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||||
@@ -3063,12 +3079,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"license": "MIT"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@
|
|||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"highcharts": "^12.4.0",
|
"echarts": "^6.0.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"openmeteo": "^1.2.3"
|
"openmeteo": "^1.2.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const prerender = true;
|
||||||
@@ -209,7 +209,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{#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)}
|
||||||
<div class="text-center" in:fly={{ y: 20, duration: 500, delay: 1200 + index * 100 }}>
|
<div class="text-center" in:fly={{ y: 20, duration: 500, delay: 1200 + index * 100 }}>
|
||||||
<div
|
<div
|
||||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-purple-500"
|
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-purple-500"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const prerender = true;
|
|
||||||
|
|
||||||
export const load = (async () => {
|
export const load = (async () => {
|
||||||
throw redirect(303, '/weather/week/');
|
throw redirect(303, '/weather/week/');
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
@@ -3,19 +3,20 @@
|
|||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { dev } from '$app/environment';
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
import '../compare/highcharts.css';
|
import '../compare/echarts.css';
|
||||||
import { defaultParameters } from './options';
|
import { defaultParameters } from './options';
|
||||||
|
|
||||||
let node: HTMLElement;
|
let node: HTMLElement;
|
||||||
let chart: any;
|
let charts: echarts.ECharts[] = [];
|
||||||
let Highcharts = $state<typeof import('highcharts') | null>(null);
|
let resizeObservers: ResizeObserver[] = [];
|
||||||
|
let mounted = $state(false);
|
||||||
|
|
||||||
let showLegend = $state(false);
|
let showLegend = $state(false);
|
||||||
let averageOnly = $state(false);
|
let averageOnly = $state(false);
|
||||||
@@ -32,32 +33,48 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let count = $state(0);
|
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) {
|
function isDarkMode(): boolean {
|
||||||
// const HighchartsDebugger = await import('highcharts/modules/debugger');
|
if (typeof document === 'undefined') return false;
|
||||||
// HighchartsDebugger.default(Highcharts);
|
return (
|
||||||
const Debugger = (
|
document.documentElement.classList.contains('dark') ||
|
||||||
await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any)
|
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||||
).default;
|
(window.matchMedia &&
|
||||||
const ErrorMessages = (
|
window.matchMedia('(prefers-color-scheme: dark)').matches &&
|
||||||
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any)
|
document.documentElement.getAttribute('data-theme') !== 'light')
|
||||||
).default;
|
);
|
||||||
if (Highcharts) {
|
|
||||||
(Highcharts as any).errorMessages = ErrorMessages;
|
|
||||||
Debugger.compose(Highcharts.Chart);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
$effect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
count = 0;
|
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();
|
node.replaceChildren();
|
||||||
|
|
||||||
const dataDaily = await fetch(
|
const dataDaily = await fetch(
|
||||||
@@ -70,41 +87,60 @@
|
|||||||
);
|
);
|
||||||
const data = await dataReq.json();
|
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) {
|
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
|
||||||
let rise = wd.daily.sunrise;
|
let rise = wd.daily.sunrise;
|
||||||
let set = wd.daily.sunset;
|
let set = wd.daily.sunset;
|
||||||
plotBands = rise.map(function (r: any, i: number) {
|
markAreas = rise.map(function (r: number, i: number) {
|
||||||
return {
|
return [
|
||||||
color: 'rgba(255, 255, 194, 0.5)',
|
{
|
||||||
from: (r + data.utc_offset_seconds) * 1000,
|
xAxis: (r + data.utc_offset_seconds) * 1000,
|
||||||
to: (set[i] + 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 minValues = new Array(data.hourly.time.length).fill(undefined);
|
||||||
let maxValues = new Array(data.hourly.time.length).fill(undefined);
|
let maxValues = new Array(data.hourly.time.length).fill(undefined);
|
||||||
|
|
||||||
|
const textColor = getTextColor();
|
||||||
|
const axisLineColor = getAxisLineColor();
|
||||||
|
const splitLineColor = getSplitLineColor();
|
||||||
|
|
||||||
for (let variable of params.hourly || []) {
|
for (let variable of params.hourly || []) {
|
||||||
const chartDiv = document.createElement('div');
|
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 unit: string = '';
|
||||||
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
|
|
||||||
|
|
||||||
const series = [];
|
const series: Array<Record<string, unknown>> = [];
|
||||||
let average = new Array(data.hourly.time.length).fill(0);
|
let average = new Array(data.hourly.time.length).fill(0);
|
||||||
let averageCount = new Array(data.hourly.time.length).fill(0);
|
let averageCount = new Array(data.hourly.time.length).fill(0);
|
||||||
|
|
||||||
|
const timestamps = data.hourly.time.map(
|
||||||
|
(t: number) => (t + data.utc_offset_seconds) * 1000
|
||||||
|
);
|
||||||
|
|
||||||
for (let [model, values] of Object.entries(data.hourly)) {
|
for (let [model, values] of Object.entries(data.hourly)) {
|
||||||
if (model === 'time') {
|
if (model === 'time') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (model.startsWith(variable)) {
|
if (model.startsWith(variable)) {
|
||||||
for (let [index, val] of (values as any[]).entries()) {
|
for (let [index, val] of (values as number[]).entries()) {
|
||||||
if (val) {
|
if (val !== null && val !== undefined) {
|
||||||
let avVal = average[index];
|
let avVal = average[index];
|
||||||
average[index] = avVal + val;
|
average[index] = avVal + val;
|
||||||
averageCount[index]++;
|
averageCount[index]++;
|
||||||
@@ -122,145 +158,231 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate average
|
||||||
for (let [index, val] of average.entries()) {
|
for (let [index, val] of average.entries()) {
|
||||||
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
|
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minMax = [];
|
// Create min-max area data
|
||||||
for (let [index, min] of minValues.entries()) {
|
const spreadData: Array<[number, number, number]> = minValues.map(
|
||||||
minMax.push([min, maxValues[index]]);
|
(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({
|
series.push({
|
||||||
name: 'temperature_2m_spread',
|
name: variable + '_spread',
|
||||||
data: minMax,
|
type: 'line',
|
||||||
type: 'arearange',
|
data: spreadData.map((d) => [d[0], d[1]]),
|
||||||
tooltip: {
|
areaStyle: {
|
||||||
valueSuffix: ' ' + unit
|
color: 'rgba(173, 216, 230, 0.3)',
|
||||||
|
origin: 'auto'
|
||||||
},
|
},
|
||||||
pointStart: hourly_starttime,
|
lineStyle: {
|
||||||
pointInterval: pointInterval,
|
width: 0
|
||||||
className: 'highcharts-spread-series'
|
},
|
||||||
|
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({
|
series.push({
|
||||||
name: variable + '_average',
|
name: variable + '_average',
|
||||||
data: average,
|
type: isColumn ? 'bar' : 'line',
|
||||||
dashStyle: 'ShortDashDot',
|
data: averageData,
|
||||||
color: '#5e5e5e',
|
smooth: !isColumn,
|
||||||
type:
|
showSymbol: false,
|
||||||
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
|
lineStyle: {
|
||||||
? 'column'
|
type: 'dashed',
|
||||||
: 'spline',
|
width: 4,
|
||||||
tooltip: {
|
color: '#5e5e5e'
|
||||||
valueSuffix: ' ' + unit
|
|
||||||
},
|
},
|
||||||
lineWidth: 4,
|
itemStyle: {
|
||||||
states: {
|
color: '#5e5e5e'
|
||||||
hover: {
|
},
|
||||||
lineWidth: 6
|
emphasis: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pointStart: hourly_starttime,
|
barMaxWidth: 5,
|
||||||
pointInterval: pointInterval,
|
z: 10
|
||||||
className: 'highcharts-average-series'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
new Highcharts!.Chart({
|
// Add current time markLine via a helper series
|
||||||
chart: {
|
series.push({
|
||||||
renderTo: chartDiv,
|
name: 'Current Time',
|
||||||
height: showLegend ? '400px' : '300px',
|
type: 'line',
|
||||||
styledMode: true,
|
data: [],
|
||||||
marginLeft: 50,
|
markLine: {
|
||||||
marginRight: 0
|
silent: true,
|
||||||
},
|
symbol: 'none',
|
||||||
|
data: [
|
||||||
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 <span class="font-bold">${params.hourly?.join(', ') || ''}</span> in models: <span class="font-bold">` +
|
|
||||||
(params.models?.join(', ') || '') +
|
|
||||||
'</span>'
|
|
||||||
: '',
|
|
||||||
align: 'left'
|
|
||||||
},
|
|
||||||
|
|
||||||
yAxis: {
|
|
||||||
title: {
|
|
||||||
text: unit
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
xAxis: {
|
|
||||||
type: 'datetime',
|
|
||||||
plotLines: [
|
|
||||||
{
|
{
|
||||||
value: Date.now() + data.utc_offset_seconds * 1000,
|
xAxis: Date.now() + data.utc_offset_seconds * 1000,
|
||||||
|
lineStyle: {
|
||||||
color: 'red',
|
color: 'red',
|
||||||
width: 2
|
width: 2
|
||||||
}
|
|
||||||
],
|
|
||||||
plotBands: plotBands
|
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
plotOptions: {
|
show: false
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
|
|
||||||
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<string, unknown> = {
|
||||||
|
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++;
|
count++;
|
||||||
node.appendChild(chartDiv);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -268,22 +390,25 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (chart) {
|
resizeObservers.forEach((ro) => ro.disconnect());
|
||||||
chart.destroy();
|
resizeObservers = [];
|
||||||
}
|
charts.forEach((chart) => {
|
||||||
|
chart.dispose();
|
||||||
|
});
|
||||||
|
charts = [];
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
|
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
|
||||||
<div
|
<div
|
||||||
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * (params.hourly?.length || 0) +
|
class="container-wrapper relative -mx-6 md:mx-0"
|
||||||
2}px]"
|
style="min-height: {300 * (params.hourly?.length || 0) + 2}px"
|
||||||
>
|
>
|
||||||
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
|
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
|
||||||
<div
|
<div
|
||||||
class="{count > 0
|
class="{count > 0
|
||||||
? 'pointer-events-none opacity-0'
|
? 'pointer-events-none opacity-0'
|
||||||
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent/100"
|
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-loader-circle animate-spin"
|
class="lucide lucide-loader-circle animate-spin"
|
||||||
@@ -314,7 +439,7 @@
|
|||||||
params.hourly = params.hourly;
|
params.hourly = params.hourly;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
|
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -325,7 +450,7 @@
|
|||||||
params.hourly = params.hourly;
|
params.hourly = params.hourly;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
|
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { dev } from '$app/environment';
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
@@ -12,15 +12,16 @@
|
|||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
import { hourly, models as modelsFlat } from '../options';
|
import { hourly, models as modelsFlat } from '../options';
|
||||||
import './highcharts.css';
|
import './echarts.css';
|
||||||
import { defaultParameters } from './options';
|
import { defaultParameters } from './options';
|
||||||
|
|
||||||
// Wrap models in array to match template expectation of nested arrays like hourly
|
// Wrap models in array to match template expectation of nested arrays like hourly
|
||||||
const models = [modelsFlat];
|
const models = [modelsFlat];
|
||||||
|
|
||||||
let node: HTMLElement;
|
let node: HTMLElement;
|
||||||
let chart: any;
|
let charts: echarts.ECharts[] = [];
|
||||||
let Highcharts = $state<typeof import('highcharts') | null>(null);
|
let resizeObservers: ResizeObserver[] = [];
|
||||||
|
let mounted = $state(false);
|
||||||
|
|
||||||
let showLegend = $state(false);
|
let showLegend = $state(false);
|
||||||
let averageOnly = $state(false);
|
let averageOnly = $state(false);
|
||||||
@@ -42,30 +43,48 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let count = $state(0);
|
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) {
|
function isDarkMode(): boolean {
|
||||||
// const HighchartsDebugger = await import('highcharts/modules/debugger');
|
if (typeof document === 'undefined') return false;
|
||||||
// HighchartsDebugger.default(Highcharts);
|
return (
|
||||||
const Debugger = (
|
document.documentElement.classList.contains('dark') ||
|
||||||
await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any)
|
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||||
).default;
|
(window.matchMedia &&
|
||||||
const ErrorMessages = (
|
window.matchMedia('(prefers-color-scheme: dark)').matches &&
|
||||||
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any)
|
document.documentElement.getAttribute('data-theme') !== 'light')
|
||||||
).default;
|
);
|
||||||
if (Highcharts) {
|
|
||||||
(Highcharts as any).errorMessages = ErrorMessages;
|
|
||||||
Debugger.compose(Highcharts.Chart);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
$effect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
count = 0;
|
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();
|
node.replaceChildren();
|
||||||
|
|
||||||
const dataReq = await fetch(
|
const dataReq = await fetch(
|
||||||
@@ -77,7 +96,9 @@
|
|||||||
dailyFirstModelKey.shift();
|
dailyFirstModelKey.shift();
|
||||||
dailyFirstModelKey = dailyFirstModelKey.join('_');
|
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 (
|
if (
|
||||||
'daily' in data &&
|
'daily' in data &&
|
||||||
'sunrise_' + dailyFirstModelKey in data.daily &&
|
'sunrise_' + dailyFirstModelKey in data.daily &&
|
||||||
@@ -85,34 +106,51 @@
|
|||||||
) {
|
) {
|
||||||
let rise = data.daily['sunrise_' + dailyFirstModelKey];
|
let rise = data.daily['sunrise_' + dailyFirstModelKey];
|
||||||
let set = data.daily['sunset_' + dailyFirstModelKey];
|
let set = data.daily['sunset_' + dailyFirstModelKey];
|
||||||
plotBands = rise.map(function (r: any, i: number) {
|
markAreas = rise.map(function (r: number, i: number) {
|
||||||
return {
|
return [
|
||||||
color: 'rgba(255, 255, 194, 0.5)',
|
{
|
||||||
from: (r + data.utc_offset_seconds) * 1000,
|
xAxis: (r + data.utc_offset_seconds) * 1000,
|
||||||
to: (set[i] + 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 || []) {
|
for (let variable of params.hourly || []) {
|
||||||
const chartDiv = document.createElement('div');
|
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 unit: string = '';
|
||||||
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
|
|
||||||
|
|
||||||
const series = [];
|
const series: Array<Record<string, unknown>> = [];
|
||||||
let average = new Array(data.hourly.time.length).fill(0);
|
let average = new Array(data.hourly.time.length).fill(0);
|
||||||
let averageCount = new Array(data.hourly.time.length).fill(0);
|
let averageCount = new Array(data.hourly.time.length).fill(0);
|
||||||
|
|
||||||
|
const timestamps = data.hourly.time.map(
|
||||||
|
(t: number) => (t + data.utc_offset_seconds) * 1000
|
||||||
|
);
|
||||||
|
|
||||||
for (let [model, values] of Object.entries(data.hourly)) {
|
for (let [model, values] of Object.entries(data.hourly)) {
|
||||||
if (model === 'time') {
|
if (model === 'time') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (model.startsWith(variable)) {
|
if (model.startsWith(variable)) {
|
||||||
for (let [index, val] of (values as any[]).entries()) {
|
for (let [index, val] of (values as number[]).entries()) {
|
||||||
if (val) {
|
if (val !== null && val !== undefined) {
|
||||||
let avVal = average[index];
|
let avVal = average[index];
|
||||||
average[index] = avVal + val;
|
average[index] = avVal + val;
|
||||||
averageCount[index]++;
|
averageCount[index]++;
|
||||||
@@ -122,145 +160,213 @@
|
|||||||
unit = data.hourly_units[model];
|
unit = data.hourly_units[model];
|
||||||
|
|
||||||
if (!averageOnly) {
|
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({
|
series.push({
|
||||||
name: model,
|
name: model,
|
||||||
data: values,
|
type: isColumn ? 'bar' : 'line',
|
||||||
type:
|
data: seriesData,
|
||||||
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
|
smooth: !isColumn,
|
||||||
? 'column'
|
showSymbol: false,
|
||||||
: 'spline',
|
lineStyle: {
|
||||||
tooltip: {
|
width: 2
|
||||||
valueSuffix: ' ' + unit
|
|
||||||
},
|
},
|
||||||
pointStart: hourly_starttime,
|
emphasis: {
|
||||||
pointInterval: pointInterval
|
lineStyle: {
|
||||||
|
width: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
barMaxWidth: 5
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate average
|
||||||
for (let [index, val] of average.entries()) {
|
for (let [index, val] of average.entries()) {
|
||||||
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
|
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²';
|
||||||
|
const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]);
|
||||||
|
|
||||||
series.push({
|
series.push({
|
||||||
name: variable + '_average',
|
name: variable + '_average',
|
||||||
data: average,
|
type: isColumn ? 'bar' : 'line',
|
||||||
dashStyle: 'ShortDashDot',
|
data: averageData,
|
||||||
color: '#5e5e5e',
|
smooth: !isColumn,
|
||||||
type:
|
showSymbol: false,
|
||||||
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
|
lineStyle: {
|
||||||
? 'column'
|
type: 'dashed',
|
||||||
: 'spline',
|
width: 4,
|
||||||
tooltip: {
|
color: '#5e5e5e'
|
||||||
valueSuffix: ' ' + unit
|
|
||||||
},
|
},
|
||||||
lineWidth: 4,
|
itemStyle: {
|
||||||
states: {
|
color: '#5e5e5e'
|
||||||
hover: {
|
},
|
||||||
lineWidth: 6
|
emphasis: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pointStart: hourly_starttime,
|
barMaxWidth: 5,
|
||||||
pointInterval: pointInterval,
|
z: 10
|
||||||
className: 'highcharts-average-series'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
new Highcharts!.Chart({
|
// Add current time markLine via a helper series
|
||||||
chart: {
|
series.push({
|
||||||
renderTo: chartDiv,
|
name: 'Current Time',
|
||||||
height: showLegend ? '400px' : '300px',
|
type: 'line',
|
||||||
styledMode: true,
|
data: [],
|
||||||
marginLeft: 50,
|
markLine: {
|
||||||
marginRight: 0
|
silent: true,
|
||||||
},
|
symbol: 'none',
|
||||||
|
data: [
|
||||||
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 <span class="font-bold">${params.hourly?.join(', ') || ''}</span> in models: <span class="font-bold">` +
|
|
||||||
(params.models?.join(', ') || '') +
|
|
||||||
'</span>'
|
|
||||||
: '',
|
|
||||||
align: 'left'
|
|
||||||
},
|
|
||||||
|
|
||||||
yAxis: {
|
|
||||||
title: {
|
|
||||||
text: unit
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
xAxis: {
|
|
||||||
type: 'datetime',
|
|
||||||
plotLines: [
|
|
||||||
{
|
{
|
||||||
value: Date.now() + data.utc_offset_seconds * 1000,
|
xAxis: Date.now() + data.utc_offset_seconds * 1000,
|
||||||
|
lineStyle: {
|
||||||
color: 'red',
|
color: 'red',
|
||||||
width: 2
|
width: 2
|
||||||
}
|
|
||||||
],
|
|
||||||
plotBands: plotBands
|
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
plotOptions: {
|
show: false
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
|
|
||||||
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<string, unknown> = {
|
||||||
|
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++;
|
count++;
|
||||||
node.appendChild(chartDiv);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -268,22 +374,25 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (chart) {
|
resizeObservers.forEach((ro) => ro.disconnect());
|
||||||
chart.destroy();
|
resizeObservers = [];
|
||||||
}
|
charts.forEach((chart) => {
|
||||||
|
chart.dispose();
|
||||||
|
});
|
||||||
|
charts = [];
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
|
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
|
||||||
<div
|
<div
|
||||||
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * (params.hourly?.length || 0) +
|
class="container-wrapper relative -mx-6 md:mx-0"
|
||||||
2}px]"
|
style="min-height: {300 * (params.hourly?.length || 0) + 2}px"
|
||||||
>
|
>
|
||||||
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
|
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
|
||||||
<div
|
<div
|
||||||
class="{count > 0
|
class="{count > 0
|
||||||
? 'pointer-events-none opacity-0'
|
? 'pointer-events-none opacity-0'
|
||||||
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent/100"
|
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-loader-circle animate-spin"
|
class="lucide lucide-loader-circle animate-spin"
|
||||||
@@ -314,7 +423,7 @@
|
|||||||
params.hourly = params.hourly;
|
params.hourly = params.hourly;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
|
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -325,7 +434,7 @@
|
|||||||
params.hourly = params.hourly;
|
params.hourly = params.hourly;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
|
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +443,7 @@
|
|||||||
<div class="flex">
|
<div class="flex">
|
||||||
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
|
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
|
||||||
{#if params.models && params.models.length > 0}
|
{#if params.models && params.models.length > 0}
|
||||||
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
|
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
|
||||||
<div
|
<div
|
||||||
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
|
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
|
||||||
>
|
>
|
||||||
@@ -387,7 +496,7 @@
|
|||||||
</h2></a
|
</h2></a
|
||||||
>
|
>
|
||||||
{#if params.hourly && params.hourly.length > 0}
|
{#if params.hourly && params.hourly.length > 0}
|
||||||
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
|
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
|
||||||
<div
|
<div
|
||||||
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
|
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
|||||||
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const prerender = true;
|
|
||||||
|
|
||||||
export const load = (async () => {
|
export const load = (async () => {
|
||||||
const location = get(storedLocation);
|
const location = get(storedLocation);
|
||||||
const locationRoute = geoLocationNameToRoute(location.name);
|
const locationRoute = geoLocationNameToRoute(location.name);
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
|||||||
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const prerender = true;
|
|
||||||
|
|
||||||
export const load: PageLoad = async (event) => {
|
export const load: PageLoad = async (event) => {
|
||||||
const urlLocation = event.params.location;
|
const urlLocation = event.params.location;
|
||||||
let urlLocationSplit, urlLocationName, urlLocationId;
|
let urlLocationSplit, urlLocationName, urlLocationId;
|
||||||
|
|||||||
Reference in New Issue
Block a user