remove echarts, use canvas
This commit is contained in:
@@ -82,7 +82,7 @@
|
||||
timezone: result.timezone,
|
||||
timestamps: result.hourlyTimestamps,
|
||||
hourlyDates: result.hourlyDates,
|
||||
markAreas: result.markAreas
|
||||
daylightBands: result.daylightBands
|
||||
};
|
||||
|
||||
fetchedDaily = {
|
||||
@@ -102,8 +102,8 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Weather | Open-Meteo.com</title>
|
||||
<link rel="canonical" href="https://open-meteo.com/weather" />
|
||||
<title>OMbrella | Weather</title>
|
||||
<link rel="canonical" href="https://ombrella.servert.ch/weather/week" />
|
||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</script>
|
||||
|
||||
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
||||
<div class="flex gap-1 overflow-x-auto pb-1" style="scrollbar-width: thin">
|
||||
<div class="flex gap-2 overflow-x-auto p-1 pb-2" style="scrollbar-width: thin">
|
||||
{#if daily}
|
||||
{#each daily.dailyDates as time, index (index)}
|
||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||
@@ -57,100 +57,90 @@
|
||||
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||
{@const unit = String(units.temperature_unit)}
|
||||
{@const maxStyle = getTempStyle(tempMax, unit)}
|
||||
{@const minStyle = getTempStyle(tempMin, unit)}
|
||||
{#if tempMax != null && !isNaN(tempMax)}
|
||||
<button
|
||||
class="group flex min-w-[108px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-0.5 rounded-xl border-2 px-1.5 py-2 transition-all duration-200
|
||||
class="group relative flex min-w-[112px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200
|
||||
{selected
|
||||
? 'scale-[1.03] border-primary bg-accent shadow-md'
|
||||
: 'border-transparent bg-card hover:bg-accent'}"
|
||||
? 'border-primary/50 bg-primary/5 shadow-md ring-2 ring-primary/40'
|
||||
: 'border-border/60 bg-card shadow-xs hover:-translate-y-0.5 hover:border-border hover:shadow-md'}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => onSelectDay(time, index)}
|
||||
>
|
||||
<!-- Day label -->
|
||||
<span class="text-sm font-bold tracking-wide">
|
||||
<span class="text-[13px] font-semibold tracking-wider">
|
||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
<span class="-mt-1 text-[11px] text-muted-foreground">
|
||||
{getRelativeDayLabel(time, daily.timezone)}
|
||||
</span>
|
||||
|
||||
<!-- Weather icon -->
|
||||
<div
|
||||
class="my-1 flex w-full items-center justify-center rounded-lg py-1.5"
|
||||
style="background: {sunColor}22"
|
||||
>
|
||||
<svg class="fill-foreground" width="48px" height="48px">
|
||||
<use
|
||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
||||
wCode as keyof typeof weatherCodes
|
||||
] ?? 'clear'}.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
</div>
|
||||
<svg class="day-icon my-1 fill-foreground" width="46px" height="46px">
|
||||
<use
|
||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
||||
wCode as keyof typeof weatherCodes
|
||||
] ?? 'clear'}.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
|
||||
<!-- Temperature max/min -->
|
||||
<div class="flex w-full flex-col">
|
||||
<div
|
||||
class="w-full rounded-t px-1 py-0.5 text-center text-[15px] font-bold"
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span
|
||||
class="rounded-lg px-2 py-0.5 text-[15px] font-bold tabular-nums"
|
||||
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
|
||||
>
|
||||
{tempMax.toFixed(0)}°
|
||||
</div>
|
||||
<div
|
||||
class="w-full rounded-b px-1 py-0.5 text-center text-xs font-semibold"
|
||||
style="background-color: {minStyle.bg}; color: {minStyle.fg}"
|
||||
>
|
||||
</span>
|
||||
<span class="text-sm font-medium tabular-nums text-muted-foreground">
|
||||
{tempMin.toFixed(0)}°
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Details section -->
|
||||
<div class="mt-1 flex w-full flex-col items-center gap-0.5">
|
||||
<!-- Sunshine bar -->
|
||||
<div class="flex w-full items-center gap-1 px-1">
|
||||
<svg class="shrink-0" width="14px" height="14px" style="fill: {sunColor}">
|
||||
<!-- Details -->
|
||||
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
|
||||
<!-- Sunshine -->
|
||||
<div class="flex w-full items-center gap-1.5">
|
||||
<svg class="shrink-0" width="13px" height="13px" style="fill: {sunColor}">
|
||||
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
||||
</svg>
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
style="width: {sunPct}%; background-color: {sunColor}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] font-medium text-muted-foreground">
|
||||
<span class="text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Precipitation -->
|
||||
<div class="flex items-center gap-1 text-[11px]">
|
||||
<svg class="fill-foreground shrink-0" width="14px" height="14px">
|
||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||
</svg>
|
||||
<span>
|
||||
<!-- Precipitation + wind -->
|
||||
<div
|
||||
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
|
||||
>
|
||||
<span class="inline-flex items-center gap-0.5">
|
||||
<svg class="shrink-0 fill-foreground/70" width="13px" height="13px">
|
||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||
</svg>
|
||||
{Number(precipSum ?? 0).toFixed(
|
||||
precipSum >= 10 ? 0 : 1
|
||||
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Wind with direction -->
|
||||
<div class="flex items-center gap-1 text-[11px]">
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<div
|
||||
class="inline-flex shrink-0"
|
||||
style="transform: {getWindArrowRotation(windDir)}"
|
||||
>
|
||||
<svg class="fill-foreground" width="20px" height="20px">
|
||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||
<span class="inline-flex items-center gap-0.5">
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="inline-flex shrink-0"
|
||||
style="transform: {getWindArrowRotation(windDir)}"
|
||||
>
|
||||
<svg class="fill-foreground/70" width="16px" height="16px">
|
||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||
</svg>
|
||||
</span>
|
||||
{:else}
|
||||
<svg class="shrink-0 fill-foreground/70" width="16px" height="16px">
|
||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<svg class="fill-foreground shrink-0" width="20px" height="20px">
|
||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>
|
||||
{/if}
|
||||
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
|
||||
>-{gustMax?.toFixed(0) ?? '-'}</span
|
||||
>
|
||||
@@ -167,12 +157,12 @@
|
||||
<style>
|
||||
@media (max-width: 768px) {
|
||||
button {
|
||||
min-width: 92px !important;
|
||||
min-width: 96px !important;
|
||||
}
|
||||
|
||||
button :global(svg[width='48px']) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
button :global(.day-icon) {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -189,27 +189,29 @@
|
||||
{formatZoned(selectedDay, data.timezone, 'EEEE')} – Hourly
|
||||
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<span class="select-none text-muted-foreground">3h</span>
|
||||
<button
|
||||
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
||||
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
||||
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
||||
title="Toggle between 1-hour and 3-hour intervals"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] size-[18px] rounded-full bg-white shadow-sm transition-[left] duration-200
|
||||
{hourlyInterval === 1 ? 'left-[22px]' : 'left-[3px]'}"
|
||||
></span>
|
||||
</button>
|
||||
<span class="select-none text-muted-foreground">1h</span>
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
||||
role="group"
|
||||
aria-label="Hourly interval"
|
||||
>
|
||||
{#each [3, 1] as interval (interval)}
|
||||
<button
|
||||
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval === interval
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={hourlyInterval === interval}
|
||||
onclick={() => (hourlyInterval = interval as 1 | 3)}
|
||||
>
|
||||
{interval}h
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if cellData.length > 0}
|
||||
{@const hourly = data.hourly}
|
||||
{@const iconPx = is3h ? 40 : 26}
|
||||
<div class="overflow-hidden rounded-lg border border-border">
|
||||
<div class="overflow-hidden rounded-xl border border-border/70 bg-card shadow-xs">
|
||||
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
||||
<colgroup>
|
||||
@@ -425,7 +427,7 @@
|
||||
|
||||
<style>
|
||||
tr {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
border-top: 1px solid hsl(var(--border) / 0.6);
|
||||
}
|
||||
|
||||
/* ── Base cell ──────────────────────────────────────────── */
|
||||
@@ -434,7 +436,8 @@
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-right: 1px solid hsl(var(--border) / 0.2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
border-right: 1px solid hsl(var(--border) / 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -444,7 +447,7 @@
|
||||
|
||||
.cell.now {
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
|
||||
}
|
||||
|
||||
/* ── Row header ─────────────────────────────────────────── */
|
||||
@@ -453,8 +456,8 @@
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
background: hsl(var(--background));
|
||||
border-right: 2px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -473,7 +476,7 @@
|
||||
}
|
||||
|
||||
.precip-cell.now {
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
|
||||
}
|
||||
|
||||
.precip-bar {
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
||||
|
||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
||||
import { echarts } from '$lib/components/charts/echarts';
|
||||
import '$lib/components/charts/echarts.css';
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
|
||||
import { CanvasChart, type ChartSeries } from '$lib/charts';
|
||||
|
||||
import { getColor } from '../../utils/colors';
|
||||
import {
|
||||
type FetchedHourly,
|
||||
type WeatherUnits,
|
||||
@@ -18,8 +16,6 @@
|
||||
getWindUnit
|
||||
} from './types';
|
||||
|
||||
import type { ECharts } from 'echarts';
|
||||
|
||||
interface Props {
|
||||
data: FetchedHourly;
|
||||
selectedDay: Date;
|
||||
@@ -31,15 +27,18 @@
|
||||
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
||||
|
||||
const CHART_GROUP = 'week-meteogram';
|
||||
const MS_PER_DAY = 24 * 3600 * 1000;
|
||||
const SECONDS_PER_DAY = 24 * 3600;
|
||||
|
||||
let showCharts = $state(false);
|
||||
let chartComponents: EChart[] = $state([]);
|
||||
let chartInstances: ECharts[] = $state([]);
|
||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||
let chartComponents: CanvasChart[] = $state([]);
|
||||
|
||||
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
|
||||
|
||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||
|
||||
export function scrollToDay(day: Date): void {
|
||||
if (chartInstances.length === 0 || !data) return;
|
||||
if (!data || liveCharts.length === 0) return;
|
||||
|
||||
const tz = data.timezone;
|
||||
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
|
||||
@@ -49,436 +48,150 @@
|
||||
|
||||
if (firstHourIdx === -1) return;
|
||||
|
||||
const dayStart = data.timestamps[firstHourIdx];
|
||||
const dayEnd = dayStart + MS_PER_DAY;
|
||||
const timestamps = data.timestamps;
|
||||
const rangeStart = timestamps[0];
|
||||
const rangeEnd = timestamps[timestamps.length - 1];
|
||||
const totalRange = rangeEnd - rangeStart;
|
||||
|
||||
if (totalRange <= 0) return;
|
||||
|
||||
const startPct = Math.max(0, ((dayStart - rangeStart) / totalRange) * 100);
|
||||
const endPct = Math.min(100, ((dayEnd - rangeStart) / totalRange) * 100);
|
||||
|
||||
for (const chart of chartInstances) {
|
||||
if (chart && !chart.isDisposed()) {
|
||||
chart.dispatchAction({ type: 'dataZoom', start: startPct, end: endPct });
|
||||
}
|
||||
}
|
||||
const dayStart = data.timestamps[firstHourIdx] / 1000;
|
||||
// Charts share the group, so setting the range on one syncs all of them
|
||||
liveCharts[0].setRange(dayStart, dayStart + SECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
function resetZoom(): void {
|
||||
for (const chart of chartInstances) {
|
||||
if (chart && !chart.isDisposed()) {
|
||||
chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 });
|
||||
}
|
||||
}
|
||||
liveCharts[0]?.resetRange();
|
||||
onResetZoom?.();
|
||||
}
|
||||
|
||||
function handleChartReady(chart: ECharts): void {
|
||||
chart.group = CHART_GROUP;
|
||||
chartInstances = [...chartInstances, chart];
|
||||
if (chartInstances.length === 3) {
|
||||
echarts.connect(CHART_GROUP);
|
||||
// Zoom to the selected day once all three charts are mounted
|
||||
let scrolledOnMount = false;
|
||||
$effect(() => {
|
||||
if (!showCharts) {
|
||||
scrolledOnMount = false;
|
||||
return;
|
||||
}
|
||||
if (!scrolledOnMount && liveCharts.length === 3 && data) {
|
||||
scrolledOnMount = true;
|
||||
requestAnimationFrame(() => scrollToDay(selectedDay));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Series Building ────────────────────────────────────────────────────────
|
||||
|
||||
let tempUnit = $derived(getTempUnit(units));
|
||||
let precipUnit = $derived(getPrecipUnit(units));
|
||||
let windUnit = $derived(getWindUnit(units));
|
||||
|
||||
interface ChartDef {
|
||||
title: string;
|
||||
unit: string;
|
||||
unitRight?: string;
|
||||
yMin?: number;
|
||||
yMinRight?: number;
|
||||
yMaxRight?: number;
|
||||
invertRight?: boolean;
|
||||
showCredit?: boolean;
|
||||
series: ChartSeries[];
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!data) return;
|
||||
let chartDefs = $derived.by((): ChartDef[] => {
|
||||
if (!data) return [];
|
||||
|
||||
const { hourly, timestamps, markAreas } = data;
|
||||
const colors = getThemeColors();
|
||||
const tempUnit = getTempUnit(units);
|
||||
const precipUnit = getPrecipUnit(units);
|
||||
const windUnit = getWindUnit(units);
|
||||
const { hourly } = data;
|
||||
|
||||
const temps = hourly.temperature_2m;
|
||||
const precip = hourly.precipitation;
|
||||
const precipProb = hourly.precipitation_probability;
|
||||
const cloudCov = hourly.cloud_cover;
|
||||
const windSpeed = hourly.windspeed_10m;
|
||||
const humidity = hourly.relative_humidity_2m;
|
||||
|
||||
const validTemps = temps.filter((t: number) => t !== null && !isNaN(t));
|
||||
const minTemp = Math.min(...validTemps);
|
||||
const maxTemp = Math.max(...validTemps);
|
||||
|
||||
const tempData = temps.map((v: number, i: number) => [timestamps[i], v]);
|
||||
const cloudData = cloudCov.map((v: number, i: number) => [timestamps[i], v]);
|
||||
const precipData = precip.map((v: number, i: number) => [timestamps[i], v]);
|
||||
const precipProbData = precipProb.map((v: number, i: number) => [timestamps[i], v]);
|
||||
const windData = windSpeed.map((v: number, i: number) => [timestamps[i], v]);
|
||||
const humidityData = humidity.map((v: number, i: number) => [timestamps[i], v]);
|
||||
|
||||
const annotations = (): Array<Record<string, unknown>> => {
|
||||
const series: Array<Record<string, unknown>> = [];
|
||||
series.push(buildCurrentTimeSeries());
|
||||
const dl = buildDaylightSeries({ markAreas });
|
||||
if (dl) series.push(dl);
|
||||
return series;
|
||||
};
|
||||
|
||||
const timeXAxis = (showLabel: boolean): Record<string, unknown> => ({
|
||||
type: 'time',
|
||||
splitLine: { show: false },
|
||||
axisLine: { lineStyle: { color: colors.axisLine } },
|
||||
axisLabel: {
|
||||
color: colors.text,
|
||||
hideOverlap: true,
|
||||
show: showLabel,
|
||||
formatter: (value: number) => formatZoned(new Date(value), data.timezone, 'HH:mm')
|
||||
},
|
||||
axisTick: { lineStyle: { color: colors.axisLine } }
|
||||
});
|
||||
|
||||
const insideZoom = (): Record<string, unknown> => ({
|
||||
type: 'inside',
|
||||
xAxisIndex: 0,
|
||||
filterMode: 'none',
|
||||
zoomOnMouseWheel: true,
|
||||
moveOnMouseMove: true,
|
||||
moveOnMouseWheel: false
|
||||
});
|
||||
|
||||
const sliderZoom = (): Record<string, unknown> => ({
|
||||
type: 'slider',
|
||||
xAxisIndex: 0,
|
||||
filterMode: 'none',
|
||||
height: 20,
|
||||
bottom: 4,
|
||||
borderColor: colors.axisLine,
|
||||
fillerColor: 'rgba(100, 140, 200, 0.2)',
|
||||
handleStyle: { color: colors.text },
|
||||
textStyle: { color: colors.text, fontSize: 10 },
|
||||
dataBackground: {
|
||||
lineStyle: { color: colors.axisLine },
|
||||
areaStyle: { color: colors.splitLine }
|
||||
},
|
||||
selectedDataBackground: {
|
||||
lineStyle: { color: colors.axisLine },
|
||||
areaStyle: { color: 'rgba(100, 140, 200, 0.15)' }
|
||||
}
|
||||
});
|
||||
|
||||
const tooltipBase = (
|
||||
formatter: (
|
||||
params: Array<{
|
||||
axisValue: number;
|
||||
seriesName: string;
|
||||
marker: string;
|
||||
value: number | number[] | null;
|
||||
}>
|
||||
) => string
|
||||
): Record<string, unknown> => ({
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
animation: false,
|
||||
label: {
|
||||
backgroundColor: colors.tooltipBg,
|
||||
color: colors.text,
|
||||
borderColor: colors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
formatter: (params: { axisDimension: string; value: number }) => {
|
||||
if (params.axisDimension === 'x') {
|
||||
return formatZoned(new Date(params.value), data.timezone, 'EEE d MMM HH:mm');
|
||||
}
|
||||
return params.value.toFixed(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundColor: colors.tooltipBg,
|
||||
borderColor: colors.tooltipBorder,
|
||||
textStyle: { color: colors.text },
|
||||
formatter
|
||||
});
|
||||
|
||||
const formatDate = (ts: number): string => {
|
||||
const date = new Date(ts);
|
||||
const dateStr = formatZoned(date, data.timezone, 'EEE d MMM HH:mm');
|
||||
return `<b>${dateStr}</b><br/>`;
|
||||
};
|
||||
|
||||
const isAnnotation = (name: string): boolean => name === 'Daylight' || name === 'Current Time';
|
||||
|
||||
const tempOption: Record<string, unknown> = {
|
||||
title: {
|
||||
text: 'Temperature & Cloud Cover',
|
||||
left: 'left',
|
||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
||||
},
|
||||
tooltip: tooltipBase((params) => {
|
||||
if (!params?.length) return '';
|
||||
let html = formatDate(params[0].axisValue as number);
|
||||
for (const item of params) {
|
||||
const name = item.seriesName as string;
|
||||
if (isAnnotation(name)) continue;
|
||||
const val = (item.value as [number, number])?.[1];
|
||||
if (val == null) continue;
|
||||
if (name === 'Temperature')
|
||||
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${tempUnit}</b><br/>`;
|
||||
else if (name === 'Cloud Cover')
|
||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
||||
}
|
||||
return html;
|
||||
}),
|
||||
legend: {
|
||||
show: true,
|
||||
bottom: 0,
|
||||
textStyle: { color: colors.text },
|
||||
data: ['Temperature', 'Cloud Cover']
|
||||
},
|
||||
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
||||
dataZoom: [insideZoom()],
|
||||
xAxis: timeXAxis(false),
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: tempUnit,
|
||||
nameTextStyle: { color: colors.text },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: colors.text },
|
||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
||||
},
|
||||
{ type: 'value', min: 0, max: 250, inverse: true, show: false }
|
||||
],
|
||||
const tempChart: ChartDef = {
|
||||
title: 'Temperature & Cloud Cover',
|
||||
unit: tempUnit,
|
||||
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
|
||||
// occupying at most the upper 40% of the plot
|
||||
yMinRight: 0,
|
||||
yMaxRight: 250,
|
||||
invertRight: true,
|
||||
series: [
|
||||
{
|
||||
name: 'Temperature',
|
||||
type: 'line',
|
||||
data: tempData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 3, color: '#ef6c00' },
|
||||
itemStyle: { color: '#ef6c00' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getColor(maxTemp, String(units.temperature_unit)) + '88'
|
||||
},
|
||||
{
|
||||
offset: 0.5,
|
||||
color: getColor((maxTemp + minTemp) / 2, String(units.temperature_unit)) + '44'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getColor(minTemp, String(units.temperature_unit)) + '08'
|
||||
}
|
||||
])
|
||||
},
|
||||
z: 5
|
||||
},
|
||||
{
|
||||
name: 'Cloud Cover',
|
||||
type: 'line',
|
||||
data: cloudData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
yAxisIndex: 1,
|
||||
lineStyle: { width: 0 },
|
||||
itemStyle: { color: colors.text },
|
||||
areaStyle: { color: 'rgba(150, 150, 150, 0.25)', origin: 'start' },
|
||||
z: 1,
|
||||
silent: true
|
||||
color: 'rgb(150, 150, 150)',
|
||||
data: hourly.cloud_cover,
|
||||
width: 0,
|
||||
fill: true,
|
||||
fillOpacity: 0.25,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
},
|
||||
...annotations()
|
||||
],
|
||||
textStyle: { color: colors.text }
|
||||
{
|
||||
name: 'Temperature',
|
||||
type: 'line',
|
||||
color: '#ef6c00',
|
||||
data: hourly.temperature_2m,
|
||||
width: 3,
|
||||
fill: true,
|
||||
fillOpacity: 0.2,
|
||||
format: (v) => `${v.toFixed(1)} ${tempUnit}`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const precipOption: Record<string, unknown> = {
|
||||
title: {
|
||||
text: 'Precipitation & Probability',
|
||||
left: 'left',
|
||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
||||
},
|
||||
tooltip: tooltipBase((params) => {
|
||||
if (!params?.length) return '';
|
||||
let html = formatDate(params[0].axisValue as number);
|
||||
for (const item of params) {
|
||||
const name = item.seriesName as string;
|
||||
if (isAnnotation(name)) continue;
|
||||
const val = (item.value as [number, number])?.[1];
|
||||
if (val == null) continue;
|
||||
if (name === 'Precipitation')
|
||||
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${precipUnit}</b><br/>`;
|
||||
else if (name === 'Precip. Probability')
|
||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
||||
}
|
||||
return html;
|
||||
}),
|
||||
legend: {
|
||||
show: true,
|
||||
bottom: 0,
|
||||
textStyle: { color: colors.text },
|
||||
data: ['Precipitation', 'Precip. Probability']
|
||||
},
|
||||
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
||||
dataZoom: [insideZoom()],
|
||||
xAxis: timeXAxis(false),
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: precipUnit,
|
||||
min: 0,
|
||||
nameTextStyle: { color: colors.text },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: colors.text },
|
||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '%',
|
||||
min: 0,
|
||||
max: 100,
|
||||
nameTextStyle: { color: colors.text },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: colors.text },
|
||||
splitLine: { show: false }
|
||||
}
|
||||
],
|
||||
const precipChart: ChartDef = {
|
||||
title: 'Precipitation & Probability',
|
||||
unit: precipUnit,
|
||||
unitRight: '%',
|
||||
yMin: 0,
|
||||
yMinRight: 0,
|
||||
yMaxRight: 100,
|
||||
series: [
|
||||
{
|
||||
name: 'Precipitation',
|
||||
type: 'bar',
|
||||
data: precipData,
|
||||
barMaxWidth: 8,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(30, 136, 229, 0.9)' },
|
||||
{ offset: 1, color: 'rgba(30, 136, 229, 0.4)' }
|
||||
])
|
||||
},
|
||||
yAxisIndex: 0,
|
||||
z: 5
|
||||
color: 'rgba(30, 136, 229, 0.8)',
|
||||
data: hourly.precipitation,
|
||||
format: (v) => `${v.toFixed(1)} ${precipUnit}`
|
||||
},
|
||||
{
|
||||
name: 'Precip. Probability',
|
||||
type: 'line',
|
||||
data: precipProbData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2, type: 'dashed', color: '#5c6bc0' },
|
||||
itemStyle: { color: '#5c6bc0' },
|
||||
yAxisIndex: 1,
|
||||
z: 4
|
||||
},
|
||||
...annotations()
|
||||
],
|
||||
textStyle: { color: colors.text }
|
||||
color: '#5c6bc0',
|
||||
data: hourly.precipitation_probability,
|
||||
width: 2,
|
||||
dashed: true,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const windOption: Record<string, unknown> = {
|
||||
title: {
|
||||
text: 'Wind Speed & Humidity',
|
||||
left: 'left',
|
||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
||||
},
|
||||
tooltip: tooltipBase((params) => {
|
||||
if (!params?.length) return '';
|
||||
let html = formatDate(params[0].axisValue as number);
|
||||
for (const item of params) {
|
||||
const name = item.seriesName as string;
|
||||
if (isAnnotation(name)) continue;
|
||||
const val = (item.value as [number, number])?.[1];
|
||||
if (val == null) continue;
|
||||
if (name === 'Wind Speed') {
|
||||
const idx = timestamps.indexOf(
|
||||
(params[0] as Record<string, unknown>).axisValue as number
|
||||
);
|
||||
const windDir = idx >= 0 ? hourly.winddirection_10m[idx] : null;
|
||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)} ${windUnit}</b>`;
|
||||
if (windDir != null && !isNaN(windDir)) html += ` (${getWindDirectionLabel(windDir)})`;
|
||||
html += '<br/>';
|
||||
} else if (name === 'Humidity') {
|
||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}),
|
||||
legend: {
|
||||
show: true,
|
||||
bottom: 28,
|
||||
textStyle: { color: colors.text },
|
||||
data: ['Wind Speed', 'Humidity']
|
||||
},
|
||||
grid: { left: 60, right: 60, top: 50, bottom: 60 },
|
||||
dataZoom: [insideZoom(), sliderZoom()],
|
||||
xAxis: timeXAxis(true),
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: windUnit,
|
||||
min: 0,
|
||||
nameTextStyle: { color: colors.text },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: colors.text },
|
||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '%',
|
||||
min: 0,
|
||||
max: 100,
|
||||
nameTextStyle: { color: colors.text },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: colors.text },
|
||||
splitLine: { show: false }
|
||||
}
|
||||
],
|
||||
const windChart: ChartDef = {
|
||||
title: 'Wind Speed & Humidity',
|
||||
unit: windUnit,
|
||||
unitRight: '%',
|
||||
yMin: 0,
|
||||
yMinRight: 0,
|
||||
yMaxRight: 100,
|
||||
showCredit: true,
|
||||
series: [
|
||||
{
|
||||
name: 'Wind Speed',
|
||||
type: 'line',
|
||||
data: windData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2, color: '#26a69a' },
|
||||
itemStyle: { color: '#26a69a' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(38, 166, 154, 0.25)' },
|
||||
{ offset: 1, color: 'rgba(38, 166, 154, 0.02)' }
|
||||
])
|
||||
},
|
||||
yAxisIndex: 0,
|
||||
z: 5
|
||||
color: '#26a69a',
|
||||
data: hourly.windspeed_10m,
|
||||
width: 2,
|
||||
fill: true,
|
||||
fillOpacity: 0.15,
|
||||
format: (v, i) => {
|
||||
const dir = hourly.winddirection_10m[i];
|
||||
const dirLabel = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
|
||||
return `${v.toFixed(0)} ${windUnit}${dirLabel}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Humidity',
|
||||
type: 'line',
|
||||
data: humidityData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2, type: 'dotted', color: '#8d6e63' },
|
||||
itemStyle: { color: '#8d6e63' },
|
||||
yAxisIndex: 1,
|
||||
z: 4
|
||||
},
|
||||
...annotations()
|
||||
],
|
||||
graphic: [
|
||||
{
|
||||
type: 'text',
|
||||
right: 10,
|
||||
bottom: 30,
|
||||
style: {
|
||||
text: 'Open-Meteo.com',
|
||||
fontSize: 10,
|
||||
fill: colors.text,
|
||||
opacity: 0.4
|
||||
},
|
||||
cursor: 'pointer'
|
||||
color: '#8d6e63',
|
||||
data: hourly.relative_humidity_2m,
|
||||
width: 2,
|
||||
dashed: true,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
}
|
||||
],
|
||||
textStyle: { color: colors.text }
|
||||
]
|
||||
};
|
||||
|
||||
chartOptions = [tempOption, precipOption, windOption];
|
||||
return [tempChart, precipChart, windChart];
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -520,19 +233,30 @@
|
||||
</div>
|
||||
|
||||
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
||||
{#each chartOptions as option, i (i)}
|
||||
<EChart
|
||||
{option}
|
||||
notMerge
|
||||
height={i === 2 ? '320px' : '300px'}
|
||||
onChartReady={handleChartReady}
|
||||
{#each chartDefs as def, i (def.title)}
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
timestamps={timestampsSec}
|
||||
timezone={data.timezone}
|
||||
series={def.series}
|
||||
bands={data.daylightBands}
|
||||
unit={def.unit}
|
||||
unitRight={def.unitRight}
|
||||
yMin={def.yMin}
|
||||
yMinRight={def.yMinRight}
|
||||
yMaxRight={def.yMaxRight}
|
||||
invertRight={def.invertRight}
|
||||
title={def.title}
|
||||
showCredit={def.showCredit}
|
||||
showLegend
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
/>
|
||||
{/each}
|
||||
</ChartContainer>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
|
||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MarkArea, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
|
||||
import type { DaylightBand, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
|
||||
|
||||
export interface WeatherUnits {
|
||||
temperature_unit: string;
|
||||
@@ -12,7 +12,7 @@ export interface FetchedHourly {
|
||||
timezone: string;
|
||||
timestamps: number[];
|
||||
hourlyDates: Date[];
|
||||
markAreas: MarkArea[];
|
||||
daylightBands: DaylightBand[];
|
||||
}
|
||||
|
||||
export interface FetchedDaily {
|
||||
|
||||
Reference in New Issue
Block a user