feat: local time (#7)
Co-authored-by: terraputix <terraputix@mailbox.org> Reviewed-on: useweb/ombrella#7
This commit is contained in:
+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.
|
||||
*/
|
||||
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) => new Date(t));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +148,7 @@ export function unitToDisplayString(unit: Unit): string {
|
||||
export interface WeatherLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface WeatherUnitParams {
|
||||
@@ -164,7 +165,6 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||
model?: string;
|
||||
forecast_days?: number;
|
||||
past_days?: number;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface WeekHourlyData {
|
||||
@@ -197,6 +197,7 @@ export interface WeekForecastResult {
|
||||
hourly: WeekHourlyData;
|
||||
daily: WeekDailyData;
|
||||
utcOffsetSeconds: number;
|
||||
timezone: string;
|
||||
hourlyTimestamps: number[];
|
||||
hourlyDates: Date[];
|
||||
dailyDates: Date[];
|
||||
@@ -219,6 +220,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 +248,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 +317,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 +340,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 +358,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,
|
||||
@@ -379,7 +384,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
||||
export async function fetchModelComparison(
|
||||
params: ModelCompareParams
|
||||
): Promise<ModelCompareResult> {
|
||||
const forecastApiParams: Record<string, string> = {
|
||||
const forecastApiParams: Record<string, string | number | undefined> = {
|
||||
latitude: String(params.latitude),
|
||||
longitude: String(params.longitude),
|
||||
hourly: params.hourlyVariables.join(','),
|
||||
@@ -387,7 +392,8 @@ export async function fetchModelComparison(
|
||||
daily: 'sunrise,sunset',
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
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);
|
||||
@@ -395,10 +401,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 +414,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 +469,7 @@ export async function fetchModelComparison(
|
||||
models,
|
||||
timestamps,
|
||||
utcOffsetSeconds,
|
||||
timezone,
|
||||
markAreas,
|
||||
units,
|
||||
hourlyFlat,
|
||||
@@ -484,7 +491,7 @@ export async function fetchEnsembleForecast(
|
||||
): Promise<EnsembleForecastResult> {
|
||||
const forecastDays = params.forecast_days ?? 14;
|
||||
|
||||
const ensembleParams: Record<string, string> = {
|
||||
const ensembleParams: Record<string, string | number | undefined> = {
|
||||
latitude: String(params.latitude),
|
||||
longitude: String(params.longitude),
|
||||
hourly: params.hourlyVariables.join(','),
|
||||
@@ -492,7 +499,8 @@ export async function fetchEnsembleForecast(
|
||||
forecast_days: String(forecastDays),
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
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> = {
|
||||
@@ -511,9 +519,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 +533,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 +622,7 @@ export async function fetchEnsembleForecast(
|
||||
variables,
|
||||
timestamps,
|
||||
utcOffsetSeconds,
|
||||
timezone,
|
||||
markAreas,
|
||||
hourlyFlat,
|
||||
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,
|
||||
calculateAverage,
|
||||
calculateSpread,
|
||||
convertTimestamps,
|
||||
findUnit
|
||||
} from './series';
|
||||
export type {
|
||||
|
||||
@@ -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 {
|
||||
@@ -129,7 +131,7 @@ export function buildTooltip(
|
||||
colors?: ThemeColors
|
||||
): Record<string, unknown> {
|
||||
const c = colors ?? getThemeColors();
|
||||
const { unit } = options;
|
||||
const { unit, timezone } = options;
|
||||
|
||||
return {
|
||||
trigger: 'axis',
|
||||
@@ -140,7 +142,15 @@ export function buildTooltip(
|
||||
backgroundColor: c.tooltipBg,
|
||||
color: c.text,
|
||||
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,
|
||||
@@ -148,6 +158,27 @@ export function buildTooltip(
|
||||
textStyle: {
|
||||
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) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return value.toFixed(1) + ' ' + unit;
|
||||
@@ -180,9 +211,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 {
|
||||
@@ -195,9 +226,20 @@ export function buildTimeXAxis(colors?: ThemeColors): Record<string, unknown> {
|
||||
color: c.axisLine
|
||||
}
|
||||
},
|
||||
axisPointer: {
|
||||
label: {
|
||||
formatter: timezone
|
||||
? (params: { value: number }) =>
|
||||
formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm')
|
||||
: undefined
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: c.text,
|
||||
hideOverlap: true
|
||||
hideOverlap: true,
|
||||
formatter: timezone
|
||||
? (value: number) => formatZoned(new Date(value), timezone, 'HH:mm')
|
||||
: undefined
|
||||
},
|
||||
axisTick: {
|
||||
lineStyle: {
|
||||
@@ -336,6 +378,7 @@ export interface ChartOptionParams {
|
||||
toolbox?: ToolboxOptions | false;
|
||||
showCredit?: boolean;
|
||||
colors?: ThemeColors;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,7 +401,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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user