feat: local time (#7)
Co-authored-by: terraputix <terraputix@mailbox.org> Reviewed-on: useweb/ombrella#7
This commit is contained in:
Generated
+634
-535
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -49,9 +49,9 @@
|
|||||||
"typescript-eslint": "^8.48.1",
|
"typescript-eslint": "^8.48.1",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.0.15",
|
"vitest": "^4.0.15",
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1",
|
||||||
},
|
"date-fns": "^4.1.0",
|
||||||
"dependencies": {
|
"date-fns-tz": "^3.2.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"openmeteo": "^1.2.3"
|
"openmeteo": "^1.2.3"
|
||||||
|
|||||||
+27
-17
@@ -39,18 +39,18 @@ export function range(start: number, stop: number, step: number): number[] {
|
|||||||
/**
|
/**
|
||||||
* Extracts timestamp array (in milliseconds, with UTC offset applied) from a VariablesWithTime block.
|
* 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 start = Number(timeBlock.time());
|
||||||
const end = Number(timeBlock.timeEnd());
|
const end = Number(timeBlock.timeEnd());
|
||||||
const interval = timeBlock.interval();
|
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.
|
* Extracts Date array (with UTC offset applied) from a VariablesWithTime block.
|
||||||
*/
|
*/
|
||||||
export function getDates(timeBlock: VariablesWithTime, utcOffsetSeconds: number): Date[] {
|
export function getDates(timeBlock: VariablesWithTime): Date[] {
|
||||||
return getTimestamps(timeBlock, utcOffsetSeconds).map((t) => new Date(t));
|
return getTimestamps(timeBlock).map((t) => new Date(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,6 +148,7 @@ export function unitToDisplayString(unit: Unit): string {
|
|||||||
export interface WeatherLocation {
|
export interface WeatherLocation {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherUnitParams {
|
export interface WeatherUnitParams {
|
||||||
@@ -164,7 +165,6 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
|||||||
model?: string;
|
model?: string;
|
||||||
forecast_days?: number;
|
forecast_days?: number;
|
||||||
past_days?: number;
|
past_days?: number;
|
||||||
timezone?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeekHourlyData {
|
export interface WeekHourlyData {
|
||||||
@@ -197,6 +197,7 @@ export interface WeekForecastResult {
|
|||||||
hourly: WeekHourlyData;
|
hourly: WeekHourlyData;
|
||||||
daily: WeekDailyData;
|
daily: WeekDailyData;
|
||||||
utcOffsetSeconds: number;
|
utcOffsetSeconds: number;
|
||||||
|
timezone: string;
|
||||||
hourlyTimestamps: number[];
|
hourlyTimestamps: number[];
|
||||||
hourlyDates: Date[];
|
hourlyDates: Date[];
|
||||||
dailyDates: Date[];
|
dailyDates: Date[];
|
||||||
@@ -219,6 +220,7 @@ export interface ModelCompareResult {
|
|||||||
models: ModelSeriesData[];
|
models: ModelSeriesData[];
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
utcOffsetSeconds: number;
|
utcOffsetSeconds: number;
|
||||||
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
markAreas: MarkArea[];
|
||||||
units: Record<string, string>;
|
units: Record<string, string>;
|
||||||
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
|
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
|
||||||
@@ -246,6 +248,7 @@ export interface EnsembleForecastResult {
|
|||||||
variables: Record<string, EnsembleVariableData>;
|
variables: Record<string, EnsembleVariableData>;
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
utcOffsetSeconds: number;
|
utcOffsetSeconds: number;
|
||||||
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
markAreas: MarkArea[];
|
||||||
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
||||||
hourlyFlat: Record<string, number[]>;
|
hourlyFlat: Record<string, number[]>;
|
||||||
@@ -314,12 +317,13 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
const responses = await fetchWeatherApi(FORECAST_URL, cleanParams);
|
const responses = await fetchWeatherApi(FORECAST_URL, cleanParams);
|
||||||
const response = responses[0];
|
const response = responses[0];
|
||||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||||
|
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
||||||
|
|
||||||
const hourlyBlock = response.hourly()!;
|
const hourlyBlock = response.hourly()!;
|
||||||
const dailyBlock = response.daily()!;
|
const dailyBlock = response.daily()!;
|
||||||
|
|
||||||
// Hourly: variables are in the same order as WEEK_HOURLY_VARS
|
// 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 hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
||||||
|
|
||||||
const hourly: WeekHourlyData = {
|
const hourly: WeekHourlyData = {
|
||||||
@@ -336,7 +340,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
// 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 sunriseVar = dailyBlock.variables(3)!;
|
||||||
const sunsetVar = dailyBlock.variables(4)!;
|
const sunsetVar = dailyBlock.variables(4)!;
|
||||||
@@ -354,12 +358,13 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset, utcOffsetSeconds);
|
const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hourly,
|
hourly,
|
||||||
daily,
|
daily,
|
||||||
utcOffsetSeconds,
|
utcOffsetSeconds,
|
||||||
|
timezone,
|
||||||
hourlyTimestamps,
|
hourlyTimestamps,
|
||||||
hourlyDates,
|
hourlyDates,
|
||||||
dailyDates,
|
dailyDates,
|
||||||
@@ -379,7 +384,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
export async function fetchModelComparison(
|
export async function fetchModelComparison(
|
||||||
params: ModelCompareParams
|
params: ModelCompareParams
|
||||||
): Promise<ModelCompareResult> {
|
): Promise<ModelCompareResult> {
|
||||||
const forecastApiParams: Record<string, string> = {
|
const forecastApiParams: Record<string, string | number | undefined> = {
|
||||||
latitude: String(params.latitude),
|
latitude: String(params.latitude),
|
||||||
longitude: String(params.longitude),
|
longitude: String(params.longitude),
|
||||||
hourly: params.hourlyVariables.join(','),
|
hourly: params.hourlyVariables.join(','),
|
||||||
@@ -387,7 +392,8 @@ export async function fetchModelComparison(
|
|||||||
daily: 'sunrise,sunset',
|
daily: 'sunrise,sunset',
|
||||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||||
precipitation_unit: params.precipitation_unit ?? 'mm'
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||||
|
timezone: params.timezone
|
||||||
};
|
};
|
||||||
|
|
||||||
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
|
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
|
||||||
@@ -395,10 +401,10 @@ export async function fetchModelComparison(
|
|||||||
// With multiple models, we get one response per model
|
// With multiple models, we get one response per model
|
||||||
const firstResponse = responses[0];
|
const firstResponse = responses[0];
|
||||||
const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
|
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 hourlyBlock = firstResponse.hourly()!;
|
||||||
const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds);
|
const timestamps = getTimestamps(hourlyBlock);
|
||||||
|
|
||||||
// Extract sunrise/sunset from the first response's daily block
|
// Extract sunrise/sunset from the first response's daily block
|
||||||
let markAreas: MarkArea[] = [];
|
let markAreas: MarkArea[] = [];
|
||||||
@@ -408,7 +414,7 @@ export async function fetchModelComparison(
|
|||||||
const sunsetVar = dailyBlock.variables(1)!;
|
const sunsetVar = dailyBlock.variables(1)!;
|
||||||
const sunrise = getInt64Values(sunriseVar);
|
const sunrise = getInt64Values(sunriseVar);
|
||||||
const sunset = getInt64Values(sunsetVar);
|
const sunset = getInt64Values(sunsetVar);
|
||||||
markAreas = buildDaylightMarkAreas(sunrise, sunset, utcOffsetSeconds);
|
markAreas = buildDaylightMarkAreas(sunrise, sunset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each model's response
|
// Process each model's response
|
||||||
@@ -463,6 +469,7 @@ export async function fetchModelComparison(
|
|||||||
models,
|
models,
|
||||||
timestamps,
|
timestamps,
|
||||||
utcOffsetSeconds,
|
utcOffsetSeconds,
|
||||||
|
timezone,
|
||||||
markAreas,
|
markAreas,
|
||||||
units,
|
units,
|
||||||
hourlyFlat,
|
hourlyFlat,
|
||||||
@@ -484,7 +491,7 @@ export async function fetchEnsembleForecast(
|
|||||||
): Promise<EnsembleForecastResult> {
|
): Promise<EnsembleForecastResult> {
|
||||||
const forecastDays = params.forecast_days ?? 14;
|
const forecastDays = params.forecast_days ?? 14;
|
||||||
|
|
||||||
const ensembleParams: Record<string, string> = {
|
const ensembleParams: Record<string, string | number | undefined> = {
|
||||||
latitude: String(params.latitude),
|
latitude: String(params.latitude),
|
||||||
longitude: String(params.longitude),
|
longitude: String(params.longitude),
|
||||||
hourly: params.hourlyVariables.join(','),
|
hourly: params.hourlyVariables.join(','),
|
||||||
@@ -492,7 +499,8 @@ export async function fetchEnsembleForecast(
|
|||||||
forecast_days: String(forecastDays),
|
forecast_days: String(forecastDays),
|
||||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||||
precipitation_unit: params.precipitation_unit ?? 'mm'
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||||
|
timezone: params.timezone
|
||||||
};
|
};
|
||||||
|
|
||||||
const dailyParams: Record<string, string> = {
|
const dailyParams: Record<string, string> = {
|
||||||
@@ -511,9 +519,10 @@ export async function fetchEnsembleForecast(
|
|||||||
|
|
||||||
const ensembleResponse = ensembleResponses[0];
|
const ensembleResponse = ensembleResponses[0];
|
||||||
const utcOffsetSeconds = ensembleResponse.utcOffsetSeconds();
|
const utcOffsetSeconds = ensembleResponse.utcOffsetSeconds();
|
||||||
|
const timezone = ensembleResponse.timezone() ?? params.timezone ?? 'UTC';
|
||||||
|
|
||||||
const hourlyBlock = ensembleResponse.hourly()!;
|
const hourlyBlock = ensembleResponse.hourly()!;
|
||||||
const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds);
|
const timestamps = getTimestamps(hourlyBlock);
|
||||||
const timeLength = timestamps.length;
|
const timeLength = timestamps.length;
|
||||||
|
|
||||||
// Extract sunrise/sunset for mark areas
|
// Extract sunrise/sunset for mark areas
|
||||||
@@ -524,7 +533,7 @@ export async function fetchEnsembleForecast(
|
|||||||
if (dailyBlock) {
|
if (dailyBlock) {
|
||||||
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
||||||
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
||||||
markAreas = buildDaylightMarkAreas(sunrise, sunset, dailyResponse.utcOffsetSeconds());
|
markAreas = buildDaylightMarkAreas(sunrise, sunset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +622,7 @@ export async function fetchEnsembleForecast(
|
|||||||
variables,
|
variables,
|
||||||
timestamps,
|
timestamps,
|
||||||
utcOffsetSeconds,
|
utcOffsetSeconds,
|
||||||
|
timezone,
|
||||||
markAreas,
|
markAreas,
|
||||||
hourlyFlat,
|
hourlyFlat,
|
||||||
hourlyUnitsFlat
|
hourlyUnitsFlat
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -54,7 +54,6 @@ export {
|
|||||||
buildSpreadSeries,
|
buildSpreadSeries,
|
||||||
calculateAverage,
|
calculateAverage,
|
||||||
calculateSpread,
|
calculateSpread,
|
||||||
convertTimestamps,
|
|
||||||
findUnit
|
findUnit
|
||||||
} from './series';
|
} from './series';
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* These builders ensure visual consistency across all chart pages and reduce
|
* These builders ensure visual consistency across all chart pages and reduce
|
||||||
* boilerplate in page-level components.
|
* boilerplate in page-level components.
|
||||||
*/
|
*/
|
||||||
|
import { formatZoned } from '../date';
|
||||||
import { getThemeColors } from './theme';
|
import { getThemeColors } from './theme';
|
||||||
|
|
||||||
import type { ThemeColors } from './theme';
|
import type { ThemeColors } from './theme';
|
||||||
@@ -30,6 +31,7 @@ export interface LegendOptions {
|
|||||||
|
|
||||||
export interface TooltipOptions {
|
export interface TooltipOptions {
|
||||||
unit: string;
|
unit: string;
|
||||||
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AxisOptions {
|
export interface AxisOptions {
|
||||||
@@ -129,7 +131,7 @@ export function buildTooltip(
|
|||||||
colors?: ThemeColors
|
colors?: ThemeColors
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const c = colors ?? getThemeColors();
|
const c = colors ?? getThemeColors();
|
||||||
const { unit } = options;
|
const { unit, timezone } = options;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
@@ -140,7 +142,15 @@ export function buildTooltip(
|
|||||||
backgroundColor: c.tooltipBg,
|
backgroundColor: c.tooltipBg,
|
||||||
color: c.text,
|
color: c.text,
|
||||||
borderColor: c.tooltipBorder,
|
borderColor: c.tooltipBorder,
|
||||||
borderWidth: 1
|
borderWidth: 1,
|
||||||
|
formatter: timezone
|
||||||
|
? (params: { axisDimension: string; value: number }) => {
|
||||||
|
if (params.axisDimension === 'x') {
|
||||||
|
return formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm');
|
||||||
|
}
|
||||||
|
return params.value.toFixed(1);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
backgroundColor: c.tooltipBg,
|
backgroundColor: c.tooltipBg,
|
||||||
@@ -148,6 +158,27 @@ export function buildTooltip(
|
|||||||
textStyle: {
|
textStyle: {
|
||||||
color: c.text
|
color: c.text
|
||||||
},
|
},
|
||||||
|
formatter: timezone
|
||||||
|
? (
|
||||||
|
params: Array<{
|
||||||
|
axisValue: number;
|
||||||
|
seriesName: string;
|
||||||
|
marker: string;
|
||||||
|
value: number | number[] | null;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
if (!params || params.length === 0) return '';
|
||||||
|
const date = new Date(params[0].axisValue);
|
||||||
|
let html = `<b>${formatZoned(date, timezone, 'EEE d MMM HH:mm')}</b><br/>`;
|
||||||
|
params.forEach((item) => {
|
||||||
|
if (item.seriesName === 'Daylight' || item.seriesName === 'Current Time') return;
|
||||||
|
const val = Array.isArray(item.value) ? item.value[1] : item.value;
|
||||||
|
if (val === null || val === undefined) return;
|
||||||
|
html += `${item.marker} ${item.seriesName}: <b>${val.toFixed(1)} ${unit}</b><br/>`;
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
valueFormatter: (value: number) => {
|
valueFormatter: (value: number) => {
|
||||||
if (value === null || value === undefined) return '-';
|
if (value === null || value === undefined) return '-';
|
||||||
return value.toFixed(1) + ' ' + unit;
|
return value.toFixed(1) + ' ' + unit;
|
||||||
@@ -180,9 +211,9 @@ export function buildLegend(options: LegendOptions, colors?: ThemeColors): Recor
|
|||||||
// ─── X Axis (Time) ───────────────────────────────────────────────────────────
|
// ─── 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();
|
const c = colors ?? getThemeColors();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -195,9 +226,20 @@ export function buildTimeXAxis(colors?: ThemeColors): Record<string, unknown> {
|
|||||||
color: c.axisLine
|
color: c.axisLine
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
axisPointer: {
|
||||||
|
label: {
|
||||||
|
formatter: timezone
|
||||||
|
? (params: { value: number }) =>
|
||||||
|
formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: c.text,
|
color: c.text,
|
||||||
hideOverlap: true
|
hideOverlap: true,
|
||||||
|
formatter: timezone
|
||||||
|
? (value: number) => formatZoned(new Date(value), timezone, 'HH:mm')
|
||||||
|
: undefined
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
@@ -336,6 +378,7 @@ export interface ChartOptionParams {
|
|||||||
toolbox?: ToolboxOptions | false;
|
toolbox?: ToolboxOptions | false;
|
||||||
showCredit?: boolean;
|
showCredit?: boolean;
|
||||||
colors?: ThemeColors;
|
colors?: ThemeColors;
|
||||||
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,7 +401,7 @@ export function composeChartOption(params: ChartOptionParams): Record<string, un
|
|||||||
hasSubtitle: hasTitle && !!params.title?.subtext,
|
hasSubtitle: hasTitle && !!params.title?.subtext,
|
||||||
showLegend
|
showLegend
|
||||||
}),
|
}),
|
||||||
xAxis: buildTimeXAxis(colors),
|
xAxis: buildTimeXAxis(params.timezone, colors),
|
||||||
yAxis: buildValueYAxis(params.yAxis, colors),
|
yAxis: buildValueYAxis(params.yAxis, colors),
|
||||||
series: params.series,
|
series: params.series,
|
||||||
textStyle: {
|
textStyle: {
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
* weather chart visualizations. These builders encapsulate the styling
|
* weather chart visualizations. These builders encapsulate the styling
|
||||||
* and configuration details so page-level code only needs to provide data.
|
* and configuration details so page-level code only needs to provide data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CHART_COLORS } from './theme';
|
|
||||||
import { isColumnUnit } from './options';
|
import { isColumnUnit } from './options';
|
||||||
|
import { CHART_COLORS } from './theme';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,9 +37,7 @@ export interface CurrentTimeSeriesParams {
|
|||||||
|
|
||||||
export interface DaylightSeriesParams {
|
export interface DaylightSeriesParams {
|
||||||
/** Array of mark area pairs: [[start, end], [start, end], ...] */
|
/** Array of mark area pairs: [[start, end], [start, end], ...] */
|
||||||
markAreas: Array<
|
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
||||||
[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpreadSeriesParams {
|
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.
|
* 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.
|
* Uses an empty data series with a markLine to overlay onto the chart.
|
||||||
*/
|
*/
|
||||||
export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record<string, unknown> {
|
export function buildCurrentTimeSeries(): Record<string, unknown> {
|
||||||
const { utcOffsetSeconds } = params;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'Current Time',
|
name: 'Current Time',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@@ -137,7 +132,7 @@ export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record<
|
|||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
xAxis: Date.now() + utcOffsetSeconds * 1000,
|
xAxis: Date.now(),
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: CHART_COLORS.currentTimeLine,
|
color: CHART_COLORS.currentTimeLine,
|
||||||
width: 2,
|
width: 2,
|
||||||
@@ -164,18 +159,17 @@ export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record<
|
|||||||
*/
|
*/
|
||||||
export function buildDaylightMarkAreas(
|
export function buildDaylightMarkAreas(
|
||||||
sunrise: number[],
|
sunrise: number[],
|
||||||
sunset: number[],
|
sunset: number[]
|
||||||
utcOffsetSeconds: number
|
|
||||||
): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> {
|
): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> {
|
||||||
return sunrise.map((r: number, i: number) => [
|
return sunrise.map((r: number, i: number) => [
|
||||||
{
|
{
|
||||||
xAxis: (r + utcOffsetSeconds) * 1000,
|
xAxis: r * 1000,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: CHART_COLORS.daylight
|
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.
|
* 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).
|
* Returns null if no mark areas are provided (so callers can filter it out).
|
||||||
*/
|
*/
|
||||||
export function buildDaylightSeries(
|
export function buildDaylightSeries(params: DaylightSeriesParams): Record<string, unknown> | null {
|
||||||
params: DaylightSeriesParams
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
if (params.markAreas.length === 0) return null;
|
if (params.markAreas.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -285,7 +277,7 @@ export function calculateAverage(
|
|||||||
if (!model.startsWith(variable)) continue;
|
if (!model.startsWith(variable)) continue;
|
||||||
|
|
||||||
for (const [index, val] of (values as number[]).entries()) {
|
for (const [index, val] of (values as number[]).entries()) {
|
||||||
if (val !== null && val !== undefined) {
|
if (val !== null && val !== undefined && isFinite(val)) {
|
||||||
average[index] += val;
|
average[index] += val;
|
||||||
averageCount[index]++;
|
averageCount[index]++;
|
||||||
}
|
}
|
||||||
@@ -338,13 +330,6 @@ export function calculateSpread(
|
|||||||
return { minValues, maxValues };
|
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.
|
* Finds the unit string for a given variable from the hourly_units map.
|
||||||
* Returns an empty string if the variable is not found.
|
* Returns an empty string if the variable is not found.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
interface FetchedData {
|
interface FetchedData {
|
||||||
ensembleResult: EnsembleForecastResult;
|
ensembleResult: EnsembleForecastResult;
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
utc_offset_seconds: number;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
markAreas: MarkArea[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,15 +102,17 @@
|
|||||||
forecast_days: 14,
|
forecast_days: 14,
|
||||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
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 = {
|
fetchedData = {
|
||||||
ensembleResult: result,
|
ensembleResult: result,
|
||||||
timestamps: result.timestamps,
|
timestamps: result.timestamps,
|
||||||
utc_offset_seconds: result.utcOffsetSeconds,
|
timezone: result.timezone,
|
||||||
markAreas: result.markAreas
|
markAreas: result.markAreas
|
||||||
};
|
};
|
||||||
|
console.log(fetchedData.timezone);
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
};
|
};
|
||||||
@@ -123,7 +125,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!fetchedData) return;
|
if (!fetchedData) return;
|
||||||
|
|
||||||
const { ensembleResult, timestamps, utc_offset_seconds, markAreas } = fetchedData;
|
const { ensembleResult, timestamps, timezone, markAreas } = fetchedData;
|
||||||
const _showLegend = showLegend;
|
const _showLegend = showLegend;
|
||||||
|
|
||||||
const colors = getThemeColors();
|
const colors = getThemeColors();
|
||||||
@@ -150,7 +152,7 @@
|
|||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||||
|
|
||||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
series.push(buildCurrentTimeSeries());
|
||||||
|
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||||
if (daylightSeries) {
|
if (daylightSeries) {
|
||||||
@@ -167,7 +169,7 @@
|
|||||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
tooltip: { unit },
|
tooltip: { unit, timezone },
|
||||||
legend: {
|
legend: {
|
||||||
show: _showLegend,
|
show: _showLegend,
|
||||||
data: [variable + '_average']
|
data: [variable + '_average']
|
||||||
@@ -181,7 +183,8 @@
|
|||||||
series,
|
series,
|
||||||
toolbox: false,
|
toolbox: false,
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
colors
|
colors,
|
||||||
|
timezone
|
||||||
});
|
});
|
||||||
|
|
||||||
newOptions.push(option);
|
newOptions.push(option);
|
||||||
|
|||||||
@@ -53,13 +53,21 @@
|
|||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'],
|
hourly: [
|
||||||
|
'temperature_2m',
|
||||||
|
'rain',
|
||||||
|
'relative_humidity_2m',
|
||||||
|
'wind_speed_10m',
|
||||||
|
'wind_direction_10m'
|
||||||
|
],
|
||||||
models: [
|
models: [
|
||||||
|
'ecmwf_ifs',
|
||||||
'ecmwf_ifs025',
|
'ecmwf_ifs025',
|
||||||
'meteofrance_seamless',
|
'meteofrance_seamless',
|
||||||
'ukmo_seamless',
|
'ukmo_seamless',
|
||||||
'icon_seamless',
|
'icon_seamless',
|
||||||
'gem_seamless'
|
'gem_seamless',
|
||||||
|
'gfs_seamless'
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +76,7 @@
|
|||||||
interface FetchedData {
|
interface FetchedData {
|
||||||
hourly: Record<string, unknown>;
|
hourly: Record<string, unknown>;
|
||||||
hourly_units: Record<string, string>;
|
hourly_units: Record<string, string>;
|
||||||
utc_offset_seconds: number;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
markAreas: MarkArea[];
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
}
|
}
|
||||||
@@ -115,13 +123,14 @@
|
|||||||
models: modelList,
|
models: modelList,
|
||||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
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 = {
|
fetchedData = {
|
||||||
hourly: result.hourlyFlat,
|
hourly: result.hourlyFlat,
|
||||||
hourly_units: result.hourlyUnitsFlat,
|
hourly_units: result.hourlyUnitsFlat,
|
||||||
utc_offset_seconds: result.utcOffsetSeconds,
|
timezone: result.timezone,
|
||||||
markAreas: result.markAreas,
|
markAreas: result.markAreas,
|
||||||
timestamps: result.timestamps
|
timestamps: result.timestamps
|
||||||
};
|
};
|
||||||
@@ -137,13 +146,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!fetchedData) return;
|
if (!fetchedData) return;
|
||||||
|
|
||||||
const {
|
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
|
||||||
hourly: hourlyData,
|
|
||||||
hourly_units,
|
|
||||||
utc_offset_seconds,
|
|
||||||
markAreas,
|
|
||||||
timestamps
|
|
||||||
} = fetchedData;
|
|
||||||
const _showLegend = showLegend;
|
const _showLegend = showLegend;
|
||||||
|
|
||||||
const colors = getThemeColors();
|
const colors = getThemeColors();
|
||||||
@@ -178,7 +181,7 @@
|
|||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||||
|
|
||||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
series.push(buildCurrentTimeSeries());
|
||||||
|
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||||
if (daylightSeries) {
|
if (daylightSeries) {
|
||||||
@@ -195,7 +198,7 @@
|
|||||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
tooltip: { unit },
|
tooltip: { unit, timezone },
|
||||||
legend: { show: _showLegend },
|
legend: { show: _showLegend },
|
||||||
grid: {
|
grid: {
|
||||||
hasTitle: isFirst,
|
hasTitle: isFirst,
|
||||||
@@ -206,7 +209,8 @@
|
|||||||
series,
|
series,
|
||||||
toolbox: false,
|
toolbox: false,
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
colors
|
colors,
|
||||||
|
timezone
|
||||||
});
|
});
|
||||||
|
|
||||||
newOptions.push(option);
|
newOptions.push(option);
|
||||||
|
|||||||
@@ -7,16 +7,52 @@ export const defaultParameters = {
|
|||||||
|
|
||||||
export const models = [
|
export const models = [
|
||||||
{ value: 'best_match', label: 'Best match' },
|
{ value: 'best_match', label: 'Best match' },
|
||||||
{ value: 'gfs_seamless', label: 'NCEP GFS Seamless' },
|
{ value: 'ecmwf_ifs', label: 'ECMWF IFS' },
|
||||||
{ value: 'jma_seamless', label: 'JMA Seamless' },
|
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25' },
|
||||||
|
{ value: 'ecmwf_aifs025_single', label: 'ECMWF AIFS 0.25 Single' },
|
||||||
|
{ value: 'cma_grapes_global', label: 'CMA GRAPES Global' },
|
||||||
|
{ value: 'bom_access_global', label: 'BOM ACCESS Global' },
|
||||||
{ value: 'kma_seamless', label: 'KMA Seamless' },
|
{ value: 'kma_seamless', label: 'KMA Seamless' },
|
||||||
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
|
{ value: 'kma_ldps', label: 'KMA LDPS' },
|
||||||
|
{ value: 'kma_gdps', label: 'KMA GDPS' },
|
||||||
|
{ value: 'meteofrance_seamless', label: 'Meteo-France Seamless' },
|
||||||
|
{ value: 'meteofrance_arpege_world', label: 'Meteo-France ARPEGE World' },
|
||||||
|
{ value: 'meteofrance_arpege_europe', label: 'Meteo-France ARPEGE Europe' },
|
||||||
|
{ value: 'meteofrance_arome_france', label: 'Meteo-France AROME France' },
|
||||||
|
{ value: 'meteofrance_arome_france_hd', label: 'Meteo-France AROME France HD' },
|
||||||
|
{ value: 'knmi_seamless', label: 'KNMI Seamless' },
|
||||||
|
{ value: 'knmi_harmonie_arome_europe', label: 'KNMI Harmonie Arome Europe' },
|
||||||
|
{ value: 'knmi_harmonie_arome_netherlands', label: 'KNMI Harmonie Arome Netherlands' },
|
||||||
|
{ value: 'dmi_seamless', label: 'DMI Seamless' },
|
||||||
|
{ value: 'dmi_harmonie_arome_europe', label: 'DMI Harmonie Arome Europe' },
|
||||||
|
{ value: 'ukmo_seamless', label: 'UKMO Seamless' },
|
||||||
|
{ value: 'ukmo_global_deterministic_10km', label: 'UKMO Global Deterministic 10km' },
|
||||||
|
{ value: 'ukmo_uk_deterministic_2km', label: 'UKMO UK Deterministic 2km' },
|
||||||
|
{ value: 'meteoswiss_icon_seamless', label: 'MeteoSwiss ICON Seamless' },
|
||||||
|
{ value: 'meteoswiss_icon_ch2', label: 'MeteoSwiss ICON CH2' },
|
||||||
|
{ value: 'meteoswiss_icon_ch1', label: 'MeteoSwiss ICON CH1' },
|
||||||
|
{ value: 'metno_nordic', label: 'MET Norway Nordic' },
|
||||||
|
{ value: 'metno_seamless', label: 'MET Norway Seamless' },
|
||||||
|
{ value: 'gem_hrdps_west', label: 'GEM HRDPS West' },
|
||||||
|
{ value: 'gem_regional', label: 'GEM Regional' },
|
||||||
|
{ value: 'gem_global', label: 'GEM Global' },
|
||||||
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
||||||
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
|
{ value: 'jma_seamless', label: 'JMA Seamless' },
|
||||||
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
|
{ value: 'jma_msm', label: 'JMA MSM' },
|
||||||
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
|
{ value: 'jma_gsm', label: 'JMA GSM' },
|
||||||
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
|
{ value: 'gfs_seamless', label: 'GFS Seamless' },
|
||||||
{ value: 'ukmo_seamless', label: 'UK Met Office Seamless' }
|
{ value: 'gfs_global', label: 'GFS Global' },
|
||||||
|
{ value: 'gfs_hrrr', label: 'GFS HRRR' },
|
||||||
|
{ value: 'gfs_graphcast025', label: 'GFS Graphcast 0.25' },
|
||||||
|
{ value: 'ncep_nbm_conus', label: 'NCEP NBM CONUS' },
|
||||||
|
{ value: 'ncep_nam_conus', label: 'NCEP NAM CONUS' },
|
||||||
|
{ value: 'ncep_aigfs025', label: 'NCEP AIGFS 0.25' },
|
||||||
|
{ value: 'ncep_hgefs025_ensemble_mean', label: 'NCEP HG-EFS 0.25 Ensemble Mean' },
|
||||||
|
{ value: 'icon_seamless', label: 'ICON Seamless (DWD)' },
|
||||||
|
{ value: 'icon_global', label: 'ICON Global' },
|
||||||
|
{ value: 'icon_eu', label: 'ICON EU' },
|
||||||
|
{ value: 'icon_d2', label: 'ICON-D2' },
|
||||||
|
{ value: 'italia_meteo_arpae_icon_2i', label: 'Italia Meteo ARPAE ICON 2i' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const hourly = [
|
export const hourly = [
|
||||||
|
|||||||
@@ -63,12 +63,14 @@
|
|||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
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',
|
||||||
forecast_days: 7,
|
forecast_days: 7,
|
||||||
past_days: 0
|
past_days: 0,
|
||||||
|
timezone: loc.timezone
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchedHourly = {
|
fetchedHourly = {
|
||||||
hourly: result.hourly,
|
hourly: result.hourly,
|
||||||
utc_offset_seconds: result.utcOffsetSeconds,
|
utc_offset_seconds: result.utcOffsetSeconds,
|
||||||
|
timezone: result.timezone,
|
||||||
timestamps: result.hourlyTimestamps,
|
timestamps: result.hourlyTimestamps,
|
||||||
hourlyDates: result.hourlyDates,
|
hourlyDates: result.hourlyDates,
|
||||||
markAreas: result.markAreas
|
markAreas: result.markAreas
|
||||||
@@ -76,6 +78,7 @@
|
|||||||
|
|
||||||
fetchedDaily = {
|
fetchedDaily = {
|
||||||
daily: result.daily,
|
daily: result.daily,
|
||||||
|
timezone: result.timezone,
|
||||||
dailyDates: result.dailyDates
|
dailyDates: result.dailyDates
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import weatherCodes from '../../utils/weather-codes';
|
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 {
|
interface Props {
|
||||||
daily: FetchedDaily | null;
|
daily: FetchedDaily | null;
|
||||||
@@ -14,8 +16,6 @@
|
|||||||
|
|
||||||
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
function getDaylightSeconds(index: number): number {
|
function getDaylightSeconds(index: number): number {
|
||||||
if (!daily) return 0;
|
if (!daily) return 0;
|
||||||
const sunriseTs = daily.daily.sunrise[index];
|
const sunriseTs = daily.daily.sunrise[index];
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="flex gap-1 overflow-x-auto pb-1" style="scrollbar-width: thin">
|
<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 = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
{@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]}
|
||||||
@@ -68,10 +68,10 @@
|
|||||||
>
|
>
|
||||||
<!-- Day label -->
|
<!-- Day label -->
|
||||||
<span class="text-sm font-bold tracking-wide">
|
<span class="text-sm font-bold tracking-wide">
|
||||||
{time.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase()}
|
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[11px] text-muted-foreground">
|
<span class="text-[11px] text-muted-foreground">
|
||||||
{getDayLabel(time, today)}
|
{getRelativeDayLabel(time, daily.timezone)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Weather icon -->
|
<!-- Weather icon -->
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pad } from '$lib/utils/index';
|
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import weatherCodes from '../../utils/weather-codes';
|
import weatherCodes from '../../utils/weather-codes';
|
||||||
@@ -10,8 +10,7 @@
|
|||||||
getPrecipUnit,
|
getPrecipUnit,
|
||||||
getTempUnit,
|
getTempUnit,
|
||||||
getWindArrowRotation,
|
getWindArrowRotation,
|
||||||
getWindUnit,
|
getWindUnit
|
||||||
isCurrentHour
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -54,36 +53,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function timeToFraction(date: Date): number {
|
function timeToFraction(date: Date): number {
|
||||||
const totalMinutes = date.getHours() * 60 + date.getMinutes();
|
const tz = data.timezone;
|
||||||
const firstMin = cellData[0].date.getHours() * 60;
|
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 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;
|
const range = lastMin - firstMin;
|
||||||
if (range <= 0) return 0;
|
if (range <= 0) return 0;
|
||||||
return Math.max(0, Math.min(1, (totalMinutes - firstMin) / range));
|
return Math.max(0, Math.min(1, (totalMinutes - firstMin) / range));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
function formatTime(date: Date): string {
|
||||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
return formatZoned(date, data.timezone, 'HH:mm');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimezone(offsetSeconds: number): string {
|
let timezoneLabel = $derived(formatUtcOffset(data.utc_offset_seconds));
|
||||||
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 {
|
function findDailyIndex(date: Date): number {
|
||||||
return daily.dailyDates.findIndex(
|
return daily.dailyDates.findIndex((dd) => isSameDayInZone(dd, date, data.timezone));
|
||||||
(dd) =>
|
|
||||||
dd.getDate() === date.getDate() &&
|
|
||||||
dd.getMonth() === date.getMonth() &&
|
|
||||||
dd.getFullYear() === date.getFullYear()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDaytime(hourDate: Date): boolean {
|
function isDaytime(hourDate: Date): boolean {
|
||||||
@@ -97,13 +91,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDayIndices(dates: Date[], day: Date): number[] {
|
function getDayIndices(dates: Date[], day: Date): number[] {
|
||||||
|
const tz = data.timezone;
|
||||||
return dates.reduce<number[]>((acc, d, i) => {
|
return dates.reduce<number[]>((acc, d, i) => {
|
||||||
if (
|
if (isSameDayInZone(d, day, tz) && (hourlyInterval === 1 || getZonedHour(d, tz) % 3 === 0)) {
|
||||||
d.getDate() === day.getDate() &&
|
|
||||||
d.getMonth() === day.getMonth() &&
|
|
||||||
d.getFullYear() === day.getFullYear() &&
|
|
||||||
(hourlyInterval === 1 || d.getHours() % 3 === 0)
|
|
||||||
) {
|
|
||||||
acc.push(i);
|
acc.push(i);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
@@ -148,12 +138,19 @@
|
|||||||
let daytimeFlags = $derived(dayIdx.map((idx) => isDaytime(data.hourlyDates[idx])));
|
let daytimeFlags = $derived(dayIdx.map((idx) => isDaytime(data.hourlyDates[idx])));
|
||||||
|
|
||||||
let cellData = $derived(
|
let cellData = $derived(
|
||||||
dayIdx.map((idx, i) => ({
|
dayIdx.map((idx, i) => {
|
||||||
idx,
|
const date = data.hourlyDates[idx];
|
||||||
date: data.hourlyDates[idx],
|
const tz = data.timezone;
|
||||||
isNow: isCurrentHour(data.hourlyDates[idx], today),
|
const isNow =
|
||||||
isDaytime: daytimeFlags[i]
|
formatZoned(date, tz, 'yyyy-MM-dd HH') === formatZoned(today, tz, 'yyyy-MM-dd HH');
|
||||||
}))
|
|
||||||
|
return {
|
||||||
|
idx,
|
||||||
|
date,
|
||||||
|
isNow,
|
||||||
|
isDaytime: daytimeFlags[i]
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
let sunrisePercent = $derived(
|
let sunrisePercent = $derived(
|
||||||
@@ -195,7 +192,7 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 class="text-lg font-bold">
|
<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>
|
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
||||||
@@ -293,10 +290,12 @@
|
|||||||
style="left:{leftPct}%;width:{widthPct}%"
|
style="left:{leftPct}%;width:{widthPct}%"
|
||||||
>
|
>
|
||||||
{#if is3h}
|
{#if is3h}
|
||||||
{pad(cell.date.getHours())}
|
{formatZoned(cell.date, data.timezone, 'HH')}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="inline-flex items-baseline gap-1">
|
<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
|
<sup
|
||||||
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
|
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
|
||||||
>00</sup
|
>00</sup
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||||
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
||||||
import { pad } from '$lib/utils/index';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
||||||
import '$lib/components/charts/echarts.css';
|
import '$lib/components/charts/echarts.css';
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
import {
|
import {
|
||||||
type FetchedHourly,
|
type FetchedHourly,
|
||||||
type WeatherUnits,
|
type WeatherUnits,
|
||||||
getDayLabel,
|
|
||||||
getPrecipUnit,
|
getPrecipUnit,
|
||||||
getTempUnit,
|
getTempUnit,
|
||||||
getWindDirectionLabel,
|
getWindDirectionLabel,
|
||||||
@@ -32,7 +31,6 @@
|
|||||||
|
|
||||||
const CHART_GROUP = 'week-meteogram';
|
const CHART_GROUP = 'week-meteogram';
|
||||||
const MS_PER_DAY = 24 * 3600 * 1000;
|
const MS_PER_DAY = 24 * 3600 * 1000;
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
let showCharts = $state(false);
|
let showCharts = $state(false);
|
||||||
let chartComponents: EChart[] = $state([]);
|
let chartComponents: EChart[] = $state([]);
|
||||||
@@ -40,9 +38,17 @@
|
|||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||||
|
|
||||||
export function scrollToDay(day: Date): void {
|
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 dayEnd = dayStart + MS_PER_DAY;
|
||||||
const timestamps = data.timestamps;
|
const timestamps = data.timestamps;
|
||||||
const rangeStart = timestamps[0];
|
const rangeStart = timestamps[0];
|
||||||
@@ -90,7 +96,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
const { hourly, utc_offset_seconds, timestamps, markAreas } = data;
|
const { hourly, timestamps, markAreas } = data;
|
||||||
const colors = getThemeColors();
|
const colors = getThemeColors();
|
||||||
const tempUnit = getTempUnit(units);
|
const tempUnit = getTempUnit(units);
|
||||||
const precipUnit = getPrecipUnit(units);
|
const precipUnit = getPrecipUnit(units);
|
||||||
@@ -116,7 +122,7 @@
|
|||||||
|
|
||||||
const annotations = (): Array<Record<string, unknown>> => {
|
const annotations = (): Array<Record<string, unknown>> => {
|
||||||
const series: Array<Record<string, unknown>> = [];
|
const series: Array<Record<string, unknown>> = [];
|
||||||
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
series.push(buildCurrentTimeSeries());
|
||||||
const dl = buildDaylightSeries({ markAreas });
|
const dl = buildDaylightSeries({ markAreas });
|
||||||
if (dl) series.push(dl);
|
if (dl) series.push(dl);
|
||||||
return series;
|
return series;
|
||||||
@@ -126,7 +132,12 @@
|
|||||||
type: 'time',
|
type: 'time',
|
||||||
splitLine: { show: false },
|
splitLine: { show: false },
|
||||||
axisLine: { lineStyle: { color: colors.axisLine } },
|
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 } }
|
axisTick: { lineStyle: { color: colors.axisLine } }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +171,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tooltipBase = (
|
const tooltipBase = (
|
||||||
formatter: (params: Record<string, unknown>[]) => string
|
formatter: (
|
||||||
|
params: Array<{
|
||||||
|
axisValue: number;
|
||||||
|
seriesName: string;
|
||||||
|
marker: string;
|
||||||
|
value: number | number[] | null;
|
||||||
|
}>
|
||||||
|
) => string
|
||||||
): Record<string, unknown> => ({
|
): Record<string, unknown> => ({
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
@@ -170,7 +188,13 @@
|
|||||||
backgroundColor: colors.tooltipBg,
|
backgroundColor: colors.tooltipBg,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
borderColor: colors.tooltipBorder,
|
borderColor: colors.tooltipBorder,
|
||||||
borderWidth: 1
|
borderWidth: 1,
|
||||||
|
formatter: (params: { axisDimension: string; value: number }) => {
|
||||||
|
if (params.axisDimension === 'x') {
|
||||||
|
return formatZoned(new Date(params.value), data.timezone, 'EEE d MMM HH:mm');
|
||||||
|
}
|
||||||
|
return params.value.toFixed(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
backgroundColor: colors.tooltipBg,
|
backgroundColor: colors.tooltipBg,
|
||||||
@@ -181,7 +205,8 @@
|
|||||||
|
|
||||||
const formatDate = (ts: number): string => {
|
const formatDate = (ts: number): string => {
|
||||||
const date = new Date(ts);
|
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';
|
const isAnnotation = (name: string): boolean => name === 'Daylight' || name === 'Current Time';
|
||||||
@@ -488,11 +513,11 @@
|
|||||||
<div class="detailed-charts" in:fade={{ duration: 200 }}>
|
<div class="detailed-charts" in:fade={{ duration: 200 }}>
|
||||||
<div class="charts-header">
|
<div class="charts-header">
|
||||||
<h3 class="charts-title">
|
<h3 class="charts-title">
|
||||||
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
|
{formatZoned(selectedDay, data.timezone, 'EEEE')}
|
||||||
<small>
|
<small>
|
||||||
{getDayLabel(selectedDay, today) !==
|
{getRelativeDayLabel(selectedDay, data.timezone) !==
|
||||||
selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })
|
formatZoned(selectedDay, data.timezone, 'EEEE')
|
||||||
? ` (${getDayLabel(selectedDay, today)})`
|
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
|
||||||
: ''}
|
: ''}
|
||||||
</small>
|
</small>
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pad } from '$lib/utils/index';
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
|
||||||
import type { FetchedDaily } from './types';
|
import type { FetchedDaily } from './types';
|
||||||
|
|
||||||
@@ -23,19 +23,19 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if sunrise && sunset}
|
{#if daily && sunrise && sunset}
|
||||||
<div class="sun-info">
|
<div class="sun-info">
|
||||||
<div class="sun-item">
|
<div class="sun-item">
|
||||||
<svg class="fill-foreground" width="24px" height="24px">
|
<svg class="fill-foreground" width="24px" height="24px">
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{pad(sunrise.getHours())}:{pad(sunrise.getMinutes())}</span>
|
<span>{formatZoned(sunrise, daily.timezone, 'HH:mm')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sun-item">
|
<div class="sun-item">
|
||||||
<svg class="fill-foreground" width="24px" height="24px">
|
<svg class="fill-foreground" width="24px" height="24px">
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{pad(sunset.getHours())}:{pad(sunset.getMinutes())}</span>
|
<span>{formatZoned(sunset, daily.timezone, 'HH:mm')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface WeatherUnits {
|
|||||||
export interface FetchedHourly {
|
export interface FetchedHourly {
|
||||||
hourly: WeekHourlyData;
|
hourly: WeekHourlyData;
|
||||||
utc_offset_seconds: number;
|
utc_offset_seconds: number;
|
||||||
|
timezone: string;
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
hourlyDates: Date[];
|
hourlyDates: Date[];
|
||||||
markAreas: MarkArea[];
|
markAreas: MarkArea[];
|
||||||
@@ -16,6 +17,7 @@ export interface FetchedHourly {
|
|||||||
|
|
||||||
export interface FetchedDaily {
|
export interface FetchedDaily {
|
||||||
daily: WeekDailyData;
|
daily: WeekDailyData;
|
||||||
|
timezone: string;
|
||||||
dailyDates: Date[];
|
dailyDates: Date[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,25 +58,3 @@ export const getWindDirectionLabel = (deg: number): string => {
|
|||||||
];
|
];
|
||||||
return dirs[Math.round(deg / 22.5) % 16];
|
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()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user