single day overview
This commit is contained in:
@@ -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<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) => {
|
||||
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<number[]>((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)}
|
||||
|
||||
<div class="hourly-header">
|
||||
<h3 class="hourly-title">
|
||||
@@ -897,26 +898,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Combined Hourly Meteogram + Table -->
|
||||
<div bind:this={tableScrollDiv} class="hourly-scroll">
|
||||
<div class="hourly-inner" style="min-width: {totalWidth + 96}px">
|
||||
<!-- Temperature Curve + Icons Area -->
|
||||
<div class="curve-area">
|
||||
<div class="curve-row-header"></div>
|
||||
<div class="curve-container" style="width: {totalWidth}px; height: {CURVE_H}px">
|
||||
<!-- Cloud cover background -->
|
||||
{#if numCols > 0}
|
||||
<!-- Single-day hourly table (no horizontal scroll) -->
|
||||
<div class="hourly-container">
|
||||
<table class="hourly-table {hourlyInterval === 3 ? 'interval-3h' : 'interval-1h'}">
|
||||
<caption class="sr-only">Hourly weather details for {location.name}</caption>
|
||||
<colgroup>
|
||||
<col class="col-header" />
|
||||
{#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
|
||||
class="curve-svg"
|
||||
width={totalWidth}
|
||||
width="100%"
|
||||
height={CURVE_H}
|
||||
viewBox="0 0 {totalWidth} {CURVE_H}"
|
||||
viewBox="0 0 {svgW} {CURVE_H}"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<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]}
|
||||
<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-opacity="0.45"
|
||||
/>
|
||||
@@ -924,13 +976,13 @@
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Cloud cover bands per cell -->
|
||||
{#each filteredIdx as idx, i (idx)}
|
||||
<!-- Cloud cover bands -->
|
||||
{#each dayIdx as idx, i (idx)}
|
||||
{@const cloud = hourly.cloud_cover[idx]}
|
||||
<rect
|
||||
x={i * CELL_W}
|
||||
x={i * SVG_UNIT}
|
||||
y={0}
|
||||
width={CELL_W}
|
||||
width={SVG_UNIT}
|
||||
height={CURVE_H}
|
||||
fill="rgb(160,175,190)"
|
||||
opacity={getCloudOpacity(cloud)}
|
||||
@@ -939,79 +991,39 @@
|
||||
|
||||
<!-- Temperature area fill -->
|
||||
<path
|
||||
d={buildAreaPath(hourly.temperature_2m, filteredIdx, minTemp, maxTemp)}
|
||||
d={buildAreaPath(hourly.temperature_2m, dayIdx, minTemp, maxTemp)}
|
||||
fill="url(#tempGrad)"
|
||||
/>
|
||||
|
||||
<!-- Temperature curve line -->
|
||||
<path
|
||||
d={buildCurvePath(hourly.temperature_2m, filteredIdx, minTemp, maxTemp)}
|
||||
d={buildCurvePath(hourly.temperature_2m, dayIdx, minTemp, maxTemp)}
|
||||
fill="none"
|
||||
stroke="#d84315"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Temperature value labels -->
|
||||
{#each filteredIdx as idx, i (idx)}
|
||||
<!-- Temperature labels (positioned with % so they align with columns) -->
|
||||
<div class="curve-labels">
|
||||
{#each dayIdx 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)}
|
||||
<text
|
||||
x={i * CELL_W + CELL_W / 2}
|
||||
y={y - 8}
|
||||
text-anchor="middle"
|
||||
class="curve-temp-label"
|
||||
fill="currentColor"
|
||||
{@const yPct =
|
||||
((1 - ((t ?? minTemp) - minTemp) / rangeT) * 0.7 + 0.05) * 100}
|
||||
<span
|
||||
class="curve-label"
|
||||
style="left: {((i + 0.5) / numCols) * 100}%; top: calc({yPct}% - 18px)"
|
||||
>
|
||||
{t?.toFixed(0) ?? '-'}°
|
||||
</text>
|
||||
{/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>
|
||||
</span>
|
||||
{/each}
|
||||
</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>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Precipitation bars row -->
|
||||
@@ -1022,16 +1034,11 @@
|
||||
<span class="row-unit">{params.precipitation_unit === 'mm' ? 'mm' : 'in'}</span>
|
||||
</div>
|
||||
</th>
|
||||
{#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}
|
||||
<td
|
||||
class="hour-cell precip-cell {isNow ? 'now' : ''} {isMidnight
|
||||
? 'midnight'
|
||||
: ''}"
|
||||
>
|
||||
<td class="hour-cell precip-cell {isNow ? 'now' : ''}">
|
||||
<div class="precip-bar-container">
|
||||
{#if val > 0}
|
||||
<div
|
||||
@@ -1050,13 +1057,12 @@
|
||||
<th class="row-header" scope="row">
|
||||
<span class="row-unit">%</span>
|
||||
</th>
|
||||
{#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}
|
||||
<td
|
||||
class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
|
||||
class="hour-cell {isNow ? 'now' : ''}"
|
||||
style="background: {getPrecipProbBg(prob ?? 0)}; color: {getPrecipProbColor(
|
||||
prob ?? 0
|
||||
)}"
|
||||
@@ -1069,23 +1075,20 @@
|
||||
<!-- Precipitation type icons -->
|
||||
<tr>
|
||||
<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>
|
||||
</svg>
|
||||
</th>
|
||||
{#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}
|
||||
<td
|
||||
class="hour-cell icon-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
|
||||
>
|
||||
<td class="hour-cell icon-cell {isNow ? 'now' : ''}">
|
||||
{#if val > 0}
|
||||
<svg
|
||||
class="fill-foreground"
|
||||
width="16px"
|
||||
height="16px"
|
||||
width="18px"
|
||||
height="18px"
|
||||
style="opacity: {Math.min(1, val / 3)}"
|
||||
>
|
||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||
@@ -1095,25 +1098,21 @@
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Wind Speed + Direction -->
|
||||
<!-- Wind Direction Arrows -->
|
||||
<tr>
|
||||
<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>
|
||||
</svg>
|
||||
</th>
|
||||
{#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}
|
||||
<td
|
||||
class="hour-cell wind-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
|
||||
>
|
||||
<td class="hour-cell icon-cell {isNow ? 'now' : ''}">
|
||||
{#if windDir != null && !isNaN(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>
|
||||
</svg>
|
||||
@@ -1126,16 +1125,15 @@
|
||||
<!-- Apparent Temperature (Feels Like) -->
|
||||
<tr>
|
||||
<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>
|
||||
</svg>
|
||||
</th>
|
||||
{#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}
|
||||
<td class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
|
||||
<td class="hour-cell {isNow ? 'now' : ''}">
|
||||
{temp != null ? temp.toFixed(0) + '°' : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
@@ -1144,15 +1142,16 @@
|
||||
<!-- Humidity -->
|
||||
<tr>
|
||||
<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>
|
||||
{#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}
|
||||
<td
|
||||
class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}"
|
||||
class="hour-cell {isNow ? 'now' : ''}"
|
||||
style="background: {getHumidityBg(hum ?? 0)}"
|
||||
>
|
||||
{hum != null ? hum.toFixed(0) + '%' : '-'}
|
||||
@@ -1160,50 +1159,23 @@
|
||||
{/each}
|
||||
</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 -->
|
||||
<tr>
|
||||
<th class="row-header" scope="row">
|
||||
<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>
|
||||
</svg>
|
||||
<span class="row-unit"
|
||||
>{params.wind_speed_unit === 'kmh' ? 'km/h' : params.wind_speed_unit}</span
|
||||
>
|
||||
</div>
|
||||
</th>
|
||||
{#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}
|
||||
<td class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
|
||||
<td class="hour-cell {isNow ? 'now' : ''}">
|
||||
{wind?.toFixed(0) ?? '-'}
|
||||
</td>
|
||||
{/each}
|
||||
@@ -1212,16 +1184,15 @@
|
||||
<!-- Dew Point -->
|
||||
<tr>
|
||||
<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>
|
||||
</svg>
|
||||
</th>
|
||||
{#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}
|
||||
<td class="hour-cell {isNow ? 'now' : ''} {isMidnight ? 'midnight' : ''}">
|
||||
<td class="hour-cell {isNow ? 'now' : ''}">
|
||||
{dp != null ? dp.toFixed(0) + '°' : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
@@ -1229,7 +1200,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Sunrise / Sunset Info -->
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user