Refactor timing calculations and improve scroll performance

- Replace magic numbers with seconds-based timing constants
- Switch from scrollTo() to hardware-accelerated transform for smooth scrolling
- Add scroll caching to prevent repeated DOM queries every scroll cycle
- Fix calculations to support flexible hourly forecast lengths
    (i.e. in the future, could offer every other hour)
- Switch to safeJson() for centralized error handling
This commit is contained in:
Eddy G
2025-06-24 23:40:07 -04:00
parent c0e2eaf33a
commit e34137e430

View File

@@ -2,60 +2,70 @@
import STATUS from './status.mjs'; import STATUS from './status.mjs';
import { DateTime, Interval, Duration } from '../vendor/auto/luxon.mjs'; import { DateTime, Interval, Duration } from '../vendor/auto/luxon.mjs';
import { json } from './utils/fetch.mjs'; import { safeJson } from './utils/fetch.mjs';
import { temperature as temperatureUnit, distanceKilometers } from './utils/units.mjs'; import { temperature as temperatureUnit, distanceKilometers } from './utils/units.mjs';
import { getHourlyIcon } from './icons.mjs'; import { getHourlyIcon } from './icons.mjs';
import { directionToNSEW } from './utils/calc.mjs'; import { directionToNSEW } from './utils/calc.mjs';
import WeatherDisplay from './weatherdisplay.mjs'; import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay, timeZone } from './navigation.mjs'; import { registerDisplay, timeZone } from './navigation.mjs';
import getSun from './almanac.mjs'; import getSun from './almanac.mjs';
import calculateScrollTiming from './utils/scroll-timing.mjs';
import { debugFlag } from './utils/debug.mjs';
class Hourly extends WeatherDisplay { class Hourly extends WeatherDisplay {
constructor(navId, elemId, defaultActive) { constructor(navId, elemId, defaultActive) {
// special height and width for scrolling // special height and width for scrolling
super(navId, elemId, 'Hourly Forecast', defaultActive); super(navId, elemId, 'Hourly Forecast', defaultActive);
// set up the timing // cache for scroll calculations
this.timing.baseDelay = 20; // This cache is essential because baseCountChange() is called 25 times per second (every 40ms)
// 24 hours = 6 pages // during scrolling. Without caching, we'd perform hundreds of expensive DOM layout queries during
const pages = 4; // first page is already displayed, last page doesn't happen // the full scroll cycle. The cache reduces this to one calculation when content changes, then
const timingStep = 75 * 4; // reuses cached values to try and get smoother scrolling.
this.timing.delay = [150 + timingStep]; this.scrollCache = {
// add additional pages displayHeight: 0,
for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep); contentHeight: 0,
// add the final 3 second delay maxOffset: 0,
this.timing.delay.push(150); hourlyLines: null,
};
} }
async getData(weatherParameters, refresh) { async getData(weatherParameters, refresh) {
// super checks for enabled // super checks for enabled
const superResponse = super.getData(weatherParameters, refresh); const superResponse = super.getData(weatherParameters, refresh);
let forecast;
try {
const forecast = await safeJson(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
if (forecast) {
try { try {
// get the forecast
forecast = await json(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
// parse the forecast // parse the forecast
this.data = await parseForecast(forecast.properties); this.data = await parseForecast(forecast.properties);
} catch (error) { } catch (error) {
console.error('Get hourly forecast failed'); console.error(`Hourly forecast parsing failed: ${error.message}`);
console.error(error.status, error.responseJSON); }
// use old data if available } else if (debugFlag('verbose-failures')) {
if (this.data) { console.warn(`Using previous hourly forecast for ${this.weatherParameters.forecastGridData}`);
console.log('Using previous hourly forecast'); }
// don't return, this.data is usable from the previous update
} else { // use old data if available, fail if no data at all
if (!this.data) {
if (this.isEnabled) this.setStatus(STATUS.failed); if (this.isEnabled) this.setStatus(STATUS.failed);
// return undefined to other subscribers // return undefined to other subscribers
this.getDataCallback(undefined); this.getDataCallback(undefined);
return; return;
} }
}
this.getDataCallback(); this.getDataCallback();
if (!superResponse) return; if (!superResponse) return;
this.setStatus(STATUS.loaded); this.setStatus(STATUS.loaded);
this.drawLongCanvas(); this.drawLongCanvas();
} catch (error) {
console.error(`Unexpected error getting hourly forecast: ${error.message}`);
if (this.isEnabled) this.setStatus(STATUS.failed);
this.getDataCallback(undefined);
}
} }
async drawLongCanvas() { async drawLongCanvas() {
@@ -102,6 +112,9 @@ class Hourly extends WeatherDisplay {
}); });
list.append(...lines); list.append(...lines);
// update timing based on actual content
this.setTiming(list);
} }
drawCanvas() { drawCanvas() {
@@ -122,19 +135,35 @@ class Hourly extends WeatherDisplay {
// base count change callback // base count change callback
baseCountChange(count) { baseCountChange(count) {
// get the hourly lines element and cache measurements if needed
const hourlyLines = this.elem.querySelector('.hourly-lines');
if (!hourlyLines) return;
// update cache if needed (when content changes or first run)
if (this.scrollCache.hourlyLines !== hourlyLines || this.scrollCache.displayHeight === 0) {
this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight;
this.scrollCache.contentHeight = hourlyLines.offsetHeight;
this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight);
this.scrollCache.hourlyLines = hourlyLines;
// Set up hardware acceleration on the hourly lines element
hourlyLines.style.willChange = 'transform';
hourlyLines.style.backfaceVisibility = 'hidden';
}
// calculate scroll offset and don't go past end // calculate scroll offset and don't go past end
let offsetY = Math.min(this.elem.querySelector('.hourly-lines').offsetHeight - 289, (count - 150)); let offsetY = Math.min(this.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
// don't let offset go negative // don't let offset go negative
if (offsetY < 0) offsetY = 0; if (offsetY < 0) offsetY = 0;
// copy the scrolled portion of the canvas // use transform instead of scrollTo for hardware acceleration
this.elem.querySelector('.main').scrollTo(0, offsetY); hourlyLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
} }
// make data available outside this class // make data available outside this class
// promise allows for data to be requested before it is available // promise allows for data to be requested before it is available
async getCurrentData(stillWaiting) { async getHourlyData(stillWaiting) {
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
// an external caller has requested data, set up auto reload // an external caller has requested data, set up auto reload
this.setAutoReload(); this.setAutoReload();
@@ -144,6 +173,18 @@ class Hourly extends WeatherDisplay {
this.getDataCallbacks.push(() => resolve(this.data)); this.getDataCallbacks.push(() => resolve(this.data));
}); });
} }
setTiming(list) {
const container = this.elem.querySelector('.main');
const timingConfig = calculateScrollTiming(list, container);
// Apply the calculated timing
this.timing.baseDelay = timingConfig.baseDelay;
this.timing.delay = timingConfig.delay;
this.scrollTiming = timingConfig.scrollTiming;
this.calcNavTiming();
}
} }
// extract specific values from forecast and format as an array // extract specific values from forecast and format as an array
@@ -192,7 +233,7 @@ const determineIcon = async (skyCover, weather, iceAccumulation, probabilityOfPr
}; };
// expand a set of values with durations to an hour-by-hour array // expand a set of values with durations to an hour-by-hour array
const expand = (data) => { const expand = (data, maxHours = 24) => {
const startOfHour = DateTime.utc().startOf('hour').toMillis(); const startOfHour = DateTime.utc().startOf('hour').toMillis();
const result = []; // resulting expanded values const result = []; // resulting expanded values
data.forEach((item) => { data.forEach((item) => {
@@ -202,12 +243,12 @@ const expand = (data) => {
// loop through duration at one hour intervals // loop through duration at one hour intervals
do { do {
// test for timestamp greater than now // test for timestamp greater than now
if (startTime >= startOfHour && result.length < 24) { if (startTime >= startOfHour && result.length < maxHours) {
result.push(item.value); // push data array result.push(item.value); // push data array
} // timestamp is after now } // timestamp is after now
// increment start time by 1 hour // increment start time by 1 hour
startTime += 3_600_000; startTime += 3_600_000;
} while (startTime < endTime && result.length < 24); } while (startTime < endTime && result.length < maxHours);
}); // for each value }); // for each value
return result; return result;
@@ -217,4 +258,4 @@ const expand = (data) => {
const display = new Hourly(3, 'hourly', false); const display = new Hourly(3, 'hourly', false);
registerDisplay(display); registerDisplay(display);
export default display.getCurrentData.bind(display); export default display.getHourlyData.bind(display);