diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 1d5b913..a159018 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -7,7 +7,6 @@ export { getDates, getValues, getInt64Values, - extractValues, unitToDisplayString } from './weather'; diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index 8c09492..417b470 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -175,6 +175,8 @@ export interface WeekHourlyData { winddirection_10m: number[]; cloud_cover: number[]; relative_humidity_2m: number[]; + apparent_temperature: number[]; + dew_point_2m: number[]; } export interface WeekDailyData { @@ -259,7 +261,9 @@ const WEEK_HOURLY_VARS = [ 'wind_speed_10m', 'wind_direction_10m', 'cloud_cover', - 'relative_humidity_2m' + 'relative_humidity_2m', + 'apparent_temperature', + 'dew_point_2m' ] as const; const WEEK_DAILY_VARS = [ @@ -324,7 +328,9 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise { 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'); + tempGradientFill.addColorStop(0, getColor(config.maxTemp, unit) + '5c'); + tempGradientFill.addColorStop(0.25, getColor(config.maxTemp, unit) + '5c'); + tempGradientFill.addColorStop(0.85, getColor(config.minTemp, unit) + '06'); + tempGradientFill.addColorStop(1, getColor(config.minTemp, unit) + '00'); ctx.beginPath(); ctx.moveTo( diff --git a/src/routes/weather/utils/colors.ts b/src/routes/weather/utils/colors.ts index 777f8fc..7ad1963 100644 --- a/src/routes/weather/utils/colors.ts +++ b/src/routes/weather/utils/colors.ts @@ -25,19 +25,18 @@ export function rgbToHex(rgb: string) { return hex; } -export const getColor = (tempString: string, unit = 'celsius'): string => { +export const getColor = (temperature: number, unit = 'celsius'): string => { let index = 0; - const temp = Number(tempString); if (unit === 'celsius') { - if (temp <= -40) { + if (temperature <= -40) { index = 0; - } else if (temp >= 60) { + } else if (temperature >= 60) { index = colorScaleHex.length - 1; } else { - index = temp + 40; + index = temperature + 40; } } else { - const tempInCelsius = Math.round(((temp - 32) * 5) / 9); + const tempInCelsius = Math.round(((temperature - 32) * 5) / 9); if (tempInCelsius <= -40) { index = 0; } else if (tempInCelsius >= 60) { diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 515ef06..2ace8c2 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -29,12 +29,10 @@ import type { GeoLocation } from '$lib/stores/settings'; - // ─── Constants ────────────────────────────────────────────────────────────── - const CHART_GROUP = 'week-meteogram'; const MS_PER_DAY = 24 * 3600 * 1000; - - // ─── State ────────────────────────────────────────────────────────────────── + const CELL_W = 38; + const CURVE_H = 120; let params = $state({ latitude: [$storedLocation.latitude], @@ -60,8 +58,8 @@ let selectedDayIndex = $state(1); let tableScrollDiv: HTMLElement | undefined = $state(); - - // ─── Fetched Data ─────────────────────────────────────────────────────────── + let hourlyInterval = $state<1 | 3>(3); + let showDetailedCharts = $state(false); interface FetchedHourly { hourly: WeekHourlyData; @@ -79,8 +77,6 @@ let fetchedHourly: FetchedHourly | null = $state(null); let fetchedDaily: FetchedDaily | null = $state(null); - // ─── Scroll-to-Day ────────────────────────────────────────────────────────── - function scrollChartsToDay(day: Date): void { if (!fetchedHourly || chartInstances.length === 0) return; @@ -138,8 +134,6 @@ } } - // ─── Lifecycle ────────────────────────────────────────────────────────────── - onMount(() => { mounted = true; @@ -171,8 +165,6 @@ chartComponents = []; }); - // ─── Helpers ──────────────────────────────────────────────────────────────── - function handleChartReady(chart: echarts.ECharts): void { chart.group = CHART_GROUP; chartInstances = [...chartInstances, chart]; @@ -239,7 +231,69 @@ return `rgba(0, 240, 240, ${hum ** 3.8 / 10 ** 8.2})`; } - // ─── Data Fetching ────────────────────────────────────────────────────────── + function getWindArrowRotation(deg: number): string { + return `rotate(${deg}deg)`; + } + + function getDayLabel(date: Date): string { + 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()}`; + } + + function getFilteredIndices(dates: Date[]): number[] { + if (hourlyInterval === 1) { + return dates.map((_, i) => i); + } + return dates.reduce((acc, date, i) => { + if (date.getHours() % 3 === 0) acc.push(i); + return acc; + }, []); + } + + function getPrecipBarHeight(val: number, maxVal: number): number { + if (!val || val <= 0 || !maxVal) return 0; + return Math.min(100, (val / Math.max(maxVal, 1)) * 100); + } + + function buildCurvePath(temps: number[], indices: number[], minT: number, maxT: number): string { + if (indices.length < 2) return ''; + const rangeT = maxT - minT || 1; + const points: [number, number][] = indices.map((idx, i) => { + const x = i * CELL_W + CELL_W / 2; + const t = temps[idx] ?? minT; + const y = CURVE_H - 20 - ((t - minT) / rangeT) * (CURVE_H - 40); + return [x, y]; + }); + + let d = `M ${points[0][0]},${points[0][1]}`; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const curr = points[i]; + const cpx1 = prev[0] + (curr[0] - prev[0]) * 0.4; + const cpx2 = prev[0] + (curr[0] - prev[0]) * 0.6; + d += ` C ${cpx1},${prev[1]} ${cpx2},${curr[1]} ${curr[0]},${curr[1]}`; + } + return d; + } + + function buildAreaPath(temps: number[], indices: number[], minT: number, maxT: number): string { + const curvePath = buildCurvePath(temps, indices, minT, maxT); + if (!curvePath) return ''; + const lastX = (indices.length - 1) * CELL_W + CELL_W / 2; + const firstX = CELL_W / 2; + return `${curvePath} L ${lastX},${CURVE_H} L ${firstX},${CURVE_H} Z`; + } + + function getCloudOpacity(cover: number): number { + return Math.min(0.7, (cover ?? 0) / 120); + } $effect(() => { const loc = location; @@ -259,7 +313,7 @@ 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: 6, + forecast_days: 7, past_days: 1 }); @@ -282,8 +336,6 @@ loadData(); }); - // ─── Chart Option Building ────────────────────────────────────────────────── - $effect(() => { if (!fetchedHourly) return; @@ -361,8 +413,6 @@ } ]; - // ── Chart 1: Temperature + Cloud Cover ────────────────────────────── - const tempOption: Record = { title: { text: 'Temperature & Cloud Cover', @@ -481,8 +531,6 @@ textStyle: { color: colors.text } }; - // ── Chart 2: Precipitation + Probability ──────────────────────────── - const precipOption: Record = { title: { text: 'Precipitation & Probability', @@ -585,8 +633,6 @@ textStyle: { color: colors.text } }; - // ── Chart 3: Wind & Humidity ───────────────────────────────────────── - const windOption: Record = { title: { text: 'Wind Speed & Humidity', @@ -722,51 +768,36 @@ -
+
- -
- {#if fetchedDaily} - {#each fetchedDaily.dailyDates as time, index (index)} - {@const selected = time.getDate() === selectedDay.getDate()} - {@const tempMax = fetchedDaily.daily.temperature_2m_max[index]} - {@const tempMin = fetchedDaily.daily.temperature_2m_min[index]} - {@const wCode = fetchedDaily.daily.weather_code[index]} - {@const sunDuration = fetchedDaily.daily.sunshine_duration[index]} - {@const precipSum = fetchedDaily.daily.precipitation_sum[index]} - {#if tempMax != null && !isNaN(tempMax)} - - {/if} - {/each} - {/if} -
- -
-

- {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)' - : ''} - -

- -
- - - - - {#each chartOptions as option, i (i)} - - {/each} - - - - {#if fetchedHourly} - {@const hourly = fetchedHourly.hourly} - {@const dates = fetchedHourly.hourlyDates} -
-

Hourly Details

-
-
- - - - - - - {#each dates as date, i (i)} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - - - - {#each dates as date, i (i)} - {@const wCode = hourly.weather_code[i]} - {@const isNow = isCurrentHour(date)} - {@const daytime = isDaytimeHour(date.getHours())} - {@const isMidnight = date.getHours() === 0} - - {/each} - + {windMax?.toFixed(0) ?? '-'}-{gustMax?.toFixed(0) ?? '-'} + - - - - {#each dates as date, i (i)} - {@const temp = hourly.temperature_2m[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - +
+ + + + + {Number(precipSum ?? 0).toFixed(0)}{params.precipitation_unit === 'mm' + ? ' mm' + : "'"} + +
- - - - {#each dates as date, i (i)} - {@const val = hourly.precipitation[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - +
+ + + + {Number((sunDuration ?? 0) / 3600).toFixed(0)} h +
- - - - {#each dates as date, i (i)} - {@const prob = hourly.precipitation_probability[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - - - - {#each dates as date, i (i)} - {@const wind = hourly.windspeed_10m[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - - - - {#each dates as date, i (i)} - {@const hum = hourly.relative_humidity_2m[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - - - - - {#each dates as date, i (i)} - {@const windDir = hourly.winddirection_10m[i]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {/each} - - -
Hourly weather details for {location.name}
Time - {pad(date.getHours())} -
- - - - - - +
+ + -
Temperature - {temp?.toFixed(1) ?? '-'} -
Precipitation - {val?.toFixed(1) ?? '-'} -
Precip Prob. - {prob?.toFixed(0) ?? '-'} -
Wind - {wind?.toFixed(0) ?? '-'} -
Rel. Hum. - {hum?.toFixed(0) ?? '-'} -
Wind Dir. - {#if windDir != null && !isNaN(windDir)} -
- + {#if windDir != null && !isNaN(windDir)} +
+
+
- {:else} - - - {/if} -
+
+ {/if} + + {/if} + {/each} + {/if} +
+
+ + + {#if fetchedHourly} + {@const hourly = fetchedHourly.hourly} + {@const dates = fetchedHourly.hourlyDates} + {@const filteredIdx = getFilteredIndices(dates)} + {@const maxPrecip = Math.max( + ...hourly.precipitation.filter((v) => v != null && !isNaN(v)), + 0.1 + )} + {@const allTemps = hourly.temperature_2m.filter((t) => t != null && !isNaN(t))} + {@const minTemp = Math.min(...allTemps)} + {@const maxTemp = Math.max(...allTemps)} + {@const totalWidth = filteredIdx.length * CELL_W} + +
+

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

+
+ 3h + + 1h +
+
+ + +
+
+ +
+
+
+ + + + + {#each filteredIdx as idx, i (idx)} + {@const t = hourly.temperature_2m[idx]} + + {/each} + + + + + {#each filteredIdx as idx, i (idx)} + {@const cloud = hourly.cloud_cover[idx]} + + {/each} + + + + + + + + + {#each filteredIdx as idx, i (idx)} + {@const t = hourly.temperature_2m[idx]} + {@const rangeT = maxTemp - minTemp || 1} + {@const y = CURVE_H - 20 - ((t - minTemp) / rangeT) * (CURVE_H - 40)} + + {t?.toFixed(0) ?? '-'}° + + {/each} + + + +
+ {#each filteredIdx as idx, i (idx)} + {@const wCode = hourly.weather_code[idx]} + {@const daytime = isDaytimeHour(dates[idx].getHours())} +
+ + + +
+ {/each} +
+
+
+ + + + + + + + + {#each filteredIdx as idx (idx)} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const val = hourly.precipitation[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const prob = hourly.precipitation_probability[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const val = hourly.precipitation[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const _wind = hourly.windspeed_10m[idx]} + {@const windDir = hourly.winddirection_10m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const temp = hourly.apparent_temperature[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const hum = hourly.relative_humidity_2m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const windDir = hourly.winddirection_10m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const wind = hourly.windspeed_10m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + + + + + {#each filteredIdx as idx (idx)} + {@const dp = hourly.dew_point_2m[idx]} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {@const isMidnight = date.getHours() === 0} + + {/each} + + +
Hourly weather details for {location.name}
+ + + + + {pad(date.getHours())}00 +
+
+ {hourlyInterval}h + {params.precipitation_unit === 'mm' ? 'mm' : 'in'} +
+
+
+ {#if val > 0} +
+ {/if} +
+ {val > 0 ? val.toFixed(1) : ''} +
+ % + + {prob != null ? prob.toFixed(0) + '%' : '-'} +
+ + + + + {#if val > 0} + + + + {/if} +
+ + + + + {#if windDir != null && !isNaN(windDir)} +
+ + + +
+ {/if} +
+ + + + + {temp != null ? temp.toFixed(0) + '°' : '-'} +
+ % + + {hum != null ? hum.toFixed(0) + '%' : '-'} +
+ + + + + {#if windDir != null && !isNaN(windDir)} +
+ + + +
+ {:else} + - + {/if} +
+
+ + + +
+
+ {wind?.toFixed(0) ?? '-'} +
+ + + + + {dp != null ? dp.toFixed(0) + '°' : '-'} +
+
{/if} - + + {#if fetchedDaily} + {@const sunriseTs = fetchedDaily.daily.sunrise[selectedDayIndex]} + {@const sunsetTs = fetchedDaily.daily.sunset[selectedDayIndex]} + {#if sunriseTs && sunsetTs} + {@const sunrise = new Date(sunriseTs * 1000)} + {@const sunset = new Date(sunsetTs * 1000)} +
+
+ + + + {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} +
+
+ + + + {pad(sunset.getHours())}:{pad(sunset.getMinutes())} +
+
+ {/if} + {/if} -
- + +
+
+ + {#if showDetailedCharts} +
+
+

+ {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)' + : ''} + +

+ +
+ + + {#each chartOptions as option, i (i)} + + {/each} + + +
+ +
+
+ {/if}
- - {#if fetchedDaily} - {@const sunriseTs = fetchedDaily.daily.sunrise[selectedDayIndex]} - {@const sunsetTs = fetchedDaily.daily.sunset[selectedDayIndex]} - {#if sunriseTs && sunsetTs} - {@const sunrise = new Date(sunriseTs * 1000)} - {@const sunset = new Date(sunsetTs * 1000)} -
-
- - - Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())} -
-
- - - - Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())} -
-
- {/if} - {/if} - - +
@@ -1093,17 +1356,426 @@