This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/routes/weather/week/[location]/HourlyTable.svelte
T
2026-02-16 11:15:51 +01:00

524 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
import {
type FetchedDaily,
type FetchedHourly,
type WeatherUnits,
getPrecipUnit,
getTempUnit,
getWindArrowRotation,
getWindUnit
} 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 tz = data.timezone;
const hour = getZonedHour(date, tz);
const minutes = parseInt(formatZoned(date, tz, 'mm'), 10);
const totalMinutes = hour * 60 + minutes;
const firstHour = getZonedHour(cellData[0].date, tz);
const firstMin = firstHour * 60;
const step = is3h ? 3 : 1;
const lastHour = getZonedHour(cellData[cellData.length - 1].date, tz);
const lastMin = lastHour * 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 formatZoned(date, data.timezone, 'HH:mm');
}
let timezoneLabel = $derived(formatUtcOffset(data.utc_offset_seconds));
function findDailyIndex(date: Date): number {
return daily.dailyDates.findIndex((dd) => isSameDayInZone(dd, date, data.timezone));
}
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[] {
const tz = data.timezone;
return dates.reduce<number[]>((acc, d, i) => {
if (isSameDayInZone(d, day, tz) && (hourlyInterval === 1 || getZonedHour(d, tz) % 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) => {
const date = data.hourlyDates[idx];
const tz = data.timezone;
const isNow =
formatZoned(date, tz, 'yyyy-MM-dd HH') === formatZoned(today, tz, 'yyyy-MM-dd HH');
return {
idx,
date,
isNow,
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">
{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>
</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}
{formatZoned(cell.date, data.timezone, 'HH')}
{:else}
<span class="inline-flex items-baseline gap-1">
<span class="text-[11px] font-semibold"
>{formatZoned(cell.date, data.timezone, 'HH')}</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>