From 53e5fb653889db6aeb1a338fe754408a54647b75 Mon Sep 17 00:00:00 2001 From: frederic Date: Mon, 16 Feb 2026 00:33:58 +0100 Subject: [PATCH] feat: canvas to echarts (#5) Co-authored-by: terraputix Reviewed-on: https://gitea.servert.ch/useweb/ombrella/pulls/5 --- src/lib/services/index.ts | 27 + src/lib/services/weather.ts | 744 ++++++++++++++++++ src/routes/weather/14-day/+page.svelte | 79 +- src/routes/weather/canvas/cloud-cover.ts | 78 -- src/routes/weather/canvas/daylight.ts | 20 - src/routes/weather/canvas/precip.ts | 20 - src/routes/weather/canvas/raster.ts | 42 - src/routes/weather/canvas/temp-gradient.ts | 60 -- src/routes/weather/compare/+page.svelte | 56 +- src/routes/weather/options.ts | 1 - src/routes/weather/utils/colors.ts | 73 +- .../weather/utils/colour-gradient.ipynb | 191 ----- .../weather/week/[location]/+page.svelte | 730 +++-------------- .../weather/week/[location]/DailyCards.svelte | 178 +++++ .../week/[location]/HourlyTable.svelte | 524 ++++++++++++ .../week/[location]/MeteogramCharts.svelte | 593 ++++++++++++++ .../week/[location]/ModelSelector.svelte | 45 ++ .../weather/week/[location]/SunInfo.svelte | 57 ++ src/routes/weather/week/[location]/types.ts | 80 ++ 19 files changed, 2449 insertions(+), 1149 deletions(-) create mode 100644 src/lib/services/index.ts create mode 100644 src/lib/services/weather.ts delete mode 100644 src/routes/weather/canvas/cloud-cover.ts delete mode 100644 src/routes/weather/canvas/daylight.ts delete mode 100644 src/routes/weather/canvas/precip.ts delete mode 100644 src/routes/weather/canvas/raster.ts delete mode 100644 src/routes/weather/canvas/temp-gradient.ts delete mode 100644 src/routes/weather/utils/colour-gradient.ipynb create mode 100644 src/routes/weather/week/[location]/DailyCards.svelte create mode 100644 src/routes/weather/week/[location]/HourlyTable.svelte create mode 100644 src/routes/weather/week/[location]/MeteogramCharts.svelte create mode 100644 src/routes/weather/week/[location]/ModelSelector.svelte create mode 100644 src/routes/weather/week/[location]/SunInfo.svelte create mode 100644 src/routes/weather/week/[location]/types.ts diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts new file mode 100644 index 0000000..a159018 --- /dev/null +++ b/src/lib/services/index.ts @@ -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'; diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts new file mode 100644 index 0000000..1cffa3e --- /dev/null +++ b/src/lib/services/weather.ts @@ -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; +} + +export interface ModelCompareResult { + models: ModelSeriesData[]; + timestamps: number[]; + utcOffsetSeconds: number; + markAreas: MarkArea[]; + units: Record; + /** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */ + hourlyFlat: Record; + hourlyUnitsFlat: Record; +} + +// ─── 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; + timestamps: number[]; + utcOffsetSeconds: number; + markAreas: MarkArea[]; + /** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */ + hourlyFlat: Record; + hourlyUnitsFlat: Record; +} + +// ─── 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 { + 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 = { + 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 = {}; + 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 { + const forecastApiParams: Record = { + 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 = {}; + const hourlyUnitsFlat: Record = {}; + const units: Record = {}; + + // 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 { + const forecastDays = params.forecast_days ?? 14; + + const ensembleParams: Record = { + 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 = { + 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 = {}; + const hourlyFlat: Record = {}; + const hourlyUnitsFlat: Record = {}; + + // 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(timeLength).fill(0); + const min = new Array(timeLength).fill(Infinity); + const max = new Array(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 = { + 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}`; +} diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index 60b6772..1500798 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -7,14 +7,9 @@ import { buildAverageSeries, buildCurrentTimeSeries, - buildDaylightMarkAreas, buildDaylightSeries, buildSpreadSeries, - calculateAverage, - calculateSpread, composeChartOption, - convertTimestamps, - findUnit, getThemeColors } from '$lib/utils/echarts'; @@ -23,6 +18,12 @@ import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; + import { + type EnsembleForecastResult, + type MarkArea, + fetchEnsembleForecast + } from '$lib/services/weather'; + import { defaultParameters } from '../options'; import type * as echarts from 'echarts'; @@ -52,11 +53,10 @@ // ─── Cached API Response ──────────────────────────────────────────────────── interface FetchedData { - hourly: Record; - hourly_units: Record; - utc_offset_seconds: number; - markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + ensembleResult: EnsembleForecastResult; timestamps: number[]; + utc_offset_seconds: number; + markAreas: MarkArea[]; } let fetchedData: FetchedData | null = $state(null); @@ -92,35 +92,22 @@ chartInstances = []; chartComponents = []; - const [dataDaily, dataReq] = await Promise.all([ - fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14` - ), - fetch( - `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` - ) - ]); - - 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); + const result: EnsembleForecastResult = await fetchEnsembleForecast({ + latitude: location.latitude!, + longitude: location.longitude!, + hourlyVariables: hourlyVars, + models: modelList, + 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' + }); fetchedData = { - hourly: data.hourly, - hourly_units: data.hourly_units, - utc_offset_seconds: data.utc_offset_seconds, - markAreas, - timestamps + ensembleResult: result, + timestamps: result.timestamps, + utc_offset_seconds: result.utcOffsetSeconds, + markAreas: result.markAreas }; loading = false; @@ -134,32 +121,26 @@ $effect(() => { if (!fetchedData) return; - const { - hourly: hourlyData, - hourly_units, - utc_offset_seconds, - markAreas, - timestamps - } = fetchedData; + const { ensembleResult, timestamps, utc_offset_seconds, markAreas } = fetchedData; const _showLegend = showLegend; const colors = getThemeColors(); const variableCount = params.hourly?.length || 0; - const timeLength = (hourlyData.time as number[]).length; const newOptions: Array> = []; for (let vi = 0; vi < variableCount; 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 { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength); + const unit = varData.unit; + const { average, min: minValues, max: maxValues } = varData; const series: Array> = []; const spreadData: Array<[number, number, number]> = minValues.map( - (min, index) => - [timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number] + (minVal, index) => + [timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number] ); series.push(...buildSpreadSeries({ variable, spreadData })); diff --git a/src/routes/weather/canvas/cloud-cover.ts b/src/routes/weather/canvas/cloud-cover.ts deleted file mode 100644 index 228e58f..0000000 --- a/src/routes/weather/canvas/cloud-cover.ts +++ /dev/null @@ -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(); - } -}; diff --git a/src/routes/weather/canvas/daylight.ts b/src/routes/weather/canvas/daylight.ts deleted file mode 100644 index fa9cf63..0000000 --- a/src/routes/weather/canvas/daylight.ts +++ /dev/null @@ -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(); - } - } - } -}; diff --git a/src/routes/weather/canvas/precip.ts b/src/routes/weather/canvas/precip.ts deleted file mode 100644 index 53e9044..0000000 --- a/src/routes/weather/canvas/precip.ts +++ /dev/null @@ -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(); - } - } -}; diff --git a/src/routes/weather/canvas/raster.ts b/src/routes/weather/canvas/raster.ts deleted file mode 100644 index d14b623..0000000 --- a/src/routes/weather/canvas/raster.ts +++ /dev/null @@ -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(); - } - } -}; diff --git a/src/routes/weather/canvas/temp-gradient.ts b/src/routes/weather/canvas/temp-gradient.ts deleted file mode 100644 index 1b44a24..0000000 --- a/src/routes/weather/canvas/temp-gradient.ts +++ /dev/null @@ -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(); - } -}; diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index 6a1172d..9d39cd8 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -8,12 +8,10 @@ import { buildAverageSeries, buildCurrentTimeSeries, - buildDaylightMarkAreas, buildDaylightSeries, buildModelSeries, calculateAverage, composeChartOption, - convertTimestamps, findUnit, getThemeColors } from '$lib/utils/echarts'; @@ -24,6 +22,12 @@ import { Label } from '$lib/components/ui/label'; 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 { defaultParameters } from '../options'; @@ -65,7 +69,7 @@ hourly: Record; hourly_units: Record; utc_offset_seconds: number; - markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + markAreas: MarkArea[]; timestamps: number[]; } @@ -102,38 +106,22 @@ chartInstances = []; chartComponents = []; - const dataReq = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise` - ); - const data = await dataReq.json(); - - let markAreas: FetchedData['markAreas'] = []; - - if ('daily' in data) { - 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); + const result: ModelCompareResult = await fetchModelComparison({ + latitude: location.latitude!, + longitude: location.longitude!, + hourlyVariables: hourlyVars, + models: modelList, + temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit', + wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn', + precipitation_unit: params.precipitation_unit as 'mm' | 'inch' + }); fetchedData = { - hourly: data.hourly, - hourly_units: data.hourly_units, - utc_offset_seconds: data.utc_offset_seconds, - markAreas, - timestamps + hourly: result.hourlyFlat, + hourly_units: result.hourlyUnitsFlat, + utc_offset_seconds: result.utcOffsetSeconds, + markAreas: result.markAreas, + timestamps: result.timestamps }; loading = false; @@ -158,7 +146,7 @@ const colors = getThemeColors(); const variableCount = params.hourly?.length || 0; - const timeLength = (hourlyData.time as number[]).length; + const timeLength = timestamps.length; const newOptions: Array> = []; for (let vi = 0; vi < variableCount; vi++) { diff --git a/src/routes/weather/options.ts b/src/routes/weather/options.ts index a58f330..7e52522 100644 --- a/src/routes/weather/options.ts +++ b/src/routes/weather/options.ts @@ -13,7 +13,6 @@ export const models = [ { value: 'icon_seamless', label: 'DWD ICON Seamless' }, { value: 'gem_seamless', label: 'GEM 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: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' }, { value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' }, diff --git a/src/routes/weather/utils/colors.ts b/src/routes/weather/utils/colors.ts index 777f8fc..d10374e 100644 --- a/src/routes/weather/utils/colors.ts +++ b/src/routes/weather/utils/colors.ts @@ -1,11 +1,11 @@ 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)); 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*\)$/; let result, r, @@ -23,29 +23,58 @@ export function rgbToHex(rgb: string) { return '355522'; } 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; - const temp = Number(tempString); - if (unit === 'celsius') { - if (temp <= -40) { - index = 0; - } else if (temp >= 60) { - index = colorScaleHex.length - 1; - } else { - index = temp + 40; - } + if (temperature <= -40) { + index = 0; + } else if (temperature >= 60) { + index = colorScaleHex.length - 1; } 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; - } + index = Math.round(temperature) + 45; } 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; +}; diff --git a/src/routes/weather/utils/colour-gradient.ipynb b/src/routes/weather/utils/colour-gradient.ipynb deleted file mode 100644 index 3f25f7d..0000000 --- a/src/routes/weather/utils/colour-gradient.ipynb +++ /dev/null @@ -1,191 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "8c554ac3-6dae-4561-b493-32943137a3ea", - "metadata": {}, - "source": [ - "## Generating colours" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "id": "ba97e928-85be-46bd-9429-e69f89c3ce13", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import json\n", - "from colour import Color\n", - "\n", - "def flatten(xss):\n", - " return [x for xs in xss for x in xs]\n", - "\n", - "deep_purple = Color(\"#4F00A3\")\n", - "dark_blue = Color(\"#0117DB\")\n", - "light_blue = Color(\"#01B1FF\")\n", - "light_green = Color(\"#00FFC2\")\n", - "dark_green = Color(\"#00D139\")\n", - "warm_green = Color(\"#FFFF00\")\n", - "light_orange = Color(\"#FFC600\")\n", - "middle_orange = Color(\"#FFA200\")\n", - "dark_orange = Color(\"#FF640A\")\n", - "deep_red = Color(\"#9E1500\")\n", - "\n", - "colors = [\n", - " list(deep_purple.range_to(dark_blue,15)),\n", - " list(dark_blue.range_to(light_blue,13)),\n", - " list(light_blue.range_to(light_green,13)),\n", - " list(light_green.range_to(dark_green,8)),\n", - " list(dark_green.range_to(warm_green,14)),\n", - " list(warm_green.range_to(light_orange,8)),\n", - " list(light_orange.range_to(middle_orange,9)),\n", - " list(middle_orange.range_to(dark_orange,10)),\n", - " list(dark_orange.range_to(deep_red,11)),\n", - "]\n", - "\n", - "colors = flatten(colors)\n", - "\n", - "color_list = []\n", - "rgb_list = []\n", - "hsl_list = []\n", - "hex_list = []\n", - "\n", - "temp_x= []\n", - "temp_height= []\n", - "\n", - "for [ind, color] in enumerate(colors):\n", - " x = -40 + ind\n", - " color_list.append(color.hex)\n", - " hex_list.append(color.hex)\n", - " rgb_list.append(color.rgb)\n", - " hsl_list.append(color.hsl)\n", - " temp_x.append(x)\n", - " temp_height.append(1)" - ] - }, - { - "cell_type": "markdown", - "id": "248a5151-772d-43e7-8fde-5d58511f30e8", - "metadata": {}, - "source": [ - "## Visualisation" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "id": "40b96c47-6025-41d1-a2cc-a46d9233efcf", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 37, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "4bf8047db9cf4060b54ccfff7857fbcb", - "version_major": 2, - "version_minor": 0 - }, - "image/png": "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BADA2OgAAgAkIwACACQjAAIAJCMAAgAkIwACACQjAAIAJOMxMEXYORyVoeFqVwEAdUSPajz5dAEAkhEAAQCSMQIuQnlvxJ691a4CAOqIHtV48ukCACQjAAIAJGMEXISdRsAAUKzGahdQ13QAAQCSEQABAJIRAAEAkrEGsAg790bstgYQAKgNOoAAAMkIgAAAyRgBF+ENI2AAoHboAAIAJCMAAgAkYwRchJ1D0dA0VO0qAADGRAcQACCZugyAL7/8cixdujTmz58fkydPjsMOOyyuvPLK2L1796jznn322TjppJOiubk5Ojo64tprr61azQAA+0tdjoA3b94cw8PDceutt8b73//+2LRpUyxbtix27doV1113XURElMvlOP3006OrqyvWrVsXzz33XFxwwQUxY8aMuPDCC6v9KwAAjJuGSqVSqXYR+8N3v/vdWLt2bbz00ksREbF27dq44ooroq+vL5qamiIi4vLLL4/7778/Nm/ePKZrlsvlaG1tjfiv/zsaGqePa/0AkMnwT+aM27Xf/v4eGBiIlpaWcXuff2V1OQLel4GBgZg1a9bI697e3jj55JNHwl9ExOLFi2PLli3x2muvValKAIDxlyIAvvjii3HzzTfHl770pZF9fX190dbWNuq8t1/39fXt8zqDg4NRLpdHbQAAtaam1gBefvnlcc011/zDc37/+9/HggULRl5v27YtPvnJT8ZZZ50Vy5Yte0/vv3r16ujp6XnngTf2Rkzyl0AAgNpQU2sAd+zYEX/+85//4TmHHnroyFh3+/btccopp8QJJ5wQP/jBD2LChH9veH7xi1+Mcrkc999//8i+xx57LE477bT4y1/+EjNnznzHtQcHB2NwcHDkdblcjo6OjohT/xANk6wBBICiDD/cPm7XtgawxjqAc+bMiTlzxrYodNu2bXHqqafGwoULY/369aPCX0REZ2dnXHHFFbFnz55obGyMiIhHHnkkjjjiiH2Gv4iIUqkUpVKpgN8EAKB6aioAjtW2bdvilFNOiUMOOSSuu+662LFjx8ix9vb/9y+Ks88+O3p6emLp0qVx2WWXxaZNm+LGG2+MNWvWvPs3NAIGAGpIXQbARx55JF588cV48cUXY968eaOOvT3xbm1tjYcffji6u7tj4cKFccABB8SqVas8AxAAqHs1tQbwX83IcwCP+701gABQoOFf/adxu7Y1gEkeAwMAwL+ryxHwfvfXvRETrQEEAGqDDiAAQDICIABAMkbARdi1N2KCETAAUBt0AAEAkhEAAQCSMQIughEwAFBDdAABAJIRAAEAkhEAAQCSEQABAJIRAAEAkhEAAQCS8RiYIry5N6JhqNpVAACMiQ4gAEAyAiAAQDJGwEX465ARMABQM3QAAQCSEQABAJIxAi5A496haDACBgBqhA4gAEAyAiAAQDICIABAMtYAFqA5hqIhrAEEAGqDDiAAQDICIABAMkbABSjF3phgBAwA1AgdQACAZARAAIBkjIAL0BxDRsAAQM3QAQQASEYABABIRgAEAEjGGsACNMVQTKxYAwgA1AYdQACAZARAAIBkjIALUIqhmOgxMABAjdABBABIRgAEAEjGCLgARsAAQC3RAQQASEYABABIRgAEAEjGGsACNMbemGQNIABQI3QAAQCSEQABAJIxAi5AUwwZAQMANUMHEAAgGQEQACAZI+ACGAEDALVEBxAAIBkBEAAgGQEQACAZawALMCmGotEaQACgRugAAgAkU/cBcHBwMI455phoaGiIZ555ZtSxZ599Nk466aRobm6Ojo6OuPbaa6tWJwDA/lL3I+BLL7005s6dG7/73e9G7S+Xy3H66adHV1dXrFu3Lp577rm44IILYsaMGXHhhRe+q/dojL1GwABAzajrAPjggw/Gww8/HD/5yU/iwQcfHHXsrrvuit27d8cdd9wRTU1N8YEPfCCeeeaZuP766991AAQAqCV1OwLu7++PZcuWxY9+9KOYMmXKO4739vbGySefHE1NTSP7Fi9eHFu2bInXXnttn9ccHByMcrk8agMAqDV12QGsVCpx3nnnxZe//OVYtGhRvPzyy+84p6+vL+bPnz9qX1tb28ixmTNnvuNnVq9eHT09Pe/Y/z8HPhctLS2F/g4AAOOlpjqAl19+eTQ0NPzDbfPmzXHzzTfHzp07Y+XKlYW+/8qVK2NgYGBk27p1a6HXBwDYH2qqA/j1r389zjvvvH94zqGHHhqPPvpo9Pb2RqlUGnVs0aJFcc4558Sdd94Z7e3t0d/fP+r426/b29v3ee1SqfSOawIA1JqaCoBz5syJOXPm/Ifn3XTTTXHVVVeNvN6+fXssXrw47rnnnjj++OMjIqKzszOuuOKK2LNnTzQ2NkZExCOPPBJHHHHEPse/AAD1oqYC4FgdfPDBo15PmzYtIiIOO+ywmDdvXkREnH322dHT0xNLly6Nyy67LDZt2hQ33nhjrFmzpio1AwDsL3UZAMeitbU1Hn744eju7o6FCxfGAQccEKtWrfIIGACg7jVUKpVKtYuoVeVyOVpbW2NgYMBdwABQI3x/19hdwAAAvHcCIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyaf8UXBHe/iMq5XK52qUAAGP09vd25j+GJgC+Bzt37oyIiI6OjmqXAgC8Szt37ozW1tZql1EV/hbwezA8PBzbt2+P6dOnR0NDQ7XLAQDGoFKpxM6dO2Pu3LkxYULO1XACIABAMjljLwBAYgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAyAiAAQDICIABAMgIgAEAy/xdOvzqG8oXciwAAAABJRU5ErkJggg==", - "text/html": [ - "\n", - "
\n", - "
\n", - " Figure\n", - "
\n", - " \n", - "
\n", - " " - ], - "text/plain": [ - "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%matplotlib ipympl\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "\n", - "fig, ax = plt.subplots()\n", - "ax.axes.get_xaxis().set_visible(False)\n", - "ax.barh(temp_x, temp_height, color=list(color_list), align='edge', height=1)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "id": "5f0ba609-0885-42d3-9c6c-a1f3921aa5d7", - "metadata": {}, - "outputs": [], - "source": [ - "plt.close()" - ] - }, - { - "cell_type": "markdown", - "id": "7d242bca-18b1-4548-8118-c82110b56b57", - "metadata": {}, - "source": [ - "## Exporting" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "id": "9f338b99-a190-4025-8491-388094ced8f2", - "metadata": {}, - "outputs": [], - "source": [ - "# os.chdir(pwd)\n", - "\n", - "with open(\"color-scale-rgb.ts\", \"w+\") as f:\n", - " f.write(\"export default \")\n", - " f.write(json.dumps(hsl_list))\n", - "\n", - "with open(\"color-scale-hsl.ts\", \"w+\") as f:\n", - " f.write(\"export default \")\n", - " f.write(json.dumps(rgb_list))\n", - " \n", - "with open(\"color-scale-hex.ts\", \"w+\") as f:\n", - " f.write(\"export default \")\n", - " f.write(json.dumps(hex_list))" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.2" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 79799b0..f43e8cd 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -1,25 +1,19 @@ Weather | Open-Meteo.com - + -
+
-
- {#await weatherDaily then wd} - {#each wd.daily.time as time, index (index)} - {@const selected = time.getDate() === selectedDay.getDate()} - {#if wd.daily.temperature_2m_max.values(index) != null && !isNaN(Number(wd.daily.temperature_2m_max - .values(index)! - .toFixed(1)))} - - {/if} - {/each} - {:catch error} -

{error.message}

- {/await} -
-
-

- {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} - - {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)' - : ''} - -

-
-
- - - - - {#await weather then weather} - - - {#each weather.indexes as index, j (j)} - - {/each} - - - - - {#each weather.indexes as index, j (j)} - {@const now = - weather.hourlyTime[index].getDate() === today.getDate() && - weather.hourlyTime[index].getHours() === today.getHours()} - - {/each} - - - - - - {#each weather.indexes as index, j (j)} - {@const temp = weather.entries?.[0]?.values?.[index]} - - {#if temp !== undefined && !isNaN(temp)} - - {/if} - {/each} - - {#each weather.entries as entry, i (i)} - - - - {#each weather.indexes as index, j (j)} - {#if entry.values && !isNaN(entry.values[index])} - - {/if} - {/each} - - {/each} - {#if winddir} - - - - {#each weather.indexes as index, j (j)} - {#if weather.windDirections && !isNaN(weather.windDirections[index])} - - {/if} - {/each} - - {/if} - {/await} - -
Weather Week {location.name}
Time{weather.hourlyTime[index].getHours() < 10 ? '0' : ''}{weather.hourlyTime[ - index - ].getHours()}
Icons - -
Temp graph{temp?.toFixed(0)}
{entry.title}{entry.name === 'precipitation' || entry.name === 'temperature_2m' - ? entry.values![index].toFixed(1) - : entry.values![index]}
Wind Dir. - -
-
-
- {#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)} -
-
- - - Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} -
-
- - - - Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())} -
-
- {/await} -
-
-
- {#if params.models && params.models.length > 0} - {@const modelValue = params.models[0]} - { - if (params.models && val) { - params.models = [val]; - } - }} - > - {modelSelected?.label} - - {#each models as mo (mo.value)} - {mo.label} - {/each} - - - - {/if} -
-
+ { + params.models = [model]; + }} + />
- - diff --git a/src/routes/weather/week/[location]/DailyCards.svelte b/src/routes/weather/week/[location]/DailyCards.svelte new file mode 100644 index 0000000..688cc31 --- /dev/null +++ b/src/routes/weather/week/[location]/DailyCards.svelte @@ -0,0 +1,178 @@ + + +
+
+ {#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)} + + {/if} + {/each} + {/if} +
+
+ + diff --git a/src/routes/weather/week/[location]/HourlyTable.svelte b/src/routes/weather/week/[location]/HourlyTable.svelte new file mode 100644 index 0000000..1a99ba2 --- /dev/null +++ b/src/routes/weather/week/[location]/HourlyTable.svelte @@ -0,0 +1,524 @@ + + +{#snippet weatherIcon(name: string, size: number = 16)} + + + +{/snippet} + +{#snippet rowHeader(iconName?: string, unit?: string, label?: string)} + +
+ {#if iconName} + {@render weatherIcon(iconName)} + {/if} + {#if label} + {label} + {/if} + {#if unit} + {unit} + {/if} +
+ +{/snippet} + + +
+

+ {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} – Hourly + ({timezoneLabel}) +

+
+ 3h + + 1h +
+
+ +{#if cellData.length > 0} + {@const hourly = data.hourly} + {@const iconPx = is3h ? 40 : 26} +
+ + + + + {#each cellData as _ (_.idx)} + + {/each} + + + + + + + + + + + {@render rowHeader('wi-day-cloudy')} + {#each cellData as cell, i (cell.idx)} + {@const wCode = hourly.weather_code[cell.idx]} + + {/each} + + + + + {@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))} + + {/each} + + + + + {@render rowHeader(undefined, tempUnit, 'Feels')} + {#each cellData as cell (cell.idx)} + {@const temp = hourly.apparent_temperature[cell.idx]} + + {/each} + + + + + {@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]} + + {/each} + + + + + {@render rowHeader('wi-humidity', '%')} + {#each cellData as cell (cell.idx)} + {@const hum = hourly.relative_humidity_2m[cell.idx]} + + {/each} + + + + + {@render rowHeader('wi-cloud', '%')} + {#each cellData as cell (cell.idx)} + {@const cloud = hourly.cloud_cover[cell.idx]} + + {/each} + + + + + {@render rowHeader('wi-raindrop', precipUnit)} + {#each cellData as cell (cell.idx)} + {@const precip = hourly.precipitation[cell.idx]} + {@const prob = hourly.precipitation_probability[cell.idx]} + + {/each} + + +
Hourly weather details for {locationName}
+ {timezoneLabel} + + + {#if sunTimes && sunrisePercent != null && sunsetPercent != null} +
+
+
+ +
+ + + {formatTime(sunTimes.sunrise)} + +
+ +
+ + + {formatTime(sunTimes.sunset)} + +
+ {/if} + + {#each cellData as cell, i (cell.idx)} + {@const leftPct = (i / cellData.length) * 100} + {@const widthPct = 100 / cellData.length} + + {#if is3h} + {pad(cell.date.getHours())} + {:else} + + {pad(cell.date.getHours())} + 00 + + {/if} + + {/each} +
+ {@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)} +
+ {formatTemp(temp)} +
+ {formatTemp(temp)} +
+ {#if windDir != null && !isNaN(windDir)} + + {@render weatherIcon('wi-direction-down', 24)} + + {/if} + + {formatValue(wind)} + +
+ {formatValue(hum)} +
+ {formatValue(cloud)} +
+ {#if precip > 0} +
+ + {precip.toFixed(1)} + + {/if} +
+
+{/if} + + diff --git a/src/routes/weather/week/[location]/MeteogramCharts.svelte b/src/routes/weather/week/[location]/MeteogramCharts.svelte new file mode 100644 index 0000000..a5640f7 --- /dev/null +++ b/src/routes/weather/week/[location]/MeteogramCharts.svelte @@ -0,0 +1,593 @@ + + +
+ +
+ +{#if showCharts} +
+
+

+ {selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })} + + {getDayLabel(selectedDay, today) !== + selectedDay.toLocaleDateString('en-GB', { weekday: 'long' }) + ? ` (${getDayLabel(selectedDay, today)})` + : ''} + +

+ +
+ + + {#each chartOptions as option, i (i)} + + {/each} + + +
+ +
+
+{/if} + + diff --git a/src/routes/weather/week/[location]/ModelSelector.svelte b/src/routes/weather/week/[location]/ModelSelector.svelte new file mode 100644 index 0000000..5ef3c89 --- /dev/null +++ b/src/routes/weather/week/[location]/ModelSelector.svelte @@ -0,0 +1,45 @@ + + +
+
+ { + if (val) onModelChange(val); + }} + > + + {modelLabel} + + + {#each models as mo (mo.value)} + {mo.label} + {/each} + + + +
+
diff --git a/src/routes/weather/week/[location]/SunInfo.svelte b/src/routes/weather/week/[location]/SunInfo.svelte new file mode 100644 index 0000000..f93a009 --- /dev/null +++ b/src/routes/weather/week/[location]/SunInfo.svelte @@ -0,0 +1,57 @@ + + +{#if sunrise && sunset} +
+
+ + + + {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} +
+
+ + + + {pad(sunset.getHours())}:{pad(sunset.getMinutes())} +
+
+{/if} + + diff --git a/src/routes/weather/week/[location]/types.ts b/src/routes/weather/week/[location]/types.ts new file mode 100644 index 0000000..cc2be44 --- /dev/null +++ b/src/routes/weather/week/[location]/types.ts @@ -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() + ); +};