remove echarts, use canvas
This commit is contained in:
Generated
+2
-38
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "open-meteo-weather",
|
"name": "ombrella",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "open-meteo-weather",
|
"name": "ombrella",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.1.0",
|
"@eslint/compat": "^2.1.0",
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"echarts": "^6.1.0",
|
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.7.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.21.0",
|
"eslint-plugin-svelte": "^3.21.0",
|
||||||
@@ -2123,24 +2122,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/echarts": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "2.3.0",
|
|
||||||
"zrender": "6.1.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==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
|
||||||
},
|
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.24.2",
|
"version": "5.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
|
||||||
@@ -4510,23 +4491,6 @@
|
|||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
|
||||||
"node_modules/zrender": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
|
||||||
"dev": true,
|
|
||||||
"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==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "open-meteo-weather",
|
"name": "ombrella",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"echarts": "^6.1.0",
|
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.7.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.21.0",
|
"eslint-plugin-svelte": "^3.21.0",
|
||||||
|
|||||||
@@ -0,0 +1,829 @@
|
|||||||
|
<!--
|
||||||
|
CanvasChart.svelte — Self-contained canvas time-series chart
|
||||||
|
|
||||||
|
Renders line and bar series over a shared hourly time axis on a
|
||||||
|
devicePixelRatio-aware canvas. Supports daylight background bands,
|
||||||
|
timezone-aware axis labels with emphasized day boundaries, a DOM tooltip
|
||||||
|
with crosshair, wheel/pinch zoom, drag panning, and cross-chart
|
||||||
|
synchronization via the `group` prop.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<CanvasChart
|
||||||
|
timestamps={epochSeconds}
|
||||||
|
timezone="Europe/Berlin"
|
||||||
|
series={[{ name: 'Temperature', type: 'line', color: '#ef6c00', data: temps }]}
|
||||||
|
bands={daylightBands}
|
||||||
|
unit="°C"
|
||||||
|
group="meteogram"
|
||||||
|
/>
|
||||||
|
-->
|
||||||
|
<script module lang="ts">
|
||||||
|
export interface ChartSeries {
|
||||||
|
/** Series display name (used in legend and tooltip) */
|
||||||
|
name: string;
|
||||||
|
/** Render style */
|
||||||
|
type: 'line' | 'bar';
|
||||||
|
/** Any CSS color string */
|
||||||
|
color: string;
|
||||||
|
/** One value per timestamp; null values break lines */
|
||||||
|
data: (number | null)[];
|
||||||
|
/** Line width in px (default 2); 0 draws only the area fill */
|
||||||
|
width?: number;
|
||||||
|
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
|
||||||
|
fill?: boolean;
|
||||||
|
/** Opacity of the area fill (default 0.15) */
|
||||||
|
fillOpacity?: number;
|
||||||
|
/** Draw the line dashed */
|
||||||
|
dashed?: boolean;
|
||||||
|
/** Hide the series entirely (not rendered, not listed in the tooltip) */
|
||||||
|
hidden?: boolean;
|
||||||
|
/** Which y axis the series is scaled against (default 'left') */
|
||||||
|
axis?: 'left' | 'right';
|
||||||
|
/** Include the series in the legend row (default true) */
|
||||||
|
showInLegend?: boolean;
|
||||||
|
/** Custom tooltip value formatter */
|
||||||
|
format?: (value: number, index: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupState {
|
||||||
|
range: { start: number; end: number } | null;
|
||||||
|
hover: number | null;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level registry: charts sharing a `group` name share zoom range and
|
||||||
|
// crosshair position through one reactive state object.
|
||||||
|
const groups: Record<string, GroupState> = $state({});
|
||||||
|
|
||||||
|
function acquireGroup(name: string): GroupState {
|
||||||
|
if (!groups[name]) {
|
||||||
|
groups[name] = { range: null, hover: null, count: 0 };
|
||||||
|
}
|
||||||
|
groups[name].count++;
|
||||||
|
return groups[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseGroup(name: string): void {
|
||||||
|
const state = groups[name];
|
||||||
|
if (!state) return;
|
||||||
|
state.count--;
|
||||||
|
if (state.count <= 0) {
|
||||||
|
delete groups[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
import { formatZoned, getZonedHour } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { CHART_COLORS } from './data';
|
||||||
|
|
||||||
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Time axis values in epoch seconds (shared by all series) */
|
||||||
|
timestamps: number[];
|
||||||
|
/** IANA timezone used for all time labels */
|
||||||
|
timezone: string;
|
||||||
|
/** Series to render */
|
||||||
|
series: ChartSeries[];
|
||||||
|
/** Background bands (epoch seconds), e.g. daylight */
|
||||||
|
bands?: { start: number; end: number }[];
|
||||||
|
/** Unit label for the left y axis (also used in tooltip values) */
|
||||||
|
unit?: string;
|
||||||
|
/** Unit label for the right y axis; when set, right-axis labels are drawn */
|
||||||
|
unitRight?: string;
|
||||||
|
/** Canvas height in px */
|
||||||
|
height?: number;
|
||||||
|
/** Charts sharing a group share x-zoom range and crosshair */
|
||||||
|
group?: string;
|
||||||
|
/** Fixed left-axis minimum (otherwise derived from data, including 0) */
|
||||||
|
yMin?: number;
|
||||||
|
/** Fixed left-axis maximum */
|
||||||
|
yMax?: number;
|
||||||
|
/** Fixed right-axis minimum (default 0) */
|
||||||
|
yMinRight?: number;
|
||||||
|
/** Fixed right-axis maximum (default 100) */
|
||||||
|
yMaxRight?: number;
|
||||||
|
/** Invert the right axis (min at the top) */
|
||||||
|
invertRight?: boolean;
|
||||||
|
/** Chart title drawn top-left on the canvas */
|
||||||
|
title?: string;
|
||||||
|
/** Smaller subtitle drawn under the title */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Show the DOM legend row above the canvas */
|
||||||
|
showLegend?: boolean;
|
||||||
|
/** Draw a red vertical line at the current time */
|
||||||
|
showNow?: boolean;
|
||||||
|
/** Draw the Open-Meteo.com credit bottom-right */
|
||||||
|
showCredit?: boolean;
|
||||||
|
/** Optional CSS class for the outer container */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
timestamps,
|
||||||
|
timezone,
|
||||||
|
series,
|
||||||
|
bands = [],
|
||||||
|
unit = '',
|
||||||
|
unitRight,
|
||||||
|
height = 300,
|
||||||
|
group,
|
||||||
|
yMin,
|
||||||
|
yMax,
|
||||||
|
yMinRight,
|
||||||
|
yMaxRight,
|
||||||
|
invertRight = false,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
showLegend = false,
|
||||||
|
showNow = true,
|
||||||
|
showCredit = false,
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PAD_LEFT = 60;
|
||||||
|
const PAD_BOTTOM = 34;
|
||||||
|
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
|
||||||
|
const HOUR = 3600;
|
||||||
|
|
||||||
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
|
let width = $state(0);
|
||||||
|
let themeVersion = $state(0);
|
||||||
|
const legendHidden = new SvelteSet<string>();
|
||||||
|
|
||||||
|
// Zoom range and crosshair: either group-shared or local to this chart.
|
||||||
|
// The group is acquired once at component init (the prop is treated as fixed).
|
||||||
|
const groupName = untrack(() => group);
|
||||||
|
const groupState: GroupState | null = groupName ? acquireGroup(groupName) : null;
|
||||||
|
let localRange = $state<{ start: number; end: number } | null>(null);
|
||||||
|
let localHover = $state<number | null>(null);
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (groupName) releaseGroup(groupName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Derived: view window & scales ──────────────────────────────────────────
|
||||||
|
|
||||||
|
let viewRange = $derived(groupState ? groupState.range : localRange);
|
||||||
|
let hoverTime = $derived(groupState ? groupState.hover : localHover);
|
||||||
|
|
||||||
|
let tMin = $derived(timestamps.length > 0 ? timestamps[0] : 0);
|
||||||
|
let tMax = $derived(timestamps.length > 1 ? timestamps[timestamps.length - 1] : tMin + HOUR);
|
||||||
|
let viewStart = $derived(viewRange ? Math.max(tMin, viewRange.start) : tMin);
|
||||||
|
let viewEnd = $derived(
|
||||||
|
viewRange ? Math.max(viewStart + MIN_SPAN / 2, Math.min(tMax, viewRange.end)) : tMax
|
||||||
|
);
|
||||||
|
let zoomed = $derived(viewRange !== null && viewEnd - viewStart < tMax - tMin);
|
||||||
|
|
||||||
|
let visibleSeries = $derived(series.filter((s) => !s.hidden && !legendHidden.has(s.name)));
|
||||||
|
let hasRightAxis = $derived(series.some((s) => s.axis === 'right'));
|
||||||
|
|
||||||
|
let padRight = $derived(hasRightAxis ? 56 : 20);
|
||||||
|
let padTop = $derived(title ? (subtitle ? 66 : 46) : 28);
|
||||||
|
let plotW = $derived(Math.max(1, width - PAD_LEFT - padRight));
|
||||||
|
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
|
||||||
|
|
||||||
|
interface Scale {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceNum(range: number, round: boolean): number {
|
||||||
|
const exp = Math.floor(Math.log10(range));
|
||||||
|
const frac = range / 10 ** exp;
|
||||||
|
let nice: number;
|
||||||
|
if (round) {
|
||||||
|
nice = frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10;
|
||||||
|
} else {
|
||||||
|
nice = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10;
|
||||||
|
}
|
||||||
|
return nice * 10 ** exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataExtent(axis: 'left' | 'right'): [number, number] {
|
||||||
|
let lo = Infinity;
|
||||||
|
let hi = -Infinity;
|
||||||
|
for (const s of visibleSeries) {
|
||||||
|
if ((s.axis ?? 'left') !== axis) continue;
|
||||||
|
for (const v of s.data) {
|
||||||
|
if (v === null || !isFinite(v)) continue;
|
||||||
|
if (v < lo) lo = v;
|
||||||
|
if (v > hi) hi = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isFinite(lo)) return [0, 1];
|
||||||
|
// Match the previous ECharts behavior (value axis without `scale`): always
|
||||||
|
// include zero in the axis extent.
|
||||||
|
lo = Math.min(lo, 0);
|
||||||
|
hi = Math.max(hi, 0);
|
||||||
|
if (lo === hi) hi = lo + 1;
|
||||||
|
return [lo, hi];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScale(lo: number, hi: number, loFixed: boolean, hiFixed: boolean): Scale {
|
||||||
|
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
|
||||||
|
const min = loFixed ? lo : Math.floor(lo / step) * step;
|
||||||
|
const max = hiFixed ? hi : Math.ceil(hi / step) * step;
|
||||||
|
return { min, max: max > min ? max : min + step, step };
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftScale = $derived.by((): Scale => {
|
||||||
|
const [dLo, dHi] = dataExtent('left');
|
||||||
|
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
let rightScale = $derived.by((): Scale => {
|
||||||
|
const lo = yMinRight ?? 0;
|
||||||
|
const hi = yMaxRight ?? 100;
|
||||||
|
return buildScale(lo, hi, true, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function xPix(t: number): number {
|
||||||
|
return PAD_LEFT + ((t - viewStart) / (viewEnd - viewStart)) * plotW;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixToTime(x: number): number {
|
||||||
|
return viewStart + ((x - PAD_LEFT) / plotW) * (viewEnd - viewStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
function yPix(v: number, axis: 'left' | 'right'): number {
|
||||||
|
if (axis === 'right') {
|
||||||
|
const frac = (v - rightScale.min) / (rightScale.max - rightScale.min);
|
||||||
|
return invertRight ? padTop + frac * plotH : padTop + (1 - frac) * plotH;
|
||||||
|
}
|
||||||
|
const frac = (v - leftScale.min) / (leftScale.max - leftScale.min);
|
||||||
|
return padTop + (1 - frac) * plotH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Zoom / pan helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setViewRange(range: { start: number; end: number } | null): void {
|
||||||
|
if (groupState) groupState.range = range;
|
||||||
|
else localRange = range;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHover(time: number | null): void {
|
||||||
|
if (groupState) groupState.hover = time;
|
||||||
|
else localHover = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRange(start: number, end: number): void {
|
||||||
|
const full = tMax - tMin;
|
||||||
|
const span = Math.min(Math.max(end - start, MIN_SPAN), full);
|
||||||
|
if (span >= full) {
|
||||||
|
setViewRange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = Math.max(tMin, Math.min(start, tMax - span));
|
||||||
|
setViewRange({ start: s, end: s + span });
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomAt(centerTime: number, factor: number): void {
|
||||||
|
const span = viewEnd - viewStart;
|
||||||
|
const newSpan = span * factor;
|
||||||
|
const frac = (centerTime - viewStart) / span;
|
||||||
|
applyRange(centerTime - frac * newSpan, centerTime + (1 - frac) * newSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the chart as a PNG data URL, or null before mount. */
|
||||||
|
export function getPngDataUrl(): string | null {
|
||||||
|
return canvasEl ? canvasEl.toDataURL('image/png') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zooms the x axis to the given epoch-second range (clamped to the data). */
|
||||||
|
export function setRange(startEpoch: number, endEpoch: number): void {
|
||||||
|
applyRange(startEpoch, endEpoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets the x axis to the full data range. */
|
||||||
|
export function resetRange(): void {
|
||||||
|
setViewRange(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tooltip data ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function nearestIndex(arr: number[], t: number): number {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = arr.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (arr[mid] < t) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
if (lo > 0 && Math.abs(arr[lo - 1] - t) <= Math.abs(arr[lo] - t)) return lo - 1;
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hoverIdx = $derived(
|
||||||
|
hoverTime === null || timestamps.length === 0 ? -1 : nearestIndex(timestamps, hoverTime)
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TooltipRow {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tooltipRows = $derived.by((): TooltipRow[] => {
|
||||||
|
if (hoverIdx < 0) return [];
|
||||||
|
const rows: TooltipRow[] = [];
|
||||||
|
for (const s of visibleSeries) {
|
||||||
|
const v = s.data[hoverIdx];
|
||||||
|
if (v === null || v === undefined || !isFinite(v)) continue;
|
||||||
|
const axisUnit = (s.axis ?? 'left') === 'right' ? (unitRight ?? '') : unit;
|
||||||
|
const value = s.format
|
||||||
|
? s.format(v, hoverIdx)
|
||||||
|
: `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`;
|
||||||
|
rows.push({ name: s.name, color: s.color, value });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
let tooltipVisible = $derived(hoverIdx >= 0 && tooltipRows.length > 0 && width > 0);
|
||||||
|
let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0);
|
||||||
|
let tooltipFlip = $derived(tooltipX > width * 0.55);
|
||||||
|
|
||||||
|
// ─── X axis ticks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface XTick {
|
||||||
|
t: number;
|
||||||
|
label: string;
|
||||||
|
isDay: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeXTicks(): XTick[] {
|
||||||
|
const spanHours = (viewEnd - viewStart) / HOUR;
|
||||||
|
const pxPerHour = plotW / spanHours;
|
||||||
|
const steps = [1, 2, 3, 6, 12, 24];
|
||||||
|
let step = 24;
|
||||||
|
for (const s of steps) {
|
||||||
|
if (s * pxPerHour >= 48) {
|
||||||
|
step = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ticks: XTick[] = [];
|
||||||
|
const first = Math.ceil(viewStart / HOUR) * HOUR;
|
||||||
|
for (let t = first; t <= viewEnd; t += HOUR) {
|
||||||
|
const date = new Date(t * 1000);
|
||||||
|
const hour = getZonedHour(date, timezone);
|
||||||
|
if (hour === 0) {
|
||||||
|
ticks.push({ t, label: formatZoned(date, timezone, 'EEE d'), isDay: true });
|
||||||
|
} else if (step < 24 && hour % step === 0) {
|
||||||
|
ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rendering ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function tickDecimals(step: number): number {
|
||||||
|
if (step >= 1) return 0;
|
||||||
|
return Math.min(3, Math.max(0, Math.ceil(-Math.log10(step))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(): void {
|
||||||
|
if (!canvasEl || !containerEl || width <= 0) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = Math.round(width * dpr);
|
||||||
|
const h = Math.round(height * dpr);
|
||||||
|
if (canvasEl.width !== w) canvasEl.width = w;
|
||||||
|
if (canvasEl.height !== h) canvasEl.height = h;
|
||||||
|
|
||||||
|
const ctx = canvasEl.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (timestamps.length === 0) return;
|
||||||
|
|
||||||
|
const styles = getComputedStyle(containerEl);
|
||||||
|
const cssColor = (name: string, fallback: string): string =>
|
||||||
|
styles.getPropertyValue(name).trim() || fallback;
|
||||||
|
const textColor = cssColor('--muted-foreground', '#6b7280');
|
||||||
|
const strongColor = cssColor('--foreground', '#374151');
|
||||||
|
const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)');
|
||||||
|
|
||||||
|
const plotRight = PAD_LEFT + plotW;
|
||||||
|
const plotBottom = padTop + plotH;
|
||||||
|
const font = '11px system-ui, sans-serif';
|
||||||
|
|
||||||
|
// Daylight bands
|
||||||
|
ctx.fillStyle = CHART_COLORS.daylight;
|
||||||
|
for (const band of bands) {
|
||||||
|
if (band.end < viewStart || band.start > viewEnd) continue;
|
||||||
|
const x1 = Math.max(PAD_LEFT, xPix(band.start));
|
||||||
|
const x2 = Math.min(plotRight, xPix(band.end));
|
||||||
|
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal grid lines + left axis labels
|
||||||
|
ctx.font = font;
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
for (let v = leftScale.min; v <= leftScale.max + leftScale.step / 2; v += leftScale.step) {
|
||||||
|
const y = yPix(v, 'left');
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(PAD_LEFT, y);
|
||||||
|
ctx.lineTo(plotRight, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), PAD_LEFT - 8, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right axis labels (only when a unit is provided)
|
||||||
|
if (hasRightAxis && unitRight !== undefined) {
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
for (
|
||||||
|
let v = rightScale.min;
|
||||||
|
v <= rightScale.max + rightScale.step / 2;
|
||||||
|
v += rightScale.step
|
||||||
|
) {
|
||||||
|
ctx.fillText(v.toFixed(tickDecimals(rightScale.step)), plotRight + 8, yPix(v, 'right'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X axis ticks: midnight gridlines + time labels
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
for (const tick of computeXTicks()) {
|
||||||
|
const x = xPix(tick.t);
|
||||||
|
if (tick.isDay) {
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, padTop);
|
||||||
|
ctx.lineTo(x, plotBottom);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.font = 'bold 11px system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = strongColor;
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, plotBottom);
|
||||||
|
ctx.lineTo(x, plotBottom + 4);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.font = font;
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(tick.label, x, plotBottom + 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series (clipped to the plot area)
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(PAD_LEFT, padTop, plotW, plotH);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
const barSeries = visibleSeries.filter((s) => s.type === 'bar');
|
||||||
|
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
|
||||||
|
const slot = plotW / ((viewEnd - viewStart) / interval);
|
||||||
|
const barWidth = Math.min(8, Math.max(1, (slot * 0.7) / Math.max(1, barSeries.length)));
|
||||||
|
|
||||||
|
for (const s of visibleSeries) {
|
||||||
|
const axis = s.axis ?? 'left';
|
||||||
|
const baseline = Math.min(plotBottom, Math.max(padTop, yPix(0, axis)));
|
||||||
|
|
||||||
|
if (s.type === 'bar') {
|
||||||
|
const bi = barSeries.indexOf(s);
|
||||||
|
const groupOffset = (barSeries.length * barWidth) / 2 - bi * barWidth;
|
||||||
|
ctx.fillStyle = s.color;
|
||||||
|
for (let i = 0; i < timestamps.length; i++) {
|
||||||
|
const v = s.data[i];
|
||||||
|
if (v === null || v === undefined || !isFinite(v)) continue;
|
||||||
|
const t = timestamps[i];
|
||||||
|
if (t < viewStart - interval || t > viewEnd + interval) continue;
|
||||||
|
const x = xPix(t) - groupOffset;
|
||||||
|
const y = yPix(v, axis);
|
||||||
|
ctx.fillRect(x, Math.min(y, baseline), barWidth, Math.abs(baseline - y) || 1);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line series: draw fill and stroke per contiguous non-null run
|
||||||
|
// (points outside the view are handled by the clip rect)
|
||||||
|
const runs: Array<Array<[number, number]>> = [];
|
||||||
|
let run: Array<[number, number]> = [];
|
||||||
|
for (let i = 0; i < timestamps.length; i++) {
|
||||||
|
const v = s.data[i];
|
||||||
|
if (v === null || v === undefined || !isFinite(v)) {
|
||||||
|
if (run.length > 0) runs.push(run);
|
||||||
|
run = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
run.push([xPix(timestamps[i]), yPix(v, axis)]);
|
||||||
|
}
|
||||||
|
if (run.length > 0) runs.push(run);
|
||||||
|
|
||||||
|
for (const points of runs) {
|
||||||
|
if (points.length === 0) continue;
|
||||||
|
|
||||||
|
if (s.fill && points.length > 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0][0], points[0][1]);
|
||||||
|
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||||||
|
ctx.lineTo(points[points.length - 1][0], baseline);
|
||||||
|
ctx.lineTo(points[0][0], baseline);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.globalAlpha = s.fillOpacity ?? 0.15;
|
||||||
|
ctx.fillStyle = s.color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineWidth = s.width ?? 2;
|
||||||
|
if (lineWidth > 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(points[0][0], points[0][1]);
|
||||||
|
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||||||
|
ctx.strokeStyle = s.color;
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.setLineDash(s.dashed ? [6, 4] : []);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current time marker
|
||||||
|
if (showNow) {
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
if (now >= viewStart && now <= viewEnd) {
|
||||||
|
const x = xPix(now);
|
||||||
|
ctx.strokeStyle = CHART_COLORS.currentTimeLine;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, padTop);
|
||||||
|
ctx.lineTo(x, plotBottom);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crosshair (snapped to the nearest timestamp)
|
||||||
|
if (hoverIdx >= 0) {
|
||||||
|
const t = timestamps[hoverIdx];
|
||||||
|
if (t >= viewStart && t <= viewEnd) {
|
||||||
|
const x = xPix(t);
|
||||||
|
ctx.strokeStyle = textColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, padTop);
|
||||||
|
ctx.lineTo(x, plotBottom);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Axis unit labels
|
||||||
|
ctx.font = font;
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
if (unit) {
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText(unit, PAD_LEFT - 4, padTop - 8);
|
||||||
|
}
|
||||||
|
if (hasRightAxis && unitRight) {
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText(unitRight, plotRight + 4, padTop - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title / subtitle
|
||||||
|
if (title) {
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.font = '16px system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = strongColor;
|
||||||
|
ctx.fillText(title, 4, 20);
|
||||||
|
if (subtitle) {
|
||||||
|
ctx.font = '12px system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText(subtitle, 4, 38);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit watermark
|
||||||
|
if (showCredit) {
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.font = '10px system-ui, sans-serif';
|
||||||
|
ctx.globalAlpha = 0.4;
|
||||||
|
ctx.fillStyle = strongColor;
|
||||||
|
ctx.fillText('Open-Meteo.com', width - 10, height - 6);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// themeVersion is a manual dependency: it bumps when the document theme
|
||||||
|
// class changes so colors are re-read from CSS custom properties.
|
||||||
|
void themeVersion;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Lifecycle: resize + theme observers ────────────────────────────────────
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
width = entry.contentRect.width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (containerEl) resizeObserver.observe(containerEl);
|
||||||
|
|
||||||
|
const themeObserver = new MutationObserver(() => {
|
||||||
|
themeVersion++;
|
||||||
|
});
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'data-theme']
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
themeObserver.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Interaction ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Listeners are attached programmatically (not via template attributes) so the
|
||||||
|
// wheel handler can be registered as non-passive.
|
||||||
|
$effect(() => {
|
||||||
|
const el = canvasEl;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Plain interaction bookkeeping (not reactive state)
|
||||||
|
const pointers = new SvelteMap<number, { x: number; y: number }>();
|
||||||
|
let panStart: { x: number; start: number; end: number } | null = null;
|
||||||
|
let pinchStart: { dist: number; start: number; end: number } | null = null;
|
||||||
|
|
||||||
|
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
|
||||||
|
|
||||||
|
const updateHover = (e: PointerEvent): void => {
|
||||||
|
const t = pixToTime(localX(e));
|
||||||
|
setHover(t >= viewStart && t <= viewEnd ? t : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent): void => {
|
||||||
|
el.setPointerCapture(e.pointerId);
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (pointers.size === 1) {
|
||||||
|
panStart = { x: e.clientX, start: viewStart, end: viewEnd };
|
||||||
|
pinchStart = null;
|
||||||
|
} else if (pointers.size === 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd };
|
||||||
|
panStart = null;
|
||||||
|
setHover(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent): void => {
|
||||||
|
if (pointers.has(e.pointerId)) {
|
||||||
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinchStart && pointers.size === 2) {
|
||||||
|
const [a, b] = [...pointers.values()];
|
||||||
|
const dist = Math.max(10, Math.abs(a.x - b.x));
|
||||||
|
const scale = pinchStart.dist / dist;
|
||||||
|
const span = pinchStart.end - pinchStart.start;
|
||||||
|
const center = (pinchStart.start + pinchStart.end) / 2;
|
||||||
|
const newSpan = span * scale;
|
||||||
|
applyRange(center - newSpan / 2, center + newSpan / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panStart && pointers.size === 1 && zoomed) {
|
||||||
|
const dt = ((panStart.x - e.clientX) / plotW) * (panStart.end - panStart.start);
|
||||||
|
applyRange(panStart.start + dt, panStart.end + dt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHover(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (e: PointerEvent): void => {
|
||||||
|
pointers.delete(e.pointerId);
|
||||||
|
if (pointers.size < 2) pinchStart = null;
|
||||||
|
if (pointers.size < 1) panStart = null;
|
||||||
|
if (e.pointerType !== 'mouse') setHover(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerLeave = (): void => {
|
||||||
|
if (pointers.size === 0) setHover(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWheel = (e: WheelEvent): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = pixToTime(localX(e));
|
||||||
|
zoomAt(t, e.deltaY < 0 ? 1 / 1.3 : 1.3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDblClick = (): void => {
|
||||||
|
setViewRange(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('pointerdown', onPointerDown);
|
||||||
|
el.addEventListener('pointermove', onPointerMove);
|
||||||
|
el.addEventListener('pointerup', onPointerUp);
|
||||||
|
el.addEventListener('pointercancel', onPointerUp);
|
||||||
|
el.addEventListener('pointerleave', onPointerLeave);
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
el.addEventListener('dblclick', onDblClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
el.removeEventListener('pointermove', onPointerMove);
|
||||||
|
el.removeEventListener('pointerup', onPointerUp);
|
||||||
|
el.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
el.removeEventListener('pointerleave', onPointerLeave);
|
||||||
|
el.removeEventListener('wheel', onWheel);
|
||||||
|
el.removeEventListener('dblclick', onDblClick);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSeries(name: string): void {
|
||||||
|
if (legendHidden.has(name)) legendHidden.delete(name);
|
||||||
|
else legendHidden.add(name);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={containerEl} class="relative w-full select-none {className}">
|
||||||
|
{#if showLegend && series.length > 0}
|
||||||
|
<div class="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1 px-1">
|
||||||
|
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 text-xs transition-opacity {legendHidden.has(
|
||||||
|
s.name
|
||||||
|
)
|
||||||
|
? 'opacity-40'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => toggleSeries(s.name)}
|
||||||
|
title="Toggle {s.name}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style:background-color={s.color}
|
||||||
|
></span>
|
||||||
|
<span class="text-muted-foreground">{s.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<canvas
|
||||||
|
bind:this={canvasEl}
|
||||||
|
class="block w-full"
|
||||||
|
style:height="{height}px"
|
||||||
|
style:touch-action="pan-y"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
{#if tooltipVisible}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md"
|
||||||
|
style:top="{padTop + 8}px"
|
||||||
|
style:left="{tooltipFlip ? tooltipX - 12 : tooltipX + 12}px"
|
||||||
|
style:transform={tooltipFlip ? 'translateX(-100%)' : ''}
|
||||||
|
>
|
||||||
|
<div class="mb-1 font-semibold">
|
||||||
|
{formatZoned(new Date(timestamps[hoverIdx] * 1000), timezone, 'EEE d MMM HH:mm')}
|
||||||
|
</div>
|
||||||
|
{#each tooltipRows as row (row.name)}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style:background-color={row.color}
|
||||||
|
></span>
|
||||||
|
<span>{row.name}:</span>
|
||||||
|
<span class="ml-auto pl-2 font-semibold">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Daylight Bands
|
||||||
|
*
|
||||||
|
* Converts sunrise/sunset timestamp arrays into neutral background band
|
||||||
|
* descriptors that CanvasChart renders as shaded daylight areas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A background band on the time axis, expressed in epoch seconds. */
|
||||||
|
export interface DaylightBand {
|
||||||
|
/** Band start (epoch seconds) */
|
||||||
|
start: number;
|
||||||
|
/** Band end (epoch seconds) */
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds daylight bands from sunrise/sunset arrays.
|
||||||
|
*
|
||||||
|
* @param sunrise - Array of sunrise timestamps (unix seconds)
|
||||||
|
* @param sunset - Array of sunset timestamps (unix seconds)
|
||||||
|
*/
|
||||||
|
export function buildDaylightBands(sunrise: number[], sunset: number[]): DaylightBand[] {
|
||||||
|
return sunrise.map((r, i) => ({ start: r, end: sunset[i] }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Chart Data Helpers
|
||||||
|
*
|
||||||
|
* Shared color palette and data-processing helpers used by the chart pages.
|
||||||
|
* Ported from the previous ECharts utilities so the visual identity and
|
||||||
|
* calculations stay identical.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── 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)',
|
||||||
|
memberLine: 'rgba(115, 192, 222, 0.45)'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Processing Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AverageResult {
|
||||||
|
average: number[];
|
||||||
|
averageCount: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates per-timestep average and count from hourly model data.
|
||||||
|
*
|
||||||
|
* @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<string, unknown>,
|
||||||
|
variable: string,
|
||||||
|
timeLength: number
|
||||||
|
): AverageResult {
|
||||||
|
const average = new Array<number>(timeLength).fill(0);
|
||||||
|
const averageCount = new Array<number>(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 && isFinite(val)) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, string>,
|
||||||
|
hourlyData: Record<string, unknown>,
|
||||||
|
variable: string
|
||||||
|
): string {
|
||||||
|
for (const model of Object.keys(hourlyData)) {
|
||||||
|
if (model === 'time') continue;
|
||||||
|
if (model.startsWith(variable) && hourlyUnits[model]) {
|
||||||
|
return hourlyUnits[model];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Canvas Charts — Barrel Export
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts';
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as CanvasChart } from './CanvasChart.svelte';
|
||||||
|
export type { ChartSeries } from './CanvasChart.svelte';
|
||||||
|
|
||||||
|
export { buildDaylightBands } from './bands';
|
||||||
|
export type { DaylightBand } from './bands';
|
||||||
|
|
||||||
|
export { CHART_COLORS, SERIES_COLORS, calculateAverage, findUnit, isColumnUnit } from './data';
|
||||||
|
export type { AverageResult } from './data';
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
Usage:
|
Usage:
|
||||||
<ChartContainer loading={!chartsReady} chartCount={3}>
|
<ChartContainer loading={!chartsReady} chartCount={3}>
|
||||||
{#each charts as chart}
|
{#each charts as chart}
|
||||||
<EChart option={chart.option} />
|
<CanvasChart {...chart} />
|
||||||
{/each}
|
{/each}
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
Provides a toolbar row with:
|
Provides a toolbar row with:
|
||||||
- Download full meteogram as PNG button
|
- Download full meteogram as PNG button
|
||||||
- Download full meteogram as SVG button
|
|
||||||
- Slot for additional custom controls (e.g. legend toggle)
|
- Slot for additional custom controls (e.g. legend toggle)
|
||||||
|
|
||||||
When multiple charts are provided, they are stitched into a single
|
When multiple charts are provided, they are stitched into a single
|
||||||
@@ -11,7 +10,7 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
<ChartToolbar
|
<ChartToolbar
|
||||||
charts={chartInstances}
|
charts={chartComponents}
|
||||||
fileName="model-comparison"
|
fileName="model-comparison"
|
||||||
>
|
>
|
||||||
{#snippet controls()}
|
{#snippet controls()}
|
||||||
@@ -19,22 +18,23 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script module lang="ts">
|
||||||
import { downloadMeteogram } from '$lib/utils/echarts/download';
|
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
|
||||||
|
export interface DownloadableChart {
|
||||||
|
getPngDataUrl(): string | null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
import type { ExportFormat } from '$lib/utils/echarts/download';
|
<script lang="ts">
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Array of ECharts instances available for download */
|
/** Chart components available for download (undefined entries are skipped) */
|
||||||
charts?: echarts.ECharts[];
|
charts?: Array<DownloadableChart | undefined | null>;
|
||||||
/** Base file name for downloaded images (without extension) */
|
/** Base file name for downloaded images (without extension) */
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
/** Pixel ratio for PNG exports (default: 2) */
|
|
||||||
pixelRatio?: number;
|
|
||||||
/** Optional CSS class for the outer container */
|
/** Optional CSS class for the outer container */
|
||||||
class?: string;
|
class?: string;
|
||||||
/** Slot for additional controls (switches, checkboxes, etc.) */
|
/** Slot for additional controls (switches, checkboxes, etc.) */
|
||||||
@@ -43,33 +43,88 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
charts = [],
|
charts = [],
|
||||||
fileName = 'open-meteo-chart',
|
fileName = 'ombrella-chart',
|
||||||
pixelRatio = 2,
|
|
||||||
class: className = '',
|
class: className = '',
|
||||||
controls
|
controls
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// ─── State ──────────────────────────────────────────────────────────────────
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let downloadingFormat: ExportFormat | null = $state(null);
|
let downloading = $state(false);
|
||||||
|
|
||||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let hasCharts = $derived(charts.length > 0);
|
let hasCharts = $derived(charts.some((chart) => chart != null));
|
||||||
|
|
||||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
// ─── Download ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleDownload(format: ExportFormat): Promise<void> {
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
if (!hasCharts || downloadingFormat) return;
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => resolve(img);
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
downloadingFormat = format;
|
/** Resolves the page background so exports match the current theme. */
|
||||||
|
function exportBackground(): string {
|
||||||
|
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||||
|
return bg || '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(url: string, name: string): void {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = name;
|
||||||
|
link.style.display = 'none';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.body.removeChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(): Promise<void> {
|
||||||
|
if (!hasCharts || downloading) return;
|
||||||
|
|
||||||
|
downloading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
const dataUrls = charts
|
||||||
downloadMeteogram(charts, { fileName, format, pixelRatio });
|
.filter((chart): chart is DownloadableChart => chart != null)
|
||||||
|
.map((chart) => chart.getPngDataUrl())
|
||||||
|
.filter((url): url is string => url !== null);
|
||||||
|
if (dataUrls.length === 0) return;
|
||||||
|
|
||||||
|
const images = (await Promise.all(dataUrls.map(loadImage))).filter(
|
||||||
|
(img) => img.naturalWidth > 0
|
||||||
|
);
|
||||||
|
if (images.length === 0) return;
|
||||||
|
|
||||||
|
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
|
||||||
|
const totalHeight = images.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;
|
||||||
|
|
||||||
|
ctx.fillStyle = exportBackground();
|
||||||
|
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||||
|
|
||||||
|
let y = 0;
|
||||||
|
for (const img of images) {
|
||||||
|
ctx.drawImage(img, 0, y);
|
||||||
|
y += img.naturalHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
downloadingFormat = null;
|
downloading = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,17 +140,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right side: Download buttons -->
|
<!-- Right side: Download button -->
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<!-- Download as PNG -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="toolbar-btn"
|
class="toolbar-btn"
|
||||||
disabled={!hasCharts || downloadingFormat !== null}
|
disabled={!hasCharts || downloading}
|
||||||
onclick={() => handleDownload('png')}
|
onclick={handleDownload}
|
||||||
title="Download meteogram as PNG image"
|
title="Download meteogram as PNG image"
|
||||||
>
|
>
|
||||||
{#if downloadingFormat === 'png'}
|
{#if downloading}
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4 animate-spin"
|
class="h-4 w-4 animate-spin"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -126,46 +180,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span>PNG</span>
|
<span>PNG</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Download as SVG -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toolbar-btn"
|
|
||||||
disabled={!hasCharts || downloadingFormat !== null}
|
|
||||||
onclick={() => handleDownload('svg')}
|
|
||||||
title="Download meteogram as SVG vector image"
|
|
||||||
>
|
|
||||||
{#if downloadingFormat === 'svg'}
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4 animate-spin"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
||||||
<polyline points="7 10 12 15 17 10" />
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<span>SVG</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
<!--
|
|
||||||
EChart.svelte — Reusable ECharts wrapper component
|
|
||||||
|
|
||||||
Handles chart lifecycle (init, update, dispose), responsive resize via
|
|
||||||
ResizeObserver, and exposes the ECharts instance for programmatic access
|
|
||||||
(e.g. export/download).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
<EChart
|
|
||||||
option={chartOption}
|
|
||||||
height="300px"
|
|
||||||
renderer="canvas"
|
|
||||||
onChartReady={(chart) => { ... }}
|
|
||||||
/>
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
|
|
||||||
import { echarts } from './echarts';
|
|
||||||
|
|
||||||
import type { ECharts } from 'echarts';
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** The ECharts option object to render */
|
|
||||||
option: Record<string, unknown>;
|
|
||||||
/** CSS height of the chart container (default: '300px') */
|
|
||||||
height?: string;
|
|
||||||
/** CSS width of the chart container (default: '100%') */
|
|
||||||
width?: string;
|
|
||||||
/** Renderer type: 'canvas' or 'svg' (default: 'canvas') */
|
|
||||||
renderer?: 'canvas' | 'svg';
|
|
||||||
/** Whether to merge options on update (true) or replace them (false) */
|
|
||||||
notMerge?: boolean;
|
|
||||||
/** Whether to delay update until next animation frame */
|
|
||||||
lazyUpdate?: boolean;
|
|
||||||
/** Optional CSS class for the outer container */
|
|
||||||
class?: string;
|
|
||||||
/** Callback fired once the chart instance is initialized */
|
|
||||||
onChartReady?: (chart: ECharts) => void;
|
|
||||||
/** Callback fired when the chart is disposed */
|
|
||||||
onChartDisposed?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
option,
|
|
||||||
height = '300px',
|
|
||||||
width = '100%',
|
|
||||||
renderer = 'canvas',
|
|
||||||
notMerge = false,
|
|
||||||
lazyUpdate = false,
|
|
||||||
class: className = '',
|
|
||||||
onChartReady,
|
|
||||||
onChartDisposed
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// ─── Internal State ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
let chartInstance: ECharts | null = null;
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
|
|
||||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the underlying ECharts instance, or null if not yet initialized.
|
|
||||||
*/
|
|
||||||
export function getChart(): ECharts | null {
|
|
||||||
return chartInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the chart has been initialized and is not disposed.
|
|
||||||
*/
|
|
||||||
export function isReady(): boolean {
|
|
||||||
return chartInstance !== null && !chartInstance.isDisposed();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers a manual resize of the chart.
|
|
||||||
* Useful after layout changes that the ResizeObserver might miss.
|
|
||||||
*/
|
|
||||||
export function resize(): void {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.resize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes the chart instance and cleans up observers.
|
|
||||||
* Called automatically on component destroy, but can be invoked manually.
|
|
||||||
*/
|
|
||||||
export function dispose(): void {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
initChart();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Reactivity: Update option when it changes ──────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed() && option) {
|
|
||||||
chartInstance.setOption(option, notMerge, lazyUpdate);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Init & Cleanup ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function initChart(): void {
|
|
||||||
if (!containerEl) return;
|
|
||||||
|
|
||||||
// Dispose any existing instance (e.g. from HMR)
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
chartInstance = echarts.init(containerEl, null, { renderer });
|
|
||||||
|
|
||||||
if (option) {
|
|
||||||
chartInstance.setOption(option, notMerge, lazyUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up responsive resize
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.resize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
resizeObserver.observe(containerEl);
|
|
||||||
|
|
||||||
onChartReady?.(chartInstance);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup(): void {
|
|
||||||
if (resizeObserver) {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
resizeObserver = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartInstance) {
|
|
||||||
if (!chartInstance.isDisposed()) {
|
|
||||||
chartInstance.dispose();
|
|
||||||
}
|
|
||||||
chartInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChartDisposed?.();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={containerEl} class="echart-wrapper {className}" style:width style:height></div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.echart-wrapper {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
/* ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// Tree-shaken ECharts build: only the pieces the app actually renders (line
|
|
||||||
// and bar series with grid/tooltip/legend/dataZoom/mark/graphic features) are
|
|
||||||
// registered, instead of the ~1 MB full bundle.
|
|
||||||
import { BarChart, LineChart } from 'echarts/charts';
|
|
||||||
import {
|
|
||||||
DataZoomInsideComponent,
|
|
||||||
DataZoomSliderComponent,
|
|
||||||
GraphicComponent,
|
|
||||||
GridComponent,
|
|
||||||
LegendComponent,
|
|
||||||
MarkAreaComponent,
|
|
||||||
MarkLineComponent,
|
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent
|
|
||||||
} from 'echarts/components';
|
|
||||||
import * as echarts from 'echarts/core';
|
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
|
||||||
|
|
||||||
echarts.use([
|
|
||||||
LineChart,
|
|
||||||
BarChart,
|
|
||||||
GridComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
LegendComponent,
|
|
||||||
TitleComponent,
|
|
||||||
DataZoomInsideComponent,
|
|
||||||
DataZoomSliderComponent,
|
|
||||||
MarkLineComponent,
|
|
||||||
MarkAreaComponent,
|
|
||||||
GraphicComponent,
|
|
||||||
CanvasRenderer
|
|
||||||
]);
|
|
||||||
|
|
||||||
export { echarts };
|
|
||||||
@@ -4,9 +4,8 @@
|
|||||||
* Re-exports all chart-related Svelte components from a single entry point.
|
* Re-exports all chart-related Svelte components from a single entry point.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts';
|
* import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as EChart } from './EChart.svelte';
|
|
||||||
export { default as ChartContainer } from './ChartContainer.svelte';
|
export { default as ChartContainer } from './ChartContainer.svelte';
|
||||||
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
||||||
|
|||||||
@@ -52,13 +52,16 @@
|
|||||||
class:w-55={!collapsed}
|
class:w-55={!collapsed}
|
||||||
class:w-14={collapsed}
|
class:w-14={collapsed}
|
||||||
>
|
>
|
||||||
<!-- Sidebar header -->
|
<!-- Sidebar header: same height as the topbar so the borders align; the
|
||||||
<div class="flex items-center border-b border-sidebar-border px-3 py-4">
|
home link fills the entire row, padding included -->
|
||||||
{#if !collapsed}
|
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
|
||||||
<a
|
<a
|
||||||
href={resolve('/weather/week')}
|
href={resolve('/weather/week')}
|
||||||
class="flex items-center gap-2.5 px-1"
|
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
|
||||||
|
? 'justify-center'
|
||||||
|
: 'px-4'}"
|
||||||
onclick={onMobileClose}
|
onclick={onMobileClose}
|
||||||
|
aria-label="OMbrella home"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
||||||
@@ -77,32 +80,12 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
{#if !collapsed}
|
||||||
<span class="text-sm font-semibold whitespace-nowrap text-sidebar-foreground">
|
<span class="text-sm font-semibold whitespace-nowrap text-sidebar-foreground">
|
||||||
Open-Meteo
|
OMbrella
|
||||||
</span>
|
</span>
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={resolve('/weather/week')}
|
|
||||||
class="mx-auto flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
|
||||||
onclick={onMobileClose}
|
|
||||||
aria-label="Open-Meteo Home"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4.5 w-4.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation links -->
|
<!-- Navigation links -->
|
||||||
|
|||||||
+14
-14
@@ -13,7 +13,7 @@
|
|||||||
import { Unit } from '@openmeteo/sdk/unit';
|
import { Unit } from '@openmeteo/sdk/unit';
|
||||||
import { fetchWeatherApi } from 'openmeteo';
|
import { fetchWeatherApi } from 'openmeteo';
|
||||||
|
|
||||||
import { buildDaylightMarkAreas } from '$lib/utils/echarts';
|
import { type DaylightBand, buildDaylightBands } from '$lib/charts/bands';
|
||||||
|
|
||||||
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
||||||
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
||||||
@@ -157,7 +157,7 @@ export interface WeatherUnitParams {
|
|||||||
precipitation_unit?: 'mm' | 'inch';
|
precipitation_unit?: 'mm' | 'inch';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }];
|
export type { DaylightBand };
|
||||||
|
|
||||||
// ─── Week Forecast Types ────────────────────────────────────────────────────────
|
// ─── Week Forecast Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ export interface WeekForecastResult {
|
|||||||
hourlyTimestamps: number[];
|
hourlyTimestamps: number[];
|
||||||
hourlyDates: Date[];
|
hourlyDates: Date[];
|
||||||
dailyDates: Date[];
|
dailyDates: Date[];
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Model Comparison Types ─────────────────────────────────────────────────────
|
// ─── Model Comparison Types ─────────────────────────────────────────────────────
|
||||||
@@ -221,7 +221,7 @@ export interface ModelCompareResult {
|
|||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
utcOffsetSeconds: number;
|
utcOffsetSeconds: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
sunrise: number[];
|
sunrise: number[];
|
||||||
sunset: number[];
|
sunset: number[];
|
||||||
units: Record<string, string>;
|
units: Record<string, string>;
|
||||||
@@ -251,7 +251,7 @@ export interface EnsembleForecastResult {
|
|||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
utcOffsetSeconds: number;
|
utcOffsetSeconds: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
||||||
hourlyFlat: Record<string, number[]>;
|
hourlyFlat: Record<string, number[]>;
|
||||||
hourlyUnitsFlat: Record<string, string>;
|
hourlyUnitsFlat: Record<string, string>;
|
||||||
@@ -360,7 +360,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset);
|
const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hourly,
|
hourly,
|
||||||
@@ -370,7 +370,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
hourlyTimestamps,
|
hourlyTimestamps,
|
||||||
hourlyDates,
|
hourlyDates,
|
||||||
dailyDates,
|
dailyDates,
|
||||||
markAreas
|
daylightBands
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +409,7 @@ export async function fetchModelComparison(
|
|||||||
const timestamps = getTimestamps(hourlyBlock);
|
const timestamps = getTimestamps(hourlyBlock);
|
||||||
|
|
||||||
// Extract sunrise/sunset from the first response's daily block
|
// Extract sunrise/sunset from the first response's daily block
|
||||||
let markAreas: MarkArea[] = [];
|
let daylightBands: DaylightBand[] = [];
|
||||||
let sunrise: number[] = [];
|
let sunrise: number[] = [];
|
||||||
let sunset: number[] = [];
|
let sunset: number[] = [];
|
||||||
const dailyBlock = firstResponse.daily();
|
const dailyBlock = firstResponse.daily();
|
||||||
@@ -418,7 +418,7 @@ export async function fetchModelComparison(
|
|||||||
const sunsetVar = dailyBlock.variables(1)!;
|
const sunsetVar = dailyBlock.variables(1)!;
|
||||||
sunrise = getInt64Values(sunriseVar);
|
sunrise = getInt64Values(sunriseVar);
|
||||||
sunset = getInt64Values(sunsetVar);
|
sunset = getInt64Values(sunsetVar);
|
||||||
markAreas = buildDaylightMarkAreas(sunrise, sunset);
|
daylightBands = buildDaylightBands(sunrise, sunset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each model's response
|
// Process each model's response
|
||||||
@@ -474,7 +474,7 @@ export async function fetchModelComparison(
|
|||||||
timestamps,
|
timestamps,
|
||||||
utcOffsetSeconds,
|
utcOffsetSeconds,
|
||||||
timezone,
|
timezone,
|
||||||
markAreas,
|
daylightBands,
|
||||||
sunrise,
|
sunrise,
|
||||||
sunset,
|
sunset,
|
||||||
units,
|
units,
|
||||||
@@ -531,15 +531,15 @@ export async function fetchEnsembleForecast(
|
|||||||
const timestamps = getTimestamps(hourlyBlock);
|
const timestamps = getTimestamps(hourlyBlock);
|
||||||
const timeLength = timestamps.length;
|
const timeLength = timestamps.length;
|
||||||
|
|
||||||
// Extract sunrise/sunset for mark areas
|
// Extract sunrise/sunset for daylight bands
|
||||||
let markAreas: MarkArea[] = [];
|
let daylightBands: DaylightBand[] = [];
|
||||||
if (dailyResponses.length > 0) {
|
if (dailyResponses.length > 0) {
|
||||||
const dailyResponse = dailyResponses[0];
|
const dailyResponse = dailyResponses[0];
|
||||||
const dailyBlock = dailyResponse.daily();
|
const dailyBlock = dailyResponse.daily();
|
||||||
if (dailyBlock) {
|
if (dailyBlock) {
|
||||||
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
||||||
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
||||||
markAreas = buildDaylightMarkAreas(sunrise, sunset);
|
daylightBands = buildDaylightBands(sunrise, sunset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,7 +629,7 @@ export async function fetchEnsembleForecast(
|
|||||||
timestamps,
|
timestamps,
|
||||||
utcOffsetSeconds,
|
utcOffsetSeconds,
|
||||||
timezone,
|
timezone,
|
||||||
markAreas,
|
daylightBands,
|
||||||
hourlyFlat,
|
hourlyFlat,
|
||||||
hourlyUnitsFlat
|
hourlyUnitsFlat
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Download Utilities
|
|
||||||
*
|
|
||||||
* Provides programmatic chart export functionality for downloading
|
|
||||||
* charts as PNG or SVG images. Supports stitching multiple chart
|
|
||||||
* instances into a single combined meteogram image.
|
|
||||||
*/
|
|
||||||
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');
|
|
||||||
|
|
||||||
const dataUrl = chart.getDataURL({
|
|
||||||
type: format === 'svg' ? 'svg' : 'png',
|
|
||||||
pixelRatio: format === 'png' ? pixelRatio : 1,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
|
|
||||||
triggerDownload(dataUrl, `${fileName}.${format}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads multiple ECharts instances stitched into a single combined
|
|
||||||
* meteogram image. Charts are stacked vertically in the order provided.
|
|
||||||
*
|
|
||||||
* For a single chart, delegates to `downloadChart`.
|
|
||||||
*
|
|
||||||
* @param charts - Array of ECharts instances to combine
|
|
||||||
* @param options - Download configuration options
|
|
||||||
*/
|
|
||||||
export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void {
|
|
||||||
const validCharts = charts.filter((c) => c && !c.isDisposed());
|
|
||||||
if (validCharts.length === 0) return;
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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: 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${maxWidth}" height="${totalHeight}" viewBox="0 0 ${maxWidth} ${totalHeight}">`;
|
|
||||||
|
|
||||||
if (backgroundColor && backgroundColor !== 'none' && backgroundColor !== 'transparent') {
|
|
||||||
combinedSvg += `<rect width="${maxWidth}" height="${totalHeight}" fill="${backgroundColor}"/>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let yOffset = 0;
|
|
||||||
for (const fragment of fragments) {
|
|
||||||
combinedSvg += `<g transform="translate(0,${yOffset})">`;
|
|
||||||
combinedSvg += fragment.svg.innerHTML;
|
|
||||||
combinedSvg += `</g>`;
|
|
||||||
yOffset += fragment.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedSvg += `</svg>`;
|
|
||||||
|
|
||||||
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 or object URL.
|
|
||||||
* Creates a temporary anchor element, clicks it, and removes it.
|
|
||||||
*/
|
|
||||||
function triggerDownload(url: string, fileName: string): void {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
link.style.display = 'none';
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.body.removeChild(link);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
findUnit
|
|
||||||
} from './series';
|
|
||||||
export type {
|
|
||||||
ModelSeriesParams,
|
|
||||||
AverageSeriesParams,
|
|
||||||
CurrentTimeSeriesParams,
|
|
||||||
DaylightSeriesParams,
|
|
||||||
SpreadSeriesParams,
|
|
||||||
AverageResult,
|
|
||||||
SpreadResult
|
|
||||||
} from './series';
|
|
||||||
|
|
||||||
// Download: export charts as PNG or SVG
|
|
||||||
export { downloadChart, downloadMeteogram, getChartDataUrl } from './download';
|
|
||||||
export type { ExportFormat, DownloadOptions } from './download';
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { formatZoned } from '../date';
|
|
||||||
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;
|
|
||||||
timezone?: 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<GridOptions> = {
|
|
||||||
top: 80
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRID_WITH_LEGEND: Partial<GridOptions> = {
|
|
||||||
bottom: 60
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Grid ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface BuildGridParams {
|
|
||||||
hasTitle?: boolean;
|
|
||||||
hasSubtitle?: boolean;
|
|
||||||
showLegend?: boolean;
|
|
||||||
overrides?: Partial<GridOptions>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
if (!options) {
|
|
||||||
return { title: { show: false } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Record<string, unknown> = {
|
|
||||||
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<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
const { unit, timezone } = options;
|
|
||||||
|
|
||||||
return {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'cross',
|
|
||||||
animation: false,
|
|
||||||
label: {
|
|
||||||
backgroundColor: c.tooltipBg,
|
|
||||||
color: c.text,
|
|
||||||
borderColor: c.tooltipBorder,
|
|
||||||
borderWidth: 1,
|
|
||||||
formatter: timezone
|
|
||||||
? (params: { axisDimension: string; value: number }) => {
|
|
||||||
if (params.axisDimension === 'x') {
|
|
||||||
return formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm');
|
|
||||||
}
|
|
||||||
return params.value.toFixed(1);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundColor: c.tooltipBg,
|
|
||||||
borderColor: c.tooltipBorder,
|
|
||||||
textStyle: {
|
|
||||||
color: c.text
|
|
||||||
},
|
|
||||||
formatter: timezone
|
|
||||||
? (
|
|
||||||
params: Array<{
|
|
||||||
axisValue: number;
|
|
||||||
seriesName: string;
|
|
||||||
marker: string;
|
|
||||||
value: number | number[] | null;
|
|
||||||
}>
|
|
||||||
) => {
|
|
||||||
if (!params || params.length === 0) return '';
|
|
||||||
const date = new Date(params[0].axisValue);
|
|
||||||
let html = `<b>${formatZoned(date, timezone, 'EEE d MMM HH:mm')}</b><br/>`;
|
|
||||||
params.forEach((item) => {
|
|
||||||
if (item.seriesName === 'Daylight' || item.seriesName === 'Current Time') return;
|
|
||||||
const val = Array.isArray(item.value) ? item.value[1] : item.value;
|
|
||||||
if (val === null || val === undefined) return;
|
|
||||||
html += `${item.marker} ${item.seriesName}: <b>${val.toFixed(1)} ${unit}</b><br/>`;
|
|
||||||
});
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
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<string, unknown> {
|
|
||||||
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 and timezone-aware labels.
|
|
||||||
*/
|
|
||||||
export function buildTimeXAxis(timezone?: string, colors?: ThemeColors): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'time',
|
|
||||||
splitLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
axisLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: c.axisLine
|
|
||||||
}
|
|
||||||
},
|
|
||||||
axisPointer: {
|
|
||||||
label: {
|
|
||||||
formatter: timezone
|
|
||||||
? (params: { value: number }) =>
|
|
||||||
formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
color: c.text,
|
|
||||||
hideOverlap: true,
|
|
||||||
formatter: timezone
|
|
||||||
? (value: number) => formatZoned(new Date(value), timezone, 'HH:mm')
|
|
||||||
: undefined
|
|
||||||
},
|
|
||||||
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<string, unknown> {
|
|
||||||
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<string, unknown>[] {
|
|
||||||
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<string, unknown> {
|
|
||||||
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<Record<string, unknown>>;
|
|
||||||
toolbox?: ToolboxOptions | false;
|
|
||||||
showCredit?: boolean;
|
|
||||||
colors?: ThemeColors;
|
|
||||||
timezone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string, unknown> {
|
|
||||||
const colors = params.colors ?? getThemeColors();
|
|
||||||
const hasTitle = params.title != null && params.title.text !== '';
|
|
||||||
const showLegend = params.legend?.show ?? false;
|
|
||||||
|
|
||||||
const option: Record<string, unknown> = {
|
|
||||||
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(params.timezone, 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);
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { isColumnUnit } from './options';
|
|
||||||
import { CHART_COLORS } from './theme';
|
|
||||||
|
|
||||||
// ─── 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<string, unknown> {
|
|
||||||
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<string, unknown> {
|
|
||||||
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(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
name: 'Current Time',
|
|
||||||
type: 'line',
|
|
||||||
data: [],
|
|
||||||
markLine: {
|
|
||||||
silent: true,
|
|
||||||
symbol: 'none',
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
xAxis: Date.now(),
|
|
||||||
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[]
|
|
||||||
): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> {
|
|
||||||
return sunrise.map((r: number, i: number) => [
|
|
||||||
{
|
|
||||||
xAxis: r * 1000,
|
|
||||||
itemStyle: {
|
|
||||||
color: CHART_COLORS.daylight
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
xAxis: sunset[i] * 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<string, unknown> | 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<Record<string, unknown>> {
|
|
||||||
const { variable, spreadData, color = CHART_COLORS.spreadArea } = params;
|
|
||||||
|
|
||||||
const lowerBound: Record<string, unknown> = {
|
|
||||||
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<string, unknown> = {
|
|
||||||
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<string, unknown>,
|
|
||||||
variable: string,
|
|
||||||
timeLength: number
|
|
||||||
): AverageResult {
|
|
||||||
const average = new Array<number>(timeLength).fill(0);
|
|
||||||
const averageCount = new Array<number>(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 && isFinite(val)) {
|
|
||||||
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<string, unknown>,
|
|
||||||
variable: string,
|
|
||||||
timeLength: number
|
|
||||||
): SpreadResult {
|
|
||||||
const minValues = new Array<number | undefined>(timeLength).fill(undefined);
|
|
||||||
const maxValues = new Array<number | undefined>(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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string, string>,
|
|
||||||
hourlyData: Record<string, unknown>,
|
|
||||||
variable: string
|
|
||||||
): string {
|
|
||||||
for (const model of Object.keys(hourlyData)) {
|
|
||||||
if (model === 'time') continue;
|
|
||||||
if (model.startsWith(variable) && hourlyUnits[model]) {
|
|
||||||
return hourlyUnits[model];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -63,7 +63,14 @@
|
|||||||
<main
|
<main
|
||||||
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-5 md:px-8 md:py-6'}
|
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-5 md:px-8 md:py-6'}
|
||||||
>
|
>
|
||||||
|
{#if fullBleed}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<!-- cap the content width on very large screens -->
|
||||||
|
<div class="mx-auto w-full max-w-[1536px]">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Open-Meteo Weather</title>
|
<title>OMbrella</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ describe('/+page.svelte', () => {
|
|||||||
render(Page);
|
render(Page);
|
||||||
|
|
||||||
const title = document.querySelector('title');
|
const title = document.querySelector('title');
|
||||||
expect(title?.textContent).toBe('Open-Meteo Weather');
|
expect(title?.textContent).toBe('OMbrella');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import {
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
buildAverageSeries,
|
|
||||||
buildCurrentTimeSeries,
|
|
||||||
buildDaylightSeries,
|
|
||||||
buildSpreadSeries,
|
|
||||||
composeChartOption,
|
|
||||||
getThemeColors
|
|
||||||
} from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
|
||||||
import '$lib/components/charts/echarts.css';
|
|
||||||
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 { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
|
||||||
import {
|
import {
|
||||||
|
type DaylightBand,
|
||||||
type EnsembleForecastResult,
|
type EnsembleForecastResult,
|
||||||
type MarkArea,
|
|
||||||
fetchEnsembleForecast
|
fetchEnsembleForecast
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
|
const CHART_GROUP = '14-day-ensemble';
|
||||||
|
|
||||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||||
|
|
||||||
@@ -34,9 +26,7 @@
|
|||||||
|
|
||||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
let chartComponents: EChart[] = $state([]);
|
let chartComponents: CanvasChart[] = $state([]);
|
||||||
let chartInstances: echarts.ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
@@ -64,7 +54,7 @@
|
|||||||
ensembleResult: EnsembleForecastResult;
|
ensembleResult: EnsembleForecastResult;
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let fetchedData: FetchedData | null = $state(null);
|
let fetchedData: FetchedData | null = $state(null);
|
||||||
@@ -75,22 +65,8 @@
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
// components persist across refetches; entries are null while unmounted
|
||||||
chartInstances = [];
|
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||||
chartOptions = [];
|
|
||||||
chartComponents = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Chart Instance Tracking ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleChartReady(chart: echarts.ECharts): void {
|
|
||||||
chartInstances = [...chartInstances, chart];
|
|
||||||
}
|
|
||||||
|
|
||||||
// chart components persist across refetches (options update in place), so
|
|
||||||
// the registry is never reset; only instances disposed because the chart
|
|
||||||
// count shrank are filtered out
|
|
||||||
let liveCharts = $derived(chartInstances.filter((chart) => !chart.isDisposed()));
|
|
||||||
|
|
||||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
||||||
|
|
||||||
@@ -125,7 +101,7 @@
|
|||||||
ensembleResult: result,
|
ensembleResult: result,
|
||||||
timestamps: result.timestamps,
|
timestamps: result.timestamps,
|
||||||
timezone: result.timezone,
|
timezone: result.timezone,
|
||||||
markAreas: result.markAreas
|
daylightBands: result.daylightBands
|
||||||
};
|
};
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -137,77 +113,74 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
|
||||||
|
|
||||||
$effect(() => {
|
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||||
if (!fetchedData) return;
|
let timestampsSec = $derived.by(() =>
|
||||||
|
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
|
||||||
|
);
|
||||||
|
|
||||||
const { ensembleResult, timestamps, timezone, markAreas } = fetchedData;
|
interface ChartDef {
|
||||||
const _showLegend = showLegend;
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
unit: string;
|
||||||
|
showCredit: boolean;
|
||||||
|
series: ChartSeries[];
|
||||||
|
}
|
||||||
|
|
||||||
const colors = getThemeColors();
|
let chartDefs = $derived.by((): ChartDef[] => {
|
||||||
const variableCount = params.hourly?.length || 0;
|
if (!fetchedData) return [];
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
const { ensembleResult } = fetchedData;
|
||||||
const variable = params.hourly![vi];
|
const variables = params.hourly || [];
|
||||||
|
const defs: ChartDef[] = [];
|
||||||
|
|
||||||
|
for (let vi = 0; vi < variables.length; vi++) {
|
||||||
|
const variable = variables[vi];
|
||||||
const varData = ensembleResult.variables[variable];
|
const varData = ensembleResult.variables[variable];
|
||||||
if (!varData) continue;
|
if (!varData) continue;
|
||||||
|
|
||||||
const unit = varData.unit;
|
const unit = varData.unit;
|
||||||
const { average, min: minValues, max: maxValues } = varData;
|
const isColumn = isColumnUnit(unit);
|
||||||
|
const series: ChartSeries[] = [];
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
// Individual ensemble members: thin, low-alpha lines
|
||||||
|
for (let mi = 0; mi < varData.members.length; mi++) {
|
||||||
const spreadData: Array<[number, number, number]> = minValues.map(
|
series.push({
|
||||||
(minVal, index) =>
|
name: `${variable}_member${String(mi).padStart(2, '0')}`,
|
||||||
[timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
type: 'line',
|
||||||
);
|
color: CHART_COLORS.memberLine,
|
||||||
|
data: varData.members[mi],
|
||||||
series.push(...buildSpreadSeries({ variable, spreadData }));
|
width: 1,
|
||||||
|
showInLegend: false
|
||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
});
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
|
||||||
|
|
||||||
series.push(buildCurrentTimeSeries());
|
|
||||||
|
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
|
||||||
if (daylightSeries) {
|
|
||||||
series.push(daylightSeries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
// Ensemble mean: bold dashed line on top
|
||||||
const isLast = vi === variableCount - 1;
|
series.push({
|
||||||
|
name: variable + '_average',
|
||||||
const option = composeChartOption({
|
type: isColumn ? 'bar' : 'line',
|
||||||
title: isFirst
|
color: CHART_COLORS.average,
|
||||||
? {
|
data: varData.average,
|
||||||
text: 'Model Spread',
|
width: 4,
|
||||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
dashed: !isColumn
|
||||||
}
|
|
||||||
: null,
|
|
||||||
tooltip: { unit, timezone },
|
|
||||||
legend: {
|
|
||||||
show: _showLegend,
|
|
||||||
data: [variable + '_average']
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
hasTitle: isFirst,
|
|
||||||
hasSubtitle: isFirst,
|
|
||||||
showLegend: _showLegend
|
|
||||||
},
|
|
||||||
yAxis: { unit },
|
|
||||||
series,
|
|
||||||
toolbox: false,
|
|
||||||
showCredit: isLast,
|
|
||||||
colors,
|
|
||||||
timezone
|
|
||||||
});
|
});
|
||||||
|
|
||||||
newOptions.push(option);
|
const isFirst = vi === 0;
|
||||||
|
const isLast = vi === variables.length - 1;
|
||||||
|
|
||||||
|
defs.push({
|
||||||
|
title: isFirst ? 'Model Spread' : undefined,
|
||||||
|
subtitle: isFirst
|
||||||
|
? `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||||
|
: undefined,
|
||||||
|
unit,
|
||||||
|
showCredit: isLast,
|
||||||
|
series
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
chartOptions = newOptions;
|
return defs;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -221,20 +194,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ChartContainer
|
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300}>
|
||||||
{loading}
|
{#if fetchedData}
|
||||||
chartCount={params.hourly?.length || 0}
|
{#each chartDefs as def, i (i)}
|
||||||
chartHeight={showLegend ? 400 : 300}
|
<CanvasChart
|
||||||
>
|
|
||||||
{#each chartOptions as option, i (i)}
|
|
||||||
<EChart
|
|
||||||
{option}
|
|
||||||
notMerge
|
|
||||||
height={showLegend ? '400px' : '300px'}
|
|
||||||
onChartReady={handleChartReady}
|
|
||||||
bind:this={chartComponents[i]}
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
title={def.title}
|
||||||
|
subtitle={def.subtitle}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import {
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
buildAverageSeries,
|
|
||||||
buildCurrentTimeSeries,
|
|
||||||
buildDaylightSeries,
|
|
||||||
buildModelSeries,
|
|
||||||
calculateAverage,
|
|
||||||
composeChartOption,
|
|
||||||
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 { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
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 {
|
import {
|
||||||
type MarkArea,
|
CHART_COLORS,
|
||||||
|
CanvasChart,
|
||||||
|
type ChartSeries,
|
||||||
|
SERIES_COLORS,
|
||||||
|
calculateAverage,
|
||||||
|
findUnit,
|
||||||
|
isColumnUnit
|
||||||
|
} from '$lib/charts';
|
||||||
|
import {
|
||||||
|
type DaylightBand,
|
||||||
type ModelCompareResult,
|
type ModelCompareResult,
|
||||||
fetchModelComparison
|
fetchModelComparison
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
@@ -32,19 +29,18 @@
|
|||||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||||
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
|
|
||||||
const models = [modelsFlat];
|
const models = [modelsFlat];
|
||||||
|
|
||||||
|
const CHART_GROUP = 'model-compare';
|
||||||
|
|
||||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||||
|
|
||||||
let showLegend = $state(false);
|
let showLegend = $state(false);
|
||||||
|
|
||||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
let chartComponents: EChart[] = $state([]);
|
let chartComponents: CanvasChart[] = $state([]);
|
||||||
let chartInstances: echarts.ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
@@ -86,7 +82,7 @@
|
|||||||
hourly: Record<string, unknown>;
|
hourly: Record<string, unknown>;
|
||||||
hourly_units: Record<string, string>;
|
hourly_units: Record<string, string>;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
sunrise: number[];
|
sunrise: number[];
|
||||||
sunset: number[];
|
sunset: number[];
|
||||||
@@ -100,22 +96,8 @@
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
// components persist across refetches; entries are null while unmounted
|
||||||
chartInstances = [];
|
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||||
chartOptions = [];
|
|
||||||
chartComponents = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Chart Instance Tracking ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleChartReady(chart: echarts.ECharts): void {
|
|
||||||
chartInstances = [...chartInstances, chart];
|
|
||||||
}
|
|
||||||
|
|
||||||
// chart components persist across refetches (options update in place), so
|
|
||||||
// the registry is never reset; only instances disposed because the chart
|
|
||||||
// count shrank are filtered out
|
|
||||||
let liveCharts = $derived(chartInstances.filter((chart) => !chart.isDisposed()));
|
|
||||||
|
|
||||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
||||||
|
|
||||||
@@ -149,7 +131,7 @@
|
|||||||
hourly: result.hourlyFlat,
|
hourly: result.hourlyFlat,
|
||||||
hourly_units: result.hourlyUnitsFlat,
|
hourly_units: result.hourlyUnitsFlat,
|
||||||
timezone: result.timezone,
|
timezone: result.timezone,
|
||||||
markAreas: result.markAreas,
|
daylightBands: result.daylightBands,
|
||||||
timestamps: result.timestamps,
|
timestamps: result.timestamps,
|
||||||
sunrise: result.sunrise,
|
sunrise: result.sunrise,
|
||||||
sunset: result.sunset
|
sunset: result.sunset
|
||||||
@@ -164,83 +146,77 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
|
||||||
|
|
||||||
$effect(() => {
|
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||||
if (!fetchedData) return;
|
let timestampsSec = $derived.by(() =>
|
||||||
|
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
|
||||||
|
);
|
||||||
|
|
||||||
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
|
interface ChartDef {
|
||||||
const _showLegend = showLegend;
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
unit: string;
|
||||||
|
showCredit: boolean;
|
||||||
|
series: ChartSeries[];
|
||||||
|
}
|
||||||
|
|
||||||
const colors = getThemeColors();
|
let chartDefs = $derived.by((): ChartDef[] => {
|
||||||
|
if (!fetchedData) return [];
|
||||||
|
|
||||||
|
const { hourly: hourlyData, hourly_units, timestamps } = fetchedData;
|
||||||
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
|
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
|
||||||
const variableCount = chartVariables.length;
|
const variableCount = chartVariables.length;
|
||||||
const timeLength = timestamps.length;
|
const timeLength = timestamps.length;
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
const defs: ChartDef[] = [];
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
const variable = chartVariables[vi];
|
const variable = chartVariables[vi];
|
||||||
const unit = findUnit(hourly_units, hourlyData, variable);
|
const unit = findUnit(hourly_units, hourlyData, variable);
|
||||||
|
const isColumn = isColumnUnit(unit);
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
const series: ChartSeries[] = [];
|
||||||
|
|
||||||
|
let modelIndex = 0;
|
||||||
for (const [model, values] of Object.entries(hourlyData)) {
|
for (const [model, values] of Object.entries(hourlyData)) {
|
||||||
if (model === 'time') continue;
|
if (model === 'time') continue;
|
||||||
if (!model.startsWith(variable)) continue;
|
if (!model.startsWith(variable)) continue;
|
||||||
|
|
||||||
const seriesData = (values as (number | null)[]).map(
|
series.push({
|
||||||
(val, idx) => [timestamps[idx], val] as [number, number | null]
|
|
||||||
);
|
|
||||||
|
|
||||||
series.push(
|
|
||||||
buildModelSeries({
|
|
||||||
name: model,
|
name: model,
|
||||||
data: seriesData,
|
type: isColumn ? 'bar' : 'line',
|
||||||
unit
|
color: SERIES_COLORS[modelIndex % SERIES_COLORS.length],
|
||||||
})
|
data: values as (number | null)[],
|
||||||
);
|
width: 2
|
||||||
|
});
|
||||||
|
modelIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
series.push({
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
name: variable + '_average',
|
||||||
|
type: isColumn ? 'bar' : 'line',
|
||||||
series.push(buildCurrentTimeSeries());
|
color: CHART_COLORS.average,
|
||||||
|
data: average,
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
width: 4,
|
||||||
if (daylightSeries) {
|
dashed: !isColumn
|
||||||
series.push(daylightSeries);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
const isFirst = vi === 0;
|
||||||
const isLast = vi === variableCount - 1;
|
const isLast = vi === variableCount - 1;
|
||||||
|
|
||||||
const option = composeChartOption({
|
defs.push({
|
||||||
title: isFirst
|
title: isFirst ? 'Model Compare' : undefined,
|
||||||
? {
|
subtitle: isFirst
|
||||||
text: 'Model Compare',
|
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||||
subtext: `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
: undefined,
|
||||||
}
|
unit,
|
||||||
: null,
|
|
||||||
tooltip: { unit, timezone },
|
|
||||||
legend: { show: _showLegend },
|
|
||||||
grid: {
|
|
||||||
hasTitle: isFirst,
|
|
||||||
hasSubtitle: isFirst,
|
|
||||||
showLegend: _showLegend
|
|
||||||
},
|
|
||||||
yAxis: { unit },
|
|
||||||
series,
|
|
||||||
toolbox: false,
|
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
colors,
|
series
|
||||||
timezone
|
|
||||||
});
|
});
|
||||||
|
|
||||||
newOptions.push(option);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chartOptions = newOptions;
|
return defs;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -254,16 +230,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}>
|
<ChartContainer {loading} chartCount={chartDefs.length || 1} chartHeight={300}>
|
||||||
{#each chartOptions as option, i (i)}
|
{#if fetchedData}
|
||||||
<EChart
|
{#each chartDefs as def, i (i)}
|
||||||
{option}
|
<CanvasChart
|
||||||
notMerge
|
|
||||||
height={showLegend ? '400px' : '300px'}
|
|
||||||
onChartReady={handleChartReady}
|
|
||||||
bind:this={chartComponents[i]}
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
title={def.title}
|
||||||
|
subtitle={def.subtitle}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
{#if fetchedData && !loading}
|
{#if fetchedData && !loading}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
timezone: result.timezone,
|
timezone: result.timezone,
|
||||||
timestamps: result.hourlyTimestamps,
|
timestamps: result.hourlyTimestamps,
|
||||||
hourlyDates: result.hourlyDates,
|
hourlyDates: result.hourlyDates,
|
||||||
markAreas: result.markAreas
|
daylightBands: result.daylightBands
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchedDaily = {
|
fetchedDaily = {
|
||||||
@@ -102,8 +102,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Weather | Open-Meteo.com</title>
|
<title>OMbrella | Weather</title>
|
||||||
<link rel="canonical" href="https://open-meteo.com/weather" />
|
<link rel="canonical" href="https://ombrella.servert.ch/weather/week" />
|
||||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
||||||
<div class="flex gap-1 overflow-x-auto pb-1" style="scrollbar-width: thin">
|
<div class="flex gap-2 overflow-x-auto p-1 pb-2" style="scrollbar-width: thin">
|
||||||
{#if daily}
|
{#if daily}
|
||||||
{#each daily.dailyDates as time, index (index)}
|
{#each daily.dailyDates as time, index (index)}
|
||||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
@@ -57,100 +57,90 @@
|
|||||||
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||||
{@const unit = String(units.temperature_unit)}
|
{@const unit = String(units.temperature_unit)}
|
||||||
{@const maxStyle = getTempStyle(tempMax, unit)}
|
{@const maxStyle = getTempStyle(tempMax, unit)}
|
||||||
{@const minStyle = getTempStyle(tempMin, unit)}
|
|
||||||
{#if tempMax != null && !isNaN(tempMax)}
|
{#if tempMax != null && !isNaN(tempMax)}
|
||||||
<button
|
<button
|
||||||
class="group flex min-w-[108px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-0.5 rounded-xl border-2 px-1.5 py-2 transition-all duration-200
|
class="group relative flex min-w-[112px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200
|
||||||
{selected
|
{selected
|
||||||
? 'scale-[1.03] border-primary bg-accent shadow-md'
|
? 'border-primary/50 bg-primary/5 shadow-md ring-2 ring-primary/40'
|
||||||
: 'border-transparent bg-card hover:bg-accent'}"
|
: 'border-border/60 bg-card shadow-xs hover:-translate-y-0.5 hover:border-border hover:shadow-md'}"
|
||||||
|
aria-pressed={selected}
|
||||||
onclick={() => onSelectDay(time, index)}
|
onclick={() => onSelectDay(time, index)}
|
||||||
>
|
>
|
||||||
<!-- Day label -->
|
<!-- Day label -->
|
||||||
<span class="text-sm font-bold tracking-wide">
|
<span class="text-[13px] font-semibold tracking-wider">
|
||||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[11px] text-muted-foreground">
|
<span class="-mt-1 text-[11px] text-muted-foreground">
|
||||||
{getRelativeDayLabel(time, daily.timezone)}
|
{getRelativeDayLabel(time, daily.timezone)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Weather icon -->
|
<!-- Weather icon -->
|
||||||
<div
|
<svg class="day-icon my-1 fill-foreground" width="46px" height="46px">
|
||||||
class="my-1 flex w-full items-center justify-center rounded-lg py-1.5"
|
|
||||||
style="background: {sunColor}22"
|
|
||||||
>
|
|
||||||
<svg class="fill-foreground" width="48px" height="48px">
|
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
||||||
wCode as keyof typeof weatherCodes
|
wCode as keyof typeof weatherCodes
|
||||||
] ?? 'clear'}.svg#Layer_1"
|
] ?? 'clear'}.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Temperature max/min -->
|
<!-- Temperature max/min -->
|
||||||
<div class="flex w-full flex-col">
|
<div class="flex items-baseline gap-1.5">
|
||||||
<div
|
<span
|
||||||
class="w-full rounded-t px-1 py-0.5 text-center text-[15px] font-bold"
|
class="rounded-lg px-2 py-0.5 text-[15px] font-bold tabular-nums"
|
||||||
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
|
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
|
||||||
>
|
>
|
||||||
{tempMax.toFixed(0)}°
|
{tempMax.toFixed(0)}°
|
||||||
</div>
|
</span>
|
||||||
<div
|
<span class="text-sm font-medium tabular-nums text-muted-foreground">
|
||||||
class="w-full rounded-b px-1 py-0.5 text-center text-xs font-semibold"
|
|
||||||
style="background-color: {minStyle.bg}; color: {minStyle.fg}"
|
|
||||||
>
|
|
||||||
{tempMin.toFixed(0)}°
|
{tempMin.toFixed(0)}°
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Details section -->
|
<!-- Details -->
|
||||||
<div class="mt-1 flex w-full flex-col items-center gap-0.5">
|
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
|
||||||
<!-- Sunshine bar -->
|
<!-- Sunshine -->
|
||||||
<div class="flex w-full items-center gap-1 px-1">
|
<div class="flex w-full items-center gap-1.5">
|
||||||
<svg class="shrink-0" width="14px" height="14px" style="fill: {sunColor}">
|
<svg class="shrink-0" width="13px" height="13px" style="fill: {sunColor}">
|
||||||
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full transition-all"
|
class="h-full rounded-full transition-all"
|
||||||
style="width: {sunPct}%; background-color: {sunColor}"
|
style="width: {sunPct}%; background-color: {sunColor}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] font-medium text-muted-foreground">
|
<span class="text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||||
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Precipitation -->
|
<!-- Precipitation + wind -->
|
||||||
<div class="flex items-center gap-1 text-[11px]">
|
<div
|
||||||
<svg class="fill-foreground shrink-0" width="14px" height="14px">
|
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-0.5">
|
||||||
|
<svg class="shrink-0 fill-foreground/70" width="13px" height="13px">
|
||||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
|
||||||
{Number(precipSum ?? 0).toFixed(
|
{Number(precipSum ?? 0).toFixed(
|
||||||
precipSum >= 10 ? 0 : 1
|
precipSum >= 10 ? 0 : 1
|
||||||
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span class="inline-flex items-center gap-0.5">
|
||||||
|
|
||||||
<!-- Wind with direction -->
|
|
||||||
<div class="flex items-center gap-1 text-[11px]">
|
|
||||||
{#if windDir != null && !isNaN(windDir)}
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
<div
|
<span
|
||||||
class="inline-flex shrink-0"
|
class="inline-flex shrink-0"
|
||||||
style="transform: {getWindArrowRotation(windDir)}"
|
style="transform: {getWindArrowRotation(windDir)}"
|
||||||
>
|
>
|
||||||
<svg class="fill-foreground" width="20px" height="20px">
|
<svg class="fill-foreground/70" width="16px" height="16px">
|
||||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<svg class="fill-foreground shrink-0" width="20px" height="20px">
|
<svg class="shrink-0 fill-foreground/70" width="16px" height="16px">
|
||||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<span>
|
|
||||||
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
|
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
|
||||||
>-{gustMax?.toFixed(0) ?? '-'}</span
|
>-{gustMax?.toFixed(0) ?? '-'}</span
|
||||||
>
|
>
|
||||||
@@ -167,12 +157,12 @@
|
|||||||
<style>
|
<style>
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
button {
|
button {
|
||||||
min-width: 92px !important;
|
min-width: 96px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
button :global(svg[width='48px']) {
|
button :global(.day-icon) {
|
||||||
width: 40px;
|
width: 38px;
|
||||||
height: 40px;
|
height: 38px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -189,27 +189,29 @@
|
|||||||
{formatZoned(selectedDay, data.timezone, 'EEEE')} – Hourly
|
{formatZoned(selectedDay, data.timezone, 'EEEE')} – Hourly
|
||||||
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
|
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
<div
|
||||||
<span class="select-none text-muted-foreground">3h</span>
|
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
||||||
<button
|
role="group"
|
||||||
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
aria-label="Hourly interval"
|
||||||
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
|
||||||
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
|
||||||
title="Toggle between 1-hour and 3-hour intervals"
|
|
||||||
>
|
>
|
||||||
<span
|
{#each [3, 1] as interval (interval)}
|
||||||
class="absolute top-[3px] size-[18px] rounded-full bg-white shadow-sm transition-[left] duration-200
|
<button
|
||||||
{hourlyInterval === 1 ? 'left-[22px]' : 'left-[3px]'}"
|
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval === interval
|
||||||
></span>
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
aria-pressed={hourlyInterval === interval}
|
||||||
|
onclick={() => (hourlyInterval = interval as 1 | 3)}
|
||||||
|
>
|
||||||
|
{interval}h
|
||||||
</button>
|
</button>
|
||||||
<span class="select-none text-muted-foreground">1h</span>
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if cellData.length > 0}
|
{#if cellData.length > 0}
|
||||||
{@const hourly = data.hourly}
|
{@const hourly = data.hourly}
|
||||||
{@const iconPx = is3h ? 40 : 26}
|
{@const iconPx = is3h ? 40 : 26}
|
||||||
<div class="overflow-hidden rounded-lg border border-border">
|
<div class="overflow-hidden rounded-xl border border-border/70 bg-card shadow-xs">
|
||||||
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||||
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -425,7 +427,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
tr {
|
tr {
|
||||||
border-top: 1px solid hsl(var(--border));
|
border-top: 1px solid hsl(var(--border) / 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Base cell ──────────────────────────────────────────── */
|
/* ── Base cell ──────────────────────────────────────────── */
|
||||||
@@ -434,7 +436,8 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-right: 1px solid hsl(var(--border) / 0.2);
|
font-variant-numeric: tabular-nums;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.15);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +447,7 @@
|
|||||||
|
|
||||||
.cell.now {
|
.cell.now {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Row header ─────────────────────────────────────────── */
|
/* ── Row header ─────────────────────────────────────────── */
|
||||||
@@ -453,8 +456,8 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: hsl(var(--background));
|
background: hsl(var(--muted) / 0.35);
|
||||||
border-right: 2px solid hsl(var(--border));
|
border-right: 1px solid hsl(var(--border));
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -473,7 +476,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.precip-cell.now {
|
.precip-cell.now {
|
||||||
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.precip-bar {
|
.precip-bar {
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||||
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
import { echarts } from '$lib/components/charts/echarts';
|
|
||||||
import '$lib/components/charts/echarts.css';
|
import { CanvasChart, type ChartSeries } from '$lib/charts';
|
||||||
|
|
||||||
import { getColor } from '../../utils/colors';
|
|
||||||
import {
|
import {
|
||||||
type FetchedHourly,
|
type FetchedHourly,
|
||||||
type WeatherUnits,
|
type WeatherUnits,
|
||||||
@@ -18,8 +16,6 @@
|
|||||||
getWindUnit
|
getWindUnit
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import type { ECharts } from 'echarts';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: FetchedHourly;
|
data: FetchedHourly;
|
||||||
selectedDay: Date;
|
selectedDay: Date;
|
||||||
@@ -31,15 +27,18 @@
|
|||||||
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
||||||
|
|
||||||
const CHART_GROUP = 'week-meteogram';
|
const CHART_GROUP = 'week-meteogram';
|
||||||
const MS_PER_DAY = 24 * 3600 * 1000;
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
|
|
||||||
let showCharts = $state(false);
|
let showCharts = $state(false);
|
||||||
let chartComponents: EChart[] = $state([]);
|
let chartComponents: CanvasChart[] = $state([]);
|
||||||
let chartInstances: ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||||
|
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
|
||||||
|
|
||||||
|
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||||
|
|
||||||
export function scrollToDay(day: Date): void {
|
export function scrollToDay(day: Date): void {
|
||||||
if (chartInstances.length === 0 || !data) return;
|
if (!data || liveCharts.length === 0) return;
|
||||||
|
|
||||||
const tz = data.timezone;
|
const tz = data.timezone;
|
||||||
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
|
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
|
||||||
@@ -49,436 +48,150 @@
|
|||||||
|
|
||||||
if (firstHourIdx === -1) return;
|
if (firstHourIdx === -1) return;
|
||||||
|
|
||||||
const dayStart = data.timestamps[firstHourIdx];
|
const dayStart = data.timestamps[firstHourIdx] / 1000;
|
||||||
const dayEnd = dayStart + MS_PER_DAY;
|
// Charts share the group, so setting the range on one syncs all of them
|
||||||
const timestamps = data.timestamps;
|
liveCharts[0].setRange(dayStart, dayStart + SECONDS_PER_DAY);
|
||||||
const rangeStart = timestamps[0];
|
|
||||||
const rangeEnd = timestamps[timestamps.length - 1];
|
|
||||||
const totalRange = rangeEnd - rangeStart;
|
|
||||||
|
|
||||||
if (totalRange <= 0) return;
|
|
||||||
|
|
||||||
const startPct = Math.max(0, ((dayStart - rangeStart) / totalRange) * 100);
|
|
||||||
const endPct = Math.min(100, ((dayEnd - rangeStart) / totalRange) * 100);
|
|
||||||
|
|
||||||
for (const chart of chartInstances) {
|
|
||||||
if (chart && !chart.isDisposed()) {
|
|
||||||
chart.dispatchAction({ type: 'dataZoom', start: startPct, end: endPct });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetZoom(): void {
|
function resetZoom(): void {
|
||||||
for (const chart of chartInstances) {
|
liveCharts[0]?.resetRange();
|
||||||
if (chart && !chart.isDisposed()) {
|
|
||||||
chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onResetZoom?.();
|
onResetZoom?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChartReady(chart: ECharts): void {
|
// Zoom to the selected day once all three charts are mounted
|
||||||
chart.group = CHART_GROUP;
|
let scrolledOnMount = false;
|
||||||
chartInstances = [...chartInstances, chart];
|
$effect(() => {
|
||||||
if (chartInstances.length === 3) {
|
if (!showCharts) {
|
||||||
echarts.connect(CHART_GROUP);
|
scrolledOnMount = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scrolledOnMount && liveCharts.length === 3 && data) {
|
||||||
|
scrolledOnMount = true;
|
||||||
requestAnimationFrame(() => scrollToDay(selectedDay));
|
requestAnimationFrame(() => scrollToDay(selectedDay));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const { hourly, timestamps, markAreas } = data;
|
|
||||||
const colors = getThemeColors();
|
|
||||||
const tempUnit = getTempUnit(units);
|
|
||||||
const precipUnit = getPrecipUnit(units);
|
|
||||||
const windUnit = getWindUnit(units);
|
|
||||||
|
|
||||||
const temps = hourly.temperature_2m;
|
|
||||||
const precip = hourly.precipitation;
|
|
||||||
const precipProb = hourly.precipitation_probability;
|
|
||||||
const cloudCov = hourly.cloud_cover;
|
|
||||||
const windSpeed = hourly.windspeed_10m;
|
|
||||||
const humidity = hourly.relative_humidity_2m;
|
|
||||||
|
|
||||||
const validTemps = temps.filter((t: number) => t !== null && !isNaN(t));
|
|
||||||
const minTemp = Math.min(...validTemps);
|
|
||||||
const maxTemp = Math.max(...validTemps);
|
|
||||||
|
|
||||||
const tempData = temps.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const cloudData = cloudCov.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const precipData = precip.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const precipProbData = precipProb.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const windData = windSpeed.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const humidityData = humidity.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
|
|
||||||
const annotations = (): Array<Record<string, unknown>> => {
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
|
||||||
series.push(buildCurrentTimeSeries());
|
|
||||||
const dl = buildDaylightSeries({ markAreas });
|
|
||||||
if (dl) series.push(dl);
|
|
||||||
return series;
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeXAxis = (showLabel: boolean): Record<string, unknown> => ({
|
|
||||||
type: 'time',
|
|
||||||
splitLine: { show: false },
|
|
||||||
axisLine: { lineStyle: { color: colors.axisLine } },
|
|
||||||
axisLabel: {
|
|
||||||
color: colors.text,
|
|
||||||
hideOverlap: true,
|
|
||||||
show: showLabel,
|
|
||||||
formatter: (value: number) => formatZoned(new Date(value), data.timezone, 'HH:mm')
|
|
||||||
},
|
|
||||||
axisTick: { lineStyle: { color: colors.axisLine } }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const insideZoom = (): Record<string, unknown> => ({
|
// ─── Series Building ────────────────────────────────────────────────────────
|
||||||
type: 'inside',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
filterMode: 'none',
|
|
||||||
zoomOnMouseWheel: true,
|
|
||||||
moveOnMouseMove: true,
|
|
||||||
moveOnMouseWheel: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const sliderZoom = (): Record<string, unknown> => ({
|
let tempUnit = $derived(getTempUnit(units));
|
||||||
type: 'slider',
|
let precipUnit = $derived(getPrecipUnit(units));
|
||||||
xAxisIndex: 0,
|
let windUnit = $derived(getWindUnit(units));
|
||||||
filterMode: 'none',
|
|
||||||
height: 20,
|
|
||||||
bottom: 4,
|
|
||||||
borderColor: colors.axisLine,
|
|
||||||
fillerColor: 'rgba(100, 140, 200, 0.2)',
|
|
||||||
handleStyle: { color: colors.text },
|
|
||||||
textStyle: { color: colors.text, fontSize: 10 },
|
|
||||||
dataBackground: {
|
|
||||||
lineStyle: { color: colors.axisLine },
|
|
||||||
areaStyle: { color: colors.splitLine }
|
|
||||||
},
|
|
||||||
selectedDataBackground: {
|
|
||||||
lineStyle: { color: colors.axisLine },
|
|
||||||
areaStyle: { color: 'rgba(100, 140, 200, 0.15)' }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const tooltipBase = (
|
interface ChartDef {
|
||||||
formatter: (
|
title: string;
|
||||||
params: Array<{
|
unit: string;
|
||||||
axisValue: number;
|
unitRight?: string;
|
||||||
seriesName: string;
|
yMin?: number;
|
||||||
marker: string;
|
yMinRight?: number;
|
||||||
value: number | number[] | null;
|
yMaxRight?: number;
|
||||||
}>
|
invertRight?: boolean;
|
||||||
) => string
|
showCredit?: boolean;
|
||||||
): Record<string, unknown> => ({
|
series: ChartSeries[];
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'cross',
|
|
||||||
animation: false,
|
|
||||||
label: {
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
color: colors.text,
|
|
||||||
borderColor: colors.tooltipBorder,
|
|
||||||
borderWidth: 1,
|
|
||||||
formatter: (params: { axisDimension: string; value: number }) => {
|
|
||||||
if (params.axisDimension === 'x') {
|
|
||||||
return formatZoned(new Date(params.value), data.timezone, 'EEE d MMM HH:mm');
|
|
||||||
}
|
}
|
||||||
return params.value.toFixed(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
borderColor: colors.tooltipBorder,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
formatter
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (ts: number): string => {
|
let chartDefs = $derived.by((): ChartDef[] => {
|
||||||
const date = new Date(ts);
|
if (!data) return [];
|
||||||
const dateStr = formatZoned(date, data.timezone, 'EEE d MMM HH:mm');
|
|
||||||
return `<b>${dateStr}</b><br/>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAnnotation = (name: string): boolean => name === 'Daylight' || name === 'Current Time';
|
const { hourly } = data;
|
||||||
|
|
||||||
const tempOption: Record<string, unknown> = {
|
const tempChart: ChartDef = {
|
||||||
title: {
|
title: 'Temperature & Cloud Cover',
|
||||||
text: 'Temperature & Cloud Cover',
|
unit: tempUnit,
|
||||||
left: 'left',
|
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
// occupying at most the upper 40% of the plot
|
||||||
},
|
yMinRight: 0,
|
||||||
tooltip: tooltipBase((params) => {
|
yMaxRight: 250,
|
||||||
if (!params?.length) return '';
|
invertRight: true,
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Temperature')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${tempUnit}</b><br/>`;
|
|
||||||
else if (name === 'Cloud Cover')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 0,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Temperature', 'Cloud Cover']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
|
||||||
dataZoom: [insideZoom()],
|
|
||||||
xAxis: timeXAxis(false),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: tempUnit,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{ type: 'value', min: 0, max: 250, inverse: true, show: false }
|
|
||||||
],
|
|
||||||
series: [
|
series: [
|
||||||
{
|
|
||||||
name: 'Temperature',
|
|
||||||
type: 'line',
|
|
||||||
data: tempData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 3, color: '#ef6c00' },
|
|
||||||
itemStyle: { color: '#ef6c00' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: getColor(maxTemp, String(units.temperature_unit)) + '88'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 0.5,
|
|
||||||
color: getColor((maxTemp + minTemp) / 2, String(units.temperature_unit)) + '44'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: getColor(minTemp, String(units.temperature_unit)) + '08'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
},
|
|
||||||
z: 5
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Cloud Cover',
|
name: 'Cloud Cover',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: cloudData,
|
color: 'rgb(150, 150, 150)',
|
||||||
smooth: true,
|
data: hourly.cloud_cover,
|
||||||
showSymbol: false,
|
width: 0,
|
||||||
yAxisIndex: 1,
|
fill: true,
|
||||||
lineStyle: { width: 0 },
|
fillOpacity: 0.25,
|
||||||
itemStyle: { color: colors.text },
|
axis: 'right',
|
||||||
areaStyle: { color: 'rgba(150, 150, 150, 0.25)', origin: 'start' },
|
format: (v) => `${v.toFixed(0)}%`
|
||||||
z: 1,
|
|
||||||
silent: true
|
|
||||||
},
|
},
|
||||||
...annotations()
|
{
|
||||||
],
|
name: 'Temperature',
|
||||||
textStyle: { color: colors.text }
|
type: 'line',
|
||||||
|
color: '#ef6c00',
|
||||||
|
data: hourly.temperature_2m,
|
||||||
|
width: 3,
|
||||||
|
fill: true,
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
format: (v) => `${v.toFixed(1)} ${tempUnit}`
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const precipOption: Record<string, unknown> = {
|
const precipChart: ChartDef = {
|
||||||
title: {
|
title: 'Precipitation & Probability',
|
||||||
text: 'Precipitation & Probability',
|
unit: precipUnit,
|
||||||
left: 'left',
|
unitRight: '%',
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
yMin: 0,
|
||||||
},
|
yMinRight: 0,
|
||||||
tooltip: tooltipBase((params) => {
|
yMaxRight: 100,
|
||||||
if (!params?.length) return '';
|
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Precipitation')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${precipUnit}</b><br/>`;
|
|
||||||
else if (name === 'Precip. Probability')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 0,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Precipitation', 'Precip. Probability']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
|
||||||
dataZoom: [insideZoom()],
|
|
||||||
xAxis: timeXAxis(false),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: precipUnit,
|
|
||||||
min: 0,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: '%',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { show: false }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'Precipitation',
|
name: 'Precipitation',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: precipData,
|
color: 'rgba(30, 136, 229, 0.8)',
|
||||||
barMaxWidth: 8,
|
data: hourly.precipitation,
|
||||||
itemStyle: {
|
format: (v) => `${v.toFixed(1)} ${precipUnit}`
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(30, 136, 229, 0.9)' },
|
|
||||||
{ offset: 1, color: 'rgba(30, 136, 229, 0.4)' }
|
|
||||||
])
|
|
||||||
},
|
|
||||||
yAxisIndex: 0,
|
|
||||||
z: 5
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Precip. Probability',
|
name: 'Precip. Probability',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: precipProbData,
|
color: '#5c6bc0',
|
||||||
smooth: true,
|
data: hourly.precipitation_probability,
|
||||||
showSymbol: false,
|
width: 2,
|
||||||
lineStyle: { width: 2, type: 'dashed', color: '#5c6bc0' },
|
dashed: true,
|
||||||
itemStyle: { color: '#5c6bc0' },
|
axis: 'right',
|
||||||
yAxisIndex: 1,
|
format: (v) => `${v.toFixed(0)}%`
|
||||||
z: 4
|
}
|
||||||
},
|
]
|
||||||
...annotations()
|
|
||||||
],
|
|
||||||
textStyle: { color: colors.text }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const windOption: Record<string, unknown> = {
|
const windChart: ChartDef = {
|
||||||
title: {
|
title: 'Wind Speed & Humidity',
|
||||||
text: 'Wind Speed & Humidity',
|
unit: windUnit,
|
||||||
left: 'left',
|
unitRight: '%',
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
yMin: 0,
|
||||||
},
|
yMinRight: 0,
|
||||||
tooltip: tooltipBase((params) => {
|
yMaxRight: 100,
|
||||||
if (!params?.length) return '';
|
showCredit: true,
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Wind Speed') {
|
|
||||||
const idx = timestamps.indexOf(
|
|
||||||
(params[0] as Record<string, unknown>).axisValue as number
|
|
||||||
);
|
|
||||||
const windDir = idx >= 0 ? hourly.winddirection_10m[idx] : null;
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)} ${windUnit}</b>`;
|
|
||||||
if (windDir != null && !isNaN(windDir)) html += ` (${getWindDirectionLabel(windDir)})`;
|
|
||||||
html += '<br/>';
|
|
||||||
} else if (name === 'Humidity') {
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 28,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Wind Speed', 'Humidity']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 60 },
|
|
||||||
dataZoom: [insideZoom(), sliderZoom()],
|
|
||||||
xAxis: timeXAxis(true),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: windUnit,
|
|
||||||
min: 0,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: '%',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { show: false }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'Wind Speed',
|
name: 'Wind Speed',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: windData,
|
color: '#26a69a',
|
||||||
smooth: true,
|
data: hourly.windspeed_10m,
|
||||||
showSymbol: false,
|
width: 2,
|
||||||
lineStyle: { width: 2, color: '#26a69a' },
|
fill: true,
|
||||||
itemStyle: { color: '#26a69a' },
|
fillOpacity: 0.15,
|
||||||
areaStyle: {
|
format: (v, i) => {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
const dir = hourly.winddirection_10m[i];
|
||||||
{ offset: 0, color: 'rgba(38, 166, 154, 0.25)' },
|
const dirLabel = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
|
||||||
{ offset: 1, color: 'rgba(38, 166, 154, 0.02)' }
|
return `${v.toFixed(0)} ${windUnit}${dirLabel}`;
|
||||||
])
|
}
|
||||||
},
|
|
||||||
yAxisIndex: 0,
|
|
||||||
z: 5
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Humidity',
|
name: 'Humidity',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: humidityData,
|
color: '#8d6e63',
|
||||||
smooth: true,
|
data: hourly.relative_humidity_2m,
|
||||||
showSymbol: false,
|
width: 2,
|
||||||
lineStyle: { width: 2, type: 'dotted', color: '#8d6e63' },
|
dashed: true,
|
||||||
itemStyle: { color: '#8d6e63' },
|
axis: 'right',
|
||||||
yAxisIndex: 1,
|
format: (v) => `${v.toFixed(0)}%`
|
||||||
z: 4
|
|
||||||
},
|
|
||||||
...annotations()
|
|
||||||
],
|
|
||||||
graphic: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
right: 10,
|
|
||||||
bottom: 30,
|
|
||||||
style: {
|
|
||||||
text: 'Open-Meteo.com',
|
|
||||||
fontSize: 10,
|
|
||||||
fill: colors.text,
|
|
||||||
opacity: 0.4
|
|
||||||
},
|
|
||||||
cursor: 'pointer'
|
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
textStyle: { color: colors.text }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
chartOptions = [tempOption, precipOption, windOption];
|
return [tempChart, precipChart, windChart];
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -520,19 +233,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
||||||
{#each chartOptions as option, i (i)}
|
{#each chartDefs as def, i (def.title)}
|
||||||
<EChart
|
<CanvasChart
|
||||||
{option}
|
|
||||||
notMerge
|
|
||||||
height={i === 2 ? '320px' : '300px'}
|
|
||||||
onChartReady={handleChartReady}
|
|
||||||
bind:this={chartComponents[i]}
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={data.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={data.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
unitRight={def.unitRight}
|
||||||
|
yMin={def.yMin}
|
||||||
|
yMinRight={def.yMinRight}
|
||||||
|
yMaxRight={def.yMaxRight}
|
||||||
|
invertRight={def.invertRight}
|
||||||
|
title={def.title}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
showLegend
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
<div class="mt-6 md:mt-10">
|
||||||
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
|
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MarkArea, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
|
import type { DaylightBand, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
|
||||||
|
|
||||||
export interface WeatherUnits {
|
export interface WeatherUnits {
|
||||||
temperature_unit: string;
|
temperature_unit: string;
|
||||||
@@ -12,7 +12,7 @@ export interface FetchedHourly {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
hourlyDates: Date[];
|
hourlyDates: Date[];
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchedDaily {
|
export interface FetchedDaily {
|
||||||
|
|||||||
Reference in New Issue
Block a user