524 lines
16 KiB
Svelte
524 lines
16 KiB
Svelte
<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>
|