diff --git a/assets/css/table.css b/assets/css/table.css index 7386b2f..f74f2e1 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -383,6 +383,17 @@ cursor: help; } +/* Hidden reference copy of the Activities tab (see ConfigPanel.js) — stays + in normal flow so its natural width sets the dropdown's fixed size, but + is never seen or reachable. */ +.profile-scroll-sizer { + visibility: hidden; + height: 0; + overflow: hidden; + pointer-events: none; +} +.profile-scroll-sizer .profile-scroll { pointer-events: none; } + /* Single scrollable row of profile cards */ .profile-scroll { display: flex; @@ -586,11 +597,9 @@ visibility 0s linear 0s; } .profile-dropdown-body { - max-width: 900px; - margin: 0 auto; max-height: calc(100vh - 24px); overflow-y: auto; - padding: 12px 24px 16px; + padding: 16px; } .profile-dropdown-close { position: absolute; @@ -618,6 +627,10 @@ top: var(--profile-anchor-top, 0px); bottom: auto; max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px); + width: fit-content; + min-width: 380px; + max-width: min(880px, calc(100vw - 32px)); + margin: 0 auto; border: 1.5px solid #c9b08a; border-radius: 12px; overflow: hidden; /* keeps the scrolling body inside the rounded corners */ @@ -663,7 +676,7 @@ border: 2px solid #d4c0a0; border-radius: 9px; cursor: pointer; - box-shadow: inset 0 -1px 0 rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.04); + box-shadow: inset 0 -1px 0 rgba(0,0,0,0.05), 0 2px 5px rgba(0,0,0,0.08); transition: filter 0.2s, border-color 0.2s, box-shadow 0.2s; } /* Remove alpha from the default fallback color and any potential inherited @@ -724,13 +737,18 @@ letter-spacing: 0.04em; color: #9a6a1a; margin-left: 8px; - opacity: 0.7; - transition: opacity 0.2s, transform 0.2s, color 0.2s; + padding: 4px 10px; + background: rgba(200,146,42,0.12); + border: 1px solid rgba(200,146,42,0.35); + border-radius: 999px; + transition: background 0.2s, border-color 0.2s, color 0.2s, transform 0.2s; } .active-profile-row:hover .active-profile-row-edit, .active-profile-row:focus-visible .active-profile-row-edit { - opacity: 1; color: #c8922a; + background: rgba(200,146,42,0.2); + border-color: #c8922a; + transform: translateY(-1px); } /* Border + text accent per thermal-model group, reusing the same colour @@ -1108,9 +1126,12 @@ z-index: 6; /* above sticky header (z-index 5) */ } -/* Left fade — warm cream fading to transparent rightward */ +/* Left fade — warm cream fading to transparent rightward. + Offset by the pinned Hour column's width (--hour-col-w, set by JS in + useTableScroll.js) so the fade sits over the first column that + actually scrolls, not over the Hour column itself. */ .utci-table-wrap::before { - left: 0; + left: var(--hour-col-w, 0px); background: linear-gradient(to right, rgba(240,228,196,0.92) 0%, transparent 100%); } @@ -1137,7 +1158,7 @@ transition: opacity 0.2s ease; user-select: none; } -.utci-table-wrap .utci-scroll-chevron.left { left: 6px; } +.utci-table-wrap .utci-scroll-chevron.left { left: calc(var(--hour-col-w, 0px) + 6px); } .utci-table-wrap .utci-scroll-chevron.right { right: 6px; } .utci-table-wrap.scroll-fade-left .utci-scroll-chevron.left { opacity: 1; } @@ -1255,6 +1276,34 @@ text-align: left; } +/* ── PINNED HOUR COLUMN ────────────────────────────────────────────────── + The Hour/time column stays put while the rest of the table scrolls + horizontally, mirroring the fixed label column on the day tabs strip. + The body scroller genuinely scrolls (overflow-x: auto) so a plain + sticky left:0 works there. The header track instead gets shifted by + JS via translate3d to mirror the body's scrollLeft - so its first + cells get an equal-and-opposite translate3d applied by JS (see + handleBodyScroll in useTableScroll.js) to cancel that shift and stay + visually pinned at the left edge. */ +.utci-table tbody td:first-child { + position: sticky; + left: 0; + z-index: 2; + background: #fffcf2; +} +.utci-table tbody tr:hover td:first-child { + background: #fef8ea; +} +.utci-table tbody tr.is-now td:first-child { + background: #fff8e4; +} + +.utci-table thead th:first-child { + position: relative; + z-index: 3; + will-change: transform; +} + /* The "°C" / "m/s" etc. unit underneath each heading */ .utci-table th .col-unit { display: block; @@ -1965,6 +2014,7 @@ letter-spacing: 0.07em; color: #7a5c2a; flex-shrink: 0; + margin-left: 0; } .forecast-view-toggle { @@ -1980,7 +2030,7 @@ font-size: 11px; font-weight: 700; line-height: 1; - padding: 6px 14px; + padding: 6px 8px; cursor: pointer; border: 1.5px solid #c9b08a; background: #f5edd6; @@ -2021,7 +2071,7 @@ display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; - padding: 0 14px; + padding: 0; } .fvt-toolbar-left { grid-column: 1; @@ -2050,7 +2100,7 @@ grid-template-columns: 1fr; justify-items: center; row-gap: 6px; - padding: 6px 10px 0; + padding: 6px 0 10px; } .fvt-toolbar-left { grid-column: 1; diff --git a/assets/js/components/ConfigPanel.js b/assets/js/components/ConfigPanel.js index 2630282..74332cd 100644 --- a/assets/js/components/ConfigPanel.js +++ b/assets/js/components/ConfigPanel.js @@ -269,6 +269,24 @@ export function ConfigPanel({ >${label} `)} + + + +
diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js index 4ec01bc..702ea63 100644 --- a/assets/js/components/DayTabs.js +++ b/assets/js/components/DayTabs.js @@ -33,7 +33,7 @@ import { h, Fragment } from '../../vendor/preact.js'; import htm from '../../vendor/htm.js'; -import { confidenceBand, utciCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; +import { confidenceBand, utciCategory, petCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js'; import { calcVehicleInteriorTempPass } from '../physics.js'; import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js'; @@ -134,7 +134,7 @@ export function DayTabs({ // only need the derived main metric to compute the day-tab numbers and to // label the "what temps am I looking at" row below. const { - isHomeOrOffice, isVehicleProfile, mainConfigKey, mainField, secondaryField, mainLabel, + isHomeOrOffice, isVehicleProfile, isPetsProfile, mainConfigKey, mainField, secondaryField, mainLabel, mainMetricLabel, mainMetricDesc, secondaryMetricLabel, secondaryMetricDesc, } = deriveProfileMain(activeProfile, outdoorsVariant); @@ -302,12 +302,18 @@ export function DayTabs({ // • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour. const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * Math.max(0, Math.min(1, t)))); const hex2rgb = (hx) => { const n = parseInt(hx.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; - const tBand = dayHi !== null ? utciCategory(dayHi) : { bg: '#f8e554' }; + const catFn = isPetsProfile ? petCategory : utciCategory; + const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' }; const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v)); const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0; - const isHot = dayHi !== null && dayHi >= 29; // Hot band and above + // Hot/cold cutoffs below are calibrated against UTCI_BANDS; for the + // Pets profile (furSurfaceT, judged against the shifted PET_BANDS) + // the same cutoffs are re-projected onto the pet scale so the + // "hot hue" / "plain cold" branches kick in at the same relative + // point in each band, not the same absolute degree. + const isHot = dayHi !== null && dayHi >= (isPetsProfile ? 35 : 29); // Hot band and above const isDanger = tBand.solid === true; // Home/indoor/vehicle tabs show a modelled temperature, not the @@ -321,7 +327,9 @@ export function DayTabs({ } else if (isHot) { // Keep the heat hue; cloud only mutes it a touch (orange/red never // greens) and rain does not override heat. - const heatFactor = dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45; + const heatFactor = isPetsProfile + ? (dayHi >= 49 ? 0.15 : dayHi >= 43 ? 0.30 : 0.45) + : (dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45); const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor; rgb = mix(hex2rgb(tBand.bg), [170, 162, 152], t); } else if (daySnow >= 0.1) { @@ -333,7 +341,7 @@ export function DayTabs({ } else if (avgCloud >= 30) { // Cloud: light → dark grey with cover (pure grey, no temperature hue). rgb = mix([205, 206, 208], [110, 115, 122], Math.max(0, Math.min(1, (avgCloud - 30) / 70))); - } else if (dayHi !== null && dayHi < 10) { + } else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) { // Clear & cold: keep the cold temperature blue. rgb = hex2rgb(tBand.bg); } else { diff --git a/assets/js/compute.js b/assets/js/compute.js index adb623e..6250d36 100644 --- a/assets/js/compute.js +++ b/assets/js/compute.js @@ -199,7 +199,7 @@ export function buildHourlyRows({ forecast, airQuality, location, vehicleType, v // Fur surface temperature with thermal lag - Pets profile. const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo; - const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo); + const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo, uvArr, cloudCatArr); hourlyRows.forEach((r, i) => { r.furSurfaceT = furTemps[i]; }); } diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js index 321babb..5abe0f4 100644 --- a/assets/js/hooks/useAppState.js +++ b/assets/js/hooks/useAppState.js @@ -630,7 +630,7 @@ export function useAppState() { tableCanScrollRight, handleBodyScroll, } = useTableScroll({ - headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, + headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef, forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, }); diff --git a/assets/js/hooks/useTableScroll.js b/assets/js/hooks/useTableScroll.js index e159811..e09a322 100644 --- a/assets/js/hooks/useTableScroll.js +++ b/assets/js/hooks/useTableScroll.js @@ -29,6 +29,7 @@ export function useTableScroll({ bodyTableRef, bodyScrollRef, headTrackRef, + tableWrapRef, forecast, visibleCols, selectedDay, @@ -111,6 +112,15 @@ export function useTableScroll({ const body = bodyScrollRef.current; if (!track || !body) return; track.style.transform = `translate3d(${-body.scrollLeft}px, 0, 0)`; + // The Hour column's header cells (one per thead row) need to stay + // pinned to the left edge even though the track they live in is + // being shifted above - cancel that shift with an equal-and-opposite + // translate so they hold still while the rest of the header scrolls. + const headTable = headTableRef.current; + if (headTable) { + const pinnedCells = headTable.querySelectorAll('thead th:first-child'); + pinnedCells.forEach(c => { c.style.transform = `translate3d(${body.scrollLeft}px, 0, 0)`; }); + } updateTableScrollIndicators(); }; @@ -198,6 +208,12 @@ export function useTableScroll({ // column N in the body, no drift as you scroll right. headTable.style.width = `${totalWidth}px`; bodyTable.style.width = `${totalWidth}px`; + // Expose the pinned Hour column's width as a CSS var so the left + // scroll-fade indicator can start after it, over the first column + // that actually scrolls, rather than over the pinned column itself. + if (tableWrapRef && tableWrapRef.current) { + tableWrapRef.current.style.setProperty('--hour-col-w', `${finalW[0]}px`); + } // Re-apply current horizontal offset so column alignment survives. handleBodyScroll(); }; diff --git a/assets/js/physics.js b/assets/js/physics.js index 164cd17..7f18e1e 100644 --- a/assets/js/physics.js +++ b/assets/js/physics.js @@ -620,11 +620,25 @@ export function calcShadeAirTemp(Ta, effectiveRad, va, elev, shade) { // A breed-specific multiplier (brachycephalic/senior) is deliberately not // modelled - this profile targets cats and small dogs as the reference animal. // ------------------------------------------------------------------------- -export function calcFurSurfaceTemp(Ta, globalRad, windSpeed, solElev, furAlbedo = 0.15) { +export function calcFurSurfaceTemp(Ta, globalRad, windSpeed, solElev, furAlbedo = 0.15, uv, cloudCat) { if (globalRad == null || Ta == null) return null; const elevDeg = (solElev != null) ? Math.max(5, solElev) : 45; const angleCorrection = Math.sin(elevDeg * Math.PI / 180); - const absorbed = globalRad * (1 - furAlbedo) * angleCorrection; + // Same cloud/UV-clarity derating calcConcreteTemp() applies on top of the + // raw radiation figure - both models share the same input array + // (hourlyRows.effectiveRad), so without these fur was absorbing the full + // beam while concrete was further discounted for haze/cloud, making fur + // read hotter than pavement on anything but a clear sky (backwards from + // the "never bakes as hot as dead concrete" intent above). + const cloudTransmit = cloudCat === 'clear' ? 1.00 + : cloudCat === 'wispy' ? 0.92 + : cloudCat === 'scattered' ? 0.72 + : cloudCat === 'overcast' ? 0.28 + : 1.00; + const uvClarity = (uv && uv > 0) + ? 0.85 + 0.15 * Math.min(1, uv / 8) + : 1.0; + const absorbed = globalRad * (1 - furAlbedo) * angleCorrection * cloudTransmit * uvClarity; // Lower convective coefficient than bare concrete (hc=10+4.5*sqrt(v)) - // trapped air in the coat insulates, running the surface hotter for a // given wind speed than pavement, but not as insulated as a thick double @@ -639,13 +653,13 @@ export function calcFurSurfaceTemp(Ta, globalRad, windSpeed, solElev, furAlbedo export const LAG_HOURS_FUR = 0.4; // ~24 min - low thermal mass, animal moves in/out of sun -export function calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo = 0.15) { +export function calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo = 0.15, uvArr, cloudCatArr) { const n = TaArr.length; const result = new Array(n); const alpha = 1 - Math.exp(-1 / LAG_HOURS_FUR); - let Tf = calcFurSurfaceTemp(TaArr[0], radArr[0], vaArr[0], elevArr[0], furAlbedo) ?? TaArr[0]; + let Tf = calcFurSurfaceTemp(TaArr[0], radArr[0], vaArr[0], elevArr[0], furAlbedo, uvArr?.[0], cloudCatArr?.[0]) ?? TaArr[0]; for (let i = 0; i < n; i++) { - const target = calcFurSurfaceTemp(TaArr[i], radArr[i], vaArr[i], elevArr[i], furAlbedo) ?? TaArr[i]; + const target = calcFurSurfaceTemp(TaArr[i], radArr[i], vaArr[i], elevArr[i], furAlbedo, uvArr?.[i], cloudCatArr?.[i]) ?? TaArr[i]; Tf = Tf + alpha * (target - Tf); result[i] = Math.max(TaArr[i], Tf); } diff --git a/data/tracking.json b/data/tracking.json index 2da7645..3b1027d 100644 --- a/data/tracking.json +++ b/data/tracking.json @@ -173,14 +173,25 @@ } }, "2026-07-29": { - "visits": 32, + "visits": 44, "profiles": { - "vehicle": 9, - "home": 9, - "basic": 7, - "showall": 1, - "pets": 3, - "alltemps": 2 + "vehicle": 16, + "home": 13, + "basic": 12, + "showall": 5, + "pets": 9, + "alltemps": 6, + "farming": 1 + } + }, + "2026-07-30": { + "visits": 6, + "profiles": { + "farming": 3, + "home": 4, + "basic": 2, + "vehicle": 4, + "pets": 1 } } } \ No newline at end of file