// 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'; import { getPoint } from './utils/weather.mjs'; import { debugFlag } from './utils/debug.mjs'; import settings from './settings.mjs'; import { stationFilter } from './utils/string.mjs'; 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); }); // redraw current screen (typically from enhanced setting change) window.addEventListener('redraw', () => { currentDisplay()?.drawCanvas(); }); // 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); }); resize(); generateCheckboxes(); }; const message = (data) => { // dispatch event if (!data.type) return false; if (data.type === 'navButton') return handleNavButton(data.message); return console.error(`Unknown event ${data.type}`); }; const getWeather = async (latLon, haveDataCallback) => { // get initial weather data const point = await getPoint(latLon.lat, latLon.lon); // check if point data was successfully retrieved if (!point) { return; } 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; } // filter stations for proper format const stationsFiltered = stations.features.filter(stationFilter); // check for stations available after filtering if (stationsFiltered.length === 0) { console.warn('No observation stations left for location after filtering'); return; } const StationId = stationsFiltered[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 = stationsFiltered; 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}`); } }; // 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()); // first display is hazards and it must load before evaluating the first display if (!displays[0] || displays[0].status === STATUS.loading) return; // calculate first enabled display 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; } // 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) { navTo(msg.command.firstFrame); } }; // 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; return acc; }, 0); const hideAllCanvases = () => { displays.forEach((display) => display.hideCanvas()); }; // 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(); 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; } } 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'; } firstDisplay.navNext(msg.command.firstFrame); firstDisplay.showCanvas(); 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; 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; } const newDisplay = displays[idx]; // 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); const currentDisplay = () => displays[currentDisplayIndex()]; const setPlaying = (newValue) => { playing = newValue; const playButton = document.querySelector('#NavigatePlay'); localStorage.setItem('play', playing); if (playing) { noSleep(true).catch(() => { // Wake lock failed, but continue normally }); playButton.title = 'Pause'; playButton.src = 'images/nav/ic_pause_white_24dp_2x.png'; } else { noSleep(false).catch(() => { // Wake lock disable failed, but continue normally }); playButton.title = 'Play'; playButton.src = 'images/nav/ic_play_arrow_white_24dp_2x.png'; } // 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); } } }; // handle all navigation buttons const handleNavButton = (button) => { switch (button) { 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); 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'; } hideAllCanvases(); break; default: console.error(`Unknown navButton ${button}`); } }; // 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) => { 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; // allow displays to register themselves const registerDisplay = (display) => { if (displays[display.navId]) console.warn(`Display nav ID ${display.navId} already in use`); displays[display.navId] = display; // generate checkboxes generateCheckboxes(); }; const generateCheckboxes = () => { const availableDisplays = document.querySelector('#enabledDisplays'); if (!availableDisplays) return; // generate checkboxes const checkboxes = displays.map((d) => d.generateCheckbox(d.defaultEnabled)).filter((d) => d); // write to page availableDisplays.innerHTML = ''; availableDisplays.append(...checkboxes); }; // special registration method for progress display const registerProgress = (_progress) => { progress = _progress; }; const populateWeatherParameters = (params, point) => { 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}`; }; const latLonReceived = (data, haveDataCallback) => { getWeather(data, haveDataCallback); }; const timeZone = () => weatherParameters.timeZone; export { updateStatus, displayNavMessage, resetStatuses, isPlaying, resize, registerDisplay, registerProgress, currentDisplay, getDisplay, msg, message, latLonReceived, timeZone, isIOS, };