ws4kp-linhanced/server/scripts/modules/navigation.mjs

811 lines
31 KiB
JavaScript
Raw Normal View History

2022-12-06 16:14:56 -06:00
// navigation handles progress, next/previous and initial load messages from the parent frame
import noSleep from './utils/nosleep.mjs';
import STATUS from './status.mjs';
import { wrap } from './utils/calc.mjs';
import { safeJson } from './utils/fetch.mjs';
2022-12-06 16:14:56 -06:00
import { getPoint } from './utils/weather.mjs';
import { debugFlag } from './utils/debug.mjs';
import settings from './settings.mjs';
2022-12-06 16:14:56 -06:00
document.addEventListener('DOMContentLoaded', () => {
init();
});
const displays = [];
let playing = false;
let progress;
const weatherParameters = {};
const init = async () => {
// set up the resize handler with debounce logic to prevent rapid-fire calls
let resizeTimeout;
// Handle fullscreen change events and trigger an immediate resize calculation
const fullscreenEvents = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange'];
fullscreenEvents.forEach((eventName) => {
document.addEventListener(eventName, () => {
if (debugFlag('fullscreen')) {
console.log(`🖥️ ${eventName} event fired. fullscreenElement=${!!document.fullscreenElement}`);
}
resize(true);
});
});
// De-bounced resize handler to prevent rapid-fire resize calls
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => resize(), 100);
});
// Handle orientation changes (Mobile Safari doesn't always fire resize events on orientation change)
window.addEventListener('orientationchange', () => {
if (debugFlag('resize')) {
console.log('📱 Orientation change detected, forcing resize after short delay');
}
clearTimeout(resizeTimeout);
// Use a slightly longer delay for orientation changes to allow the browser to settle
resizeTimeout = setTimeout(() => resize(true), 200);
});
2022-12-08 15:05:51 -06:00
resize();
2022-12-06 16:14:56 -06:00
2022-12-07 10:53:18 -06:00
generateCheckboxes();
2022-12-06 16:14:56 -06:00
};
const message = (data) => {
// dispatch event
2023-01-06 14:39:39 -06:00
if (!data.type) return false;
if (data.type === 'navButton') return handleNavButton(data.message);
return console.error(`Unknown event ${data.type}`);
2022-12-06 16:14:56 -06:00
};
2022-12-13 15:43:06 -06:00
const getWeather = async (latLon, haveDataCallback) => {
2022-12-06 16:14:56 -06:00
// get initial weather data
const point = await getPoint(latLon.lat, latLon.lon);
// check if point data was successfully retrieved
if (!point) {
return;
}
2022-12-13 15:43:06 -06:00
if (typeof haveDataCallback === 'function') haveDataCallback(point);
try {
// get stations using centralized safe handling
const stations = await safeJson(point.properties.observationStations);
if (!stations) {
console.warn('Failed to get Observation Stations');
return;
}
// check if stations data is valid
if (!stations || !stations.features || stations.features.length === 0) {
console.warn('No Observation Stations found for this location');
return;
}
2022-12-06 16:14:56 -06:00
const StationId = stations.features[0].properties.stationIdentifier;
let { city } = point.properties.relativeLocation.properties;
const { state } = point.properties.relativeLocation.properties;
if (StationId in StationInfo) {
city = StationInfo[StationId].city;
[city] = city.split('/');
city = city.replace(/\s+$/, '');
}
// populate the weather parameters
weatherParameters.latitude = latLon.lat;
weatherParameters.longitude = latLon.lon;
weatherParameters.zoneId = point.properties.forecastZone.substr(-6);
weatherParameters.radarId = point.properties.radarStation.substr(-3);
weatherParameters.stationId = StationId;
weatherParameters.weatherOffice = point.properties.cwa;
weatherParameters.city = city;
weatherParameters.state = state;
weatherParameters.timeZone = point.properties.timeZone;
weatherParameters.forecast = point.properties.forecast;
weatherParameters.forecastGridData = point.properties.forecastGridData;
weatherParameters.stations = stations.features;
2025-11-05 05:24:04 +00:00
weatherParameters.relativeLocation = point.properties.relativeLocation.properties;
// update the main process for display purposes
populateWeatherParameters(weatherParameters, point.properties);
// reset the scroll
postMessage({ type: 'current-weather-scroll', method: 'reload' });
// draw the progress canvas and hide others
hideAllCanvases();
if (!settings?.kiosk?.value) {
// In normal mode, hide loading screen and show progress
// (In kiosk mode, keep the loading screen visible until autoplay starts)
document.querySelector('#loading').style.display = 'none';
if (progress) {
await progress.drawCanvas();
progress.showCanvas();
}
}
// call for new data on each display
displays.forEach((display) => display?.getData(weatherParameters));
} catch (error) {
console.error(`Failed to get weather data: ${error.message}`);
2022-12-06 16:14:56 -06:00
}
};
// receive a status update from a module {id, value}
const updateStatus = (value) => {
if (value.id < 0) return;
if (!progress && !settings?.kiosk?.value) return;
if (progress) progress.drawCanvas(displays, countLoadedDisplays());
2022-12-06 16:14:56 -06:00
2022-12-14 16:28:33 -06:00
// first display is hazards and it must load before evaluating the first display
if (!displays[0] || displays[0].status === STATUS.loading) return;
2022-12-14 16:28:33 -06:00
// calculate first enabled display
2025-06-12 12:14:56 -05:00
const firstDisplayIndex = displays.findIndex((display) => display?.enabled && display?.timing?.totalScreens > 0);
// value.id = 0 is hazards, if they fail to load hot-wire a new value.id to the current display to see if it needs to be loaded
// typically this plays out as current conditions loads, then hazards fails.
if (value.id === 0 && (value.status === STATUS.failed || value.status === STATUS.retrying)) {
value.id = firstDisplayIndex;
value.status = displays[firstDisplayIndex].status;
}
// if hazards data arrives after the firstDisplayIndex loads, then we need to hot wire this to the first display
if (value.id === 0 && value.status === STATUS.loaded && displays[0] && displays[0].timing && displays[0].timing.totalScreens === 0) {
value.id = firstDisplayIndex;
value.status = displays[firstDisplayIndex].status;
}
2022-12-06 16:14:56 -06:00
// if this is the first display and we're playing, load it up so it starts playing
if (isPlaying() && value.id === firstDisplayIndex && value.status === STATUS.loaded) {
2022-12-06 16:14:56 -06:00
navTo(msg.command.firstFrame);
}
};
2022-12-12 13:53:33 -06:00
// note: a display that is "still waiting"/"retrying" is considered loaded intentionally
// the weather.gov api has long load times for some products when you are the first
// requester for the product after the cache expires
const countLoadedDisplays = () => displays.reduce((acc, display) => {
if (display?.status !== STATUS.loading) return acc + 1;
2022-12-06 16:14:56 -06:00
return acc;
}, 0);
const hideAllCanvases = () => {
displays.forEach((display) => display?.hideCanvas());
2022-12-06 16:14:56 -06:00
};
// is playing interface
const isPlaying = () => playing;
// navigation message constants
const msg = {
response: { // display to navigation
previous: Symbol('previous'), // already at first frame, calling function should switch to previous canvas
inProgress: Symbol('inProgress'), // have data to display, calling function should do nothing
next: Symbol('next'), // end of frames reached, calling function should switch to next canvas
},
command: { // navigation to display
firstFrame: Symbol('firstFrame'),
previousFrame: Symbol('previousFrame'),
nextFrame: Symbol('nextFrame'),
lastFrame: Symbol('lastFrame'), // used when navigating backwards from the begining of the next canvas
},
};
// receive navigation messages from displays
const displayNavMessage = (myMessage) => {
if (myMessage.type === msg.response.previous) loadDisplay(-1);
if (myMessage.type === msg.response.next) loadDisplay(1);
};
// navigate to next or previous
const navTo = (direction) => {
// test for a current display
const current = currentDisplay();
if (progress) progress.hideCanvas();
2022-12-06 16:14:56 -06:00
if (!current) {
// special case for no active displays (typically on progress screen)
// find the first ready display
let firstDisplay;
let displayCount = 0;
do {
// Check if displayCount is within bounds and the display exists
if (displayCount < displays.length && displays[displayCount]) {
const display = displays[displayCount];
if (display.status === STATUS.loaded && display.timing?.totalScreens > 0) {
firstDisplay = display;
}
}
2022-12-06 16:14:56 -06:00
displayCount += 1;
} while (!firstDisplay && displayCount < displays.length);
if (!firstDisplay) return;
// In kiosk mode, hide the loading screen when we start showing the first display
if (settings?.kiosk?.value) {
document.querySelector('#loading').style.display = 'none';
}
2022-12-06 16:14:56 -06:00
firstDisplay.navNext(msg.command.firstFrame);
2022-12-19 11:15:48 -06:00
firstDisplay.showCanvas();
2022-12-06 16:14:56 -06:00
return;
}
if (direction === msg.command.nextFrame) currentDisplay().navNext();
if (direction === msg.command.previousFrame) currentDisplay().navPrev();
};
// find the next or previous available display
const loadDisplay = (direction) => {
const totalDisplays = displays.length;
const curIdx = currentDisplayIndex();
let idx;
let foundSuitableDisplay = false;
2022-12-06 16:14:56 -06:00
for (let i = 0; i < totalDisplays; i += 1) {
// convert form simple 0-10 to start at current display index +/-1 and wrap
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
if (displays[idx]?.status === STATUS.loaded && displays[idx]?.timing.totalScreens > 0) {
// Prevent infinite recursion by ensuring we don't select the same display
if (idx !== curIdx) {
foundSuitableDisplay = true;
break;
}
}
}
// If no other suitable display was found, but current display is still suitable (e.g. user only enabled one display), stay on it
if (!foundSuitableDisplay && displays[curIdx] && displays[curIdx].status === STATUS.loaded && displays[curIdx].timing.totalScreens > 0) {
idx = curIdx;
foundSuitableDisplay = true;
}
// if no suitable display was found at all, do NOT proceed to avoid infinite recursion
if (!foundSuitableDisplay) {
console.warn('No suitable display found for navigation');
return;
}
2022-12-06 16:14:56 -06:00
const newDisplay = displays[idx];
if (!newDisplay) {
console.warn('Selected display is undefined, aborting navigation');
return;
}
2022-12-06 16:14:56 -06:00
// hide all displays
hideAllCanvases();
// show the new display and navigate to an appropriate display
if (direction < 0) newDisplay.showCanvas(msg.command.lastFrame);
if (direction > 0) newDisplay.showCanvas(msg.command.firstFrame);
};
// get the current display index or value
const currentDisplayIndex = () => displays.findIndex((display) => display?.active);
2022-12-06 16:14:56 -06:00
const currentDisplay = () => displays[currentDisplayIndex()];
const setPlaying = (newValue) => {
playing = newValue;
2023-01-06 14:39:39 -06:00
const playButton = document.querySelector('#NavigatePlay');
2022-12-14 16:28:33 -06:00
localStorage.setItem('play', playing);
2022-12-06 16:14:56 -06:00
if (playing) {
noSleep(true).catch(() => {
// Wake lock failed, but continue normally
});
2022-12-06 16:14:56 -06:00
playButton.title = 'Pause';
2022-12-19 15:21:38 -06:00
playButton.src = 'images/nav/ic_pause_white_24dp_2x.png';
2022-12-06 16:14:56 -06:00
} else {
noSleep(false).catch(() => {
// Wake lock disable failed, but continue normally
});
2022-12-06 16:14:56 -06:00
playButton.title = 'Play';
2022-12-19 15:21:38 -06:00
playButton.src = 'images/nav/ic_play_arrow_white_24dp_2x.png';
2022-12-06 16:14:56 -06:00
}
// if we're playing and on the progress screen (or in kiosk mode), jump to the next screen
if (playing && !currentDisplay()) {
if (progress || settings?.kiosk?.value) {
navTo(msg.command.firstFrame);
}
}
2022-12-06 16:14:56 -06:00
};
// handle all navigation buttons
const handleNavButton = (button) => {
switch (button) {
2024-10-21 19:21:05 -05:00
case 'play':
setPlaying(true);
break;
case 'playToggle':
setPlaying(!playing);
break;
case 'stop':
setPlaying(false);
break;
case 'next':
setPlaying(false);
navTo(msg.command.nextFrame);
break;
case 'previous':
setPlaying(false);
navTo(msg.command.previousFrame);
break;
case 'menu':
setPlaying(false);
2025-09-24 22:27:31 -05:00
postMessage({ type: 'current-weather-scroll', method: 'hide' });
if (progress) {
progress.showCanvas();
} else if (settings?.kiosk?.value) {
// In kiosk mode without progress, show the loading screen
document.querySelector('#loading').style.display = 'flex';
}
2024-10-21 19:21:05 -05:00
hideAllCanvases();
break;
default:
console.error(`Unknown navButton ${button}`);
2022-12-06 16:14:56 -06:00
}
};
// return the specificed display
const getDisplay = (index) => displays[index];
// Helper function to detect iOS (using technique from nosleep.js)
const isIOS = () => {
const { userAgent } = navigator;
const iOSRegex = /CPU.*OS ([0-9_]{1,})[0-9_]{0,}|(CPU like).*AppleWebKit.*Mobile/i;
return iOSRegex.test(userAgent) && !window.MSStream;
};
// Track the last applied scale to avoid redundant operations
let lastAppliedScale = null;
let lastAppliedKioskMode = null;
// Helper function to clear CSS properties from elements
const clearElementStyles = (element, properties) => {
properties.forEach((prop) => element.style.removeProperty(prop));
};
// Define property groups for different scaling modes
const SCALING_PROPERTIES = {
wrapper: ['width', 'height', 'transform', 'transform-origin'],
positioning: ['transform', 'transform-origin', 'width', 'height', 'position', 'left', 'top', 'margin-left', 'margin-top'],
};
// resize the container on a page resize
const resize = (force = false) => {
// Ignore resize events caused by pinch-to-zoom on mobile
if (window.visualViewport && Math.abs(window.visualViewport.scale - 1) > 0.01) {
return;
}
const isFullscreen = !!document.fullscreenElement;
const isKioskMode = settings.kiosk?.value || false;
const isMobileSafariKiosk = isIOS() && isKioskMode; // Detect Mobile Safari in kiosk mode (regardless of standalone status)
const targetWidth = settings.wide.value ? 640 + 107 + 107 : 640;
// Use window width instead of bottom container width to avoid zero-dimension issues
const widthZoomPercent = window.innerWidth / targetWidth;
const heightZoomPercent = window.innerHeight / 480;
// Standard scaling: fit within both dimensions
const scale = Math.min(widthZoomPercent, heightZoomPercent);
// Use centering behavior for fullscreen, kiosk mode, or Mobile Safari kiosk mode
const isKioskLike = isFullscreen || isKioskMode || isMobileSafariKiosk;
if (debugFlag('resize') || debugFlag('fullscreen')) {
console.log(`🖥️ Resize: force=${force} isKioskLike=${isKioskLike} window=${window.innerWidth}x${window.innerHeight} targetWidth=${targetWidth} widthZoom=${widthZoomPercent.toFixed(3)} heightZoom=${heightZoomPercent.toFixed(3)} finalScale=${scale.toFixed(3)} fullscreenElement=${!!document.fullscreenElement} isIOS=${isIOS()} standalone=${window.navigator.standalone} isMobileSafariKiosk=${isMobileSafariKiosk} kioskMode=${settings.kiosk?.value} wideMode=${settings.wide.value}`);
}
// Prevent zero or negative scale values
if (scale <= 0) {
console.warn('Invalid scale calculated, skipping resize');
return;
}
// Skip redundant resize operations if scale and mode haven't changed (unless forced)
const scaleChanged = Math.abs((lastAppliedScale || 0) - scale) > 0.001;
const modeChanged = lastAppliedKioskMode !== isKioskLike;
if (!force && !scaleChanged && !modeChanged) {
return; // No meaningful change, skip resize operation
}
// Update tracking variables
lastAppliedScale = scale;
lastAppliedKioskMode = isKioskLike;
window.currentScale = scale; // Make scale available to settings module
const wrapper = document.querySelector('#divTwc');
const mainContainer = document.querySelector('#divTwcMain');
// BASELINE: content fits naturally, no scaling needed
if (!isKioskLike && scale >= 1.0 && !isKioskMode) {
if (debugFlag('fullscreen')) {
console.log('🖥️ Resetting fullscreen/kiosk styles to normal');
}
// Reset all scaling-related styles
const container = document.querySelector('#container');
clearElementStyles(wrapper, SCALING_PROPERTIES.wrapper);
clearElementStyles(container, SCALING_PROPERTIES.positioning);
clearElementStyles(mainContainer, SCALING_PROPERTIES.positioning);
applyScanlineScaling(1.0);
return;
}
// MOBILE SCALING: Use wrapper scaling for mobile devices (but not when in fullscreen/kiosk mode)
if ((scale < 1.0 || (isKioskMode && !isKioskLike)) && !isMobileSafariKiosk && !isKioskLike) {
/*
* MOBILE SCALING (Wrapper Scaling)
*
* This path is used for regular mobile browsing (NOT fullscreen/kiosk modes).
* Why scale the wrapper instead of mainContainer?
* - For mobile devices where content is larger than viewport, we need to scale the entire layout
* - The wrapper (#divTwc) contains both the main content AND the bottom navigation bar
* - Scaling the wrapper ensures both elements are scaled together as a unit
* - Content aligns to top-left for typical mobile web browsing behavior (no centering)
* - Uses explicit dimensions to prevent layout issues and eliminate gaps after scaling
*/
// Reset any container/mainContainer styles that might have been set during fullscreen/kiosk mode
const container = document.querySelector('#container');
clearElementStyles(container, SCALING_PROPERTIES.positioning);
clearElementStyles(mainContainer, SCALING_PROPERTIES.positioning);
wrapper.style.setProperty('transform', `scale(${scale})`);
wrapper.style.setProperty('transform-origin', 'top left'); // Scale from top-left corner
// Set explicit dimensions to prevent layout issues on mobile
const wrapperWidth = settings.wide.value ? 854 : 640;
// Calculate total height: main content (480px) + bottom navigation bar
const bottomBar = document.querySelector('#divTwcBottom');
const bottomBarHeight = bottomBar ? bottomBar.offsetHeight : 40; // fallback to ~40px
const totalHeight = 480 + bottomBarHeight;
const scaledHeight = totalHeight * scale; // Height after scaling
wrapper.style.setProperty('width', `${wrapperWidth}px`);
wrapper.style.setProperty('height', `${scaledHeight}px`); // Use scaled height to eliminate gap under #divTwc on index page
applyScanlineScaling(scale);
return;
}
// KIOSK/FULLSCREEN SCALING: Two different positioning approaches for different platforms
const wrapperWidth = settings.wide.value ? 854 : 640;
const wrapperHeight = 480;
// Reset wrapper styles to avoid double scaling (wrapper remains unstyled)
clearElementStyles(wrapper, SCALING_PROPERTIES.wrapper);
// Platform-specific positioning logic
let transformOrigin;
let leftPosition;
let topPosition;
let marginLeft;
let marginTop;
if (isMobileSafariKiosk) {
/*
* MOBILE SAFARI KIOSK MODE (Manual offset calculation)
*
* Why this approach?
* - Mobile Safari in kiosk mode has unique viewport behaviors that don't work well with standard CSS centering
* - We want orientation-specific centering: vertical in portrait, horizontal in landscape
* - The standard CSS centering method can cause layout issues in Mobile Safari's constrained environment
*/
const scaledWidth = wrapperWidth * scale;
const scaledHeight = wrapperHeight * scale;
// Determine if we're in portrait or landscape
const isPortrait = window.innerHeight > window.innerWidth;
let offsetX = 0;
let offsetY = 0;
if (isPortrait) {
offsetY = (window.innerHeight - scaledHeight) / 2; // center vertically, align to left edge
} else {
offsetX = (window.innerWidth - scaledWidth) / 2; // center horizontally, align to top edge
}
if (debugFlag('fullscreen')) {
console.log(`📱 Mobile Safari kiosk centering: ${isPortrait ? 'portrait' : 'landscape'} wrapper=${wrapperWidth}x${wrapperHeight} scale=${scale.toFixed(3)} offset=${offsetX.toFixed(1)},${offsetY.toFixed(1)}`);
}
// Set positioning values for manual offset calculation
transformOrigin = 'top left'; // Scale from top-left corner
leftPosition = `${offsetX}px`; // Exact pixel positioning
topPosition = `${offsetY}px`; // Exact pixel positioning
marginLeft = null; // Clear any previous centering margins
marginTop = null; // Clear any previous centering margins
} else {
/*
* STANDARD FULLSCREEN/KIOSK MODE (CSS-based Centering)
*
* Why this approach?
* - Should work reliably across all other browsers and scenarios (desktop, non-Safari mobile, etc.)
* - Uses standard CSS centering techniques that browsers handle efficiently
* - Always centers both horizontally and vertically
*/
const scaledWidth = wrapperWidth * scale;
const scaledHeight = wrapperHeight * scale;
const offsetX = (window.innerWidth - scaledWidth) / 2;
const offsetY = (window.innerHeight - scaledHeight) / 2;
if (debugFlag('fullscreen')) {
console.log(`🖥️ Applying fullscreen/kiosk scaling: wrapper=${wrapperWidth}x${wrapperHeight} scale=${scale.toFixed(3)} offset=${offsetX.toFixed(1)},${offsetY.toFixed(1)} target=${isFullscreen ? '#container' : '#divTwcMain'}`);
}
// Set positioning values for CSS-based centering
transformOrigin = 'center center'; // Scale from center point
leftPosition = '50%'; // Position at 50% from left
topPosition = '50%'; // Position at 50% from top
marginLeft = `-${wrapperWidth / 2}px`; // Pull back by half width
marginTop = `-${wrapperHeight / 2}px`; // Pull back by half height
}
// Chrome fullscreen compatibility: apply transform to #container instead of #divTwcMain
// This works around Chrome's restriction on styling fullscreen elements directly
const container = document.querySelector('#container');
const targetElement = isFullscreen ? container : mainContainer;
// Reset the other element's styles to avoid conflicts
if (isFullscreen) {
// Reset mainContainer styles when using container for fullscreen
clearElementStyles(mainContainer, SCALING_PROPERTIES.positioning);
} else {
// Reset container styles when using mainContainer for kiosk mode
clearElementStyles(container, SCALING_PROPERTIES.positioning);
}
// Apply shared properties to the target element
targetElement.style.setProperty('transform', `scale(${scale})`, 'important');
targetElement.style.setProperty('transform-origin', transformOrigin, 'important');
// the width of the target element does not change it is the fixed width of the 4:3 display which is then scaled
// the wrapper adds margins and padding to achieve widescreen
// targetElement.style.setProperty('width', `${wrapperWidth}px`, 'important');
targetElement.style.setProperty('height', `${wrapperHeight}px`, 'important');
targetElement.style.setProperty('position', 'absolute', 'important');
targetElement.style.setProperty('left', leftPosition, 'important');
targetElement.style.setProperty('top', topPosition, 'important');
// Apply or clear margin properties based on positioning method
if (marginLeft !== null) {
targetElement.style.setProperty('margin-left', marginLeft, 'important');
} else {
targetElement.style.removeProperty('margin-left');
}
if (marginTop !== null) {
targetElement.style.setProperty('margin-top', marginTop, 'important');
} else {
targetElement.style.removeProperty('margin-top');
}
applyScanlineScaling(scale);
};
// reset all statuses to loading on all displays, used to keep the progress bar accurate during refresh
const resetStatuses = () => {
displays.forEach((display) => { if (display) display.status = STATUS.loading; });
};
// Apply scanline scaling to try and prevent banding by avoiding fractional scaling
const applyScanlineScaling = (scale) => {
const container = document.querySelector('#container');
if (!container || !container.classList.contains('scanlines')) {
return;
}
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const devicePixelRatio = window.devicePixelRatio || 1;
const currentMode = settings?.scanLineMode?.value || 'auto';
let cssThickness;
let scanlineDebugInfo = null;
// Helper function to round CSS values intelligently based on scale and DPR
// At high scales, precise fractional pixels render fine; at low scales, alignment matters more
const roundCSSValue = (value) => {
// On 1x DPI displays, use exact calculated values
if (devicePixelRatio === 1) {
return value;
}
// At high scales (>2x), the browser scaling dominates and fractional pixels render well
// Prioritize nice fractions for better visual consistency
if (scale > 2.0) {
// Try quarter-pixel boundaries first (0.25, 0.5, 0.75, 1.0, etc.)
const quarterRounded = Math.round(value * 4) / 4;
if (Math.abs(quarterRounded - value) <= 0.125) { // Within 0.125px tolerance
return quarterRounded;
}
// Fall through to half-pixel boundaries for high scale fallback
}
// At lower scales (and high scale fallback), pixel alignment matters more for crisp rendering
// Round UP to the next half-pixel to ensure scanlines are never thinner than intended
const halfPixelRounded = Math.ceil(value * 2) / 2;
return halfPixelRounded;
};
// Manual modes: use smart rounding in scaled scenarios to avoid banding
if (currentMode === 'thin') {
const rawValue = 1 / scale;
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
cssThickness = `${cssValue}px`;
scanlineDebugInfo = {
css: cssValue,
visual: 1,
target: '1px visual thickness',
reason: scale === 1.0 ? 'Thin: 1px visual user override (exact)' : 'Thin: 1px visual user override (rounded)',
isManual: true,
};
} else if (currentMode === 'medium') {
const rawValue = 2 / scale;
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
cssThickness = `${cssValue}px`;
scanlineDebugInfo = {
css: cssValue,
visual: 2,
target: '2px visual thickness',
reason: scale === 1.0 ? 'Medium: 2px visual user override (exact)' : 'Medium: 2px visual user override (rounded)',
isManual: true,
};
} else if (currentMode === 'thick') {
const rawValue = 3 / scale;
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
cssThickness = `${cssValue}px`;
scanlineDebugInfo = {
css: cssValue,
visual: 3,
target: '3px visual thickness',
reason: scale === 1.0 ? 'Thick: 3px visual user override (exact)' : 'Thick: 3px visual user override (rounded)',
isManual: true,
};
} else {
// Auto mode: choose thickness based on scaling behavior
let visualThickness;
let reason;
if (scale === 1.0) {
// Unscaled mode: use reasonable thickness based on device characteristics
const isHighDPIMobile = devicePixelRatio >= 2 && viewportWidth <= 768 && viewportHeight <= 768;
const isHighDPITablet = devicePixelRatio >= 2 && viewportWidth <= 1024 && viewportHeight <= 1024;
if (isHighDPIMobile) {
// High-DPI mobile: use thin scanlines but not too thin
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
cssThickness = `${cssValue}px`;
reason = `Auto: ${cssValue}px unscaled (high-DPI mobile, DPR=${devicePixelRatio})`;
} else if (isHighDPITablet) {
// High-DPI tablets: use slightly thicker scanlines for better visibility
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
cssThickness = `${cssValue}px`;
reason = `Auto: ${cssValue}px unscaled (high-DPI tablet, DPR=${devicePixelRatio})`;
} else if (devicePixelRatio >= 2) {
// High-DPI desktop: use scanlines that look similar to scaled mode
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
cssThickness = `${cssValue}px`;
reason = `Auto: ${cssValue}px unscaled (high-DPI desktop, DPR=${devicePixelRatio})`;
} else {
// Standard DPI desktop: use 2px for better visibility
cssThickness = '2px';
reason = 'Auto: 2px unscaled (standard DPI desktop)';
}
} else if (scale < 1.0) {
// Mobile scaling: use thinner scanlines for small displays
visualThickness = 1;
const cssValue = roundCSSValue(visualThickness / scale);
cssThickness = `${cssValue}px`;
reason = `Auto: ${cssValue}px scaled (mobile, scale=${scale})`;
} else if (scale >= 3.0) {
// Very high scale (large displays/high DPI): use thick scanlines for visibility
visualThickness = 3;
const cssValue = roundCSSValue(visualThickness / scale);
cssThickness = `${cssValue}px`;
reason = `Auto: ${cssValue}px scaled (large display/high scale, scale=${scale})`;
} else {
// Medium scale kiosk/fullscreen: use medium scanlines with smart rounding
visualThickness = 2;
const rawValue = visualThickness / scale;
const cssValue = roundCSSValue(rawValue);
cssThickness = `${cssValue}px`;
reason = `Auto: ${cssValue}px scaled (kiosk/fullscreen, scale=${scale})`;
if (debugFlag('scanlines')) {
console.log(`↕️ Kiosk/fullscreen rounding: raw=${rawValue}, rounded=${cssValue}, DPR=${devicePixelRatio}, scale=${scale}`);
}
}
// Extract numeric value from cssThickness for debug info
const cssNumericValue = parseFloat(cssThickness);
scanlineDebugInfo = {
css: cssNumericValue,
visual: scale === 1.0 ? cssNumericValue : visualThickness, // For unscaled mode, visual thickness equals CSS thickness
target: scale === 1.0 ? `${cssNumericValue}px CSS (unscaled)` : `${visualThickness}px visual thickness`,
reason,
isManual: false,
};
}
container.style.setProperty('--scanline-thickness', cssThickness);
// Output debug information if enabled
if (debugFlag('scanlines')) {
const actualRendered = scanlineDebugInfo.css * scale;
const physicalRendered = actualRendered * devicePixelRatio;
const visualThickness = scanlineDebugInfo.visual || actualRendered; // Use visual thickness if available
console.log(`↕️ Scanline optimization: ${cssThickness} CSS × ${scale.toFixed(3)} scale = ${actualRendered.toFixed(3)}px rendered (${visualThickness}px visual target) × ${devicePixelRatio}x DPI = ${physicalRendered.toFixed(3)}px physical - ${scanlineDebugInfo.reason}`);
console.log(`↕️ Display: ${viewportWidth}×${viewportHeight}, Scale factors: width=${(window.innerWidth / (settings.wide.value ? 854 : 640)).toFixed(3)}, height=${(window.innerHeight / 480).toFixed(3)}, DPR=${devicePixelRatio}`);
console.log(`↕️ Thickness: CSS=${cssThickness}, Visual=${visualThickness.toFixed(1)}px, Rendered=${actualRendered.toFixed(3)}px, Physical=${physicalRendered.toFixed(3)}px`);
}
};
// Make applyScanlineScaling available for direct calls from Settings
window.applyScanlineScaling = applyScanlineScaling;
2022-12-06 16:14:56 -06:00
// allow displays to register themselves
const registerDisplay = (display) => {
2025-05-27 16:33:03 -05:00
if (displays[display.navId]) console.warn(`Display nav ID ${display.navId} already in use`);
2022-12-06 16:14:56 -06:00
displays[display.navId] = display;
2022-12-07 10:53:18 -06:00
// generate checkboxes
generateCheckboxes();
};
const generateCheckboxes = () => {
2023-01-06 14:39:39 -06:00
const availableDisplays = document.querySelector('#enabledDisplays');
2022-12-07 10:53:18 -06:00
if (!availableDisplays) return;
2022-12-06 16:14:56 -06:00
// generate checkboxes
const checkboxes = displays.map((d) => d?.generateCheckbox(d?.defaultEnabled)).filter((d) => d);
2022-12-06 16:14:56 -06:00
// write to page
availableDisplays.innerHTML = '';
availableDisplays.append(...checkboxes);
};
// special registration method for progress display
const registerProgress = (_progress) => {
progress = _progress;
};
const populateWeatherParameters = (params, point) => {
2023-01-06 14:39:39 -06:00
document.querySelector('#spanCity').innerHTML = `${params.city}, `;
document.querySelector('#spanState').innerHTML = params.state;
document.querySelector('#spanStationId').innerHTML = params.stationId;
document.querySelector('#spanRadarId').innerHTML = params.radarId;
document.querySelector('#spanZoneId').innerHTML = params.zoneId;
document.querySelector('#spanOfficeId').innerHTML = point.cwa;
document.querySelector('#spanGridPoint').innerHTML = `${point.gridX},${point.gridY}`;
2022-12-06 16:14:56 -06:00
};
2022-12-13 15:43:06 -06:00
const latLonReceived = (data, haveDataCallback) => {
getWeather(data, haveDataCallback);
2022-12-06 16:14:56 -06:00
};
2022-12-21 15:17:50 -06:00
const timeZone = () => weatherParameters.timeZone;
2022-12-06 16:14:56 -06:00
export {
updateStatus,
displayNavMessage,
resetStatuses,
isPlaying,
resize,
registerDisplay,
registerProgress,
currentDisplay,
getDisplay,
msg,
message,
latLonReceived,
2022-12-21 15:17:50 -06:00
timeZone,
isIOS,
2022-12-06 16:14:56 -06:00
};