single day overview

This commit is contained in:
terraputix
2026-02-15 20:57:45 +01:00
parent 5180c121c4
commit 1c4979e12e
+291 -271
View File
@@ -31,8 +31,8 @@
const CHART_GROUP = 'week-meteogram'; const CHART_GROUP = 'week-meteogram';
const MS_PER_DAY = 24 * 3600 * 1000; const MS_PER_DAY = 24 * 3600 * 1000;
const CELL_W = 38; const CURVE_H = 140;
const CURVE_H = 120; const SVG_UNIT = 100;
let params = $state({ let params = $state({
latitude: [$storedLocation.latitude], latitude: [$storedLocation.latitude],
@@ -57,7 +57,6 @@
const selectedDay = new SvelteDate(); const selectedDay = new SvelteDate();
let selectedDayIndex = $state(1); let selectedDayIndex = $state(1);
let tableScrollDiv: HTMLElement | undefined = $state();
let hourlyInterval = $state<1 | 3>(3); let hourlyInterval = $state<1 | 3>(3);
let showDetailedCharts = $state(false); let showDetailedCharts = $state(false);
@@ -104,22 +103,10 @@
} }
} }
function scrollTableToDay(day: Date): void {
if (!tableScrollDiv) return;
const cells = tableScrollDiv.querySelectorAll<HTMLElement>('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) => { const switchDay = (date: Date, index: number) => {
selectedDay.setTime(date.getTime()); selectedDay.setTime(date.getTime());
selectedDayIndex = index; selectedDayIndex = index;
scrollChartsToDay(date); scrollChartsToDay(date);
requestAnimationFrame(() => scrollTableToDay(date));
}; };
function resetZoom(): void { function resetZoom(): void {
@@ -172,7 +159,6 @@
echarts.connect(CHART_GROUP); echarts.connect(CHART_GROUP);
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollChartsToDay(selectedDay); scrollChartsToDay(selectedDay);
scrollTableToDay(selectedDay);
}); });
} }
} }
@@ -247,14 +233,20 @@
return `${date.getMonth() + 1}-${date.getDate()}`; return `${date.getMonth() + 1}-${date.getDate()}`;
} }
function getFilteredIndices(dates: Date[]): number[] { function getDayIndices(dates: Date[], day: Date): number[] {
if (hourlyInterval === 1) { const dayDate = day.getDate();
return dates.map((_, i) => i); 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<number[]>((acc, date, i) => { }
if (date.getHours() % 3 === 0) acc.push(i); }
return acc; return indices;
}, []);
} }
function getPrecipBarHeight(val: number, maxVal: number): number { function getPrecipBarHeight(val: number, maxVal: number): number {
@@ -266,9 +258,9 @@
if (indices.length < 2) return ''; if (indices.length < 2) return '';
const rangeT = maxT - minT || 1; const rangeT = maxT - minT || 1;
const points: [number, number][] = indices.map((idx, i) => { 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 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]; return [x, y];
}); });
@@ -286,8 +278,9 @@
function buildAreaPath(temps: number[], indices: number[], minT: number, maxT: number): string { function buildAreaPath(temps: number[], indices: number[], minT: number, maxT: number): string {
const curvePath = buildCurvePath(temps, indices, minT, maxT); const curvePath = buildCurvePath(temps, indices, minT, maxT);
if (!curvePath) return ''; if (!curvePath) return '';
const lastX = (indices.length - 1) * CELL_W + CELL_W / 2; const n = indices.length;
const firstX = CELL_W / 2; 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`; return `${curvePath} L ${lastX},${CURVE_H} L ${firstX},${CURVE_H} Z`;
} }
@@ -295,6 +288,10 @@
return Math.min(0.7, (cover ?? 0) / 120); return Math.min(0.7, (cover ?? 0) / 120);
} }
function getIconSize(interval: 1 | 3): number {
return interval === 3 ? 40 : 26;
}
$effect(() => { $effect(() => {
const loc = location; const loc = location;
const modelList = params.models; const modelList = params.models;
@@ -870,15 +867,19 @@
{#if fetchedHourly} {#if fetchedHourly}
{@const hourly = fetchedHourly.hourly} {@const hourly = fetchedHourly.hourly}
{@const dates = fetchedHourly.hourlyDates} {@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( {@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 0.1
)} )}
{@const allTemps = hourly.temperature_2m.filter((t) => t != null && !isNaN(t))} {@const dayTemps = dayIdx
{@const minTemp = Math.min(...allTemps)} .map((i) => hourly.temperature_2m[i])
{@const maxTemp = Math.max(...allTemps)} .filter((t) => t != null && !isNaN(t))}
{@const totalWidth = filteredIdx.length * CELL_W} {@const minTemp = dayTemps.length ? Math.min(...dayTemps) : 0}
{@const maxTemp = dayTemps.length ? Math.max(...dayTemps) : 10}
{@const iconPx = getIconSize(hourlyInterval)}
<div class="hourly-header"> <div class="hourly-header">
<h3 class="hourly-title"> <h3 class="hourly-title">
@@ -897,26 +898,77 @@
</div> </div>
</div> </div>
<!-- Combined Hourly Meteogram + Table --> {#if numCols > 0}
<div bind:this={tableScrollDiv} class="hourly-scroll"> <!-- Single-day hourly table (no horizontal scroll) -->
<div class="hourly-inner" style="min-width: {totalWidth + 96}px"> <div class="hourly-container">
<!-- Temperature Curve + Icons Area --> <table class="hourly-table {hourlyInterval === 3 ? 'interval-3h' : 'interval-1h'}">
<div class="curve-area"> <caption class="sr-only">Hourly weather details for {location.name}</caption>
<div class="curve-row-header"></div> <colgroup>
<div class="curve-container" style="width: {totalWidth}px; height: {CURVE_H}px"> <col class="col-header" />
<!-- Cloud cover background --> {#each dayIdx as _ (_.toString())}
<col />
{/each}
</colgroup>
<tbody>
<!-- Time row -->
<tr>
<th class="row-header" scope="row"></th>
{#each dayIdx as idx (idx)}
{@const date = dates[idx]}
{@const isNow = isCurrentHour(date)}
<td class="hour-cell time-cell {isNow ? 'now' : ''}" data-date={date.getDate()}>
{pad(date.getHours())}<sup class="time-sup">00</sup>
</td>
{/each}
</tr>
<!-- Weather icons row (prominent) -->
<tr class="icon-row">
<th class="row-header" scope="row">
<svg class="fill-foreground inline-block" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-day-cloudy.svg#Layer_1"></use>
</svg>
</th>
{#each dayIdx as idx (idx)}
{@const wCode = hourly.weather_code[idx]}
{@const date = dates[idx]}
{@const daytime = isDaytimeHour(date.getHours())}
{@const isNow = isCurrentHour(date)}
<td class="hour-cell weather-icon-cell {isNow ? 'now' : ''}">
<svg class="fill-foreground" width="{iconPx}px" height="{iconPx}px">
<use
xlink:href="/images/weather-icons/wi-{daytime
? 'day'
: 'night'}-{weatherCodes[wCode as keyof typeof weatherCodes] ??
'clear'}.svg#Layer_1"
></use>
</svg>
</td>
{/each}
</tr>
<!-- Temperature curve row (colspan) -->
<tr class="curve-row">
<th class="row-header curve-header" scope="row">
<span class="row-label"
>{params.temperature_unit === 'celsius' ? '°C' : '°F'}</span
>
</th>
<td colspan={numCols} class="curve-cell">
<div class="curve-container" style="height: {CURVE_H}px">
<svg <svg
class="curve-svg" class="curve-svg"
width={totalWidth} width="100%"
height={CURVE_H} height={CURVE_H}
viewBox="0 0 {totalWidth} {CURVE_H}" viewBox="0 0 {svgW} {CURVE_H}"
preserveAspectRatio="none"
> >
<defs> <defs>
<linearGradient id="tempGrad" x1="0" y1="0" x2="1" y2="0"> <linearGradient id="tempGrad" x1="0" y1="0" x2="1" y2="0">
{#each filteredIdx as idx, i (idx)} {#each dayIdx as idx, i (idx)}
{@const t = hourly.temperature_2m[idx]} {@const t = hourly.temperature_2m[idx]}
<stop <stop
offset="{(i / Math.max(filteredIdx.length - 1, 1)) * 100}%" offset="{(i / Math.max(numCols - 1, 1)) * 100}%"
stop-color={getColor(t, String(params.temperature_unit))} stop-color={getColor(t, String(params.temperature_unit))}
stop-opacity="0.45" stop-opacity="0.45"
/> />
@@ -924,13 +976,13 @@
</linearGradient> </linearGradient>
</defs> </defs>
<!-- Cloud cover bands per cell --> <!-- Cloud cover bands -->
{#each filteredIdx as idx, i (idx)} {#each dayIdx as idx, i (idx)}
{@const cloud = hourly.cloud_cover[idx]} {@const cloud = hourly.cloud_cover[idx]}
<rect <rect
x={i * CELL_W} x={i * SVG_UNIT}
y={0} y={0}
width={CELL_W} width={SVG_UNIT}
height={CURVE_H} height={CURVE_H}
fill="rgb(160,175,190)" fill="rgb(160,175,190)"
opacity={getCloudOpacity(cloud)} opacity={getCloudOpacity(cloud)}
@@ -939,79 +991,39 @@
<!-- Temperature area fill --> <!-- Temperature area fill -->
<path <path
d={buildAreaPath(hourly.temperature_2m, filteredIdx, minTemp, maxTemp)} d={buildAreaPath(hourly.temperature_2m, dayIdx, minTemp, maxTemp)}
fill="url(#tempGrad)" fill="url(#tempGrad)"
/> />
<!-- Temperature curve line --> <!-- Temperature curve line -->
<path <path
d={buildCurvePath(hourly.temperature_2m, filteredIdx, minTemp, maxTemp)} d={buildCurvePath(hourly.temperature_2m, dayIdx, minTemp, maxTemp)}
fill="none" fill="none"
stroke="#d84315" stroke="#d84315"
stroke-width="2.5" stroke-width="2.5"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/> />
</svg>
<!-- Temperature value labels --> <!-- Temperature labels (positioned with % so they align with columns) -->
{#each filteredIdx as idx, i (idx)} <div class="curve-labels">
{#each dayIdx as idx, i (idx)}
{@const t = hourly.temperature_2m[idx]} {@const t = hourly.temperature_2m[idx]}
{@const rangeT = maxTemp - minTemp || 1} {@const rangeT = maxTemp - minTemp || 1}
{@const y = CURVE_H - 20 - ((t - minTemp) / rangeT) * (CURVE_H - 40)} {@const yPct =
<text ((1 - ((t ?? minTemp) - minTemp) / rangeT) * 0.7 + 0.05) * 100}
x={i * CELL_W + CELL_W / 2} <span
y={y - 8} class="curve-label"
text-anchor="middle" style="left: {((i + 0.5) / numCols) * 100}%; top: calc({yPct}% - 18px)"
class="curve-temp-label"
fill="currentColor"
> >
{t?.toFixed(0) ?? '-'}° {t?.toFixed(0) ?? '-'}°
</text> </span>
{/each}
</svg>
<!-- Weather icons overlaid on top -->
<div class="curve-icons" style="width: {totalWidth}px">
{#each filteredIdx as idx, i (idx)}
{@const wCode = hourly.weather_code[idx]}
{@const daytime = isDaytimeHour(dates[idx].getHours())}
<div class="curve-icon-cell" style="width: {CELL_W}px; left: {i * CELL_W}px">
<svg class="fill-foreground" width="20px" height="20px">
<use
xlink:href="/images/weather-icons/wi-{daytime
? 'day'
: 'night'}-{weatherCodes[wCode as keyof typeof weatherCodes] ??
'clear'}.svg#Layer_1"
></use>
</svg>
</div>
{/each} {/each}
</div> </div>
</div> </div>
</div>
<!-- Hourly Data Table -->
<table class="hourly-table">
<caption class="sr-only">Hourly weather details for {location.name}</caption>
<tbody>
<!-- Time row -->
<tr>
<th class="row-header" scope="row">
<svg class="fill-foreground inline-block" width="14px" height="14px">
<use xlink:href="/images/weather-icons/wi-time-4.svg#Layer_1"></use>
</svg>
</th>
{#each filteredIdx as idx (idx)}
{@const date = dates[idx]}
{@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0}
<td
class="hour-cell time-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
data-date={date.getDate()}
>
{pad(date.getHours())}<sup class="time-sup">00</sup>
</td> </td>
{/each}
</tr> </tr>
<!-- Precipitation bars row --> <!-- Precipitation bars row -->
@@ -1022,16 +1034,11 @@
<span class="row-unit">{params.precipitation_unit === 'mm' ? 'mm' : 'in'}</span> <span class="row-unit">{params.precipitation_unit === 'mm' ? 'mm' : 'in'}</span>
</div> </div>
</th> </th>
{#each filteredIdx as idx (idx)} {#each dayIdx as idx (idx)}
{@const val = hourly.precipitation[idx]} {@const val = hourly.precipitation[idx]}
{@const date = dates[idx]} {@const date = dates[idx]}
{@const isNow = isCurrentHour(date)} {@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0} <td class="hour-cell precip-cell {isNow ? 'now' : ''}">
<td
class="hour-cell precip-cell {isNow ? 'now' : ''} {isMidnight
? 'midnight'
: ''}"
>
<div class="precip-bar-container"> <div class="precip-bar-container">
{#if val > 0} {#if val > 0}
<div <div
@@ -1050,13 +1057,12 @@
<th class="row-header" scope="row"> <th class="row-header" scope="row">
<span class="row-unit">%</span> <span class="row-unit">%</span>
</th> </th>
{#each filteredIdx as idx (idx)} {#each dayIdx as idx (idx)}
{@const prob = hourly.precipitation_probability[idx]} {@const prob = hourly.precipitation_probability[idx]}
{@const date = dates[idx]} {@const date = dates[idx]}
{@const isNow = isCurrentHour(date)} {@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0}
<td <td
class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}" class="hour-cell {isNow ? 'now' : ''}"
style="background: {getPrecipProbBg(prob ?? 0)}; color: {getPrecipProbColor( style="background: {getPrecipProbBg(prob ?? 0)}; color: {getPrecipProbColor(
prob ?? 0 prob ?? 0
)}" )}"
@@ -1069,23 +1075,20 @@
<!-- Precipitation type icons --> <!-- Precipitation type icons -->
<tr> <tr>
<th class="row-header" scope="row"> <th class="row-header" scope="row">
<svg class="fill-foreground inline-block" width="14px" height="14px"> <svg class="fill-foreground inline-block" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg> </svg>
</th> </th>
{#each filteredIdx as idx (idx)} {#each dayIdx as idx (idx)}
{@const val = hourly.precipitation[idx]} {@const val = hourly.precipitation[idx]}
{@const date = dates[idx]} {@const date = dates[idx]}
{@const isNow = isCurrentHour(date)} {@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0} <td class="hour-cell icon-cell {isNow ? 'now' : ''}">
<td
class="hour-cell icon-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
>
{#if val > 0} {#if val > 0}
<svg <svg
class="fill-foreground" class="fill-foreground"
width="16px" width="18px"
height="16px" height="18px"
style="opacity: {Math.min(1, val / 3)}" style="opacity: {Math.min(1, val / 3)}"
> >
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
@@ -1095,25 +1098,21 @@
{/each} {/each}
</tr> </tr>
<!-- Wind Speed + Direction --> <!-- Wind Direction Arrows -->
<tr> <tr>
<th class="row-header" scope="row"> <th class="row-header" scope="row">
<svg class="fill-foreground inline-block" width="14px" height="14px"> <svg class="fill-foreground inline-block" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg> </svg>
</th> </th>
{#each filteredIdx as idx (idx)} {#each dayIdx as idx (idx)}
{@const _wind = hourly.windspeed_10m[idx]}
{@const windDir = hourly.winddirection_10m[idx]} {@const windDir = hourly.winddirection_10m[idx]}
{@const date = dates[idx]} {@const date = dates[idx]}
{@const isNow = isCurrentHour(date)} {@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0} <td class="hour-cell icon-cell {isNow ? 'now' : ''}">
<td
class="hour-cell wind-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
>
{#if windDir != null && !isNaN(windDir)} {#if windDir != null && !isNaN(windDir)}
<div class="wind-arrow" style="transform: {getWindArrowRotation(windDir)}"> <div class="wind-arrow" style="transform: {getWindArrowRotation(windDir)}">
<svg class="fill-foreground" width="16px" height="16px"> <svg class="fill-foreground" width="20px" height="20px">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1" <use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
></use> ></use>
</svg> </svg>
@@ -1126,16 +1125,15 @@
<!-- Apparent Temperature (Feels Like) --> <!-- Apparent Temperature (Feels Like) -->
<tr> <tr>
<th class="row-header" scope="row"> <th class="row-header" scope="row">
<svg class="fill-foreground inline-block" width="14px" height="14px"> <svg class="fill-foreground inline-block" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-thermometer.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-thermometer.svg#Layer_1"></use>
</svg> </svg>
</th> </th>
{#each filteredIdx as idx (idx)} {#each dayIdx as idx (idx)}
{@const temp = hourly.apparent_temperature[idx]} {@const temp = hourly.apparent_temperature[idx]}
{@const date = dates[idx]} {@const date = dates[idx]}
{@const isNow = isCurrentHour(date)} {@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0} <td class="hour-cell {isNow ? 'now' : ''}">
<td class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
{temp != null ? temp.toFixed(0) + '°' : '-'} {temp != null ? temp.toFixed(0) + '°' : '-'}
</td> </td>
{/each} {/each}
@@ -1144,15 +1142,16 @@
<!-- Humidity --> <!-- Humidity -->
<tr> <tr>
<th class="row-header" scope="row"> <th class="row-header" scope="row">
<span class="row-unit">%</span> <svg class="fill-foreground inline-block" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-humidity.svg#Layer_1"></use>
</svg>
</th> </th>
{#each filteredIdx as idx (idx)} {#each dayIdx as idx (idx)}
{@const hum = hourly.relative_humidity_2m[idx]} {@const hum = hourly.relative_humidity_2m[idx]}
{@const date = dates[idx]} {@const date = dates[idx]}
{@const isNow = isCurrentHour(date)} {@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0}
<td <td
class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}" class="hour-cell {isNow ? 'now' : ''}"
style="background: {getHumidityBg(hum ?? 0)}" style="background: {getHumidityBg(hum ?? 0)}"
> >
{hum != null ? hum.toFixed(0) + '%' : '-'} {hum != null ? hum.toFixed(0) + '%' : '-'}
@@ -1160,50 +1159,23 @@
{/each} {/each}
</tr> </tr>
<!-- Wind Direction Labels -->
<tr>
<th class="row-header" scope="row">
<svg class="fill-foreground inline-block" width="14px" height="14px">
<use xlink:href="/images/weather-icons/wi-wind-deg.svg#Layer_1"></use>
</svg>
</th>
{#each filteredIdx as idx (idx)}
{@const windDir = hourly.winddirection_10m[idx]}
{@const date = dates[idx]}
{@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0}
<td
class="hour-cell icon-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
>
{#if windDir != null && !isNaN(windDir)}
<div style="transform: rotate({windDir}deg); display: inline-block">
<svg class="fill-foreground" width="18px" height="18px">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
></use>
</svg>
</div>
{:else}
-
{/if}
</td>
{/each}
</tr>
<!-- Wind Speed values --> <!-- Wind Speed values -->
<tr> <tr>
<th class="row-header" scope="row"> <th class="row-header" scope="row">
<div class="row-header-stack"> <div class="row-header-stack">
<svg class="fill-foreground inline-block" width="14px" height="14px"> <svg class="fill-foreground inline-block" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg> </svg>
<span class="row-unit"
>{params.wind_speed_unit === 'kmh' ? 'km/h' : params.wind_speed_unit}</span
>
</div> </div>
</th> </th>
{#each filteredIdx as idx (idx)} {#each dayIdx as idx (idx)}
{@const wind = hourly.windspeed_10m[idx]} {@const wind = hourly.windspeed_10m[idx]}
{@const date = dates[idx]} {@const date = dates[idx]}
{@const isNow = isCurrentHour(date)} {@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0} <td class="hour-cell {isNow ? 'now' : ''}">
<td class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
{wind?.toFixed(0) ?? '-'} {wind?.toFixed(0) ?? '-'}
</td> </td>
{/each} {/each}
@@ -1212,16 +1184,15 @@
<!-- Dew Point --> <!-- Dew Point -->
<tr> <tr>
<th class="row-header" scope="row"> <th class="row-header" scope="row">
<svg class="fill-foreground inline-block" width="14px" height="14px"> <svg class="fill-foreground inline-block" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-raindrops.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-raindrops.svg#Layer_1"></use>
</svg> </svg>
</th> </th>
{#each filteredIdx as idx (idx)} {#each dayIdx as idx (idx)}
{@const dp = hourly.dew_point_2m[idx]} {@const dp = hourly.dew_point_2m[idx]}
{@const date = dates[idx]} {@const date = dates[idx]}
{@const isNow = isCurrentHour(date)} {@const isNow = isCurrentHour(date)}
{@const isMidnight = date.getHours() === 0} <td class="hour-cell {isNow ? 'now' : ''}">
<td class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
{dp != null ? dp.toFixed(0) + '°' : '-'} {dp != null ? dp.toFixed(0) + '°' : '-'}
</td> </td>
{/each} {/each}
@@ -1229,7 +1200,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div> {/if}
{/if} {/if}
<!-- Sunrise / Sunset Info --> <!-- Sunrise / Sunset Info -->
@@ -1520,86 +1491,25 @@
left: 22px; left: 22px;
} }
/* ═══ Horizontal Scroll Container ═══════════════════════════════════════════ */ /* ═══ Hourly Table Container ═══════════════════════════════════════════════ */
.hourly-scroll { .hourly-container {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
margin-left: -1.25rem;
margin-right: -1.25rem;
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 8px; 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; 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 Data Table ═══════════════════════════════════════════════════ */
.hourly-table { .hourly-table {
border-collapse: collapse; border-collapse: collapse;
white-space: nowrap; white-space: nowrap;
font-size: 11px;
width: 100%; width: 100%;
table-layout: fixed;
}
.hourly-table .col-header {
width: 64px;
} }
.hourly-table tr { .hourly-table tr {
@@ -1611,18 +1521,15 @@
} }
.row-header { .row-header {
position: sticky; width: 64px;
left: 0; padding: 4px 4px;
z-index: 10;
min-width: 90px;
max-width: 90px;
padding: 3px 6px;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
font-size: 11px; font-size: 11px;
background: hsl(var(--background)); background: hsl(var(--background));
border-right: 2px solid hsl(var(--border)); border-right: 2px solid hsl(var(--border));
white-space: nowrap; white-space: nowrap;
overflow: hidden;
} }
.row-header-stack { .row-header-stack {
@@ -1630,7 +1537,13 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0; 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 { .row-unit {
@@ -1639,17 +1552,24 @@
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
/* ═══ Hour Cells ═══════════════════════════════════════════════════════════ */
.hour-cell { .hour-cell {
min-width: 38px; padding: 6px 2px;
max-width: 38px;
padding: 3px 1px;
text-align: center; text-align: center;
font-size: 11px; font-size: 13px;
border-right: 1px solid hsl(var(--border) / 0.4); font-weight: 500;
border-right: 1px solid hsl(var(--border) / 0.3);
overflow: hidden;
} }
.hour-cell.midnight { .interval-3h .hour-cell {
border-left: 2px solid hsl(var(--primary) / 0.5); font-size: 15px;
padding: 8px 4px;
}
.hour-cell:last-child {
border-right: none;
} }
.hour-cell.now { .hour-cell.now {
@@ -1659,32 +1579,111 @@
.time-cell { .time-cell {
font-weight: 700; font-weight: 700;
font-size: 10px;
background: hsl(var(--muted) / 0.3); 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 { .time-sup {
font-size: 7px; font-size: 8px;
vertical-align: super; 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 ═══════════════════════════════════════════════════ */ /* ═══ Precipitation Bar ═══════════════════════════════════════════════════ */
.precip-cell { .precip-cell {
vertical-align: bottom; vertical-align: bottom;
padding: 1px; padding: 2px !important;
} }
.precip-bar-container { .precip-bar-container {
width: 100%; width: 100%;
height: 22px; height: 28px;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
} }
.interval-3h .precip-bar-container {
height: 36px;
}
.precip-bar { .precip-bar {
width: 60%; width: 55%;
min-height: 2px; min-height: 2px;
background: linear-gradient(to top, rgba(30, 136, 229, 0.5), rgba(30, 136, 229, 0.9)); background: linear-gradient(to top, rgba(30, 136, 229, 0.5), rgba(30, 136, 229, 0.9));
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
@@ -1692,16 +1691,22 @@
.precip-val { .precip-val {
display: block; display: block;
font-size: 9px; font-size: 10px;
line-height: 1; line-height: 1.2;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
text-align: center; text-align: center;
margin-top: 1px;
}
.interval-3h .precip-val {
font-size: 12px;
} }
.icon-cell { .icon-cell {
padding: 2px; padding: 4px 2px;
vertical-align: middle; vertical-align: middle;
line-height: 0; line-height: 0;
text-align: center;
} }
.icon-cell > div, .icon-cell > div,
@@ -1709,12 +1714,7 @@
display: inline-block; display: inline-block;
} }
/* ═══ Wind Cell ═══════════════════════════════════════════════════════════ */ /* ═══ Wind Arrow ═══════════════════════════════════════════════════════════ */
.wind-cell {
vertical-align: middle;
line-height: 0;
}
.wind-arrow { .wind-arrow {
display: inline-block; display: inline-block;
@@ -1817,5 +1817,25 @@
.card-temp-min { .card-temp-min {
font-size: 13px; 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;
}
} }
</style> </style>