improve hourly table
This commit is contained in:
@@ -13,7 +13,6 @@ export const models = [
|
|||||||
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
|
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
|
||||||
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
||||||
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
|
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
|
||||||
{ value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' },
|
|
||||||
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
|
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
|
||||||
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
|
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
|
||||||
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
|
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import colorScaleHex from './color-scale-hex';
|
import colorScaleHex from './color-scale-hex';
|
||||||
|
|
||||||
function componentFromStr(numStr: string, percent: number) {
|
const componentFromStr = (numStr: string, percent: number) => {
|
||||||
const num = Math.max(0, parseInt(numStr, 10));
|
const num = Math.max(0, parseInt(numStr, 10));
|
||||||
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
|
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function rgbToHex(rgb: string) {
|
export const rgbToHex = (rgb: string): string => {
|
||||||
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
|
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
|
||||||
let result,
|
let result,
|
||||||
r,
|
r,
|
||||||
@@ -23,28 +23,58 @@ export function rgbToHex(rgb: string) {
|
|||||||
return '355522';
|
return '355522';
|
||||||
}
|
}
|
||||||
return hex;
|
return hex;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export const hexToRgb = (hex: string): [number, number, number] => {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
return [
|
||||||
|
parseInt(h.substring(0, 2), 16),
|
||||||
|
parseInt(h.substring(2, 4), 16),
|
||||||
|
parseInt(h.substring(4, 6), 16)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export const getColor = (temperature: number, unit = 'celsius'): string => {
|
export const getColor = (temperature: number, unit = 'celsius'): string => {
|
||||||
|
if (unit !== 'celsius') {
|
||||||
|
temperature = Math.round(((temperature - 32) * 5) / 9);
|
||||||
|
}
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
if (unit === 'celsius') {
|
if (temperature <= -40) {
|
||||||
if (temperature <= -40) {
|
index = 0;
|
||||||
index = 0;
|
} else if (temperature >= 60) {
|
||||||
} else if (temperature >= 60) {
|
index = colorScaleHex.length - 1;
|
||||||
index = colorScaleHex.length - 1;
|
|
||||||
} else {
|
|
||||||
index = temperature + 40;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const tempInCelsius = Math.round(((temperature - 32) * 5) / 9);
|
index = Math.round(temperature) + 45;
|
||||||
if (tempInCelsius <= -40) {
|
|
||||||
index = 0;
|
|
||||||
} else if (tempInCelsius >= 60) {
|
|
||||||
index = colorScaleHex.length - 1;
|
|
||||||
} else {
|
|
||||||
index = tempInCelsius + 40;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return colorScaleHex[index];
|
return colorScaleHex[index];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface TempStyle {
|
||||||
|
bg: string;
|
||||||
|
fg: 'white' | 'black';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTempStyle = (temp: number, unit: string): TempStyle => {
|
||||||
|
const bg = getColor(temp, unit);
|
||||||
|
const fg = textWhite(hexToRgb(bg)) ? 'white' : 'black';
|
||||||
|
return { bg, fg };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const textWhite = (
|
||||||
|
[r, g, b, a]: [number, number, number, number] | [number, number, number],
|
||||||
|
dark?: boolean,
|
||||||
|
globalOpacity?: number
|
||||||
|
): boolean => {
|
||||||
|
const alpha = ((a || 1) * (globalOpacity || 100)) / 100;
|
||||||
|
if (alpha < 0.65) {
|
||||||
|
if (dark) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// check luminance
|
||||||
|
return r * 0.299 + g * 0.587 + b * 0.114 <= 150;
|
||||||
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,6 @@
|
|||||||
import HourlyTable from './HourlyTable.svelte';
|
import HourlyTable from './HourlyTable.svelte';
|
||||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||||
import ModelSelector from './ModelSelector.svelte';
|
import ModelSelector from './ModelSelector.svelte';
|
||||||
import SunInfo from './SunInfo.svelte';
|
|
||||||
|
|
||||||
import type { GeoLocation } from '$lib/stores/settings';
|
import type { GeoLocation } from '$lib/stores/settings';
|
||||||
import type { FetchedDaily, FetchedHourly } from './types';
|
import type { FetchedDaily, FetchedHourly } from './types';
|
||||||
@@ -116,17 +115,16 @@
|
|||||||
<div class="weather-content" style="min-height: 50vh">
|
<div class="weather-content" style="min-height: 50vh">
|
||||||
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
|
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
|
||||||
|
|
||||||
{#if fetchedHourly}
|
{#if fetchedHourly && fetchedDaily}
|
||||||
<HourlyTable
|
<HourlyTable
|
||||||
data={fetchedHourly}
|
data={fetchedHourly}
|
||||||
|
daily={fetchedDaily}
|
||||||
{selectedDay}
|
{selectedDay}
|
||||||
units={params}
|
units={params}
|
||||||
locationName={location.name ?? ''}
|
locationName={location.name ?? ''}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<SunInfo daily={fetchedDaily} dayIndex={selectedDayIndex} />
|
|
||||||
|
|
||||||
{#if fetchedHourly}
|
{#if fetchedHourly}
|
||||||
<MeteogramCharts
|
<MeteogramCharts
|
||||||
bind:this={meteogramCharts}
|
bind:this={meteogramCharts}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { getColor } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import weatherCodes from '../../utils/weather-codes';
|
import weatherCodes from '../../utils/weather-codes';
|
||||||
import {
|
import { type FetchedDaily, type WeatherUnits, getDayLabel, getWindArrowRotation } from './types';
|
||||||
type FetchedDaily,
|
|
||||||
type WeatherUnits,
|
|
||||||
getDayLabel,
|
|
||||||
getTextColorForTemp,
|
|
||||||
getWindArrowRotation
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
daily: FetchedDaily | null;
|
daily: FetchedDaily | null;
|
||||||
@@ -21,10 +15,32 @@
|
|||||||
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
||||||
|
function getDaylightSeconds(index: number): number {
|
||||||
|
if (!daily) return 0;
|
||||||
|
const sunriseTs = daily.daily.sunrise[index];
|
||||||
|
const sunsetTs = daily.daily.sunset[index];
|
||||||
|
if (!sunriseTs || !sunsetTs) return 0;
|
||||||
|
return Math.max(0, sunsetTs - sunriseTs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSunshinePercent(sunshineSeconds: number | null, daylightSeconds: number): number {
|
||||||
|
if (!sunshineSeconds || daylightSeconds <= 0) return 0;
|
||||||
|
return Math.min(100, (sunshineSeconds / daylightSeconds) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSunshineColor(sunshineSeconds: number | null, daylightSeconds: number): string {
|
||||||
|
if (daylightSeconds <= 0) return '#d1d5db';
|
||||||
|
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
|
||||||
|
if (ratio >= 0.7) return '#f59e0b';
|
||||||
|
if (ratio >= 0.45) return '#fbbf24';
|
||||||
|
if (ratio >= 0.2) return '#fcd34d';
|
||||||
|
return '#d1d5db';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div in:fade out:fade class="daily-cards-wrapper">
|
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
||||||
<div class="daily-cards-scroll">
|
<div class="flex gap-1 overflow-x-auto pb-1" style="scrollbar-width: thin">
|
||||||
{#if daily}
|
{#if daily}
|
||||||
{#each daily.dailyDates as time, index (index)}
|
{#each daily.dailyDates as time, index (index)}
|
||||||
{@const selected = time.getDate() === selectedDay.getDate()}
|
{@const selected = time.getDate() === selectedDay.getDate()}
|
||||||
@@ -32,22 +48,38 @@
|
|||||||
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||||
{@const wCode = daily.daily.weather_code[index]}
|
{@const wCode = daily.daily.weather_code[index]}
|
||||||
{@const sunDuration = daily.daily.sunshine_duration[index]}
|
{@const sunDuration = daily.daily.sunshine_duration[index]}
|
||||||
|
{@const daylightSec = getDaylightSeconds(index)}
|
||||||
|
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
|
||||||
|
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
|
||||||
{@const precipSum = daily.daily.precipitation_sum[index]}
|
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||||
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||||
{@const gustMax = daily.daily.windgusts_10m_max[index]}
|
{@const gustMax = daily.daily.windgusts_10m_max[index]}
|
||||||
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||||
|
{@const unit = String(units.temperature_unit)}
|
||||||
|
{@const maxStyle = getTempStyle(tempMax, unit)}
|
||||||
|
{@const minStyle = getTempStyle(tempMin, unit)}
|
||||||
{#if tempMax != null && !isNaN(tempMax)}
|
{#if tempMax != null && !isNaN(tempMax)}
|
||||||
<button
|
<button
|
||||||
class="daily-card {selected ? 'selected' : ''}"
|
class="group flex min-w-[108px] max-w-[130px] flex-1 cursor-pointer flex-col items-center gap-0.5 rounded-xl border-2 px-1.5 py-2 transition-all duration-200
|
||||||
|
{selected
|
||||||
|
? 'scale-[1.03] border-primary bg-accent shadow-md'
|
||||||
|
: 'border-transparent bg-card hover:bg-accent'}"
|
||||||
onclick={() => onSelectDay(time, index)}
|
onclick={() => onSelectDay(time, index)}
|
||||||
>
|
>
|
||||||
<div class="card-day-name">
|
<!-- Day label -->
|
||||||
|
<span class="text-sm font-bold tracking-wide">
|
||||||
{time.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase()}
|
{time.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase()}
|
||||||
</div>
|
</span>
|
||||||
<div class="card-day-date">{getDayLabel(time, today)}</div>
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{getDayLabel(time, today)}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div class="card-icon">
|
<!-- Weather icon -->
|
||||||
<svg class="fill-foreground" width="52px" height="52px">
|
<div
|
||||||
|
class="my-1 flex w-full items-center justify-center rounded-lg py-1.5"
|
||||||
|
style="background: {sunColor}22"
|
||||||
|
>
|
||||||
|
<svg class="fill-foreground" width="48px" height="48px">
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
||||||
wCode as keyof typeof weatherCodes
|
wCode as keyof typeof weatherCodes
|
||||||
@@ -56,57 +88,75 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- Temperature max/min -->
|
||||||
class="card-temp-max"
|
<div class="flex w-full flex-col">
|
||||||
style="background-color: {getColor(
|
<div
|
||||||
tempMax,
|
class="w-full rounded-t px-1 py-0.5 text-center text-[15px] font-bold"
|
||||||
String(units.temperature_unit)
|
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
|
||||||
)}; color: {getTextColorForTemp(tempMax, String(units.temperature_unit))}"
|
>
|
||||||
>
|
{tempMax.toFixed(0)}°
|
||||||
{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>
|
</div>
|
||||||
{/if}
|
<div
|
||||||
|
class="w-full rounded-b px-1 py-0.5 text-center text-xs font-semibold"
|
||||||
|
style="background-color: {minStyle.bg}; color: {minStyle.fg}"
|
||||||
|
>
|
||||||
|
{tempMin.toFixed(0)}°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details section -->
|
||||||
|
<div class="mt-1 flex w-full flex-col items-center gap-0.5">
|
||||||
|
<!-- Sunshine bar -->
|
||||||
|
<div class="flex w-full items-center gap-1 px-1">
|
||||||
|
<svg class="shrink-0" width="14px" height="14px" style="fill: {sunColor}">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all"
|
||||||
|
style="width: {sunPct}%; background-color: {sunColor}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] font-medium text-muted-foreground">
|
||||||
|
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Precipitation -->
|
||||||
|
<div class="flex items-center gap-1 text-[11px]">
|
||||||
|
<svg class="fill-foreground shrink-0" width="14px" height="14px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{Number(precipSum ?? 0).toFixed(
|
||||||
|
precipSum >= 10 ? 0 : 1
|
||||||
|
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wind with direction -->
|
||||||
|
<div class="flex items-center gap-1 text-[11px]">
|
||||||
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
|
<div
|
||||||
|
class="inline-flex shrink-0"
|
||||||
|
style="transform: {getWindArrowRotation(windDir)}"
|
||||||
|
>
|
||||||
|
<svg class="fill-foreground" width="20px" height="20px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<svg class="fill-foreground shrink-0" width="20px" height="20px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span>
|
||||||
|
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
|
||||||
|
>-{gustMax?.toFixed(0) ?? '-'}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -115,118 +165,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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) {
|
@media (max-width: 768px) {
|
||||||
.daily-card {
|
button {
|
||||||
min-width: 95px;
|
min-width: 92px !important;
|
||||||
max-width: 110px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon svg {
|
button :global(svg[width='48px']) {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-temp-max,
|
|
||||||
.card-temp-min {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pad } from '$lib/utils/index';
|
import { pad } from '$lib/utils/index';
|
||||||
|
|
||||||
import { getColor } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import weatherCodes from '../../utils/weather-codes';
|
import weatherCodes from '../../utils/weather-codes';
|
||||||
import {
|
import {
|
||||||
|
type FetchedDaily,
|
||||||
type FetchedHourly,
|
type FetchedHourly,
|
||||||
type WeatherUnits,
|
type WeatherUnits,
|
||||||
getPrecipUnit,
|
getPrecipUnit,
|
||||||
getTempUnit,
|
getTempUnit,
|
||||||
getTextColorForTemp,
|
|
||||||
getWindArrowRotation,
|
getWindArrowRotation,
|
||||||
getWindUnit,
|
getWindUnit,
|
||||||
isCurrentHour,
|
isCurrentHour
|
||||||
isDaytimeHour
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: FetchedHourly;
|
data: FetchedHourly;
|
||||||
|
daily: FetchedDaily;
|
||||||
selectedDay: Date;
|
selectedDay: Date;
|
||||||
units: WeatherUnits;
|
units: WeatherUnits;
|
||||||
locationName: string;
|
locationName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data, selectedDay, units, locationName }: Props = $props();
|
let { data, daily, selectedDay, units, locationName }: Props = $props();
|
||||||
|
|
||||||
let hourlyInterval = $state<1 | 3>(3);
|
let hourlyInterval = $state<1 | 3>(3);
|
||||||
|
|
||||||
@@ -30,283 +30,398 @@
|
|||||||
const tempUnit = $derived(getTempUnit(units));
|
const tempUnit = $derived(getTempUnit(units));
|
||||||
const windUnit = $derived(getWindUnit(units));
|
const windUnit = $derived(getWindUnit(units));
|
||||||
const precipUnit = $derived(getPrecipUnit(units));
|
const precipUnit = $derived(getPrecipUnit(units));
|
||||||
|
let sunTimes = $derived(getSunTimes());
|
||||||
|
|
||||||
function getDayIndices(dates: Date[], day: Date): number[] {
|
const PRECIP_MAX_MM = 10;
|
||||||
const dayDate = day.getDate();
|
const PRECIP_MAX_INCH = 0.4;
|
||||||
const dayMonth = day.getMonth();
|
|
||||||
const dayYear = day.getFullYear();
|
let precipAbsMax = $derived(units.precipitation_unit === 'mm' ? PRECIP_MAX_MM : PRECIP_MAX_INCH);
|
||||||
const indices: number[] = [];
|
|
||||||
for (let i = 0; i < dates.length; i++) {
|
function getSelectedDayDailyIndex(): number {
|
||||||
const d = dates[i];
|
return findDailyIndex(selectedDay);
|
||||||
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 {
|
function getSunTimes(): { sunrise: Date; sunset: Date } | null {
|
||||||
if (!val || val <= 0 || !maxVal) return 0;
|
const di = getSelectedDayDailyIndex();
|
||||||
return Math.min(100, (val / Math.max(maxVal, 1)) * 100);
|
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 {
|
function getCloudOpacity(cover: number): number {
|
||||||
return Math.min(0.7, (cover ?? 0) / 120);
|
return Math.min(0.55, (cover ?? 0) / 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHumidityBg(hum: number): string {
|
function getHumidityBg(hum: number): string {
|
||||||
return `rgba(0, 240, 240, ${hum ** 3.8 / 10 ** 8.2})`;
|
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
|
||||||
}
|
|
||||||
|
|
||||||
function getPrecipProbColor(prob: number): string {
|
|
||||||
return prob > 50 ? 'white' : 'inherit';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
|
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
|
||||||
let text = '';
|
const parts: string[] = [];
|
||||||
if (prob != null) text += `Probability: ${prob}%`;
|
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
|
||||||
if (precip != null && precip > 0) {
|
if (precip != null && precip > 0) parts.push(`Amount: ${precip.toFixed(1)} ${precipUnit}`);
|
||||||
if (text) text += '\n';
|
return parts.join('\n');
|
||||||
text += `Amount: ${precip.toFixed(1)} ${precipUnit}`;
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIconSize(interval: 1 | 3): number {
|
function formatTemp(temp: number | null): string {
|
||||||
return interval === 3 ? 40 : 26;
|
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 dayIdx = $derived(getDayIndices(data.hourlyDates, selectedDay));
|
||||||
let numCols = $derived(dayIdx.length);
|
let is3h = $derived(hourlyInterval === 3);
|
||||||
let iconPx = $derived(getIconSize(hourlyInterval));
|
let daytimeFlags = $derived(dayIdx.map((idx) => isDaytime(data.hourlyDates[idx])));
|
||||||
let maxPrecip = $derived(
|
|
||||||
Math.max(
|
let cellData = $derived(
|
||||||
...dayIdx.map((i) => data.hourly.precipitation[i]).filter((v) => v != null && !isNaN(v)),
|
dayIdx.map((idx, i) => ({
|
||||||
0.1
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="hourly-header">
|
{#snippet weatherIcon(name: string, size: number = 16)}
|
||||||
<h3 class="hourly-title">
|
<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
|
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} – Hourly
|
||||||
|
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="interval-toggle">
|
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
||||||
<span class="interval-label">3h</span>
|
<span class="select-none text-muted-foreground">3h</span>
|
||||||
<button
|
<button
|
||||||
class="toggle-track {hourlyInterval === 1 ? 'active' : ''}"
|
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)}
|
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
||||||
title="Toggle between 1-hour and 3-hour intervals"
|
title="Toggle between 1-hour and 3-hour intervals"
|
||||||
>
|
>
|
||||||
<span class="toggle-thumb {hourlyInterval === 1 ? 'right' : 'left'}"></span>
|
<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>
|
</button>
|
||||||
<span class="interval-label">1h</span>
|
<span class="select-none text-muted-foreground">1h</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if numCols > 0}
|
{#if cellData.length > 0}
|
||||||
{@const hourly = data.hourly}
|
{@const hourly = data.hourly}
|
||||||
{@const dates = data.hourlyDates}
|
{@const iconPx = is3h ? 40 : 26}
|
||||||
<div class="hourly-container">
|
<div class="overflow-hidden rounded-lg border border-border">
|
||||||
<table class="hourly-table {hourlyInterval === 3 ? 'interval-3h' : 'interval-1h'}">
|
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||||
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col class="col-header" />
|
<col class="w-14 md:w-16" />
|
||||||
{#each dayIdx as _ (_.toString())}
|
{#each cellData as _ (_.idx)}
|
||||||
<col />
|
<col />
|
||||||
{/each}
|
{/each}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<tbody>
|
<tbody>
|
||||||
<!-- Time -->
|
<!-- Time + Daylight bar (merged) -->
|
||||||
<tr>
|
<tr class="!border-t-0">
|
||||||
<th class="row-header" scope="row"></th>
|
<th class="hdr" scope="row">
|
||||||
{#each dayIdx as idx (idx)}
|
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span>
|
||||||
{@const date = dates[idx]}
|
</th>
|
||||||
{@const now = isCurrentHour(date, today)}
|
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
|
||||||
<td class="hour-cell time-cell {now ? 'now' : ''}">
|
<!-- Daylight background -->
|
||||||
{pad(date.getHours())}<sup class="time-sup">00</sup>
|
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
|
||||||
</td>
|
<div
|
||||||
{/each}
|
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>
|
</tr>
|
||||||
|
|
||||||
<!-- Weather Icons -->
|
<!-- Weather Icons -->
|
||||||
<tr class="icon-row">
|
<tr>
|
||||||
<th class="row-header" scope="row">
|
{@render rowHeader('wi-day-cloudy')}
|
||||||
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
{#each cellData as cell, i (cell.idx)}
|
||||||
<use xlink:href="/images/weather-icons/wi-day-cloudy.svg#Layer_1"></use>
|
{@const wCode = hourly.weather_code[cell.idx]}
|
||||||
</svg>
|
<td
|
||||||
</th>
|
class="cell leading-[0] {is3h ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}"
|
||||||
{#each dayIdx as idx (idx)}
|
class:now={cell.isNow}
|
||||||
{@const wCode = hourly.weather_code[idx]}
|
class:icon-day={cell.isDaytime}
|
||||||
{@const date = dates[idx]}
|
class:icon-night={!cell.isDaytime}
|
||||||
{@const daytime = isDaytimeHour(date.getHours())}
|
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
||||||
{@const now = isCurrentHour(date, today)}
|
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
|
||||||
<td class="hour-cell weather-icon-cell {now ? 'now' : ''}">
|
>
|
||||||
<svg class="fill-foreground" width="{iconPx}px" height="{iconPx}px">
|
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||||
<use
|
|
||||||
xlink:href="/images/weather-icons/wi-{daytime ? 'day' : 'night'}-{weatherCodes[
|
|
||||||
wCode as keyof typeof weatherCodes
|
|
||||||
] ?? 'clear'}.svg#Layer_1"
|
|
||||||
></use>
|
|
||||||
</svg>
|
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Temperature -->
|
<!-- Temperature -->
|
||||||
<tr>
|
<tr>
|
||||||
<th class="row-header" scope="row">
|
{@render rowHeader('wi-thermometer', tempUnit)}
|
||||||
<div class="row-header-stack">
|
{#each cellData as cell (cell.idx)}
|
||||||
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||||
<use xlink:href="/images/weather-icons/wi-thermometer.svg#Layer_1"></use>
|
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||||
</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
|
<td
|
||||||
class="hour-cell temp-cell {now ? 'now' : ''}"
|
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}"
|
||||||
style="background-color: {bg}; color: {fg}"
|
class:now={cell.isNow}
|
||||||
|
style="background-color:{style.bg};color:{style.fg}"
|
||||||
>
|
>
|
||||||
{temp != null ? temp.toFixed(0) + '°' : '-'}
|
{formatTemp(temp)}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Feels Like -->
|
<!-- Feels Like -->
|
||||||
<tr>
|
<tr>
|
||||||
<th class="row-header" scope="row">
|
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||||
<div class="row-header-stack">
|
{#each cellData as cell (cell.idx)}
|
||||||
<span class="row-label">Feels</span>
|
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||||
<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
|
<td
|
||||||
class="hour-cell feels-cell {now ? 'now' : ''}"
|
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
|
||||||
style="background-color: {bg}99; color: {fg}"
|
class:now={cell.isNow}
|
||||||
>
|
>
|
||||||
{temp != null ? temp.toFixed(0) + '°' : '-'}
|
{formatTemp(temp)}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Cloud Cover -->
|
<!-- Wind -->
|
||||||
<tr>
|
<tr>
|
||||||
<th class="row-header" scope="row">
|
{@render rowHeader('wi-strong-wind', windUnit)}
|
||||||
<div class="row-header-stack">
|
{#each cellData as cell (cell.idx)}
|
||||||
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||||
<use xlink:href="/images/weather-icons/wi-cloud.svg#Layer_1"></use>
|
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||||
</svg>
|
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}>
|
||||||
<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)}
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
<div class="wind-arrow" style="transform: {getWindArrowRotation(windDir)}">
|
<span
|
||||||
<svg class="fill-foreground" width="18px" height="18px">
|
class="inline-block leading-[0]"
|
||||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
style="transform:{getWindArrowRotation(windDir)}"
|
||||||
</svg>
|
>
|
||||||
</div>
|
{@render weatherIcon('wi-direction-down', 24)}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="wind-speed-value">{wind?.toFixed(0) ?? '-'}</span>
|
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}">
|
||||||
|
{formatValue(wind)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Humidity -->
|
<!-- Humidity -->
|
||||||
<tr>
|
<tr>
|
||||||
<th class="row-header" scope="row">
|
{@render rowHeader('wi-humidity', '%')}
|
||||||
<div class="row-header-stack">
|
{#each cellData as cell (cell.idx)}
|
||||||
<svg class="fill-foreground inline-block" width="16px" height="16px">
|
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||||
<use xlink:href="/images/weather-icons/wi-humidity.svg#Layer_1"></use>
|
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}">
|
||||||
</svg>
|
{formatValue(hum)}
|
||||||
<span class="row-unit">%</span>
|
</td>
|
||||||
</div>
|
{/each}
|
||||||
</th>
|
</tr>
|
||||||
{#each dayIdx as idx (idx)}
|
|
||||||
{@const hum = hourly.relative_humidity_2m[idx]}
|
<!-- Cloud Cover -->
|
||||||
{@const date = dates[idx]}
|
<tr>
|
||||||
{@const now = isCurrentHour(date, today)}
|
{@render rowHeader('wi-cloud', '%')}
|
||||||
<td class="hour-cell {now ? 'now' : ''}" style="background: {getHumidityBg(hum ?? 0)}">
|
{#each cellData as cell (cell.idx)}
|
||||||
{hum != null ? hum.toFixed(0) : '-'}
|
{@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>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -316,94 +431,31 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hourly-header {
|
tr {
|
||||||
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));
|
border-top: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hourly-table tr:first-child {
|
/* ── Base cell ──────────────────────────────────────────── */
|
||||||
border-top: none;
|
.cell {
|
||||||
|
padding: 6px 2px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.2);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-header {
|
.cell:last-child {
|
||||||
width: 64px;
|
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;
|
padding: 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -414,212 +466,59 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-header-stack {
|
/* ── Precipitation ──────────────────────────────────────── */
|
||||||
display: flex;
|
.precip-cell {
|
||||||
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;
|
position: relative;
|
||||||
vertical-align: bottom;
|
padding: 0;
|
||||||
height: 52px;
|
text-align: center;
|
||||||
padding: 0 !important;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.interval-3h .precip-overlay-cell {
|
.precip-cell:last-child {
|
||||||
height: 60px;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.precip-prob-fill {
|
.precip-cell.now {
|
||||||
|
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 15%;
|
||||||
right: 0;
|
right: 15%;
|
||||||
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;
|
min-height: 3px;
|
||||||
transition: height 200ms;
|
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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.precip-overlay-label {
|
.precip-label {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 11px;
|
font-weight: 700;
|
||||||
font-weight: 600;
|
color: rgba(20, 60, 160, 0.9);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interval-3h .precip-overlay-label {
|
:global(.dark) .precip-label {
|
||||||
font-size: 13px;
|
color: rgba(120, 180, 255, 0.95);
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hourly-table .col-header {
|
.hdr {
|
||||||
width: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-header {
|
|
||||||
width: 48px;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 3px 2px;
|
padding: 3px 2px;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
.cell {
|
||||||
.hour-cell {
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 4px 1px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -240,17 +240,15 @@
|
|||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
color: getColor(Math.round(maxTemp), String(units.temperature_unit)) + '88'
|
color: getColor(maxTemp, String(units.temperature_unit)) + '88'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offset: 0.5,
|
offset: 0.5,
|
||||||
color:
|
color: getColor((maxTemp + minTemp) / 2, String(units.temperature_unit)) + '44'
|
||||||
getColor(Math.round((maxTemp + minTemp) / 2), String(units.temperature_unit)) +
|
|
||||||
'44'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offset: 1,
|
offset: 1,
|
||||||
color: getColor(Math.round(minTemp), String(units.temperature_unit)) + '08'
|
color: getColor(minTemp, String(units.temperature_unit)) + '08'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,28 +19,23 @@ export interface FetchedDaily {
|
|||||||
dailyDates: Date[];
|
dailyDates: Date[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTempUnit(units: WeatherUnits): string {
|
export const getTempUnit = (units: WeatherUnits): '°C' | '°F' => {
|
||||||
return units.temperature_unit === 'celsius' ? '°C' : '°F';
|
return units.temperature_unit === 'celsius' ? '°C' : '°F';
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getWindUnit(units: WeatherUnits): string {
|
export const getWindUnit = (units: WeatherUnits): string => {
|
||||||
return units.wind_speed_unit === 'kmh' ? 'km/h' : units.wind_speed_unit;
|
return units.wind_speed_unit === 'kmh' ? 'km/h' : units.wind_speed_unit;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getPrecipUnit(units: WeatherUnits): string {
|
export const getPrecipUnit = (units: WeatherUnits): 'mm' | 'in' => {
|
||||||
return units.precipitation_unit === 'mm' ? 'mm' : 'in';
|
return units.precipitation_unit === 'mm' ? 'mm' : 'in';
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getTextColorForTemp(temp: number, unit: string): string {
|
export const getWindArrowRotation = (deg: number): 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)`;
|
return `rotate(${deg}deg)`;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getWindDirectionLabel(deg: number): string {
|
export const getWindDirectionLabel = (deg: number): string => {
|
||||||
const dirs = [
|
const dirs = [
|
||||||
'N',
|
'N',
|
||||||
'NNE',
|
'NNE',
|
||||||
@@ -60,9 +55,9 @@ export function getWindDirectionLabel(deg: number): string {
|
|||||||
'NNW'
|
'NNW'
|
||||||
];
|
];
|
||||||
return dirs[Math.round(deg / 22.5) % 16];
|
return dirs[Math.round(deg / 22.5) % 16];
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getDayLabel(date: Date, today: Date): string {
|
export const getDayLabel = (date: Date, today: Date): string => {
|
||||||
const MS_PER_DAY = 24 * 3600 * 1000;
|
const MS_PER_DAY = 24 * 3600 * 1000;
|
||||||
const diff = Math.round(
|
const diff = Math.round(
|
||||||
(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() -
|
(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() -
|
||||||
@@ -73,17 +68,13 @@ export function getDayLabel(date: Date, today: Date): string {
|
|||||||
if (diff === 1) return 'Tomorrow';
|
if (diff === 1) return 'Tomorrow';
|
||||||
if (diff === -1) return 'Yesterday';
|
if (diff === -1) return 'Yesterday';
|
||||||
return `${date.getMonth() + 1}-${date.getDate()}`;
|
return `${date.getMonth() + 1}-${date.getDate()}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function isDaytimeHour(hour: number): boolean {
|
export const isCurrentHour = (date: Date, now: Date): boolean => {
|
||||||
return hour >= 6 && hour < 21;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCurrentHour(date: Date, now: Date): boolean {
|
|
||||||
return (
|
return (
|
||||||
date.getDate() === now.getDate() &&
|
date.getDate() === now.getDate() &&
|
||||||
date.getMonth() === now.getMonth() &&
|
date.getMonth() === now.getMonth() &&
|
||||||
date.getFullYear() === now.getFullYear() &&
|
date.getFullYear() === now.getFullYear() &&
|
||||||
date.getHours() === now.getHours()
|
date.getHours() === now.getHours()
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user