New table rotation flip
This commit is contained in:
fraxle
2026-08-06 00:48:07 +01:00
parent 7f3fbb18c7
commit 60eee8e4c1
4 changed files with 315 additions and 24 deletions
+277
View File
@@ -1732,6 +1732,20 @@
flex-shrink: 0; 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) { @media (max-width: 800px) {
.utci-time { .utci-time {
font-size: 15px; font-size: 15px;
@@ -2509,3 +2523,266 @@
min-height: 48px; 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 <tr> 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;
}
}
+20 -19
View File
@@ -235,7 +235,7 @@ export function UTCIForecast() {
utciEnv, setUtciEnv, utciEnv, setUtciEnv,
tableRotated, toggleTableRotated, tableRotated, toggleTableRotated,
headStickyRef, headTrackRef, headTableRef, headStickyRef, headTrackRef, headTableRef,
bodyScrollRef, bodyTableRef, tableWrapRef, bodyScrollRef, bodyTableRef, tableWrapRef, rotatedScrollRef,
colPopup, colPopupRef, colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave, handleThClick, handleThEnter, handleThLeave,
handlePopupEnter, handlePopupLeave, handlePopupEnter, handlePopupLeave,
@@ -1144,6 +1144,15 @@ export function UTCIForecast() {
<span class="col-toggles-edit-btn-label">${!isPro ? '🔒 Edit columns' : colTogglesOpen ? 'Hide Columns' : 'Edit columns'}</span> <span class="col-toggles-edit-btn-label">${!isPro ? '🔒 Edit columns' : colTogglesOpen ? 'Hide Columns' : 'Edit columns'}</span>
<svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg> <svg class="col-toggles-edit-btn-icon" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
</button> </button>
${forecastView === 'table' && html`
<button type="button"
class=${'table-rotate-btn table-rotate-btn--toolbar' + (tableRotated ? ' on' : '')}
title=${tableRotated ? 'Hours run along the top — click for hours down the side' : 'Hours run down the side — click for hours along the top'}
aria-pressed=${tableRotated ? 'true' : 'false'}
onClick=${toggleTableRotated}>
<span class="table-rotate-btn-icon" aria-hidden="true">⭮</span>
<span class="table-rotate-btn-label">Rotate</span>
</button>`}
</span> </span>
${forecastView === 'simple' && (() => { ${forecastView === 'simple' && (() => {
const showFur = visibleCols.furSurfaceT; const showFur = visibleCols.furSurfaceT;
@@ -1407,26 +1416,20 @@ export function UTCIForecast() {
</span>`; </span>`;
}; };
// 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`
<button type="button"
class=${`table-rotate-btn${tableRotated ? ' on' : ''}`}
title=${tableRotated ? 'Hours down the side' : 'Hours along the top'}
aria-label="Rotate table"
aria-pressed=${tableRotated ? 'true' : 'false'}
onClick=${(e) => { e.stopPropagation(); toggleTableRotated(); }}
>⭮</button>`;
// ── ROTATED: hours across the top, metrics down the side ── // ── ROTATED: hours across the top, metrics down the side ──
// One table, not two: the sticky top row and sticky left // One table, not two: the sticky top row and sticky left
// column are pure CSS here, so none of the head/body width // column are pure CSS here, so none of the head/body width
// syncing in useTableScroll is needed (it is disabled while // syncing in useTableScroll is needed (it is disabled while
// rotated). // rotated).
// The key matters: both orientations root at a <div>, 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` if (tableRotated) return html`
<div class=${`utci-table-wrap is-rotated${showUnits ? '' : ' no-units'}`}> <div key="table-rotated" class=${`utci-table-wrap is-rotated${showUnits ? '' : ' no-units'}`}>
<div class="utci-rotated-scroll"> <div class="utci-rotated-scroll" ref=${rotatedScrollRef}>
<table class="utci-table utci-table-rotated"> <table class="utci-table utci-table-rotated">
<thead> <thead>
<tr> <tr>
@@ -1439,7 +1442,6 @@ export function UTCIForecast() {
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}> <span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span> </span>
${rotateBtn}
</span> </span>
</th> </th>
${tableRows.map(r => { ${tableRows.map(r => {
@@ -1463,7 +1465,7 @@ export function UTCIForecast() {
${newGroup && html` ${newGroup && html`
<tr class="grp-label-row grp-label-row--rotated"> <tr class="grp-label-row grp-label-row--rotated">
<th class=${`grp-label grp-label-${newGroup} grp-${newGroup}`} scope="rowgroup" colspan=${tableRows.length + 1}> <th class=${`grp-label grp-label-${newGroup} grp-${newGroup}`} scope="rowgroup" colspan=${tableRows.length + 1}>
${GROUP_LABELS[newGroup]} <span class="grp-label-pin">${GROUP_LABELS[newGroup]}</span>
</th> </th>
</tr>`} </tr>`}
<tr class="rot-metric-row"> <tr class="rot-metric-row">
@@ -1494,7 +1496,7 @@ export function UTCIForecast() {
// Split into a sticky header table and a scrolling body // Split into a sticky header table and a scrolling body
// table whose column widths useTableScroll keeps in step. // table whose column widths useTableScroll keeps in step.
return html` return html`
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}> <div key="table-normal" class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
<span class="utci-scroll-chevron left" aria-hidden="true"></span> <span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span> <span class="utci-scroll-chevron right" aria-hidden="true"></span>
@@ -1509,7 +1511,6 @@ export function UTCIForecast() {
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}> <span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span> </span>
${rotateBtn}
</th> </th>
${visibleColumnDefs.map(d => html` ${visibleColumnDefs.map(d => html`
<th key=${d.key} scope="col" <th key=${d.key} scope="col"
+4 -1
View File
@@ -732,6 +732,8 @@ export function useAppState() {
const bodyScrollRef = useRef(null); const bodyScrollRef = useRef(null);
const bodyTableRef = useRef(null); const bodyTableRef = useRef(null);
const tableWrapRef = useRef(null); const tableWrapRef = useRef(null);
// The rotated orientation's single scroll pane (scrolls in both axes).
const rotatedScrollRef = useRef(null);
// ── 7. COLUMN POPUP + TABLE SCROLL ─────────────────────────────────── // ── 7. COLUMN POPUP + TABLE SCROLL ───────────────────────────────────
const { const {
@@ -751,6 +753,7 @@ export function useAppState() {
handleBodyScroll, handleBodyScroll,
} = useTableScroll({ } = useTableScroll({
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef, headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef,
rotatedScrollRef,
forecast, visibleCols, selectedDay, skinType, vehicleType, forecast, visibleCols, selectedDay, skinType, vehicleType,
indoorMode, indoorManaged, showDecimals, showUnits, indoorMode, indoorManaged, showDecimals, showUnits,
// The rotated table is a single table with CSS-only sticky edges, so // The rotated table is a single table with CSS-only sticky edges, so
@@ -907,7 +910,7 @@ export function useAppState() {
forecastView, setForecastView, forecastView, setForecastView,
// table refs // table refs
headStickyRef, headTrackRef, headTableRef, headStickyRef, headTrackRef, headTableRef,
bodyScrollRef, bodyTableRef, tableWrapRef, bodyScrollRef, bodyTableRef, tableWrapRef, rotatedScrollRef,
// column popup // column popup
colPopup, colPopupRef, colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave, handleThClick, handleThEnter, handleThLeave,
+14 -4
View File
@@ -28,6 +28,7 @@ export function useTableScroll({
headTableRef, headTableRef,
bodyTableRef, bodyTableRef,
bodyScrollRef, bodyScrollRef,
rotatedScrollRef,
headTrackRef, headTrackRef,
tableWrapRef, tableWrapRef,
forecast, forecast,
@@ -41,7 +42,9 @@ export function useTableScroll({
showUnits, showUnits,
// When the table is rotated it renders as ONE table whose sticky header // When the table is rotated it renders as ONE table whose sticky header
// row and sticky metric column are handled entirely in CSS. There is no // row and sticky metric column are handled entirely in CSS. There is no
// second table to keep in step, so every effect below short-circuits. // second table to keep in step, so the width-sync and scroll-indicator
// effects short-circuit — but drag-to-scroll still applies, just to the
// rotated pane (which scrolls in both axes) instead of the body table.
rotated = false, rotated = false,
}) { }) {
// --- SCROLL INDICATORS --------------------------------------------- // --- SCROLL INDICATORS ---------------------------------------------
@@ -57,21 +60,27 @@ export function useTableScroll({
// --- DRAG-TO-SCROLL ------------------------------------------------ // --- DRAG-TO-SCROLL ------------------------------------------------
useEffect(() => { useEffect(() => {
if (rotated) return; // Whichever element is actually the scroller in the current
const el = bodyScrollRef.current; // orientation. Rotated, it scrolls vertically too, so the drag
// follows both axes.
const el = rotated ? (rotatedScrollRef && rotatedScrollRef.current) : bodyScrollRef.current;
if (!el) return; if (!el) return;
let isDown = false; let isDown = false;
let startX = 0; let startX = 0;
let startY = 0;
let startScroll = 0; let startScroll = 0;
let startScrollTop = 0;
const onMouseDown = (e) => { 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 (!el.contains(e.target)) return;
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest('button, a, input, select')) return; if (e.target.closest('button, a, input, select')) return;
isDown = true; isDown = true;
startX = e.clientX; startX = e.clientX;
startY = e.clientY;
startScroll = el.scrollLeft; startScroll = el.scrollLeft;
startScrollTop = el.scrollTop;
el.style.cursor = 'grabbing'; el.style.cursor = 'grabbing';
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none'; document.body.style.webkitUserSelect = 'none';
@@ -80,6 +89,7 @@ export function useTableScroll({
if (!isDown) return; if (!isDown) return;
const dx = e.clientX - startX; const dx = e.clientX - startX;
el.scrollLeft = startScroll - dx; el.scrollLeft = startScroll - dx;
if (rotated) el.scrollTop = startScrollTop - (e.clientY - startY);
}; };
const onMouseUp = () => { const onMouseUp = () => {
if (!isDown) return; if (!isDown) return;