diff --git a/src/lib/components/charts/ChartContainer.svelte b/src/lib/components/charts/ChartContainer.svelte new file mode 100644 index 0000000..c7fa6ec --- /dev/null +++ b/src/lib/components/charts/ChartContainer.svelte @@ -0,0 +1,118 @@ + + + +
+ +
+ {#if children} + {@render children()} + {/if} +
+ + +
+
+ + Loading charts... +
+
+
+ + diff --git a/src/lib/components/charts/ChartToolbar.svelte b/src/lib/components/charts/ChartToolbar.svelte new file mode 100644 index 0000000..46d8a5e --- /dev/null +++ b/src/lib/components/charts/ChartToolbar.svelte @@ -0,0 +1,278 @@ + + + +
+ +
+ {#if controls} + {@render controls()} + {/if} +
+ + +
+ + + + + + + + {#if showDownloadAll && hasMultipleCharts} + + + {/if} +
+
+ + diff --git a/src/lib/components/charts/EChart.svelte b/src/lib/components/charts/EChart.svelte new file mode 100644 index 0000000..e56575c --- /dev/null +++ b/src/lib/components/charts/EChart.svelte @@ -0,0 +1,165 @@ + + + +
+ + diff --git a/src/lib/components/charts/echarts.css b/src/lib/components/charts/echarts.css new file mode 100644 index 0000000..1832367 --- /dev/null +++ b/src/lib/components/charts/echarts.css @@ -0,0 +1,164 @@ +/* ═══════════════════════════════════════════════════════════════════════════════ + ECharts Global Styles — Open-Meteo Weather + + Shared CSS for all ECharts chart instances across the application. + Provides consistent theming, tooltip styling, and responsive behavior + that integrates with the application's design system (Tailwind + shadcn). + ═══════════════════════════════════════════════════════════════════════════════ */ + +/* ─── Chart Wrapper ────────────────────────────────────────────────────────── */ + +.echart-wrapper { + width: 100%; + position: relative; + overflow: hidden; +} + +/* ─── Chart Container (legacy class support) ───────────────────────────────── */ + +.echarts-container { + width: 100%; + height: 100%; + min-height: 300px; +} + +/* Ensure canvas background is always transparent so our page bg shows through */ +.echart-wrapper canvas, +.echarts-container canvas { + background: transparent !important; +} + +/* ─── Tooltip Styling ──────────────────────────────────────────────────────── */ + +/* Override ECharts' default tooltip to match the application's popover design */ +.echarts-tooltip { + background: hsl(var(--popover)) !important; + border: 1px solid hsl(var(--border)) !important; + border-radius: var(--radius, 0.5rem) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.05) !important; + padding: 0.625rem 0.75rem !important; + font-size: 0.8125rem !important; + line-height: 1.4 !important; + max-width: min(90vw, 480px) !important; + pointer-events: none; +} + +.echarts-tooltip-content { + color: hsl(var(--popover-foreground)) !important; +} + +/* Tooltip marker dots — make them slightly larger and rounded */ +.echarts-tooltip .echarts-tooltip-marker, +.echarts-tooltip span[style*="border-radius"] { + display: inline-block; + vertical-align: middle; + margin-right: 0.25rem; +} + +/* ─── Loading Mask ─────────────────────────────────────────────────────────── */ + +.echarts-loading-mask { + background: hsl(var(--background) / 0.8) !important; +} + +/* ─── Light Mode Adjustments ───────────────────────────────────────────────── */ + +[data-theme='light'] .echart-wrapper, +[data-theme='light'] .echarts-container, +:root:not(.dark):not([data-theme='dark']) .echart-wrapper, +:root:not(.dark):not([data-theme='dark']) .echarts-container { + color: hsl(var(--foreground)); +} + +[data-theme='light'] .echarts-tooltip, +:root:not(.dark):not([data-theme='dark']) .echarts-tooltip { + color: hsl(var(--popover-foreground)) !important; +} + +/* ─── Dark Mode Adjustments ────────────────────────────────────────────────── */ + +.dark .echart-wrapper, +[data-theme='dark'] .echart-wrapper, +.dark .echarts-container, +[data-theme='dark'] .echarts-container { + color: hsl(var(--foreground)); +} + +.dark .echarts-tooltip, +[data-theme='dark'] .echarts-tooltip { + color: hsl(var(--popover-foreground)) !important; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.3), + 0 2px 4px -2px rgb(0 0 0 / 0.15) !important; +} + +/* ─── Toolbox Icon Overrides ───────────────────────────────────────────────── */ + +/* Make sure the toolbox icons are visually subtle until hovered */ +.echart-wrapper [class*="toolbox"], +.echarts-container [class*="toolbox"] { + opacity: 0.6; + transition: opacity 150ms ease; +} + +.echart-wrapper:hover [class*="toolbox"], +.echarts-container:hover [class*="toolbox"] { + opacity: 1; +} + +/* ─── Chart Spacing ────────────────────────────────────────────────────────── */ + +/* Add consistent vertical spacing between stacked chart instances */ +.chart-content .echart-wrapper + .echart-wrapper { + margin-top: 0.5rem; +} + +/* ─── Responsive Sizing ────────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .echarts-container { + min-height: 240px; + } + + /* Slightly smaller tooltips on mobile */ + .echarts-tooltip { + font-size: 0.75rem !important; + padding: 0.5rem 0.625rem !important; + } +} + +@media (min-width: 641px) and (max-width: 1024px) { + .echarts-container { + min-height: 280px; + } +} + +/* ─── Print Styles ─────────────────────────────────────────────────────────── */ + +@media print { + .echart-wrapper, + .echarts-container { + break-inside: avoid; + page-break-inside: avoid; + } + + /* Hide interactive elements when printing */ + .echart-wrapper [class*="toolbox"], + .echarts-container [class*="toolbox"], + .chart-toolbar { + display: none !important; + } +} + +/* ─── Accessibility ────────────────────────────────────────────────────────── */ + +/* Respect reduced motion preferences */ +@media (prefers-reduced-motion: reduce) { + .echart-wrapper *, + .echarts-container * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/lib/components/charts/index.ts b/src/lib/components/charts/index.ts new file mode 100644 index 0000000..c67aa73 --- /dev/null +++ b/src/lib/components/charts/index.ts @@ -0,0 +1,12 @@ +/** + * Chart Components — Barrel Export + * + * Re-exports all chart-related Svelte components from a single entry point. + * + * Usage: + * import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts'; + */ + +export { default as EChart } from './EChart.svelte'; +export { default as ChartContainer } from './ChartContainer.svelte'; +export { default as ChartToolbar } from './ChartToolbar.svelte'; diff --git a/src/lib/utils/echarts/download.ts b/src/lib/utils/echarts/download.ts new file mode 100644 index 0000000..86bba24 --- /dev/null +++ b/src/lib/utils/echarts/download.ts @@ -0,0 +1,134 @@ +/** + * ECharts Download Utilities + * + * Provides programmatic chart export functionality for downloading + * charts as PNG or SVG images. These utilities wrap ECharts' built-in + * export capabilities with a convenient API and sensible defaults. + */ + +import type * as echarts from 'echarts'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ExportFormat = 'png' | 'svg'; + +export interface DownloadOptions { + /** The file name (without extension) */ + fileName?: string; + /** Export format: 'png' or 'svg' */ + format?: ExportFormat; + /** Pixel ratio for PNG exports (default: 2 for retina quality) */ + pixelRatio?: number; + /** Background color (default: '#ffffff' for PNG, 'none' for SVG) */ + backgroundColor?: string; + /** Components to exclude from the export (e.g. ['toolbox']) */ + excludeComponents?: string[]; +} + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +const DEFAULT_FILE_NAME = 'open-meteo-chart'; +const DEFAULT_PIXEL_RATIO = 2; + +// ─── Download Functions ────────────────────────────────────────────────────── + +/** + * Downloads a single ECharts instance as an image file. + * + * @param chart - The ECharts instance to export + * @param options - Download configuration options + */ +export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = {}): void { + const { + fileName = DEFAULT_FILE_NAME, + format = 'png', + pixelRatio = DEFAULT_PIXEL_RATIO, + backgroundColor, + excludeComponents = ['toolbox'] + } = options; + + const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); + + // Use ECharts' getDataURL for PNG, getConnectedDataURL for SVG + const dataUrl = chart.getDataURL({ + type: format === 'svg' ? 'svg' : 'png', + pixelRatio: format === 'png' ? pixelRatio : 1, + backgroundColor: resolvedBg, + excludeComponents + }); + + triggerDownload(dataUrl, `${fileName}.${format}`); +} + +/** + * Downloads all provided ECharts instances as separate image files. + * Each file is named with an incrementing suffix (e.g. chart-1.png, chart-2.png). + * + * @param charts - Array of ECharts instances to export + * @param options - Download configuration options (fileName is used as prefix) + */ +export function downloadAllCharts( + charts: echarts.ECharts[], + options: DownloadOptions = {} +): void { + const { fileName = DEFAULT_FILE_NAME, ...rest } = options; + + charts.forEach((chart, index) => { + if (chart && !chart.isDisposed()) { + downloadChart(chart, { + ...rest, + fileName: charts.length === 1 ? fileName : `${fileName}-${index + 1}` + }); + } + }); +} + +/** + * Returns the data URL of a chart without triggering a download. + * Useful for previewing or embedding chart images programmatically. + * + * @param chart - The ECharts instance to export + * @param options - Export configuration options + * @returns A base64-encoded data URL string + */ +export function getChartDataUrl( + chart: echarts.ECharts, + options: DownloadOptions = {} +): string { + const { + format = 'png', + pixelRatio = DEFAULT_PIXEL_RATIO, + backgroundColor, + excludeComponents = ['toolbox'] + } = options; + + const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); + + return chart.getDataURL({ + type: format === 'svg' ? 'svg' : 'png', + pixelRatio: format === 'png' ? pixelRatio : 1, + backgroundColor: resolvedBg, + excludeComponents + }); +} + +// ─── Internal Helpers ──────────────────────────────────────────────────────── + +/** + * Triggers a browser file download from a data URL. + * Creates a temporary anchor element, clicks it, and removes it. + */ +function triggerDownload(dataUrl: string, fileName: string): void { + const link = document.createElement('a'); + link.href = dataUrl; + link.download = fileName; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + + // Clean up the DOM after a brief delay to ensure the download starts + requestAnimationFrame(() => { + document.body.removeChild(link); + }); +} diff --git a/src/lib/utils/echarts/index.ts b/src/lib/utils/echarts/index.ts new file mode 100644 index 0000000..c036a1b --- /dev/null +++ b/src/lib/utils/echarts/index.ts @@ -0,0 +1,72 @@ +/** + * ECharts Utilities — Barrel Export + * + * Re-exports all ECharts-related utilities from a single entry point. + * + * Usage: + * import { getThemeColors, composeChartOption, buildModelSeries, downloadChart } from '$lib/utils/echarts'; + */ + +// Theme: dark/light detection, color palettes, theme color accessors +export { + SERIES_COLORS, + CHART_COLORS, + isDarkMode, + getThemeColors, + getTextColor, + getAxisLineColor, + getSplitLineColor +} from './theme'; +export type { ThemeColors } from './theme'; + +// Option builders: grid, title, tooltip, legend, axes, toolbox, full composer +export { + buildGrid, + buildTitle, + buildTooltip, + buildLegend, + buildTimeXAxis, + buildValueYAxis, + buildCreditGraphic, + buildToolbox, + composeChartOption, + isColumnUnit +} from './options'; +export type { + GridOptions, + TitleOptions, + LegendOptions, + TooltipOptions, + AxisOptions, + CreditOptions, + BuildGridParams, + ToolboxOptions, + ChartOptionParams +} from './options'; + +// Series builders: model lines, averages, time markers, daylight bands, ensemble spread +export { + buildModelSeries, + buildAverageSeries, + buildCurrentTimeSeries, + buildDaylightMarkAreas, + buildDaylightSeries, + buildSpreadSeries, + calculateAverage, + calculateSpread, + convertTimestamps, + findUnit +} from './series'; +export type { + ModelSeriesParams, + AverageSeriesParams, + CurrentTimeSeriesParams, + DaylightSeriesParams, + SpreadSeriesParams, + AverageResult, + SpreadResult +} from './series'; + +// Download: export charts as PNG or SVG +export { downloadChart, downloadAllCharts, getChartDataUrl } from './download'; +export type { ExportFormat, DownloadOptions } from './download'; diff --git a/src/lib/utils/echarts/options.ts b/src/lib/utils/echarts/options.ts new file mode 100644 index 0000000..bd02ace --- /dev/null +++ b/src/lib/utils/echarts/options.ts @@ -0,0 +1,392 @@ +/** + * ECharts Option Builders + * + * Shared factory functions for constructing common ECharts option fragments. + * These builders ensure visual consistency across all chart pages and reduce + * boilerplate in page-level components. + */ +import { getThemeColors } from './theme'; + +import type { ThemeColors } from './theme'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface GridOptions { + left?: number; + right?: number; + top?: number; + bottom?: number; +} + +export interface TitleOptions { + text: string; + subtext?: string; +} + +export interface LegendOptions { + show: boolean; + data?: string[]; +} + +export interface TooltipOptions { + unit: string; +} + +export interface AxisOptions { + unit?: string; +} + +export interface CreditOptions { + show: boolean; +} + +// ─── Default Constants ─────────────────────────────────────────────────────── + +const DEFAULT_GRID: GridOptions = { + left: 60, + right: 16, + top: 40, + bottom: 40 +}; + +const GRID_WITH_TITLE: Partial = { + top: 80 +}; + +const GRID_WITH_LEGEND: Partial = { + bottom: 60 +}; + +// ─── Grid ──────────────────────────────────────────────────────────────────── + +export interface BuildGridParams { + hasTitle?: boolean; + hasSubtitle?: boolean; + showLegend?: boolean; + overrides?: Partial; +} + +/** + * Builds a grid configuration with sensible defaults. + * Automatically adjusts top/bottom spacing for title and legend presence. + */ +export function buildGrid(params: BuildGridParams = {}): GridOptions { + const { hasTitle = false, hasSubtitle = false, showLegend = false, overrides } = params; + + return { + ...DEFAULT_GRID, + ...(hasTitle ? GRID_WITH_TITLE : {}), + ...(hasSubtitle ? { top: 90 } : {}), + ...(showLegend ? GRID_WITH_LEGEND : {}), + ...overrides + }; +} + +// ─── Title ─────────────────────────────────────────────────────────────────── + +/** + * Builds a title configuration. Pass `null` to hide the title. + */ +export function buildTitle( + options: TitleOptions | null, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + + if (!options) { + return { title: { show: false } }; + } + + const result: Record = { + text: options.text, + left: 'left', + textStyle: { + fontWeight: 'normal', + fontSize: 16, + color: c.text + } + }; + + if (options.subtext) { + result.subtext = options.subtext; + result.subtextStyle = { + fontWeight: 'normal', + fontSize: 12, + color: c.textMuted + }; + } + + return result; +} + +// ─── Tooltip ───────────────────────────────────────────────────────────────── + +/** + * Builds a tooltip with cross-axis pointer and unit-aware value formatting. + */ +export function buildTooltip( + options: TooltipOptions, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + const { unit } = options; + + return { + trigger: 'axis', + axisPointer: { + type: 'cross', + animation: false, + label: { + backgroundColor: c.tooltipBg, + color: c.text, + borderColor: c.tooltipBorder, + borderWidth: 1 + } + }, + backgroundColor: c.tooltipBg, + borderColor: c.tooltipBorder, + textStyle: { + color: c.text + }, + valueFormatter: (value: number) => { + if (value === null || value === undefined) return '-'; + return value.toFixed(1) + ' ' + unit; + } + }; +} + +// ─── Legend ─────────────────────────────────────────────────────────────────── + +/** + * Builds a scrollable legend configuration pinned to the bottom. + */ +export function buildLegend(options: LegendOptions, colors?: ThemeColors): Record { + const c = colors ?? getThemeColors(); + + return { + show: options.show, + bottom: 0, + type: 'scroll', + ...(options.data ? { data: options.data } : {}), + textStyle: { + color: c.text + }, + pageTextStyle: { + color: c.text + } + }; +} + +// ─── X Axis (Time) ─────────────────────────────────────────────────────────── + +/** + * Builds a time-based X axis with theme-aware styling. + */ +export function buildTimeXAxis(colors?: ThemeColors): Record { + const c = colors ?? getThemeColors(); + + return { + type: 'time', + splitLine: { + show: false + }, + axisLine: { + lineStyle: { + color: c.axisLine + } + }, + axisLabel: { + color: c.text, + hideOverlap: true + }, + axisTick: { + lineStyle: { + color: c.axisLine + } + } + }; +} + +// ─── Y Axis (Value) ───────────────────────────────────────────────────────── + +/** + * Builds a value-based Y axis with optional unit label. + */ +export function buildValueYAxis( + options: AxisOptions = {}, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + + return { + type: 'value', + ...(options.unit ? { name: options.unit } : {}), + nameTextStyle: { + color: c.text, + padding: [0, 0, 0, 4] + }, + axisLine: { + show: false + }, + axisLabel: { + color: c.text + }, + splitLine: { + lineStyle: { + color: c.splitLine + } + } + }; +} + +// ─── Credit Watermark ──────────────────────────────────────────────────────── + +/** + * Builds the Open-Meteo.com credit watermark graphic element. + */ +export function buildCreditGraphic(colors?: ThemeColors): Record[] { + const c = colors ?? getThemeColors(); + + return [ + { + type: 'text', + right: 10, + bottom: 5, + style: { + text: 'Open-Meteo.com', + fontSize: 10, + fill: c.text, + opacity: 0.4 + }, + onclick: function () { + window.open('https://open-meteo.com', '_blank'); + }, + cursor: 'pointer' + } + ]; +} + +// ─── Toolbox (Download) ───────────────────────────────────────────────────── + +export interface ToolboxOptions { + /** Show the save-as-image button */ + saveAsImage?: boolean; + /** File name prefix for downloaded images */ + fileName?: string; + /** Export format: 'png' or 'svg' */ + format?: 'png' | 'svg'; +} + +/** + * Builds the ECharts toolbox with download functionality. + */ +export function buildToolbox( + options: ToolboxOptions = {}, + colors?: ThemeColors +): Record { + const c = colors ?? getThemeColors(); + const { saveAsImage = true, fileName = 'open-meteo-chart', format = 'png' } = options; + + return { + show: true, + right: 16, + top: 4, + iconStyle: { + borderColor: c.textMuted + }, + emphasis: { + iconStyle: { + borderColor: c.text + } + }, + feature: { + ...(saveAsImage + ? { + saveAsImage: { + type: format, + name: fileName, + title: format === 'svg' ? 'Save as SVG' : 'Save as PNG', + pixelRatio: 2, + backgroundColor: 'transparent', + excludeComponents: ['toolbox'], + iconStyle: { + borderColor: c.textMuted + }, + emphasis: { + iconStyle: { + borderColor: c.text + } + } + } + } + : {}) + } + }; +} + +// ─── Full Option Composer ──────────────────────────────────────────────────── + +export interface ChartOptionParams { + title?: TitleOptions | null; + tooltip: TooltipOptions; + legend?: LegendOptions; + grid?: BuildGridParams; + yAxis?: AxisOptions; + series: Array>; + toolbox?: ToolboxOptions | false; + showCredit?: boolean; + colors?: ThemeColors; +} + +/** + * Composes a complete ECharts option object from individual builder params. + * This is the primary entry point for building chart options — it calls all + * the individual builders and merges the results into a single config object. + */ +export function composeChartOption(params: ChartOptionParams): Record { + const colors = params.colors ?? getThemeColors(); + const hasTitle = params.title != null && params.title.text !== ''; + const showLegend = params.legend?.show ?? false; + + const option: Record = { + title: buildTitle(params.title ?? null, colors), + tooltip: buildTooltip(params.tooltip, colors), + legend: buildLegend(params.legend ?? { show: false }, colors), + grid: buildGrid({ + ...params.grid, + hasTitle, + hasSubtitle: hasTitle && !!params.title?.subtext, + showLegend + }), + xAxis: buildTimeXAxis(colors), + yAxis: buildValueYAxis(params.yAxis, colors), + series: params.series, + textStyle: { + color: colors.text + } + }; + + // Add toolbox unless explicitly disabled + if (params.toolbox !== false) { + option.toolbox = buildToolbox(params.toolbox ?? {}, colors); + } + + // Add credit watermark + if (params.showCredit) { + option.graphic = buildCreditGraphic(colors); + } + + return option; +} + +// ─── Utility: Detect column-type variables ─────────────────────────────────── + +/** Units that should be rendered as bar/column charts instead of lines. */ +const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']); + +/** + * Returns true if the given unit should be rendered as a bar chart. + */ +export function isColumnUnit(unit: string): boolean { + return COLUMN_UNITS.has(unit); +} diff --git a/src/lib/utils/echarts/series.ts b/src/lib/utils/echarts/series.ts new file mode 100644 index 0000000..9a84b71 --- /dev/null +++ b/src/lib/utils/echarts/series.ts @@ -0,0 +1,364 @@ +/** + * ECharts Series Builders + * + * Factory functions for constructing common series patterns used across + * weather chart visualizations. These builders encapsulate the styling + * and configuration details so page-level code only needs to provide data. + */ + +import { CHART_COLORS } from './theme'; +import { isColumnUnit } from './options'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ModelSeriesParams { + /** The series name (typically the model key from the API response) */ + name: string; + /** Array of [timestamp, value] data points */ + data: Array<[number, number | null]>; + /** The unit string, used to determine bar vs line rendering */ + unit: string; + /** Optional line width override (default: 2) */ + lineWidth?: number; +} + +export interface AverageSeriesParams { + /** The variable name, used to construct the series name */ + variable: string; + /** Array of [timestamp, value] data points */ + data: Array<[number, number]>; + /** The unit string, used to determine bar vs line rendering */ + unit: string; +} + +export interface CurrentTimeSeriesParams { + /** UTC offset in seconds from the API response */ + utcOffsetSeconds: number; +} + +export interface DaylightSeriesParams { + /** Array of mark area pairs: [[start, end], [start, end], ...] */ + markAreas: Array< + [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }] + >; +} + +export interface SpreadSeriesParams { + /** The variable name, used to construct series names */ + variable: string; + /** Array of [timestamp, min, max] data points */ + spreadData: Array<[number, number, number]>; + /** Optional color for the spread area (default: theme spread color) */ + color?: string; +} + +export interface MarkAreaEntry { + xAxis: number; + itemStyle?: { color: string }; +} + +// ─── Model Series ──────────────────────────────────────────────────────────── + +/** + * Builds a single model series (line or bar depending on the unit). + * Used on the Model Comparison page where each weather model gets its own series. + */ +export function buildModelSeries(params: ModelSeriesParams): Record { + const { name, data, unit, lineWidth = 2 } = params; + const isColumn = isColumnUnit(unit); + + return { + name, + type: isColumn ? 'bar' : 'line', + data, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + width: lineWidth + }, + emphasis: { + lineStyle: { + width: lineWidth + 1 + } + }, + barMaxWidth: 5 + }; +} + +// ─── Average Series ────────────────────────────────────────────────────────── + +/** + * Builds the ensemble/model average series. + * Rendered as a dashed line (or bar) that stands out from individual model lines. + */ +export function buildAverageSeries(params: AverageSeriesParams): Record { + const { variable, data, unit } = params; + const isColumn = isColumnUnit(unit); + + return { + name: variable + '_average', + type: isColumn ? 'bar' : 'line', + data, + smooth: !isColumn, + showSymbol: false, + lineStyle: { + type: 'dashed', + width: 4, + color: CHART_COLORS.average + }, + itemStyle: { + color: CHART_COLORS.average + }, + emphasis: { + lineStyle: { + width: 6 + } + }, + barMaxWidth: 5, + z: 10 + }; +} + +// ─── Current Time Marker ───────────────────────────────────────────────────── + +/** + * Builds a helper series that renders a vertical red line at the current time. + * Uses an empty data series with a markLine to overlay onto the chart. + */ +export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record { + const { utcOffsetSeconds } = params; + + return { + name: 'Current Time', + type: 'line', + data: [], + markLine: { + silent: true, + symbol: 'none', + data: [ + { + xAxis: Date.now() + utcOffsetSeconds * 1000, + lineStyle: { + color: CHART_COLORS.currentTimeLine, + width: 2, + type: 'solid' + }, + label: { + show: false + } + } + ] + } + }; +} + +// ─── Daylight Bands ────────────────────────────────────────────────────────── + +/** + * Builds mark area entries from sunrise/sunset arrays. + * Each entry is a pair of axis markers that ECharts renders as a shaded band. + * + * @param sunrise - Array of sunrise timestamps (unix seconds, without UTC offset) + * @param sunset - Array of sunset timestamps (unix seconds, without UTC offset) + * @param utcOffsetSeconds - UTC offset to apply (from the API response) + */ +export function buildDaylightMarkAreas( + sunrise: number[], + sunset: number[], + utcOffsetSeconds: number +): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> { + return sunrise.map((r: number, i: number) => [ + { + xAxis: (r + utcOffsetSeconds) * 1000, + itemStyle: { + color: CHART_COLORS.daylight + } + }, + { + xAxis: (sunset[i] + utcOffsetSeconds) * 1000 + } + ]); +} + +/** + * Builds a helper series that renders day/night shading bands via markArea. + * Returns null if no mark areas are provided (so callers can filter it out). + */ +export function buildDaylightSeries( + params: DaylightSeriesParams +): Record | null { + if (params.markAreas.length === 0) return null; + + return { + name: 'Daylight', + type: 'line', + data: [], + markArea: { + silent: true, + data: params.markAreas + } + }; +} + +// ─── Ensemble Spread (Min/Max Area) ────────────────────────────────────────── + +/** + * Builds a pair of stacked area series that visualize the ensemble spread + * (min-to-max range). The lower bound is rendered invisibly and the upper + * bound delta is stacked on top with a translucent fill. + * + * Returns an array of two series that should be spread into the series list. + */ +export function buildSpreadSeries(params: SpreadSeriesParams): Array> { + const { variable, spreadData, color = CHART_COLORS.spreadArea } = params; + + const lowerBound: Record = { + name: variable + '_spread_lower', + type: 'line', + data: spreadData.map((d) => [d[0], d[1]]), + areaStyle: { + color, + origin: 'auto' + }, + lineStyle: { + width: 0 + }, + showSymbol: false, + stack: 'spread_' + variable, + smooth: true, + z: 1, + silent: true + }; + + const upperDelta: Record = { + name: variable + '_spread_upper', + type: 'line', + data: spreadData.map((d) => [d[0], d[2] - d[1]]), + areaStyle: { + color, + origin: 'auto' + }, + lineStyle: { + width: 0 + }, + showSymbol: false, + stack: 'spread_' + variable, + smooth: true, + z: 1, + silent: true + }; + + return [lowerBound, upperDelta]; +} + +// ─── Data Processing Helpers ───────────────────────────────────────────────── + +export interface AverageResult { + average: number[]; + averageCount: number[]; +} + +export interface SpreadResult { + minValues: (number | undefined)[]; + maxValues: (number | undefined)[]; +} + +/** + * Calculates per-timestep average and count from hourly model data. + * Shared between the Model Compare and 14-Day Forecast pages. + * + * @param hourlyData - The `data.hourly` object from the API response + * @param variable - The variable prefix to filter on (e.g. 'temperature_2m') + * @param timeLength - Number of timesteps + * @returns Object containing running average and count arrays + */ +export function calculateAverage( + hourlyData: Record, + variable: string, + timeLength: number +): AverageResult { + const average = new Array(timeLength).fill(0); + const averageCount = new Array(timeLength).fill(0); + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + for (const [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined) { + average[index] += val; + averageCount[index]++; + } + } + } + + // Finalize average values + for (let i = 0; i < timeLength; i++) { + if (averageCount[i] > 0) { + average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10; + } + } + + return { average, averageCount }; +} + +/** + * Calculates per-timestep min and max values from hourly ensemble data. + * Used by the 14-Day Forecast page to render the ensemble spread. + * + * @param hourlyData - The `data.hourly` object from the API response + * @param variable - The variable prefix to filter on + * @param timeLength - Number of timesteps + * @returns Object containing min and max value arrays + */ +export function calculateSpread( + hourlyData: Record, + variable: string, + timeLength: number +): SpreadResult { + const minValues = new Array(timeLength).fill(undefined); + const maxValues = new Array(timeLength).fill(undefined); + + for (const [model, values] of Object.entries(hourlyData)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + for (const [index, val] of (values as number[]).entries()) { + if (val !== null && val !== undefined) { + if (minValues[index] === undefined || val < minValues[index]!) { + minValues[index] = val; + } + if (maxValues[index] === undefined || val > maxValues[index]!) { + maxValues[index] = val; + } + } + } + } + + return { minValues, maxValues }; +} + +/** + * Converts raw unix timestamps (seconds) to millisecond timestamps with UTC offset applied. + */ +export function convertTimestamps(times: number[], utcOffsetSeconds: number): number[] { + return times.map((t) => (t + utcOffsetSeconds) * 1000); +} + +/** + * Finds the unit string for a given variable from the hourly_units map. + * Returns an empty string if the variable is not found. + */ +export function findUnit( + hourlyUnits: Record, + hourlyData: Record, + variable: string +): string { + for (const model of Object.keys(hourlyData)) { + if (model === 'time') continue; + if (model.startsWith(variable) && hourlyUnits[model]) { + return hourlyUnits[model]; + } + } + return ''; +} diff --git a/src/lib/utils/echarts/theme.ts b/src/lib/utils/echarts/theme.ts new file mode 100644 index 0000000..e34d3a1 --- /dev/null +++ b/src/lib/utils/echarts/theme.ts @@ -0,0 +1,106 @@ +/** + * ECharts Theme Utilities + * + * Centralized dark/light mode detection and color helpers for consistent + * chart theming across all ECharts visualizations. + */ + +// ─── Color Palette ─────────────────────────────────────────────────────────── + +/** Default series color palette matching the application's design system */ +export const SERIES_COLORS = [ + '#5470c6', + '#91cc75', + '#fac858', + '#ee6666', + '#73c0de', + '#3ba272', + '#fc8452', + '#9a60b4', + '#ea7ccc', + '#4dc9f6' +] as const; + +/** Semantic colors used for specific chart elements */ +export const CHART_COLORS = { + average: '#5e5e5e', + currentTimeLine: '#ef4444', + daylight: 'rgba(255, 255, 194, 0.3)', + spreadArea: 'rgba(173, 216, 230, 0.3)', + creditText: { light: '#374151', dark: '#e5e7eb' } +} as const; + +// ─── Dark Mode Detection ───────────────────────────────────────────────────── + +/** + * Detects whether the application is currently in dark mode. + * Checks multiple sources: HTML class, data-theme attribute, and media query. + */ +export function isDarkMode(): boolean { + if (typeof document === 'undefined') return false; + + const html = document.documentElement; + const dataTheme = html.getAttribute('data-theme'); + + // Explicit data-theme takes priority + if (dataTheme === 'dark') return true; + if (dataTheme === 'light') return false; + + // Check for dark class (e.g. Tailwind dark mode) + if (html.classList.contains('dark')) return true; + + // Fall back to system preference + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; +} + +// ─── Theme Colors ──────────────────────────────────────────────────────────── + +export interface ThemeColors { + text: string; + textMuted: string; + axisLine: string; + splitLine: string; + background: string; + tooltipBg: string; + tooltipBorder: string; +} + +const LIGHT_COLORS: ThemeColors = { + text: '#374151', + textMuted: 'rgba(55, 65, 81, 0.6)', + axisLine: 'rgba(55, 65, 81, 0.3)', + splitLine: 'rgba(55, 65, 81, 0.1)', + background: 'transparent', + tooltipBg: '#ffffff', + tooltipBorder: '#e5e7eb' +}; + +const DARK_COLORS: ThemeColors = { + text: '#e5e7eb', + textMuted: 'rgba(229, 231, 235, 0.6)', + axisLine: 'rgba(229, 231, 235, 0.3)', + splitLine: 'rgba(229, 231, 235, 0.1)', + background: 'transparent', + tooltipBg: '#1f2937', + tooltipBorder: '#374151' +}; + +/** + * Returns the full set of theme colors based on current dark/light mode. + */ +export function getThemeColors(): ThemeColors { + return isDarkMode() ? DARK_COLORS : LIGHT_COLORS; +} + +/** Shorthand helpers kept for backward compatibility and convenience */ +export function getTextColor(): string { + return getThemeColors().text; +} + +export function getAxisLineColor(): string { + return getThemeColors().axisLine; +} + +export function getSplitLineColor(): string { + return getThemeColors().splitLine; +} diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index a245b7e..61d44ca 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -1,22 +1,39 @@ - -
-
-
- - - - -
-
+ -
-
-
- { - params.hourly = params.hourly; - }} - /> - -
-
- { - params.hourly = params.hourly; - }} - /> - -
-
+ + {#each chartOptions as option, i (i)} + + {/each} + + + + +
+ + {#snippet controls()} +
+ { + params.hourly = params.hourly; + }} + /> + +
+
+ { + params.hourly = params.hourly; + }} + /> + +
+ {/snippet} +
diff --git a/src/routes/weather/14-day/options.ts b/src/routes/weather/14-day/options.ts deleted file mode 100644 index 970b98d..0000000 --- a/src/routes/weather/14-day/options.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Default configuration for 14-day ensemble forecast charts -export const defaultParameters = { - timeformat: 'iso8601', - wind_speed_unit: 'kmh', - temperature_unit: 'celsius', - precipitation_unit: 'mm' -}; diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index a8ea7cd..6875688 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -3,25 +3,42 @@ import { get } from 'svelte/store'; import { fade } from 'svelte/transition'; - import * as echarts from 'echarts'; - import { storedLocation } from '$lib/stores/settings'; + import { + buildAverageSeries, + buildCurrentTimeSeries, + buildDaylightMarkAreas, + buildDaylightSeries, + buildModelSeries, + calculateAverage, + composeChartOption, + convertTimestamps, + findUnit, + getThemeColors + } from '$lib/utils/echarts'; + + import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts'; + import '$lib/components/charts/echarts.css'; import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; import { hourly, models as modelsFlat } from '../options'; - import './echarts.css'; - import { defaultParameters } from './options'; + import { defaultParameters } from '../options'; + + import type * as echarts from 'echarts'; // Wrap models in array to match template expectation of nested arrays like hourly const models = [modelsFlat]; - let node: HTMLElement; - let charts: echarts.ECharts[] = []; - let resizeObservers: ResizeObserver[] = []; + // ─── State ────────────────────────────────────────────────────────────────── + + let chartComponents: EChart[] = $state([]); + let chartInstances: echarts.ECharts[] = $state([]); + let chartOptions: Array> = $state([]); let mounted = $state(false); + let loading = $state(true); let showLegend = $state(false); let averageOnly = $state(false); @@ -42,402 +59,203 @@ ] }); - let count = $state(0); - - function isDarkMode(): boolean { - if (typeof document === 'undefined') return false; - return ( - document.documentElement.classList.contains('dark') || - document.documentElement.getAttribute('data-theme') === 'dark' || - (window.matchMedia && - window.matchMedia('(prefers-color-scheme: dark)').matches && - document.documentElement.getAttribute('data-theme') !== 'light') - ); - } - - function getTextColor(): string { - return isDarkMode() ? '#e5e7eb' : '#374151'; - } - - function getAxisLineColor(): string { - return isDarkMode() ? 'rgba(229, 231, 235, 0.3)' : 'rgba(55, 65, 81, 0.3)'; - } - - function getSplitLineColor(): string { - return isDarkMode() ? 'rgba(229, 231, 235, 0.1)' : 'rgba(55, 65, 81, 0.1)'; - } + // ─── Lifecycle ────────────────────────────────────────────────────────────── onMount(() => { mounted = true; }); + onDestroy(() => { + chartInstances = []; + chartOptions = []; + chartComponents = []; + }); + + // ─── Chart Instance Tracking ──────────────────────────────────────────────── + + function handleChartReady(chart: echarts.ECharts): void { + chartInstances = [...chartInstances, chart]; + } + + // ─── Data Loading & Chart Building ────────────────────────────────────────── + $effect(() => { const loadData = async () => { - count = 0; - if (mounted) { - // Dispose existing charts and observers - resizeObservers.forEach((ro) => ro.disconnect()); - resizeObservers = []; - charts.forEach((chart) => { - if (chart) { - chart.dispose(); - } - }); - charts = []; - // eslint-disable-next-line svelte/no-dom-manipulating - node.replaceChildren(); + if (!mounted) return; - const dataReq = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise` - ); - const data = await dataReq.json(); + loading = true; + chartInstances = []; + chartComponents = []; + const dataReq = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise` + ); + const data = await dataReq.json(); + + // ─── Compute daylight bands ───────────────────────────────────── + + let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> = + []; + + if ('daily' in data) { + // Find the first model-suffixed key for sunrise/sunset let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_'); dailyFirstModelKey.shift(); dailyFirstModelKey = dailyFirstModelKey.join('_'); - // Create day/night plot bands as markArea data - let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> = - []; - if ( - 'daily' in data && - 'sunrise_' + dailyFirstModelKey in data.daily && - 'sunset_' + dailyFirstModelKey in data.daily - ) { - let rise = data.daily['sunrise_' + dailyFirstModelKey]; - let set = data.daily['sunset_' + dailyFirstModelKey]; - markAreas = rise.map(function (r: number, i: number) { - return [ - { - xAxis: (r + data.utc_offset_seconds) * 1000, - itemStyle: { - color: 'rgba(255, 255, 194, 0.3)' - } - }, - { - xAxis: (set[i] + data.utc_offset_seconds) * 1000 - } - ]; - }); - } + const sunriseKey = 'sunrise_' + dailyFirstModelKey; + const sunsetKey = 'sunset_' + dailyFirstModelKey; - const textColor = getTextColor(); - const axisLineColor = getAxisLineColor(); - const splitLineColor = getSplitLineColor(); - - for (let variable of params.hourly || []) { - const chartDiv = document.createElement('div'); - chartDiv.style.width = '100%'; - chartDiv.style.height = showLegend ? '400px' : '300px'; - - // Append to DOM BEFORE echarts.init so it can measure dimensions - // eslint-disable-next-line svelte/no-dom-manipulating - node.appendChild(chartDiv); - - let unit: string = ''; - - const series: Array> = []; - let average = new Array(data.hourly.time.length).fill(0); - let averageCount = new Array(data.hourly.time.length).fill(0); - - const timestamps = data.hourly.time.map( - (t: number) => (t + data.utc_offset_seconds) * 1000 + if (sunriseKey in data.daily && sunsetKey in data.daily) { + markAreas = buildDaylightMarkAreas( + data.daily[sunriseKey], + data.daily[sunsetKey], + data.utc_offset_seconds ); - - for (let [model, values] of Object.entries(data.hourly)) { - if (model === 'time') { - continue; - } - if (model.startsWith(variable)) { - for (let [index, val] of (values as number[]).entries()) { - if (val !== null && val !== undefined) { - let avVal = average[index]; - average[index] = avVal + val; - averageCount[index]++; - } - } - - unit = data.hourly_units[model]; - - if (!averageOnly) { - const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'; - - const seriesData = (values as (number | null)[]).map( - (val: number | null, idx: number) => [timestamps[idx], val] - ); - - series.push({ - name: model, - type: isColumn ? 'bar' : 'line', - data: seriesData, - smooth: !isColumn, - showSymbol: false, - lineStyle: { - width: 2 - }, - emphasis: { - lineStyle: { - width: 3 - } - }, - barMaxWidth: 5 - }); - } - } - } - - // Calculate average - for (let [index, val] of average.entries()) { - average[index] = Math.round((val / averageCount[index]) * 10) / 10; - } - - const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'; - const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]); - - series.push({ - name: variable + '_average', - type: isColumn ? 'bar' : 'line', - data: averageData, - smooth: !isColumn, - showSymbol: false, - lineStyle: { - type: 'dashed', - width: 4, - color: '#5e5e5e' - }, - itemStyle: { - color: '#5e5e5e' - }, - emphasis: { - lineStyle: { - width: 6 - } - }, - barMaxWidth: 5, - z: 10 - }); - - // Add current time markLine via a helper series - series.push({ - name: 'Current Time', - type: 'line', - data: [], - markLine: { - silent: true, - symbol: 'none', - data: [ - { - xAxis: Date.now() + data.utc_offset_seconds * 1000, - lineStyle: { - color: 'red', - width: 2 - }, - label: { - show: false - } - } - ] - } - }); - - // Add day/night bands via markArea - if (markAreas.length > 0) { - series.push({ - name: 'Daylight', - type: 'line', - data: [], - markArea: { - silent: true, - data: markAreas - } - }); - } - - const option: Record = { - title: { - text: count === 0 ? 'Model Compare' : '', - left: 'left', - textStyle: { - fontWeight: 'normal', - color: textColor - }, - ...(count === 0 - ? { - subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`, - subtextStyle: { - fontWeight: 'normal', - color: textColor - } - } - : {}) - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'cross', - animation: false - }, - valueFormatter: (value: number) => { - if (value === null || value === undefined) return '-'; - return value.toFixed(1) + ' ' + unit; - } - }, - legend: { - show: showLegend, - bottom: 0, - type: 'scroll', - textStyle: { - color: textColor - } - }, - grid: { - left: 60, - right: 10, - top: count === 0 ? 80 : 40, - bottom: showLegend ? 60 : 40 - }, - xAxis: { - type: 'time', - splitLine: { - show: false - }, - axisLine: { - lineStyle: { - color: axisLineColor - } - }, - axisLabel: { - color: textColor - } - }, - yAxis: { - type: 'value', - name: unit, - nameTextStyle: { - color: textColor - }, - axisLine: { - show: false - }, - axisLabel: { - color: textColor - }, - splitLine: { - lineStyle: { - color: splitLineColor - } - } - }, - series: series, - textStyle: { - color: textColor - } - }; - - // Add credits for last chart - if (count === (params.hourly?.length || 0) - 1) { - option.graphic = [ - { - type: 'text', - right: 10, - bottom: 5, - style: { - text: 'Open-Meteo.com', - fontSize: 10, - fill: textColor, - opacity: 0.5 - }, - onclick: function () { - window.open('https://open-meteo.com', '_blank'); - }, - cursor: 'pointer' - } - ]; - } - - const chart = echarts.init(chartDiv, null, { renderer: 'canvas' }); - charts.push(chart); - chart.setOption(option); - - // Handle responsive resize - const resizeObserver = new ResizeObserver(() => { - chart.resize(); - }); - resizeObserver.observe(chartDiv); - resizeObservers.push(resizeObserver); - - count++; } } - }; - loadData(); - }); - onDestroy(() => { - resizeObservers.forEach((ro) => ro.disconnect()); - resizeObservers = []; - charts.forEach((chart) => { - chart.dispose(); - }); - charts = []; + // ─── Build chart options for each variable ────────────────────── + + const colors = getThemeColors(); + const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); + const variableCount = params.hourly?.length || 0; + const newOptions: Array> = []; + + for (let vi = 0; vi < variableCount; vi++) { + const variable = params.hourly![vi]; + const unit = findUnit(data.hourly_units, data.hourly, variable); + const timeLength = data.hourly.time.length; + + // ─── Build individual model series ─────────────────────────── + + const series: Array> = []; + + if (!averageOnly) { + for (const [model, values] of Object.entries(data.hourly)) { + if (model === 'time') continue; + if (!model.startsWith(variable)) continue; + + const seriesData = (values as (number | null)[]).map( + (val, idx) => [timestamps[idx], val] as [number, number | null] + ); + + series.push( + buildModelSeries({ + name: model, + data: seriesData, + unit + }) + ); + } + } + + // ─── Average series ───────────────────────────────────────── + + const { average } = calculateAverage(data.hourly, variable, timeLength); + const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]); + + series.push(buildAverageSeries({ variable, data: averageData, unit })); + + // ─── Annotation series ─────────────────────────────────────── + + series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds })); + + const daylightSeries = buildDaylightSeries({ markAreas }); + if (daylightSeries) { + series.push(daylightSeries); + } + + // ─── Compose final option ─────────────────────────────────── + + const isFirst = vi === 0; + const isLast = vi === variableCount - 1; + + const option = composeChartOption({ + title: isFirst + ? { + text: 'Model Compare', + subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` + } + : null, + tooltip: { unit }, + legend: { show: showLegend }, + grid: { + hasTitle: isFirst, + hasSubtitle: isFirst, + showLegend + }, + yAxis: { unit }, + series, + toolbox: { + saveAsImage: true, + fileName: `model-compare-${variable}`, + format: 'png' + }, + showCredit: isLast, + colors + }); + + newOptions.push(option); + } + + chartOptions = newOptions; + loading = false; + }; + + loadData(); }); - -
+ + -
-
- - - - -
+ {#each chartOptions as option, i (i)} + + {/each} +
+ + + +
+ + {#snippet controls()} +
+ { + params.hourly = params.hourly; + }} + /> + +
+
+ { + params.hourly = params.hourly; + }} + /> + +
+ {/snippet} +
-
-
-
- { - params.hourly = params.hourly; - }} - /> - -
-
- { - params.hourly = params.hourly; - }} - /> - -
-
-
+
@@ -487,7 +305,8 @@ {/each}
- + +