feat: local time #7

Merged
frederic merged 3 commits from local-time into main 2026-02-16 11:41:34 +01:00
15 changed files with 837 additions and 683 deletions
Showing only changes of commit 60b89c5856 - Show all commits
+634 -535
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -49,9 +49,9 @@
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
"vitest": "^4.0.15",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"vitest-browser-svelte": "^2.0.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"echarts": "^6.0.0",
"mode-watcher": "^1.1.0",
"openmeteo": "^1.2.3"
+24 -13
View File
@@ -39,18 +39,21 @@ export function range(start: number, stop: number, step: number): number[] {
/**
* Extracts timestamp array (in milliseconds, with UTC offset applied) from a VariablesWithTime block.
*/
export function getTimestamps(timeBlock: VariablesWithTime, utcOffsetSeconds: number): number[] {
export function getTimestamps(timeBlock: VariablesWithTime): number[] {
const start = Number(timeBlock.time());
const end = Number(timeBlock.timeEnd());
const interval = timeBlock.interval();
return range(start, end, interval).map((t) => (t + utcOffsetSeconds) * 1000);
return range(start, end, interval).map((t) => t * 1000);
}
/**
* Extracts Date array (with UTC offset applied) from a VariablesWithTime block.
*/
export function getDates(timeBlock: VariablesWithTime, utcOffsetSeconds: number): Date[] {
return getTimestamps(timeBlock, utcOffsetSeconds).map((t) => new Date(t));
export function getDates(timeBlock: VariablesWithTime): Date[] {
return getTimestamps(timeBlock).map((t) => {
const date = new Date(t);
return date;
});
}
/**
@@ -148,6 +151,7 @@ export function unitToDisplayString(unit: Unit): string {
export interface WeatherLocation {
latitude: number;
longitude: number;
timezone?: string;
}
export interface WeatherUnitParams {
@@ -164,7 +168,6 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
model?: string;
forecast_days?: number;
past_days?: number;
timezone?: string;
}
export interface WeekHourlyData {
@@ -197,6 +200,7 @@ export interface WeekForecastResult {
hourly: WeekHourlyData;
daily: WeekDailyData;
utcOffsetSeconds: number;
timezone: string;
hourlyTimestamps: number[];
hourlyDates: Date[];
dailyDates: Date[];
@@ -219,6 +223,7 @@ export interface ModelCompareResult {
models: ModelSeriesData[];
timestamps: number[];
utcOffsetSeconds: number;
timezone: string;
markAreas: MarkArea[];
units: Record<string, string>;
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
@@ -246,6 +251,7 @@ export interface EnsembleForecastResult {
variables: Record<string, EnsembleVariableData>;
timestamps: number[];
utcOffsetSeconds: number;
timezone: string;
markAreas: MarkArea[];
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
hourlyFlat: Record<string, number[]>;
@@ -314,12 +320,13 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
const responses = await fetchWeatherApi(FORECAST_URL, cleanParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
const hourlyBlock = response.hourly()!;
const dailyBlock = response.daily()!;
// Hourly: variables are in the same order as WEEK_HOURLY_VARS
const hourlyTimestamps = getTimestamps(hourlyBlock, utcOffsetSeconds);
const hourlyTimestamps = getTimestamps(hourlyBlock);
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
const hourly: WeekHourlyData = {
@@ -336,7 +343,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
};
// Daily: variables are in the same order as WEEK_DAILY_VARS
const dailyDates = getDates(dailyBlock, utcOffsetSeconds);
const dailyDates = getDates(dailyBlock);
const sunriseVar = dailyBlock.variables(3)!;
const sunsetVar = dailyBlock.variables(4)!;
@@ -354,12 +361,13 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
};
const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset, utcOffsetSeconds);
const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset);
return {
hourly,
daily,
utcOffsetSeconds,
timezone,
hourlyTimestamps,
hourlyDates,
dailyDates,
@@ -395,10 +403,10 @@ export async function fetchModelComparison(
// With multiple models, we get one response per model
const firstResponse = responses[0];
const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
const timezone = firstResponse.timezone() ?? params.timezone ?? 'UTC';
// Extract timestamps from the first response's hourly block
const hourlyBlock = firstResponse.hourly()!;
const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds);
const timestamps = getTimestamps(hourlyBlock);
// Extract sunrise/sunset from the first response's daily block
let markAreas: MarkArea[] = [];
@@ -408,7 +416,7 @@ export async function fetchModelComparison(
const sunsetVar = dailyBlock.variables(1)!;
const sunrise = getInt64Values(sunriseVar);
const sunset = getInt64Values(sunsetVar);
markAreas = buildDaylightMarkAreas(sunrise, sunset, utcOffsetSeconds);
markAreas = buildDaylightMarkAreas(sunrise, sunset);
}
// Process each model's response
@@ -463,6 +471,7 @@ export async function fetchModelComparison(
models,
timestamps,
utcOffsetSeconds,
timezone,
markAreas,
units,
hourlyFlat,
@@ -511,9 +520,10 @@ export async function fetchEnsembleForecast(
const ensembleResponse = ensembleResponses[0];
const utcOffsetSeconds = ensembleResponse.utcOffsetSeconds();
const timezone = ensembleResponse.timezone() ?? params.timezone ?? 'UTC';
const hourlyBlock = ensembleResponse.hourly()!;
const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds);
const timestamps = getTimestamps(hourlyBlock);
const timeLength = timestamps.length;
// Extract sunrise/sunset for mark areas
@@ -524,7 +534,7 @@ export async function fetchEnsembleForecast(
if (dailyBlock) {
const sunrise = getInt64Values(dailyBlock.variables(0)!);
const sunset = getInt64Values(dailyBlock.variables(1)!);
markAreas = buildDaylightMarkAreas(sunrise, sunset, dailyResponse.utcOffsetSeconds());
markAreas = buildDaylightMarkAreas(sunrise, sunset);
}
}
@@ -613,6 +623,7 @@ export async function fetchEnsembleForecast(
variables,
timestamps,
utcOffsetSeconds,
timezone,
markAreas,
hourlyFlat,
hourlyUnitsFlat
+61
View File
@@ -0,0 +1,61 @@
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
import { isSameDay as isSameDayDateFns } from 'date-fns';
/**
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
*/
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
return formatInTimeZone(date, timeZone, pattern);
}
/**
* Checks if two dates are the same day in a specific timezone.
* Important for comparing weather forecast days against a selected date.
*/
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
const z1 = toZonedTime(date1, timeZone);
const z2 = toZonedTime(date2, timeZone);
return isSameDayDateFns(z1, z2);
}
/**
* Gets the numeric hour (0-23) for a date in a specific timezone.
*/
export function getZonedHour(date: Date, timeZone: string): number {
return parseInt(formatInTimeZone(date, timeZone, 'H'), 10);
}
/**
* Returns a relative label like "Today", "Tomorrow", "Yesterday",
* or a formatted date string, all relative to the target timezone.
*/
export function getRelativeDayLabel(date: Date, timeZone: string): string {
const now = new Date();
const zonedDate = toZonedTime(date, timeZone);
const zonedNow = toZonedTime(now, timeZone);
if (isSameDayDateFns(zonedDate, zonedNow)) return 'Today';
const tomorrow = new Date(zonedNow);
tomorrow.setDate(tomorrow.getDate() + 1);
if (isSameDayDateFns(zonedDate, tomorrow)) return 'Tomorrow';
const yesterday = new Date(zonedNow);
yesterday.setDate(yesterday.getDate() - 1);
if (isSameDayDateFns(zonedDate, yesterday)) return 'Yesterday';
return formatInTimeZone(date, timeZone, 'EEE d MMM');
}
/**
* Formats a UTC offset in seconds to a string like "UTC+1" or "UTC-05:00"
*/
export function formatUtcOffset(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);
const pad = (n: number) => n.toString().padStart(2, '0');
return minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours}:${pad(minutes)}`;
}
-1
View File
@@ -54,7 +54,6 @@ export {
buildSpreadSeries,
calculateAverage,
calculateSpread,
convertTimestamps,
findUnit
} from './series';
export type {
+10 -4
View File
@@ -5,6 +5,7 @@
* These builders ensure visual consistency across all chart pages and reduce
* boilerplate in page-level components.
*/
import { formatZoned } from '../date';
import { getThemeColors } from './theme';
import type { ThemeColors } from './theme';
@@ -30,6 +31,7 @@ export interface LegendOptions {
export interface TooltipOptions {
unit: string;
timezone?: string;
}
export interface AxisOptions {
@@ -180,9 +182,9 @@ export function buildLegend(options: LegendOptions, colors?: ThemeColors): Recor
// ─── X Axis (Time) ───────────────────────────────────────────────────────────
/**
* Builds a time-based X axis with theme-aware styling.
* Builds a time-based X axis with theme-aware styling and timezone-aware labels.
*/
export function buildTimeXAxis(colors?: ThemeColors): Record<string, unknown> {
export function buildTimeXAxis(timezone?: string, colors?: ThemeColors): Record<string, unknown> {
const c = colors ?? getThemeColors();
return {
@@ -197,7 +199,10 @@ export function buildTimeXAxis(colors?: ThemeColors): Record<string, unknown> {
},
axisLabel: {
color: c.text,
hideOverlap: true
hideOverlap: true,
formatter: timezone
? (value: number) => formatZoned(new Date(value), timezone, 'HH:mm')
: undefined
},
axisTick: {
lineStyle: {
@@ -336,6 +341,7 @@ export interface ChartOptionParams {
toolbox?: ToolboxOptions | false;
showCredit?: boolean;
colors?: ThemeColors;
timezone?: string;
}
/**
@@ -358,7 +364,7 @@ export function composeChartOption(params: ChartOptionParams): Record<string, un
hasSubtitle: hasTitle && !!params.title?.subtext,
showLegend
}),
xAxis: buildTimeXAxis(colors),
xAxis: buildTimeXAxis(params.timezone, colors),
yAxis: buildValueYAxis(params.yAxis, colors),
series: params.series,
textStyle: {
+9 -24
View File
@@ -5,9 +5,8 @@
* weather chart visualizations. These builders encapsulate the styling
* and configuration details so page-level code only needs to provide data.
*/
import { CHART_COLORS } from './theme';
import { isColumnUnit } from './options';
import { CHART_COLORS } from './theme';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -38,9 +37,7 @@ export interface CurrentTimeSeriesParams {
export interface DaylightSeriesParams {
/** Array of mark area pairs: [[start, end], [start, end], ...] */
markAreas: Array<
[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]
>;
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
}
export interface SpreadSeriesParams {
@@ -125,9 +122,7 @@ export function buildAverageSeries(params: AverageSeriesParams): Record<string,
* Builds a helper series that renders a vertical red line at the current time.
* Uses an empty data series with a markLine to overlay onto the chart.
*/
export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record<string, unknown> {
const { utcOffsetSeconds } = params;
export function buildCurrentTimeSeries(): Record<string, unknown> {
return {
name: 'Current Time',
type: 'line',
@@ -137,7 +132,7 @@ export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record<
symbol: 'none',
data: [
{
xAxis: Date.now() + utcOffsetSeconds * 1000,
xAxis: Date.now(),
lineStyle: {
color: CHART_COLORS.currentTimeLine,
width: 2,
@@ -164,18 +159,17 @@ export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record<
*/
export function buildDaylightMarkAreas(
sunrise: number[],
sunset: number[],
utcOffsetSeconds: number
sunset: number[]
): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> {
return sunrise.map((r: number, i: number) => [
{
xAxis: (r + utcOffsetSeconds) * 1000,
xAxis: r * 1000,
itemStyle: {
color: CHART_COLORS.daylight
}
},
{
xAxis: (sunset[i] + utcOffsetSeconds) * 1000
xAxis: sunset[i] * 1000
}
]);
}
@@ -184,9 +178,7 @@ export function buildDaylightMarkAreas(
* Builds a helper series that renders day/night shading bands via markArea.
* Returns null if no mark areas are provided (so callers can filter it out).
*/
export function buildDaylightSeries(
params: DaylightSeriesParams
): Record<string, unknown> | null {
export function buildDaylightSeries(params: DaylightSeriesParams): Record<string, unknown> | null {
if (params.markAreas.length === 0) return null;
return {
@@ -285,7 +277,7 @@ export function calculateAverage(
if (!model.startsWith(variable)) continue;
for (const [index, val] of (values as number[]).entries()) {
if (val !== null && val !== undefined) {
if (val !== null && val !== undefined && isFinite(val)) {
average[index] += val;
averageCount[index]++;
}
@@ -338,13 +330,6 @@ export function calculateSpread(
return { minValues, maxValues };
}
/**
* Converts raw unix timestamps (seconds) to millisecond timestamps with UTC offset applied.
*/
export function convertTimestamps(times: number[], utcOffsetSeconds: number): number[] {
return times.map((t) => (t + utcOffsetSeconds) * 1000);
}
/**
* Finds the unit string for a given variable from the hourly_units map.
* Returns an empty string if the variable is not found.
@@ -55,7 +55,7 @@
interface FetchedData {
ensembleResult: EnsembleForecastResult;
timestamps: number[];
utc_offset_seconds: number;
timezone: string;
markAreas: MarkArea[];
}
@@ -102,15 +102,17 @@
forecast_days: 14,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone
});
fetchedData = {
ensembleResult: result,
timestamps: result.timestamps,
utc_offset_seconds: result.utcOffsetSeconds,
timezone: result.timezone,
markAreas: result.markAreas
};
console.log(fetchedData.timezone);
loading = false;
};
@@ -123,7 +125,7 @@
$effect(() => {
if (!fetchedData) return;
const { ensembleResult, timestamps, utc_offset_seconds, markAreas } = fetchedData;
const { ensembleResult, timestamps, timezone, markAreas } = fetchedData;
const _showLegend = showLegend;
const colors = getThemeColors();
@@ -150,7 +152,7 @@
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
series.push(buildCurrentTimeSeries());
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
@@ -167,7 +169,7 @@
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
tooltip: { unit, timezone },
legend: {
show: _showLegend,
data: [variable + '_average']
@@ -181,7 +183,8 @@
series,
toolbox: false,
showCredit: isLast,
colors
colors,
timezone
});
newOptions.push(option);
@@ -68,7 +68,7 @@
interface FetchedData {
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
utc_offset_seconds: number;
timezone: string;
markAreas: MarkArea[];
timestamps: number[];
}
@@ -115,13 +115,14 @@
models: modelList,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone
});
fetchedData = {
hourly: result.hourlyFlat,
hourly_units: result.hourlyUnitsFlat,
utc_offset_seconds: result.utcOffsetSeconds,
timezone: result.timezone,
markAreas: result.markAreas,
timestamps: result.timestamps
};
@@ -137,13 +138,7 @@
$effect(() => {
if (!fetchedData) return;
const {
hourly: hourlyData,
hourly_units,
utc_offset_seconds,
markAreas,
timestamps
} = fetchedData;
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
const _showLegend = showLegend;
const colors = getThemeColors();
@@ -178,7 +173,7 @@
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
series.push(buildCurrentTimeSeries());
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
@@ -195,7 +190,7 @@
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
tooltip: { unit, timezone },
legend: { show: _showLegend },
grid: {
hasTitle: isFirst,
@@ -206,7 +201,8 @@
series,
toolbox: false,
showCredit: isLast,
colors
colors,
timezone
});
newOptions.push(option);
@@ -63,12 +63,14 @@
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
forecast_days: 7,
past_days: 0
past_days: 0,
timezone: loc.timezone
});
fetchedHourly = {
hourly: result.hourly,
utc_offset_seconds: result.utcOffsetSeconds,
timezone: result.timezone,
timestamps: result.hourlyTimestamps,
hourlyDates: result.hourlyDates,
markAreas: result.markAreas
@@ -76,6 +78,7 @@
fetchedDaily = {
daily: result.daily,
timezone: result.timezone,
dailyDates: result.dailyDates
};
@@ -1,9 +1,11 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
import { type FetchedDaily, type WeatherUnits, getDayLabel, getWindArrowRotation } from './types';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props {
daily: FetchedDaily | null;
@@ -14,8 +16,6 @@
let { daily, selectedDay, units, onSelectDay }: Props = $props();
const today = new Date();
function getDaylightSeconds(index: number): number {
if (!daily) return 0;
const sunriseTs = daily.daily.sunrise[index];
@@ -43,7 +43,7 @@
<div class="flex gap-1 overflow-x-auto pb-1" style="scrollbar-width: thin">
{#if daily}
{#each daily.dailyDates as time, index (index)}
{@const selected = time.getDate() === selectedDay.getDate()}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const tempMax = daily.daily.temperature_2m_max[index]}
{@const tempMin = daily.daily.temperature_2m_min[index]}
{@const wCode = daily.daily.weather_code[index]}
@@ -68,10 +68,10 @@
>
<!-- Day label -->
<span class="text-sm font-bold tracking-wide">
{time.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase()}
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span class="text-[11px] text-muted-foreground">
{getDayLabel(time, today)}
{getRelativeDayLabel(time, daily.timezone)}
</span>
<!-- Weather icon -->
@@ -1,5 +1,5 @@
<script lang="ts">
import { pad } from '$lib/utils/index';
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
@@ -10,8 +10,7 @@
getPrecipUnit,
getTempUnit,
getWindArrowRotation,
getWindUnit,
isCurrentHour
getWindUnit
} from './types';
interface Props {
@@ -54,36 +53,31 @@
}
function timeToFraction(date: Date): number {
const totalMinutes = date.getHours() * 60 + date.getMinutes();
const firstMin = cellData[0].date.getHours() * 60;
const tz = data.timezone;
const hour = getZonedHour(date, tz);
const minutes = parseInt(formatZoned(date, tz, 'mm'), 10);
const totalMinutes = hour * 60 + minutes;
const firstHour = getZonedHour(cellData[0].date, tz);
const firstMin = firstHour * 60;
const step = is3h ? 3 : 1;
const lastMin = cellData[cellData.length - 1].date.getHours() * 60 + step * 60;
const lastHour = getZonedHour(cellData[cellData.length - 1].date, tz);
const lastMin = lastHour * 60 + step * 60;
const range = lastMin - firstMin;
if (range <= 0) return 0;
return Math.max(0, Math.min(1, (totalMinutes - firstMin) / range));
}
function formatTime(date: Date): string {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
return formatZoned(date, data.timezone, 'HH:mm');
}
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));
let timezoneLabel = $derived(formatUtcOffset(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()
);
return daily.dailyDates.findIndex((dd) => isSameDayInZone(dd, date, data.timezone));
}
function isDaytime(hourDate: Date): boolean {
@@ -97,13 +91,9 @@
}
function getDayIndices(dates: Date[], day: Date): number[] {
const tz = data.timezone;
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)
) {
if (isSameDayInZone(d, day, tz) && (hourlyInterval === 1 || getZonedHour(d, tz) % 3 === 0)) {
acc.push(i);
}
return acc;
@@ -148,12 +138,19 @@
let daytimeFlags = $derived(dayIdx.map((idx) => isDaytime(data.hourlyDates[idx])));
let cellData = $derived(
dayIdx.map((idx, i) => ({
idx,
date: data.hourlyDates[idx],
isNow: isCurrentHour(data.hourlyDates[idx], today),
isDaytime: daytimeFlags[i]
}))
dayIdx.map((idx, i) => {
const date = data.hourlyDates[idx];
const tz = data.timezone;
const isNow =
formatZoned(date, tz, 'yyyy-MM-dd HH') === formatZoned(today, tz, 'yyyy-MM-dd HH');
return {
idx,
date,
isNow,
isDaytime: daytimeFlags[i]
};
})
);
let sunrisePercent = $derived(
@@ -195,7 +192,7 @@
<!-- 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
{formatZoned(selectedDay, data.timezone, 'EEEE')} Hourly
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
</h3>
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
@@ -293,10 +290,12 @@
style="left:{leftPct}%;width:{widthPct}%"
>
{#if is3h}
{pad(cell.date.getHours())}
{formatZoned(cell.date, data.timezone, 'HH')}
{:else}
<span class="inline-flex items-baseline gap-1">
<span class="text-[11px] font-semibold">{pad(cell.date.getHours())}</span>
<span class="text-[11px] font-semibold"
>{formatZoned(cell.date, data.timezone, 'HH')}</span
>
<sup
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
>00</sup
@@ -3,8 +3,8 @@
import * as echarts from 'echarts';
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
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';
@@ -13,7 +13,6 @@
import {
type FetchedHourly,
type WeatherUnits,
getDayLabel,
getPrecipUnit,
getTempUnit,
getWindDirectionLabel,
@@ -32,7 +31,6 @@
const CHART_GROUP = 'week-meteogram';
const MS_PER_DAY = 24 * 3600 * 1000;
const today = new Date();
let showCharts = $state(false);
let chartComponents: EChart[] = $state([]);
@@ -40,9 +38,17 @@
let chartOptions: Array<Record<string, unknown>> = $state([]);
export function scrollToDay(day: Date): void {
if (chartInstances.length === 0) return;
if (chartInstances.length === 0 || !data) return;
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const tz = data.timezone;
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
const firstHourIdx = data.hourlyDates.findIndex(
(d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr
);
if (firstHourIdx === -1) return;
const dayStart = data.timestamps[firstHourIdx];
const dayEnd = dayStart + MS_PER_DAY;
const timestamps = data.timestamps;
const rangeStart = timestamps[0];
@@ -90,7 +96,7 @@
$effect(() => {
if (!data) return;
const { hourly, utc_offset_seconds, timestamps, markAreas } = data;
const { hourly, timestamps, markAreas } = data;
const colors = getThemeColors();
const tempUnit = getTempUnit(units);
const precipUnit = getPrecipUnit(units);
@@ -116,7 +122,7 @@
const annotations = (): Array<Record<string, unknown>> => {
const series: Array<Record<string, unknown>> = [];
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
series.push(buildCurrentTimeSeries());
const dl = buildDaylightSeries({ markAreas });
if (dl) series.push(dl);
return series;
@@ -126,7 +132,12 @@
type: 'time',
splitLine: { show: false },
axisLine: { lineStyle: { color: colors.axisLine } },
axisLabel: { color: colors.text, hideOverlap: true, show: showLabel },
axisLabel: {
color: colors.text,
hideOverlap: true,
show: showLabel,
formatter: (value: number) => formatZoned(new Date(value), data.timezone, 'HH:mm')
},
axisTick: { lineStyle: { color: colors.axisLine } }
});
@@ -181,7 +192,8 @@
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 dateStr = formatZoned(date, data.timezone, 'EEE d MMM HH:mm');
return `<b>${dateStr}</b><br/>`;
};
const isAnnotation = (name: string): boolean => name === 'Daylight' || name === 'Current Time';
@@ -488,11 +500,11 @@
<div class="detailed-charts" in:fade={{ duration: 200 }}>
<div class="charts-header">
<h3 class="charts-title">
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
{formatZoned(selectedDay, data.timezone, 'EEEE')}
<small>
{getDayLabel(selectedDay, today) !==
selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })
? ` (${getDayLabel(selectedDay, today)})`
{getRelativeDayLabel(selectedDay, data.timezone) !==
formatZoned(selectedDay, data.timezone, 'EEEE')
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
: ''}
</small>
</h3>
@@ -1,5 +1,5 @@
<script lang="ts">
import { pad } from '$lib/utils/index';
import { formatZoned } from '$lib/utils/date';
import type { FetchedDaily } from './types';
@@ -23,19 +23,19 @@
});
</script>
{#if sunrise && sunset}
{#if daily && 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>
<span>{formatZoned(sunrise, daily.timezone, 'HH:mm')}</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>
<span>{formatZoned(sunset, daily.timezone, 'HH:mm')}</span>
</div>
</div>
{/if}
+2 -22
View File
@@ -9,6 +9,7 @@ export interface WeatherUnits {
export interface FetchedHourly {
hourly: WeekHourlyData;
utc_offset_seconds: number;
timezone: string;
timestamps: number[];
hourlyDates: Date[];
markAreas: MarkArea[];
@@ -16,6 +17,7 @@ export interface FetchedHourly {
export interface FetchedDaily {
daily: WeekDailyData;
timezone: string;
dailyDates: Date[];
}
@@ -56,25 +58,3 @@ export const getWindDirectionLabel = (deg: number): string => {
];
return dirs[Math.round(deg / 22.5) % 16];
};
export const 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 const isCurrentHour = (date: Date, now: Date): boolean => {
return (
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear() &&
date.getHours() === now.getHours()
);
};