feat: canvas to echarts #5
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { getColor } from '../../utils/colors';
|
||||||
|
import weatherCodes from '../../utils/weather-codes';
|
||||||
|
import {
|
||||||
|
type FetchedDaily,
|
||||||
|
type WeatherUnits,
|
||||||
|
getDayLabel,
|
||||||
|
getTextColorForTemp,
|
||||||
|
getWindArrowRotation
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: FetchedDaily | null;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
onSelectDay: (date: Date, index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div in:fade out:fade class="daily-cards-wrapper">
|
||||||
|
<div class="daily-cards-scroll">
|
||||||
|
{#if daily}
|
||||||
|
{#each daily.dailyDates as time, index (index)}
|
||||||
|
{@const selected = time.getDate() === selectedDay.getDate()}
|
||||||
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
|
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||||
|
{@const wCode = daily.daily.weather_code[index]}
|
||||||
|
{@const sunDuration = daily.daily.sunshine_duration[index]}
|
||||||
|
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||||
|
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||||
|
{@const gustMax = daily.daily.windgusts_10m_max[index]}
|
||||||
|
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||||
|
{#if tempMax != null && !isNaN(tempMax)}
|
||||||
|
<button
|
||||||
|
class="daily-card {selected ? 'selected' : ''}"
|
||||||
|
onclick={() => onSelectDay(time, index)}
|
||||||
|
>
|
||||||
|
<div class="card-day-name">
|
||||||
|
{time.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="card-day-date">{getDayLabel(time, today)}</div>
|
||||||
|
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg class="fill-foreground" width="52px" height="52px">
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
||||||
|
wCode as keyof typeof weatherCodes
|
||||||
|
] ?? 'clear'}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="card-temp-max"
|
||||||
|
style="background-color: {getColor(
|
||||||
|
tempMax,
|
||||||
|
String(units.temperature_unit)
|
||||||
|
)}; color: {getTextColorForTemp(tempMax, String(units.temperature_unit))}"
|
||||||
|
>
|
||||||
|
{tempMax.toFixed(0)}{units.temperature_unit === 'celsius' ? '°C' : '°F'}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="card-temp-min"
|
||||||
|
style="background-color: {getColor(
|
||||||
|
tempMin,
|
||||||
|
String(units.temperature_unit)
|
||||||
|
)}; color: {getTextColorForTemp(tempMin, String(units.temperature_unit))}"
|
||||||
|
>
|
||||||
|
{tempMin.toFixed(0)}{units.temperature_unit === 'celsius' ? '°C' : '°F'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-detail">
|
||||||
|
<svg class="fill-foreground detail-icon" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>{windMax?.toFixed(0) ?? '-'}-{gustMax?.toFixed(0) ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-detail">
|
||||||
|
<svg class="fill-foreground detail-icon" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{Number(precipSum ?? 0).toFixed(0)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-detail">
|
||||||
|
<svg class="fill-foreground detail-icon" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>{Number((sunDuration ?? 0) / 3600).toFixed(0)} h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
|
<div class="card-wind-dir">
|
||||||
|
<div style="transform: {getWindArrowRotation(windDir)}; display: inline-block">
|
||||||
|
<svg class="fill-foreground" width="18px" height="18px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.daily-cards-wrapper {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-cards-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 110px;
|
||||||
|
max-width: 130px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
transition: all 200ms ease;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-card:hover {
|
||||||
|
background: hsl(var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-card.selected {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--accent));
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-day-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-day-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: #0061a5;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-temp-max {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 3px 0;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-temp-min {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 3px 0;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wind-dir {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.daily-card {
|
||||||
|
min-width: 95px;
|
||||||
|
max-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-temp-max,
|
||||||
|
.card-temp-min {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pad } from '$lib/utils/index';
|
||||||
|
|
||||||
|
import { getColor } from '../../utils/colors';
|
||||||
|
import weatherCodes from '../../utils/weather-codes';
|
||||||
|
import {
|
||||||
|
type FetchedHourly,
|
||||||
|
type WeatherUnits,
|
||||||
|
getPrecipUnit,
|
||||||
|
getTempUnit,
|
||||||
|
getTextColorForTemp,
|
||||||
|
getWindArrowRotation,
|
||||||
|
getWindUnit,
|
||||||
|
isCurrentHour,
|
||||||
|
isDaytimeHour
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FetchedHourly;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
locationName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, 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));
|
||||||
|
|
||||||
|
function getDayIndices(dates: Date[], day: Date): number[] {
|
||||||
|
const dayDate = day.getDate();
|
||||||
|
const dayMonth = day.getMonth();
|
||||||
|
const dayYear = day.getFullYear();
|
||||||
|
const indices: number[] = [];
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
const d = dates[i];
|
||||||
|
if (d.getDate() === dayDate && d.getMonth() === dayMonth && d.getFullYear() === dayYear) {
|
||||||
|
if (hourlyInterval === 1 || d.getHours() % 3 === 0) {
|
||||||
|
indices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrecipBarHeight(val: number, maxVal: number): number {
|
||||||
|
if (!val || val <= 0 || !maxVal) return 0;
|
||||||
|
return Math.min(100, (val / Math.max(maxVal, 1)) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCloudOpacity(cover: number): number {
|
||||||
|
return Math.min(0.7, (cover ?? 0) / 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHumidityBg(hum: number): string {
|
||||||
|
return `rgba(0, 240, 240, ${hum ** 3.8 / 10 ** 8.2})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrecipProbColor(prob: number): string {
|
||||||
|
return prob > 50 ? 'white' : 'inherit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
|
||||||
|
let text = '';
|
||||||
|
if (prob != null) text += `Probability: ${prob}%`;
|
||||||
|
if (precip != null && precip > 0) {
|
||||||
|
if (text) text += '\n';
|
||||||
|
text += `Amount: ${precip.toFixed(1)} ${precipUnit}`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconSize(interval: 1 | 3): number {
|
||||||
|
return interval === 3 ? 40 : 26;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dayIdx = $derived(getDayIndices(data.hourlyDates, selectedDay));
|
||||||
|
let numCols = $derived(dayIdx.length);
|
||||||
|
let iconPx = $derived(getIconSize(hourlyInterval));
|
||||||
|
let maxPrecip = $derived(
|
||||||
|
Math.max(
|
||||||
|
...dayIdx.map((i) => data.hourly.precipitation[i]).filter((v) => v != null && !isNaN(v)),
|
||||||
|
0.1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="hourly-header">
|
||||||
|
<h3 class="hourly-title">
|
||||||
|
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} – Hourly
|
||||||
|
</h3>
|
||||||
|
<div class="interval-toggle">
|
||||||
|
<span class="interval-label">3h</span>
|
||||||
|
<button
|
||||||
|
class="toggle-track {hourlyInterval === 1 ? 'active' : ''}"
|
||||||
|
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
||||||
|
title="Toggle between 1-hour and 3-hour intervals"
|
||||||
|
>
|
||||||
|
<span class="toggle-thumb {hourlyInterval === 1 ? 'right' : 'left'}"></span>
|
||||||
|
</button>
|
||||||
|
<span class="interval-label">1h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if numCols > 0}
|
||||||
|
{@const hourly = data.hourly}
|
||||||
|
{@const dates = data.hourlyDates}
|
||||||
|
<div class="hourly-container">
|
||||||
|
<table class="hourly-table {hourlyInterval === 3 ? 'interval-3h' : 'interval-1h'}">
|
||||||
|
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col class="col-header" />
|
||||||
|
{#each dayIdx as _ (_.toString())}
|
||||||
|
<col />
|
||||||
|
{/each}
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
<!-- Time -->
|
||||||
|
<tr>
|
||||||
|
<th class="row-header" scope="row"></th>
|
||||||
|
{#each dayIdx as idx (idx)}
|
||||||
|
{@const date = dates[idx]}
|
||||||
|
{@const now = isCurrentHour(date, today)}
|
||||||
|
<td class="hour-cell time-cell {now ? 'now' : ''}">
|
||||||
|
{pad(date.getHours())}<sup class="time-sup">00</sup>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Weather Icons -->
|
||||||
|
<tr class="icon-row">
|
||||||
|
<th class="row-header" scope="row">
|
||||||
|
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-day-cloudy.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
</th>
|
||||||
|
{#each dayIdx as idx (idx)}
|
||||||
|
{@const wCode = hourly.weather_code[idx]}
|
||||||
|
{@const date = dates[idx]}
|
||||||
|
{@const daytime = isDaytimeHour(date.getHours())}
|
||||||
|
{@const now = isCurrentHour(date, today)}
|
||||||
|
<td class="hour-cell weather-icon-cell {now ? 'now' : ''}">
|
||||||
|
<svg class="fill-foreground" width="{iconPx}px" height="{iconPx}px">
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/wi-{daytime ? 'day' : 'night'}-{weatherCodes[
|
||||||
|
wCode as keyof typeof weatherCodes
|
||||||
|
] ?? 'clear'}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Temperature -->
|
||||||
|
<tr>
|
||||||
|
<th class="row-header" scope="row">
|
||||||
|
<div class="row-header-stack">
|
||||||
|
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-thermometer.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span class="row-unit">{tempUnit}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{#each dayIdx as idx (idx)}
|
||||||
|
{@const temp = hourly.temperature_2m[idx]}
|
||||||
|
{@const date = dates[idx]}
|
||||||
|
{@const now = isCurrentHour(date, today)}
|
||||||
|
{@const bg = getColor(temp, String(units.temperature_unit))}
|
||||||
|
{@const fg = getTextColorForTemp(temp, String(units.temperature_unit))}
|
||||||
|
<td
|
||||||
|
class="hour-cell temp-cell {now ? 'now' : ''}"
|
||||||
|
style="background-color: {bg}; color: {fg}"
|
||||||
|
>
|
||||||
|
{temp != null ? temp.toFixed(0) + '°' : '-'}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Feels Like -->
|
||||||
|
<tr>
|
||||||
|
<th class="row-header" scope="row">
|
||||||
|
<div class="row-header-stack">
|
||||||
|
<span class="row-label">Feels</span>
|
||||||
|
<span class="row-unit">{tempUnit}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{#each dayIdx as idx (idx)}
|
||||||
|
{@const temp = hourly.apparent_temperature[idx]}
|
||||||
|
{@const date = dates[idx]}
|
||||||
|
{@const now = isCurrentHour(date, today)}
|
||||||
|
{@const bg = getColor(temp, String(units.temperature_unit))}
|
||||||
|
{@const fg = getTextColorForTemp(temp ?? 0, String(units.temperature_unit))}
|
||||||
|
<td
|
||||||
|
class="hour-cell feels-cell {now ? 'now' : ''}"
|
||||||
|
style="background-color: {bg}99; color: {fg}"
|
||||||
|
>
|
||||||
|
{temp != null ? temp.toFixed(0) + '°' : '-'}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Cloud Cover -->
|
||||||
|
<tr>
|
||||||
|
<th class="row-header" scope="row">
|
||||||
|
<div class="row-header-stack">
|
||||||
|
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-cloud.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span class="row-unit">%</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{#each dayIdx as idx (idx)}
|
||||||
|
{@const cloud = hourly.cloud_cover[idx]}
|
||||||
|
{@const date = dates[idx]}
|
||||||
|
{@const now = isCurrentHour(date, today)}
|
||||||
|
<td
|
||||||
|
class="hour-cell {now ? 'now' : ''}"
|
||||||
|
style="background: rgba(160, 175, 190, {getCloudOpacity(cloud ?? 0)})"
|
||||||
|
>
|
||||||
|
{cloud != null ? cloud.toFixed(0) : '-'}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Precipitation (overlaid on probability) -->
|
||||||
|
<tr>
|
||||||
|
<th class="row-header" scope="row">
|
||||||
|
<div class="row-header-stack">
|
||||||
|
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span class="row-unit">{precipUnit}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{#each dayIdx as idx (idx)}
|
||||||
|
{@const precip = hourly.precipitation[idx]}
|
||||||
|
{@const prob = hourly.precipitation_probability[idx]}
|
||||||
|
{@const date = dates[idx]}
|
||||||
|
{@const now = isCurrentHour(date, today)}
|
||||||
|
<td
|
||||||
|
class="hour-cell precip-overlay-cell {now ? 'now' : ''}"
|
||||||
|
title={formatPrecipTooltip(precip, prob)}
|
||||||
|
>
|
||||||
|
<div class="precip-prob-fill" style="height: {prob ?? 0}%"></div>
|
||||||
|
{#if precip > 0}
|
||||||
|
<div
|
||||||
|
class="precip-amount-bar"
|
||||||
|
style="height: {getPrecipBarHeight(precip, maxPrecip)}%"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
<span class="precip-overlay-label" style="color: {getPrecipProbColor(prob ?? 0)}">
|
||||||
|
{#if precip > 0}
|
||||||
|
{precip.toFixed(1)}
|
||||||
|
{:else if prob != null && prob > 0}
|
||||||
|
{prob}%
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Wind (direction + speed combined) -->
|
||||||
|
<tr>
|
||||||
|
<th class="row-header" scope="row">
|
||||||
|
<div class="row-header-stack">
|
||||||
|
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span class="row-unit">{windUnit}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{#each dayIdx as idx (idx)}
|
||||||
|
{@const wind = hourly.windspeed_10m[idx]}
|
||||||
|
{@const windDir = hourly.winddirection_10m[idx]}
|
||||||
|
{@const date = dates[idx]}
|
||||||
|
{@const now = isCurrentHour(date, today)}
|
||||||
|
<td class="hour-cell wind-cell {now ? 'now' : ''}">
|
||||||
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
|
<div class="wind-arrow" style="transform: {getWindArrowRotation(windDir)}">
|
||||||
|
<svg class="fill-foreground" width="18px" height="18px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="wind-speed-value">{wind?.toFixed(0) ?? '-'}</span>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Humidity -->
|
||||||
|
<tr>
|
||||||
|
<th class="row-header" scope="row">
|
||||||
|
<div class="row-header-stack">
|
||||||
|
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-humidity.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span class="row-unit">%</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{#each dayIdx as idx (idx)}
|
||||||
|
{@const hum = hourly.relative_humidity_2m[idx]}
|
||||||
|
{@const date = dates[idx]}
|
||||||
|
{@const now = isCurrentHour(date, today)}
|
||||||
|
<td class="hour-cell {now ? 'now' : ''}" style="background: {getHumidityBg(hum ?? 0)}">
|
||||||
|
{hum != null ? hum.toFixed(0) : '-'}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hourly-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hourly-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-label {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track.active {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
transition: left 200ms;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb.left {
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb.right {
|
||||||
|
left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hourly-container {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hourly-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hourly-table .col-header {
|
||||||
|
width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hourly-table tr {
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hourly-table tr:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-header {
|
||||||
|
width: 64px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-header-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-unit {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-cell {
|
||||||
|
padding: 6px 2px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .hour-cell {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-cell.now {
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-cell {
|
||||||
|
font-weight: 700;
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
padding: 5px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .time-cell {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-1h .time-cell {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-sup {
|
||||||
|
font-size: 8px;
|
||||||
|
vertical-align: super;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-row {
|
||||||
|
background: hsl(var(--muted) / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-icon-cell {
|
||||||
|
padding: 6px 2px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 0;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-icon-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .weather-icon-cell {
|
||||||
|
padding: 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-cell {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 8px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .temp-cell {
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feels-cell {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .feels-cell {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-overlay-cell {
|
||||||
|
position: relative;
|
||||||
|
vertical-align: bottom;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .precip-overlay-cell {
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-prob-fill {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0, 0, 230, 0.12);
|
||||||
|
transition: height 200ms;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-amount-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 25%;
|
||||||
|
right: 25%;
|
||||||
|
background: linear-gradient(to top, rgba(30, 136, 229, 0.5), rgba(30, 136, 229, 0.95));
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
min-height: 3px;
|
||||||
|
transition: height 200ms;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-overlay-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .precip-overlay-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-cell {
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 2px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-speed-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .wind-speed-value {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hourly-table .col-header {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-header {
|
||||||
|
width: 48px;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-cell {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .hour-cell {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-cell {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-3h .temp-cell {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
||||||
|
import { pad } from '$lib/utils/index';
|
||||||
|
|
||||||
|
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
||||||
|
import '$lib/components/charts/echarts.css';
|
||||||
|
|
||||||
|
import { getColor } from '../../utils/colors';
|
||||||
|
import {
|
||||||
|
type FetchedHourly,
|
||||||
|
type WeatherUnits,
|
||||||
|
getDayLabel,
|
||||||
|
getPrecipUnit,
|
||||||
|
getTempUnit,
|
||||||
|
getWindDirectionLabel,
|
||||||
|
getWindUnit
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FetchedHourly;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
loading: boolean;
|
||||||
|
onResetZoom?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
||||||
|
|
||||||
|
const CHART_GROUP = 'week-meteogram';
|
||||||
|
const MS_PER_DAY = 24 * 3600 * 1000;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
let showCharts = $state(false);
|
||||||
|
let chartComponents: EChart[] = $state([]);
|
||||||
|
let chartInstances: echarts.ECharts[] = $state([]);
|
||||||
|
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||||
|
|
||||||
|
export function scrollToDay(day: Date): void {
|
||||||
|
if (chartInstances.length === 0) return;
|
||||||
|
|
||||||
|
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom(): void {
|
||||||
|
for (const chart of chartInstances) {
|
||||||
|
if (chart && !chart.isDisposed()) {
|
||||||
|
chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onResetZoom?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChartReady(chart: echarts.ECharts): void {
|
||||||
|
chart.group = CHART_GROUP;
|
||||||
|
chartInstances = [...chartInstances, chart];
|
||||||
|
if (chartInstances.length === 3) {
|
||||||
|
echarts.connect(CHART_GROUP);
|
||||||
|
requestAnimationFrame(() => scrollToDay(selectedDay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Reset chart instances when data changes
|
||||||
|
if (data) {
|
||||||
|
chartInstances = [];
|
||||||
|
chartComponents = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const { hourly, utc_offset_seconds, timestamps, markAreas } = data;
|
||||||
|
const colors = getThemeColors();
|
||||||
|
const tempUnit = getTempUnit(units);
|
||||||
|
const precipUnit = getPrecipUnit(units);
|
||||||
|
const windUnit = getWindUnit(units);
|
||||||
|
|
||||||
|
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({ utcOffsetSeconds: utc_offset_seconds }));
|
||||||
|
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 },
|
||||||
|
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: Record<string, unknown>[]) => string
|
||||||
|
): Record<string, unknown> => ({
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'cross',
|
||||||
|
animation: false,
|
||||||
|
label: {
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
color: colors.text,
|
||||||
|
borderColor: colors.tooltipBorder,
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
borderColor: colors.tooltipBorder,
|
||||||
|
textStyle: { color: colors.text },
|
||||||
|
formatter
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (ts: number): string => {
|
||||||
|
const date = new Date(ts);
|
||||||
|
return `<b>${date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} ${pad(date.getHours())}:${pad(date.getMinutes())}</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 }
|
||||||
|
],
|
||||||
|
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(Math.round(maxTemp), String(units.temperature_unit)) + '88'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 0.5,
|
||||||
|
color:
|
||||||
|
getColor(Math.round((maxTemp + minTemp) / 2), String(units.temperature_unit)) +
|
||||||
|
'44'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: getColor(Math.round(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
|
||||||
|
},
|
||||||
|
...annotations()
|
||||||
|
],
|
||||||
|
textStyle: { color: colors.text }
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 }
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
textStyle: { color: colors.text }
|
||||||
|
};
|
||||||
|
|
||||||
|
chartOptions = [tempOption, precipOption, windOption];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="charts-toggle-section">
|
||||||
|
<button class="charts-toggle-btn" onclick={() => (showCharts = !showCharts)}>
|
||||||
|
<span>Detailed Meteogram Charts</span>
|
||||||
|
<svg
|
||||||
|
class="toggle-chevron {showCharts ? 'open' : ''}"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCharts}
|
||||||
|
<div class="detailed-charts" in:fade={{ duration: 200 }}>
|
||||||
|
<div class="charts-header">
|
||||||
|
<h3 class="charts-title">
|
||||||
|
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
|
||||||
|
<small>
|
||||||
|
{getDayLabel(selectedDay, today) !==
|
||||||
|
selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })
|
||||||
|
? ` (${getDayLabel(selectedDay, today)})`
|
||||||
|
: ''}
|
||||||
|
</small>
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}>
|
||||||
|
Show All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
||||||
|
{#each chartOptions as option, i (i)}
|
||||||
|
<EChart
|
||||||
|
{option}
|
||||||
|
height={i === 2 ? '320px' : '300px'}
|
||||||
|
onChartReady={handleChartReady}
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<div class="mt-6 md:mt-10">
|
||||||
|
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.charts-toggle-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-toggle-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius, 0.375rem);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-toggle-btn:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-chevron {
|
||||||
|
transition: transform 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-chevron.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-charts {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-reset-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius, 0.375rem);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 150ms ease,
|
||||||
|
background-color 150ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-reset-btn:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
|
||||||
|
import { models } from '../../options';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedModel: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selectedModel, onModelChange }: Props = $props();
|
||||||
|
|
||||||
|
let modelLabel = $derived(
|
||||||
|
models.find((mo) => String(mo.value) === selectedModel)?.label ?? selectedModel
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-6 flex gap-6 md:mt-12">
|
||||||
|
<div class="relative w-1/2">
|
||||||
|
<Select.Root
|
||||||
|
name="model_selection"
|
||||||
|
type="single"
|
||||||
|
value={selectedModel}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val) onModelChange(val);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
aria-label="Forecast model selection"
|
||||||
|
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3"
|
||||||
|
>
|
||||||
|
{modelLabel}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content preventScroll={false} class="border-border">
|
||||||
|
{#each models as mo (mo.value)}
|
||||||
|
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground">
|
||||||
|
Weather model
|
||||||
|
</Label>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pad } from '$lib/utils/index';
|
||||||
|
|
||||||
|
import type { FetchedDaily } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: FetchedDaily | null;
|
||||||
|
dayIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { daily, dayIndex }: Props = $props();
|
||||||
|
|
||||||
|
let sunrise = $derived.by(() => {
|
||||||
|
if (!daily) return null;
|
||||||
|
const ts = daily.daily.sunrise[dayIndex];
|
||||||
|
return ts ? new Date(ts * 1000) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
let sunset = $derived.by(() => {
|
||||||
|
if (!daily) return null;
|
||||||
|
const ts = daily.daily.sunset[dayIndex];
|
||||||
|
return ts ? new Date(ts * 1000) : null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if sunrise && sunset}
|
||||||
|
<div class="sun-info">
|
||||||
|
<div class="sun-item">
|
||||||
|
<svg class="fill-foreground" width="24px" height="24px">
|
||||||
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>{pad(sunrise.getHours())}:{pad(sunrise.getMinutes())}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sun-item">
|
||||||
|
<svg class="fill-foreground" width="24px" height="24px">
|
||||||
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>{pad(sunset.getHours())}:{pad(sunset.getMinutes())}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sun-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { MarkArea, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
|
||||||
|
|
||||||
|
export interface WeatherUnits {
|
||||||
|
temperature_unit: string;
|
||||||
|
wind_speed_unit: string;
|
||||||
|
precipitation_unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchedHourly {
|
||||||
|
hourly: WeekHourlyData;
|
||||||
|
utc_offset_seconds: number;
|
||||||
|
timestamps: number[];
|
||||||
|
hourlyDates: Date[];
|
||||||
|
markAreas: MarkArea[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchedDaily {
|
||||||
|
daily: WeekDailyData;
|
||||||
|
dailyDates: Date[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTempUnit(units: WeatherUnits): string {
|
||||||
|
return units.temperature_unit === 'celsius' ? '°C' : '°F';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindUnit(units: WeatherUnits): string {
|
||||||
|
return units.wind_speed_unit === 'kmh' ? 'km/h' : units.wind_speed_unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrecipUnit(units: WeatherUnits): string {
|
||||||
|
return units.precipitation_unit === 'mm' ? 'mm' : 'in';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextColorForTemp(temp: number, unit: string): string {
|
||||||
|
const threshold = unit === 'celsius' ? { low: -13, high: 40 } : { low: 7, high: 104 };
|
||||||
|
return temp < threshold.low || temp >= threshold.high ? 'white' : 'black';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindArrowRotation(deg: number): string {
|
||||||
|
return `rotate(${deg}deg)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindDirectionLabel(deg: number): string {
|
||||||
|
const dirs = [
|
||||||
|
'N',
|
||||||
|
'NNE',
|
||||||
|
'NE',
|
||||||
|
'ENE',
|
||||||
|
'E',
|
||||||
|
'ESE',
|
||||||
|
'SE',
|
||||||
|
'SSE',
|
||||||
|
'S',
|
||||||
|
'SSW',
|
||||||
|
'SW',
|
||||||
|
'WSW',
|
||||||
|
'W',
|
||||||
|
'WNW',
|
||||||
|
'NW',
|
||||||
|
'NNW'
|
||||||
|
];
|
||||||
|
return dirs[Math.round(deg / 22.5) % 16];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDayLabel(date: Date, today: Date): string {
|
||||||
|
const MS_PER_DAY = 24 * 3600 * 1000;
|
||||||
|
const diff = Math.round(
|
||||||
|
(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() -
|
||||||
|
new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) /
|
||||||
|
MS_PER_DAY
|
||||||
|
);
|
||||||
|
if (diff === 0) return 'Today';
|
||||||
|
if (diff === 1) return 'Tomorrow';
|
||||||
|
if (diff === -1) return 'Yesterday';
|
||||||
|
return `${date.getMonth() + 1}-${date.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDaytimeHour(hour: number): boolean {
|
||||||
|
return hour >= 6 && hour < 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCurrentHour(date: Date, now: Date): boolean {
|
||||||
|
return (
|
||||||
|
date.getDate() === now.getDate() &&
|
||||||
|
date.getMonth() === now.getMonth() &&
|
||||||
|
date.getFullYear() === now.getFullYear() &&
|
||||||
|
date.getHours() === now.getHours()
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user