diff --git a/assets/css/table.css b/assets/css/table.css index d627c36..d934ff9 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -1732,6 +1732,20 @@ flex-shrink: 0; } +/* Lays out the sky scope + "3pm" + any event tags on one line. Shared by + both orientations — it leads the row normally and heads the column when + the table is rotated. */ +.utci-time-inner { + display: inline-flex; + align-items: center; + gap: 7px; + vertical-align: middle; +} +.utci-time-inner svg { + margin: -6px 0; + flex-shrink: 0; +} + @media (max-width: 800px) { .utci-time { font-size: 15px; @@ -2509,3 +2523,266 @@ min-height: 48px; } } + +/* ══════════════════════════════════════════════════════════════════════ + ROTATED TABLE — hours along the top, metrics down the side + ───────────────────────────────────────────────────────────────────── + Toggled by the rotate button in the table's top-left cell (state lives + in useAppState as tableRotated). Unlike the normal orientation — which + is two tables kept in step by useTableScroll — this is ONE table in a + single scroll pane, so both sticky edges are pure CSS. + + The pane scrolls in both axes rather than letting the page scroll + vertically: sticky positioning resolves against the nearest scrolling + ancestor, and making the pane scroll horizontally makes it the + vertical scroll ancestor too. Scrolling both here is what lets the + hour row and the metric column stay pinned at the same time. + ══════════════════════════════════════════════════════════════════════ */ + +.utci-rotated-scroll { + overflow: auto; + max-height: min(78vh, 900px); + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + cursor: grab; /* drag-to-scroll, same as the normal body scroller */ +} +.utci-rotated-scroll:active { + cursor: grabbing; +} + +/* Auto layout — the whole point of the rotated view is that each column + is only as wide as the hour it holds, so the fixed layout the normal + table needs for its JS width-sync is explicitly turned off here. */ +.utci-table.utci-table-rotated { + table-layout: auto; + width: max-content; + min-width: 100%; + /* The separate border model is required, not cosmetic. Under + border-collapse:collapse the borders belong to the TABLE rather than + to the cells, so they do not travel with a sticky cell — the brass + now-column brackets and the row separators paint straight over the + pinned metric column and corner as you scroll. Separate borders + belong to their cell and move with it. */ + border-collapse: separate; + border-spacing: 0; +} + +/* Row separators have to move to the cells: a border never paints + in the separate model. */ +.utci-table-rotated tbody td, +.utci-table-rotated tbody th { + border-bottom: 1px solid #ffffff; +} + +/* ── SPACING ────────────────────────────────────────────────────────── + Cells carry icons alongside their numbers (sky scope, cloud, precip, + wind vane), so they need noticeably more room than a bare figure. */ +.utci-table-rotated td, +.utci-table-rotated th { + padding: 11px 16px; + min-width: 92px; + white-space: nowrap; +} + +/* ── STICKY HOUR ROW (top) ────────────────────────────────────────── */ +.utci-table-rotated thead th { + position: sticky; + top: 0; + z-index: 4; + background: #f0e4c4; + vertical-align: middle; +} + +/* Hour headings read as times, not as column labels — same display serif + as the time cells in the normal orientation. */ +.utci-table-rotated .rot-hour-th { + font-family: Fraunces, serif; + font-weight: 500; + font-size: 16px; + text-transform: none; + letter-spacing: 0; + color: #1e1208; + min-width: 76px; +} + +/* Stacked, not inline: rotated, every hour is its own COLUMN, so sitting + the scope beside the time would set the width of all of them. Stacking + puts the scope over the label and lets the columns stay narrow. The + negative svg margin that tightens the inline layout has to go — here it + would eat into the row above. */ +.utci-table-rotated .rot-hour-th .utci-time-inner { + flex-direction: column; + gap: 3px; +} +.utci-table-rotated .rot-hour-th .utci-time-inner svg { + margin: 0; +} +.utci-table-rotated .rot-hour-th.is-night { + color: #7a5c30; +} + +/* ── STICKY METRIC COLUMN (left) ────────────────────────────────────── + The column-group tints (.utci-table th.grp-*) are 50% transparent, + which is fine for a heading that has nothing behind it — but data + cells scroll UNDER this column, so any transparency lets them show + through. The tint is therefore composited over an opaque cream base: + --grp-tint carries the group colour, painted as a background-image + layer on top of an opaque background-color. */ +/* Set on the group divider rows as well as the metric labels, so a group + heading and the metrics beneath it resolve to the SAME colour. Both + composite over the identical opaque base below — a shared tint over + two different backgrounds is what makes them drift apart. */ +.utci-table-rotated .grp-felt { --grp-tint: rgba(223,243,236,0.50); } +.utci-table-rotated .grp-surface { --grp-tint: rgba(221,234,248,0.50); } +.utci-table-rotated .grp-ambient { --grp-tint: rgba(236,234,227,0.50); } +.utci-table-rotated .grp-precip { --grp-tint: rgba(247,228,238,0.50); } +.utci-table-rotated .grp-sky { --grp-tint: rgba(234,232,252,0.50); } +.utci-table-rotated .grp-wind { --grp-tint: rgba(250,230,224,0.50); } +.utci-table-rotated .grp-airqual { --grp-tint: rgba(229,240,216,0.50); } +.utci-table-rotated .grp-solar { --grp-tint: rgba(250,234,204,0.50); } + +/* Over-qualified on purpose: the normal orientation's group rules + (.utci-table th.grp-felt, .utci-table .grp-label-row th.grp-felt) are + specific enough to win otherwise and would restore the transparency. */ +.utci-table.utci-table-rotated tbody th.rot-metric-label, +.utci-table.utci-table-rotated tbody th.rot-metric-label.col-info-th:hover, +.utci-table.utci-table-rotated tbody .grp-label-row--rotated th { + background-color: #f0e4c4; + background-image: linear-gradient(var(--grp-tint, transparent), var(--grp-tint, transparent)); +} + +.utci-table.utci-table-rotated tbody th.rot-metric-label { + position: sticky; + left: 0; + z-index: 3; + text-align: left; + min-width: 136px; + /* box-shadow rather than border-right so the divider sits outside the + cell box and cannot add to the column's measured width. */ + box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18); +} + +/* The corner cell sits in both sticky tracks, so it needs to win over + each of them. */ +/* The selector is deliberately over-qualified: the normal orientation's + `.utci-table thead th:first-child` rule pins the Hour heading with + position:relative + a JS transform, and would otherwise out-specify + this and stop the corner sticking. */ +.utci-table.utci-table-rotated thead th.rot-corner { + position: sticky; + top: 0; + left: 0; + z-index: 6; + background: #f0e4c4; + text-align: left; + transform: none; + box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18); +} +.utci-table-rotated .rot-corner-date { + display: block; + font-size: 10px; + letter-spacing: 0.08em; + color: #a08040; + margin-bottom: 6px; +} +.utci-table-rotated .rot-corner-controls { + display: flex; + align-items: center; + gap: 8px; +} + +@media (max-width: 640px) { + /* Icon-only on narrow screens — the toolbar is already tight. */ + .table-rotate-btn-label { + display: none; + } + .table-rotate-btn { + padding: 6px 8px; + gap: 0; + } +} + +/* ── GROUP DIVIDER ROWS ─────────────────────────────────────────────── + Stand in for the spanning group labels that sit above the columns in + the normal orientation. The cell spans the full width, so the LABEL is + what gets pinned rather than the cell. */ +.utci-table-rotated .grp-label-row--rotated th { + text-align: left; + padding: 5px 16px; + border-right: 0; +} +.utci-table-rotated .grp-label-pin { + position: sticky; + left: 16px; + display: inline-block; +} + +/* ── NOW / NIGHT ────────────────────────────────────────────────────── + Rotated, "now" is a column rather than a row, so the brass bracket + that frames it runs down each side instead of across top and bottom. */ +.utci-table-rotated td.is-now, +.utci-table-rotated th.rot-hour-th.is-now { + border-left: 2px solid #c8922a; + border-right: 2px solid #c8922a; +} +.utci-table-rotated th.rot-hour-th.is-now { + background: #fff8e4; + border-top: 2px solid #c8922a; +} +.utci-table-rotated td.is-night { + color: #7a5c30; +} + +/* ── ROTATE BUTTON ──────────────────────────────────────────────────── + Sits in the toolbar after the view switcher and Edit columns, and only + while the detailed table is showing. */ +.table-rotate-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 11px; + border: 1px solid #c9b08a; + border-radius: 6px; + background: #fffcf2; + color: #8a6a34; + font-family: Manrope, sans-serif; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + line-height: 1; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.table-rotate-btn-icon { + font-size: 14px; + line-height: 1; +} +.table-rotate-btn:hover { + background: #f6ead0; + color: #6b4f2a; +} +.table-rotate-btn.on { + background: #c8922a; + border-color: #c8922a; + color: #fffcf2; +} + +/* ── MOBILE ───────────────────────────────────────────────────────── */ +@media (max-width: 640px) { + .utci-rotated-scroll { + max-height: min(70vh, 620px); + } + .utci-table-rotated td, + .utci-table-rotated th { + padding: 9px 11px; + min-width: 80px; + } + .utci-table-rotated .rot-hour-th { + font-size: 14px; + min-width: 68px; + } + .utci-table-rotated .rot-metric-label { + min-width: 112px; + } +} diff --git a/assets/js/app.js b/assets/js/app.js index f395793..c976f1b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -235,7 +235,7 @@ export function UTCIForecast() { utciEnv, setUtciEnv, tableRotated, toggleTableRotated, headStickyRef, headTrackRef, headTableRef, - bodyScrollRef, bodyTableRef, tableWrapRef, + bodyScrollRef, bodyTableRef, tableWrapRef, rotatedScrollRef, colPopup, colPopupRef, handleThClick, handleThEnter, handleThLeave, handlePopupEnter, handlePopupLeave, @@ -1144,6 +1144,15 @@ export function UTCIForecast() { ${!isPro ? '🔒 Edit columns' : colTogglesOpen ? 'Hide Columns' : 'Edit columns'} + ${forecastView === 'table' && html` + `} ${forecastView === 'simple' && (() => { const showFur = visibleCols.furSurfaceT; @@ -1407,26 +1416,20 @@ export function UTCIForecast() { `; }; - // Lives in the table's top-left cell in both orientations. - // stopPropagation so it doesn't also fire the header cell's - // column-info popup, same as the row-interval stepper. - const rotateBtn = html` - `; - // ── ROTATED: hours across the top, metrics down the side ── // One table, not two: the sticky top row and sticky left // column are pure CSS here, so none of the head/body width // syncing in useTableScroll is needed (it is disabled while // rotated). + // The key matters: both orientations root at a
, so + // without distinct keys Preact diffs one into the other and + // reuses the DOM nodes — carrying over the inline column + // widths and header transforms that the width-sync leaves + // behind, which is what knocks the columns out of + // alignment when you flip back. if (tableRotated) return html` -
-
+
+
@@ -1439,7 +1442,6 @@ export function UTCIForecast() { e.stopPropagation()}> <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> - ${rotateBtn} ${tableRows.map(r => { @@ -1463,7 +1465,7 @@ export function UTCIForecast() { ${newGroup && html` `} @@ -1494,7 +1496,7 @@ export function UTCIForecast() { // Split into a sticky header table and a scrolling body // table whose column widths useTableScroll keeps in step. return html` -
+
@@ -1509,7 +1511,6 @@ export function UTCIForecast() { e.stopPropagation()}> <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> - ${rotateBtn} ${visibleColumnDefs.map(d => html`
- ${GROUP_LABELS[newGroup]} + ${GROUP_LABELS[newGroup]}
{ - if (rotated) return; - const el = bodyScrollRef.current; + // Whichever element is actually the scroller in the current + // orientation. Rotated, it scrolls vertically too, so the drag + // follows both axes. + const el = rotated ? (rotatedScrollRef && rotatedScrollRef.current) : bodyScrollRef.current; if (!el) return; let isDown = false; let startX = 0; + let startY = 0; let startScroll = 0; + let startScrollTop = 0; const onMouseDown = (e) => { - // Only act on clicks that land inside the body scroller + // Only act on clicks that land inside the scroller if (!el.contains(e.target)) return; if (e.button !== 0) return; if (e.target.closest('button, a, input, select')) return; isDown = true; startX = e.clientX; + startY = e.clientY; startScroll = el.scrollLeft; + startScrollTop = el.scrollTop; el.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; document.body.style.webkitUserSelect = 'none'; @@ -80,6 +89,7 @@ export function useTableScroll({ if (!isDown) return; const dx = e.clientX - startX; el.scrollLeft = startScroll - dx; + if (rotated) el.scrollTop = startScrollTop - (e.clientY - startY); }; const onMouseUp = () => { if (!isDown) return;