feat: canvas to echarts (#5)

Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/open-meteo-blue#5
This commit was merged in pull request #5.
This commit is contained in:
2026-02-16 00:33:58 +01:00
co-authored by terraputix
parent 2fe8cf92e1
commit 7a6cca5dad
19 changed files with 2449 additions and 1149 deletions
@@ -0,0 +1,524 @@
<script lang="ts">
import { pad } from '$lib/utils/index';
import { getTempStyle } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
import {
type FetchedDaily,
type FetchedHourly,
type WeatherUnits,
getPrecipUnit,
getTempUnit,
getWindArrowRotation,
getWindUnit,
isCurrentHour
} from './types';
interface Props {
data: FetchedHourly;
daily: FetchedDaily;
selectedDay: Date;
units: WeatherUnits;
locationName: string;
}
let { data, daily, selectedDay, units, locationName }: Props = $props();
let hourlyInterval = $state<1 | 3>(3);
const today = new Date();
const tempUnit = $derived(getTempUnit(units));
const windUnit = $derived(getWindUnit(units));
const precipUnit = $derived(getPrecipUnit(units));
let sunTimes = $derived(getSunTimes());
const PRECIP_MAX_MM = 10;
const PRECIP_MAX_INCH = 0.4;
let precipAbsMax = $derived(units.precipitation_unit === 'mm' ? PRECIP_MAX_MM : PRECIP_MAX_INCH);
function getSelectedDayDailyIndex(): number {
return findDailyIndex(selectedDay);
}
function getSunTimes(): { sunrise: Date; sunset: Date } | null {
const di = getSelectedDayDailyIndex();
if (di < 0) return null;
const rise = daily.daily.sunrise[di];
const set = daily.daily.sunset[di];
if (!rise || !set) return null;
return {
sunrise: new Date(rise * 1000),
sunset: new Date(set * 1000)
};
}
function timeToFraction(date: Date): number {
const totalMinutes = date.getHours() * 60 + date.getMinutes();
const firstMin = cellData[0].date.getHours() * 60;
const step = is3h ? 3 : 1;
const lastMin = cellData[cellData.length - 1].date.getHours() * 60 + step * 60;
const range = lastMin - firstMin;
if (range <= 0) return 0;
return Math.max(0, Math.min(1, (totalMinutes - firstMin) / range));
}
function formatTime(date: Date): string {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function formatTimezone(offsetSeconds: number): string {
const sign = offsetSeconds >= 0 ? '+' : '-';
const abs = Math.abs(offsetSeconds);
const hours = Math.floor(abs / 3600);
const minutes = Math.floor((abs % 3600) / 60);
return minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours}:${pad(minutes)}`;
}
let timezoneLabel = $derived(formatTimezone(data.utc_offset_seconds));
function findDailyIndex(date: Date): number {
return daily.dailyDates.findIndex(
(dd) =>
dd.getDate() === date.getDate() &&
dd.getMonth() === date.getMonth() &&
dd.getFullYear() === date.getFullYear()
);
}
function isDaytime(hourDate: Date): boolean {
const di = findDailyIndex(hourDate);
if (di < 0) return true;
const sunrise = daily.daily.sunrise[di];
const sunset = daily.daily.sunset[di];
if (!sunrise || !sunset) return true;
const ts = Math.floor(hourDate.getTime() / 1000);
return ts >= sunrise && ts < sunset;
}
function getDayIndices(dates: Date[], day: Date): number[] {
return dates.reduce<number[]>((acc, d, i) => {
if (
d.getDate() === day.getDate() &&
d.getMonth() === day.getMonth() &&
d.getFullYear() === day.getFullYear() &&
(hourlyInterval === 1 || d.getHours() % 3 === 0)
) {
acc.push(i);
}
return acc;
}, []);
}
function getPrecipBarHeight(val: number): number {
if (!val || val <= 0) return 0;
return Math.min(100, (val / precipAbsMax) * 100);
}
function getPrecipProbBg(prob: number): string {
if (!prob || prob <= 0) return 'transparent';
return `rgba(30, 100, 220, ${(Math.round(prob / 10) / 100) * 10 * 0.45})`;
}
function getCloudOpacity(cover: number): number {
return Math.min(0.55, (cover ?? 0) / 150);
}
function getHumidityBg(hum: number): string {
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
}
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
const parts: string[] = [];
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
if (precip != null && precip > 0) parts.push(`Amount: ${precip.toFixed(1)} ${precipUnit}`);
return parts.join('\n');
}
function formatTemp(temp: number | null): string {
return temp != null ? `${temp.toFixed(0)}°` : '-';
}
function formatValue(val: number | null): string {
return val != null ? val.toFixed(0) : '-';
}
let dayIdx = $derived(getDayIndices(data.hourlyDates, selectedDay));
let is3h = $derived(hourlyInterval === 3);
let daytimeFlags = $derived(dayIdx.map((idx) => isDaytime(data.hourlyDates[idx])));
let cellData = $derived(
dayIdx.map((idx, i) => ({
idx,
date: data.hourlyDates[idx],
isNow: isCurrentHour(data.hourlyDates[idx], today),
isDaytime: daytimeFlags[i]
}))
);
let sunrisePercent = $derived(
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunrise) * 100 : null
);
let sunsetPercent = $derived(
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
);
function getWeatherIconName(code: number, daytime: boolean): string {
const prefix = daytime ? 'day' : 'night';
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
return `wi-${prefix}-${name}`;
}
</script>
{#snippet weatherIcon(name: string, size: number = 16)}
<svg class="inline-block fill-foreground" width={size} height={size}>
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg>
{/snippet}
{#snippet rowHeader(iconName?: string, unit?: string, label?: string)}
<th class="hdr" scope="row">
<div class="flex flex-col items-center leading-tight">
{#if iconName}
{@render weatherIcon(iconName)}
{/if}
{#if label}
<span class="text-[11px] font-semibold text-muted-foreground">{label}</span>
{/if}
{#if unit}
<span class="text-[10px] font-semibold text-muted-foreground">{unit}</span>
{/if}
</div>
</th>
{/snippet}
<!-- Header -->
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold">
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} 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>
</div>
{#if cellData.length > 0}
{@const hourly = data.hourly}
{@const iconPx = is3h ? 40 : 26}
<div class="overflow-hidden rounded-lg border border-border">
<table class="w-full table-fixed border-collapse whitespace-nowrap">
<caption class="sr-only">Hourly weather details for {locationName}</caption>
<colgroup>
<col class="w-14 md:w-16" />
{#each cellData as _ (_.idx)}
<col />
{/each}
</colgroup>
<tbody>
<!-- Time + Daylight bar (merged) -->
<tr class="!border-t-0">
<th class="hdr" scope="row">
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span>
</th>
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
<!-- Daylight background -->
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
<div
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/30"
style="width:{sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-400/10"
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/30"
style="width:{100 - sunsetPercent}%"
></div>
<!-- Sunrise marker + label -->
<div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%">
<span
class="absolute bottom-0.5 left-1 whitespace-nowrap text-[10px] font-semibold leading-none text-amber-700 dark:text-amber-300"
>
<svg
class="fill-foreground inline-block"
width="12px"
height="12px"
aria-hidden="true"
>
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
></use>
</svg>
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
</span>
</div>
<!-- Sunset marker + label -->
<div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%">
<span
class="absolute bottom-0.5 right-1 whitespace-nowrap text-[10px] font-semibold leading-none text-indigo-600 dark:text-indigo-300 inline-flex items-center gap-1"
>
<svg
class="fill-foreground inline-block"
width="12px"
height="12px"
aria-hidden="true"
>
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
></use>
</svg>
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
</span>
</div>
{/if}
<!-- Hour labels -->
{#each cellData as cell, i (cell.idx)}
{@const leftPct = (i / cellData.length) * 100}
{@const widthPct = 100 / cellData.length}
<span
class="absolute top-0 flex items-start pt-1 font-bold pl-0.5 text-sm
{cell.isNow ? 'text-destructive' : ''}"
style="left:{leftPct}%;width:{widthPct}%"
>
{#if is3h}
{pad(cell.date.getHours())}
{:else}
<span class="inline-flex items-baseline gap-1">
<span class="text-[11px] font-semibold">{pad(cell.date.getHours())}</span>
<sup
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
>00</sup
>
</span>
{/if}
</span>
{/each}
</td>
</tr>
<!-- Weather Icons -->
<tr>
{@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
<td
class="cell leading-[0] {is3h ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}"
class:now={cell.isNow}
class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
>
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
</td>
{/each}
</tr>
<!-- Temperature -->
<tr>
{@render rowHeader('wi-thermometer', tempUnit)}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<td
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}"
class:now={cell.isNow}
style="background-color:{style.bg};color:{style.fg}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
<!-- Feels Like -->
<tr>
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
class:now={cell.isNow}
>
{formatTemp(temp)}
</td>
{/each}
</tr>
<!-- Wind -->
<tr>
{@render rowHeader('wi-strong-wind', windUnit)}
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}>
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-block leading-[0]"
style="transform:{getWindArrowRotation(windDir)}"
>
{@render weatherIcon('wi-direction-down', 24)}
</span>
{/if}
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}">
{formatValue(wind)}
</span>
</td>
{/each}
</tr>
<!-- Humidity -->
<tr>
{@render rowHeader('wi-humidity', '%')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)}
</td>
{/each}
</tr>
<!-- Cloud Cover -->
<tr>
{@render rowHeader('wi-cloud', '%')}
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell"
class:now={cell.isNow}
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
>
{formatValue(cloud)}
</td>
{/each}
</tr>
<!-- Precipitation -->
<tr>
{@render rowHeader('wi-raindrop', precipUnit)}
{#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class="precip-cell {is3h ? 'h-14' : 'h-11'}"
class:now={cell.isNow}
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
{#if precip > 0}
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
{precip.toFixed(1)}
</span>
{/if}
</td>
{/each}
</tr>
</tbody>
</table>
</div>
{/if}
<style>
tr {
border-top: 1px solid hsl(var(--border));
}
/* ── Base cell ──────────────────────────────────────────── */
.cell {
padding: 6px 2px;
text-align: center;
font-size: 13px;
font-weight: 500;
border-right: 1px solid hsl(var(--border) / 0.2);
overflow: hidden;
}
.cell:last-child {
border-right: none;
}
.cell.now {
font-weight: 700;
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
}
/* ── Row header ─────────────────────────────────────────── */
.hdr {
padding: 4px;
text-align: center;
font-weight: 600;
font-size: 11px;
background: hsl(var(--background));
border-right: 2px solid hsl(var(--border));
white-space: nowrap;
overflow: hidden;
}
/* ── Precipitation ──────────────────────────────────────── */
.precip-cell {
position: relative;
padding: 0;
text-align: center;
overflow: hidden;
border-right: 1px solid hsl(var(--border) / 0.2);
}
.precip-cell:last-child {
border-right: none;
}
.precip-cell.now {
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
}
.precip-bar {
position: absolute;
bottom: 0;
left: 15%;
right: 15%;
min-height: 3px;
border-radius: 2px 2px 0 0;
background: linear-gradient(to top, rgba(30, 120, 220, 0.5), rgba(30, 120, 220, 0.9));
pointer-events: none;
}
.precip-label {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-weight: 700;
color: rgba(20, 60, 160, 0.9);
pointer-events: none;
}
:global(.dark) .precip-label {
color: rgba(120, 180, 255, 0.95);
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.hdr {
padding: 3px 2px;
font-size: 10px;
}
.cell {
font-size: 11px;
padding: 4px 1px;
}
}
</style>