temperature chart and table alignments
This commit is contained in:
@@ -31,6 +31,9 @@
|
||||
width?: number;
|
||||
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
|
||||
fill?: boolean;
|
||||
/** Area fill coloured by the value scale (segmentColor), fading to
|
||||
* transparent ~20px below the series minimum. */
|
||||
gradientFill?: boolean;
|
||||
/** With `fill`, fill the area between this line and another data array
|
||||
* instead of the baseline (e.g. an ensemble min-max band) */
|
||||
bandTo?: (number | null)[];
|
||||
@@ -50,6 +53,9 @@
|
||||
shortName?: string;
|
||||
/** Colour each line segment by value (e.g. a temperature colour scale) */
|
||||
segmentColor?: (value: number, index: number) => string;
|
||||
/** Draw the line itself in the theme foreground (black/white), ignoring
|
||||
* segmentColor for the stroke (segmentColor still colours any fill) */
|
||||
foregroundLine?: boolean;
|
||||
/** Draw a contrasting halo (black in light mode, white in dark) under the line */
|
||||
outline?: boolean;
|
||||
/** Annotate local minima / maxima with their value */
|
||||
@@ -101,6 +107,14 @@
|
||||
export function groupRange(name: string): { start: number; end: number } | null {
|
||||
return groups[name]?.range ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current shared crosshair time of a group (epoch seconds, or null), reactive.
|
||||
* Lets outside UI (e.g. the hourly table) mirror the chart's hovered timestep.
|
||||
*/
|
||||
export function groupHover(name: string): number | null {
|
||||
return groups[name]?.hover ?? null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -202,12 +216,66 @@
|
||||
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
|
||||
const HOUR = 3600;
|
||||
// Top icon rows (weather pictograms / wind arrows)
|
||||
const ICON_ROW_H = 30; // reserved height per icon row
|
||||
const ICON_BAND_H = 28; // visible band height
|
||||
const ICON_PX = 26; // pictogram size
|
||||
const ICON_ROW_H = 40; // reserved height per icon row
|
||||
const ICON_BAND_H = 38; // visible band height
|
||||
const ICON_PX = 25; // pictogram size (a touch smaller than the arrows)
|
||||
const ARROW_PX = 36; // wind-direction arrow size
|
||||
// edge inset used for BOTH rows so pictograms and arrows clamp to the same
|
||||
// centre and stay aligned with each other
|
||||
const ICON_EDGE = ARROW_PX / 2;
|
||||
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
|
||||
const CLOUD_BAND_MAX = 40;
|
||||
|
||||
/** Return a colour string with the given alpha (handles rgb/rgba/#hex). */
|
||||
function withAlpha(color: string, alpha: number): string {
|
||||
const m = color.match(/rgba?\(([^)]+)\)/);
|
||||
if (m) {
|
||||
const [r, g, b] = m[1].split(',').map((p) => parseFloat(p));
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
if (color[0] === '#') {
|
||||
const h = color.slice(1);
|
||||
const n =
|
||||
h.length === 3
|
||||
? h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: h;
|
||||
const r = parseInt(n.slice(0, 2), 16);
|
||||
const g = parseInt(n.slice(2, 4), 16);
|
||||
const b = parseInt(n.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// Multiply an rgb/hex colour toward black by `amount` (0 = unchanged, 1 = black).
|
||||
function darken(color: string, amount: number): string {
|
||||
const f = 1 - amount;
|
||||
const m = color.match(/rgba?\(([^)]+)\)/);
|
||||
if (m) {
|
||||
const [r, g, b, a] = m[1].split(',').map((p) => parseFloat(p));
|
||||
const alpha = isNaN(a) ? 1 : a;
|
||||
return `rgba(${Math.round(r * f)}, ${Math.round(g * f)}, ${Math.round(b * f)}, ${alpha})`;
|
||||
}
|
||||
if (color[0] === '#') {
|
||||
const h = color.slice(1);
|
||||
const n =
|
||||
h.length === 3
|
||||
? h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: h;
|
||||
const r = Math.round(parseInt(n.slice(0, 2), 16) * f);
|
||||
const g = Math.round(parseInt(n.slice(2, 4), 16) * f);
|
||||
const b = Math.round(parseInt(n.slice(4, 6), 16) * f);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
@@ -455,6 +523,8 @@
|
||||
for (const p of pictograms) {
|
||||
if (p.t < viewStart || p.t > viewEnd) continue;
|
||||
const x = iconBandX(p.t);
|
||||
// keep clear of the band edges so the first/last never bunch or clip
|
||||
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
|
||||
if (x - lastX < 40) continue;
|
||||
out.push({ x, icon: p.icon });
|
||||
lastX = x;
|
||||
@@ -470,6 +540,7 @@
|
||||
for (const a of windArrows) {
|
||||
if (a.t < viewStart || a.t > viewEnd) continue;
|
||||
const x = iconBandX(a.t);
|
||||
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
|
||||
if (x - lastX < 40) continue;
|
||||
out.push({ x, deg: a.deg });
|
||||
lastX = x;
|
||||
@@ -582,7 +653,9 @@
|
||||
const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)');
|
||||
const bgColor = cssColor('--card', '#ffffff');
|
||||
const dark = document.documentElement.classList.contains('dark');
|
||||
const outlineColor = dark ? '#ffffff' : '#000000';
|
||||
// halo matches the background (white in light mode, dark in dark mode) so a
|
||||
// line reads clearly where it crosses others
|
||||
const outlineColor = bgColor;
|
||||
|
||||
const plotRight = padLeft + plotW;
|
||||
const plotBottom = padTop + plotH;
|
||||
@@ -753,6 +826,59 @@
|
||||
for (const points of runs) {
|
||||
if (points.length === 0) continue;
|
||||
|
||||
if (s.gradientFill && points.length > 1) {
|
||||
// Coloured fill anchored at the curve, fading out towards zero: down
|
||||
// for positive values, up for all-negative values. Full opacity near
|
||||
// the far-from-zero extreme, fading ~30px past the near-zero extreme.
|
||||
let minV = Infinity;
|
||||
let maxV = -Infinity;
|
||||
for (const p of points) {
|
||||
const val = s.data[p[3]] as number;
|
||||
if (val < minV) minV = val;
|
||||
if (val > maxV) maxV = val;
|
||||
}
|
||||
const goUp = maxV <= 0; // all non-positive → fill toward zero (upward)
|
||||
const maxYp = yPix(maxV, axis);
|
||||
const minYp = yPix(minV, axis);
|
||||
// anchor = opaque end (far-from-zero extreme); fade = transparent end
|
||||
const anchorY = goUp ? minYp : maxYp;
|
||||
const nearY = goUp ? maxYp : minYp;
|
||||
// fade runs a good stretch past the near extreme, but stops short of
|
||||
// the plot edge (~60% of the way there)
|
||||
const fadeY = goUp
|
||||
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.6)
|
||||
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.6);
|
||||
const anchorV = goUp ? minV : maxV;
|
||||
const nearV = goUp ? maxV : minV;
|
||||
const span = fadeY - anchorY;
|
||||
// begin the fade a touch (~10px) BEFORE the near-zero extreme
|
||||
const fadeStartY = nearY - Math.sign(nearY - anchorY) * 10;
|
||||
const fadeStart =
|
||||
span !== 0 ? Math.max(0, Math.min(0.95, (fadeStartY - anchorY) / span)) : 0.6;
|
||||
const colorFn = s.segmentColor ?? (() => s.color);
|
||||
const FULL = 0.8;
|
||||
const grad = ctx.createLinearGradient(0, anchorY, 0, fadeY);
|
||||
// full colour from the curve down to just before the near extreme…
|
||||
const STOPS = 6;
|
||||
for (let k = 0; k <= STOPS; k++) {
|
||||
const t = k / STOPS;
|
||||
const val = anchorV + t * (nearV - anchorV);
|
||||
grad.addColorStop(t * fadeStart, withAlpha(colorFn(val, 0), FULL));
|
||||
}
|
||||
// …then a long, gentle fade out to the plot edge
|
||||
grad.addColorStop(fadeStart, withAlpha(colorFn(nearV, 0), FULL));
|
||||
grad.addColorStop(1, withAlpha(colorFn(nearV, 0), 0));
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||||
ctx.lineTo(points[points.length - 1][0], fadeY);
|
||||
ctx.lineTo(points[0][0], fadeY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (s.fill && points.length > 1) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
@@ -775,7 +901,7 @@
|
||||
if (lineWidth > 0) {
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.setLineDash(s.dashed ? [6, 4] : []);
|
||||
ctx.setLineDash(s.dashed ? [8, 8] : []);
|
||||
|
||||
// Contrasting halo drawn under the line so a multi-colour line
|
||||
// stays legible over any background.
|
||||
@@ -789,7 +915,7 @@
|
||||
}
|
||||
|
||||
ctx.lineWidth = lineWidth;
|
||||
if (s.segmentColor) {
|
||||
if (s.segmentColor && !s.foregroundLine) {
|
||||
// Colour each segment by its value (temperature colour scale).
|
||||
// `idx[i]` maps a run point back to its source data index.
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
@@ -800,6 +926,37 @@
|
||||
ctx.lineTo(points[i][0], points[i][1]);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else if (s.foregroundLine) {
|
||||
// Foreground line drawn on top of its own fill. Stroke the whole
|
||||
// path once with a horizontal gradient sampled from the value
|
||||
// colour scale, so the colour flows smoothly along the line
|
||||
// instead of stepping at each data point.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
|
||||
if (s.segmentColor && points.length > 1) {
|
||||
const x0 = points[0][0];
|
||||
const x1 = points[points.length - 1][0];
|
||||
const span = x1 - x0 || 1;
|
||||
const grad = ctx.createLinearGradient(x0, 0, x1, 0);
|
||||
let prevT = -1;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
let t = (points[i][0] - x0) / span;
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing
|
||||
if (t > 1) t = 1;
|
||||
prevT = t;
|
||||
// a touch darker than the fill so the line reads as its edge
|
||||
grad.addColorStop(
|
||||
t,
|
||||
darken(s.segmentColor(s.data[points[i][3]] as number, points[i][3]), 0.05)
|
||||
);
|
||||
}
|
||||
ctx.strokeStyle = grad;
|
||||
} else {
|
||||
ctx.strokeStyle = strongColor;
|
||||
}
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
@@ -829,7 +986,12 @@
|
||||
const x = xPix(t);
|
||||
const y = yPix(v, axis);
|
||||
const label = fmt(v);
|
||||
const ly = ext.type === 'max' ? y - off : y + off + 8;
|
||||
// keep the label inside the plot vertically (never under the icon
|
||||
// band above or clipped at the bottom)
|
||||
const ly = Math.max(
|
||||
padTop + 11,
|
||||
Math.min(plotBottom - 3, ext.type === 'max' ? y - off : y + off + 8)
|
||||
);
|
||||
// keep the centred label fully inside the plot so it never clips
|
||||
const halfW = ctx.measureText(label).width / 2 + 2;
|
||||
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
|
||||
@@ -1142,11 +1304,12 @@
|
||||
style:height="{ICON_BAND_H}px"
|
||||
>
|
||||
{#each visiblePictograms as p (p.x)}
|
||||
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x))}
|
||||
<svg
|
||||
class="absolute top-px fill-foreground"
|
||||
class="absolute top-1/2 -translate-y-1/2 fill-foreground"
|
||||
width={ICON_PX}
|
||||
height={ICON_PX}
|
||||
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, p.x - ICON_PX / 2))}px"
|
||||
style:left="{cx - ICON_PX / 2}px"
|
||||
>
|
||||
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
|
||||
</svg>
|
||||
@@ -1164,14 +1327,15 @@
|
||||
style:height="{ICON_BAND_H}px"
|
||||
>
|
||||
{#each visibleWindArrows as a (a.x)}
|
||||
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x))}
|
||||
<span
|
||||
class="absolute top-px inline-flex items-center justify-center"
|
||||
style:width="{ICON_PX}px"
|
||||
style:height="{ICON_PX}px"
|
||||
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px"
|
||||
style:transform="rotate({a.deg}deg)"
|
||||
class="absolute top-1/2 inline-flex items-center justify-center"
|
||||
style:width="{ARROW_PX}px"
|
||||
style:height="{ARROW_PX}px"
|
||||
style:left="{cx - ARROW_PX / 2}px"
|
||||
style:transform="translateY(-50%) rotate({a.deg}deg)"
|
||||
>
|
||||
<svg class="fill-foreground/80" width="22" height="22">
|
||||
<svg class="fill-foreground/80" width={ARROW_PX} height={ARROW_PX}>
|
||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
* import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts';
|
||||
*/
|
||||
|
||||
export { default as CanvasChart, setGroupHover, groupRange } from './CanvasChart.svelte';
|
||||
export {
|
||||
default as CanvasChart,
|
||||
setGroupHover,
|
||||
groupRange,
|
||||
groupHover
|
||||
} from './CanvasChart.svelte';
|
||||
export type { ChartSeries } from './CanvasChart.svelte';
|
||||
|
||||
export { buildDaylightBands } from './bands';
|
||||
|
||||
@@ -106,12 +106,18 @@
|
||||
column on md+ (main has 2rem padding) for extra readability. */
|
||||
margin-left: -0.75rem;
|
||||
margin-right: -0.75rem;
|
||||
/* overflow-y is pinned (never `visible`): a bare `overflow-x: auto`
|
||||
makes the browser compute overflow-y as `auto` too, which turns the
|
||||
chart into a 1-2px vertical micro-scroller that swallows page scroll. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.chart-bleed.no-bleed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
/* content fits the column, so no horizontal scroller is needed */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
|
||||
@@ -19,13 +19,12 @@
|
||||
</ChartToolbar>
|
||||
-->
|
||||
<script module lang="ts">
|
||||
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
|
||||
export interface DownloadableChart {
|
||||
getPngDataUrl(): string | null;
|
||||
}
|
||||
export type { DownloadableChart } from './downloadChartsPng';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { type DownloadableChart, downloadChartsPng } from './downloadChartsPng';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
@@ -58,70 +57,11 @@
|
||||
|
||||
// ─── Download ───────────────────────────────────────────────────────────────
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(img);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves the page background so exports match the current theme. */
|
||||
function exportBackground(): string {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
return bg || '#ffffff';
|
||||
}
|
||||
|
||||
function triggerDownload(url: string, name: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDownload(): Promise<void> {
|
||||
if (!hasCharts || downloading) return;
|
||||
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
const dataUrls = charts
|
||||
.filter((chart): chart is DownloadableChart => chart != null)
|
||||
.map((chart) => chart.getPngDataUrl())
|
||||
.filter((url): url is string => url !== null);
|
||||
if (dataUrls.length === 0) return;
|
||||
|
||||
const images = (await Promise.all(dataUrls.map(loadImage))).filter(
|
||||
(img) => img.naturalWidth > 0
|
||||
);
|
||||
if (images.length === 0) return;
|
||||
|
||||
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
|
||||
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = totalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = exportBackground();
|
||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||
|
||||
let y = 0;
|
||||
for (const img of images) {
|
||||
ctx.drawImage(img, 0, y);
|
||||
y += img.naturalHeight;
|
||||
}
|
||||
|
||||
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
||||
await downloadChartsPng(charts, fileName);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
downloading = false;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Shared PNG export for charts.
|
||||
*
|
||||
* Stitches one or more chart images vertically onto a single canvas (over the
|
||||
* current theme background) and triggers a download. Used by both the standalone
|
||||
* ChartToolbar and the inline toolbar buttons.
|
||||
*/
|
||||
|
||||
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
|
||||
export interface DownloadableChart {
|
||||
getPngDataUrl(): string | null;
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(img);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves the page background so exports match the current theme. */
|
||||
function exportBackground(): string {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
return bg || '#ffffff';
|
||||
}
|
||||
|
||||
function triggerDownload(url: string, name: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stitch the given charts into one PNG and download it. Resolves once the
|
||||
* download has been triggered (or immediately if there is nothing to export).
|
||||
*/
|
||||
export async function downloadChartsPng(
|
||||
charts: Array<DownloadableChart | undefined | null>,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
const dataUrls = charts
|
||||
.filter((chart): chart is DownloadableChart => chart != null)
|
||||
.map((chart) => chart.getPngDataUrl())
|
||||
.filter((url): url is string => url !== null);
|
||||
if (dataUrls.length === 0) return;
|
||||
|
||||
const images = (await Promise.all(dataUrls.map(loadImage))).filter((img) => img.naturalWidth > 0);
|
||||
if (images.length === 0) return;
|
||||
|
||||
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
|
||||
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = totalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = exportBackground();
|
||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||
|
||||
let y = 0;
|
||||
for (const img of images) {
|
||||
ctx.drawImage(img, 0, y);
|
||||
y += img.naturalHeight;
|
||||
}
|
||||
|
||||
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
||||
}
|
||||
@@ -9,3 +9,4 @@
|
||||
|
||||
export { default as ChartContainer } from './ChartContainer.svelte';
|
||||
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
||||
export { downloadChartsPng, type DownloadableChart } from './downloadChartsPng';
|
||||
|
||||
@@ -65,7 +65,14 @@ export const defaultVariablePrefs: VariablePrefs = {
|
||||
wind: true,
|
||||
humidity: true,
|
||||
clouds: true,
|
||||
precipitation: true
|
||||
precipitation: true,
|
||||
// extra rows, off by default
|
||||
dew_point: false,
|
||||
gusts: false,
|
||||
pressure: false,
|
||||
uv: false,
|
||||
visibility: false,
|
||||
snowfall: false
|
||||
},
|
||||
charts: {
|
||||
temperature: true,
|
||||
@@ -79,6 +86,9 @@ export const defaultVariablePrefs: VariablePrefs = {
|
||||
|
||||
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
||||
|
||||
/** Hourly table interval: 3-hourly (default) or 1-hourly. */
|
||||
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
|
||||
|
||||
/**
|
||||
* Meteogram layout: an ordered list of chart panels, each holding an ordered
|
||||
* list of variable keys (see the chart variable registry). Users drag
|
||||
@@ -90,9 +100,9 @@ export interface ChartPanel {
|
||||
}
|
||||
|
||||
export const defaultChartLayout: ChartPanel[] = [
|
||||
{ id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] },
|
||||
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
|
||||
{ id: 'panel-3', variables: ['wind', 'wind_direction', 'humidity'] }
|
||||
{ id: 'panel-1', variables: ['temperature', 'weather_icons'] },
|
||||
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability', 'cloud_cover'] },
|
||||
{ id: 'panel-3', variables: ['wind', 'wind_gusts', 'wind_direction'] }
|
||||
];
|
||||
|
||||
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
|
||||
|
||||
@@ -88,8 +88,9 @@
|
||||
{#if fullBleed}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<!-- cap the content width on very large screens -->
|
||||
<div class="mx-auto w-full max-w-[1536px]">
|
||||
<!-- cap the content width on very large screens; generous bottom room
|
||||
so the last chart/table never sits flush against the viewport edge -->
|
||||
<div class="mx-auto w-full max-w-[1536px] pb-80">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -165,6 +165,21 @@
|
||||
unit: string;
|
||||
showCredit: boolean;
|
||||
series: ChartSeries[];
|
||||
zeroBaseLeft?: boolean;
|
||||
yMin?: number;
|
||||
yMax?: number;
|
||||
}
|
||||
|
||||
// Sensible axis behaviour per variable so the y-scale stays readable (e.g.
|
||||
// pressure never anchored to zero; percentages pinned to 0-100).
|
||||
function axisForVar(v: string): { zeroBaseLeft: boolean; yMin?: number; yMax?: number } {
|
||||
if (['pressure_msl', 'surface_pressure', 'temperature_2m', 'dew_point_2m'].includes(v)) {
|
||||
return { zeroBaseLeft: false };
|
||||
}
|
||||
if (['relative_humidity_2m', 'cloud_cover', 'precipitation_probability'].includes(v)) {
|
||||
return { zeroBaseLeft: true, yMin: 0, yMax: 100 };
|
||||
}
|
||||
return { zeroBaseLeft: true };
|
||||
}
|
||||
|
||||
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
|
||||
@@ -202,33 +217,40 @@
|
||||
const isColumn = isColumnUnit(unit);
|
||||
const memberCount = varData.members.length;
|
||||
|
||||
// Trim to the valid horizon so the axis scale ignores the trailing
|
||||
// zeros the service pads past the model's range.
|
||||
const vMax = varData.max.slice(0, validLength);
|
||||
const vMin = varData.min.slice(0, validLength);
|
||||
const vAvg = varData.average.slice(0, validLength);
|
||||
|
||||
// Min/max spread band + mean, instead of every individual member
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
name: 'Max',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: varData.max,
|
||||
data: vMax,
|
||||
width: 1,
|
||||
fill: true,
|
||||
fillOpacity: 0.25,
|
||||
bandTo: varData.min,
|
||||
bandTo: vMin,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
},
|
||||
{
|
||||
name: 'Mean',
|
||||
type: isColumn ? 'bar' : 'line',
|
||||
color: CHART_COLORS.average,
|
||||
data: varData.average,
|
||||
width: 3,
|
||||
data: vAvg,
|
||||
width: 3.5,
|
||||
dashed: !isColumn,
|
||||
outline: !isColumn,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
},
|
||||
{
|
||||
name: 'Min',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: varData.min,
|
||||
data: vMin,
|
||||
width: 1,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
}
|
||||
@@ -237,6 +259,7 @@
|
||||
const isFirst = vi === 0;
|
||||
const isLast = vi === variables.length - 1;
|
||||
|
||||
const axis = axisForVar(variable);
|
||||
defs.push({
|
||||
// each chart is labelled so the variable is obvious at a glance
|
||||
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
|
||||
@@ -245,7 +268,10 @@
|
||||
: `min · mean · max (${unit})`,
|
||||
unit,
|
||||
showCredit: isLast,
|
||||
series
|
||||
series,
|
||||
zeroBaseLeft: axis.zeroBaseLeft,
|
||||
yMin: axis.yMin,
|
||||
yMax: axis.yMax
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,7 +281,7 @@
|
||||
|
||||
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
|
||||
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||
@@ -275,7 +301,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center gap-3 sm:w-auto">
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
groups={ensembleModelGroups}
|
||||
@@ -322,7 +348,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300}>
|
||||
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300} bleed={false}>
|
||||
{#if fetchedData}
|
||||
{#each chartDefs as def, i (i)}
|
||||
<CanvasChart
|
||||
@@ -335,6 +361,9 @@
|
||||
title={def.title}
|
||||
subtitle={def.subtitle}
|
||||
showCredit={def.showCredit}
|
||||
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
||||
yMin={def.yMin}
|
||||
yMax={def.yMax}
|
||||
{showLegend}
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
|
||||
@@ -257,7 +257,8 @@
|
||||
color: CHART_COLORS.average,
|
||||
data: average,
|
||||
width: 4,
|
||||
dashed: !isColumn
|
||||
dashed: !isColumn,
|
||||
outline: !isColumn
|
||||
});
|
||||
|
||||
const isFirst = vi === 0;
|
||||
@@ -281,7 +282,7 @@
|
||||
|
||||
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
|
||||
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||
@@ -302,7 +303,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground lg:inline">
|
||||
drag or
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
|
||||
@@ -362,6 +363,7 @@
|
||||
{loading}
|
||||
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
||||
chartHeight={300}
|
||||
bleed={false}
|
||||
>
|
||||
{#if fetchedData}
|
||||
{#each chartDefs as def, i (i)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import {
|
||||
storedChartLayout,
|
||||
@@ -152,7 +153,7 @@
|
||||
<div class="week-page">
|
||||
<div class="weather-content" style="min-height: 50vh">
|
||||
<!-- Page hero: prominent location + weather model selection -->
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||
@@ -174,7 +175,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||
onModelChange={(model) => {
|
||||
@@ -220,14 +221,17 @@
|
||||
/>
|
||||
{:else}
|
||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
||||
<div class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if fetchedHourly}
|
||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||
{:else}
|
||||
<!-- reserve the exact chart area height before the first fetch resolves -->
|
||||
<section class="mt-8">
|
||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -119,13 +119,13 @@
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div transition:fade={{ duration: 200 }} class="mb-6 min-h-[260px]">
|
||||
<div transition:fade={{ duration: 200 }} class="mb-1 min-h-47.5 md:mb-6 md:min-h-65">
|
||||
<!-- negative margin + matching padding: the scroll box gains room so a
|
||||
lifted/scaled/shadowed card is never clipped, while the first card still
|
||||
lines up with the page content edge -->
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
|
||||
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-2 pb-3 md:pt-5 md:pb-11"
|
||||
class:scrolling
|
||||
onscroll={onScroll}
|
||||
>
|
||||
@@ -187,7 +187,7 @@
|
||||
<button
|
||||
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
|
||||
{selected
|
||||
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60'
|
||||
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
|
||||
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => onSelectDay(time, index)}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedVariablePrefs } from '$lib/stores/settings';
|
||||
import {
|
||||
defaultVariablePrefs,
|
||||
storedHourlyInterval,
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { setGroupHover } from '$lib/charts';
|
||||
import { groupHover, setGroupHover } from '$lib/charts';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
@@ -53,11 +57,15 @@
|
||||
setGroupHover(METEOGRAM_GROUP, null);
|
||||
}
|
||||
|
||||
let hourlyInterval = $state<1 | 3>(3);
|
||||
// persisted 1h / 3h preference
|
||||
let hourlyInterval = $derived($storedHourlyInterval);
|
||||
|
||||
// Row visibility, controlled from the Variables sidebar (missing keys
|
||||
// from older stored prefs default to visible)
|
||||
let showRow = $derived((key: string): boolean => $storedVariablePrefs.table?.[key] ?? true);
|
||||
let showRow = $derived(
|
||||
(key: string): boolean =>
|
||||
$storedVariablePrefs.table?.[key] ?? defaultVariablePrefs.table[key] ?? true
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
const tempUnit = $derived(getTempUnit(units));
|
||||
@@ -152,6 +160,67 @@
|
||||
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
|
||||
}
|
||||
|
||||
// ─── Maps-project colour ramps (open-meteo/maps) ────────────────────────────
|
||||
// Same breakpoint colours the weather maps use, applied as cell backgrounds.
|
||||
function hexRgb(hex: string): [number, number, number] {
|
||||
const h = hex.replace('#', '');
|
||||
const n =
|
||||
h.length === 3
|
||||
? h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: h;
|
||||
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
|
||||
}
|
||||
function mixRgb(
|
||||
a: [number, number, number],
|
||||
b: [number, number, number],
|
||||
f: number
|
||||
): [number, number, number] {
|
||||
return [
|
||||
Math.round(a[0] + (b[0] - a[0]) * f),
|
||||
Math.round(a[1] + (b[1] - a[1]) * f),
|
||||
Math.round(a[2] + (b[2] - a[2]) * f)
|
||||
];
|
||||
}
|
||||
|
||||
// Pressure: 940 hPa blue → 1010 white → 1060 red.
|
||||
const PRESSURE_LOW = hexRgb('#4444ff');
|
||||
const PRESSURE_MID = hexRgb('#ffffff');
|
||||
const PRESSURE_HIGH = hexRgb('#ff4444');
|
||||
function getPressureBg(hpa: number | null): string {
|
||||
if (hpa == null || isNaN(hpa)) return 'transparent';
|
||||
const v = Math.max(940, Math.min(1060, hpa));
|
||||
const c =
|
||||
v <= 1010
|
||||
? mixRgb(PRESSURE_LOW, PRESSURE_MID, (v - 940) / 70)
|
||||
: mixRgb(PRESSURE_MID, PRESSURE_HIGH, (v - 1010) / 50);
|
||||
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.5)`;
|
||||
}
|
||||
|
||||
// UV index: 0 → 12 across the maps' teal→green→yellow→orange→pink ramp. The
|
||||
// opacity tracks the value so low/night hours stay faint instead of tinting
|
||||
// the whole row.
|
||||
const UV_STOPS = [
|
||||
'#009392',
|
||||
'#39b185',
|
||||
'#9ccb86',
|
||||
'#e9e29c',
|
||||
'#eeb479',
|
||||
'#e88471',
|
||||
'#cf597e'
|
||||
].map(hexRgb);
|
||||
function getUvBg(uv: number | null): string {
|
||||
if (uv == null || isNaN(uv) || uv <= 0) return 'transparent';
|
||||
const v = Math.min(12, uv);
|
||||
const p = (v / 12) * (UV_STOPS.length - 1);
|
||||
const i = Math.min(UV_STOPS.length - 2, Math.floor(p));
|
||||
const c = mixRgb(UV_STOPS[i], UV_STOPS[i + 1], p - i);
|
||||
const alpha = 0.18 + (v / 12) * 0.5;
|
||||
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha.toFixed(3)})`;
|
||||
}
|
||||
|
||||
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
|
||||
const parts: string[] = [];
|
||||
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
|
||||
@@ -211,6 +280,21 @@
|
||||
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
|
||||
: null
|
||||
);
|
||||
|
||||
// ─── Chart-hover mirror ─────────────────────────────────────────────────────
|
||||
// When the shared meteogram is hovered, highlight the matching table column
|
||||
// (only if the hovered time falls on the day the table is currently showing).
|
||||
let chartHoverTime = $derived(groupHover(METEOGRAM_GROUP)); // epoch seconds, or null
|
||||
let hoveredCol = $derived.by((): number => {
|
||||
if (chartHoverTime == null || cellData.length === 0) return -1;
|
||||
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
|
||||
const tMs = chartHoverTime * 1000;
|
||||
for (let i = 0; i < cellData.length; i++) {
|
||||
const start = cellData[i].date.getTime();
|
||||
if (tMs >= start && tMs < start + stepMs) return i;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet weatherIcon(name: string, size: number = 16)}
|
||||
@@ -292,7 +376,7 @@
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={hourlyInterval === interval}
|
||||
onclick={() => (hourlyInterval = interval as 1 | 3)}
|
||||
onclick={() => storedHourlyInterval.set(interval as 1 | 3)}
|
||||
>
|
||||
{interval}h
|
||||
</button>
|
||||
@@ -324,7 +408,7 @@
|
||||
</th>
|
||||
<td
|
||||
colspan={cellData.length}
|
||||
class="relative h-11 overflow-visible p-0"
|
||||
class="relative h-12 overflow-visible p-0"
|
||||
onmousemove={hoverTimeRow}
|
||||
onmouseleave={clearTimeRowHover}
|
||||
>
|
||||
@@ -440,7 +524,7 @@
|
||||
{#each cellData as cell, i (cell.idx)}
|
||||
{@const wCode = hourly.weather_code[cell.idx]}
|
||||
<td
|
||||
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
|
||||
class="cell h-12 leading-0"
|
||||
class:icon-day={cell.isDaytime}
|
||||
class:icon-night={!cell.isDaytime}
|
||||
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
||||
@@ -462,7 +546,7 @@
|
||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||
<td
|
||||
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||
style="background-color:{style.bg};color:{style.fg}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
@@ -471,13 +555,30 @@
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Feels Like -->
|
||||
<!-- Feels Like (short row) -->
|
||||
{#if showRow('feels')}
|
||||
<tr class="row">
|
||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||
<td
|
||||
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Dew Point (short row; off by default) -->
|
||||
{#if showRow('dew_point')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.dew_point_2m?.[cell.idx]}
|
||||
<td
|
||||
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
@@ -491,16 +592,16 @@
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||
<td class="cell h-13 align-middle leading-tight">
|
||||
<td class="relative cell h-12 align-middle leading-none">
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="inline-block leading-0"
|
||||
style="transform:{getWindArrowRotation(windDir)}"
|
||||
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
|
||||
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
|
||||
>
|
||||
{@render weatherIcon('wi-direction-down', 22)}
|
||||
{@render weatherIcon('wi-direction-down', 40)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
|
||||
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||
{formatValue(wind)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -508,13 +609,30 @@
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Humidity -->
|
||||
<!-- Wind Gusts (off by default) -->
|
||||
{#if showRow('gusts')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
|
||||
<td
|
||||
class="cell h-12 font-semibold text-foreground/80 {is3h
|
||||
? 'text-sm'
|
||||
: 'text-xs'}"
|
||||
>
|
||||
{formatValue(v)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Humidity (short row) -->
|
||||
{#if showRow('humidity')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-humidity', '%', 'Humidity')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
|
||||
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
|
||||
{formatValue(hum)}
|
||||
</td>
|
||||
{/each}
|
||||
@@ -528,7 +646,7 @@
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||
<td
|
||||
class="cell h-8"
|
||||
class="cell h-12"
|
||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||
>
|
||||
{formatValue(cloud)}
|
||||
@@ -537,6 +655,51 @@
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Pressure (off by default) -->
|
||||
{#if showRow('pressure')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.pressure_msl?.[cell.idx]}
|
||||
<td
|
||||
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
|
||||
style="background:{getPressureBg(v ?? null)}"
|
||||
>
|
||||
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- UV Index (off by default) -->
|
||||
{#if showRow('uv')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.uv_index?.[cell.idx]}
|
||||
<td
|
||||
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
|
||||
style="background:{getUvBg(v ?? null)}"
|
||||
>
|
||||
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Visibility (off by default) -->
|
||||
{#if showRow('visibility')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-fog', 'km', 'Visibility')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.visibility?.[cell.idx]}
|
||||
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
||||
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Precipitation -->
|
||||
{#if showRow('precipitation')}
|
||||
<tr class="row">
|
||||
@@ -545,7 +708,7 @@
|
||||
{@const precip = hourly.precipitation[cell.idx]}
|
||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||
<td
|
||||
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
|
||||
class="cell precip-cell h-12"
|
||||
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||
title={formatPrecipTooltip(precip, prob)}
|
||||
>
|
||||
@@ -559,9 +722,31 @@
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Snowfall (off by default) -->
|
||||
{#if showRow('snowfall')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-snow', 'cm', 'Snow')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const v = hourly.snowfall?.[cell.idx]}
|
||||
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
||||
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
|
||||
{#if hoveredCol >= 0 && tableWidth > 0}
|
||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
|
||||
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- "Now" column highlight + exact-time line -->
|
||||
{#if nowLeftPx != null}
|
||||
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||
|
||||
import { CanvasChart, groupRange } from '$lib/charts';
|
||||
|
||||
@@ -29,6 +29,17 @@
|
||||
const CHART_HEIGHT = 300;
|
||||
|
||||
let customizerOpen = $state(false);
|
||||
let downloadingPng = $state(false);
|
||||
|
||||
async function downloadPng(): Promise<void> {
|
||||
if (liveCharts.length === 0 || downloadingPng) return;
|
||||
downloadingPng = true;
|
||||
try {
|
||||
await downloadChartsPng(liveCharts, 'week-forecast');
|
||||
} finally {
|
||||
setTimeout(() => (downloadingPng = false), 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Charts persist across data refetches; entries are null while unmounted.
|
||||
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||
@@ -224,6 +235,40 @@
|
||||
</svg>
|
||||
Customize
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={liveCharts.length === 0 || downloadingPng}
|
||||
onclick={downloadPng}
|
||||
title="Download meteogram as PNG image"
|
||||
>
|
||||
{#if downloadingPng}
|
||||
<svg
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
{/if}
|
||||
PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -286,10 +331,6 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -17,10 +17,16 @@
|
||||
{ key: 'icons', label: 'Weather icons' },
|
||||
{ key: 'temperature', label: 'Temperature' },
|
||||
{ key: 'feels', label: 'Feels like' },
|
||||
{ key: 'dew_point', label: 'Dew point' },
|
||||
{ key: 'wind', label: 'Wind' },
|
||||
{ key: 'gusts', label: 'Wind gusts' },
|
||||
{ key: 'humidity', label: 'Humidity' },
|
||||
{ key: 'clouds', label: 'Cloud cover' },
|
||||
{ key: 'precipitation', label: 'Precipitation' }
|
||||
{ key: 'pressure', label: 'Pressure' },
|
||||
{ key: 'uv', label: 'UV index' },
|
||||
{ key: 'visibility', label: 'Visibility' },
|
||||
{ key: 'precipitation', label: 'Precipitation' },
|
||||
{ key: 'snowfall', label: 'Snowfall' }
|
||||
];
|
||||
|
||||
function toggle(section: 'table' | 'charts', key: string) {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* (data field, render style, colour, unit family) so the panels can be
|
||||
* assembled dynamically from a user-defined layout.
|
||||
*/
|
||||
import { defaultVariablePrefs } from '$lib/stores/settings';
|
||||
|
||||
import { getColor } from '../../utils/colors';
|
||||
import {
|
||||
type WeatherUnits,
|
||||
@@ -36,9 +38,14 @@ export interface ChartVariableDef {
|
||||
dashed?: boolean;
|
||||
fill?: boolean;
|
||||
fillOpacity?: number;
|
||||
/** Area fill coloured by the value scale, fading out below the minimum */
|
||||
gradientFill?: boolean;
|
||||
width?: number;
|
||||
/** Stroke the line coloured by the temperature scale */
|
||||
colorScale?: boolean;
|
||||
/** Draw the line in the theme foreground (black/white), while colorScale still
|
||||
* drives the gradient fill */
|
||||
foregroundLine?: boolean;
|
||||
/** Draw a contrasting halo under the line */
|
||||
outline?: boolean;
|
||||
/** Annotate local minima / maxima with their value */
|
||||
@@ -66,9 +73,10 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
|
||||
type: 'line',
|
||||
kind: 'temp',
|
||||
color: '#ef6c00',
|
||||
width: 9,
|
||||
width: 5.6,
|
||||
colorScale: true,
|
||||
outline: true,
|
||||
foregroundLine: true,
|
||||
gradientFill: true,
|
||||
extrema: true
|
||||
},
|
||||
{
|
||||
@@ -312,23 +320,29 @@ export function neededHourlyApiVars(
|
||||
tablePrefs: Record<string, boolean> | undefined,
|
||||
layoutKeys: string[]
|
||||
): string[] {
|
||||
const on = (key: string): boolean => tablePrefs?.[key] ?? true;
|
||||
const on = (key: string): boolean => tablePrefs?.[key] ?? defaultVariablePrefs.table[key] ?? true;
|
||||
const s = new Set<string>();
|
||||
|
||||
// Hourly table rows
|
||||
if (on('icons')) s.add('weather_code');
|
||||
if (on('temperature')) s.add('temperature_2m');
|
||||
if (on('feels')) s.add('apparent_temperature');
|
||||
if (on('dew_point')) s.add('dew_point_2m');
|
||||
if (on('wind')) {
|
||||
s.add('wind_speed_10m');
|
||||
s.add('wind_direction_10m');
|
||||
}
|
||||
if (on('gusts')) s.add('wind_gusts_10m');
|
||||
if (on('humidity')) s.add('relative_humidity_2m');
|
||||
if (on('clouds')) s.add('cloud_cover');
|
||||
if (on('pressure')) s.add('pressure_msl');
|
||||
if (on('uv')) s.add('uv_index');
|
||||
if (on('visibility')) s.add('visibility');
|
||||
if (on('precipitation')) {
|
||||
s.add('precipitation');
|
||||
s.add('precipitation_probability');
|
||||
}
|
||||
if (on('snowfall')) s.add('snowfall');
|
||||
|
||||
// Meteogram variables
|
||||
for (const key of layoutKeys) {
|
||||
@@ -454,10 +468,12 @@ export function buildPanelDef(
|
||||
width: d.width,
|
||||
fill: d.fill,
|
||||
fillOpacity: d.fillOpacity,
|
||||
gradientFill: d.gradientFill,
|
||||
dashed: d.dashed,
|
||||
axis,
|
||||
cloudBand: d.cloudBand,
|
||||
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
|
||||
foregroundLine: d.foregroundLine,
|
||||
outline: d.outline,
|
||||
labelExtrema: d.extrema,
|
||||
labelFormat: d.extrema
|
||||
|
||||
Reference in New Issue
Block a user