From 1c4979e12ee7134890a15796eff8cddabafd75fa Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 20:57:45 +0100 Subject: [PATCH] single day overview --- .../weather/week/[location]/+page.svelte | 630 +++++++++--------- 1 file changed, 325 insertions(+), 305 deletions(-) diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 2ace8c2..ec2bb8e 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -31,8 +31,8 @@ const CHART_GROUP = 'week-meteogram'; const MS_PER_DAY = 24 * 3600 * 1000; - const CELL_W = 38; - const CURVE_H = 120; + const CURVE_H = 140; + const SVG_UNIT = 100; let params = $state({ latitude: [$storedLocation.latitude], @@ -57,7 +57,6 @@ const selectedDay = new SvelteDate(); let selectedDayIndex = $state(1); - let tableScrollDiv: HTMLElement | undefined = $state(); let hourlyInterval = $state<1 | 3>(3); let showDetailedCharts = $state(false); @@ -104,22 +103,10 @@ } } - function scrollTableToDay(day: Date): void { - if (!tableScrollDiv) return; - const cells = tableScrollDiv.querySelectorAll('td.hour-cell[data-date]'); - for (const cell of cells) { - if (Number(cell.dataset['date']) === day.getDate()) { - tableScrollDiv.scrollTo({ left: cell.offsetLeft - 120, behavior: 'smooth' }); - break; - } - } - } - const switchDay = (date: Date, index: number) => { selectedDay.setTime(date.getTime()); selectedDayIndex = index; scrollChartsToDay(date); - requestAnimationFrame(() => scrollTableToDay(date)); }; function resetZoom(): void { @@ -172,7 +159,6 @@ echarts.connect(CHART_GROUP); requestAnimationFrame(() => { scrollChartsToDay(selectedDay); - scrollTableToDay(selectedDay); }); } } @@ -247,14 +233,20 @@ return `${date.getMonth() + 1}-${date.getDate()}`; } - function getFilteredIndices(dates: Date[]): number[] { - if (hourlyInterval === 1) { - return dates.map((_, i) => i); + function getDayIndices(dates: Date[], day: Date): number[] { + const dayDate = day.getDate(); + const dayMonth = day.getMonth(); + const dayYear = day.getFullYear(); + const indices: number[] = []; + for (let i = 0; i < dates.length; i++) { + const d = dates[i]; + if (d.getDate() === dayDate && d.getMonth() === dayMonth && d.getFullYear() === dayYear) { + if (hourlyInterval === 1 || d.getHours() % 3 === 0) { + indices.push(i); + } + } } - return dates.reduce((acc, date, i) => { - if (date.getHours() % 3 === 0) acc.push(i); - return acc; - }, []); + return indices; } function getPrecipBarHeight(val: number, maxVal: number): number { @@ -266,9 +258,9 @@ 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 x = i * SVG_UNIT + SVG_UNIT / 2; const t = temps[idx] ?? minT; - const y = CURVE_H - 20 - ((t - minT) / rangeT) * (CURVE_H - 40); + const y = CURVE_H - 24 - ((t - minT) / rangeT) * (CURVE_H - 48); return [x, y]; }); @@ -286,8 +278,9 @@ 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; + const n = indices.length; + const lastX = (n - 1) * SVG_UNIT + SVG_UNIT / 2; + const firstX = SVG_UNIT / 2; return `${curvePath} L ${lastX},${CURVE_H} L ${firstX},${CURVE_H} Z`; } @@ -295,6 +288,10 @@ return Math.min(0.7, (cover ?? 0) / 120); } + function getIconSize(interval: 1 | 3): number { + return interval === 3 ? 40 : 26; + } + $effect(() => { const loc = location; const modelList = params.models; @@ -870,15 +867,19 @@ {#if fetchedHourly} {@const hourly = fetchedHourly.hourly} {@const dates = fetchedHourly.hourlyDates} - {@const filteredIdx = getFilteredIndices(dates)} + {@const dayIdx = getDayIndices(dates, selectedDay)} + {@const numCols = dayIdx.length} + {@const svgW = numCols * SVG_UNIT} {@const maxPrecip = Math.max( - ...hourly.precipitation.filter((v) => v != null && !isNaN(v)), + ...dayIdx.map((i) => hourly.precipitation[i]).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} + {@const dayTemps = dayIdx + .map((i) => hourly.temperature_2m[i]) + .filter((t) => t != null && !isNaN(t))} + {@const minTemp = dayTemps.length ? Math.min(...dayTemps) : 0} + {@const maxTemp = dayTemps.length ? Math.max(...dayTemps) : 10} + {@const iconPx = getIconSize(hourlyInterval)}

@@ -897,86 +898,44 @@

- -
-
- -
-
-
- - - - - {#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]} - + {#if numCols > 0} + +
+ + + + + {#each dayIdx as _ (_.toString())} + + {/each} + + + + + + {#each dayIdx as idx (idx)} + {@const date = dates[idx]} + {@const isNow = isCurrentHour(date)} + {/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)} + +
+ + {#each dayIdx as idx (idx)} {@const wCode = hourly.weather_code[idx]} - {@const daytime = isDaytimeHour(dates[idx].getHours())} -
- + {@const date = dates[idx]} + {@const daytime = isDaytimeHour(date.getHours())} + {@const isNow = isCurrentHour(date)} + + -
- {/each} - - - - - -
Hourly weather details for {location.name}
+ {pad(date.getHours())}00 +
+ + + +
- - - - - - {#each filteredIdx as idx (idx)} - {@const date = dates[idx]} - {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - {/each} + + + + + + - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const val = hourly.precipitation[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const val = hourly.precipitation[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - + - {#each filteredIdx as idx (idx)} - {@const _wind = hourly.windspeed_10m[idx]} + {#each dayIdx as idx (idx)} {@const windDir = hourly.winddirection_10m[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - - - - - {#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)} + {#each dayIdx as idx (idx)} {@const wind = hourly.windspeed_10m[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - {/each} @@ -1212,16 +1184,15 @@ - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const dp = hourly.dew_point_2m[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - {/each} @@ -1229,7 +1200,7 @@
Hourly weather details for {location.name}
- - - - - {pad(date.getHours())}00
+ {params.temperature_unit === 'celsius' ? '°C' : '°F'} + +
+ + + + {#each dayIdx as idx, i (idx)} + {@const t = hourly.temperature_2m[idx]} + + {/each} + + + + + {#each dayIdx as idx, i (idx)} + {@const cloud = hourly.cloud_cover[idx]} + + {/each} + + + + + + + + + +
+ {#each dayIdx as idx, i (idx)} + {@const t = hourly.temperature_2m[idx]} + {@const rangeT = maxTemp - minTemp || 1} + {@const yPct = + ((1 - ((t ?? minTemp) - minTemp) / rangeT) * 0.7 + 0.05) * 100} + + {t?.toFixed(0) ?? '-'}° + + {/each} +
+
+
@@ -1022,16 +1034,11 @@ {params.precipitation_unit === 'mm' ? 'mm' : 'in'} +
{#if val > 0}
% - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const prob = hourly.precipitation_probability[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0}
- + + {#if val > 0} @@ -1095,25 +1098,21 @@ {/each}
- + + {#if windDir != null && !isNaN(windDir)}
- + @@ -1126,16 +1125,15 @@
- + - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const temp = hourly.apparent_temperature[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0} - + {temp != null ? temp.toFixed(0) + '°' : '-'} {/each} @@ -1144,15 +1142,16 @@
-
% + + + - {#each filteredIdx as idx (idx)} + {#each dayIdx as idx (idx)} {@const hum = hourly.relative_humidity_2m[idx]} {@const date = dates[idx]} {@const isNow = isCurrentHour(date)} - {@const isMidnight = date.getHours() === 0}
{hum != null ? hum.toFixed(0) + '%' : '-'} @@ -1160,50 +1159,23 @@ {/each}
- - - - - {#if windDir != null && !isNaN(windDir)} -
- - - -
- {:else} - - - {/if} -
- + + {params.wind_speed_unit === 'kmh' ? 'km/h' : params.wind_speed_unit}
+ {wind?.toFixed(0) ?? '-'}
- + + {dp != null ? dp.toFixed(0) + '°' : '-'}
-
+ {/if} {/if} @@ -1520,86 +1491,25 @@ left: 22px; } - /* ═══ Horizontal Scroll Container ═══════════════════════════════════════════ */ + /* ═══ Hourly Table Container ═══════════════════════════════════════════════ */ - .hourly-scroll { - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; - margin-left: -1.25rem; - margin-right: -1.25rem; + .hourly-container { border: 1px solid hsl(var(--border)); border-radius: 8px; - } - - @media (min-width: 768px) { - .hourly-scroll { - margin-left: 0; - margin-right: 0; - } - } - - .hourly-inner { - display: flex; - flex-direction: column; - } - - /* ═══ Temperature Curve Area ═══════════════════════════════════════════════ */ - - .curve-area { - display: flex; - border-bottom: 2px solid hsl(var(--border)); - background: hsl(var(--card) / 0.5); - } - - .curve-row-header { - position: sticky; - left: 0; - z-index: 10; - min-width: 90px; - max-width: 90px; - background: hsl(var(--background)); - border-right: 2px solid hsl(var(--border)); - } - - .curve-container { - position: relative; overflow: hidden; } - .curve-svg { - display: block; - } - - .curve-temp-label { - font-size: 10px; - font-weight: 600; - } - - .curve-icons { - position: absolute; - top: 2px; - left: 0; - display: flex; - pointer-events: none; - } - - .curve-icon-cell { - position: absolute; - top: 0; - display: flex; - align-items: center; - justify-content: center; - height: 22px; - } - /* ═══ Hourly Data Table ═══════════════════════════════════════════════════ */ .hourly-table { border-collapse: collapse; white-space: nowrap; - font-size: 11px; width: 100%; + table-layout: fixed; + } + + .hourly-table .col-header { + width: 64px; } .hourly-table tr { @@ -1611,18 +1521,15 @@ } .row-header { - position: sticky; - left: 0; - z-index: 10; - min-width: 90px; - max-width: 90px; - padding: 3px 6px; + width: 64px; + padding: 4px 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; } .row-header-stack { @@ -1630,7 +1537,13 @@ flex-direction: column; align-items: center; gap: 0; - line-height: 1.1; + line-height: 1.2; + } + + .row-label { + font-size: 11px; + font-weight: 600; + color: hsl(var(--muted-foreground)); } .row-unit { @@ -1639,17 +1552,24 @@ color: hsl(var(--muted-foreground)); } + /* ═══ Hour Cells ═══════════════════════════════════════════════════════════ */ + .hour-cell { - min-width: 38px; - max-width: 38px; - padding: 3px 1px; + padding: 6px 2px; text-align: center; - font-size: 11px; - border-right: 1px solid hsl(var(--border) / 0.4); + font-size: 13px; + font-weight: 500; + border-right: 1px solid hsl(var(--border) / 0.3); + overflow: hidden; } - .hour-cell.midnight { - border-left: 2px solid hsl(var(--primary) / 0.5); + .interval-3h .hour-cell { + font-size: 15px; + padding: 8px 4px; + } + + .hour-cell:last-child { + border-right: none; } .hour-cell.now { @@ -1659,32 +1579,111 @@ .time-cell { font-weight: 700; - font-size: 10px; background: hsl(var(--muted) / 0.3); + padding: 5px 2px; + } + + .interval-3h .time-cell { + font-size: 14px; + padding: 6px 4px; + } + + .interval-1h .time-cell { + font-size: 11px; } .time-sup { - font-size: 7px; + font-size: 8px; vertical-align: super; } + /* ═══ Weather Icon Row ═══════════════════════════════════════════════════ */ + + .icon-row { + background: hsl(var(--muted) / 0.15); + } + + .weather-icon-cell { + padding: 6px 2px; + text-align: center; + vertical-align: middle; + line-height: 0; + border-right: 1px solid hsl(var(--border) / 0.3); + } + + .weather-icon-cell:last-child { + border-right: none; + } + + .interval-3h .weather-icon-cell { + padding: 10px 4px; + } + + /* ═══ Temperature Curve ═══════════════════════════════════════════════════ */ + + .curve-row { + border-top: none !important; + } + + .curve-header { + vertical-align: middle; + } + + .curve-cell { + padding: 0 !important; + border-right: none !important; + } + + .curve-container { + position: relative; + overflow: hidden; + width: 100%; + } + + .curve-svg { + display: block; + width: 100%; + } + + .curve-labels { + position: absolute; + inset: 0; + pointer-events: none; + } + + .curve-label { + position: absolute; + transform: translateX(-50%); + font-size: 11px; + font-weight: 700; + white-space: nowrap; + } + + .interval-3h .curve-label { + font-size: 14px; + } + /* ═══ Precipitation Bar ═══════════════════════════════════════════════════ */ .precip-cell { vertical-align: bottom; - padding: 1px; + padding: 2px !important; } .precip-bar-container { width: 100%; - height: 22px; + height: 28px; display: flex; align-items: flex-end; justify-content: center; } + .interval-3h .precip-bar-container { + height: 36px; + } + .precip-bar { - width: 60%; + width: 55%; min-height: 2px; background: linear-gradient(to top, rgba(30, 136, 229, 0.5), rgba(30, 136, 229, 0.9)); border-radius: 2px 2px 0 0; @@ -1692,16 +1691,22 @@ .precip-val { display: block; - font-size: 9px; - line-height: 1; + font-size: 10px; + line-height: 1.2; color: hsl(var(--muted-foreground)); text-align: center; + margin-top: 1px; + } + + .interval-3h .precip-val { + font-size: 12px; } .icon-cell { - padding: 2px; + padding: 4px 2px; vertical-align: middle; line-height: 0; + text-align: center; } .icon-cell > div, @@ -1709,12 +1714,7 @@ display: inline-block; } - /* ═══ Wind Cell ═══════════════════════════════════════════════════════════ */ - - .wind-cell { - vertical-align: middle; - line-height: 0; - } + /* ═══ Wind Arrow ═══════════════════════════════════════════════════════════ */ .wind-arrow { display: inline-block; @@ -1817,5 +1817,25 @@ .card-temp-min { font-size: 13px; } + + .hourly-table .col-header { + width: 48px; + } + + .row-header { + width: 48px; + font-size: 10px; + padding: 3px 2px; + } + + .hour-cell { + font-size: 11px; + padding: 4px 1px; + } + + .interval-3h .hour-cell { + font-size: 13px; + padding: 6px 2px; + } }