Files
WeatherStar4000/server/scripts/modules/navigation.mjs
Eddy G 975bccdac5 Switch from json() to safeJson() for centralized error handling
- Prevent infinitie recursion by ensuring the same display is not
    selected in loadDisplay()
2025-06-24 23:37:47 -04:00

1063 lines
35 KiB
JavaScript

// 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 settings from './settings.mjs';
document.addEventListener('DOMContentLoaded', () => {
init();
});
const displays = [];
let playing = false;
let progress;
const weatherParameters = {};
const init = async () => {
// set up resize handler
window.addEventListener('resize', resize);
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;
}
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;
// update the main process for display purposes
populateWeatherParameters(weatherParameters);
// 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].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].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 {
if (displays[displayCount].status === STATUS.loaded && displays[displayCount].timing.totalScreens > 0) firstDisplay = displays[displayCount];
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 suitable display was found, 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);
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];
// resize the container on a page resize
const resize = () => {
// Check for display optimization opportunities before applying zoom
const displayInfo = getDisplayInfo();
const targetWidth = settings.wide.value ? 640 + 107 + 107 : 640;
const widthZoomPercent = (document.querySelector('#divTwcBottom').getBoundingClientRect().width) / targetWidth;
const heightZoomPercent = (window.innerHeight) / 480;
const scale = Math.min(widthZoomPercent, heightZoomPercent);
const { isKioskLike } = displayInfo;
if (scale < 1.0 || isKioskLike) {
document.querySelector('#container').style.zoom = scale;
// Apply scanline scaling for low-resolution displays and kiosk mode
applyScanlineScaling(scale);
} else {
document.querySelector('#container').style.zoom = 'unset';
// Reset scanline scaling
applyScanlineScaling(1.0);
}
};
// 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; });
};
// Enhanced kiosk detection with automatic fullscreen optimization
const getDisplayInfo = () => {
const isKiosk = settings.kiosk?.value || false;
const isFullscreen = !!document.fullscreenElement;
const isKioskLike = isKiosk || isFullscreen || (window.innerHeight >= window.screen.height - 10);
return { isKiosk, isFullscreen, isKioskLike };
};
// Make function globally available for debugging
window.getDisplayInfo = getDisplayInfo;
// Apply dynamic scanline scaling based on zoom level
const applyScanlineScaling = (zoomScale) => {
// Only apply if scanlines are enabled
const container = document.querySelector('#container');
if (!container || !container.classList.contains('scanlines')) {
return;
}
// Get display and viewport information
const displayWidth = window.screen.width;
const displayHeight = window.screen.height;
const devicePixelRatio = window.devicePixelRatio || 1;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const isFullscreen = !!document.fullscreenElement;
const isKiosk = settings.kiosk?.value || false;
const isKioskLike = isKiosk || isFullscreen || (window.innerHeight >= window.screen.height - 10);
// Check for sub-pixel rendering issues
const effectiveScanlineHeight = 1 * zoomScale * devicePixelRatio;
const willCauseAliasing = effectiveScanlineHeight < 1.0 || (effectiveScanlineHeight % 1 !== 0);
// Calculate optimal scanline thickness
let scanlineScale = 1;
let scalingReason = 'default';
// Primary strategy: Ensure scanlines render as whole pixels
if (willCauseAliasing) {
if (zoomScale > 1.0) {
// Upscaling scenario (like 1024x768 → 1.6x zoom)
const targetThickness = Math.ceil(1 / zoomScale);
scanlineScale = Math.max(1, targetThickness);
scalingReason = 'upscaling aliasing prevention';
} else {
// Downscaling scenario
scanlineScale = Math.ceil(1 / zoomScale);
scalingReason = 'downscaling aliasing prevention';
}
}
// Specific display-based adjustments
if (displayWidth <= 1024 && displayHeight <= 768 && devicePixelRatio < 2) {
if (zoomScale > 1.4) {
scanlineScale = Math.max(scanlineScale, Math.round(1 / zoomScale * 2));
scalingReason = '1024x768 high upscaling compensation';
} else {
scanlineScale = Math.max(scanlineScale, 1);
scalingReason = '1024x768 display optimization';
}
}
// Override for kiosk/fullscreen mode with specific viewport dimensions
if (isKioskLike && (
Math.abs(zoomScale - 1.598) < 0.05 // More flexible zoom detection for 1024x768 scenarios
|| (viewportWidth === 1023 && viewportHeight === 767) // Exact Chrome kiosk viewport
|| (viewportWidth === 1024 && viewportHeight === 768) // Perfect viewport
)) {
// Kiosk mode optimization for 1024x768 displays
// Use optimal scanlines that render as exactly 2px with no banding
if (viewportWidth === 1023 && viewportHeight === 767) {
// For the exact 1023x767 Chrome kiosk viewport
// Calculate precise thickness for exactly 2px rendering
const targetRendered = 2.0;
scanlineScale = targetRendered / zoomScale; // This gives us exactly 2px
scalingReason = 'Chrome kiosk 1023x767 - optimal 2px scanlines';
} else {
// For 1024x768 or similar zoomed scenarios
scanlineScale = 1.25; // Standard 2px optimization
scalingReason = 'Kiosk/fullscreen 1024x768 - optimal 2px scanlines';
}
}
// Calculate precise thickness to avoid sub-pixel rendering
let preciseThickness = scanlineScale;
let backgroundSize = scanlineScale * 2;
// For upscaling scenarios, try to make the final rendered size a whole number
// BUT skip this if we already have a specific override for the zoom level
if (zoomScale > 1.0 && willCauseAliasing && !scalingReason.includes('optimal') && !scalingReason.includes('Kiosk')) {
const targetRenderedHeight = Math.round(effectiveScanlineHeight);
preciseThickness = targetRenderedHeight / zoomScale / devicePixelRatio;
backgroundSize = preciseThickness * 2;
}
// Apply dynamic styles with fractional pixel compensation
let styleElement = document.getElementById('dynamic-scanlines');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = 'dynamic-scanlines';
document.head.appendChild(styleElement);
}
const cssRules = `
.scanlines:before {
height: ${preciseThickness}px !important;
image-rendering: pixelated !important;
image-rendering: crisp-edges !important;
}
.scanlines:after {
background-size: 100% ${backgroundSize}px !important;
image-rendering: pixelated !important;
image-rendering: crisp-edges !important;
}
`;
styleElement.textContent = cssRules;
// Only log when optimal kiosk mode is applied (minimize debug output)
if (scalingReason.includes('optimal') && !window.scanlineLoggedOnce) {
console.log(`Scanlines: ${preciseThickness}px (${scalingReason})`);
window.scanlineLoggedOnce = true;
}
};
// Debug function for scanlines
// All these can be called from browser console.
// Leaving them here for now, but they can potentially be removed later.
// Function to request perfect fullscreen for optimal display
const requestPerfectFullscreen = async () => {
const element = document.querySelector('#divTwc');
try {
// Use the Fullscreen API to get perfect viewport control
const requestMethod = element.requestFullscreen || element.webkitRequestFullscreen
|| element.mozRequestFullScreen || element.msRequestFullscreen;
if (requestMethod) {
// Request fullscreen with minimal logging
await requestMethod.call(element, {
navigationUI: 'hide',
// Request specific fullscreen options if supported
allowsInlineMediaPlayback: true,
});
// Allow a moment for fullscreen to engage
setTimeout(() => {
// Re-trigger resize to apply optimal scaling
resize();
// Apply scanline scaling based on new dimensions
const container = document.querySelector('#container');
const zoomScale = parseFloat(container.style.zoom) || 1;
applyScanlineScaling(zoomScale);
}, 100);
return true;
}
console.warn('Fullscreen API not supported');
return false;
} catch (error) {
console.error('Failed to request fullscreen:', error);
return false;
}
};
// Make function globally available for debugging
window.requestPerfectFullscreen = requestPerfectFullscreen;
const debugScanlines = () => {
console.group('Manual Scanlines Debug');
const container = document.querySelector('#container');
if (!container) {
console.error('Container element not found');
console.groupEnd();
return { error: 'Container element not found' };
}
const hasScanlinesClass = container.classList.contains('scanlines');
const containerRect = container.getBoundingClientRect();
const currentZoom = parseFloat(container.style.zoom) || 1;
console.log(`Scanlines class present: ${hasScanlinesClass}`);
console.log(`Container dimensions: ${containerRect.width.toFixed(2)}x${containerRect.height.toFixed(2)}`);
console.log(`Current zoom: ${currentZoom}`);
const debugInfo = {
hasScanlinesClass,
containerDimensions: {
width: containerRect.width,
height: containerRect.height,
left: containerRect.left,
top: containerRect.top,
},
currentZoom,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
screen: {
width: window.screen.width,
height: window.screen.height,
},
devicePixelRatio: window.devicePixelRatio || 1,
isFullscreen: !!document.fullscreenElement,
};
if (hasScanlinesClass) {
console.log(`Triggering applyScanlineScaling with zoom: ${currentZoom}`);
applyScanlineScaling(currentZoom);
// Check if dynamic styles exist
const dynamicStyle = document.getElementById('dynamic-scanlines');
if (dynamicStyle) {
console.log('Current dynamic CSS:', dynamicStyle.textContent);
debugInfo.dynamicCSS = dynamicStyle.textContent;
} else {
console.log('No dynamic scanlines styles found');
debugInfo.dynamicCSS = null;
}
// Get computed styles for scanlines
const beforeStyle = window.getComputedStyle(container, ':before');
const afterStyle = window.getComputedStyle(container, ':after');
const computedStyles = {
before: {
height: beforeStyle.height,
background: beforeStyle.background,
opacity: beforeStyle.opacity,
imageRendering: beforeStyle.imageRendering,
},
after: {
backgroundSize: afterStyle.backgroundSize,
backgroundImage: afterStyle.backgroundImage,
opacity: afterStyle.opacity,
imageRendering: afterStyle.imageRendering,
},
};
console.log('Computed :before styles:');
console.log(' height:', computedStyles.before.height);
console.log(' background:', computedStyles.before.background);
console.log(' opacity:', computedStyles.before.opacity);
console.log(' image-rendering:', computedStyles.before.imageRendering);
console.log('Computed :after styles:');
console.log(' background-size:', computedStyles.after.backgroundSize);
console.log(' background-image:', computedStyles.after.backgroundImage);
console.log(' opacity:', computedStyles.after.opacity);
console.log(' image-rendering:', computedStyles.after.imageRendering);
debugInfo.computedStyles = computedStyles;
}
console.groupEnd();
return debugInfo;
};
// Make debug function globally available
window.debugScanlines = debugScanlines;
// Test function to manually set scanline scale - can be called from browser console
const testScanlineScale = (scale) => {
console.log(`Testing scanline scale: ${scale}x`);
let styleElement = document.getElementById('dynamic-scanlines');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = 'dynamic-scanlines';
document.head.appendChild(styleElement);
}
const cssRules = `
.scanlines:before {
height: ${scale}px !important;
image-rendering: pixelated !important;
image-rendering: crisp-edges !important;
}
.scanlines:after {
background-size: 100% ${scale * 2}px !important;
image-rendering: pixelated !important;
image-rendering: crisp-edges !important;
}
`;
styleElement.textContent = cssRules;
// Calculate what this will look like when rendered
const container = document.querySelector('#container');
const zoom = parseFloat(container?.style.zoom) || 1;
const expectedRendered = scale * zoom;
const isWholePixel = Math.abs(expectedRendered % 1) < 0.01;
const result = {
appliedScale: scale,
backgroundSize: scale * 2,
currentZoom: zoom,
expectedRendered,
isWholePixel,
cssRules: cssRules.trim(),
};
console.log(`Applied ${scale}px scanline height with ${scale * 2}px background-size`);
console.log(`Expected rendered height: ${expectedRendered.toFixed(4)}px`);
console.log(`Will render as whole pixels: ${isWholePixel}`);
return result;
};
// Make test function globally available
window.testScanlineScale = testScanlineScale;
// Test function for precise fractional values to eliminate banding
const testPreciseScanlines = () => {
const container = document.querySelector('#container');
const zoom = parseFloat(container?.style.zoom) || 1;
console.group('Testing Precise Scanline Values');
console.log(`Current zoom: ${zoom.toFixed(4)}`);
// Test values that should result in whole pixel rendering
const testValues = [
0.625, // Should render as 1px (0.625 * 1.598 ≈ 1.0)
1.25, // Should render as 2px (1.25 * 1.598 ≈ 2.0)
1.875, // Should render as 3px (1.875 * 1.598 ≈ 3.0)
2.5, // Should render as 4px (2.5 * 1.598 ≈ 4.0)
];
const results = testValues.map((value) => {
const rendered = value * zoom;
const isWholePixel = Math.abs(rendered % 1) < 0.01;
const result = {
inputValue: value,
renderedValue: rendered,
isWholePixel,
fractionalPart: rendered % 1,
};
console.log(`Test ${value}px → ${rendered.toFixed(4)}px rendered (${isWholePixel ? '✅ whole' : '❌ fractional'})`);
return result;
});
console.log('Use testScanlineScale(value) to try these values');
console.groupEnd();
return {
currentZoom: zoom,
testResults: results,
recommendation: 'Use testScanlineScale(value) to apply a specific value',
};
};
// Make precise test function globally available
window.testPreciseScanlines = testPreciseScanlines;
// Function to analyze container dimension issues
const analyzeContainerDimensions = () => {
const container = document.querySelector('#container');
if (!container) {
return { error: 'Container not found' };
}
const containerRect = container.getBoundingClientRect();
const containerStyle = window.getComputedStyle(container);
const { parentElement } = container;
const parentRect = parentElement ? parentElement.getBoundingClientRect() : null;
const parentStyle = parentElement ? window.getComputedStyle(parentElement) : null;
const analysis = {
container: {
rect: {
width: containerRect.width,
height: containerRect.height,
left: containerRect.left,
top: containerRect.top,
},
computedStyle: {
width: containerStyle.width,
height: containerStyle.height,
padding: containerStyle.padding,
margin: containerStyle.margin,
border: containerStyle.border,
boxSizing: containerStyle.boxSizing,
zoom: containerStyle.zoom,
transform: containerStyle.transform,
},
},
parent: parentRect ? {
rect: {
width: parentRect.width,
height: parentRect.height,
left: parentRect.left,
top: parentRect.top,
},
computedStyle: {
width: parentStyle.width,
height: parentStyle.height,
padding: parentStyle.padding,
margin: parentStyle.margin,
border: parentStyle.border,
boxSizing: parentStyle.boxSizing,
},
} : null,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
screen: {
width: window.screen.width,
height: window.screen.height,
},
devicePixelRatio: window.devicePixelRatio || 1,
isFullscreen: !!document.fullscreenElement,
};
console.group('Container Dimension Analysis');
console.log('Container Rect:', analysis.container.rect);
console.log('Container Computed Style:', analysis.container.computedStyle);
if (analysis.parent) {
console.log('Parent Rect:', analysis.parent.rect);
console.log('Parent Computed Style:', analysis.parent.computedStyle);
}
console.log('Viewport:', analysis.viewport);
console.log('Screen:', analysis.screen);
// Check for fractional dimension causes
const expectedTargetWidth = 640; // Base width
const expectedTargetHeight = 480; // Base height
const actualScale = Math.min(analysis.viewport.width / expectedTargetWidth, analysis.viewport.height / expectedTargetHeight);
const fractionalWidth = analysis.container.rect.width % 1;
const fractionalHeight = analysis.container.rect.height % 1;
console.log(`Expected scale: ${actualScale.toFixed(4)}`);
console.log(`Fractional width: ${fractionalWidth.toFixed(4)}px`);
console.log(`Fractional height: ${fractionalHeight.toFixed(4)}px`);
console.log(`Width is fractional: ${fractionalWidth > 0.01}`);
console.log(`Height is fractional: ${fractionalHeight > 0.01}`);
analysis.scaling = {
expectedScale: actualScale,
fractionalWidth,
fractionalHeight,
hasFractionalDimensions: fractionalWidth > 0.01 || fractionalHeight > 0.01,
};
console.groupEnd();
return analysis;
};
// Make container analysis function globally available
window.analyzeContainerDimensions = analyzeContainerDimensions;
// Function to calculate optimal scanline thickness that eliminates fractional rendering
const calculateOptimalScanlineThickness = (targetZoom = null) => {
const container = document.querySelector('#container');
if (!container) {
return { error: 'Container not found' };
}
const currentZoom = targetZoom || parseFloat(container.style.zoom) || 1;
const devicePixelRatio = window.devicePixelRatio || 1;
console.group('Calculating Optimal Scanline Thickness');
console.log(`Current zoom: ${currentZoom.toFixed(4)}`);
console.log(`Device pixel ratio: ${devicePixelRatio}`);
// Calculate possible thickness values that result in whole pixel rendering
const candidates = [];
// Test thickness values from 0.1 to 3.0 in 0.001 increments
for (let thickness = 0.1; thickness <= 3.0; thickness += 0.001) {
const renderedHeight = thickness * currentZoom * devicePixelRatio;
const fractionalPart = renderedHeight % 1;
// If the rendered height is very close to a whole number
if (fractionalPart < 0.001 || fractionalPart > 0.999) {
const wholePixelHeight = Math.round(renderedHeight);
candidates.push({
thickness: Math.round(thickness * 1000) / 1000, // Round to 3 decimal places
renderedHeight: wholePixelHeight,
actualRendered: renderedHeight,
error: Math.abs(renderedHeight - wholePixelHeight),
});
}
}
// Sort by error (closest to whole pixel) and prefer reasonable thickness values
candidates.sort((a, b) => {
if (Math.abs(a.error - b.error) < 0.0001) {
// If errors are similar, prefer thickness closer to 1
return Math.abs(a.thickness - 1) - Math.abs(b.thickness - 1);
}
return a.error - b.error;
});
// Take the best candidates for different pixel heights
const recommendations = [];
const seenHeights = new Set();
candidates.some((candidate) => {
if (!seenHeights.has(candidate.renderedHeight) && recommendations.length < 5) {
seenHeights.add(candidate.renderedHeight);
recommendations.push(candidate);
}
return recommendations.length >= 5; // Stop when we have 5 recommendations
});
console.log('Recommendations:');
recommendations.forEach((rec, index) => {
console.log(`${index + 1}. ${rec.thickness}px → ${rec.renderedHeight}px (error: ${rec.error.toFixed(6)})`);
});
const result = {
currentZoom,
devicePixelRatio,
recommendations,
bestRecommendation: recommendations[0] || null,
};
if (result.bestRecommendation) {
console.log(`Best recommendation: ${result.bestRecommendation.thickness}px`);
console.log(` Will render as: ${result.bestRecommendation.renderedHeight}px`);
console.log(` Use: testScanlineScale(${result.bestRecommendation.thickness})`);
}
console.groupEnd();
return result;
};
// Make optimal calculation function globally available
window.calculateOptimalScanlineThickness = calculateOptimalScanlineThickness;
// Function to analyze viewport and provide fullscreen optimization recommendations
const analyzeViewportOptimization = () => {
const viewport = {
width: window.innerWidth,
height: window.innerHeight,
screen: {
width: window.screen.width,
height: window.screen.height,
},
devicePixelRatio: window.devicePixelRatio || 1,
isFullscreen: !!document.fullscreenElement,
isKiosk: settings.kiosk?.value || false,
};
// Check for fractional viewport dimensions
const hasFractionalViewport = (viewport.width % 1 !== 0) || (viewport.height % 1 !== 0);
// Check for common kiosk viewport sizes
const isKnownKioskSize = (
(viewport.width === 1023 && viewport.height === 767) // Common Chrome kiosk issue
|| (viewport.width === 1024 && viewport.height === 768) // Perfect kiosk size
);
// Minimize debug output for production use
if (window.debugMode) {
console.group('Viewport Optimization Analysis');
console.log('Current viewport:', `${viewport.width}x${viewport.height}`);
console.log('Screen resolution:', `${viewport.screen.width}x${viewport.screen.height}`);
console.log('Device pixel ratio:', viewport.devicePixelRatio);
console.log('Has fractional viewport:', hasFractionalViewport);
console.log('Is known kiosk size:', isKnownKioskSize);
console.log('Is fullscreen:', viewport.isFullscreen);
console.log('Is kiosk mode:', viewport.isKiosk);
}
// Kiosk-specific analysis
const recommendations = [];
if (viewport.isKiosk && isKnownKioskSize) {
if (viewport.width === 1023 && viewport.height === 767) {
recommendations.push('Detected 1023x767 kiosk viewport - using calculated optimal scanlines for perfect 2px rendering');
} else if (viewport.width === 1024 && viewport.height === 768) {
recommendations.push('Perfect 1024x768 kiosk viewport detected - optimal scanlines will be applied');
}
} else if (viewport.isKiosk && hasFractionalViewport) {
recommendations.push('Custom kiosk viewport detected - scanlines will be optimized for exact dimensions');
}
// Calculate what the zoom scale would be with current dimensions
const targetWidth = settings.wide?.value ? 640 + 107 + 107 : 640;
const targetHeight = 480;
const currentWidthRatio = viewport.width / targetWidth;
const currentHeightRatio = viewport.height / targetHeight;
const currentScale = Math.min(currentWidthRatio, currentHeightRatio);
// Calculate scanline rendering for current setup
const currentScanlineHeight = 1 * currentScale * viewport.devicePixelRatio;
const willCauseAliasing = currentScanlineHeight < 1.0 || (currentScanlineHeight % 1 !== 0);
if (window.debugMode) {
console.log('Scaling Analysis:');
console.log(` Current scale: ${currentScale.toFixed(6)}`);
console.log(` Base scanline rendering: ${currentScanlineHeight.toFixed(6)}px`);
console.log(` Will cause aliasing: ${willCauseAliasing}`);
if (viewport.isKiosk && isKnownKioskSize) {
// Calculate what our optimal scanline thickness would be
const targetRendered = 2.0; // We want 2px scanlines
const optimalThickness = targetRendered / (currentScale * viewport.devicePixelRatio);
console.log(`Optimal scanline thickness: ${optimalThickness.toFixed(6)}px`);
console.log(`Expected rendered height: ${(optimalThickness * currentScale * viewport.devicePixelRatio).toFixed(6)}px`);
}
if (recommendations.length > 0) {
console.log('Kiosk Optimization Status:');
recommendations.forEach((rec) => console.log(`${rec}`));
} else if (viewport.isKiosk) {
console.log('Custom kiosk configuration - using automatic optimization');
} else {
console.log('Not in kiosk mode - standard scaling applies');
}
console.groupEnd();
}
return {
viewport,
hasFractionalViewport,
isKnownKioskSize,
recommendations,
scaling: {
current: currentScale,
scanlineRendering: currentScanlineHeight,
willCauseAliasing,
},
};
};
// Make function globally available for debugging
window.analyzeViewportOptimization = analyzeViewportOptimization;
// Function to test fullscreen API capabilities
const testFullscreenCapabilities = () => {
const element = document.querySelector('#divTwc');
console.group('Fullscreen API Test');
const capabilities = {
requestFullscreen: !!element.requestFullscreen,
webkitRequestFullscreen: !!element.webkitRequestFullscreen,
mozRequestFullScreen: !!element.mozRequestFullScreen,
msRequestFullscreen: !!element.msRequestFullscreen,
fullscreenEnabled: !!document.fullscreenEnabled,
currentlyFullscreen: !!document.fullscreenElement,
};
console.log('API Support:', capabilities);
// Determine the best method
const requestMethod = element.requestFullscreen || element.webkitRequestFullscreen
|| element.mozRequestFullScreen || element.msRequestFullscreen;
if (requestMethod) {
console.log('Fullscreen API available');
console.log('Can attempt programmatic fullscreen for viewport optimization');
} else {
console.log('Fullscreen API not supported');
}
console.groupEnd();
return capabilities;
};
// Make function globally available for debugging
window.testFullscreenCapabilities = testFullscreenCapabilities;
// 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) => {
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;
};
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,
};