From e031716ce6fee88227896a0951f7816b43dae0d4 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Sun, 19 Jul 2026 15:25:04 +0200 Subject: [PATCH] remove echarts, use canvas --- .claude/hooks | 0 .claude/launch.json | 0 .claude/routines | 0 .claude/scheduled_tasks.json | 0 .claude/settings.json | 0 .claude/skills | 0 .claude/workflows | 0 .mcp.json | 0 package-lock.json | 40 +- package.json | 3 +- src/lib/charts/CanvasChart.svelte | 829 ++++++++++++++++++ src/lib/charts/bands.ts | 24 + src/lib/charts/data.ts | 106 +++ src/lib/charts/index.ts | 15 + .../components/charts/ChartContainer.svelte | 2 +- src/lib/components/charts/ChartToolbar.svelte | 146 +-- src/lib/components/charts/EChart.svelte | 167 ---- src/lib/components/charts/echarts.css | 164 ---- src/lib/components/charts/echarts.ts | 34 - src/lib/components/charts/index.ts | 3 +- .../components/navigation/weather-nav.svelte | 57 +- src/lib/services/weather.ts | 28 +- src/lib/utils/echarts/download.ts | 321 ------- src/lib/utils/echarts/index.ts | 71 -- src/lib/utils/echarts/options.ts | 435 --------- src/lib/utils/echarts/series.ts | 349 -------- src/lib/utils/echarts/theme.ts | 106 --- src/routes/+layout.svelte | 9 +- src/routes/+page.svelte | 2 +- src/routes/page.svelte.spec.ts | 2 +- .../weather/14-day/[location]/+page.svelte | 188 ++-- .../weather/compare/[location]/+page.svelte | 179 ++-- .../weather/week/[location]/+page.svelte | 6 +- .../weather/week/[location]/DailyCards.svelte | 116 ++- .../week/[location]/HourlyTable.svelte | 45 +- .../week/[location]/MeteogramCharts.svelte | 542 +++--------- src/routes/weather/week/[location]/types.ts | 4 +- 37 files changed, 1483 insertions(+), 2510 deletions(-) create mode 100644 .claude/hooks create mode 100644 .claude/launch.json create mode 100644 .claude/routines create mode 100644 .claude/scheduled_tasks.json create mode 100644 .claude/settings.json create mode 100644 .claude/skills create mode 100644 .claude/workflows create mode 100644 .mcp.json create mode 100644 src/lib/charts/CanvasChart.svelte create mode 100644 src/lib/charts/bands.ts create mode 100644 src/lib/charts/data.ts create mode 100644 src/lib/charts/index.ts delete mode 100644 src/lib/components/charts/EChart.svelte delete mode 100644 src/lib/components/charts/echarts.css delete mode 100644 src/lib/components/charts/echarts.ts delete mode 100644 src/lib/utils/echarts/download.ts delete mode 100644 src/lib/utils/echarts/index.ts delete mode 100644 src/lib/utils/echarts/options.ts delete mode 100644 src/lib/utils/echarts/series.ts delete mode 100644 src/lib/utils/echarts/theme.ts diff --git a/.claude/hooks b/.claude/hooks new file mode 100644 index 0000000..e69de29 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..e69de29 diff --git a/.claude/routines b/.claude/routines new file mode 100644 index 0000000..e69de29 diff --git a/.claude/scheduled_tasks.json b/.claude/scheduled_tasks.json new file mode 100644 index 0000000..e69de29 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e69de29 diff --git a/.claude/skills b/.claude/skills new file mode 100644 index 0000000..e69de29 diff --git a/.claude/workflows b/.claude/workflows new file mode 100644 index 0000000..e69de29 diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index bd54024..019fcde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "open-meteo-weather", + "name": "ombrella", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "open-meteo-weather", + "name": "ombrella", "version": "0.0.1", "devDependencies": { "@eslint/compat": "^2.1.0", @@ -23,7 +23,6 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", - "echarts": "^6.1.0", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.21.0", @@ -2123,24 +2122,6 @@ "dev": true, "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": { "version": "5.24.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", @@ -4510,23 +4491,6 @@ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "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" } } } diff --git a/package.json b/package.json index c3549b4..68989c7 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "open-meteo-weather", + "name": "ombrella", "private": true, "version": "0.0.1", "type": "module", @@ -32,7 +32,6 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", - "echarts": "^6.1.0", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.21.0", diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte new file mode 100644 index 0000000..535a5c2 --- /dev/null +++ b/src/lib/charts/CanvasChart.svelte @@ -0,0 +1,829 @@ + + + + + +
+ {#if showLegend && series.length > 0} +
+ {#each series.filter((s) => s.showInLegend !== false) as s (s.name)} + + {/each} +
+ {/if} + +
+ + + {#if tooltipVisible} +
+
+ {formatZoned(new Date(timestamps[hoverIdx] * 1000), timezone, 'EEE d MMM HH:mm')} +
+ {#each tooltipRows as row (row.name)} +
+ + {row.name}: + {row.value} +
+ {/each} +
+ {/if} +
+
diff --git a/src/lib/charts/bands.ts b/src/lib/charts/bands.ts new file mode 100644 index 0000000..d97e9a2 --- /dev/null +++ b/src/lib/charts/bands.ts @@ -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] })); +} diff --git a/src/lib/charts/data.ts b/src/lib/charts/data.ts new file mode 100644 index 0000000..65e5731 --- /dev/null +++ b/src/lib/charts/data.ts @@ -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, + variable: string, + timeLength: number +): AverageResult { + const average = new Array(timeLength).fill(0); + const averageCount = new Array(timeLength).fill(0); + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + for (const [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined && 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, + hourlyData: Record, + variable: string +): string { + for (const model of Object.keys(hourlyData)) { + if (model === 'time') continue; + if (model.startsWith(variable) && hourlyUnits[model]) { + return hourlyUnits[model]; + } + } + return ''; +} diff --git a/src/lib/charts/index.ts b/src/lib/charts/index.ts new file mode 100644 index 0000000..a81689c --- /dev/null +++ b/src/lib/charts/index.ts @@ -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'; diff --git a/src/lib/components/charts/ChartContainer.svelte b/src/lib/components/charts/ChartContainer.svelte index 83403f0..4cfab83 100644 --- a/src/lib/components/charts/ChartContainer.svelte +++ b/src/lib/components/charts/ChartContainer.svelte @@ -11,7 +11,7 @@ Usage: {#each charts as chart} - + {/each} --> diff --git a/src/lib/components/charts/ChartToolbar.svelte b/src/lib/components/charts/ChartToolbar.svelte index ea7a079..1024e2f 100644 --- a/src/lib/components/charts/ChartToolbar.svelte +++ b/src/lib/components/charts/ChartToolbar.svelte @@ -3,7 +3,6 @@ Provides a toolbar row with: - Download full meteogram as PNG button - - Download full meteogram as SVG button - Slot for additional custom controls (e.g. legend toggle) When multiple charts are provided, they are stitched into a single @@ -11,7 +10,7 @@ Usage: {#snippet controls()} @@ -19,22 +18,23 @@ {/snippet} --> - - import type { ExportFormat } from '$lib/utils/echarts/download'; - import type * as echarts from 'echarts'; + - -
- - diff --git a/src/lib/components/charts/echarts.css b/src/lib/components/charts/echarts.css deleted file mode 100644 index be91b40..0000000 --- a/src/lib/components/charts/echarts.css +++ /dev/null @@ -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; - } -} diff --git a/src/lib/components/charts/echarts.ts b/src/lib/components/charts/echarts.ts deleted file mode 100644 index 6df7931..0000000 --- a/src/lib/components/charts/echarts.ts +++ /dev/null @@ -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 }; diff --git a/src/lib/components/charts/index.ts b/src/lib/components/charts/index.ts index c67aa73..a892500 100644 --- a/src/lib/components/charts/index.ts +++ b/src/lib/components/charts/index.ts @@ -4,9 +4,8 @@ * Re-exports all chart-related Svelte components from a single entry point. * * 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 ChartToolbar } from './ChartToolbar.svelte'; diff --git a/src/lib/components/navigation/weather-nav.svelte b/src/lib/components/navigation/weather-nav.svelte index 872c818..51fabbf 100644 --- a/src/lib/components/navigation/weather-nav.svelte +++ b/src/lib/components/navigation/weather-nav.svelte @@ -52,41 +52,19 @@ class:w-55={!collapsed} class:w-14={collapsed} > - -
- {#if !collapsed} - -
- - - -
- - Open-Meteo - -
- {:else} - +
+ +
- - {/if} +
+ {#if !collapsed} + + OMbrella + + {/if} +
diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index b4577e6..5c9b1c2 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -13,7 +13,7 @@ import { Unit } from '@openmeteo/sdk/unit'; 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 { VariablesWithTime } from '@openmeteo/sdk/variables-with-time'; @@ -157,7 +157,7 @@ export interface WeatherUnitParams { precipitation_unit?: 'mm' | 'inch'; } -export type MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]; +export type { DaylightBand }; // ─── Week Forecast Types ──────────────────────────────────────────────────────── @@ -201,7 +201,7 @@ export interface WeekForecastResult { hourlyTimestamps: number[]; hourlyDates: Date[]; dailyDates: Date[]; - markAreas: MarkArea[]; + daylightBands: DaylightBand[]; } // ─── Model Comparison Types ───────────────────────────────────────────────────── @@ -221,7 +221,7 @@ export interface ModelCompareResult { timestamps: number[]; utcOffsetSeconds: number; timezone: string; - markAreas: MarkArea[]; + daylightBands: DaylightBand[]; sunrise: number[]; sunset: number[]; units: Record; @@ -251,7 +251,7 @@ export interface EnsembleForecastResult { timestamps: number[]; utcOffsetSeconds: number; timezone: string; - markAreas: MarkArea[]; + daylightBands: DaylightBand[]; /** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */ hourlyFlat: Record; hourlyUnitsFlat: Record; @@ -360,7 +360,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise 0) { const dailyResponse = dailyResponses[0]; const dailyBlock = dailyResponse.daily(); if (dailyBlock) { const sunrise = getInt64Values(dailyBlock.variables(0)!); const sunset = getInt64Values(dailyBlock.variables(1)!); - markAreas = buildDaylightMarkAreas(sunrise, sunset); + daylightBands = buildDaylightBands(sunrise, sunset); } } @@ -629,7 +629,7 @@ export async function fetchEnsembleForecast( timestamps, utcOffsetSeconds, timezone, - markAreas, + daylightBands, hourlyFlat, hourlyUnitsFlat }; diff --git a/src/lib/utils/echarts/download.ts b/src/lib/utils/echarts/download.ts deleted file mode 100644 index 0f13b48..0000000 --- a/src/lib/utils/echarts/download.ts +++ /dev/null @@ -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 = ``; - - if (backgroundColor && backgroundColor !== 'none' && backgroundColor !== 'transparent') { - combinedSvg += ``; - } - - let yOffset = 0; - for (const fragment of fragments) { - combinedSvg += ``; - combinedSvg += fragment.svg.innerHTML; - combinedSvg += ``; - yOffset += fragment.height; - } - - combinedSvg += ``; - - const blob = new Blob([combinedSvg], { type: 'image/svg+xml;charset=utf-8' }); - const url = URL.createObjectURL(blob); - triggerDownload(url, `${fileName}.svg`); - - setTimeout(() => URL.revokeObjectURL(url), 10000); -} - -function decodeSvgDataUrl(dataUrl: string): string | null { - try { - if (dataUrl.startsWith('data:image/svg+xml;charset=UTF-8,')) { - return decodeURIComponent(dataUrl.slice('data:image/svg+xml;charset=UTF-8,'.length)); - } - if (dataUrl.startsWith('data:image/svg+xml;base64,')) { - return atob(dataUrl.slice('data:image/svg+xml;base64,'.length)); - } - if (dataUrl.startsWith('data:image/svg+xml,')) { - return decodeURIComponent(dataUrl.slice('data:image/svg+xml,'.length)); - } - return null; - } catch { - return null; - } -} - -// ─── Internal Helpers ──────────────────────────────────────────────────────── - -/** - * Triggers a browser file download from a data URL 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); - }); -} diff --git a/src/lib/utils/echarts/index.ts b/src/lib/utils/echarts/index.ts deleted file mode 100644 index e1831a2..0000000 --- a/src/lib/utils/echarts/index.ts +++ /dev/null @@ -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'; diff --git a/src/lib/utils/echarts/options.ts b/src/lib/utils/echarts/options.ts deleted file mode 100644 index 5c8b11b..0000000 --- a/src/lib/utils/echarts/options.ts +++ /dev/null @@ -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 = { - top: 80 -}; - -const GRID_WITH_LEGEND: Partial = { - bottom: 60 -}; - -// ─── Grid ──────────────────────────────────────────────────────────────────── - -export interface BuildGridParams { - hasTitle?: boolean; - hasSubtitle?: boolean; - showLegend?: boolean; - overrides?: Partial; -} - -/** - * Builds a grid configuration with sensible defaults. - * Automatically adjusts top/bottom spacing for title and legend presence. - */ -export function buildGrid(params: BuildGridParams = {}): GridOptions { - const { hasTitle = false, hasSubtitle = false, showLegend = false, overrides } = params; - - return { - ...DEFAULT_GRID, - ...(hasTitle ? GRID_WITH_TITLE : {}), - ...(hasSubtitle ? { top: 90 } : {}), - ...(showLegend ? GRID_WITH_LEGEND : {}), - ...overrides - }; -} - -// ─── Title ─────────────────────────────────────────────────────────────────── - -/** - * Builds a title configuration. Pass `null` to hide the title. - */ -export function buildTitle( - options: TitleOptions | null, - colors?: ThemeColors -): Record { - const c = colors ?? getThemeColors(); - - if (!options) { - return { title: { show: false } }; - } - - const result: Record = { - text: options.text, - left: 'left', - textStyle: { - fontWeight: 'normal', - fontSize: 16, - color: c.text - } - }; - - if (options.subtext) { - result.subtext = options.subtext; - result.subtextStyle = { - fontWeight: 'normal', - fontSize: 12, - color: c.textMuted - }; - } - - return result; -} - -// ─── Tooltip ───────────────────────────────────────────────────────────────── - -/** - * Builds a tooltip with cross-axis pointer and unit-aware value formatting. - */ -export function buildTooltip( - options: TooltipOptions, - colors?: ThemeColors -): Record { - const c = colors ?? getThemeColors(); - const { unit, 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 = `${formatZoned(date, timezone, 'EEE d MMM HH:mm')}
`; - 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}: ${val.toFixed(1)} ${unit}
`; - }); - 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 { - 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 { - 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 { - const c = colors ?? getThemeColors(); - - return { - type: 'value', - ...(options.unit ? { name: options.unit } : {}), - nameTextStyle: { - color: c.text, - padding: [0, 0, 0, 4] - }, - axisLine: { - show: false - }, - axisLabel: { - color: c.text - }, - splitLine: { - lineStyle: { - color: c.splitLine - } - } - }; -} - -// ─── Credit Watermark ──────────────────────────────────────────────────────── - -/** - * Builds the Open-Meteo.com credit watermark graphic element. - */ -export function buildCreditGraphic(colors?: ThemeColors): Record[] { - const c = colors ?? getThemeColors(); - - return [ - { - type: 'text', - right: 10, - bottom: 5, - style: { - text: 'Open-Meteo.com', - fontSize: 10, - fill: c.text, - opacity: 0.4 - }, - onclick: function () { - window.open('https://open-meteo.com', '_blank'); - }, - cursor: 'pointer' - } - ]; -} - -// ─── Toolbox (Download) ───────────────────────────────────────────────────── - -export interface ToolboxOptions { - /** Show the save-as-image button */ - saveAsImage?: boolean; - /** File name prefix for downloaded images */ - fileName?: string; - /** Export format: 'png' or 'svg' */ - format?: 'png' | 'svg'; -} - -/** - * Builds the ECharts toolbox with download functionality. - */ -export function buildToolbox( - options: ToolboxOptions = {}, - colors?: ThemeColors -): Record { - const c = colors ?? getThemeColors(); - const { saveAsImage = true, fileName = 'open-meteo-chart', format = 'png' } = options; - - return { - show: true, - right: 16, - top: 4, - iconStyle: { - borderColor: c.textMuted - }, - emphasis: { - iconStyle: { - borderColor: c.text - } - }, - feature: { - ...(saveAsImage - ? { - saveAsImage: { - type: format, - name: fileName, - title: format === 'svg' ? 'Save as SVG' : 'Save as PNG', - pixelRatio: 2, - backgroundColor: 'transparent', - excludeComponents: ['toolbox'], - iconStyle: { - borderColor: c.textMuted - }, - emphasis: { - iconStyle: { - borderColor: c.text - } - } - } - } - : {}) - } - }; -} - -// ─── Full Option Composer ──────────────────────────────────────────────────── - -export interface ChartOptionParams { - title?: TitleOptions | null; - tooltip: TooltipOptions; - legend?: LegendOptions; - grid?: BuildGridParams; - yAxis?: AxisOptions; - series: Array>; - toolbox?: ToolboxOptions | false; - showCredit?: boolean; - colors?: ThemeColors; - 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 { - const colors = params.colors ?? getThemeColors(); - const hasTitle = params.title != null && params.title.text !== ''; - const showLegend = params.legend?.show ?? false; - - const option: Record = { - title: buildTitle(params.title ?? null, colors), - tooltip: buildTooltip(params.tooltip, colors), - legend: buildLegend(params.legend ?? { show: false }, colors), - grid: buildGrid({ - ...params.grid, - hasTitle, - hasSubtitle: hasTitle && !!params.title?.subtext, - showLegend - }), - xAxis: buildTimeXAxis(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); -} diff --git a/src/lib/utils/echarts/series.ts b/src/lib/utils/echarts/series.ts deleted file mode 100644 index d1d744f..0000000 --- a/src/lib/utils/echarts/series.ts +++ /dev/null @@ -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 { - const { name, data, unit, lineWidth = 2 } = params; - const isColumn = isColumnUnit(unit); - - return { - name, - type: isColumn ? 'bar' : 'line', - data, - smooth: !isColumn, - showSymbol: false, - lineStyle: { - width: lineWidth - }, - emphasis: { - lineStyle: { - width: lineWidth + 1 - } - }, - barMaxWidth: 5 - }; -} - -// ─── Average Series ────────────────────────────────────────────────────────── - -/** - * Builds the ensemble/model average series. - * Rendered as a dashed line (or bar) that stands out from individual model lines. - */ -export function buildAverageSeries(params: AverageSeriesParams): Record { - const { variable, data, unit } = params; - const isColumn = isColumnUnit(unit); - - return { - name: variable + '_average', - type: isColumn ? 'bar' : 'line', - data, - smooth: !isColumn, - showSymbol: false, - lineStyle: { - type: 'dashed', - width: 4, - color: CHART_COLORS.average - }, - itemStyle: { - color: CHART_COLORS.average - }, - emphasis: { - lineStyle: { - width: 6 - } - }, - barMaxWidth: 5, - z: 10 - }; -} - -// ─── Current Time Marker ───────────────────────────────────────────────────── - -/** - * Builds a helper series that renders a vertical red line at the current time. - * Uses an empty data series with a markLine to overlay onto the chart. - */ -export function buildCurrentTimeSeries(): Record { - 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 | null { - if (params.markAreas.length === 0) return null; - - return { - name: 'Daylight', - type: 'line', - data: [], - markArea: { - silent: true, - data: params.markAreas - } - }; -} - -// ─── Ensemble Spread (Min/Max Area) ────────────────────────────────────────── - -/** - * Builds a pair of stacked area series that visualize the ensemble spread - * (min-to-max range). The lower bound is rendered invisibly and the upper - * bound delta is stacked on top with a translucent fill. - * - * Returns an array of two series that should be spread into the series list. - */ -export function buildSpreadSeries(params: SpreadSeriesParams): Array> { - const { variable, spreadData, color = CHART_COLORS.spreadArea } = params; - - const lowerBound: Record = { - name: variable + '_spread_lower', - type: 'line', - data: spreadData.map((d) => [d[0], d[1]]), - areaStyle: { - color, - origin: 'auto' - }, - lineStyle: { - width: 0 - }, - showSymbol: false, - stack: 'spread_' + variable, - smooth: true, - z: 1, - silent: true - }; - - const upperDelta: Record = { - name: variable + '_spread_upper', - type: 'line', - data: spreadData.map((d) => [d[0], d[2] - d[1]]), - areaStyle: { - color, - origin: 'auto' - }, - lineStyle: { - width: 0 - }, - showSymbol: false, - stack: 'spread_' + variable, - smooth: true, - z: 1, - silent: true - }; - - return [lowerBound, upperDelta]; -} - -// ─── Data Processing Helpers ───────────────────────────────────────────────── - -export interface AverageResult { - average: number[]; - averageCount: number[]; -} - -export interface SpreadResult { - minValues: (number | undefined)[]; - maxValues: (number | undefined)[]; -} - -/** - * Calculates per-timestep average and count from hourly model data. - * Shared between the Model Compare and 14-Day Forecast pages. - * - * @param hourlyData - The `data.hourly` object from the API response - * @param variable - The variable prefix to filter on (e.g. 'temperature_2m') - * @param timeLength - Number of timesteps - * @returns Object containing running average and count arrays - */ -export function calculateAverage( - hourlyData: Record, - variable: string, - timeLength: number -): AverageResult { - const average = new Array(timeLength).fill(0); - const averageCount = new Array(timeLength).fill(0); - - for (const [model, values] of Object.entries(hourlyData)) { - if (model === 'time') continue; - if (!model.startsWith(variable)) continue; - - for (const [index, val] of (values as number[]).entries()) { - if (val !== null && val !== undefined && 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, - variable: string, - timeLength: number -): SpreadResult { - const minValues = new Array(timeLength).fill(undefined); - const maxValues = new Array(timeLength).fill(undefined); - - for (const [model, values] of Object.entries(hourlyData)) { - if (model === 'time') continue; - if (!model.startsWith(variable)) continue; - - for (const [index, val] of (values as number[]).entries()) { - if (val !== null && val !== undefined) { - if (minValues[index] === undefined || val < minValues[index]!) { - minValues[index] = val; - } - if (maxValues[index] === undefined || val > maxValues[index]!) { - maxValues[index] = val; - } - } - } - } - - return { minValues, maxValues }; -} - -/** - * Finds the unit string for a given variable from the hourly_units map. - * Returns an empty string if the variable is not found. - */ -export function findUnit( - hourlyUnits: Record, - hourlyData: Record, - variable: string -): string { - for (const model of Object.keys(hourlyData)) { - if (model === 'time') continue; - if (model.startsWith(variable) && hourlyUnits[model]) { - return hourlyUnits[model]; - } - } - return ''; -} diff --git a/src/lib/utils/echarts/theme.ts b/src/lib/utils/echarts/theme.ts deleted file mode 100644 index e34d3a1..0000000 --- a/src/lib/utils/echarts/theme.ts +++ /dev/null @@ -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; -} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 73edeb3..2e2057d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -63,7 +63,14 @@
- {@render children()} + {#if fullBleed} + {@render children()} + {:else} + +
+ {@render children()} +
+ {/if}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 33e9f0e..f2bb703 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,3 +1,3 @@ - Open-Meteo Weather + OMbrella diff --git a/src/routes/page.svelte.spec.ts b/src/routes/page.svelte.spec.ts index 08ddddc..1fa08f6 100644 --- a/src/routes/page.svelte.spec.ts +++ b/src/routes/page.svelte.spec.ts @@ -8,6 +8,6 @@ describe('/+page.svelte', () => { render(Page); const title = document.querySelector('title'); - expect(title?.textContent).toBe('Open-Meteo Weather'); + expect(title?.textContent).toBe('OMbrella'); }); }); diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index 9f26483..64752b1 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -1,32 +1,24 @@ @@ -221,20 +194,25 @@ {/if} - - {#each chartOptions as option, i (i)} - - {/each} + + {#if fetchedData} + {#each chartDefs as def, i (i)} + + {/each} + {/if} diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index 7a29abe..93b612d 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -1,28 +1,25 @@ @@ -254,16 +230,25 @@ {/if} - - {#each chartOptions as option, i (i)} - - {/each} + + {#if fetchedData} + {#each chartDefs as def, i (i)} + + {/each} + {/if} {#if fetchedData && !loading} diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 9ea855f..7dfecce 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -82,7 +82,7 @@ timezone: result.timezone, timestamps: result.hourlyTimestamps, hourlyDates: result.hourlyDates, - markAreas: result.markAreas + daylightBands: result.daylightBands }; fetchedDaily = { @@ -102,8 +102,8 @@ - Weather | Open-Meteo.com - + OMbrella | Weather + diff --git a/src/routes/weather/week/[location]/DailyCards.svelte b/src/routes/weather/week/[location]/DailyCards.svelte index 671f9bd..9742154 100644 --- a/src/routes/weather/week/[location]/DailyCards.svelte +++ b/src/routes/weather/week/[location]/DailyCards.svelte @@ -40,7 +40,7 @@
-
+
{#if daily} {#each daily.dailyDates as time, index (index)} {@const selected = isSameDayInZone(time, selectedDay, daily.timezone)} @@ -57,100 +57,90 @@ {@const windDir = daily.daily.winddirection_10m_dominant[index]} {@const unit = String(units.temperature_unit)} {@const maxStyle = getTempStyle(tempMax, unit)} - {@const minStyle = getTempStyle(tempMin, unit)} {#if tempMax != null && !isNaN(tempMax)} - 1h +
+ {#each [3, 1] as interval (interval)} + + {/each}
{#if cellData.length > 0} {@const hourly = data.hourly} {@const iconPx = is3h ? 40 : 26} -
+
@@ -425,7 +427,7 @@
Hourly weather details for {locationName}