diff --git a/README.md b/README.md index 806e050..c8993b0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Our objective is to provide a comprehensive, user-friendly weather platform for - **Framework**: [SvelteKit](https://kit.svelte.dev/) - **Language**: [TypeScript](https://www.typescriptlang.org/) - **Data Source**: [Open-Meteo API](https://open-meteo.com/) -- **Visualization**: [Highcharts](https://www.highcharts.com/) (Current, transitioning to a more flexible charting library in the future) +- **Visualization**: [Apache Echarts](https://echarts.apache.org) ## Developing diff --git a/package-lock.json b/package-lock.json index 0f088c0..f48e1cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "open-meteo-weather", "version": "0.0.1", "dependencies": { - "highcharts": "^12.4.0", + "echarts": "^6.0.0", "mode-watcher": "^1.1.0", "openmeteo": "^1.2.3" }, @@ -2594,6 +2594,22 @@ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "license": "MIT" }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -3063,12 +3079,6 @@ "node": ">=8" } }, - "node_modules/highcharts": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/highcharts/-/highcharts-12.4.0.tgz", - "integrity": "sha512-o6UxxfChSUrvrZUbWrAuqL1HO/+exhAUPcZY6nnqLsadZQlnP16d082sg7DnXKZCk1gtfkyfkp6g3qkIZ9miZg==", - "license": "https://www.highcharts.com/license" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4989,6 +4999,21 @@ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/package.json b/package.json index ee3d27d..f6dff6e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "vitest-browser-svelte": "^2.0.1" }, "dependencies": { - "highcharts": "^12.4.0", + "echarts": "^6.0.0", "mode-watcher": "^1.1.0", "openmeteo": "^1.2.3" } diff --git a/src/lib/components/charts/ChartContainer.svelte b/src/lib/components/charts/ChartContainer.svelte new file mode 100644 index 0000000..c7fa6ec --- /dev/null +++ b/src/lib/components/charts/ChartContainer.svelte @@ -0,0 +1,118 @@ + + + +
+ +
+ {#if children} + {@render children()} + {/if} +
+ + +
+
+ + Loading charts... +
+
+
+ + diff --git a/src/lib/components/charts/ChartToolbar.svelte b/src/lib/components/charts/ChartToolbar.svelte new file mode 100644 index 0000000..ea7a079 --- /dev/null +++ b/src/lib/components/charts/ChartToolbar.svelte @@ -0,0 +1,214 @@ + + + +
+ +
+ {#if controls} + {@render controls()} + {/if} +
+ + +
+ + + + + +
+
+ + diff --git a/src/lib/components/charts/EChart.svelte b/src/lib/components/charts/EChart.svelte new file mode 100644 index 0000000..e56575c --- /dev/null +++ b/src/lib/components/charts/EChart.svelte @@ -0,0 +1,165 @@ + + + +
+ + diff --git a/src/lib/components/charts/echarts.css b/src/lib/components/charts/echarts.css new file mode 100644 index 0000000..1832367 --- /dev/null +++ b/src/lib/components/charts/echarts.css @@ -0,0 +1,164 @@ +/* ═══════════════════════════════════════════════════════════════════════════════ + ECharts Global Styles — Open-Meteo Weather + + Shared CSS for all ECharts chart instances across the application. + Provides consistent theming, tooltip styling, and responsive behavior + that integrates with the application's design system (Tailwind + shadcn). + ═══════════════════════════════════════════════════════════════════════════════ */ + +/* ─── Chart Wrapper ────────────────────────────────────────────────────────── */ + +.echart-wrapper { + width: 100%; + position: relative; + overflow: hidden; +} + +/* ─── Chart Container (legacy class support) ───────────────────────────────── */ + +.echarts-container { + width: 100%; + height: 100%; + min-height: 300px; +} + +/* Ensure canvas background is always transparent so our page bg shows through */ +.echart-wrapper canvas, +.echarts-container canvas { + background: transparent !important; +} + +/* ─── Tooltip Styling ──────────────────────────────────────────────────────── */ + +/* Override ECharts' default tooltip to match the application's popover design */ +.echarts-tooltip { + background: hsl(var(--popover)) !important; + border: 1px solid hsl(var(--border)) !important; + border-radius: var(--radius, 0.5rem) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.05) !important; + padding: 0.625rem 0.75rem !important; + font-size: 0.8125rem !important; + line-height: 1.4 !important; + max-width: min(90vw, 480px) !important; + pointer-events: none; +} + +.echarts-tooltip-content { + color: hsl(var(--popover-foreground)) !important; +} + +/* Tooltip marker dots — make them slightly larger and rounded */ +.echarts-tooltip .echarts-tooltip-marker, +.echarts-tooltip span[style*="border-radius"] { + display: inline-block; + vertical-align: middle; + margin-right: 0.25rem; +} + +/* ─── Loading Mask ─────────────────────────────────────────────────────────── */ + +.echarts-loading-mask { + background: hsl(var(--background) / 0.8) !important; +} + +/* ─── Light Mode Adjustments ───────────────────────────────────────────────── */ + +[data-theme='light'] .echart-wrapper, +[data-theme='light'] .echarts-container, +:root:not(.dark):not([data-theme='dark']) .echart-wrapper, +:root:not(.dark):not([data-theme='dark']) .echarts-container { + color: hsl(var(--foreground)); +} + +[data-theme='light'] .echarts-tooltip, +:root:not(.dark):not([data-theme='dark']) .echarts-tooltip { + color: hsl(var(--popover-foreground)) !important; +} + +/* ─── Dark Mode Adjustments ────────────────────────────────────────────────── */ + +.dark .echart-wrapper, +[data-theme='dark'] .echart-wrapper, +.dark .echarts-container, +[data-theme='dark'] .echarts-container { + color: hsl(var(--foreground)); +} + +.dark .echarts-tooltip, +[data-theme='dark'] .echarts-tooltip { + color: hsl(var(--popover-foreground)) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.3), + 0 2px 4px -2px rgb(0 0 0 / 0.15) !important; +} + +/* ─── Toolbox Icon Overrides ───────────────────────────────────────────────── */ + +/* Make sure the toolbox icons are visually subtle until hovered */ +.echart-wrapper [class*="toolbox"], +.echarts-container [class*="toolbox"] { + opacity: 0.6; + transition: opacity 150ms ease; +} + +.echart-wrapper:hover [class*="toolbox"], +.echarts-container:hover [class*="toolbox"] { + opacity: 1; +} + +/* ─── Chart Spacing ────────────────────────────────────────────────────────── */ + +/* Add consistent vertical spacing between stacked chart instances */ +.chart-content .echart-wrapper + .echart-wrapper { + margin-top: 0.5rem; +} + +/* ─── Responsive Sizing ────────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .echarts-container { + min-height: 240px; + } + + /* Slightly smaller tooltips on mobile */ + .echarts-tooltip { + font-size: 0.75rem !important; + padding: 0.5rem 0.625rem !important; + } +} + +@media (min-width: 641px) and (max-width: 1024px) { + .echarts-container { + min-height: 280px; + } +} + +/* ─── Print Styles ─────────────────────────────────────────────────────────── */ + +@media print { + .echart-wrapper, + .echarts-container { + break-inside: avoid; + page-break-inside: avoid; + } + + /* Hide interactive elements when printing */ + .echart-wrapper [class*="toolbox"], + .echarts-container [class*="toolbox"], + .chart-toolbar { + display: none !important; + } +} + +/* ─── Accessibility ────────────────────────────────────────────────────────── */ + +/* Respect reduced motion preferences */ +@media (prefers-reduced-motion: reduce) { + .echart-wrapper *, + .echarts-container * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/lib/components/charts/index.ts b/src/lib/components/charts/index.ts new file mode 100644 index 0000000..c67aa73 --- /dev/null +++ b/src/lib/components/charts/index.ts @@ -0,0 +1,12 @@ +/** + * Chart Components — Barrel Export + * + * Re-exports all chart-related Svelte components from a single entry point. + * + * Usage: + * import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts'; + */ + +export { default as EChart } from './EChart.svelte'; +export { default as ChartContainer } from './ChartContainer.svelte'; +export { default as ChartToolbar } from './ChartToolbar.svelte'; diff --git a/src/lib/utils/echarts/download.ts b/src/lib/utils/echarts/download.ts new file mode 100644 index 0000000..0f13b48 --- /dev/null +++ b/src/lib/utils/echarts/download.ts @@ -0,0 +1,321 @@ +/** + * 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 new file mode 100644 index 0000000..22732e9 --- /dev/null +++ b/src/lib/utils/echarts/index.ts @@ -0,0 +1,72 @@ +/** + * ECharts Utilities — Barrel Export + * + * Re-exports all ECharts-related utilities from a single entry point. + * + * Usage: + * import { getThemeColors, composeChartOption, buildModelSeries, downloadChart } from '$lib/utils/echarts'; + */ + +// Theme: dark/light detection, color palettes, theme color accessors +export { + SERIES_COLORS, + CHART_COLORS, + isDarkMode, + getThemeColors, + getTextColor, + getAxisLineColor, + getSplitLineColor +} from './theme'; +export type { ThemeColors } from './theme'; + +// Option builders: grid, title, tooltip, legend, axes, toolbox, full composer +export { + buildGrid, + buildTitle, + buildTooltip, + buildLegend, + buildTimeXAxis, + buildValueYAxis, + buildCreditGraphic, + buildToolbox, + composeChartOption, + isColumnUnit +} from './options'; +export type { + GridOptions, + TitleOptions, + LegendOptions, + TooltipOptions, + AxisOptions, + CreditOptions, + BuildGridParams, + ToolboxOptions, + ChartOptionParams +} from './options'; + +// Series builders: model lines, averages, time markers, daylight bands, ensemble spread +export { + buildModelSeries, + buildAverageSeries, + buildCurrentTimeSeries, + buildDaylightMarkAreas, + buildDaylightSeries, + buildSpreadSeries, + calculateAverage, + calculateSpread, + convertTimestamps, + findUnit +} from './series'; +export type { + ModelSeriesParams, + AverageSeriesParams, + CurrentTimeSeriesParams, + DaylightSeriesParams, + SpreadSeriesParams, + AverageResult, + SpreadResult +} from './series'; + +// Download: export charts as PNG or SVG +export { downloadChart, 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 new file mode 100644 index 0000000..bd02ace --- /dev/null +++ b/src/lib/utils/echarts/options.ts @@ -0,0 +1,392 @@ +/** + * ECharts Option Builders + * + * Shared factory functions for constructing common ECharts option fragments. + * These builders ensure visual consistency across all chart pages and reduce + * boilerplate in page-level components. + */ +import { getThemeColors } from './theme'; + +import type { ThemeColors } from './theme'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface GridOptions { + left?: number; + right?: number; + top?: number; + bottom?: number; +} + +export interface TitleOptions { + text: string; + subtext?: string; +} + +export interface LegendOptions { + show: boolean; + data?: string[]; +} + +export interface TooltipOptions { + unit: string; +} + +export interface AxisOptions { + unit?: string; +} + +export interface CreditOptions { + show: boolean; +} + +// ─── Default Constants ─────────────────────────────────────────────────────── + +const DEFAULT_GRID: GridOptions = { + left: 60, + right: 16, + top: 40, + bottom: 40 +}; + +const GRID_WITH_TITLE: Partial = { + top: 80 +}; + +const GRID_WITH_LEGEND: Partial = { + bottom: 60 +}; + +// ─── Grid ──────────────────────────────────────────────────────────────────── + +export interface BuildGridParams { + hasTitle?: boolean; + hasSubtitle?: boolean; + showLegend?: boolean; + overrides?: Partial; +} + +/** + * Builds a grid configuration with sensible defaults. + * Automatically adjusts top/bottom spacing for title and legend presence. + */ +export function buildGrid(params: BuildGridParams = {}): GridOptions { + const { hasTitle = false, hasSubtitle = false, showLegend = false, overrides } = params; + + return { + ...DEFAULT_GRID, + ...(hasTitle ? GRID_WITH_TITLE : {}), + ...(hasSubtitle ? { top: 90 } : {}), + ...(showLegend ? GRID_WITH_LEGEND : {}), + ...overrides + }; +} + +// ─── Title ─────────────────────────────────────────────────────────────────── + +/** + * Builds a title configuration. Pass `null` to hide the title. + */ +export function buildTitle( + options: TitleOptions | null, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + + if (!options) { + return { title: { show: false } }; + } + + const result: Record = { + text: options.text, + left: 'left', + textStyle: { + fontWeight: 'normal', + fontSize: 16, + color: c.text + } + }; + + if (options.subtext) { + result.subtext = options.subtext; + result.subtextStyle = { + fontWeight: 'normal', + fontSize: 12, + color: c.textMuted + }; + } + + return result; +} + +// ─── Tooltip ───────────────────────────────────────────────────────────────── + +/** + * Builds a tooltip with cross-axis pointer and unit-aware value formatting. + */ +export function buildTooltip( + options: TooltipOptions, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + const { unit } = options; + + return { + trigger: 'axis', + axisPointer: { + type: 'cross', + animation: false, + label: { + backgroundColor: c.tooltipBg, + color: c.text, + borderColor: c.tooltipBorder, + borderWidth: 1 + } + }, + backgroundColor: c.tooltipBg, + borderColor: c.tooltipBorder, + textStyle: { + color: c.text + }, + valueFormatter: (value: number) => { + if (value === null || value === undefined) return '-'; + return value.toFixed(1) + ' ' + unit; + } + }; +} + +// ─── Legend ─────────────────────────────────────────────────────────────────── + +/** + * Builds a scrollable legend configuration pinned to the bottom. + */ +export function buildLegend(options: LegendOptions, colors?: ThemeColors): Record { + const c = colors ?? getThemeColors(); + + return { + show: options.show, + bottom: 0, + type: 'scroll', + ...(options.data ? { data: options.data } : {}), + textStyle: { + color: c.text + }, + pageTextStyle: { + color: c.text + } + }; +} + +// ─── X Axis (Time) ─────────────────────────────────────────────────────────── + +/** + * Builds a time-based X axis with theme-aware styling. + */ +export function buildTimeXAxis(colors?: ThemeColors): Record { + const c = colors ?? getThemeColors(); + + return { + type: 'time', + splitLine: { + show: false + }, + axisLine: { + lineStyle: { + color: c.axisLine + } + }, + axisLabel: { + color: c.text, + hideOverlap: true + }, + axisTick: { + lineStyle: { + color: c.axisLine + } + } + }; +} + +// ─── Y Axis (Value) ───────────────────────────────────────────────────────── + +/** + * Builds a value-based Y axis with optional unit label. + */ +export function buildValueYAxis( + options: AxisOptions = {}, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + + return { + type: 'value', + ...(options.unit ? { name: options.unit } : {}), + nameTextStyle: { + color: c.text, + padding: [0, 0, 0, 4] + }, + axisLine: { + show: false + }, + axisLabel: { + color: c.text + }, + splitLine: { + lineStyle: { + color: c.splitLine + } + } + }; +} + +// ─── Credit Watermark ──────────────────────────────────────────────────────── + +/** + * Builds the Open-Meteo.com credit watermark graphic element. + */ +export function buildCreditGraphic(colors?: ThemeColors): Record[] { + const c = colors ?? getThemeColors(); + + return [ + { + type: 'text', + right: 10, + bottom: 5, + style: { + text: 'Open-Meteo.com', + fontSize: 10, + fill: c.text, + opacity: 0.4 + }, + onclick: function () { + window.open('https://open-meteo.com', '_blank'); + }, + cursor: 'pointer' + } + ]; +} + +// ─── Toolbox (Download) ───────────────────────────────────────────────────── + +export interface ToolboxOptions { + /** Show the save-as-image button */ + saveAsImage?: boolean; + /** File name prefix for downloaded images */ + fileName?: string; + /** Export format: 'png' or 'svg' */ + format?: 'png' | 'svg'; +} + +/** + * Builds the ECharts toolbox with download functionality. + */ +export function buildToolbox( + options: ToolboxOptions = {}, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + const { saveAsImage = true, fileName = 'open-meteo-chart', format = 'png' } = options; + + return { + show: true, + right: 16, + top: 4, + iconStyle: { + borderColor: c.textMuted + }, + emphasis: { + iconStyle: { + borderColor: c.text + } + }, + feature: { + ...(saveAsImage + ? { + saveAsImage: { + type: format, + name: fileName, + title: format === 'svg' ? 'Save as SVG' : 'Save as PNG', + pixelRatio: 2, + backgroundColor: 'transparent', + excludeComponents: ['toolbox'], + iconStyle: { + borderColor: c.textMuted + }, + emphasis: { + iconStyle: { + borderColor: c.text + } + } + } + } + : {}) + } + }; +} + +// ─── Full Option Composer ──────────────────────────────────────────────────── + +export interface ChartOptionParams { + title?: TitleOptions | null; + tooltip: TooltipOptions; + legend?: LegendOptions; + grid?: BuildGridParams; + yAxis?: AxisOptions; + series: Array>; + toolbox?: ToolboxOptions | false; + showCredit?: boolean; + colors?: ThemeColors; +} + +/** + * Composes a complete ECharts option object from individual builder params. + * This is the primary entry point for building chart options — it calls all + * the individual builders and merges the results into a single config object. + */ +export function composeChartOption(params: ChartOptionParams): Record { + const colors = params.colors ?? getThemeColors(); + const hasTitle = params.title != null && params.title.text !== ''; + const showLegend = params.legend?.show ?? false; + + const option: Record = { + title: buildTitle(params.title ?? null, colors), + tooltip: buildTooltip(params.tooltip, colors), + legend: buildLegend(params.legend ?? { show: false }, colors), + grid: buildGrid({ + ...params.grid, + hasTitle, + hasSubtitle: hasTitle && !!params.title?.subtext, + showLegend + }), + xAxis: buildTimeXAxis(colors), + yAxis: buildValueYAxis(params.yAxis, colors), + series: params.series, + textStyle: { + color: colors.text + } + }; + + // Add toolbox unless explicitly disabled + if (params.toolbox !== false) { + option.toolbox = buildToolbox(params.toolbox ?? {}, colors); + } + + // Add credit watermark + if (params.showCredit) { + option.graphic = buildCreditGraphic(colors); + } + + return option; +} + +// ─── Utility: Detect column-type variables ─────────────────────────────────── + +/** Units that should be rendered as bar/column charts instead of lines. */ +const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']); + +/** + * Returns true if the given unit should be rendered as a bar chart. + */ +export function isColumnUnit(unit: string): boolean { + return COLUMN_UNITS.has(unit); +} diff --git a/src/lib/utils/echarts/series.ts b/src/lib/utils/echarts/series.ts new file mode 100644 index 0000000..9a84b71 --- /dev/null +++ b/src/lib/utils/echarts/series.ts @@ -0,0 +1,364 @@ +/** + * ECharts Series Builders + * + * Factory functions for constructing common series patterns used across + * weather chart visualizations. These builders encapsulate the styling + * and configuration details so page-level code only needs to provide data. + */ + +import { CHART_COLORS } from './theme'; +import { isColumnUnit } from './options'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ModelSeriesParams { + /** The series name (typically the model key from the API response) */ + name: string; + /** Array of [timestamp, value] data points */ + data: Array<[number, number | null]>; + /** The unit string, used to determine bar vs line rendering */ + unit: string; + /** Optional line width override (default: 2) */ + lineWidth?: number; +} + +export interface AverageSeriesParams { + /** The variable name, used to construct the series name */ + variable: string; + /** Array of [timestamp, value] data points */ + data: Array<[number, number]>; + /** The unit string, used to determine bar vs line rendering */ + unit: string; +} + +export interface CurrentTimeSeriesParams { + /** UTC offset in seconds from the API response */ + utcOffsetSeconds: number; +} + +export interface DaylightSeriesParams { + /** Array of mark area pairs: [[start, end], [start, end], ...] */ + markAreas: Array< + [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }] + >; +} + +export interface SpreadSeriesParams { + /** The variable name, used to construct series names */ + variable: string; + /** Array of [timestamp, min, max] data points */ + spreadData: Array<[number, number, number]>; + /** Optional color for the spread area (default: theme spread color) */ + color?: string; +} + +export interface MarkAreaEntry { + xAxis: number; + itemStyle?: { color: string }; +} + +// ─── Model Series ──────────────────────────────────────────────────────────── + +/** + * Builds a single model series (line or bar depending on the unit). + * Used on the Model Comparison page where each weather model gets its own series. + */ +export function buildModelSeries(params: ModelSeriesParams): Record { + const { name, data, unit, lineWidth = 2 } = params; + const isColumn = isColumnUnit(unit); + + return { + name, + type: isColumn ? 'bar' : 'line', + data, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + width: lineWidth + }, + emphasis: { + lineStyle: { + width: lineWidth + 1 + } + }, + barMaxWidth: 5 + }; +} + +// ─── Average Series ────────────────────────────────────────────────────────── + +/** + * Builds the ensemble/model average series. + * Rendered as a dashed line (or bar) that stands out from individual model lines. + */ +export function buildAverageSeries(params: AverageSeriesParams): Record { + const { variable, data, unit } = params; + const isColumn = isColumnUnit(unit); + + return { + name: variable + '_average', + type: isColumn ? 'bar' : 'line', + data, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + type: 'dashed', + width: 4, + color: CHART_COLORS.average + }, + itemStyle: { + color: CHART_COLORS.average + }, + emphasis: { + lineStyle: { + width: 6 + } + }, + barMaxWidth: 5, + z: 10 + }; +} + +// ─── Current Time Marker ───────────────────────────────────────────────────── + +/** + * Builds a helper series that renders a vertical red line at the current time. + * Uses an empty data series with a markLine to overlay onto the chart. + */ +export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record { + const { utcOffsetSeconds } = params; + + return { + name: 'Current Time', + type: 'line', + data: [], + markLine: { + silent: true, + symbol: 'none', + data: [ + { + xAxis: Date.now() + utcOffsetSeconds * 1000, + lineStyle: { + color: CHART_COLORS.currentTimeLine, + width: 2, + type: 'solid' + }, + label: { + show: false + } + } + ] + } + }; +} + +// ─── Daylight Bands ────────────────────────────────────────────────────────── + +/** + * Builds mark area entries from sunrise/sunset arrays. + * Each entry is a pair of axis markers that ECharts renders as a shaded band. + * + * @param sunrise - Array of sunrise timestamps (unix seconds, without UTC offset) + * @param sunset - Array of sunset timestamps (unix seconds, without UTC offset) + * @param utcOffsetSeconds - UTC offset to apply (from the API response) + */ +export function buildDaylightMarkAreas( + sunrise: number[], + sunset: number[], + utcOffsetSeconds: number +): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> { + return sunrise.map((r: number, i: number) => [ + { + xAxis: (r + utcOffsetSeconds) * 1000, + itemStyle: { + color: CHART_COLORS.daylight + } + }, + { + xAxis: (sunset[i] + utcOffsetSeconds) * 1000 + } + ]); +} + +/** + * Builds a helper series that renders day/night shading bands via markArea. + * Returns null if no mark areas are provided (so callers can filter it out). + */ +export function buildDaylightSeries( + params: DaylightSeriesParams +): Record | null { + if (params.markAreas.length === 0) return null; + + return { + name: 'Daylight', + type: 'line', + data: [], + markArea: { + silent: true, + data: params.markAreas + } + }; +} + +// ─── Ensemble Spread (Min/Max Area) ────────────────────────────────────────── + +/** + * Builds a pair of stacked area series that visualize the ensemble spread + * (min-to-max range). The lower bound is rendered invisibly and the upper + * bound delta is stacked on top with a translucent fill. + * + * Returns an array of two series that should be spread into the series list. + */ +export function buildSpreadSeries(params: SpreadSeriesParams): Array> { + const { variable, spreadData, color = CHART_COLORS.spreadArea } = params; + + const lowerBound: Record = { + name: variable + '_spread_lower', + type: 'line', + data: spreadData.map((d) => [d[0], d[1]]), + areaStyle: { + color, + origin: 'auto' + }, + lineStyle: { + width: 0 + }, + showSymbol: false, + stack: 'spread_' + variable, + smooth: true, + z: 1, + silent: true + }; + + const upperDelta: Record = { + name: variable + '_spread_upper', + type: 'line', + data: spreadData.map((d) => [d[0], d[2] - d[1]]), + areaStyle: { + color, + origin: 'auto' + }, + lineStyle: { + width: 0 + }, + showSymbol: false, + stack: 'spread_' + variable, + smooth: true, + z: 1, + silent: true + }; + + return [lowerBound, upperDelta]; +} + +// ─── Data Processing Helpers ───────────────────────────────────────────────── + +export interface AverageResult { + average: number[]; + averageCount: number[]; +} + +export interface SpreadResult { + minValues: (number | undefined)[]; + maxValues: (number | undefined)[]; +} + +/** + * Calculates per-timestep average and count from hourly model data. + * Shared between the Model Compare and 14-Day Forecast pages. + * + * @param hourlyData - The `data.hourly` object from the API response + * @param variable - The variable prefix to filter on (e.g. 'temperature_2m') + * @param timeLength - Number of timesteps + * @returns Object containing running average and count arrays + */ +export function calculateAverage( + hourlyData: Record, + variable: string, + timeLength: number +): AverageResult { + const average = new Array(timeLength).fill(0); + const averageCount = new Array(timeLength).fill(0); + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + for (const [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined) { + average[index] += val; + averageCount[index]++; + } + } + } + + // Finalize average values + for (let i = 0; i < timeLength; i++) { + if (averageCount[i] > 0) { + average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10; + } + } + + return { average, averageCount }; +} + +/** + * Calculates per-timestep min and max values from hourly ensemble data. + * Used by the 14-Day Forecast page to render the ensemble spread. + * + * @param hourlyData - The `data.hourly` object from the API response + * @param variable - The variable prefix to filter on + * @param timeLength - Number of timesteps + * @returns Object containing min and max value arrays + */ +export function calculateSpread( + hourlyData: Record, + variable: string, + timeLength: number +): SpreadResult { + const minValues = new Array(timeLength).fill(undefined); + const maxValues = new Array(timeLength).fill(undefined); + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + for (const [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined) { + if (minValues[index] === undefined || val < minValues[index]!) { + minValues[index] = val; + } + if (maxValues[index] === undefined || val > maxValues[index]!) { + maxValues[index] = val; + } + } + } + } + + return { minValues, maxValues }; +} + +/** + * Converts raw unix timestamps (seconds) to millisecond timestamps with UTC offset applied. + */ +export function convertTimestamps(times: number[], utcOffsetSeconds: number): number[] { + return times.map((t) => (t + utcOffsetSeconds) * 1000); +} + +/** + * Finds the unit string for a given variable from the hourly_units map. + * Returns an empty string if the variable is not found. + */ +export function findUnit( + hourlyUnits: Record, + hourlyData: Record, + variable: string +): string { + for (const model of Object.keys(hourlyData)) { + if (model === 'time') continue; + if (model.startsWith(variable) && hourlyUnits[model]) { + return hourlyUnits[model]; + } + } + return ''; +} diff --git a/src/lib/utils/echarts/theme.ts b/src/lib/utils/echarts/theme.ts new file mode 100644 index 0000000..e34d3a1 --- /dev/null +++ b/src/lib/utils/echarts/theme.ts @@ -0,0 +1,106 @@ +/** + * ECharts Theme Utilities + * + * Centralized dark/light mode detection and color helpers for consistent + * chart theming across all ECharts visualizations. + */ + +// ─── Color Palette ─────────────────────────────────────────────────────────── + +/** Default series color palette matching the application's design system */ +export const SERIES_COLORS = [ + '#5470c6', + '#91cc75', + '#fac858', + '#ee6666', + '#73c0de', + '#3ba272', + '#fc8452', + '#9a60b4', + '#ea7ccc', + '#4dc9f6' +] as const; + +/** Semantic colors used for specific chart elements */ +export const CHART_COLORS = { + average: '#5e5e5e', + currentTimeLine: '#ef4444', + daylight: 'rgba(255, 255, 194, 0.3)', + spreadArea: 'rgba(173, 216, 230, 0.3)', + creditText: { light: '#374151', dark: '#e5e7eb' } +} as const; + +// ─── Dark Mode Detection ───────────────────────────────────────────────────── + +/** + * Detects whether the application is currently in dark mode. + * Checks multiple sources: HTML class, data-theme attribute, and media query. + */ +export function isDarkMode(): boolean { + if (typeof document === 'undefined') return false; + + const html = document.documentElement; + const dataTheme = html.getAttribute('data-theme'); + + // Explicit data-theme takes priority + if (dataTheme === 'dark') return true; + if (dataTheme === 'light') return false; + + // Check for dark class (e.g. Tailwind dark mode) + if (html.classList.contains('dark')) return true; + + // Fall back to system preference + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; +} + +// ─── Theme Colors ──────────────────────────────────────────────────────────── + +export interface ThemeColors { + text: string; + textMuted: string; + axisLine: string; + splitLine: string; + background: string; + tooltipBg: string; + tooltipBorder: string; +} + +const LIGHT_COLORS: ThemeColors = { + text: '#374151', + textMuted: 'rgba(55, 65, 81, 0.6)', + axisLine: 'rgba(55, 65, 81, 0.3)', + splitLine: 'rgba(55, 65, 81, 0.1)', + background: 'transparent', + tooltipBg: '#ffffff', + tooltipBorder: '#e5e7eb' +}; + +const DARK_COLORS: ThemeColors = { + text: '#e5e7eb', + textMuted: 'rgba(229, 231, 235, 0.6)', + axisLine: 'rgba(229, 231, 235, 0.3)', + splitLine: 'rgba(229, 231, 235, 0.1)', + background: 'transparent', + tooltipBg: '#1f2937', + tooltipBorder: '#374151' +}; + +/** + * Returns the full set of theme colors based on current dark/light mode. + */ +export function getThemeColors(): ThemeColors { + return isDarkMode() ? DARK_COLORS : LIGHT_COLORS; +} + +/** Shorthand helpers kept for backward compatibility and convenience */ +export function getTextColor(): string { + return getThemeColors().text; +} + +export function getAxisLineColor(): string { + return getThemeColors().axisLine; +} + +export function getSplitLineColor(): string { + return getThemeColors().splitLine; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..189f71e --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1 @@ +export const prerender = true; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 57e6aad..af09426 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -209,7 +209,7 @@
- {#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Highcharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)} + {#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Apache ECharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)}
{ throw redirect(303, '/weather/week/'); }) satisfies PageLoad; diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index 85227ab..60b6772 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -1,28 +1,46 @@ - -
-
-
- - - - -
-
+ -
-
-
- { - params.hourly = params.hourly; - }} - /> - -
-
- { - params.hourly = params.hourly; - }} - /> - -
-
+ + {#each chartOptions as option, i (i)} + + {/each} + + + + +
+ + {#snippet controls()} +
+ + +
+ {/snippet} +
diff --git a/src/routes/weather/14-day/options.ts b/src/routes/weather/14-day/options.ts deleted file mode 100644 index 970b98d..0000000 --- a/src/routes/weather/14-day/options.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Default configuration for 14-day ensemble forecast charts -export const defaultParameters = { - timeformat: 'iso8601', - wind_speed_unit: 'kmh', - temperature_unit: 'celsius', - precipitation_unit: 'mm' -}; diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index 25406ca..6a1172d 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -3,27 +3,45 @@ import { get } from 'svelte/store'; import { fade } from 'svelte/transition'; - import { dev } from '$app/environment'; - import { storedLocation } from '$lib/stores/settings'; + import { + buildAverageSeries, + buildCurrentTimeSeries, + buildDaylightMarkAreas, + buildDaylightSeries, + buildModelSeries, + calculateAverage, + composeChartOption, + convertTimestamps, + findUnit, + getThemeColors + } from '$lib/utils/echarts'; + + import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts'; + import '$lib/components/charts/echarts.css'; import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; import { hourly, models as modelsFlat } from '../options'; - import './highcharts.css'; - import { defaultParameters } from './options'; + import { defaultParameters } from '../options'; + + import type * as echarts from 'echarts'; - // Wrap models in array to match template expectation of nested arrays like hourly const models = [modelsFlat]; - let node: HTMLElement; - let chart: any; - let Highcharts = $state(null); + // ─── Display State (does NOT trigger data re-fetch) ───────────────────────── let showLegend = $state(false); - let averageOnly = $state(false); + + // ─── Data Fetch State ─────────────────────────────────────────────────────── + + let chartComponents: EChart[] = $state([]); + let chartInstances: echarts.ECharts[] = $state([]); + let chartOptions: Array> = $state([]); + let mounted = $state(false); + let loading = $state(true); const location = get(storedLocation); @@ -41,300 +59,210 @@ ] }); - let count = $state(0); - onMount(async () => { - /// Highcharts needs to be loaded in `onMount` to work with prerendered SSG - Highcharts = (await import('highcharts')).default; + // ─── Cached API Response ──────────────────────────────────────────────────── - if (dev) { - // const HighchartsDebugger = await import('highcharts/modules/debugger'); - // HighchartsDebugger.default(Highcharts); - const Debugger = ( - await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any) - ).default; - const ErrorMessages = ( - await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any) - ).default; - if (Highcharts) { - (Highcharts as any).errorMessages = ErrorMessages; - Debugger.compose(Highcharts.Chart); - } - } + interface FetchedData { + hourly: Record; + hourly_units: Record; + utc_offset_seconds: number; + markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + timestamps: number[]; + } + + let fetchedData: FetchedData | null = $state(null); + + // ─── Lifecycle ────────────────────────────────────────────────────────────── + + onMount(() => { + mounted = true; }); + onDestroy(() => { + chartInstances = []; + chartOptions = []; + chartComponents = []; + }); + + // ─── Chart Instance Tracking ──────────────────────────────────────────────── + + function handleChartReady(chart: echarts.ECharts): void { + chartInstances = [...chartInstances, chart]; + } + + // ─── Data Fetching (only when params.hourly or params.models change) ─────── + $effect(() => { + const hourlyVars = params.hourly; + const modelList = params.models; + + if (!mounted || !hourlyVars?.length || !modelList?.length) return; + const loadData = async () => { - count = 0; - if (Highcharts) { - node.replaceChildren(); + loading = true; + chartInstances = []; + chartComponents = []; - const dataReq = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise` - ); - const data = await dataReq.json(); + const dataReq = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise` + ); + const data = await dataReq.json(); + let markAreas: FetchedData['markAreas'] = []; + + if ('daily' in data) { let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_'); dailyFirstModelKey.shift(); dailyFirstModelKey = dailyFirstModelKey.join('_'); - let plotBands: any = []; - if ( - 'daily' in data && - 'sunrise_' + dailyFirstModelKey in data.daily && - 'sunset_' + dailyFirstModelKey in data.daily - ) { - let rise = data.daily['sunrise_' + dailyFirstModelKey]; - let set = data.daily['sunset_' + dailyFirstModelKey]; - plotBands = rise.map(function (r: any, i: number) { - return { - color: 'rgba(255, 255, 194, 0.5)', - from: (r + data.utc_offset_seconds) * 1000, - to: (set[i] + data.utc_offset_seconds) * 1000 - }; - }); - } + const sunriseKey = 'sunrise_' + dailyFirstModelKey; + const sunsetKey = 'sunset_' + dailyFirstModelKey; - for (let variable of params.hourly || []) { - const chartDiv = document.createElement('div'); - - let unit; - - let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000; - let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000; - - const series = []; - let average = new Array(data.hourly.time.length).fill(0); - let averageCount = new Array(data.hourly.time.length).fill(0); - - for (let [model, values] of Object.entries(data.hourly)) { - if (model === 'time') { - continue; - } - if (model.startsWith(variable)) { - for (let [index, val] of (values as any[]).entries()) { - if (val) { - let avVal = average[index]; - average[index] = avVal + val; - averageCount[index]++; - } - } - - unit = data.hourly_units[model]; - - if (!averageOnly) { - series.push({ - name: model, - data: values, - type: - unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' - ? 'column' - : 'spline', - tooltip: { - valueSuffix: ' ' + unit - }, - pointStart: hourly_starttime, - pointInterval: pointInterval - }); - } - } - } - - for (let [index, val] of average.entries()) { - average[index] = Math.round((val / averageCount[index]) * 10) / 10; - } - - series.push({ - name: variable + '_average', - data: average, - dashStyle: 'ShortDashDot', - color: '#5e5e5e', - type: - unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' - ? 'column' - : 'spline', - tooltip: { - valueSuffix: ' ' + unit - }, - lineWidth: 4, - states: { - hover: { - lineWidth: 6 - } - }, - pointStart: hourly_starttime, - pointInterval: pointInterval, - className: 'highcharts-average-series' - }); - - new Highcharts!.Chart({ - chart: { - renderTo: chartDiv, - height: showLegend ? '400px' : '300px', - styledMode: true, - marginLeft: 50, - marginRight: 0 - }, - - credits: { - text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '', - href: 'http://open-meteo.com' - }, - - lang: { - locale: 'en-GB' - }, - - title: { - text: count === 0 ? 'Model Compare' : '', - align: 'left' - }, - - subtitle: { - text: - count === 0 - ? `Compare ${params.hourly?.join(', ') || ''} in models: ` + - (params.models?.join(', ') || '') + - '' - : '', - align: 'left' - }, - - yAxis: { - title: { - text: unit - } - }, - - xAxis: { - type: 'datetime', - plotLines: [ - { - value: Date.now() + data.utc_offset_seconds * 1000, - color: 'red', - width: 2 - } - ], - plotBands: plotBands - }, - - plotOptions: { - spline: { - lineWidth: 2, - states: { - hover: { - lineWidth: 3 - } - }, - marker: { - enabled: false - } - }, - column: { - pointWidth: 5 - } - }, - - legend: { - enabled: showLegend, - layout: 'horizontal', - align: 'center', - verticalAlign: 'bottom' - }, - - series: series as any, - - responsive: { - rules: [ - { - condition: { - maxWidth: 800 - } - } - ] - }, - - tooltip: { - shared: true, - animation: false - } - }); - - count++; - node.appendChild(chartDiv); + if (sunriseKey in data.daily && sunsetKey in data.daily) { + markAreas = buildDaylightMarkAreas( + data.daily[sunriseKey], + data.daily[sunsetKey], + data.utc_offset_seconds + ); } } + + const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); + + fetchedData = { + hourly: data.hourly, + hourly_units: data.hourly_units, + utc_offset_seconds: data.utc_offset_seconds, + markAreas, + timestamps + }; + + loading = false; }; + loadData(); }); - onDestroy(() => { - if (chart) { - chart.destroy(); + // ─── Chart Option Building (runs when fetchedData OR display toggles change) ─ + + $effect(() => { + if (!fetchedData) return; + + const { + hourly: hourlyData, + hourly_units, + utc_offset_seconds, + markAreas, + timestamps + } = fetchedData; + const _showLegend = showLegend; + + const colors = getThemeColors(); + const variableCount = params.hourly?.length || 0; + const timeLength = (hourlyData.time as number[]).length; + const newOptions: Array> = []; + + for (let vi = 0; vi < variableCount; vi++) { + const variable = params.hourly![vi]; + const unit = findUnit(hourly_units, hourlyData, variable); + + const series: Array> = []; + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + const seriesData = (values as (number | null)[]).map( + (val, idx) => [timestamps[idx], val] as [number, number | null] + ); + + series.push( + buildModelSeries({ + name: model, + data: seriesData, + unit + }) + ); + } + + const { average } = calculateAverage(hourlyData, variable, timeLength); + const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]); + series.push(buildAverageSeries({ variable, data: averageData, unit })); + + series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds })); + + const daylightSeries = buildDaylightSeries({ markAreas }); + if (daylightSeries) { + series.push(daylightSeries); + } + + const isFirst = vi === 0; + const isLast = vi === variableCount - 1; + + const option = composeChartOption({ + title: isFirst + ? { + text: 'Model Compare', + subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` + } + : null, + tooltip: { unit }, + legend: { show: _showLegend }, + grid: { + hasTitle: isFirst, + hasSubtitle: isFirst, + showLegend: _showLegend + }, + yAxis: { unit }, + series, + toolbox: false, + showCredit: isLast, + colors + }); + + newOptions.push(option); } + + chartOptions = newOptions; }); - -
+ + -
-
- - - - -
+ {#each chartOptions as option, i (i)} + + {/each} +
+ + + +
+ + {#snippet controls()} +
+ + +
+ {/snippet} +
-
-
-
- { - params.hourly = params.hourly; - }} - /> - -
-
- { - params.hourly = params.hourly; - }} - /> - -
-
-
+

Models

{#if params.models && params.models.length > 0} -
+
@@ -362,8 +290,7 @@ return item !== value; }); } else if (params.models) { - params.models.push(value); - params.models = params.models; + params.models = [...params.models, value]; } }} /> @@ -378,7 +305,8 @@ {/each}
- + +
{#if params.hourly && params.hourly.length > 0} -
+
@@ -416,8 +344,7 @@ return item !== value; }); } else if (params.hourly) { - params.hourly.push(value); - params.hourly = params.hourly; + params.hourly = [...params.hourly, value]; } }} /> diff --git a/src/routes/weather/compare/highcharts.css b/src/routes/weather/compare/highcharts.css deleted file mode 100644 index 3463cb2..0000000 --- a/src/routes/weather/compare/highcharts.css +++ /dev/null @@ -1,1172 +0,0 @@ -:root, -.highcharts-light { - --highcharts-color-0: #2caffe; - --highcharts-color-1: #544fc5; - --highcharts-color-2: #00e272; - --highcharts-color-3: #fe6a35; - --highcharts-color-4: #6b8abc; - --highcharts-color-5: #d568fb; - --highcharts-color-6: #2ee0ca; - --highcharts-color-7: #fa4b42; - --highcharts-color-8: #feb56a; - --highcharts-color-9: #91e8e1; - --highcharts-background-color: #ffffff; - --highcharts-tooltip-color: #f6f6f6; - --highcharts-neutral-color-100: #000000; - --highcharts-neutral-color-80: #333333; - --highcharts-neutral-color-60: #666666; - --highcharts-neutral-color-40: #999999; - --highcharts-neutral-color-20: #cccccc; - --highcharts-neutral-color-10: #e6e6e6; - --highcharts-neutral-color-5: #f2f2f2; - --highcharts-neutral-color-3: #f7f7f7; - --highcharts-highlight-color-100: #0022ff; - --highcharts-highlight-color-80: #334eff; - --highcharts-highlight-color-60: #667aff; - --highcharts-highlight-color-20: #ccd3ff; - --highcharts-highlight-color-10: #e6e9ff; - --highcharts-positive-color: #06b535; - --highcharts-negative-color: #f21313; -} - -@media (prefers-color-scheme: dark) { - :root { - --highcharts-background-color: rgb(2, 8, 23); - --highcharts-neutral-color-100: rgb(255, 255, 255); - --highcharts-neutral-color-80: rgb(248, 250, 252); - --highcharts-neutral-color-60: rgb(173, 173, 173); - --highcharts-neutral-color-40: rgb(133, 133, 133); - --highcharts-neutral-color-20: rgb(92, 92, 92); - --highcharts-neutral-color-10: rgb(71, 71, 71); - --highcharts-neutral-color-5: rgb(61, 61, 61); - --highcharts-neutral-color-3: rgb(57, 57, 57); - --highcharts-highlight-color-100: rgb(122, 167, 255); - --highcharts-highlight-color-80: rgb(108, 144, 214); - --highcharts-highlight-color-60: rgb(94, 121, 173); - --highcharts-highlight-color-20: rgb(65, 74, 92); - --highcharts-highlight-color-10: rgb(58, 63, 71); - } -} - -.highcharts-dark { - /*--highcharts-background-color: rgb(48, 48, 48);*/ - --highcharts-background-color: rgb(2, 8, 23); - --highcharts-tooltip-color: rgb(57, 64, 70); - --highcharts-neutral-color-100: rgb(255, 255, 255); - --highcharts-neutral-color-80: rgb(214, 214, 214); - --highcharts-neutral-color-60: rgb(173, 173, 173); - --highcharts-neutral-color-40: rgb(133, 133, 133); - --highcharts-neutral-color-20: rgb(92, 92, 92); - --highcharts-neutral-color-10: rgb(71, 71, 71); - --highcharts-neutral-color-5: rgb(61, 61, 61); - --highcharts-neutral-color-3: rgb(57, 57, 57); - --highcharts-highlight-color-100: rgb(122, 167, 255); - --highcharts-highlight-color-80: rgb(108, 144, 214); - --highcharts-highlight-color-60: rgb(94, 121, 173); - --highcharts-highlight-color-20: rgb(65, 74, 92); - --highcharts-highlight-color-10: rgb(58, 63, 71); -} - -.highcharts-container { - position: relative; - overflow: hidden; - width: 100%; - height: 100%; - text-align: left; - line-height: normal; - z-index: 0; - -webkit-tap-highlight-color: transparent; - font-family: Helvetica, Arial, sans-serif; - font-size: 1rem; - user-select: none; - touch-action: manipulation; - outline: none; -} - -.highcharts-root { - display: block; -} - -.highcharts-root text { - stroke-width: 0; -} - -.highcharts-strong { - font-weight: 700; -} - -.highcharts-emphasized { - font-style: italic; -} - -.highcharts-anchor { - cursor: pointer; -} - -.highcharts-background { - fill: var(--highcharts-background-color); -} - -.highcharts-plot-border, -.highcharts-plot-background { - fill: none; -} - -.highcharts-label-box { - fill: none; -} - -.highcharts-label text { - fill: var(--highcharts-neutral-color-80); - font-size: 0.8em; -} - -.highcharts-button-box { - fill: inherit; -} - -.highcharts-tracker-line { - stroke-linejoin: round; - stroke: rgba(192, 192, 192, 0.0001); - stroke-width: 22; - fill: none; -} - -.highcharts-tracker-area { - fill: rgba(192, 192, 192, 0.0001); - stroke-width: 0; -} - -.highcharts-title { - fill: var(--highcharts-neutral-color-80); - font-size: 1.2em; - font-weight: 700; -} - -.highcharts-subtitle { - fill: var(--highcharts-neutral-color-60); - font-size: 0.8em; -} - -.highcharts-axis-line { - fill: none; - stroke: var(--highcharts-neutral-color-80); -} - -.highcharts-yaxis .highcharts-axis-line { - stroke-width: 0; -} - -.highcharts-axis-title { - fill: var(--highcharts-neutral-color-60); - font-size: 0.8em; -} - -.highcharts-axis-labels { - fill: var(--highcharts-neutral-color-80); - cursor: default; - font-size: 0.65em; -} - -.highcharts-grid-line { - fill: none; - stroke: var(--highcharts-neutral-color-10); -} - -.highcharts-xaxis-grid .highcharts-grid-line { - stroke-width: 0; -} - -.highcharts-tick { - stroke: var(--highcharts-neutral-color-80); -} - -.highcharts-yaxis .highcharts-tick { - stroke-width: 0; -} - -.highcharts-minor-grid-line { - stroke: var(--highcharts-neutral-color-5); -} - -.highcharts-crosshair-thin { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-crosshair-category { - stroke: var(--highcharts-highlight-color-20); - stroke-opacity: 0.25; -} - -.highcharts-credits { - cursor: pointer; - fill: var(--highcharts-neutral-color-40); - font-size: 0.6em; - transition: - fill 250ms, - font-size 250ms; -} - -.highcharts-credits:hover { - fill: var(--highcharts-neutral-color-100); - font-size: 0.7em; -} - -.highcharts-tooltip { - cursor: default; - pointer-events: none; - white-space: nowrap; - transition: stroke 150ms; -} - -.highcharts-tooltip text { - fill: var(--highcharts-neutral-color-80); - font-size: 0.8em; -} - -.highcharts-tooltip .highcharts-header { - font-size: 0.8em; -} - -.highcharts-tooltip-box { - stroke-width: 1px; - fill: var(--highcharts-tooltip-color); -} - -.highcharts-tooltip-box { - stroke-width: 0; - fill: var(--highcharts-tooltip-color); -} - -.highcharts-tooltip-box .highcharts-label-box { - fill: var(--highcharts-tooltip-color); -} - -div.highcharts-tooltip { - filter: none; - font-size: 0.8em; -} - -.highcharts-selection-marker { - fill: var(--highcharts-highlight-color-80); - fill-opacity: 0.25; -} - -.highcharts-graph { - fill: none; - stroke-width: 1.5px; - stroke-linecap: round; - stroke-linejoin: round; -} - -.highcharts-empty-series { - stroke-width: 1px; - fill: none; - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-state-hover .highcharts-graph { - stroke-width: 3; -} - -.highcharts-point-inactive { - opacity: 0.2; - transition: opacity 50ms; -} - -.highcharts-series-inactive { - opacity: 0.2; - transition: opacity 50ms; -} - -.highcharts-state-hover path { - transition: stroke-width 50ms; -} - -.highcharts-state-normal path { - transition: stroke-width 250ms; -} - -g.highcharts-series, -.highcharts-point, -.highcharts-markers, -.highcharts-data-labels { - transition: opacity 250ms; -} - -.highcharts-legend-series-active g.highcharts-series:not(.highcharts-series-hover), -.highcharts-legend-point-active .highcharts-point:not(.highcharts-point-hover), -.highcharts-legend-series-active .highcharts-markers:not(.highcharts-series-hover), -.highcharts-legend-series-active .highcharts-data-labels:not(.highcharts-series-hover) { - opacity: 0.2; -} - -.highcharts-color-0 { - fill: var(--highcharts-color-0); - stroke: var(--highcharts-color-0); -} - -.highcharts-color-1 { - fill: var(--highcharts-color-1); - stroke: var(--highcharts-color-1); -} - -.highcharts-color-2 { - fill: var(--highcharts-color-2); - stroke: var(--highcharts-color-2); -} - -.highcharts-color-3 { - fill: var(--highcharts-color-3); - stroke: var(--highcharts-color-3); -} - -.highcharts-color-4 { - fill: var(--highcharts-color-4); - stroke: var(--highcharts-color-4); -} - -.highcharts-color-5 { - fill: var(--highcharts-color-5); - stroke: var(--highcharts-color-5); -} - -.highcharts-color-6 { - fill: var(--highcharts-color-6); - stroke: var(--highcharts-color-6); -} - -.highcharts-color-7 { - fill: var(--highcharts-color-7); - stroke: var(--highcharts-color-7); -} - -.highcharts-color-8 { - fill: var(--highcharts-color-8); - stroke: var(--highcharts-color-8); -} - -.highcharts-color-9 { - fill: var(--highcharts-color-9); - stroke: var(--highcharts-color-9); -} - -.highcharts-area { - fill-opacity: 0.75; - stroke-width: 0; -} - -.highcharts-markers { - stroke-width: 1px; - stroke: var(--highcharts-background-color); -} - -.highcharts-a11y-markers-hidden - .highcharts-point:not(.highcharts-point-hover, .highcharts-a11y-marker-visible), -.highcharts-a11y-marker-hidden { - opacity: 0; -} - -.highcharts-point { - stroke-width: 1px; -} - -.highcharts-dense-data .highcharts-point { - stroke-width: 0; -} - -.highcharts-data-label text, -text.highcharts-data-label { - font-size: 0.7em; - font-weight: 700; -} - -.highcharts-data-label-box { - fill: none; - stroke-width: 0; -} - -.highcharts-data-label text, -text.highcharts-data-label { - fill: var(--highcharts-neutral-color-80); -} - -.highcharts-data-label-connector { - fill: none; -} - -.highcharts-data-label-hidden { - pointer-events: none; -} - -.highcharts-halo { - fill-opacity: 0.25; - stroke-width: 0; -} - -.highcharts-series-label text { - fill: inherit; - font-weight: 700; -} - -.highcharts-series:not(.highcharts-pie-series) .highcharts-point-select, -.highcharts-markers .highcharts-point-select { - fill: var(--highcharts-neutral-color-20); - stroke: var(--highcharts-neutral-color-100); -} - -.highcharts-column-series rect.highcharts-point { - stroke: var(--highcharts-background-color); -} - -.highcharts-column-series .highcharts-point { - transition: fill-opacity 250ms; -} - -.highcharts-column-series .highcharts-point-hover { - fill-opacity: 0.75; - transition: fill-opacity 50ms; -} - -.highcharts-pie-series .highcharts-point { - stroke-linejoin: round; - stroke: var(--highcharts-background-color); -} - -.highcharts-pie-series .highcharts-point-hover { - fill-opacity: 0.75; - transition: fill-opacity 50ms; -} - -.highcharts-funnel-series .highcharts-point { - stroke-linejoin: round; - stroke: var(--highcharts-background-color); -} - -.highcharts-funnel-series .highcharts-point-hover { - fill-opacity: 0.75; - transition: fill-opacity 50ms; -} - -.highcharts-funnel-series .highcharts-point-select { - fill: inherit; - stroke: inherit; -} - -.highcharts-pyramid-series .highcharts-point { - stroke-linejoin: round; - stroke: var(--highcharts-background-color); -} - -.highcharts-pyramid-series .highcharts-point-hover { - fill-opacity: 0.75; - transition: fill-opacity 50ms; -} - -.highcharts-pyramid-series .highcharts-point-select { - fill: inherit; - stroke: inherit; -} - -.highcharts-solidgauge-series .highcharts-point { - stroke-width: 0; -} - -.highcharts-treemap-series .highcharts-point { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-10); - transition: - stroke 250ms, - fill 250ms, - fill-opacity 250ms; -} - -.highcharts-treemap-series .highcharts-point-hover { - stroke: var(--highcharts-neutral-color-40); - transition: - stroke 25ms, - fill 25ms, - fill-opacity 25ms; -} - -.highcharts-treemap-series .highcharts-above-level { - display: none; -} - -.highcharts-treemap-series .highcharts-internal-node { - fill: none; -} - -.highcharts-treemap-series .highcharts-internal-node-interactive { - fill-opacity: 0.15; - cursor: pointer; -} - -.highcharts-treemap-series .highcharts-internal-node-interactive:hover { - fill-opacity: 0.75; -} - -.highcharts-vector-series .highcharts-point { - fill: none; - stroke-width: 2px; -} - -.highcharts-windbarb-series .highcharts-point { - fill: none; - stroke-width: 2px; -} - -.highcharts-lollipop-stem { - stroke: var(--highcharts-neutral-color-100); -} - -.highcharts-focus-border { - fill: none; - stroke-width: 2px; -} - -.highcharts-legend-item-hidden .highcharts-focus-border { - fill: none !important; -} - -.highcharts-legend-box { - fill: none; - stroke-width: 0; -} - -.highcharts-legend-item > text { - fill: var(--highcharts-neutral-color-80); - font-weight: 700; - font-size: 0.8em; - cursor: pointer; - stroke-width: 0; -} - -.highcharts-legend-item:hover text { - fill: var(--highcharts-neutral-color-100); -} - -.highcharts-legend-item-hidden * { - fill: var(--highcharts-neutral-color-60) !important; - stroke: var(--highcharts-neutral-color-60) !important; - transition: fill 250ms; - text-decoration: line-through; -} - -.highcharts-legend-nav-active { - fill: var(--highcharts-highlight-color-100); - cursor: pointer; -} - -.highcharts-legend-nav-inactive { - fill: var(--highcharts-neutral-color-20); -} - -circle.highcharts-legend-nav-active, -circle.highcharts-legend-nav-inactive { - fill: rgba(192, 192, 192, 0.0001); -} - -.highcharts-legend-title-box { - fill: none; - stroke-width: 0; -} - -.highcharts-bubble-legend-symbol { - stroke-width: 2; - fill-opacity: 0.5; -} - -.highcharts-bubble-legend-connectors { - stroke-width: 1; -} - -.highcharts-bubble-legend-labels { - fill: var(--highcharts-neutral-color-80); - font-size: 0.7em; -} - -.highcharts-loading { - position: absolute; - background-color: var(--highcharts-background-color); - opacity: 0.5; - text-align: center; - z-index: 10; - transition: opacity 250ms; -} - -.highcharts-loading-hidden { - height: 0 !important; - opacity: 0; - overflow: hidden; - transition: - opacity 250ms, - height 250ms step-end; -} - -.highcharts-loading-inner { - font-weight: 700; - position: relative; - top: 45%; -} - -.highcharts-plot-band, -.highcharts-pane { - fill: rgba(255, 255, 194, 0.5); -} - -.highcharts-dark .highcharts-plot-band, -.highcharts-dark .highcharts-pane { - fill: rgba(255, 255, 194, 0.1); -} - -.highcharts-plot-line { - fill: none; - stroke: var(--highcharts-neutral-color-40); - stroke-width: 1px; -} - -.highcharts-plot-line-label { - font-size: 0.8em; -} - -.highcharts-boxplot-box { - fill: var(--highcharts-background-color); -} - -.highcharts-boxplot-median { - stroke-width: 2px; -} - -.highcharts-bubble-series .highcharts-point { - fill-opacity: 0.5; -} - -.highcharts-errorbar-series .highcharts-point { - stroke: var(--highcharts-neutral-color-100); -} - -.highcharts-gauge-series .highcharts-data-label-box { - stroke: var(--highcharts-neutral-color-20); - stroke-width: 1px; -} - -.highcharts-gauge-series .highcharts-dial { - fill: var(--highcharts-neutral-color-100); - stroke-width: 0; -} - -.highcharts-polygon-series .highcharts-graph { - fill: inherit; - stroke-width: 0; -} - -.highcharts-waterfall-series .highcharts-graph { - stroke: var(--highcharts-neutral-color-80); - stroke-dasharray: 1, 3; -} - -.highcharts-sankey-series .highcharts-point { - stroke-width: 0; -} - -.highcharts-sankey-series .highcharts-link { - transition: - fill 250ms, - fill-opacity 250ms; - fill-opacity: 0.5; -} - -.highcharts-sankey-series .highcharts-point-hover.highcharts-link { - transition: - fill 50ms, - fill-opacity 50ms; - fill-opacity: 1; -} - -.highcharts-venn-series .highcharts-point { - fill-opacity: 0.75; - stroke: var(--highcharts-neutral-color-20); - transition: - stroke 250ms, - fill-opacity 250ms; -} - -.highcharts-venn-series .highcharts-point-hover { - fill-opacity: 1; - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-timeline-series .highcharts-graph { - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-navigator-mask-outside { - fill-opacity: 0; -} - -.highcharts-navigator-mask-inside { - fill: var(--highcharts-highlight-color-60); - fill-opacity: 0.25; - cursor: ew-resize; -} - -.highcharts-navigator-outline { - stroke: var(--highcharts-neutral-color-40); - fill: none; -} - -.highcharts-navigator-handle { - stroke: var(--highcharts-neutral-color-40); - fill: var(--highcharts-neutral-color-5); - cursor: ew-resize; -} - -.highcharts-navigator-series { - fill: var(--highcharts-highlight-color-80); - stroke: var(--highcharts-highlight-color-80); -} - -.highcharts-navigator-series .highcharts-graph { - stroke-width: 1px; -} - -.highcharts-navigator-series .highcharts-area { - fill-opacity: 0.05; -} - -.highcharts-navigator-xaxis .highcharts-axis-line { - stroke-width: 0; -} - -.highcharts-navigator-xaxis .highcharts-grid-line { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-10); -} - -.highcharts-navigator-xaxis.highcharts-axis-labels { - fill: var(--highcharts-neutral-color-100); - font-size: 0.7em; - opacity: 0.6; -} - -.highcharts-navigator-yaxis .highcharts-grid-line { - stroke-width: 0; -} - -.highcharts-scrollbar-thumb { - fill: var(--highcharts-neutral-color-20); - stroke: var(--highcharts-neutral-color-20); - stroke-width: 0; -} - -.highcharts-scrollbar-button { - fill: var(--highcharts-neutral-color-10); - stroke: var(--highcharts-neutral-color-20); - stroke-width: 1px; -} - -.highcharts-scrollbar-arrow { - fill: var(--highcharts-neutral-color-60); -} - -.highcharts-scrollbar-rifles { - stroke: none; - stroke-width: 1px; -} - -.highcharts-scrollbar-track { - fill: rgba(255, 255, 255, 0.001); - stroke: var(--highcharts-neutral-color-20); - stroke-width: 1px; -} - -.highcharts-button { - fill: var(--highcharts-neutral-color-3); - stroke: var(--highcharts-neutral-color-20); - cursor: default; - stroke-width: 1px; - transition: fill 250ms; -} - -.highcharts-button text { - fill: var(--highcharts-neutral-color-80); - font-size: 0.8em; -} - -.highcharts-button-hover { - transition: fill 0ms; - fill: var(--highcharts-neutral-color-10); - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-button-hover text { - fill: var(--highcharts-neutral-color-80); -} - -.highcharts-button-pressed { - font-weight: 700; - fill: var(--highcharts-highlight-color-10); - stroke: var(--highcharts-neutral-color-20); -} - -.highcharts-button-pressed text { - fill: var(--highcharts-neutral-color-80); - font-weight: 700; -} - -.highcharts-button-disabled text { - fill: var(--highcharts-neutral-color-80); -} - -.highcharts-range-selector-buttons .highcharts-button { - stroke-width: 0; -} - -.highcharts-range-label rect { - fill: none; -} - -.highcharts-range-label text { - fill: var(--highcharts-neutral-color-60); -} - -.highcharts-range-input rect { - fill: none; -} - -.highcharts-range-input text { - fill: var(--highcharts-neutral-color-80); - font-size: 0.8em; -} - -.highcharts-range-input { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-20); -} - -input.highcharts-range-selector { - position: absolute; - border: 0; - width: 1px; - height: 1px; - padding: 0; - text-align: center; - left: -9em; -} - -.highcharts-crosshair-label text { - fill: var(--highcharts-background-color); - font-size: 1.7em; -} - -.highcharts-crosshair-label .highcharts-label-box { - fill: inherit; -} - -.highcharts-candlestick-series .highcharts-point { - stroke: var(--highcharts-neutral-color-100); - stroke-width: 1px; -} - -.highcharts-candlestick-series .highcharts-point-up { - fill: var(--highcharts-background-color); -} - -.highcharts-hollowcandlestick-series .highcharts-point-down { - fill: var(--highcharts-negative-color); - stroke: var(--highcharts-negative-color); -} - -.highcharts-hollowcandlestick-series .highcharts-point-down-bearish-up { - fill: var(--highcharts-positive-color); - stroke: var(--highcharts-positive-color); -} - -.highcharts-hollowcandlestick-series .highcharts-point-up { - fill: transparent; - stroke: var(--highcharts-positive-color); -} - -.highcharts-ohlc-series .highcharts-point-hover { - stroke-width: 3px; -} - -.highcharts-flags-series .highcharts-point .highcharts-label-box { - stroke: var(--highcharts-neutral-color-40); - fill: var(--highcharts-background-color); - transition: fill 250ms; -} - -.highcharts-flags-series .highcharts-point-hover .highcharts-label-box { - stroke: var(--highcharts-neutral-color-100); - fill: var(--highcharts-highlight-color-20); -} - -.highcharts-flags-series .highcharts-point text { - fill: var(--highcharts-neutral-color-100); - font-size: 0.9em; - font-weight: 700; -} - -.highcharts-map-series .highcharts-point { - transition: - fill 500ms, - fill-opacity 500ms, - stroke-width 250ms; - stroke: var(--highcharts-neutral-color-20); - stroke-width: inherit; -} - -.highcharts-map-series .highcharts-point-hover { - transition: - fill 0ms, - fill-opacity 0ms; - fill-opacity: 0.5; -} - -.highcharts-mapline-series .highcharts-point { - fill: none; -} - -.highcharts-heatmap-series .highcharts-point { - stroke-width: 0; -} - -.highcharts-map-navigation { - font-size: 1.3em; - font-weight: 700; - text-align: center; -} - -.highcharts-map-navigation.highcharts-button { - fill: var(--highcharts-background-color); - stroke: var(--highcharts-neutral-color-10); -} - -.highcharts-map-navigation.highcharts-button:hover { - fill: var(--highcharts-neutral-color-10); -} - -.highcharts-map-navigation.highcharts-button .highcharts-button-symbol { - stroke-width: 2px; -} - -.highcharts-mapview-inset-border { - stroke: var(--highcharts-neutral-color-20); - stroke-width: 1px; - fill: none; -} - -.highcharts-coloraxis { - stroke-width: 0; -} - -.highcharts-coloraxis-marker { - fill: var(--highcharts-neutral-color-40); -} - -.highcharts-null-point { - fill: var(--highcharts-neutral-color-3); -} - -.highcharts-3d-frame { - fill: transparent; -} - -.highcharts-contextbutton { - fill: var(--highcharts-background-color); - stroke: none; - stroke-linecap: round; -} - -.highcharts-contextbutton:hover { - fill: var(--highcharts-neutral-color-10); - stroke: var(--highcharts-neutral-color-10); -} - -.highcharts-button-symbol { - stroke: var(--highcharts-neutral-color-60); - stroke-width: 3px; -} - -.highcharts-menu { - border: none; - background: var(--highcharts-background-color); - border-radius: 3px; - padding: 0.5em; - box-shadow: 3px 3px 10px #888; -} - -.highcharts-menu-item { - background: 0 0; - border-radius: 3px; - color: var(--highcharts-neutral-color-80); - cursor: pointer; - font-size: 0.8em; - list-style-type: none; - padding: 0.5em; - transition: - background 250ms, - color 250ms; -} - -.highcharts-menu-item:hover { - background: var(--highcharts-neutral-color-5); -} - -.highcharts-breadcrumbs-button { - fill: none; - stroke-width: 0; - cursor: pointer; -} - -.highcharts-breadcrumbs-separator { - fill: var(--highcharts-neutral-color-60); -} - -.highcharts-drilldown-point { - cursor: pointer; -} - -.highcharts-drilldown-data-label text, -text.highcharts-drilldown-data-label, -.highcharts-drilldown-axis-label { - cursor: pointer; - fill: var(--highcharts-highlight-color-100); - font-weight: 700; - text-decoration: underline; -} - -.highcharts-no-data text { - font-weight: 700; - font-size: 0.8em; - fill: var(--highcharts-neutral-color-60); -} - -.highcharts-axis-resizer { - cursor: ns-resize; - stroke: var(--highcharts-neutral-color-100); - stroke-width: 2px; -} - -.highcharts-bullet-target { - stroke-width: 0; -} - -.highcharts-lineargauge-target { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-80); -} - -.highcharts-lineargauge-target-line { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-80); -} - -.highcharts-annotation-label-box { - stroke-width: 1px; - stroke: var(--highcharts-neutral-color-100); - fill: var(--highcharts-neutral-color-100); - fill-opacity: 0.75; -} - -.highcharts-annotation-label text { - fill: var(--highcharts-neutral-color-10); - font-size: 0.8em; -} - -.highcharts-a11y-proxy-button { - border-width: 0; - background-color: transparent; - cursor: pointer; - outline: none; - opacity: 0.001; - z-index: 999; - overflow: hidden; - padding: 0; - margin: 0; - display: block; - position: absolute; -} - -.highcharts-a11y-proxy-group li { - list-style: none; -} - -.highcharts-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - white-space: nowrap; - clip: rect(1px, 1px, 1px, 1px); - margin-top: -3px; - opacity: 0.01; -} - -.highcharts-a11y-invisible { - visibility: hidden; -} - -.highcharts-a11y-proxy-container, -.highcharts-a11y-proxy-container-before, -.highcharts-a11y-proxy-container-after { - position: absolute; - white-space: nowrap; -} - -g.highcharts-series, -.highcharts-markers, -.highcharts-point { - outline: none; -} - -.highcharts-treegrid-node-collapsed, -.highcharts-treegrid-node-expanded { - cursor: pointer; -} - -.highcharts-point-connecting-path { - fill: none; -} - -.highcharts-grid-axis .highcharts-tick { - stroke-width: 1px; -} - -.highcharts-grid-axis .highcharts-axis-line { - stroke-width: 1px; -} - -.highcharts-average-series { - fill: rgb(110, 110, 110); - stroke: rgb(110, 110, 110); -} - -.highcharts-average-series .highcharts-graph { - stroke-width: 4; - stroke-dasharray: 3, 8, 1, 8, 3; - stroke-linecap: square; - stroke-dashoffset:; -} - -.highcharts-dark .highcharts-average-series { - fill: rgb(200, 200, 200); - stroke: rgb(200, 200, 200); -} - -.highcharts-spread-series { - fill: rgb(236, 190, 41); - stroke: rgb(236, 190, 41); -} - -.highcharts-spread-series .highcharts-graph { - stroke-width: 2; -} - -.highcharts-dark .highcharts-spread-series { - fill: rgb(201, 162, 35); - stroke: rgb(201, 162, 35); -} diff --git a/src/routes/weather/compare/options.ts b/src/routes/weather/compare/options.ts deleted file mode 100644 index 463bab5..0000000 --- a/src/routes/weather/compare/options.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Default configuration for weather comparison charts -export const defaultParameters = { - timeformat: 'iso8601', - wind_speed_unit: 'kmh', - temperature_unit: 'celsius', - precipitation_unit: 'mm' -}; diff --git a/src/routes/weather/week/+page.ts b/src/routes/weather/week/+page.ts index 0963c36..c891c68 100644 --- a/src/routes/weather/week/+page.ts +++ b/src/routes/weather/week/+page.ts @@ -8,8 +8,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo'; import type { PageLoad } from './$types'; -export const prerender = true; - export const load = (async () => { const location = get(storedLocation); const locationRoute = geoLocationNameToRoute(location.name); diff --git a/src/routes/weather/week/[location]/+page.ts b/src/routes/weather/week/[location]/+page.ts index e321db1..efabba4 100644 --- a/src/routes/weather/week/[location]/+page.ts +++ b/src/routes/weather/week/[location]/+page.ts @@ -6,8 +6,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo'; import type { PageLoad } from './$types'; -export const prerender = true; - export const load: PageLoad = async (event) => { const urlLocation = event.params.location; let urlLocationSplit, urlLocationName, urlLocationId;