feat: canvas to echarts (#5)
Co-authored-by: terraputix <terraputix@mailbox.org> Reviewed-on: useweb/open-meteo-blue#5
This commit was merged in pull request #5.
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
export {
|
||||||
|
fetchWeekForecast,
|
||||||
|
fetchModelComparison,
|
||||||
|
fetchEnsembleForecast,
|
||||||
|
range,
|
||||||
|
getTimestamps,
|
||||||
|
getDates,
|
||||||
|
getValues,
|
||||||
|
getInt64Values,
|
||||||
|
unitToDisplayString
|
||||||
|
} from './weather';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
WeatherLocation,
|
||||||
|
WeatherUnitParams,
|
||||||
|
MarkArea,
|
||||||
|
WeekForecastParams,
|
||||||
|
WeekHourlyData,
|
||||||
|
WeekDailyData,
|
||||||
|
WeekForecastResult,
|
||||||
|
ModelCompareParams,
|
||||||
|
ModelSeriesData,
|
||||||
|
ModelCompareResult,
|
||||||
|
EnsembleForecastParams,
|
||||||
|
EnsembleVariableData,
|
||||||
|
EnsembleForecastResult
|
||||||
|
} from './weather';
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
/**
|
||||||
|
* Weather Data Service
|
||||||
|
*
|
||||||
|
* Centralized, type-safe weather data fetching using the Open-Meteo SDK
|
||||||
|
* with protobuf (FlatBuffers) transport for efficient data transfer.
|
||||||
|
*
|
||||||
|
* All weather data fetching flows through this service, providing:
|
||||||
|
* - Type-safe request parameters and response structures
|
||||||
|
* - Automatic retries with exponential backoff (via the SDK)
|
||||||
|
* - Efficient binary protobuf transport instead of JSON
|
||||||
|
* - Consistent timestamp and unit handling
|
||||||
|
*/
|
||||||
|
import { Unit } from '@openmeteo/sdk/unit';
|
||||||
|
import { fetchWeatherApi } from 'openmeteo';
|
||||||
|
|
||||||
|
import { buildDaylightMarkAreas } from '$lib/utils/echarts';
|
||||||
|
|
||||||
|
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
||||||
|
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||||
|
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
||||||
|
|
||||||
|
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an array of numbers from start (inclusive) to stop (exclusive) with the given step.
|
||||||
|
* Used to reconstruct timestamp arrays from the protobuf time/timeEnd/interval fields.
|
||||||
|
*/
|
||||||
|
export function range(start: number, stop: number, step: number): number[] {
|
||||||
|
return Array.from(
|
||||||
|
{ length: Math.max(0, Math.ceil((stop - start) / step)) },
|
||||||
|
(_, i) => start + i * step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts timestamp array (in milliseconds, with UTC offset applied) from a VariablesWithTime block.
|
||||||
|
*/
|
||||||
|
export function getTimestamps(timeBlock: VariablesWithTime, utcOffsetSeconds: number): 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a Float32Array of values from a VariableWithValues, returning a regular number[].
|
||||||
|
* Falls back to an empty array if no values are present.
|
||||||
|
*/
|
||||||
|
export function getValues(variable: VariableWithValues): number[] {
|
||||||
|
const arr = variable.valuesArray();
|
||||||
|
if (!arr) return [];
|
||||||
|
return Array.from(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts Int64 (BigInt) values from a VariableWithValues, converting to number[].
|
||||||
|
* Used for variables stored as unix timestamps (e.g. sunrise, sunset).
|
||||||
|
*/
|
||||||
|
export function getInt64Values(variable: VariableWithValues): number[] {
|
||||||
|
const len = variable.valuesInt64Length();
|
||||||
|
const result: number[] = [];
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const val = variable.valuesInt64(i);
|
||||||
|
result.push(val !== null ? Number(val) : 0);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the SDK Unit enum to a human-readable display string.
|
||||||
|
*/
|
||||||
|
export function unitToDisplayString(unit: Unit): string {
|
||||||
|
switch (unit) {
|
||||||
|
case Unit.celsius:
|
||||||
|
return '°C';
|
||||||
|
case Unit.fahrenheit:
|
||||||
|
return '°F';
|
||||||
|
case Unit.millimetre:
|
||||||
|
return 'mm';
|
||||||
|
case Unit.inch:
|
||||||
|
return 'in';
|
||||||
|
case Unit.kilometres_per_hour:
|
||||||
|
return 'km/h';
|
||||||
|
case Unit.metre_per_second:
|
||||||
|
return 'm/s';
|
||||||
|
case Unit.miles_per_hour:
|
||||||
|
return 'mph';
|
||||||
|
case Unit.knots:
|
||||||
|
return 'kn';
|
||||||
|
case Unit.percentage:
|
||||||
|
return '%';
|
||||||
|
case Unit.hectopascal:
|
||||||
|
return 'hPa';
|
||||||
|
case Unit.degree_direction:
|
||||||
|
return '°';
|
||||||
|
case Unit.wmo_code:
|
||||||
|
return 'wmo code';
|
||||||
|
case Unit.seconds:
|
||||||
|
return 's';
|
||||||
|
case Unit.hours:
|
||||||
|
return 'h';
|
||||||
|
case Unit.watt_per_square_metre:
|
||||||
|
return 'W/m²';
|
||||||
|
case Unit.megajoule_per_square_metre:
|
||||||
|
return 'MJ/m²';
|
||||||
|
case Unit.joule_per_kilogram:
|
||||||
|
return 'J/kg';
|
||||||
|
case Unit.metre:
|
||||||
|
return 'm';
|
||||||
|
case Unit.centimetre:
|
||||||
|
return 'cm';
|
||||||
|
case Unit.kilogram_per_square_metre:
|
||||||
|
return 'kg/m²';
|
||||||
|
case Unit.kilopascal:
|
||||||
|
return 'kPa';
|
||||||
|
case Unit.pascal:
|
||||||
|
return 'Pa';
|
||||||
|
case Unit.fraction:
|
||||||
|
return '';
|
||||||
|
case Unit.dimensionless:
|
||||||
|
return '';
|
||||||
|
case Unit.dimensionless_integer:
|
||||||
|
return '';
|
||||||
|
case Unit.unix_time:
|
||||||
|
return 'unixtime';
|
||||||
|
case Unit.grains_per_cubic_metre:
|
||||||
|
return 'grains/m³';
|
||||||
|
case Unit.micrograms_per_cubic_metre:
|
||||||
|
return 'µg/m³';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface WeatherLocation {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherUnitParams {
|
||||||
|
temperature_unit?: 'celsius' | 'fahrenheit';
|
||||||
|
wind_speed_unit?: 'kmh' | 'ms' | 'mph' | 'kn';
|
||||||
|
precipitation_unit?: 'mm' | 'inch';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }];
|
||||||
|
|
||||||
|
// ─── Week Forecast Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||||
|
model?: string;
|
||||||
|
forecast_days?: number;
|
||||||
|
past_days?: number;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekHourlyData {
|
||||||
|
temperature_2m: number[];
|
||||||
|
precipitation: number[];
|
||||||
|
precipitation_probability: number[];
|
||||||
|
weather_code: number[];
|
||||||
|
windspeed_10m: number[];
|
||||||
|
winddirection_10m: number[];
|
||||||
|
cloud_cover: number[];
|
||||||
|
relative_humidity_2m: number[];
|
||||||
|
apparent_temperature: number[];
|
||||||
|
dew_point_2m: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekDailyData {
|
||||||
|
weather_code: number[];
|
||||||
|
temperature_2m_max: number[];
|
||||||
|
temperature_2m_min: number[];
|
||||||
|
sunrise: number[];
|
||||||
|
sunset: number[];
|
||||||
|
sunshine_duration: number[];
|
||||||
|
precipitation_sum: number[];
|
||||||
|
windspeed_10m_max: number[];
|
||||||
|
windgusts_10m_max: number[];
|
||||||
|
winddirection_10m_dominant: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeekForecastResult {
|
||||||
|
hourly: WeekHourlyData;
|
||||||
|
daily: WeekDailyData;
|
||||||
|
utcOffsetSeconds: number;
|
||||||
|
hourlyTimestamps: number[];
|
||||||
|
hourlyDates: Date[];
|
||||||
|
dailyDates: Date[];
|
||||||
|
markAreas: MarkArea[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model Comparison Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
|
||||||
|
hourlyVariables: string[];
|
||||||
|
models: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSeriesData {
|
||||||
|
modelName: string;
|
||||||
|
variables: Record<string, number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelCompareResult {
|
||||||
|
models: ModelSeriesData[];
|
||||||
|
timestamps: number[];
|
||||||
|
utcOffsetSeconds: number;
|
||||||
|
markAreas: MarkArea[];
|
||||||
|
units: Record<string, string>;
|
||||||
|
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
|
||||||
|
hourlyFlat: Record<string, number[]>;
|
||||||
|
hourlyUnitsFlat: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ensemble Forecast Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface EnsembleForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||||
|
hourlyVariables: string[];
|
||||||
|
models: string[];
|
||||||
|
forecast_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsembleVariableData {
|
||||||
|
members: number[][];
|
||||||
|
average: number[];
|
||||||
|
min: number[];
|
||||||
|
max: number[];
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsembleForecastResult {
|
||||||
|
variables: Record<string, EnsembleVariableData>;
|
||||||
|
timestamps: number[];
|
||||||
|
utcOffsetSeconds: number;
|
||||||
|
markAreas: MarkArea[];
|
||||||
|
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
||||||
|
hourlyFlat: Record<string, number[]>;
|
||||||
|
hourlyUnitsFlat: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const WEEK_HOURLY_VARS = [
|
||||||
|
'temperature_2m',
|
||||||
|
'precipitation',
|
||||||
|
'precipitation_probability',
|
||||||
|
'weather_code',
|
||||||
|
'wind_speed_10m',
|
||||||
|
'wind_direction_10m',
|
||||||
|
'cloud_cover',
|
||||||
|
'relative_humidity_2m',
|
||||||
|
'apparent_temperature',
|
||||||
|
'dew_point_2m'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const WEEK_DAILY_VARS = [
|
||||||
|
'weather_code',
|
||||||
|
'temperature_2m_max',
|
||||||
|
'temperature_2m_min',
|
||||||
|
'sunrise',
|
||||||
|
'sunset',
|
||||||
|
'sunshine_duration',
|
||||||
|
'precipitation_sum',
|
||||||
|
'wind_speed_10m_max',
|
||||||
|
'wind_gusts_10m_max',
|
||||||
|
'wind_direction_10m_dominant'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the 7-day (week) weather forecast for a single location and model.
|
||||||
|
* Returns typed hourly and daily data structures.
|
||||||
|
*/
|
||||||
|
export async function fetchWeekForecast(params: WeekForecastParams): Promise<WeekForecastResult> {
|
||||||
|
const forecastDays = params.forecast_days ?? 6;
|
||||||
|
const pastDays = params.past_days ?? 0;
|
||||||
|
const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined;
|
||||||
|
|
||||||
|
const apiParams: Record<string, string | number | undefined> = {
|
||||||
|
latitude: params.latitude,
|
||||||
|
longitude: params.longitude,
|
||||||
|
hourly: WEEK_HOURLY_VARS.join(','),
|
||||||
|
daily: WEEK_DAILY_VARS.join(','),
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||||
|
forecast_days: forecastDays,
|
||||||
|
past_days: pastDays,
|
||||||
|
models: modelParam,
|
||||||
|
timezone: params.timezone
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove undefined values
|
||||||
|
const cleanParams: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(apiParams)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
cleanParams[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await fetchWeatherApi(FORECAST_URL, cleanParams);
|
||||||
|
const response = responses[0];
|
||||||
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||||
|
|
||||||
|
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 hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
||||||
|
|
||||||
|
const hourly: WeekHourlyData = {
|
||||||
|
temperature_2m: getValues(hourlyBlock.variables(0)!),
|
||||||
|
precipitation: getValues(hourlyBlock.variables(1)!),
|
||||||
|
precipitation_probability: getValues(hourlyBlock.variables(2)!),
|
||||||
|
weather_code: getValues(hourlyBlock.variables(3)!),
|
||||||
|
windspeed_10m: getValues(hourlyBlock.variables(4)!),
|
||||||
|
winddirection_10m: getValues(hourlyBlock.variables(5)!),
|
||||||
|
cloud_cover: getValues(hourlyBlock.variables(6)!),
|
||||||
|
relative_humidity_2m: getValues(hourlyBlock.variables(7)!),
|
||||||
|
apparent_temperature: getValues(hourlyBlock.variables(8)!),
|
||||||
|
dew_point_2m: getValues(hourlyBlock.variables(9)!)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
||||||
|
const dailyDates = getDates(dailyBlock, utcOffsetSeconds);
|
||||||
|
|
||||||
|
const sunriseVar = dailyBlock.variables(3)!;
|
||||||
|
const sunsetVar = dailyBlock.variables(4)!;
|
||||||
|
|
||||||
|
const daily: WeekDailyData = {
|
||||||
|
weather_code: getValues(dailyBlock.variables(0)!),
|
||||||
|
temperature_2m_max: getValues(dailyBlock.variables(1)!),
|
||||||
|
temperature_2m_min: getValues(dailyBlock.variables(2)!),
|
||||||
|
sunrise: getInt64Values(sunriseVar),
|
||||||
|
sunset: getInt64Values(sunsetVar),
|
||||||
|
sunshine_duration: getValues(dailyBlock.variables(5)!),
|
||||||
|
precipitation_sum: getValues(dailyBlock.variables(6)!),
|
||||||
|
windspeed_10m_max: getValues(dailyBlock.variables(7)!),
|
||||||
|
windgusts_10m_max: getValues(dailyBlock.variables(8)!),
|
||||||
|
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset, utcOffsetSeconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hourly,
|
||||||
|
daily,
|
||||||
|
utcOffsetSeconds,
|
||||||
|
hourlyTimestamps,
|
||||||
|
hourlyDates,
|
||||||
|
dailyDates,
|
||||||
|
markAreas
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model Comparison Fetch ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches forecast data for multiple models for comparison.
|
||||||
|
* Also fetches daily sunrise/sunset for daylight mark areas.
|
||||||
|
*
|
||||||
|
* Returns both a typed model array structure and a flat record structure
|
||||||
|
* compatible with existing chart utilities.
|
||||||
|
*/
|
||||||
|
export async function fetchModelComparison(
|
||||||
|
params: ModelCompareParams
|
||||||
|
): Promise<ModelCompareResult> {
|
||||||
|
const forecastApiParams: Record<string, string> = {
|
||||||
|
latitude: String(params.latitude),
|
||||||
|
longitude: String(params.longitude),
|
||||||
|
hourly: params.hourlyVariables.join(','),
|
||||||
|
models: params.models.join(','),
|
||||||
|
daily: 'sunrise,sunset',
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm'
|
||||||
|
};
|
||||||
|
|
||||||
|
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
|
||||||
|
|
||||||
|
// With multiple models, we get one response per model
|
||||||
|
const firstResponse = responses[0];
|
||||||
|
const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
|
||||||
|
|
||||||
|
// Extract timestamps from the first response's hourly block
|
||||||
|
const hourlyBlock = firstResponse.hourly()!;
|
||||||
|
const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds);
|
||||||
|
|
||||||
|
// Extract sunrise/sunset from the first response's daily block
|
||||||
|
let markAreas: MarkArea[] = [];
|
||||||
|
const dailyBlock = firstResponse.daily();
|
||||||
|
if (dailyBlock) {
|
||||||
|
const sunriseVar = dailyBlock.variables(0)!;
|
||||||
|
const sunsetVar = dailyBlock.variables(1)!;
|
||||||
|
const sunrise = getInt64Values(sunriseVar);
|
||||||
|
const sunset = getInt64Values(sunsetVar);
|
||||||
|
markAreas = buildDaylightMarkAreas(sunrise, sunset, utcOffsetSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each model's response
|
||||||
|
const models: ModelSeriesData[] = [];
|
||||||
|
const hourlyFlat: Record<string, number[]> = {};
|
||||||
|
const hourlyUnitsFlat: Record<string, string> = {};
|
||||||
|
const units: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Add time to flat record
|
||||||
|
const timeInUnixSeconds = range(
|
||||||
|
Number(hourlyBlock.time()),
|
||||||
|
Number(hourlyBlock.timeEnd()),
|
||||||
|
hourlyBlock.interval()
|
||||||
|
);
|
||||||
|
hourlyFlat['time'] = timeInUnixSeconds;
|
||||||
|
|
||||||
|
for (const response of responses) {
|
||||||
|
const modelHourly = response.hourly();
|
||||||
|
if (!modelHourly) continue;
|
||||||
|
|
||||||
|
// Determine model name from the response
|
||||||
|
const modelEnum = response.model();
|
||||||
|
const modelName = modelEnumToString(modelEnum);
|
||||||
|
|
||||||
|
const modelData: ModelSeriesData = {
|
||||||
|
modelName,
|
||||||
|
variables: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let vi = 0; vi < params.hourlyVariables.length; vi++) {
|
||||||
|
const varName = params.hourlyVariables[vi];
|
||||||
|
const variable = modelHourly.variables(vi);
|
||||||
|
if (!variable) continue;
|
||||||
|
|
||||||
|
const values = getValues(variable);
|
||||||
|
modelData.variables[varName] = values;
|
||||||
|
|
||||||
|
// Build flat key like "temperature_2m_icon_seamless"
|
||||||
|
const flatKey = `${varName}_${modelName}`;
|
||||||
|
hourlyFlat[flatKey] = values;
|
||||||
|
|
||||||
|
// Record unit
|
||||||
|
const unitStr = unitToDisplayString(variable.unit());
|
||||||
|
units[varName] = unitStr;
|
||||||
|
hourlyUnitsFlat[flatKey] = unitStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
models.push(modelData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
timestamps,
|
||||||
|
utcOffsetSeconds,
|
||||||
|
markAreas,
|
||||||
|
units,
|
||||||
|
hourlyFlat,
|
||||||
|
hourlyUnitsFlat
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ensemble Forecast Fetch ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches ensemble forecast data from the ensemble API.
|
||||||
|
* Separately fetches daily sunrise/sunset from the standard forecast API.
|
||||||
|
*
|
||||||
|
* Returns typed ensemble data with per-variable member arrays, averages, and spreads,
|
||||||
|
* plus a flat record structure for compatibility with existing chart utilities.
|
||||||
|
*/
|
||||||
|
export async function fetchEnsembleForecast(
|
||||||
|
params: EnsembleForecastParams
|
||||||
|
): Promise<EnsembleForecastResult> {
|
||||||
|
const forecastDays = params.forecast_days ?? 14;
|
||||||
|
|
||||||
|
const ensembleParams: Record<string, string> = {
|
||||||
|
latitude: String(params.latitude),
|
||||||
|
longitude: String(params.longitude),
|
||||||
|
hourly: params.hourlyVariables.join(','),
|
||||||
|
models: params.models.join(','),
|
||||||
|
forecast_days: String(forecastDays),
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm'
|
||||||
|
};
|
||||||
|
|
||||||
|
const dailyParams: Record<string, string> = {
|
||||||
|
latitude: String(params.latitude),
|
||||||
|
longitude: String(params.longitude),
|
||||||
|
daily: 'sunrise,sunset',
|
||||||
|
forecast_days: String(forecastDays),
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch ensemble and daily data in parallel
|
||||||
|
const [ensembleResponses, dailyResponses] = await Promise.all([
|
||||||
|
fetchWeatherApi(ENSEMBLE_URL, ensembleParams),
|
||||||
|
fetchWeatherApi(FORECAST_URL, dailyParams)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ensembleResponse = ensembleResponses[0];
|
||||||
|
const utcOffsetSeconds = ensembleResponse.utcOffsetSeconds();
|
||||||
|
|
||||||
|
const hourlyBlock = ensembleResponse.hourly()!;
|
||||||
|
const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds);
|
||||||
|
const timeLength = timestamps.length;
|
||||||
|
|
||||||
|
// Extract sunrise/sunset for mark areas
|
||||||
|
let markAreas: MarkArea[] = [];
|
||||||
|
if (dailyResponses.length > 0) {
|
||||||
|
const dailyResponse = dailyResponses[0];
|
||||||
|
const dailyBlock = dailyResponse.daily();
|
||||||
|
if (dailyBlock) {
|
||||||
|
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
||||||
|
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
||||||
|
markAreas = buildDaylightMarkAreas(sunrise, sunset, dailyResponse.utcOffsetSeconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process ensemble variables
|
||||||
|
// Each requested variable will have multiple entries in the variables list (one per ensemble member)
|
||||||
|
const variables: Record<string, EnsembleVariableData> = {};
|
||||||
|
const hourlyFlat: Record<string, number[]> = {};
|
||||||
|
const hourlyUnitsFlat: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Add time to flat record
|
||||||
|
const timeInUnixSeconds = range(
|
||||||
|
Number(hourlyBlock.time()),
|
||||||
|
Number(hourlyBlock.timeEnd()),
|
||||||
|
hourlyBlock.interval()
|
||||||
|
);
|
||||||
|
hourlyFlat['time'] = timeInUnixSeconds;
|
||||||
|
|
||||||
|
// Group variables by their requested variable name
|
||||||
|
// The SDK provides variables indexed sequentially:
|
||||||
|
// For N requested variables and M ensemble members, we get N*M variables
|
||||||
|
// ordered as: var0_member0, var0_member1, ..., var0_memberM-1, var1_member0, ...
|
||||||
|
const totalVariables = hourlyBlock.variablesLength();
|
||||||
|
const numRequestedVars = params.hourlyVariables.length;
|
||||||
|
|
||||||
|
if (totalVariables > 0 && numRequestedVars > 0) {
|
||||||
|
const membersPerVar = Math.floor(totalVariables / numRequestedVars);
|
||||||
|
|
||||||
|
for (let vi = 0; vi < numRequestedVars; vi++) {
|
||||||
|
const varName = params.hourlyVariables[vi];
|
||||||
|
const members: number[][] = [];
|
||||||
|
let unitStr = '';
|
||||||
|
|
||||||
|
for (let mi = 0; mi < membersPerVar; mi++) {
|
||||||
|
const varIdx = vi * membersPerVar + mi;
|
||||||
|
const variable = hourlyBlock.variables(varIdx);
|
||||||
|
if (!variable) continue;
|
||||||
|
|
||||||
|
const values = getValues(variable);
|
||||||
|
members.push(values);
|
||||||
|
|
||||||
|
if (mi === 0) {
|
||||||
|
unitStr = unitToDisplayString(variable.unit());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build flat key compatible with JSON API format
|
||||||
|
const memberStr = String(mi).padStart(2, '0');
|
||||||
|
const flatKey = `${varName}_member${memberStr}`;
|
||||||
|
hourlyFlat[flatKey] = values;
|
||||||
|
hourlyUnitsFlat[flatKey] = unitStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average, min, max across members
|
||||||
|
const average = new Array<number>(timeLength).fill(0);
|
||||||
|
const min = new Array<number>(timeLength).fill(Infinity);
|
||||||
|
const max = new Array<number>(timeLength).fill(-Infinity);
|
||||||
|
|
||||||
|
for (let t = 0; t < timeLength; t++) {
|
||||||
|
let count = 0;
|
||||||
|
for (const memberValues of members) {
|
||||||
|
const val = memberValues[t];
|
||||||
|
if (val !== null && val !== undefined && !isNaN(val)) {
|
||||||
|
average[t] += val;
|
||||||
|
count++;
|
||||||
|
if (val < min[t]) min[t] = val;
|
||||||
|
if (val > max[t]) max[t] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
average[t] = Math.round((average[t] / count) * 10) / 10;
|
||||||
|
}
|
||||||
|
if (min[t] === Infinity) min[t] = 0;
|
||||||
|
if (max[t] === -Infinity) max[t] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
variables[varName] = {
|
||||||
|
members,
|
||||||
|
average,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
unit: unitStr
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
variables,
|
||||||
|
timestamps,
|
||||||
|
utcOffsetSeconds,
|
||||||
|
markAreas,
|
||||||
|
hourlyFlat,
|
||||||
|
hourlyUnitsFlat
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the SDK Model enum integer to a string model name.
|
||||||
|
* This table must stay in sync with the @openmeteo/sdk Model enum.
|
||||||
|
*/
|
||||||
|
function modelEnumToString(modelEnum: number): string {
|
||||||
|
const modelMap: Record<number, string> = {
|
||||||
|
0: 'undefined',
|
||||||
|
1: 'best_match',
|
||||||
|
2: 'gfs_seamless',
|
||||||
|
3: 'gfs_global',
|
||||||
|
4: 'gfs_hrrr',
|
||||||
|
5: 'meteofrance_seamless',
|
||||||
|
6: 'meteofrance_arpege_seamless',
|
||||||
|
7: 'meteofrance_arpege_world',
|
||||||
|
8: 'meteofrance_arpege_europe',
|
||||||
|
9: 'meteofrance_arome_seamless',
|
||||||
|
10: 'meteofrance_arome_france',
|
||||||
|
11: 'meteofrance_arome_france_hd',
|
||||||
|
12: 'jma_seamless',
|
||||||
|
13: 'jma_msm',
|
||||||
|
14: 'jms_gsm',
|
||||||
|
15: 'jma_gsm',
|
||||||
|
16: 'gem_seamless',
|
||||||
|
17: 'gem_global',
|
||||||
|
18: 'gem_regional',
|
||||||
|
19: 'gem_hrdps_continental',
|
||||||
|
20: 'icon_seamless',
|
||||||
|
21: 'icon_global',
|
||||||
|
22: 'icon_eu',
|
||||||
|
23: 'icon_d2',
|
||||||
|
24: 'ecmwf_ifs04',
|
||||||
|
25: 'metno_nordic',
|
||||||
|
26: 'era5_seamless',
|
||||||
|
27: 'era5',
|
||||||
|
28: 'cerra',
|
||||||
|
29: 'era5_land',
|
||||||
|
30: 'ecmwf_ifs',
|
||||||
|
31: 'gwam',
|
||||||
|
32: 'ewam',
|
||||||
|
33: 'glofas_seamless_v3',
|
||||||
|
34: 'glofas_forecast_v3',
|
||||||
|
35: 'glofas_consolidated_v3',
|
||||||
|
36: 'glofas_seamless_v4',
|
||||||
|
37: 'glofas_forecast_v4',
|
||||||
|
38: 'glofas_consolidated_v4',
|
||||||
|
39: 'gfs025',
|
||||||
|
40: 'gfs05',
|
||||||
|
41: 'CMCC_CM2_VHR4',
|
||||||
|
42: 'FGOALS_f3_H_highresSST',
|
||||||
|
43: 'FGOALS_f3_H',
|
||||||
|
44: 'HiRAM_SIT_HR',
|
||||||
|
45: 'MRI_AGCM3_2_S',
|
||||||
|
46: 'EC_Earth3P_HR',
|
||||||
|
47: 'MPI_ESM1_2_XR',
|
||||||
|
48: 'NICAM16_8S',
|
||||||
|
49: 'cams_europe',
|
||||||
|
50: 'cams_global',
|
||||||
|
51: 'cfsv2',
|
||||||
|
52: 'era5_ocean',
|
||||||
|
53: 'cma_grapes_global',
|
||||||
|
54: 'bom_access_global',
|
||||||
|
55: 'bom_access_global_ensemble',
|
||||||
|
56: 'arpae_cosmo_seamless',
|
||||||
|
57: 'arpae_cosmo_2i',
|
||||||
|
58: 'arpae_cosmo_2i_ruc',
|
||||||
|
59: 'arpae_cosmo_5m',
|
||||||
|
60: 'ecmwf_ifs025',
|
||||||
|
61: 'ecmwf_aifs025',
|
||||||
|
62: 'gfs013',
|
||||||
|
63: 'gfs_graphcast025',
|
||||||
|
64: 'ecmwf_wam025',
|
||||||
|
65: 'meteofrance_wave',
|
||||||
|
66: 'meteofrance_currents',
|
||||||
|
67: 'ecmwf_wam025_ensemble',
|
||||||
|
68: 'ncep_gfswave025',
|
||||||
|
69: 'ncep_gefswave025',
|
||||||
|
70: 'knmi_seamless',
|
||||||
|
71: 'knmi_harmonie_arome_europe',
|
||||||
|
72: 'knmi_harmonie_arome_netherlands',
|
||||||
|
73: 'dmi_seamless',
|
||||||
|
74: 'dmi_harmonie_arome_europe',
|
||||||
|
75: 'metno_seamless',
|
||||||
|
76: 'era5_ensemble',
|
||||||
|
77: 'ecmwf_ifs_analysis',
|
||||||
|
78: 'ecmwf_ifs_long_window',
|
||||||
|
79: 'ecmwf_ifs_analysis_long_window',
|
||||||
|
80: 'ukmo_global_deterministic_10km',
|
||||||
|
81: 'ukmo_uk_deterministic_2km',
|
||||||
|
82: 'ukmo_seamless',
|
||||||
|
83: 'ncep_gfswave016',
|
||||||
|
84: 'ncep_nbm_conus',
|
||||||
|
85: 'ukmo_global_ensemble_20km',
|
||||||
|
86: 'ecmwf_aifs025_single',
|
||||||
|
87: 'jma_jaxa_himawari',
|
||||||
|
88: 'eumetsat_sarah3',
|
||||||
|
89: 'eumetsat_lsa_saf_msg',
|
||||||
|
90: 'eumetsat_lsa_saf_iodc',
|
||||||
|
91: 'satellite_radiation_seamless',
|
||||||
|
92: 'kma_gdps',
|
||||||
|
93: 'kma_ldps',
|
||||||
|
94: 'kma_seamless',
|
||||||
|
95: 'italia_meteo_arpae_icon_2i',
|
||||||
|
96: 'ukmo_uk_ensemble_2km',
|
||||||
|
97: 'meteofrance_arome_france_hd_15min',
|
||||||
|
98: 'meteofrance_arome_france_15min',
|
||||||
|
99: 'meteoswiss_icon_ch1',
|
||||||
|
100: 'meteoswiss_icon_ch2',
|
||||||
|
101: 'meteoswiss_icon_ch1_ensemble',
|
||||||
|
102: 'meteoswiss_icon_ch2_ensemble',
|
||||||
|
103: 'meteoswiss_icon_seamless',
|
||||||
|
104: 'ncep_nam_conus',
|
||||||
|
105: 'icon_d2_ruc',
|
||||||
|
106: 'ecmwf_seas5',
|
||||||
|
107: 'ecmwf_ec46',
|
||||||
|
108: 'ecmwf_seasonal_seamless',
|
||||||
|
109: 'ecmwf_ifs_seamless',
|
||||||
|
110: 'jma_jaxa_mtg_fci',
|
||||||
|
111: 'gem_hrdps_west'
|
||||||
|
};
|
||||||
|
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
||||||
|
}
|
||||||
@@ -7,14 +7,9 @@
|
|||||||
import {
|
import {
|
||||||
buildAverageSeries,
|
buildAverageSeries,
|
||||||
buildCurrentTimeSeries,
|
buildCurrentTimeSeries,
|
||||||
buildDaylightMarkAreas,
|
|
||||||
buildDaylightSeries,
|
buildDaylightSeries,
|
||||||
buildSpreadSeries,
|
buildSpreadSeries,
|
||||||
calculateAverage,
|
|
||||||
calculateSpread,
|
|
||||||
composeChartOption,
|
composeChartOption,
|
||||||
convertTimestamps,
|
|
||||||
findUnit,
|
|
||||||
getThemeColors
|
getThemeColors
|
||||||
} from '$lib/utils/echarts';
|
} from '$lib/utils/echarts';
|
||||||
|
|
||||||
@@ -23,6 +18,12 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type EnsembleForecastResult,
|
||||||
|
type MarkArea,
|
||||||
|
fetchEnsembleForecast
|
||||||
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { defaultParameters } from '../options';
|
import { defaultParameters } from '../options';
|
||||||
|
|
||||||
import type * as echarts from 'echarts';
|
import type * as echarts from 'echarts';
|
||||||
@@ -52,11 +53,10 @@
|
|||||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FetchedData {
|
interface FetchedData {
|
||||||
hourly: Record<string, unknown>;
|
ensembleResult: EnsembleForecastResult;
|
||||||
hourly_units: Record<string, string>;
|
|
||||||
utc_offset_seconds: number;
|
|
||||||
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
|
utc_offset_seconds: number;
|
||||||
|
markAreas: MarkArea[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let fetchedData: FetchedData | null = $state(null);
|
let fetchedData: FetchedData | null = $state(null);
|
||||||
@@ -92,35 +92,22 @@
|
|||||||
chartInstances = [];
|
chartInstances = [];
|
||||||
chartComponents = [];
|
chartComponents = [];
|
||||||
|
|
||||||
const [dataDaily, dataReq] = await Promise.all([
|
const result: EnsembleForecastResult = await fetchEnsembleForecast({
|
||||||
fetch(
|
latitude: location.latitude!,
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
|
longitude: location.longitude!,
|
||||||
),
|
hourlyVariables: hourlyVars,
|
||||||
fetch(
|
models: modelList,
|
||||||
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&forecast_days=14`
|
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'
|
||||||
const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]);
|
});
|
||||||
|
|
||||||
let markAreas: FetchedData['markAreas'] = [];
|
|
||||||
|
|
||||||
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
|
|
||||||
markAreas = buildDaylightMarkAreas(
|
|
||||||
wd.daily.sunrise,
|
|
||||||
wd.daily.sunset,
|
|
||||||
data.utc_offset_seconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
|
||||||
|
|
||||||
fetchedData = {
|
fetchedData = {
|
||||||
hourly: data.hourly,
|
ensembleResult: result,
|
||||||
hourly_units: data.hourly_units,
|
timestamps: result.timestamps,
|
||||||
utc_offset_seconds: data.utc_offset_seconds,
|
utc_offset_seconds: result.utcOffsetSeconds,
|
||||||
markAreas,
|
markAreas: result.markAreas
|
||||||
timestamps
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -134,32 +121,26 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!fetchedData) return;
|
if (!fetchedData) return;
|
||||||
|
|
||||||
const {
|
const { ensembleResult, timestamps, utc_offset_seconds, markAreas } = fetchedData;
|
||||||
hourly: hourlyData,
|
|
||||||
hourly_units,
|
|
||||||
utc_offset_seconds,
|
|
||||||
markAreas,
|
|
||||||
timestamps
|
|
||||||
} = fetchedData;
|
|
||||||
const _showLegend = showLegend;
|
const _showLegend = showLegend;
|
||||||
|
|
||||||
const colors = getThemeColors();
|
const colors = getThemeColors();
|
||||||
const variableCount = params.hourly?.length || 0;
|
const variableCount = params.hourly?.length || 0;
|
||||||
const timeLength = (hourlyData.time as number[]).length;
|
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
const newOptions: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
const variable = params.hourly![vi];
|
const variable = params.hourly![vi];
|
||||||
const unit = findUnit(hourly_units, hourlyData, variable);
|
const varData = ensembleResult.variables[variable];
|
||||||
|
if (!varData) continue;
|
||||||
|
|
||||||
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
const unit = varData.unit;
|
||||||
const { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength);
|
const { average, min: minValues, max: maxValues } = varData;
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
const series: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
const spreadData: Array<[number, number, number]> = minValues.map(
|
const spreadData: Array<[number, number, number]> = minValues.map(
|
||||||
(min, index) =>
|
(minVal, index) =>
|
||||||
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
[timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
||||||
);
|
);
|
||||||
|
|
||||||
series.push(...buildSpreadSeries({ variable, spreadData }));
|
series.push(...buildSpreadSeries({ variable, spreadData }));
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
export default (
|
|
||||||
ctx: CanvasRenderingContext2D | null | undefined,
|
|
||||||
config: ConfigInterface,
|
|
||||||
series: Float32Array | null | undefined,
|
|
||||||
canvasElement: HTMLCanvasElement
|
|
||||||
): void => {
|
|
||||||
if (ctx && series) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, 35 + (series[0] ** 1.5 / 1000) * 30);
|
|
||||||
for (const [index, value] of series.entries()) {
|
|
||||||
ctx.strokeStyle = '#444';
|
|
||||||
ctx.lineWidth = 0.1;
|
|
||||||
const nextValue = series[index + 1];
|
|
||||||
|
|
||||||
const xc =
|
|
||||||
(index * config.deltaX +
|
|
||||||
0.5 * config.deltaX +
|
|
||||||
(index * config.deltaX + 1.5 * config.deltaX)) /
|
|
||||||
2;
|
|
||||||
const yc = (35 + (value ** 1.5 / 1000) * 30 + 35 + (nextValue ** 1.5 / 1000) * 30) / 2;
|
|
||||||
|
|
||||||
ctx.quadraticCurveTo(
|
|
||||||
index * config.deltaX + 0.5 * config.deltaX,
|
|
||||||
35 + (value ** 1.5 / 1000) * 30,
|
|
||||||
xc,
|
|
||||||
yc
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.quadraticCurveTo(
|
|
||||||
config.maxX,
|
|
||||||
35 + (series[series.length - 1] ** 1.5 / 1000) * 30,
|
|
||||||
config.maxX,
|
|
||||||
35 + (series[series.length - 1] ** 1.5 / 1000) * 30
|
|
||||||
);
|
|
||||||
ctx.quadraticCurveTo(
|
|
||||||
config.maxX,
|
|
||||||
35 - (series[series.length - 1] ** 1.5 / 1000) * 30,
|
|
||||||
config.maxX,
|
|
||||||
35 - (series[series.length - 1] ** 1.5 / 1000) * 30
|
|
||||||
);
|
|
||||||
|
|
||||||
// same series but reversed
|
|
||||||
for (const [ind, _v] of series.entries()) {
|
|
||||||
const index = series.length - 1 - ind;
|
|
||||||
const value = series[index];
|
|
||||||
const nextValue = series[index - 1];
|
|
||||||
|
|
||||||
ctx.strokeStyle = '#444';
|
|
||||||
ctx.lineWidth = 0.1;
|
|
||||||
|
|
||||||
const xc =
|
|
||||||
(index * config.deltaX +
|
|
||||||
0.5 * config.deltaX +
|
|
||||||
(index * config.deltaX - 0.5 * config.deltaX)) /
|
|
||||||
2;
|
|
||||||
const yc = (35 - (value ** 2 / 10000) * 30 + (35 - (nextValue ** 2 / 10000) * 30)) / 2;
|
|
||||||
|
|
||||||
ctx.quadraticCurveTo(
|
|
||||||
index * config.deltaX + 0.5 * config.deltaX,
|
|
||||||
35 - (value ** 2 / 10000) * 30,
|
|
||||||
xc,
|
|
||||||
yc
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ctx.quadraticCurveTo(
|
|
||||||
0.5 * config.deltaX,
|
|
||||||
35 - (series[0] ** 1.5 / 1000) * 30,
|
|
||||||
0,
|
|
||||||
35 - (series[0] ** 1.5 / 1000) * 30
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.closePath();
|
|
||||||
//to fill the space in the shape
|
|
||||||
ctx.fillStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--muted-foreground').split(' ').join(',')}, 0.5)`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export default (
|
|
||||||
ctx: CanvasRenderingContext2D | null | undefined,
|
|
||||||
config: ConfigInterface,
|
|
||||||
series: Date[]
|
|
||||||
): void => {
|
|
||||||
if (ctx) {
|
|
||||||
for (const [index, value] of series.entries()) {
|
|
||||||
if (value.getHours() > 6 && value.getHours() < 21) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(index * config.deltaX, config.maxY);
|
|
||||||
ctx.lineTo(index * config.deltaX, 0);
|
|
||||||
ctx.lineTo((index + 1) * config.deltaX, 0);
|
|
||||||
ctx.lineTo((index + 1) * config.deltaX, config.maxY);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = '#f4ff0014';
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export default (
|
|
||||||
ctx: CanvasRenderingContext2D | null | undefined,
|
|
||||||
config: ConfigInterface,
|
|
||||||
series: Float32Array | null | undefined,
|
|
||||||
canvasElement: HTMLCanvasElement
|
|
||||||
): void => {
|
|
||||||
if (ctx && series) {
|
|
||||||
for (const [index, value] of series.entries()) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY);
|
|
||||||
|
|
||||||
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--primary').split(' ').join(',')}, 1)`;
|
|
||||||
ctx.lineWidth = 12;
|
|
||||||
|
|
||||||
ctx.lineTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY - value ** 0.55 * 45);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
export default (
|
|
||||||
ctx: CanvasRenderingContext2D | null | undefined,
|
|
||||||
config: ConfigInterface,
|
|
||||||
series: Date[],
|
|
||||||
today: Date,
|
|
||||||
canvasElement: HTMLCanvasElement
|
|
||||||
): void => {
|
|
||||||
if (ctx && series) {
|
|
||||||
for (const [index, _v] of series.entries()) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(index * config.deltaX, 0);
|
|
||||||
ctx.lineTo(index * config.deltaX, config.maxY);
|
|
||||||
if (series[index].getHours() === 0) {
|
|
||||||
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 1)`;
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
} else if (
|
|
||||||
series[index].getDate() === today.getDate() &&
|
|
||||||
series[index].getHours() === today.getHours()
|
|
||||||
) {
|
|
||||||
// fill now line
|
|
||||||
// TODO: update this line every minute
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = 'red';
|
|
||||||
ctx.lineWidth = 5;
|
|
||||||
const minutes = today.getMinutes();
|
|
||||||
ctx.moveTo(index * config.deltaX + (config.deltaX / 60) * minutes, 0);
|
|
||||||
ctx.lineTo(index * config.deltaX + (config.deltaX / 60) * minutes, config.maxY);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 0.75)`;
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
} else {
|
|
||||||
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 0.75)`;
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { getColor } from '../utils/colors';
|
|
||||||
|
|
||||||
export default (
|
|
||||||
ctx: CanvasRenderingContext2D | null | undefined,
|
|
||||||
config: ConfigInterface,
|
|
||||||
series: Float32Array | null | undefined,
|
|
||||||
unit = 'celsius'
|
|
||||||
): void => {
|
|
||||||
if (ctx && series) {
|
|
||||||
const tempGradientFill = ctx.createLinearGradient(0, 0, 0, config.maxY);
|
|
||||||
tempGradientFill.addColorStop(0, getColor(config.maxTemp.toFixed(0), unit) + '5c');
|
|
||||||
tempGradientFill.addColorStop(0.25, getColor(config.maxTemp.toFixed(0), unit) + '5c');
|
|
||||||
tempGradientFill.addColorStop(0.85, getColor(config.minTemp.toFixed(0), unit) + '06');
|
|
||||||
tempGradientFill.addColorStop(1, getColor(config.minTemp.toFixed(0), unit) + '00');
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(
|
|
||||||
0,
|
|
||||||
0.25 * config.maxY + ((config.maxTemp - series[0]) / config.diffTemp) * 0.55 * config.maxY
|
|
||||||
);
|
|
||||||
for (const [index, value] of series.filter((t) => !isNaN(t)).entries()) {
|
|
||||||
const indexDiffTemp = config.maxTemp - value;
|
|
||||||
const indexDiffTempNext = config.maxTemp - series[index + 1];
|
|
||||||
|
|
||||||
ctx.strokeStyle = '#d3d3d3';
|
|
||||||
ctx.lineWidth = 4;
|
|
||||||
|
|
||||||
const xc =
|
|
||||||
(index * config.deltaX +
|
|
||||||
0.5 * config.deltaX +
|
|
||||||
(index * config.deltaX + 1.5 * config.deltaX)) /
|
|
||||||
2;
|
|
||||||
const yc =
|
|
||||||
(0.25 * config.maxY +
|
|
||||||
(indexDiffTemp / config.diffTemp) * 0.55 * config.maxY +
|
|
||||||
(0.25 * config.maxY + (indexDiffTempNext / config.diffTemp) * 0.55 * config.maxY)) /
|
|
||||||
2;
|
|
||||||
|
|
||||||
ctx.quadraticCurveTo(
|
|
||||||
index * config.deltaX + 0.5 * config.deltaX,
|
|
||||||
0.25 * config.maxY + (indexDiffTemp / config.diffTemp) * 0.55 * config.maxY,
|
|
||||||
xc,
|
|
||||||
yc
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ctx.quadraticCurveTo(
|
|
||||||
config.maxX,
|
|
||||||
0.25 * config.maxY +
|
|
||||||
((config.maxTemp - series[series.length - 1]) / config.diffTemp) * 0.55 * config.maxY,
|
|
||||||
(config.maxX + config.maxX + config.deltaX) / 2,
|
|
||||||
0.25 * config.maxY +
|
|
||||||
((config.maxTemp - series[series.length - 1]) / config.diffTemp) * 0.55 * config.maxY
|
|
||||||
);
|
|
||||||
ctx.lineTo(config.maxX, config.maxY);
|
|
||||||
ctx.lineTo(0, config.maxY);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = tempGradientFill;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -8,12 +8,10 @@
|
|||||||
import {
|
import {
|
||||||
buildAverageSeries,
|
buildAverageSeries,
|
||||||
buildCurrentTimeSeries,
|
buildCurrentTimeSeries,
|
||||||
buildDaylightMarkAreas,
|
|
||||||
buildDaylightSeries,
|
buildDaylightSeries,
|
||||||
buildModelSeries,
|
buildModelSeries,
|
||||||
calculateAverage,
|
calculateAverage,
|
||||||
composeChartOption,
|
composeChartOption,
|
||||||
convertTimestamps,
|
|
||||||
findUnit,
|
findUnit,
|
||||||
getThemeColors
|
getThemeColors
|
||||||
} from '$lib/utils/echarts';
|
} from '$lib/utils/echarts';
|
||||||
@@ -24,6 +22,12 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type MarkArea,
|
||||||
|
type ModelCompareResult,
|
||||||
|
fetchModelComparison
|
||||||
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { hourly, models as modelsFlat } from '../options';
|
import { hourly, models as modelsFlat } from '../options';
|
||||||
import { defaultParameters } from '../options';
|
import { defaultParameters } from '../options';
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@
|
|||||||
hourly: Record<string, unknown>;
|
hourly: Record<string, unknown>;
|
||||||
hourly_units: Record<string, string>;
|
hourly_units: Record<string, string>;
|
||||||
utc_offset_seconds: number;
|
utc_offset_seconds: number;
|
||||||
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
markAreas: MarkArea[];
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,38 +106,22 @@
|
|||||||
chartInstances = [];
|
chartInstances = [];
|
||||||
chartComponents = [];
|
chartComponents = [];
|
||||||
|
|
||||||
const dataReq = await fetch(
|
const result: ModelCompareResult = await fetchModelComparison({
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise`
|
latitude: location.latitude!,
|
||||||
);
|
longitude: location.longitude!,
|
||||||
const data = await dataReq.json();
|
hourlyVariables: hourlyVars,
|
||||||
|
models: modelList,
|
||||||
let markAreas: FetchedData['markAreas'] = [];
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
if ('daily' in data) {
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
|
||||||
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
|
});
|
||||||
dailyFirstModelKey.shift();
|
|
||||||
dailyFirstModelKey = dailyFirstModelKey.join('_');
|
|
||||||
|
|
||||||
const sunriseKey = 'sunrise_' + dailyFirstModelKey;
|
|
||||||
const sunsetKey = 'sunset_' + dailyFirstModelKey;
|
|
||||||
|
|
||||||
if (sunriseKey in data.daily && sunsetKey in data.daily) {
|
|
||||||
markAreas = buildDaylightMarkAreas(
|
|
||||||
data.daily[sunriseKey],
|
|
||||||
data.daily[sunsetKey],
|
|
||||||
data.utc_offset_seconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
|
||||||
|
|
||||||
fetchedData = {
|
fetchedData = {
|
||||||
hourly: data.hourly,
|
hourly: result.hourlyFlat,
|
||||||
hourly_units: data.hourly_units,
|
hourly_units: result.hourlyUnitsFlat,
|
||||||
utc_offset_seconds: data.utc_offset_seconds,
|
utc_offset_seconds: result.utcOffsetSeconds,
|
||||||
markAreas,
|
markAreas: result.markAreas,
|
||||||
timestamps
|
timestamps: result.timestamps
|
||||||
};
|
};
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -158,7 +146,7 @@
|
|||||||
|
|
||||||
const colors = getThemeColors();
|
const colors = getThemeColors();
|
||||||
const variableCount = params.hourly?.length || 0;
|
const variableCount = params.hourly?.length || 0;
|
||||||
const timeLength = (hourlyData.time as number[]).length;
|
const timeLength = timestamps.length;
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
const newOptions: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export const models = [
|
|||||||
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
|
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
|
||||||
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
||||||
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
|
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
|
||||||
{ value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' },
|
|
||||||
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
|
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
|
||||||
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
|
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
|
||||||
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
|
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import colorScaleHex from './color-scale-hex';
|
import colorScaleHex from './color-scale-hex';
|
||||||
|
|
||||||
function componentFromStr(numStr: string, percent: number) {
|
const componentFromStr = (numStr: string, percent: number) => {
|
||||||
const num = Math.max(0, parseInt(numStr, 10));
|
const num = Math.max(0, parseInt(numStr, 10));
|
||||||
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
|
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function rgbToHex(rgb: string) {
|
export const rgbToHex = (rgb: string): string => {
|
||||||
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
|
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
|
||||||
let result,
|
let result,
|
||||||
r,
|
r,
|
||||||
@@ -23,29 +23,58 @@ export function rgbToHex(rgb: string) {
|
|||||||
return '355522';
|
return '355522';
|
||||||
}
|
}
|
||||||
return hex;
|
return hex;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hexToRgb = (hex: string): [number, number, number] => {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
return [
|
||||||
|
parseInt(h.substring(0, 2), 16),
|
||||||
|
parseInt(h.substring(2, 4), 16),
|
||||||
|
parseInt(h.substring(4, 6), 16)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getColor = (temperature: number, unit = 'celsius'): string => {
|
||||||
|
if (unit !== 'celsius') {
|
||||||
|
temperature = Math.round(((temperature - 32) * 5) / 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getColor = (tempString: string, unit = 'celsius'): string => {
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const temp = Number(tempString);
|
if (temperature <= -40) {
|
||||||
if (unit === 'celsius') {
|
|
||||||
if (temp <= -40) {
|
|
||||||
index = 0;
|
index = 0;
|
||||||
} else if (temp >= 60) {
|
} else if (temperature >= 60) {
|
||||||
index = colorScaleHex.length - 1;
|
index = colorScaleHex.length - 1;
|
||||||
} else {
|
} else {
|
||||||
index = temp + 40;
|
index = Math.round(temperature) + 45;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const tempInCelsius = Math.round(((temp - 32) * 5) / 9);
|
|
||||||
if (tempInCelsius <= -40) {
|
|
||||||
index = 0;
|
|
||||||
} else if (tempInCelsius >= 60) {
|
|
||||||
index = colorScaleHex.length - 1;
|
|
||||||
} else {
|
|
||||||
index = tempInCelsius + 40;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return colorScaleHex[index];
|
return colorScaleHex[index];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface TempStyle {
|
||||||
|
bg: string;
|
||||||
|
fg: 'white' | 'black';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTempStyle = (temp: number, unit: string): TempStyle => {
|
||||||
|
const bg = getColor(temp, unit);
|
||||||
|
const fg = textWhite(hexToRgb(bg)) ? 'white' : 'black';
|
||||||
|
return { bg, fg };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const textWhite = (
|
||||||
|
[r, g, b, a]: [number, number, number, number] | [number, number, number],
|
||||||
|
dark?: boolean,
|
||||||
|
globalOpacity?: number
|
||||||
|
): boolean => {
|
||||||
|
const alpha = ((a || 1) * (globalOpacity || 100)) / 100;
|
||||||
|
if (alpha < 0.65) {
|
||||||
|
if (dark) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// check luminance
|
||||||
|
return r * 0.299 + g * 0.587 + b * 0.114 <= 150;
|
||||||
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,25 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
import { fetchWeatherApi } from 'openmeteo';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
||||||
|
|
||||||
import { pad } from '$lib/utils/index';
|
import { defaultParameters } from '../../options';
|
||||||
|
import DailyCards from './DailyCards.svelte';
|
||||||
|
import HourlyTable from './HourlyTable.svelte';
|
||||||
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||||
|
import ModelSelector from './ModelSelector.svelte';
|
||||||
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
import type { GeoLocation } from '$lib/stores/settings';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import type { FetchedDaily, FetchedHourly } from './types';
|
||||||
|
|
||||||
import cloudCover from '../../canvas/cloud-cover';
|
|
||||||
import daylight from '../../canvas/daylight';
|
|
||||||
import precip from '../../canvas/precip';
|
|
||||||
import raster from '../../canvas/raster';
|
|
||||||
import tempGradient from '../../canvas/temp-gradient';
|
|
||||||
import { defaultParameters, models } from '../../options';
|
|
||||||
import { getColor } from '../../utils/colors';
|
|
||||||
import weatherCodes from '../../utils/weather-codes';
|
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
latitude: [$storedLocation.latitude],
|
latitude: [$storedLocation.latitude],
|
||||||
@@ -28,652 +22,124 @@
|
|||||||
...defaultParameters
|
...defaultParameters
|
||||||
});
|
});
|
||||||
|
|
||||||
let location = $state($storedLocation);
|
let location = $state<GeoLocation>($storedLocation);
|
||||||
storedLocation.subscribe((value) => {
|
storedLocation.subscribe((value) => {
|
||||||
location = value;
|
location = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
let diffTemp: number | undefined = $state();
|
let mounted = $state(false);
|
||||||
let maxTemp: number | undefined = $state();
|
let loading = $state(true);
|
||||||
|
|
||||||
let weatherCodesHourly: Float32Array | null | undefined = $state();
|
const selectedDay = new SvelteDate();
|
||||||
let canvasElement: HTMLCanvasElement | null | undefined = $state();
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
let selectedDay = $state(new Date());
|
|
||||||
let selectedDayIndex = $state(1);
|
let selectedDayIndex = $state(1);
|
||||||
|
|
||||||
let entries = $state(0);
|
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||||
|
let fetchedDaily: FetchedDaily | null = $state(null);
|
||||||
|
|
||||||
let weather = $derived(
|
let meteogramCharts: MeteogramCharts | undefined = $state();
|
||||||
(async (location: GeoLocation) => {
|
|
||||||
const reqParams = {
|
|
||||||
latitude: location.latitude,
|
|
||||||
longitude: location.longitude,
|
|
||||||
elevation: location.elevation,
|
|
||||||
// timezone: location.timezone, ???
|
|
||||||
models: [params.models],
|
|
||||||
hourly: [
|
|
||||||
'precipitation',
|
|
||||||
'precipitation_probability',
|
|
||||||
'temperature_2m',
|
|
||||||
'weather_code',
|
|
||||||
'windspeed_10m',
|
|
||||||
'winddirection_10m',
|
|
||||||
'cloud_cover',
|
|
||||||
'relative_humidity_2m'
|
|
||||||
].join(','),
|
|
||||||
forecast_days: 6,
|
|
||||||
past_days: 1,
|
|
||||||
temperature_unit: params.temperature_unit,
|
|
||||||
wind_speed_unit: params.wind_speed_unit,
|
|
||||||
precipitation_unit: params.precipitation_unit
|
|
||||||
};
|
|
||||||
const url = 'https://api.open-meteo.com/v1/forecast';
|
|
||||||
const responses = await fetchWeatherApi(url, reqParams);
|
|
||||||
const response = responses[0];
|
|
||||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
||||||
const hourly = response.hourly()!;
|
|
||||||
|
|
||||||
weatherCodesHourly = hourly.variables(3)?.valuesArray();
|
|
||||||
|
|
||||||
let hourlyTime = [
|
|
||||||
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
|
|
||||||
].map(
|
|
||||||
(_, i) =>
|
|
||||||
new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
|
|
||||||
);
|
|
||||||
const hourlyTemps = hourly.variables(2)?.valuesArray();
|
|
||||||
const hourlyCloudCover = hourly.variables(6)?.valuesArray();
|
|
||||||
const hourlyPrecip = hourly.variables(0)?.valuesArray();
|
|
||||||
const indexes = [];
|
|
||||||
if (hourlyTemps) {
|
|
||||||
for (const index of hourlyTemps.keys()) {
|
|
||||||
indexes.push(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxX = 10000;
|
|
||||||
const maxY = 500;
|
|
||||||
const deltaX = 10000 / (hourly.variables(0)?.valuesArray()?.length || 1);
|
|
||||||
|
|
||||||
const ctx = canvasElement?.getContext('2d');
|
|
||||||
if (ctx) {
|
|
||||||
ctx.clearRect(0, 0, maxX, maxY);
|
|
||||||
|
|
||||||
const minTemp = Math.min(
|
|
||||||
...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t))
|
|
||||||
);
|
|
||||||
maxTemp = Math.max(...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t)));
|
|
||||||
diffTemp = maxTemp - minTemp;
|
|
||||||
|
|
||||||
const config: ConfigInterface = {
|
|
||||||
maxX: maxX,
|
|
||||||
maxY: maxY,
|
|
||||||
deltaX: deltaX,
|
|
||||||
minTemp: minTemp,
|
|
||||||
maxTemp: maxTemp,
|
|
||||||
diffTemp: diffTemp
|
|
||||||
};
|
|
||||||
|
|
||||||
// create canvas
|
|
||||||
daylight(ctx, config, hourlyTime);
|
|
||||||
raster(ctx, config, hourlyTime, today, canvasElement!);
|
|
||||||
tempGradient(ctx, config, hourlyTemps, params.temperature_unit);
|
|
||||||
cloudCover(ctx, config, hourlyCloudCover, canvasElement!);
|
|
||||||
precip(ctx, config, hourlyPrecip, canvasElement!);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
name: 'temperature_2m',
|
|
||||||
title: 'Temperature',
|
|
||||||
values: hourly
|
|
||||||
.variables(2)
|
|
||||||
?.valuesArray()
|
|
||||||
?.map((t) => Number(t.toFixed(1)))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'precipitation',
|
|
||||||
title: 'Precipitation',
|
|
||||||
values: hourly
|
|
||||||
.variables(0)
|
|
||||||
?.valuesArray()
|
|
||||||
?.map((p) => Number(p.toFixed(1)))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'precipitation_probability',
|
|
||||||
title: 'Precip Prob.',
|
|
||||||
values: hourly
|
|
||||||
.variables(1)
|
|
||||||
?.valuesArray()
|
|
||||||
?.map((p) => Number(p.toFixed(0)))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'windspeed_10m',
|
|
||||||
title: 'Wind',
|
|
||||||
values: hourly
|
|
||||||
.variables(4)
|
|
||||||
?.valuesArray()
|
|
||||||
?.map((p) => Number(p.toFixed(0)))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'relative_humidity_2m',
|
|
||||||
title: 'Rel. Hum.',
|
|
||||||
values: hourly
|
|
||||||
.variables(7)
|
|
||||||
?.valuesArray()
|
|
||||||
?.map((p) => Number(p.toFixed(0)))
|
|
||||||
}
|
|
||||||
],
|
|
||||||
entriesLength: hourly.variables(0)?.valuesArray()?.length,
|
|
||||||
hourlyTime: hourlyTime,
|
|
||||||
windDirections: hourly.variables(5)?.valuesArray(),
|
|
||||||
indexes: indexes
|
|
||||||
};
|
|
||||||
})(location)
|
|
||||||
);
|
|
||||||
|
|
||||||
let weatherDaily = $derived(
|
|
||||||
(async (location: GeoLocation) => {
|
|
||||||
const reqParams = {
|
|
||||||
latitude: location.latitude,
|
|
||||||
longitude: location.longitude,
|
|
||||||
elevation: location.elevation,
|
|
||||||
// timezone: location.timezone, ???
|
|
||||||
models: [params.models],
|
|
||||||
daily: [
|
|
||||||
'weather_code',
|
|
||||||
'temperature_2m_max',
|
|
||||||
'temperature_2m_min',
|
|
||||||
'sunrise',
|
|
||||||
'sunset',
|
|
||||||
'sunshine_duration',
|
|
||||||
'precipitation_sum',
|
|
||||||
'windspeed_10m_max',
|
|
||||||
'windgusts_10m_max',
|
|
||||||
'winddirection_10m_dominant'
|
|
||||||
].join(','),
|
|
||||||
forecast_days: 6,
|
|
||||||
past_days: 1,
|
|
||||||
temperature_unit: params.temperature_unit,
|
|
||||||
wind_speed_unit: params.wind_speed_unit,
|
|
||||||
precipitation_unit: params.precipitation_unit
|
|
||||||
};
|
|
||||||
const url = 'https://api.open-meteo.com/v1/forecast';
|
|
||||||
const responses = await fetchWeatherApi(url, reqParams);
|
|
||||||
const response = responses[0];
|
|
||||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
|
||||||
const daily = response.daily()!;
|
|
||||||
|
|
||||||
return {
|
|
||||||
daily: {
|
|
||||||
time: [...Array((Number(daily.timeEnd()) - Number(daily.time())) / daily.interval())].map(
|
|
||||||
(_, i) =>
|
|
||||||
new Date((Number(daily.time()) + i * daily.interval() + utcOffsetSeconds) * 1000)
|
|
||||||
),
|
|
||||||
weather_code: daily.variables(0)!,
|
|
||||||
temperature_2m_max: daily.variables(1)!,
|
|
||||||
temperature_2m_min: daily.variables(2)!,
|
|
||||||
sunrise: daily.variables(3)!,
|
|
||||||
sunset: daily.variables(4)!,
|
|
||||||
sunshine_duration: daily.variables(5)!,
|
|
||||||
precipitation_sum: daily.variables(6)!,
|
|
||||||
windspeed_10m_max: daily.variables(7)!,
|
|
||||||
windgusts_10m_max: daily.variables(8)!,
|
|
||||||
winddirection_10m_dominant: daily.variables(9)!
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})(location)
|
|
||||||
);
|
|
||||||
|
|
||||||
let winddir = true;
|
|
||||||
entries = 6;
|
|
||||||
|
|
||||||
let scrollDiv: HTMLElement | undefined = $state();
|
|
||||||
let tableCells;
|
|
||||||
|
|
||||||
const switchDay = (date: Date, index: number) => {
|
const switchDay = (date: Date, index: number) => {
|
||||||
selectedDay = date;
|
selectedDay.setTime(date.getTime());
|
||||||
|
|
||||||
tableCells = document.querySelectorAll('td.time');
|
|
||||||
|
|
||||||
for (let tableCell of tableCells) {
|
|
||||||
const htmlCell = tableCell as HTMLElement;
|
|
||||||
if (Number(htmlCell.dataset['date']) === selectedDay.getDate()) {
|
|
||||||
scrollDiv?.scrollTo({ left: htmlCell.offsetLeft - 110, behavior: 'smooth' });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedDayIndex = index;
|
selectedDayIndex = index;
|
||||||
|
meteogramCharts?.scrollToDay(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setTimeout(() => {
|
mounted = true;
|
||||||
tableCells = document.querySelectorAll('td.time');
|
|
||||||
for (let tableCell of tableCells) {
|
|
||||||
const htmlCell = tableCell as HTMLElement;
|
|
||||||
if (Number(htmlCell.dataset['date']) === selectedDay.getDate()) {
|
|
||||||
scrollDiv?.scrollTo({ left: htmlCell.offsetLeft - 110 });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
document.onkeydown = (e) => {
|
document.onkeydown = (e) => {
|
||||||
if (scrollDiv !== document.activeElement && !scrollDiv?.contains(document.activeElement)) {
|
if (!fetchedDaily) return;
|
||||||
|
const days = fetchedDaily.dailyDates;
|
||||||
|
|
||||||
if (e.key === 'ArrowLeft') {
|
if (e.key === 'ArrowLeft') {
|
||||||
if (selectedDay.getDate() >= today.getDate()) {
|
const i = selectedDayIndex - 1;
|
||||||
let newDate = new Date();
|
if (i >= 0 && i < days.length) switchDay(days[i], i);
|
||||||
newDate.setDate(selectedDay.getDate() - 1);
|
} else if (e.key === 'ArrowRight') {
|
||||||
switchDay(newDate, selectedDayIndex - 1);
|
const i = selectedDayIndex + 1;
|
||||||
}
|
if (i >= 0 && i < days.length) switchDay(days[i], i);
|
||||||
}
|
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
if (selectedDay.getDate() <= today.getDate() + 4) {
|
|
||||||
let newDate = new Date();
|
|
||||||
newDate.setDate(selectedDay.getDate() + 1);
|
|
||||||
switchDay(newDate, selectedDayIndex + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let modelSelected = $derived(models.find((mo) => String(mo.value) === params.models?.[0]));
|
onDestroy(() => {
|
||||||
// let modelSelectedValue = $derived(params.models[0]);
|
document.onkeydown = null;
|
||||||
//
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const loc = location;
|
||||||
|
const modelList = params.models;
|
||||||
|
|
||||||
|
if (!mounted || !loc || !modelList?.length) return;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
const result: WeekForecastResult = await fetchWeekForecast({
|
||||||
|
latitude: loc.latitude!,
|
||||||
|
longitude: loc.longitude!,
|
||||||
|
model: modelList[0],
|
||||||
|
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',
|
||||||
|
forecast_days: 7,
|
||||||
|
past_days: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchedHourly = {
|
||||||
|
hourly: result.hourly,
|
||||||
|
utc_offset_seconds: result.utcOffsetSeconds,
|
||||||
|
timestamps: result.hourlyTimestamps,
|
||||||
|
hourlyDates: result.hourlyDates,
|
||||||
|
markAreas: result.markAreas
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchedDaily = {
|
||||||
|
daily: result.daily,
|
||||||
|
dailyDates: result.dailyDates
|
||||||
|
};
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Weather | Open-Meteo.com</title>
|
<title>Weather | Open-Meteo.com</title>
|
||||||
<link rel="canonical" href="https://open-meteo.com/weather" />
|
<link rel="canonical" href="https://open-meteo.com/weather" />
|
||||||
<meta name="description" content="segseg" />
|
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="">
|
<div class="week-page">
|
||||||
<div class="weather-content" style="min-height: 50vh">
|
<div class="weather-content" style="min-height: 50vh">
|
||||||
<div
|
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
|
||||||
in:fade
|
|
||||||
out:fade
|
{#if fetchedHourly && fetchedDaily}
|
||||||
style="min-height: 256px"
|
<HourlyTable
|
||||||
class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row"
|
data={fetchedHourly}
|
||||||
>
|
daily={fetchedDaily}
|
||||||
{#await weatherDaily then wd}
|
{selectedDay}
|
||||||
{#each wd.daily.time as time, index (index)}
|
units={params}
|
||||||
{@const selected = time.getDate() === selectedDay.getDate()}
|
locationName={location.name ?? ''}
|
||||||
{#if wd.daily.temperature_2m_max.values(index) != null && !isNaN(Number(wd.daily.temperature_2m_max
|
/>
|
||||||
.values(index)!
|
{/if}
|
||||||
.toFixed(1)))}
|
|
||||||
<button
|
{#if fetchedHourly}
|
||||||
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
|
<MeteogramCharts
|
||||||
class="cursor-pointer"
|
bind:this={meteogramCharts}
|
||||||
onclick={() => {
|
data={fetchedHourly}
|
||||||
switchDay(time, index);
|
{selectedDay}
|
||||||
|
units={params}
|
||||||
|
{loading}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||||
|
onModelChange={(model) => {
|
||||||
|
params.models = [model];
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
class="gap-md-1 flex flex-row items-center justify-center rounded-xl p-1 md:flex-col md:justify-center md:p-3 {selected
|
|
||||||
? 'bg-accent'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
<div class="weather-week-date">
|
|
||||||
<b>{time.getDate()} - {time.getMonth() + 1}</b>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-text={time.toLocaleDateString('en-GB', { weekday: 'long' })}
|
|
||||||
class="grow-text relative mx-auto inline-flex flex-col {selected
|
|
||||||
? 'font-bold'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
{time.toLocaleDateString('en-GB', { weekday: 'long' })}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="weather-week-icon pe-none py-2">
|
|
||||||
<svg class="fill-foreground" width="60px" height="60px">
|
|
||||||
<use
|
|
||||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
|
||||||
(wd.daily.weather_code.values(index) ?? 0) as number
|
|
||||||
]}.svg#Layer_1"
|
|
||||||
></use>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="weather-temp-max flex min-w-[65px] justify-center rounded-t p-1 text-sm"
|
|
||||||
style={`background-color: ${getColor((wd.daily.temperature_2m_max.values(index) ?? 0).toFixed(0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
|
|
||||||
>
|
|
||||||
{wd.daily.temperature_2m_max.values(index)?.toFixed(1)}
|
|
||||||
{params.temperature_unit === 'celsius' ? '°C' : '°F'}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="weather-temp-min flex min-w-[65px] justify-center rounded-b p-1 text-sm"
|
|
||||||
style={`background: ${getColor((wd.daily.temperature_2m_min.values(index) ?? 0).toFixed(0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
|
|
||||||
>
|
|
||||||
{wd.daily.temperature_2m_min.values(index)?.toFixed(1)}
|
|
||||||
{params.temperature_unit === 'celsius' ? '°C' : '°F'}
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 flex items-center justify-center gap-1">
|
|
||||||
<div class="relative flex h-6 w-6 items-center justify-center">
|
|
||||||
<div class="absolute">
|
|
||||||
<svg class="fill-foreground" width="26px" height="26px">
|
|
||||||
<use
|
|
||||||
class="stroke-2"
|
|
||||||
xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"
|
|
||||||
></use>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Number((wd.daily.sunshine_duration.values(index) ?? 0) / 3600).toFixed(0)}h
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex items-center justify-center">
|
|
||||||
<div class="relative flex h-6 w-6 items-center justify-center">
|
|
||||||
<div class="absolute">
|
|
||||||
<svg class="fill-foreground" width="28px" height="28px">
|
|
||||||
<use
|
|
||||||
class="stroke-2"
|
|
||||||
xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"
|
|
||||||
></use>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{Number(wd.daily.precipitation_sum.values(index)).toFixed(
|
|
||||||
1
|
|
||||||
)}{params.precipitation_unit === 'mm' ? 'mm' : "'"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{:catch error}
|
|
||||||
<p style="color: red">{error.message}</p>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
<div class="ml-22 md:ml-0">
|
|
||||||
<h3 class="text-xl font-bold">
|
|
||||||
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
|
|
||||||
<small>
|
|
||||||
{selectedDay.getDate() === new Date(today.getTime() - 24 * 60 * 60 * 1000).getDate()
|
|
||||||
? ' (Yesterday)'
|
|
||||||
: ''}
|
|
||||||
{selectedDay.getDate() === today.getDate() ? ' (Today)' : ''}
|
|
||||||
{selectedDay.getDate() === new Date(today.getTime() + 24 * 60 * 60 * 1000).getDate()
|
|
||||||
? ' (Tomorrow)'
|
|
||||||
: ''}
|
|
||||||
</small>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
bind:this={scrollDiv}
|
|
||||||
style=" height: {218 + entries * 27.5}px; "
|
|
||||||
class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-[110px]"
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
bind:this={canvasElement}
|
|
||||||
id="weather_week_canvas"
|
|
||||||
class="border border-border"
|
|
||||||
style="margin-top: 24px; margin-left: 110px; width: 5000px; height: 200px; "
|
|
||||||
height="500px"
|
|
||||||
width="10000px"
|
|
||||||
></canvas>
|
|
||||||
<table in:fade class="absolute bottom-0 border-b border-border">
|
|
||||||
<caption style="display:none"> Weather Week {location.name} </caption>
|
|
||||||
<tbody>
|
|
||||||
{#await weather then weather}
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
class="time"
|
|
||||||
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
|
|
||||||
>Time</th
|
|
||||||
>
|
|
||||||
{#each weather.indexes as index, j (j)}
|
|
||||||
<td
|
|
||||||
class="time {weather.hourlyTime[index].getDate() === today.getDate() &&
|
|
||||||
weather.hourlyTime[index].getHours() === today.getHours()
|
|
||||||
? 'now'
|
|
||||||
: ''}"
|
|
||||||
data-date={weather.hourlyTime[index].getDate()}
|
|
||||||
data-time={weather.hourlyTime[index].getHours() + ':00'}
|
|
||||||
style="font-size: 11px; position: absolute; bottom: {188 +
|
|
||||||
27 * entries}px; left:{111 +
|
|
||||||
(5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
|
|
||||||
(weather.entriesLength || 1)}px; max-width: {5000 /
|
|
||||||
(weather.entriesLength || 1)}px;"
|
|
||||||
>{weather.hourlyTime[index].getHours() < 10 ? '0' : ''}{weather.hourlyTime[
|
|
||||||
index
|
|
||||||
].getHours()}</td
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
<!-- icons -->
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
|
|
||||||
>Icons</th
|
|
||||||
>
|
|
||||||
{#each weather.indexes as index, j (j)}
|
|
||||||
{@const now =
|
|
||||||
weather.hourlyTime[index].getDate() === today.getDate() &&
|
|
||||||
weather.hourlyTime[index].getHours() === today.getHours()}
|
|
||||||
<td
|
|
||||||
style="position: absolute; bottom: {27.5 * entries -
|
|
||||||
24 +
|
|
||||||
0.8 * 200 -
|
|
||||||
0.54 *
|
|
||||||
200 *
|
|
||||||
((maxTemp! - weather.entries[0].values![index]) / diffTemp!)}px; left:{116 +
|
|
||||||
(5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
|
|
||||||
(weather.entriesLength || 1)}px; max-width: {5000 /
|
|
||||||
(weather.entriesLength || 1)}px;"
|
|
||||||
><svg class="fill-foreground {now ? 'scale-125' : ''}" width="20px" height="20px">
|
|
||||||
<use
|
|
||||||
class="stroke-2"
|
|
||||||
xlink:href="/images/weather-icons/wi-{weather.hourlyTime[index].getHours() >
|
|
||||||
6 && weather.hourlyTime[index].getHours() < 21
|
|
||||||
? 'day'
|
|
||||||
: 'night'}-{weatherCodes[weatherCodesHourly![index] as number]}.svg#Layer_1"
|
|
||||||
></use>
|
|
||||||
</svg></td
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- min / max -->
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
|
|
||||||
>Temp graph</th
|
|
||||||
>
|
|
||||||
{#each weather.indexes as index, j (j)}
|
|
||||||
{@const temp = weather.entries?.[0]?.values?.[index]}
|
|
||||||
|
|
||||||
{#if temp !== undefined && !isNaN(temp)}
|
|
||||||
<td
|
|
||||||
class={weather.hourlyTime[index].getDate() === today.getDate() &&
|
|
||||||
weather.hourlyTime[index].getHours() === today.getHours()
|
|
||||||
? 'now'
|
|
||||||
: ''}
|
|
||||||
style="position: absolute; bottom: {27.5 * entries -
|
|
||||||
49 +
|
|
||||||
0.8 * 200 -
|
|
||||||
0.55 * 200 * ((maxTemp! - temp!) / diffTemp!)}px; left:{111 +
|
|
||||||
(5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
|
|
||||||
(weather.entriesLength || 1)}px; max-width: {5000 /
|
|
||||||
(weather.entriesLength || 1)}px;">{temp?.toFixed(0)}</td
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{#each weather.entries as entry, i (i)}
|
|
||||||
<tr class="border-t border-border">
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
class="bg-background text-left"
|
|
||||||
style="left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
|
|
||||||
>{entry.title}</th
|
|
||||||
>
|
|
||||||
|
|
||||||
{#each weather.indexes as index, j (j)}
|
|
||||||
{#if entry.values && !isNaN(entry.values[index])}
|
|
||||||
<td
|
|
||||||
class="border-r border-border {weather.hourlyTime[index].getDate() ===
|
|
||||||
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
|
|
||||||
? 'now'
|
|
||||||
: ''}"
|
|
||||||
style="min-width: {5000 / (weather.entriesLength || 1)}px; max-width: {5000 /
|
|
||||||
(weather.entriesLength || 1)}px;
|
|
||||||
{entry.name === 'temperature_2m'
|
|
||||||
? 'background: ' +
|
|
||||||
getColor(
|
|
||||||
weather.entries[0].values![index].toFixed(0),
|
|
||||||
params.temperature_unit
|
|
||||||
)
|
|
||||||
: ''};
|
|
||||||
{entry.name === 'temperature_2m'
|
|
||||||
? 'color: ' +
|
|
||||||
(weather.entries[0].values![index] <
|
|
||||||
(params.temperature_unit === 'celsius' ? -13 : 7) ||
|
|
||||||
weather.entries[0].values![index] >=
|
|
||||||
(params.temperature_unit === 'celsius' ? 40 : 104)
|
|
||||||
? 'white'
|
|
||||||
: 'black')
|
|
||||||
: ''};
|
|
||||||
{entry.name === 'precipitation_probability'
|
|
||||||
? 'background: rgba(0, 0, 230,' +
|
|
||||||
weather.entries[2].values![index] / 120 +
|
|
||||||
')'
|
|
||||||
: ''};
|
|
||||||
{entry.name === 'precipitation_probability'
|
|
||||||
? 'color: ' +
|
|
||||||
(weather.entries[2].values![index] > 50
|
|
||||||
? 'white'
|
|
||||||
: 'hsl(var(--foreground)')
|
|
||||||
: ''};
|
|
||||||
{entry.name === 'relative_humidity_2m'
|
|
||||||
? 'background: rgba(0, 240, 240,' +
|
|
||||||
weather.entries[4].values![index] ** 3.8 / 10 ** 8.2 +
|
|
||||||
')'
|
|
||||||
: ''};"
|
|
||||||
>{entry.name === 'precipitation' || entry.name === 'temperature_2m'
|
|
||||||
? entry.values![index].toFixed(1)
|
|
||||||
: entry.values![index]}</td
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
{#if winddir}
|
|
||||||
<!-- winddir -->
|
|
||||||
<tr class="border-t border-border">
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
class="bg-background text-left"
|
|
||||||
style="z-index: 20; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
|
|
||||||
>Wind Dir.</th
|
|
||||||
>
|
|
||||||
{#each weather.indexes as index, j (j)}
|
|
||||||
{#if weather.windDirections && !isNaN(weather.windDirections[index])}
|
|
||||||
<td
|
|
||||||
class="border-r border-border {weather.hourlyTime[index].getDate() ===
|
|
||||||
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
|
|
||||||
? 'now'
|
|
||||||
: ''}"
|
|
||||||
style="transform: rotate({weather.windDirections![
|
|
||||||
index
|
|
||||||
]}deg);min-width: {5000 / (weather.entriesLength || 1)}px; max-width: {5000 /
|
|
||||||
(weather.entriesLength || 1)}px;"
|
|
||||||
><svg class="fill-foreground" width="25px" height="25px">
|
|
||||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
|
||||||
</svg></td
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#await weatherDaily then wd}
|
|
||||||
{@const sunrise = new Date(Number(wd.daily.sunrise.valuesInt64(selectedDayIndex)) * 1000)}
|
|
||||||
{@const sunset = new Date(Number(wd.daily.sunset.valuesInt64(selectedDayIndex)) * 1000)}
|
|
||||||
<div class="mt-6">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<svg class="fill-foreground" width="28px" height="28px">
|
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
|
|
||||||
</svg>Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<svg class="fill-foreground" width="28px" height="28px">
|
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
||||||
<div>
|
|
||||||
<div class="mt-6 flex gap-6 md:mt-12">
|
|
||||||
<div class="relative w-1/2">
|
|
||||||
{#if params.models && params.models.length > 0}
|
|
||||||
{@const modelValue = params.models[0]}
|
|
||||||
<Select.Root
|
|
||||||
name="model_selection"
|
|
||||||
type="single"
|
|
||||||
value={modelValue}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
if (params.models && val) {
|
|
||||||
params.models = [val];
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger
|
|
||||||
aria-label="Forecast days input"
|
|
||||||
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
|
|
||||||
>
|
|
||||||
<Select.Content preventScroll={false} class="border-border">
|
|
||||||
{#each models as mo (mo.value)}
|
|
||||||
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground"
|
|
||||||
>Weather model</Label
|
|
||||||
>
|
|
||||||
</Select.Root>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.now {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-week-icon {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background: #0061a5;
|
|
||||||
margin: 5px 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { getTempStyle } from '../../utils/colors';
|
||||||
|
import weatherCodes from '../../utils/weather-codes';
|
||||||
|
import { type FetchedDaily, type WeatherUnits, getDayLabel, getWindArrowRotation } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: FetchedDaily | null;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
onSelectDay: (date: Date, index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
function getDaylightSeconds(index: number): number {
|
||||||
|
if (!daily) return 0;
|
||||||
|
const sunriseTs = daily.daily.sunrise[index];
|
||||||
|
const sunsetTs = daily.daily.sunset[index];
|
||||||
|
if (!sunriseTs || !sunsetTs) return 0;
|
||||||
|
return Math.max(0, sunsetTs - sunriseTs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSunshinePercent(sunshineSeconds: number | null, daylightSeconds: number): number {
|
||||||
|
if (!sunshineSeconds || daylightSeconds <= 0) return 0;
|
||||||
|
return Math.min(100, (sunshineSeconds / daylightSeconds) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSunshineColor(sunshineSeconds: number | null, daylightSeconds: number): string {
|
||||||
|
if (daylightSeconds <= 0) return '#d1d5db';
|
||||||
|
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
|
||||||
|
if (ratio >= 0.7) return '#f59e0b';
|
||||||
|
if (ratio >= 0.45) return '#fbbf24';
|
||||||
|
if (ratio >= 0.2) return '#fcd34d';
|
||||||
|
return '#d1d5db';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
||||||
|
<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 tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
|
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||||
|
{@const wCode = daily.daily.weather_code[index]}
|
||||||
|
{@const sunDuration = daily.daily.sunshine_duration[index]}
|
||||||
|
{@const daylightSec = getDaylightSeconds(index)}
|
||||||
|
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
|
||||||
|
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
|
||||||
|
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||||
|
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||||
|
{@const gustMax = daily.daily.windgusts_10m_max[index]}
|
||||||
|
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||||
|
{@const unit = String(units.temperature_unit)}
|
||||||
|
{@const maxStyle = getTempStyle(tempMax, unit)}
|
||||||
|
{@const minStyle = getTempStyle(tempMin, unit)}
|
||||||
|
{#if tempMax != null && !isNaN(tempMax)}
|
||||||
|
<button
|
||||||
|
class="group flex min-w-[108px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-0.5 rounded-xl border-2 px-1.5 py-2 transition-all duration-200
|
||||||
|
{selected
|
||||||
|
? 'scale-[1.03] border-primary bg-accent shadow-md'
|
||||||
|
: 'border-transparent bg-card hover:bg-accent'}"
|
||||||
|
onclick={() => onSelectDay(time, index)}
|
||||||
|
>
|
||||||
|
<!-- Day label -->
|
||||||
|
<span class="text-sm font-bold tracking-wide">
|
||||||
|
{time.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{getDayLabel(time, today)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Weather icon -->
|
||||||
|
<div
|
||||||
|
class="my-1 flex w-full items-center justify-center rounded-lg py-1.5"
|
||||||
|
style="background: {sunColor}22"
|
||||||
|
>
|
||||||
|
<svg class="fill-foreground" width="48px" height="48px">
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
||||||
|
wCode as keyof typeof weatherCodes
|
||||||
|
] ?? 'clear'}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Temperature max/min -->
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<div
|
||||||
|
class="w-full rounded-t px-1 py-0.5 text-center text-[15px] font-bold"
|
||||||
|
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
|
||||||
|
>
|
||||||
|
{tempMax.toFixed(0)}°
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full rounded-b px-1 py-0.5 text-center text-xs font-semibold"
|
||||||
|
style="background-color: {minStyle.bg}; color: {minStyle.fg}"
|
||||||
|
>
|
||||||
|
{tempMin.toFixed(0)}°
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details section -->
|
||||||
|
<div class="mt-1 flex w-full flex-col items-center gap-0.5">
|
||||||
|
<!-- Sunshine bar -->
|
||||||
|
<div class="flex w-full items-center gap-1 px-1">
|
||||||
|
<svg class="shrink-0" width="14px" height="14px" style="fill: {sunColor}">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all"
|
||||||
|
style="width: {sunPct}%; background-color: {sunColor}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] font-medium text-muted-foreground">
|
||||||
|
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Precipitation -->
|
||||||
|
<div class="flex items-center gap-1 text-[11px]">
|
||||||
|
<svg class="fill-foreground shrink-0" width="14px" height="14px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{Number(precipSum ?? 0).toFixed(
|
||||||
|
precipSum >= 10 ? 0 : 1
|
||||||
|
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wind with direction -->
|
||||||
|
<div class="flex items-center gap-1 text-[11px]">
|
||||||
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
|
<div
|
||||||
|
class="inline-flex shrink-0"
|
||||||
|
style="transform: {getWindArrowRotation(windDir)}"
|
||||||
|
>
|
||||||
|
<svg class="fill-foreground" width="20px" height="20px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<svg class="fill-foreground shrink-0" width="20px" height="20px">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span>
|
||||||
|
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
|
||||||
|
>-{gustMax?.toFixed(0) ?? '-'}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
button {
|
||||||
|
min-width: 92px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button :global(svg[width='48px']) {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pad } from '$lib/utils/index';
|
||||||
|
|
||||||
|
import { getTempStyle } from '../../utils/colors';
|
||||||
|
import weatherCodes from '../../utils/weather-codes';
|
||||||
|
import {
|
||||||
|
type FetchedDaily,
|
||||||
|
type FetchedHourly,
|
||||||
|
type WeatherUnits,
|
||||||
|
getPrecipUnit,
|
||||||
|
getTempUnit,
|
||||||
|
getWindArrowRotation,
|
||||||
|
getWindUnit,
|
||||||
|
isCurrentHour
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FetchedHourly;
|
||||||
|
daily: FetchedDaily;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
locationName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, daily, selectedDay, units, locationName }: Props = $props();
|
||||||
|
|
||||||
|
let hourlyInterval = $state<1 | 3>(3);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const tempUnit = $derived(getTempUnit(units));
|
||||||
|
const windUnit = $derived(getWindUnit(units));
|
||||||
|
const precipUnit = $derived(getPrecipUnit(units));
|
||||||
|
let sunTimes = $derived(getSunTimes());
|
||||||
|
|
||||||
|
const PRECIP_MAX_MM = 10;
|
||||||
|
const PRECIP_MAX_INCH = 0.4;
|
||||||
|
|
||||||
|
let precipAbsMax = $derived(units.precipitation_unit === 'mm' ? PRECIP_MAX_MM : PRECIP_MAX_INCH);
|
||||||
|
|
||||||
|
function getSelectedDayDailyIndex(): number {
|
||||||
|
return findDailyIndex(selectedDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSunTimes(): { sunrise: Date; sunset: Date } | null {
|
||||||
|
const di = getSelectedDayDailyIndex();
|
||||||
|
if (di < 0) return null;
|
||||||
|
const rise = daily.daily.sunrise[di];
|
||||||
|
const set = daily.daily.sunset[di];
|
||||||
|
if (!rise || !set) return null;
|
||||||
|
return {
|
||||||
|
sunrise: new Date(rise * 1000),
|
||||||
|
sunset: new Date(set * 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToFraction(date: Date): number {
|
||||||
|
const totalMinutes = date.getHours() * 60 + date.getMinutes();
|
||||||
|
const firstMin = cellData[0].date.getHours() * 60;
|
||||||
|
const step = is3h ? 3 : 1;
|
||||||
|
const lastMin = cellData[cellData.length - 1].date.getHours() * 60 + step * 60;
|
||||||
|
const range = lastMin - firstMin;
|
||||||
|
if (range <= 0) return 0;
|
||||||
|
return Math.max(0, Math.min(1, (totalMinutes - firstMin) / range));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimezone(offsetSeconds: number): string {
|
||||||
|
const sign = offsetSeconds >= 0 ? '+' : '-';
|
||||||
|
const abs = Math.abs(offsetSeconds);
|
||||||
|
const hours = Math.floor(abs / 3600);
|
||||||
|
const minutes = Math.floor((abs % 3600) / 60);
|
||||||
|
return minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours}:${pad(minutes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timezoneLabel = $derived(formatTimezone(data.utc_offset_seconds));
|
||||||
|
|
||||||
|
function findDailyIndex(date: Date): number {
|
||||||
|
return daily.dailyDates.findIndex(
|
||||||
|
(dd) =>
|
||||||
|
dd.getDate() === date.getDate() &&
|
||||||
|
dd.getMonth() === date.getMonth() &&
|
||||||
|
dd.getFullYear() === date.getFullYear()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDaytime(hourDate: Date): boolean {
|
||||||
|
const di = findDailyIndex(hourDate);
|
||||||
|
if (di < 0) return true;
|
||||||
|
const sunrise = daily.daily.sunrise[di];
|
||||||
|
const sunset = daily.daily.sunset[di];
|
||||||
|
if (!sunrise || !sunset) return true;
|
||||||
|
const ts = Math.floor(hourDate.getTime() / 1000);
|
||||||
|
return ts >= sunrise && ts < sunset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayIndices(dates: Date[], day: Date): number[] {
|
||||||
|
return dates.reduce<number[]>((acc, d, i) => {
|
||||||
|
if (
|
||||||
|
d.getDate() === day.getDate() &&
|
||||||
|
d.getMonth() === day.getMonth() &&
|
||||||
|
d.getFullYear() === day.getFullYear() &&
|
||||||
|
(hourlyInterval === 1 || d.getHours() % 3 === 0)
|
||||||
|
) {
|
||||||
|
acc.push(i);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrecipBarHeight(val: number): number {
|
||||||
|
if (!val || val <= 0) return 0;
|
||||||
|
return Math.min(100, (val / precipAbsMax) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrecipProbBg(prob: number): string {
|
||||||
|
if (!prob || prob <= 0) return 'transparent';
|
||||||
|
return `rgba(30, 100, 220, ${(Math.round(prob / 10) / 100) * 10 * 0.45})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCloudOpacity(cover: number): number {
|
||||||
|
return Math.min(0.55, (cover ?? 0) / 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHumidityBg(hum: number): string {
|
||||||
|
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
|
||||||
|
if (precip != null && precip > 0) parts.push(`Amount: ${precip.toFixed(1)} ${precipUnit}`);
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemp(temp: number | null): string {
|
||||||
|
return temp != null ? `${temp.toFixed(0)}°` : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(val: number | null): string {
|
||||||
|
return val != null ? val.toFixed(0) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
let dayIdx = $derived(getDayIndices(data.hourlyDates, selectedDay));
|
||||||
|
let is3h = $derived(hourlyInterval === 3);
|
||||||
|
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]
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
let sunrisePercent = $derived(
|
||||||
|
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunrise) * 100 : null
|
||||||
|
);
|
||||||
|
let sunsetPercent = $derived(
|
||||||
|
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
|
||||||
|
);
|
||||||
|
|
||||||
|
function getWeatherIconName(code: number, daytime: boolean): string {
|
||||||
|
const prefix = daytime ? 'day' : 'night';
|
||||||
|
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
||||||
|
return `wi-${prefix}-${name}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet weatherIcon(name: string, size: number = 16)}
|
||||||
|
<svg class="inline-block fill-foreground" width={size} height={size}>
|
||||||
|
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet rowHeader(iconName?: string, unit?: string, label?: string)}
|
||||||
|
<th class="hdr" scope="row">
|
||||||
|
<div class="flex flex-col items-center leading-tight">
|
||||||
|
{#if iconName}
|
||||||
|
{@render weatherIcon(iconName)}
|
||||||
|
{/if}
|
||||||
|
{#if label}
|
||||||
|
<span class="text-[11px] font-semibold text-muted-foreground">{label}</span>
|
||||||
|
{/if}
|
||||||
|
{#if unit}
|
||||||
|
<span class="text-[10px] font-semibold text-muted-foreground">{unit}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} – Hourly
|
||||||
|
<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">
|
||||||
|
<span class="select-none text-muted-foreground">3h</span>
|
||||||
|
<button
|
||||||
|
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
||||||
|
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
||||||
|
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
||||||
|
title="Toggle between 1-hour and 3-hour intervals"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-[3px] size-[18px] rounded-full bg-white shadow-sm transition-[left] duration-200
|
||||||
|
{hourlyInterval === 1 ? 'left-[22px]' : 'left-[3px]'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
<span class="select-none text-muted-foreground">1h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if cellData.length > 0}
|
||||||
|
{@const hourly = data.hourly}
|
||||||
|
{@const iconPx = is3h ? 40 : 26}
|
||||||
|
<div class="overflow-hidden rounded-lg border border-border">
|
||||||
|
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||||
|
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col class="w-14 md:w-16" />
|
||||||
|
{#each cellData as _ (_.idx)}
|
||||||
|
<col />
|
||||||
|
{/each}
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
<!-- Time + Daylight bar (merged) -->
|
||||||
|
<tr class="!border-t-0">
|
||||||
|
<th class="hdr" scope="row">
|
||||||
|
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span>
|
||||||
|
</th>
|
||||||
|
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
|
||||||
|
<!-- Daylight background -->
|
||||||
|
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/30"
|
||||||
|
style="width:{sunrisePercent}%"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-400/10"
|
||||||
|
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/30"
|
||||||
|
style="width:{100 - sunsetPercent}%"
|
||||||
|
></div>
|
||||||
|
<!-- Sunrise marker + label -->
|
||||||
|
<div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%">
|
||||||
|
<span
|
||||||
|
class="absolute bottom-0.5 left-1 whitespace-nowrap text-[10px] font-semibold leading-none text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="fill-foreground inline-block"
|
||||||
|
width="12px"
|
||||||
|
height="12px"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Sunset marker + label -->
|
||||||
|
<div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%">
|
||||||
|
<span
|
||||||
|
class="absolute bottom-0.5 right-1 whitespace-nowrap text-[10px] font-semibold leading-none text-indigo-600 dark:text-indigo-300 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="fill-foreground inline-block"
|
||||||
|
width="12px"
|
||||||
|
height="12px"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Hour labels -->
|
||||||
|
{#each cellData as cell, i (cell.idx)}
|
||||||
|
{@const leftPct = (i / cellData.length) * 100}
|
||||||
|
{@const widthPct = 100 / cellData.length}
|
||||||
|
<span
|
||||||
|
class="absolute top-0 flex items-start pt-1 font-bold pl-0.5 text-sm
|
||||||
|
{cell.isNow ? 'text-destructive' : ''}"
|
||||||
|
style="left:{leftPct}%;width:{widthPct}%"
|
||||||
|
>
|
||||||
|
{#if is3h}
|
||||||
|
{pad(cell.date.getHours())}
|
||||||
|
{:else}
|
||||||
|
<span class="inline-flex items-baseline gap-1">
|
||||||
|
<span class="text-[11px] font-semibold">{pad(cell.date.getHours())}</span>
|
||||||
|
<sup
|
||||||
|
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
|
||||||
|
>00</sup
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Weather Icons -->
|
||||||
|
<tr>
|
||||||
|
{@render rowHeader('wi-day-cloudy')}
|
||||||
|
{#each cellData as cell, i (cell.idx)}
|
||||||
|
{@const wCode = hourly.weather_code[cell.idx]}
|
||||||
|
<td
|
||||||
|
class="cell leading-[0] {is3h ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}"
|
||||||
|
class:now={cell.isNow}
|
||||||
|
class:icon-day={cell.isDaytime}
|
||||||
|
class:icon-night={!cell.isDaytime}
|
||||||
|
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
||||||
|
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
|
||||||
|
>
|
||||||
|
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Temperature -->
|
||||||
|
<tr>
|
||||||
|
{@render rowHeader('wi-thermometer', tempUnit)}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||||
|
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||||
|
<td
|
||||||
|
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}"
|
||||||
|
class:now={cell.isNow}
|
||||||
|
style="background-color:{style.bg};color:{style.fg}"
|
||||||
|
>
|
||||||
|
{formatTemp(temp)}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Feels Like -->
|
||||||
|
<tr>
|
||||||
|
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||||
|
<td
|
||||||
|
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
|
||||||
|
class:now={cell.isNow}
|
||||||
|
>
|
||||||
|
{formatTemp(temp)}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Wind -->
|
||||||
|
<tr>
|
||||||
|
{@render rowHeader('wi-strong-wind', windUnit)}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||||
|
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||||
|
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}>
|
||||||
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
|
<span
|
||||||
|
class="inline-block leading-[0]"
|
||||||
|
style="transform:{getWindArrowRotation(windDir)}"
|
||||||
|
>
|
||||||
|
{@render weatherIcon('wi-direction-down', 24)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}">
|
||||||
|
{formatValue(wind)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Humidity -->
|
||||||
|
<tr>
|
||||||
|
{@render rowHeader('wi-humidity', '%')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||||
|
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}">
|
||||||
|
{formatValue(hum)}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Cloud Cover -->
|
||||||
|
<tr>
|
||||||
|
{@render rowHeader('wi-cloud', '%')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||||
|
<td
|
||||||
|
class="cell"
|
||||||
|
class:now={cell.isNow}
|
||||||
|
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||||
|
>
|
||||||
|
{formatValue(cloud)}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Precipitation -->
|
||||||
|
<tr>
|
||||||
|
{@render rowHeader('wi-raindrop', precipUnit)}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const precip = hourly.precipitation[cell.idx]}
|
||||||
|
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||||
|
<td
|
||||||
|
class="precip-cell {is3h ? 'h-14' : 'h-11'}"
|
||||||
|
class:now={cell.isNow}
|
||||||
|
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||||
|
title={formatPrecipTooltip(precip, prob)}
|
||||||
|
>
|
||||||
|
{#if precip > 0}
|
||||||
|
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
|
||||||
|
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
|
||||||
|
{precip.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
tr {
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base cell ──────────────────────────────────────────── */
|
||||||
|
.cell {
|
||||||
|
padding: 6px 2px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.now {
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Row header ─────────────────────────────────────────── */
|
||||||
|
.hdr {
|
||||||
|
padding: 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
border-right: 2px solid hsl(var(--border));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Precipitation ──────────────────────────────────────── */
|
||||||
|
.precip-cell {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-cell.now {
|
||||||
|
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 15%;
|
||||||
|
right: 15%;
|
||||||
|
min-height: 3px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: linear-gradient(to top, rgba(30, 120, 220, 0.5), rgba(30, 120, 220, 0.9));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(20, 60, 160, 0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .precip-label {
|
||||||
|
color: rgba(120, 180, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hdr {
|
||||||
|
padding: 3px 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
||||||
|
import { pad } from '$lib/utils/index';
|
||||||
|
|
||||||
|
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
||||||
|
import '$lib/components/charts/echarts.css';
|
||||||
|
|
||||||
|
import { getColor } from '../../utils/colors';
|
||||||
|
import {
|
||||||
|
type FetchedHourly,
|
||||||
|
type WeatherUnits,
|
||||||
|
getDayLabel,
|
||||||
|
getPrecipUnit,
|
||||||
|
getTempUnit,
|
||||||
|
getWindDirectionLabel,
|
||||||
|
getWindUnit
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FetchedHourly;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
loading: boolean;
|
||||||
|
onResetZoom?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
||||||
|
|
||||||
|
const CHART_GROUP = 'week-meteogram';
|
||||||
|
const MS_PER_DAY = 24 * 3600 * 1000;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
let showCharts = $state(false);
|
||||||
|
let chartComponents: EChart[] = $state([]);
|
||||||
|
let chartInstances: echarts.ECharts[] = $state([]);
|
||||||
|
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||||
|
|
||||||
|
export function scrollToDay(day: Date): void {
|
||||||
|
if (chartInstances.length === 0) return;
|
||||||
|
|
||||||
|
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
|
||||||
|
const dayEnd = dayStart + MS_PER_DAY;
|
||||||
|
const timestamps = data.timestamps;
|
||||||
|
const rangeStart = timestamps[0];
|
||||||
|
const rangeEnd = timestamps[timestamps.length - 1];
|
||||||
|
const totalRange = rangeEnd - rangeStart;
|
||||||
|
|
||||||
|
if (totalRange <= 0) return;
|
||||||
|
|
||||||
|
const startPct = Math.max(0, ((dayStart - rangeStart) / totalRange) * 100);
|
||||||
|
const endPct = Math.min(100, ((dayEnd - rangeStart) / totalRange) * 100);
|
||||||
|
|
||||||
|
for (const chart of chartInstances) {
|
||||||
|
if (chart && !chart.isDisposed()) {
|
||||||
|
chart.dispatchAction({ type: 'dataZoom', start: startPct, end: endPct });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom(): void {
|
||||||
|
for (const chart of chartInstances) {
|
||||||
|
if (chart && !chart.isDisposed()) {
|
||||||
|
chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onResetZoom?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChartReady(chart: echarts.ECharts): void {
|
||||||
|
chart.group = CHART_GROUP;
|
||||||
|
chartInstances = [...chartInstances, chart];
|
||||||
|
if (chartInstances.length === 3) {
|
||||||
|
echarts.connect(CHART_GROUP);
|
||||||
|
requestAnimationFrame(() => scrollToDay(selectedDay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Reset chart instances when data changes
|
||||||
|
if (data) {
|
||||||
|
chartInstances = [];
|
||||||
|
chartComponents = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const { hourly, utc_offset_seconds, timestamps, markAreas } = data;
|
||||||
|
const colors = getThemeColors();
|
||||||
|
const tempUnit = getTempUnit(units);
|
||||||
|
const precipUnit = getPrecipUnit(units);
|
||||||
|
const windUnit = getWindUnit(units);
|
||||||
|
|
||||||
|
const temps = hourly.temperature_2m;
|
||||||
|
const precip = hourly.precipitation;
|
||||||
|
const precipProb = hourly.precipitation_probability;
|
||||||
|
const cloudCov = hourly.cloud_cover;
|
||||||
|
const windSpeed = hourly.windspeed_10m;
|
||||||
|
const humidity = hourly.relative_humidity_2m;
|
||||||
|
|
||||||
|
const validTemps = temps.filter((t: number) => t !== null && !isNaN(t));
|
||||||
|
const minTemp = Math.min(...validTemps);
|
||||||
|
const maxTemp = Math.max(...validTemps);
|
||||||
|
|
||||||
|
const tempData = temps.map((v: number, i: number) => [timestamps[i], v]);
|
||||||
|
const cloudData = cloudCov.map((v: number, i: number) => [timestamps[i], v]);
|
||||||
|
const precipData = precip.map((v: number, i: number) => [timestamps[i], v]);
|
||||||
|
const precipProbData = precipProb.map((v: number, i: number) => [timestamps[i], v]);
|
||||||
|
const windData = windSpeed.map((v: number, i: number) => [timestamps[i], v]);
|
||||||
|
const humidityData = humidity.map((v: number, i: number) => [timestamps[i], v]);
|
||||||
|
|
||||||
|
const annotations = (): Array<Record<string, unknown>> => {
|
||||||
|
const series: Array<Record<string, unknown>> = [];
|
||||||
|
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
|
||||||
|
const dl = buildDaylightSeries({ markAreas });
|
||||||
|
if (dl) series.push(dl);
|
||||||
|
return series;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeXAxis = (showLabel: boolean): Record<string, unknown> => ({
|
||||||
|
type: 'time',
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLine: { lineStyle: { color: colors.axisLine } },
|
||||||
|
axisLabel: { color: colors.text, hideOverlap: true, show: showLabel },
|
||||||
|
axisTick: { lineStyle: { color: colors.axisLine } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const insideZoom = (): Record<string, unknown> => ({
|
||||||
|
type: 'inside',
|
||||||
|
xAxisIndex: 0,
|
||||||
|
filterMode: 'none',
|
||||||
|
zoomOnMouseWheel: true,
|
||||||
|
moveOnMouseMove: true,
|
||||||
|
moveOnMouseWheel: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderZoom = (): Record<string, unknown> => ({
|
||||||
|
type: 'slider',
|
||||||
|
xAxisIndex: 0,
|
||||||
|
filterMode: 'none',
|
||||||
|
height: 20,
|
||||||
|
bottom: 4,
|
||||||
|
borderColor: colors.axisLine,
|
||||||
|
fillerColor: 'rgba(100, 140, 200, 0.2)',
|
||||||
|
handleStyle: { color: colors.text },
|
||||||
|
textStyle: { color: colors.text, fontSize: 10 },
|
||||||
|
dataBackground: {
|
||||||
|
lineStyle: { color: colors.axisLine },
|
||||||
|
areaStyle: { color: colors.splitLine }
|
||||||
|
},
|
||||||
|
selectedDataBackground: {
|
||||||
|
lineStyle: { color: colors.axisLine },
|
||||||
|
areaStyle: { color: 'rgba(100, 140, 200, 0.15)' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipBase = (
|
||||||
|
formatter: (params: Record<string, unknown>[]) => string
|
||||||
|
): Record<string, unknown> => ({
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'cross',
|
||||||
|
animation: false,
|
||||||
|
label: {
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
color: colors.text,
|
||||||
|
borderColor: colors.tooltipBorder,
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
borderColor: colors.tooltipBorder,
|
||||||
|
textStyle: { color: colors.text },
|
||||||
|
formatter
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (ts: number): string => {
|
||||||
|
const date = new Date(ts);
|
||||||
|
return `<b>${date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} ${pad(date.getHours())}:${pad(date.getMinutes())}</b><br/>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAnnotation = (name: string): boolean => name === 'Daylight' || name === 'Current Time';
|
||||||
|
|
||||||
|
const tempOption: Record<string, unknown> = {
|
||||||
|
title: {
|
||||||
|
text: 'Temperature & Cloud Cover',
|
||||||
|
left: 'left',
|
||||||
|
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
||||||
|
},
|
||||||
|
tooltip: tooltipBase((params) => {
|
||||||
|
if (!params?.length) return '';
|
||||||
|
let html = formatDate(params[0].axisValue as number);
|
||||||
|
for (const item of params) {
|
||||||
|
const name = item.seriesName as string;
|
||||||
|
if (isAnnotation(name)) continue;
|
||||||
|
const val = (item.value as [number, number])?.[1];
|
||||||
|
if (val == null) continue;
|
||||||
|
if (name === 'Temperature')
|
||||||
|
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${tempUnit}</b><br/>`;
|
||||||
|
else if (name === 'Cloud Cover')
|
||||||
|
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}),
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
bottom: 0,
|
||||||
|
textStyle: { color: colors.text },
|
||||||
|
data: ['Temperature', 'Cloud Cover']
|
||||||
|
},
|
||||||
|
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
||||||
|
dataZoom: [insideZoom()],
|
||||||
|
xAxis: timeXAxis(false),
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: tempUnit,
|
||||||
|
nameTextStyle: { color: colors.text },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: colors.text },
|
||||||
|
splitLine: { lineStyle: { color: colors.splitLine } }
|
||||||
|
},
|
||||||
|
{ type: 'value', min: 0, max: 250, inverse: true, show: false }
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Temperature',
|
||||||
|
type: 'line',
|
||||||
|
data: tempData,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 3, color: '#ef6c00' },
|
||||||
|
itemStyle: { color: '#ef6c00' },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: getColor(maxTemp, String(units.temperature_unit)) + '88'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 0.5,
|
||||||
|
color: getColor((maxTemp + minTemp) / 2, String(units.temperature_unit)) + '44'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: getColor(minTemp, String(units.temperature_unit)) + '08'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
},
|
||||||
|
z: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cloud Cover',
|
||||||
|
type: 'line',
|
||||||
|
data: cloudData,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
lineStyle: { width: 0 },
|
||||||
|
itemStyle: { color: colors.text },
|
||||||
|
areaStyle: { color: 'rgba(150, 150, 150, 0.25)', origin: 'start' },
|
||||||
|
z: 1,
|
||||||
|
silent: true
|
||||||
|
},
|
||||||
|
...annotations()
|
||||||
|
],
|
||||||
|
textStyle: { color: colors.text }
|
||||||
|
};
|
||||||
|
|
||||||
|
const precipOption: Record<string, unknown> = {
|
||||||
|
title: {
|
||||||
|
text: 'Precipitation & Probability',
|
||||||
|
left: 'left',
|
||||||
|
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
||||||
|
},
|
||||||
|
tooltip: tooltipBase((params) => {
|
||||||
|
if (!params?.length) return '';
|
||||||
|
let html = formatDate(params[0].axisValue as number);
|
||||||
|
for (const item of params) {
|
||||||
|
const name = item.seriesName as string;
|
||||||
|
if (isAnnotation(name)) continue;
|
||||||
|
const val = (item.value as [number, number])?.[1];
|
||||||
|
if (val == null) continue;
|
||||||
|
if (name === 'Precipitation')
|
||||||
|
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${precipUnit}</b><br/>`;
|
||||||
|
else if (name === 'Precip. Probability')
|
||||||
|
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}),
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
bottom: 0,
|
||||||
|
textStyle: { color: colors.text },
|
||||||
|
data: ['Precipitation', 'Precip. Probability']
|
||||||
|
},
|
||||||
|
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
||||||
|
dataZoom: [insideZoom()],
|
||||||
|
xAxis: timeXAxis(false),
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: precipUnit,
|
||||||
|
min: 0,
|
||||||
|
nameTextStyle: { color: colors.text },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: colors.text },
|
||||||
|
splitLine: { lineStyle: { color: colors.splitLine } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '%',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
nameTextStyle: { color: colors.text },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: colors.text },
|
||||||
|
splitLine: { show: false }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Precipitation',
|
||||||
|
type: 'bar',
|
||||||
|
data: precipData,
|
||||||
|
barMaxWidth: 8,
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(30, 136, 229, 0.9)' },
|
||||||
|
{ offset: 1, color: 'rgba(30, 136, 229, 0.4)' }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
yAxisIndex: 0,
|
||||||
|
z: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Precip. Probability',
|
||||||
|
type: 'line',
|
||||||
|
data: precipProbData,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 2, type: 'dashed', color: '#5c6bc0' },
|
||||||
|
itemStyle: { color: '#5c6bc0' },
|
||||||
|
yAxisIndex: 1,
|
||||||
|
z: 4
|
||||||
|
},
|
||||||
|
...annotations()
|
||||||
|
],
|
||||||
|
textStyle: { color: colors.text }
|
||||||
|
};
|
||||||
|
|
||||||
|
const windOption: Record<string, unknown> = {
|
||||||
|
title: {
|
||||||
|
text: 'Wind Speed & Humidity',
|
||||||
|
left: 'left',
|
||||||
|
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
||||||
|
},
|
||||||
|
tooltip: tooltipBase((params) => {
|
||||||
|
if (!params?.length) return '';
|
||||||
|
let html = formatDate(params[0].axisValue as number);
|
||||||
|
for (const item of params) {
|
||||||
|
const name = item.seriesName as string;
|
||||||
|
if (isAnnotation(name)) continue;
|
||||||
|
const val = (item.value as [number, number])?.[1];
|
||||||
|
if (val == null) continue;
|
||||||
|
if (name === 'Wind Speed') {
|
||||||
|
const idx = timestamps.indexOf(
|
||||||
|
(params[0] as Record<string, unknown>).axisValue as number
|
||||||
|
);
|
||||||
|
const windDir = idx >= 0 ? hourly.winddirection_10m[idx] : null;
|
||||||
|
html += `${item.marker} ${name}: <b>${val.toFixed(0)} ${windUnit}</b>`;
|
||||||
|
if (windDir != null && !isNaN(windDir)) html += ` (${getWindDirectionLabel(windDir)})`;
|
||||||
|
html += '<br/>';
|
||||||
|
} else if (name === 'Humidity') {
|
||||||
|
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}),
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
bottom: 28,
|
||||||
|
textStyle: { color: colors.text },
|
||||||
|
data: ['Wind Speed', 'Humidity']
|
||||||
|
},
|
||||||
|
grid: { left: 60, right: 60, top: 50, bottom: 60 },
|
||||||
|
dataZoom: [insideZoom(), sliderZoom()],
|
||||||
|
xAxis: timeXAxis(true),
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: windUnit,
|
||||||
|
min: 0,
|
||||||
|
nameTextStyle: { color: colors.text },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: colors.text },
|
||||||
|
splitLine: { lineStyle: { color: colors.splitLine } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '%',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
nameTextStyle: { color: colors.text },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: colors.text },
|
||||||
|
splitLine: { show: false }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Wind Speed',
|
||||||
|
type: 'line',
|
||||||
|
data: windData,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 2, color: '#26a69a' },
|
||||||
|
itemStyle: { color: '#26a69a' },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(38, 166, 154, 0.25)' },
|
||||||
|
{ offset: 1, color: 'rgba(38, 166, 154, 0.02)' }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
yAxisIndex: 0,
|
||||||
|
z: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Humidity',
|
||||||
|
type: 'line',
|
||||||
|
data: humidityData,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 2, type: 'dotted', color: '#8d6e63' },
|
||||||
|
itemStyle: { color: '#8d6e63' },
|
||||||
|
yAxisIndex: 1,
|
||||||
|
z: 4
|
||||||
|
},
|
||||||
|
...annotations()
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
right: 10,
|
||||||
|
bottom: 30,
|
||||||
|
style: {
|
||||||
|
text: 'Open-Meteo.com',
|
||||||
|
fontSize: 10,
|
||||||
|
fill: colors.text,
|
||||||
|
opacity: 0.4
|
||||||
|
},
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
textStyle: { color: colors.text }
|
||||||
|
};
|
||||||
|
|
||||||
|
chartOptions = [tempOption, precipOption, windOption];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="charts-toggle-section">
|
||||||
|
<button class="charts-toggle-btn" onclick={() => (showCharts = !showCharts)}>
|
||||||
|
<span>Detailed Meteogram Charts</span>
|
||||||
|
<svg
|
||||||
|
class="toggle-chevron {showCharts ? 'open' : ''}"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCharts}
|
||||||
|
<div class="detailed-charts" in:fade={{ duration: 200 }}>
|
||||||
|
<div class="charts-header">
|
||||||
|
<h3 class="charts-title">
|
||||||
|
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
|
||||||
|
<small>
|
||||||
|
{getDayLabel(selectedDay, today) !==
|
||||||
|
selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })
|
||||||
|
? ` (${getDayLabel(selectedDay, today)})`
|
||||||
|
: ''}
|
||||||
|
</small>
|
||||||
|
</h3>
|
||||||
|
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}>
|
||||||
|
Show All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
||||||
|
{#each chartOptions as option, i (i)}
|
||||||
|
<EChart
|
||||||
|
{option}
|
||||||
|
height={i === 2 ? '320px' : '300px'}
|
||||||
|
onChartReady={handleChartReady}
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<div class="mt-6 md:mt-10">
|
||||||
|
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.charts-toggle-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-toggle-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius, 0.375rem);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-toggle-btn:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-chevron {
|
||||||
|
transition: transform 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-chevron.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-charts {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-reset-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius, 0.375rem);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 150ms ease,
|
||||||
|
background-color 150ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-reset-btn:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
|
||||||
|
import { models } from '../../options';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedModel: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selectedModel, onModelChange }: Props = $props();
|
||||||
|
|
||||||
|
let modelLabel = $derived(
|
||||||
|
models.find((mo) => String(mo.value) === selectedModel)?.label ?? selectedModel
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-6 flex gap-6 md:mt-12">
|
||||||
|
<div class="relative w-1/2">
|
||||||
|
<Select.Root
|
||||||
|
name="model_selection"
|
||||||
|
type="single"
|
||||||
|
value={selectedModel}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val) onModelChange(val);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
aria-label="Forecast model selection"
|
||||||
|
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3"
|
||||||
|
>
|
||||||
|
{modelLabel}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content preventScroll={false} class="border-border">
|
||||||
|
{#each models as mo (mo.value)}
|
||||||
|
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground">
|
||||||
|
Weather model
|
||||||
|
</Label>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pad } from '$lib/utils/index';
|
||||||
|
|
||||||
|
import type { FetchedDaily } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: FetchedDaily | null;
|
||||||
|
dayIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { daily, dayIndex }: Props = $props();
|
||||||
|
|
||||||
|
let sunrise = $derived.by(() => {
|
||||||
|
if (!daily) return null;
|
||||||
|
const ts = daily.daily.sunrise[dayIndex];
|
||||||
|
return ts ? new Date(ts * 1000) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
let sunset = $derived.by(() => {
|
||||||
|
if (!daily) return null;
|
||||||
|
const ts = daily.daily.sunset[dayIndex];
|
||||||
|
return ts ? new Date(ts * 1000) : null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if sunrise && sunset}
|
||||||
|
<div class="sun-info">
|
||||||
|
<div class="sun-item">
|
||||||
|
<svg class="fill-foreground" width="24px" height="24px">
|
||||||
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>{pad(sunrise.getHours())}:{pad(sunrise.getMinutes())}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sun-item">
|
||||||
|
<svg class="fill-foreground" width="24px" height="24px">
|
||||||
|
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
<span>{pad(sunset.getHours())}:{pad(sunset.getMinutes())}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sun-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { MarkArea, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
|
||||||
|
|
||||||
|
export interface WeatherUnits {
|
||||||
|
temperature_unit: string;
|
||||||
|
wind_speed_unit: string;
|
||||||
|
precipitation_unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchedHourly {
|
||||||
|
hourly: WeekHourlyData;
|
||||||
|
utc_offset_seconds: number;
|
||||||
|
timestamps: number[];
|
||||||
|
hourlyDates: Date[];
|
||||||
|
markAreas: MarkArea[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchedDaily {
|
||||||
|
daily: WeekDailyData;
|
||||||
|
dailyDates: Date[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTempUnit = (units: WeatherUnits): '°C' | '°F' => {
|
||||||
|
return units.temperature_unit === 'celsius' ? '°C' : '°F';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWindUnit = (units: WeatherUnits): string => {
|
||||||
|
return units.wind_speed_unit === 'kmh' ? 'km/h' : units.wind_speed_unit;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPrecipUnit = (units: WeatherUnits): 'mm' | 'in' => {
|
||||||
|
return units.precipitation_unit === 'mm' ? 'mm' : 'in';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWindArrowRotation = (deg: number): string => {
|
||||||
|
return `rotate(${deg}deg)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWindDirectionLabel = (deg: number): string => {
|
||||||
|
const dirs = [
|
||||||
|
'N',
|
||||||
|
'NNE',
|
||||||
|
'NE',
|
||||||
|
'ENE',
|
||||||
|
'E',
|
||||||
|
'ESE',
|
||||||
|
'SE',
|
||||||
|
'SSE',
|
||||||
|
'S',
|
||||||
|
'SSW',
|
||||||
|
'SW',
|
||||||
|
'WSW',
|
||||||
|
'W',
|
||||||
|
'WNW',
|
||||||
|
'NW',
|
||||||
|
'NNW'
|
||||||
|
];
|
||||||
|
return dirs[Math.round(deg / 22.5) % 16];
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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