Add more labels for the day tabs
This commit is contained in:
Fraxle
2026-07-29 18:35:45 +01:00
parent 1acadb7519
commit f356954f40
6 changed files with 220 additions and 29 deletions
+114 -16
View File
@@ -77,24 +77,29 @@
pointer-events: none; pointer-events: none;
} }
/* Fixed first column naming the two numbers stacked in each tab — which /* Fixed first column naming every stacked row in each tab — day name, date,
metrics those are changes with the active profile (SunSoak/Shade, Indoors/ the weather/status icon, and the two numbers (which metrics those are
Managed, Fur/Shade, Vehicle/Windows Open), so without this the numbers are changes with the active profile: SunSoak/Shade, Indoors/Managed, Fur/Shade,
unlabelled. Sits outside .utci-day-tabs so it never scrolls away. Vehicle/Windows Open) — so without this column the tabs read as unlabelled
numbers. Sits outside .utci-day-tabs so it never scrolls away.
Alignment: .utci-day-tabs stretches every tab to equal height and the Alignment: every label is absolutely positioned at a fixed offset from the
numeric block is the last thing in each one, so bottom-aligning this column top of the column, matching the corresponding row inside .utci-day-tab.
with matching padding lands the labels on the same lines as the values — Taking the labels out of normal flow is what makes this robust across
no need to replicate the day-name / date / icon stack above them. */ profiles: they no longer stack against each other, so a label that wraps
to two lines ("Managed Indoors") can't push the rows below it, and a
shorter one ("Shade Air") can't pull them up. Previously every label's
position depended on the height of the ones above it, so alignment tuned
against one profile drifted on the others.
The offsets below are measured from the tab's own stack:
1px border + 10px padding → day 16px → +3px date 16px → +6px icon 30px
→ +2px values
The legend column has no top border of its own, hence the leading 11px. */
.utci-day-legend-col { .utci-day-legend-col {
position: relative;
flex: 0 0 var(--day-legend-w); flex: 0 0 var(--day-legend-w);
display: flex; padding: 0 6px 0 0;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
gap: 1px;
/* Bottom 11px = the tab's 10px padding + its 1px bottom border. */
padding: 10px 6px 11px;
background: #fdf8ee; background: #fdf8ee;
text-align: right; text-align: right;
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
@@ -103,10 +108,76 @@
overflow-wrap: break-word; overflow-wrap: break-word;
} }
/* Shared anchoring for every absolutely-positioned label row. `left: 0` +
`right` (rather than a width) keeps them right-aligned against the column's
padding while still allowing long labels to wrap leftwards. */
.utci-day-legend-day,
.utci-day-legend-date,
.utci-day-legend-icon,
.utci-day-legend-values {
position: absolute;
left: 0;
right: 6px;
}
.utci-day-legend-day {
top: 11px;
height: 16px;
/* Explicit line-height (not the browser default "normal") so the row's
height is identical in Chrome and Firefox — those engines compute
"normal" differently for the same font and size. */
line-height: 16px;
font-size: 8px;
font-weight: 700;
color: #9a7d5a;
}
.utci-day-legend-date {
top: 30px;
height: 16px;
line-height: 16px;
font-size: 8px;
font-weight: 700;
color: #9a7d5a;
}
.utci-day-legend-icon {
top: 52px;
height: 30px;
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 8px;
font-weight: 700;
color: #9a7d5a;
}
/* Positioning context for the two value labels. The separator and the
secondary label are pinned to fixed offsets inside it (matching the tab's
own hi/lo block) rather than stacking after the main label, so a main
label that wraps to two lines can't shove them down. */
.utci-day-legend-values {
top: 84px;
height: 0;
}
/* Divider between the top (warmer) and bottom (cooler) values — mirrors the
thin line added between the hi/lo numbers inside each tab. */
.utci-day-legend-sep {
position: absolute;
top: 17px;
left: 0;
right: 0;
height: 1px;
background: rgba(0, 0, 0, 0.15);
}
/* Each label matches the weight and colour family of the value beside it, so /* Each label matches the weight and colour family of the value beside it, so
the big/small pairing reads without a legend. Heights match the value line the big/small pairing reads without a legend. Heights match the value line
boxes: 14px x 1.1 and 11px x 1.1 (see the numeric block in DayTabs.js). */ boxes: 14px x 1.1 and 11px x 1.1 (see the numeric block in DayTabs.js). */
.utci-day-legend-main { .utci-day-legend-main {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
@@ -117,8 +188,17 @@
color: #6b4f2a; color: #6b4f2a;
} }
.utci-day-legend-sec { .utci-day-legend-sec {
/* 20px matches the tab's secondary number, measured from the top of its
hi/lo block (15px main line + 5px separator gap). Absolute, so it holds
that offset even if the main label above it wraps. */
position: absolute;
top: 20px;
left: 0;
right: 0;
display: flex; display: flex;
align-items: center; /* Top-aligned (not centred) so the label stays level with the TOP of the
cooler/secondary value beside it even when the label text wraps. */
align-items: flex-start;
justify-content: flex-end; justify-content: flex-end;
min-height: 12px; min-height: 12px;
font-size: 8px; font-size: 8px;
@@ -135,6 +215,11 @@
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* old Edge */ -ms-overflow-style: none; /* old Edge */
/* Grab cursor hints drag-to-scroll, matching .utci-tbody-scroll */
cursor: grab;
}
.utci-day-tabs:active {
cursor: grabbing;
} }
.utci-day-tabs::-webkit-scrollbar { .utci-day-tabs::-webkit-scrollbar {
display: none; /* Chrome/Safari */ display: none; /* Chrome/Safari */
@@ -161,6 +246,15 @@
border-left: none; border-left: none;
} }
/* Explicit line-height (rather than the browser-default "normal") so this
row's rendered height is identical across browsers — Firefox and Chrome
compute "normal" differently for the same font/size, which threw off the
legend column's row-matching in .utci-day-legend-day (table.css). */
.utci-day-name {
display: block;
line-height: 16px;
}
.utci-day-tab:hover:not(.active):not(.locked) { .utci-day-tab:hover:not(.active):not(.locked) {
filter: brightness(1.05) !important; filter: brightness(1.05) !important;
} }
@@ -191,6 +285,9 @@
font-family: Fraunces, serif; font-family: Fraunces, serif;
font-style: italic; font-style: italic;
font-size: 13px; font-size: 13px;
/* Explicit, not "normal" — pins the row height so it renders identically
across browsers (see .utci-day-name for why this matters). */
line-height: 16px;
text-transform: none; text-transform: none;
letter-spacing: 0; letter-spacing: 0;
display: block; display: block;
@@ -1100,6 +1197,7 @@
possible on phones. */ possible on phones. */
.utci-day-tabs-wrap { --day-legend-w: 56px; } .utci-day-tabs-wrap { --day-legend-w: 56px; }
.utci-day-legend-col { padding: 10px 4px 11px; letter-spacing: 0.04em; } .utci-day-legend-col { padding: 10px 4px 11px; letter-spacing: 0.04em; }
.utci-day-legend-day, .utci-day-legend-date, .utci-day-legend-icon { font-size: 7px; }
.utci-day-legend-main { font-size: 8px; } .utci-day-legend-main { font-size: 8px; }
.utci-day-legend-sec { font-size: 7px; } .utci-day-legend-sec { font-size: 7px; }
} }
+21 -10
View File
@@ -204,14 +204,21 @@ export function DayTabs({
onClick=${() => scrollDayTabs(1)} onClick=${() => scrollDayTabs(1)}
aria-label="Scroll days right" aria-label="Scroll days right"
type="button"></button> type="button"></button>
${/* Fixed label column outside the scroller — names the two stacked ${/* Fixed label column outside the scroller — names every stacked row
numbers in every tab, since which metrics they are changes with in each tab (day name, date, icon, then the two numbers, since
the active profile. Bottom-aligned against the tabs' numeric which metrics those are changes with the active profile). Mirrors
block (see .utci-day-legend-col in table.css). */''} the tabs' own top-down layout row for row (see .utci-day-legend-col
in table.css). */''}
<div class="utci-day-legend-col"> <div class="utci-day-legend-col">
<span class="utci-day-legend-main" title=${mainMetricDesc}>${mainMetricLabel}</span> <span class="utci-day-legend-day">Day</span>
${(isVehicleProfile || secondaryField) && html` <span class="utci-day-legend-date">Date</span>
<span class="utci-day-legend-sec" title=${secondaryMetricDesc}>${secondaryMetricLabel}</span>`} <span class="utci-day-legend-icon">${isHomeOrOffice ? 'Home' : isVehicleProfile ? 'Alert' : 'Sky'}</span>
<div class="utci-day-legend-values">
<span class="utci-day-legend-main" title=${mainMetricDesc}>${mainMetricLabel}</span>
${(isVehicleProfile || secondaryField) && html`
<span class="utci-day-legend-sep" aria-hidden="true"></span>
<span class="utci-day-legend-sec" title=${secondaryMetricDesc}>${secondaryMetricLabel}</span>`}
</div>
</div> </div>
<div class="utci-day-tabs" ref=${dayTabsRef}> <div class="utci-day-tabs" ref=${dayTabsRef}>
${days.map((d, i) => { ${days.map((d, i) => {
@@ -378,7 +385,7 @@ export function DayTabs({
> >
${locked && html` ${locked && html`
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`} <span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
${dayName} <span class="utci-day-name">${dayName}</span>
<span class="utci-day-date" style=${{ color: '#5a3f24' }}> <span class="utci-day-date" style=${{ color: '#5a3f24' }}>
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })} ${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span> </span>
@@ -410,12 +417,16 @@ export function DayTabs({
}}> }}>
<span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}> <span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}>
<span style=${{ color: '#c0622a', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayHi}°</span> <span style=${{ color: '#c0622a', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayHi}°</span>
${shadeHi !== null && html`<span style=${{ color: '#c0622a', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${shadeHi}°</span>`} ${shadeHi !== null && html`
<span style=${{ width: '10px', height: '1px', background: 'rgba(0,0,0,0.15)', margin: '1px 0' }}></span>
<span style=${{ color: '#c0622a', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${shadeHi}°</span>`}
</span> </span>
<span style=${{ width: '1px', background: 'rgba(0,0,0,0.15)', margin: '2px 0', alignSelf: 'stretch' }}></span> <span style=${{ width: '1px', background: 'rgba(0,0,0,0.15)', margin: '2px 0', alignSelf: 'stretch' }}></span>
<span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}> <span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}>
<span style=${{ color: '#3a6080', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayLo}°</span> <span style=${{ color: '#3a6080', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayLo}°</span>
${shadeLo !== null && html`<span style=${{ color: '#3a6080', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${shadeLo}°</span>`} ${shadeLo !== null && html`
<span style=${{ width: '10px', height: '1px', background: 'rgba(0,0,0,0.15)', margin: '1px 0' }}></span>
<span style=${{ color: '#3a6080', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${shadeLo}°</span>`}
</span> </span>
</span>`} </span>`}
</button>`; </button>`;
+1 -1
View File
@@ -276,7 +276,7 @@ export const COL_DESCRIPTIONS = {
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' }, soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' },
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.' }, soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.' },
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 1525 °C above air temperature on sunny days.' }, concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 1525 °C above air temperature on sunny days.' },
vehicleT: { title: 'Vehicle Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces that warm more slowly but hold heat longer, with gain through the windscreen and rooflights and a little retained living-space warmth. Accounts for ambient wind over the bodywork, road speed, and whether the windows are open. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' }, vehicleT: { title: 'Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces that warm more slowly but hold heat longer, with gain through the windscreen and rooflights and a little retained living-space warmth. Accounts for ambient wind over the bodywork, road speed, and whether the windows are open. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' }, indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' }, managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' }, vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' },
+71
View File
@@ -231,6 +231,77 @@ export function useAppState() {
el.scrollBy({ left: dir * 200, behavior: 'smooth' }); el.scrollBy({ left: dir * 200, behavior: 'smooth' });
}; };
// Drag-to-scroll for the day-tab strip, mirroring the hourly table's body
// scroller (see useTableScroll). Unlike the table scroller, the draggable
// surface here IS the clickable element (each day is a <button>), so a
// plain mousedown/mouseup pair must still open that day - only a real drag
// (movement past a small threshold) should scroll instead of select. We
// track that with `dragged` and swallow the resulting click in capture
// phase so a drag never also fires the tab's onClick.
useEffect(() => {
const el = dayTabsRef.current;
if (!el) return;
let isDown = false;
let dragged = false;
let startX = 0;
let startScroll = 0;
const suppressClick = (e) => {
e.preventDefault();
e.stopPropagation();
el.removeEventListener('click', suppressClick, true);
};
const onMouseDown = (e) => {
if (!el.contains(e.target)) return;
if (e.button !== 0) return;
isDown = true;
dragged = false;
startX = e.clientX;
startScroll = el.scrollLeft;
};
const onMouseMove = (e) => {
if (!isDown) return;
const dx = e.clientX - startX;
if (!dragged && Math.abs(dx) > 4) {
dragged = true;
// .utci-day-tabs has scroll-behavior:smooth for the chevron/snap
// scrolls elsewhere - suspend it during drag so scrollLeft tracks
// the pointer 1:1 instead of easing behind it.
el.style.scrollBehavior = 'auto';
el.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
el.addEventListener('click', suppressClick, true);
}
if (dragged) el.scrollLeft = startScroll - dx;
};
const onMouseUp = () => {
if (!isDown) return;
isDown = false;
el.style.scrollBehavior = '';
el.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
// Safety net: normally the browser's own click (fired right after
// mouseup, synchronously ahead of this timeout) hits suppressClick
// first and removes it. But if mouseup lands outside every tab or a
// click never follows for any other reason, this stops the listener
// leaking and silently swallowing the *next* real click.
if (dragged) setTimeout(() => el.removeEventListener('click', suppressClick, true), 0);
};
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
el.removeEventListener('click', suppressClick, true);
};
}, [forecast]);
// ── 3. PRO TIER ────────────────────────────────────────────────────── // ── 3. PRO TIER ──────────────────────────────────────────────────────
// (isPro is declared near the top so useForecast can read it; see above.) // (isPro is declared near the top so useForecast can read it; see above.)
+11
View File
@@ -171,5 +171,16 @@
"custom": 1, "custom": 1,
"farming": 1 "farming": 1
} }
},
"2026-07-29": {
"visits": 32,
"profiles": {
"vehicle": 9,
"home": 9,
"basic": 7,
"showall": 1,
"pets": 3,
"alltemps": 2
}
} }
} }
+2 -2
View File
@@ -70,10 +70,10 @@
<link rel="preconnect" href="https://api.open-meteo.com" crossorigin /> <link rel="preconnect" href="https://api.open-meteo.com" crossorigin />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" /> <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="stylesheet" href="./assets/sunscope.css?v=15k"> <link rel="stylesheet" href="./assets/sunscope.css?v=15m">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./assets/js/main.js?v=15g"></script> <script type="module" src="./assets/js/main.js?v=15i"></script>
</body> </body>
</html> </html>