3.0.0
Added variable temps to the simple view Fixed height shifting on model selections Tweaked style and added guides in simple view Linked profiles to simple view
This commit is contained in:
+166
-26
@@ -138,6 +138,59 @@ export function UTCIForecast() {
|
||||
const searchWrapRef = useRef(null);
|
||||
const [colTogglesOpen, setColTogglesOpen] = useState(false);
|
||||
const searchInputRef = useRef(null);
|
||||
const fscScrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = fscScrollRef.current;
|
||||
if (!el) return;
|
||||
let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
|
||||
const onMouseDown = (e) => {
|
||||
if (!el.contains(e.target) || e.button !== 0) return;
|
||||
isDown = true; hasDragged = false;
|
||||
startX = e.clientX; startScroll = el.scrollLeft;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.webkitUserSelect = 'none';
|
||||
};
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDown) return;
|
||||
const dx = e.clientX - startX;
|
||||
if (Math.abs(dx) > 5) {
|
||||
hasDragged = true;
|
||||
el.style.cursor = 'grabbing';
|
||||
el.scrollLeft = startScroll - dx;
|
||||
}
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
if (!isDown) return;
|
||||
isDown = false;
|
||||
el.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.webkitUserSelect = '';
|
||||
};
|
||||
const onClickCapture = (e) => {
|
||||
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
|
||||
};
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
el.addEventListener('click', onClickCapture, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
el.removeEventListener('click', onClickCapture, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [simpleTemp, setSimpleTemp] = useState('utciAdj');
|
||||
useEffect(() => {
|
||||
if (activeProfile === 'vehicle') setSimpleTemp('vehicleT');
|
||||
else if (activeProfile === 'home') setSimpleTemp('indoorT');
|
||||
else setSimpleTemp('utciAdj');
|
||||
if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
|
||||
setForecastView('table');
|
||||
}
|
||||
}, [activeProfile]);
|
||||
|
||||
// Focus the input the moment the search opens.
|
||||
useEffect(() => {
|
||||
@@ -668,6 +721,65 @@ export function UTCIForecast() {
|
||||
<span class="fvt-interval">
|
||||
${[1, 2, 3, 4].map(n => html`<button key=${n} type="button" class=${'hour-interval-btn' + (tableInterval === n ? ' on' : '')} title=${n === 1 ? 'Every hour' : `Every ${n} hours`} onClick=${() => setTableInterval(n)}>${n}h</button>`)}
|
||||
</span>
|
||||
${forecastView === 'simple' && (() => {
|
||||
const showSolar = visibleCols.utciP;
|
||||
const showVehicle = visibleCols.vehicleT;
|
||||
const showIndoor = visibleCols.indoorT || visibleCols.managedT;
|
||||
if (!showSolar && !showVehicle && !showIndoor) return null;
|
||||
const solarOn = simpleTemp === 'utciAdj';
|
||||
const vehicleOn = simpleTemp === 'vehicleT';
|
||||
const indoorOn = simpleTemp === 'indoorT' || simpleTemp === 'managedT';
|
||||
return html`
|
||||
<span class="fvt-model-sep"></span>
|
||||
${showSolar && html`
|
||||
<span class=${'col-toggle-group' + (solarOn ? ' col-toggle-group--expanded' : '')}>
|
||||
<${CustomSelect}
|
||||
value=${utciEnv} isOn=${solarOn} grpClass="grp-felt"
|
||||
hideLabel="Solar Model" hidingLabel="Solar Model"
|
||||
groupedLeft=${true} isLastChild=${true}
|
||||
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label }))}
|
||||
onChange=${v => {
|
||||
if (v === 'off') setSimpleTemp(showVehicle ? 'vehicleT' : showIndoor ? 'indoorT' : 'utciAdj');
|
||||
else { setUtciEnv(v); setSimpleTemp('utciAdj'); }
|
||||
}}
|
||||
/>
|
||||
</span>`}
|
||||
${showVehicle && html`
|
||||
<span class=${'col-toggle-group' + (vehicleOn ? ' col-toggle-group--expanded' : '')}>
|
||||
<${CustomSelect}
|
||||
value=${vehicleType} isOn=${vehicleOn} grpClass="grp-felt"
|
||||
hideLabel="Vehicle" hidingLabel="Vehicle"
|
||||
groupedLeft=${true} isLastChild=${!vehicleOn}
|
||||
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
|
||||
onChange=${v => {
|
||||
if (v === 'off') setSimpleTemp(showSolar ? 'utciAdj' : showIndoor ? 'indoorT' : 'vehicleT');
|
||||
else { setVehicleType(v); setSimpleTemp('vehicleT'); }
|
||||
}}
|
||||
/>
|
||||
${vehicleOn && html`
|
||||
<${VentPill} checked=${vehicleVent} onChange=${() => setVehicleVent(v => !v)}
|
||||
grpClass="grp-felt" label="Ventilation"
|
||||
title="Open windows significantly reduce cabin heat build-up" />`}
|
||||
</span>`}
|
||||
${showIndoor && html`
|
||||
<span class=${'col-toggle-group' + (indoorOn ? ' col-toggle-group--expanded' : '')}>
|
||||
<${CustomSelect}
|
||||
value=${buildingType} isOn=${indoorOn} grpClass="grp-felt"
|
||||
hideLabel="Indoors" hidingLabel="Indoors"
|
||||
groupedLeft=${true} isLastChild=${!indoorOn}
|
||||
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
|
||||
onChange=${v => {
|
||||
if (v === 'off') setSimpleTemp(showSolar ? 'utciAdj' : showVehicle ? 'vehicleT' : 'indoorT');
|
||||
else { setBuildingType(v); setIndoorMode('on'); setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT'); }
|
||||
}}
|
||||
/>
|
||||
${indoorOn && html`
|
||||
<${VentPill} checked=${indoorManaged} onChange=${() => { const next = !indoorManaged; setIndoorManaged(next); setSimpleTemp(next ? 'managedT' : 'indoorT'); }}
|
||||
grpClass="grp-felt" label="Managed"
|
||||
title="Curtains closed by day, windows open when cooler outside" />`}
|
||||
</span>`}
|
||||
`;
|
||||
})()}
|
||||
<span class="col-toggles-label">Columns:</span>
|
||||
<button class=${'col-toggles-edit-btn' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '')} onClick=${() => setColTogglesOpen(v => !v)}>
|
||||
<span class="col-toggles-edit-btn-label">${colTogglesOpen ? 'Done' : 'Edit columns'}</span>
|
||||
@@ -840,17 +952,18 @@ export function UTCIForecast() {
|
||||
</div>
|
||||
|
||||
<div class="forecast-simple-wrap">
|
||||
<div class="forecast-simple-scroll">
|
||||
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
||||
<div class="forecast-simple-inner">
|
||||
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: `repeat(${tableRows.length}, minmax(55px, 1fr))` }}>
|
||||
${tableRows.map(r => {
|
||||
const cat = utciCategory(r.utciAdj);
|
||||
const dispTemp = r[simpleTemp] ?? r.utciAdj;
|
||||
const cat = utciCategory(dispTemp);
|
||||
const h24s = parseInt(r.iso.slice(11, 13), 10);
|
||||
const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`;
|
||||
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
|
||||
const domIcon = (() => {
|
||||
if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0))
|
||||
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} />`;
|
||||
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`;
|
||||
if (r.cloudCat && r.cloudCat !== 'clear')
|
||||
return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} />`;
|
||||
return null;
|
||||
@@ -868,7 +981,7 @@ export function UTCIForecast() {
|
||||
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph</span>
|
||||
<span class="fsc-meta-row fsc-meta-dir">${r.compass ? r.compass.label : '—'}</span>
|
||||
</div>
|
||||
<div class="fsc-temp">${Math.round(r.utciAdj)}°</div>
|
||||
<div class="fsc-temp">${Math.round(dispTemp)}°</div>
|
||||
<div class="fsc-feel" style=${{background: cat.bg, color: cat.fg}}>${cat.label}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -876,28 +989,41 @@ export function UTCIForecast() {
|
||||
</div>
|
||||
${(() => {
|
||||
if (!tableRows.length) return null;
|
||||
const fscTemps = tableRows.map(r => r.utciAdj);
|
||||
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
|
||||
const fscMin = Math.min(...fscTemps) - 2;
|
||||
const fscMax = Math.max(...fscTemps) + 2;
|
||||
const fscN = fscTemps.length;
|
||||
const fscN = tableRows.length;
|
||||
const fscSvgW = 1000, fscSvgH = 80;
|
||||
const fscCardW = fscSvgW / fscN;
|
||||
// Card-centre points — used for connector lines and the stroke curve
|
||||
const fscPts = fscTemps.map((t, i) => ({
|
||||
x: (i + 0.5) * fscCardW,
|
||||
y: fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * 44 - 6,
|
||||
// Use full 1-hour data for the smooth curve, tableRows for connectors/gradient
|
||||
const fscSrc = visible.length > fscN ? visible : tableRows;
|
||||
// Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10
|
||||
const fscMin = -15, fscMax = 45;
|
||||
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
|
||||
const getT = r => r[simpleTemp] ?? r.utciAdj;
|
||||
// 1-hour points for the smooth curve — evenly spread across SVG width
|
||||
const fscAllPts = fscSrc.map((r, j) => ({
|
||||
x: (j + 0.5) * fscSvgW / fscSrc.length,
|
||||
y: toY(getT(r)),
|
||||
}));
|
||||
// Extended points (edge-anchored) used only for the gradient fill
|
||||
const fscFillPts = [{ x: 0, y: fscPts[0].y }, ...fscPts, { x: fscSvgW, y: fscPts[fscN - 1].y }];
|
||||
// Stroke path through card centres
|
||||
let fscLine = `M ${fscPts[0].x},${fscPts[0].y}`;
|
||||
for (let i = 1; i < fscPts.length; i++) {
|
||||
const p0 = fscPts[i - 1], p1 = fscPts[i];
|
||||
const cpx = (p0.x + p1.x) / 2;
|
||||
fscLine += ` C ${cpx},${p0.y} ${cpx},${p1.y} ${p1.x},${p1.y}`;
|
||||
}
|
||||
// Fill path through edge-anchored points
|
||||
// Connector points — x centred under card column, y at the exact point
|
||||
// the 1-hour bezier curve passes through at that x.
|
||||
// For a bucket of size n starting at visible index jFirst, the card
|
||||
// centre x lands at the bezier midpoint between visible[jFirst+(n-1)/2 floor]
|
||||
// and visible[jFirst+(n-1)/2 ceil], so y = lerp of those two neighbours.
|
||||
let fscVj = 0;
|
||||
const fscPts = tableRows.map((r, i) => {
|
||||
const bucketLen = r.isoHours ? r.isoHours.length : 1;
|
||||
const jFirst = fscVj;
|
||||
fscVj += bucketLen;
|
||||
const x = (i + 0.5) * fscCardW;
|
||||
const ctr = (bucketLen - 1) / 2;
|
||||
const jLow = Math.min(jFirst + Math.floor(ctr), fscSrc.length - 1);
|
||||
const jHigh = Math.min(jFirst + Math.ceil(ctr), fscSrc.length - 1);
|
||||
const frac = ctr - Math.floor(ctr);
|
||||
const tCtr = getT(fscSrc[jLow]) * (1 - frac) + getT(fscSrc[jHigh]) * frac;
|
||||
return { x, y: toY(tCtr) };
|
||||
});
|
||||
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
|
||||
// Extended fill points (edge-anchored) for the gradient area
|
||||
const fscFillPts = [{ x: 0, y: fscAllPts[0].y }, ...fscAllPts, { x: fscSvgW, y: fscAllPts[fscAllPts.length - 1].y }];
|
||||
let fscFillLine = `M ${fscFillPts[0].x},${fscFillPts[0].y}`;
|
||||
for (let i = 1; i < fscFillPts.length; i++) {
|
||||
const p0 = fscFillPts[i - 1], p1 = fscFillPts[i];
|
||||
@@ -909,12 +1035,20 @@ export function UTCIForecast() {
|
||||
<svg class="fsc-temp-curve" viewBox="0 0 ${fscSvgW} ${fscSvgH}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="fsc-grad" x1="0" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
|
||||
${fscTemps.map((t, i) => {
|
||||
const fc = utciCategory(t);
|
||||
return html`<stop key=${i} offset=${`${((i + 0.5) / fscN * 100).toFixed(1)}%`} stop-color=${fc.bg} />`;
|
||||
${fscSrc.map((r, j) => {
|
||||
const fc = utciCategory(getT(r));
|
||||
return html`<stop key=${j} offset=${`${((j + 0.5) / fscSrc.length * 100).toFixed(1)}%`} stop-color=${fc.bg} />`;
|
||||
})}
|
||||
</linearGradient>
|
||||
</defs>
|
||||
${[[0, '#4a90c4'], [20, '#4a9e6a']].map(([t, color]) => {
|
||||
const gy = toY(t);
|
||||
return html`
|
||||
<line key=${'g'+t} x1="0" y1=${gy} x2=${fscSvgW} y2=${gy}
|
||||
stroke=${color} stroke-width="1" opacity="0.35"
|
||||
vector-effect="non-scaling-stroke" />
|
||||
`;
|
||||
})}
|
||||
${fscPts.map((pt, i) => {
|
||||
const isNow = fscNowFlags[i];
|
||||
return html`
|
||||
@@ -922,6 +1056,7 @@ export function UTCIForecast() {
|
||||
stroke=${isNow ? '#c8922a' : '#b09870'}
|
||||
stroke-width="1"
|
||||
opacity=${isNow ? '1' : '0.55'}
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
`;
|
||||
})}
|
||||
@@ -931,6 +1066,11 @@ export function UTCIForecast() {
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fsc-table-btn-wrap">
|
||||
<button type="button" class="fsc-table-btn" onClick=${() => setForecastView('table')}>
|
||||
View Detailed Hourly Forecast →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}${showUnits ? '' : ' no-units'}`} ref=${tableWrapRef}>
|
||||
|
||||
Reference in New Issue
Block a user