mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-22 03:29:31 -07:00
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:
@@ -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 {
|
try {
|
||||||
// get the forecast
|
const forecast = await safeJson(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||||
forecast = await json(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
|
||||||
// parse the forecast
|
if (forecast) {
|
||||||
this.data = await parseForecast(forecast.properties);
|
try {
|
||||||
} catch (error) {
|
// parse the forecast
|
||||||
console.error('Get hourly forecast failed');
|
this.data = await parseForecast(forecast.properties);
|
||||||
console.error(error.status, error.responseJSON);
|
} catch (error) {
|
||||||
// use old data if available
|
console.error(`Hourly forecast parsing failed: ${error.message}`);
|
||||||
if (this.data) {
|
}
|
||||||
console.log('Using previous hourly forecast');
|
} else if (debugFlag('verbose-failures')) {
|
||||||
// don't return, this.data is usable from the previous update
|
console.warn(`Using previous hourly forecast for ${this.weatherParameters.forecastGridData}`);
|
||||||
} 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();
|
||||||
|
if (!superResponse) return;
|
||||||
|
|
||||||
|
this.setStatus(STATUS.loaded);
|
||||||
|
this.drawLongCanvas();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Unexpected error getting hourly forecast: ${error.message}`);
|
||||||
|
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||||
|
this.getDataCallback(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.getDataCallback();
|
|
||||||
if (!superResponse) return;
|
|
||||||
|
|
||||||
this.setStatus(STATUS.loaded);
|
|
||||||
this.drawLongCanvas();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user