visual updates

This commit is contained in:
Vincent van der Wal
2026-07-25 13:56:16 +02:00
parent cd98b7a5cc
commit c7924bb0ae
19 changed files with 485 additions and 201 deletions
+251 -60
View File
@@ -250,32 +250,6 @@
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();
@@ -323,17 +297,20 @@
let isNarrow = $derived(width > 0 && width < 520);
let padLeft = $derived(isNarrow ? 26 : 60);
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
// has a right axis, so a stacked row of charts share the same plot width.
let padRight = $derived(
hasRightAxis || reserveRightAxis ? (isNarrow ? 34 : 56) : isNarrow ? 6 : 20
);
// has a right axis, so a stacked row of charts share the same plot width. On
// narrow (mobile) screens we don't reserve it — the graph uses the full width
// and the right-axis labels are drawn overlaid on top instead (see below).
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
// a stacked row of charts the same height even if some have fewer icon rows.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + iconRows * ICON_ROW_H);
// tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM);
let plotW = $derived(Math.max(1, width - padLeft - padRight));
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
let plotH = $derived(Math.max(1, height - padTop - padBottom));
interface Scale {
min: number;
@@ -452,6 +429,153 @@
return canvasEl ? canvasEl.toDataURL('image/png') : null;
}
// ─── Full export (title + icon bands + legend composited onto one canvas) ────
// The canvas alone omits the DOM overlays (weather/wind icons), the panel
// title (rendered by the parent), and the legend (an HTML row). This builds a
// standalone canvas that includes all of them so downloads look like the page.
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
const key = `${name}|${color}`;
let p = iconImageCache.get(key);
if (!p) {
p = fetch(`/images/weather-icons/${name}.svg`)
.then((r) => r.text())
.then(
(svg) =>
new Promise<HTMLImageElement>((resolve, reject) => {
// the paths carry no fill, so a root fill tints the whole glyph
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = (e) => {
URL.revokeObjectURL(url);
reject(e);
};
img.src = url;
})
);
iconImageCache.set(key, p);
}
return p;
}
/**
* Composite the chart, its icon bands, an optional title, and the legend onto
* a fresh canvas for export. Async because the icon SVGs are rasterized.
*/
export async function getExportImage(opts?: {
title?: string;
}): Promise<HTMLCanvasElement | null> {
if (!canvasEl || !containerEl || width <= 0) return null;
const dpr = window.devicePixelRatio || 1;
const styles = getComputedStyle(containerEl);
const cssVar = (n: string, f: string) => styles.getPropertyValue(n).trim() || f;
const strong = cssVar('--foreground', '#374151');
const muted = cssVar('--muted-foreground', '#6b7280');
const bg = cssVar('--card', '#ffffff');
const title = opts?.title;
const titleH = title ? 30 : 0;
const legendItems = showLegend ? series.filter((s) => s.showInLegend !== false) : [];
const legendH = legendItems.length > 0 ? 28 : 0;
const totalH = titleH + height + legendH;
const out = document.createElement('canvas');
out.width = Math.round(width * dpr);
out.height = Math.round(totalH * dpr);
const ctx = out.getContext('2d');
if (!ctx) return null;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, totalH);
if (title) {
ctx.fillStyle = strong;
ctx.font = '600 14px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(title, 4, titleH / 2 + 2);
}
// the plot itself (canvasEl is a dpr-scaled bitmap of the css-sized chart)
ctx.drawImage(canvasEl, 0, titleH, width, height);
// weather pictograms
for (const p of visiblePictograms) {
try {
const img = await loadColoredIcon(p.icon, strong);
const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x));
ctx.drawImage(
img,
iconBandLeft + cx - ICON_PX / 2,
titleH + pictoRowTop + (ICON_BAND_H - ICON_PX) / 2,
ICON_PX,
ICON_PX
);
} catch {
/* skip an icon that failed to rasterize */
}
}
// wind-direction arrows, rotated about their centre
for (const a of visibleWindArrows) {
try {
const img = await loadColoredIcon('wi-direction-down', strong);
const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x));
ctx.save();
ctx.translate(iconBandLeft + cx, titleH + windRowTop + ICON_BAND_H / 2);
ctx.rotate((a.deg * Math.PI) / 180);
ctx.globalAlpha = 0.8;
ctx.drawImage(img, -ARROW_PX / 2, -ARROW_PX / 2, ARROW_PX, ARROW_PX);
ctx.restore();
} catch {
/* skip */
}
}
// legend row, centred like the on-screen one
if (legendItems.length > 0) {
ctx.font = '12px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const gap = 12;
const dot = 9;
const dotGap = 6;
const widths = legendItems.map((s) => dot + dotGap + ctx.measureText(s.name).width);
const totalW = widths.reduce((a, b) => a + b, 0) + gap * (legendItems.length - 1);
let x = Math.max(4, (width - totalW) / 2);
const y = titleH + height + legendH / 2;
for (let i = 0; i < legendItems.length; i++) {
const s = legendItems[i];
ctx.fillStyle = s.color;
ctx.beginPath();
ctx.arc(x + dot / 2, y, dot / 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = muted;
ctx.fillText(s.name, x + dot + dotGap, y);
x += widths[i] + gap;
}
}
// credit hugging the bottom-right corner of the export
if (showCredit) {
ctx.font = '10px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = muted;
ctx.globalAlpha = 0.8;
ctx.fillText('Weather data by Open-Meteo · visualisation by Drizz.li', width - 6, totalH - 5);
ctx.globalAlpha = 1;
}
return out;
}
/** Zooms the x axis to the given epoch-second range (clamped to the data). */
export function setRange(startEpoch: number, endEpoch: number): void {
applyRange(startEpoch, endEpoch);
@@ -551,8 +675,8 @@
// Vertical offset (px from container top) of each icon row's top edge, anchored
// just above the plot. When both rows are present, pictograms sit above the
// wind arrows (which stay closest to the plot).
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2);
let windRowTop = $derived(padTop - ICON_ROW_H + 2);
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * iconRowH + 2);
let windRowTop = $derived(padTop - iconRowH + 2);
// ─── Local minima / maxima (for value labels) ────────────────────────────────
@@ -608,13 +732,25 @@
break;
}
}
// A "EEE d" day label needs ~46px of room; when days are packed tighter
// (long ranges on a narrow screen) keep every day's gridline but only
// label every Nth one so the dates never overlap.
const pxPerDay = 24 * pxPerHour;
const dayStride = pxPerDay >= 46 ? 1 : Math.max(1, Math.ceil(46 / pxPerDay));
const ticks: XTick[] = [];
const first = Math.ceil(viewStart / HOUR) * HOUR;
let dayCount = 0;
for (let t = first; t <= viewEnd; t += HOUR) {
const date = new Date(t * 1000);
const hour = getZonedHour(date, timezone);
if (hour === 0) {
ticks.push({ t, label: formatZoned(date, timezone, 'EEE d'), isDay: true });
const showLabel = dayCount % dayStride === 0;
dayCount++;
ticks.push({
t,
label: showLabel ? formatZoned(date, timezone, 'EEE d') : '',
isDay: true
});
} else if (step < 24 && hour % step === 0) {
ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false });
}
@@ -661,14 +797,16 @@
const plotBottom = padTop + plotH;
const font = '11px system-ui, sans-serif';
// Daylight bands
// Daylight bands (kept subtle so they don't compete with the data)
ctx.fillStyle = CHART_COLORS.daylight;
ctx.globalAlpha = 0.65;
for (const band of bands) {
if (band.end < viewStart || band.start > viewEnd) continue;
const x1 = Math.max(padLeft, xPix(band.start));
const x2 = Math.min(plotRight, xPix(band.end));
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
}
ctx.globalAlpha = 1;
// Selected-day highlight: soft tint + dashed edge lines
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
@@ -713,8 +851,10 @@
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
}
// Right axis labels (only when a unit is provided)
if (hasRightAxis && unitRight !== undefined) {
// Right axis labels in the gutter (desktop only). On mobile there is no
// gutter — the labels are drawn on top of the graph with halos after the
// series (see drawOverlaidAxisLabels below), so the data can't cover them.
if (hasRightAxis && unitRight !== undefined && !isNarrow) {
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
for (
@@ -843,11 +983,11 @@
// 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)
// fade runs a good stretch past the near extreme, flowing most of the
// way to the plot edge (~78% of the way there)
const fadeY = goUp
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.6)
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.6);
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.78)
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.78);
const anchorV = goUp ? minV : maxV;
const nearV = goUp ? maxV : minV;
const span = fadeY - anchorY;
@@ -876,7 +1016,11 @@
ctx.lineTo(points[0][0], fadeY);
ctx.closePath();
ctx.fillStyle = grad;
// the bright temperature colours glow over a dark background, so
// knock the whole fill back to 75% in dark mode
ctx.globalAlpha = dark ? 0.75 : 1;
ctx.fill();
ctx.globalAlpha = 1;
}
if (s.fill && points.length > 1) {
@@ -946,16 +1090,15 @@
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)
);
// exact same colour as the fill so the line and gradient match
grad.addColorStop(t, s.segmentColor(s.data[points[i][3]] as number, points[i][3]));
}
ctx.strokeStyle = grad;
} else {
ctx.strokeStyle = strongColor;
}
// keep the line crisp (full opacity) as a clean edge; only the
// large fill area is dimmed in dark mode
ctx.stroke();
} else {
ctx.beginPath();
@@ -1001,6 +1144,28 @@
}
}
// Mobile: right-axis labels overlaid on top of the data with a halo (no
// gutter is reserved on narrow screens, so the graph runs full width).
if (isNarrow && hasRightAxis && unitRight !== undefined) {
ctx.font = font;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
for (
let v = rightScale.min;
v <= rightScale.max + rightScale.step / 2;
v += rightScale.step
) {
const y = yPix(v, 'right');
const label = v.toFixed(tickDecimals(rightScale.step));
ctx.strokeStyle = bgColor;
ctx.strokeText(label, plotRight - 2, y);
ctx.fillStyle = textColor;
ctx.fillText(label, plotRight - 2, y);
}
}
// Current time marker
if (showNow) {
const now = Date.now() / 1000;
@@ -1042,9 +1207,20 @@
ctx.fillText(unit, padLeft - 4, padTop - 8);
}
if (hasRightAxis && unitRight) {
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
ctx.fillText(unitRight, plotRight + 4, padTop - 8);
if (isNarrow) {
// overlaid inside the plot's top-right corner (no right gutter) with a halo
ctx.textAlign = 'right';
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.strokeStyle = bgColor;
ctx.strokeText(unitRight, plotRight - 2, padTop - 8);
ctx.fillStyle = textColor;
ctx.fillText(unitRight, plotRight - 2, padTop - 8);
} else {
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
ctx.fillText(unitRight, plotRight + 4, padTop - 8);
}
}
// Title / subtitle
@@ -1060,15 +1236,8 @@
}
}
// Credit watermark
if (showCredit) {
ctx.textAlign = 'right';
ctx.font = '10px system-ui, sans-serif';
ctx.globalAlpha = 0.4;
ctx.fillStyle = strongColor;
ctx.fillText('Open-Meteo.com', width - 10, height - 6);
ctx.globalAlpha = 1;
}
// Credit is a DOM overlay (below) so its two sources can be links; the
// export path redraws it onto the exported canvas in getExportImage().
}
$effect(() => {
@@ -1369,7 +1538,9 @@
<!-- Legend sits below the graph -->
{#if showLegend && series.length > 0}
<div class="mt-1.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 px-1">
<div
class="mt-0.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 px-1 md:mt-1.5 md:gap-y-1"
>
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
<button
type="button"
@@ -1392,4 +1563,24 @@
{/each}
</div>
{/if}
<!-- Credit: below the legend, in the bottom-right corner, with linked sources -->
{#if showCredit}
<div
class="px-2 pt-2 pb-1 text-center md:text-right text-[10px] leading-none text-muted-foreground/70"
>
Weather data by <a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://open-meteo.com"
target="_blank"
rel="noopener noreferrer">Open-Meteo</a
>, visualisation by
<a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://drizz.li"
target="_blank"
rel="noopener noreferrer">Drizz.li</a
>
</div>
{/if}
</div>
@@ -124,9 +124,9 @@
min-width: var(--chart-min-width);
}
/* Mobile: fit the chart to the viewport instead of forcing a min-width
/* Below lg: fit the chart to the viewport instead of forcing a min-width
sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
@media (max-width: 767px) {
@media (max-width: 1023px) {
.chart-container {
min-width: 0;
}
@@ -135,7 +135,7 @@
}
}
@media (min-width: 768px) {
@media (min-width: 1024px) {
.chart-bleed {
margin-left: -1.5rem;
margin-right: -1.5rem;
@@ -19,11 +19,11 @@
</ChartToolbar>
-->
<script module lang="ts">
export type { DownloadableChart } from './downloadChartsPng';
export type { ExportableChart } from './downloadChartsPng';
</script>
<script lang="ts">
import { type DownloadableChart, downloadChartsPng } from './downloadChartsPng';
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
import type { Snippet } from 'svelte';
@@ -31,7 +31,7 @@
interface Props {
/** Chart components available for download (undefined entries are skipped) */
charts?: Array<DownloadableChart | undefined | null>;
charts?: Array<ExportableChart | undefined | null>;
/** Base file name for downloaded images (without extension) */
fileName?: string;
/** Optional CSS class for the outer container */
+28 -26
View File
@@ -1,23 +1,20 @@
/**
* 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.
* Each chart composites itself (plot + icon bands + optional title + legend)
* onto a canvas via `getExportImage`; those are stacked vertically over the
* current theme background and downloaded as one PNG.
*/
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
export interface DownloadableChart {
getPngDataUrl(): string | null;
/** A chart that can composite itself (plot + icons + legend) for export. */
export interface ExportableChart {
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | 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;
});
/** One chart to export, with the title to render above it (e.g. panel name). */
export interface ChartExportItem {
chart: ExportableChart | null | undefined;
title?: string;
}
/** Resolves the page background so exports match the current theme. */
@@ -38,25 +35,30 @@ function triggerDownload(url: string, name: string): void {
});
}
/** Normalise either a bare chart or a {chart, title} item. */
function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartExportItem {
if (x && 'getExportImage' in x) return { chart: x };
return (x as ChartExportItem) ?? { chart: null };
}
/**
* 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>,
charts: Array<ChartExportItem | ExportableChart | null | undefined>,
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 items = charts.map(toItem).filter((it) => it.chart != null);
if (items.length === 0) return;
const images = (await Promise.all(dataUrls.map(loadImage))).filter((img) => img.naturalWidth > 0);
if (images.length === 0) return;
const canvases = (
await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
if (canvases.length === 0) return;
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
const maxWidth = Math.max(...canvases.map((c) => c.width));
const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
const canvas = document.createElement('canvas');
canvas.width = maxWidth;
@@ -69,9 +71,9 @@ export async function downloadChartsPng(
ctx.fillRect(0, 0, maxWidth, totalHeight);
let y = 0;
for (const img of images) {
ctx.drawImage(img, 0, y);
y += img.naturalHeight;
for (const c of canvases) {
ctx.drawImage(c, 0, y);
y += c.height;
}
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
+1 -1
View File
@@ -9,4 +9,4 @@
export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type DownloadableChart } from './downloadChartsPng';
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
+1 -1
View File
@@ -57,7 +57,7 @@
>
<!-- Mobile menu toggle -->
<button
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-black/5 md:hidden dark:hover:bg-white/10"
class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
onclick={onMenuToggle}
aria-label="Toggle menu"
>
@@ -87,7 +87,7 @@
</div>
{#if !collapsed}
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
Drizzli
Drizz.li
</span>
{/if}
</a>
@@ -129,7 +129,7 @@
{#if onToggle}
<div class="border-t border-sidebar-border px-2 py-3">
<button
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
class="relative flex w-full cursor-pointer items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
onclick={onToggle}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
+1 -2
View File
@@ -44,7 +44,7 @@
<Popover.Root>
<Popover.Trigger
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-2.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label="Choose measurement units"
title="Units"
>
@@ -63,7 +63,6 @@
/>
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
</svg>
<span class="hidden text-xs font-semibold sm:inline">Units</span>
</Popover.Trigger>
<Popover.Content align="end" class="w-64 border-border">
<div class="flex flex-col gap-4">