Compare commits

...

29 Commits

Author SHA1 Message Date
Matt Walsh
28baa022a9 6.1.11 2025-09-15 08:54:20 -05:00
Matt Walsh
e8b8890260 fix full screen centering on chrome #139 2025-09-15 08:54:11 -05:00
Matt Walsh
b797a10b9e 6.1.10 2025-09-15 08:04:24 -05:00
Matt Walsh
2a64cda383 Merge branch 'fullscreen-kiosk-sizing' 2025-09-15 08:03:53 -05:00
Matt Walsh
e6e357c51b separate full screen container and scaling #139 2025-09-15 08:01:28 -05:00
Matt Walsh
24deb4dce4 additional full screen scaling calculation adjustments 2025-09-11 15:34:02 -05:00
Matt Walsh
f17f69f60e 6.1.9 2025-09-09 22:07:51 -05:00
Matt Walsh
fa16095355 filter for actual alerts (not test) close #141 2025-09-09 22:07:42 -05:00
Matt Walsh
cc3dbeb043 6.1.8 2025-09-09 21:35:51 -05:00
Matt Walsh
8ee1e954eb better background and wide screen for hazard displays 2025-09-09 21:35:31 -05:00
Matt Walsh
bfc4bddfef Add quick start to readme 2025-09-09 20:54:13 -05:00
Matt Walsh
567325e3c5 update dependencies 2025-09-09 20:47:40 -05:00
Matt Walsh
4903b95fec 6.1.7 2025-09-09 20:26:37 -05:00
Matt Walsh
b43fb32820 Merge branch 'ios-metar-regex' 2025-09-09 20:26:29 -05:00
Matt Walsh
0d0c4ec452 fix Dockerfile.server build close #142 2025-09-09 20:06:54 -05:00
Matt Walsh
49d18c2fbe 6.1.6 2025-09-09 19:36:33 -05:00
Matt Walsh
1732a3381f fix hazards displaying when disabled (sometimes) close #140 2025-09-09 19:36:23 -05:00
Matt Walsh
cc05aafb95 patch for kiosk drawing off screen 2025-09-09 19:22:18 -05:00
Matt Walsh
093b6ac239 unique version numbers for staging uploads 2025-09-04 21:57:12 -05:00
Matt Walsh
12d068d740 playlist info in readme close #138 2025-09-04 21:26:01 -05:00
Matt Walsh
517c560ef6 don't parse metar for old ios versions 2025-09-03 21:46:48 -05:00
Matt Walsh
3eb571bed4 update community notes in readme close #135 2025-08-23 16:28:02 -05:00
Matt Walsh
52ca161bdb 6.1.5 2025-08-15 15:13:39 -05:00
Matt Walsh
ee5690dcad Merge branch 'station-data' 2025-08-15 15:01:00 -05:00
Matt Walsh
c05b827593 move station post processor inline with api gets 2025-08-15 14:59:16 -05:00
Matt Walsh
bef42a3da2 6.1.4 2025-08-11 22:35:18 -05:00
Matt Walsh
13ff0317e6 fix nighttime icons on extended forecast close #134 2025-08-11 22:35:03 -05:00
Matt Walsh
5cc85840a9 6.1.3 2025-08-11 22:15:28 -05:00
Matt Walsh
190e50e2f3 add debugging information to forecast info box 2025-08-11 22:15:16 -05:00
25 changed files with 37977 additions and 19295 deletions

View File

@@ -11,4 +11,4 @@ Please do not report issues with api.weather.gov being down. It's a new service
Please include: Please include:
* Web browser and OS * Web browser and OS
* Location for which you are viewing a forecast * Forecast Information text block from the very bottom of the web page

12
.vscode/launch.json vendored
View File

@@ -26,6 +26,18 @@
"skipFiles": [ "skipFiles": [
"<node_internals>/**" "<node_internals>/**"
], ],
"args": [
"--use-cache"
],
"type": "node"
},
{
"name": "Data:stations-api",
"program": "${workspaceFolder}/datagenerators/stations.mjs",
"request": "launch",
"skipFiles": [
"<node_internals>/**"
],
"type": "node" "type": "node"
}, },
{ {

View File

@@ -2,7 +2,7 @@ FROM node:24-alpine
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev --legacy-peer-deps RUN npm ci --legacy-peer-deps
COPY . . COPY . .
RUN npm run build RUN npm run build

View File

@@ -32,6 +32,18 @@ From a learning standpoint, this codebase make use of a lot of different methods
* Hand written CSS made easier to mange with SASS * Hand written CSS made easier to mange with SASS
* A linting library to keep code style consistent * A linting library to keep code style consistent
## Quck Start
Ensure you have Node installed.
```bash
git clone https://github.com/netbymatt/ws4kp.git
cd ws4kp
npm install
npm start
```
Open your browser and navigate to https://localhost:8080
## Does WeatherStar 4000+ work outside of the USA? ## Does WeatherStar 4000+ work outside of the USA?
This project is tightly coupled to [NOAA's Weather API](https://www.weather.gov/documentation/services-web-api), which is exclusive to the United States. Using NOAA's Weather API is a crucial requirement to provide an authentic WeatherStar 4000+ experience. This project is tightly coupled to [NOAA's Weather API](https://www.weather.gov/documentation/services-web-api), which is exclusive to the United States. Using NOAA's Weather API is a crucial requirement to provide an authentic WeatherStar 4000+ experience.
@@ -57,14 +69,7 @@ WeatherStar 4000+ supports two deployment modes:
* Browser-based caching * Browser-based caching
* Used by: static file hosting and default `Dockerfile` * Used by: static file hosting and default `Dockerfile`
## Run Your WeatherStar ## Other methods to run Ws4kp
Ensure you have Node installed. Clone the repository:
```bash
git clone https://github.com/netbymatt/ws4kp.git
cd ws4kp
npm install
```
### Development Mode (individual JS files, easier debugging) ### Development Mode (individual JS files, easier debugging)
```bash ```bash
@@ -309,11 +314,13 @@ If you're unable to pre-set the play state before entering kiosk mode (such as w
## Community Notes ## Community Notes
Thanks to the WeatherStar community for providing these discussions to further extend your retro forecasts! Thanks to the WeatherStar+ community for providing these discussions to further extend your retro forecasts!
* [Stream as FFMPEG](https://github.com/netbymatt/ws4kp/issues/37#issuecomment-2008491948) * [Stream as FFMPEG](https://github.com/netbymatt/ws4kp/issues/37#issuecomment-2008491948)
* [Weather like it's 1999](https://blog.scottlabs.io/2024/02/weather-like-its-1999/) Raspberry pi, streaming, music and CRT all combined into a complete solution. * [Weather like it's 1999](https://blog.scottlabs.io/2024/02/weather-like-its-1999/) Raspberry pi, streaming, music and CRT all combined into a complete solution.
* [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg. * [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg.
* [SSL Certificates](https://github.com/netbymatt/ws4kp/issues/135) Discussion about how to host with an SSL certificate (enables geolocation).
* [Changing playlists](https://github.com/netbymatt/ws4kp/issues/138) Possible ways to automatically change the playlist on a schedule.
## Customization ## Customization

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
import { readFileSync, writeFileSync } from 'fs'; import { readFileSync, writeFileSync } from 'fs';
import * as url from 'node:url';
// Load station data // Load station data
const stationInfo = JSON.parse(readFileSync('./datagenerators/output/stations-raw.json', 'utf8')); const stationInfo = JSON.parse(readFileSync('./datagenerators/output/stations-raw.json', 'utf8'));
// const regionalCities = JSON.parse(readFileSync('./datagenerators/output/regionalcities.json', 'utf8')); // const regionalCities = JSON.parse(readFileSync('./datagenerators/output/regionalcities.json', 'utf8'));
@@ -1109,139 +1111,184 @@ or where the fallback to the ICAO airport code occurred:
jq -c '.[] | select(.name | test("^[A-Z]{3}$")) | {state, city, simple, name}' jq -c '.[] | select(.name | test("^[A-Z]{3}$")) | {state, city, simple, name}'
*/ */
const diffMode = process.argv.includes('--diff'); const readArguments = () => ({
const onlyProblems = process.argv.includes('--only-problems'); diffMode: process.argv.includes('--diff'),
const noProblems = process.argv.includes('--no-problems'); onlyProblems: process.argv.includes('--only-problems'),
const onlyDuplicates = process.argv.includes('--only-dupes'); noProblems: process.argv.includes('--no-problems'),
const noPriority = process.argv.includes('--no-priority'); onlyDuplicates: process.argv.includes('--only-dupes'),
const noSimple = process.argv.includes('--no-simple'); noPriority: process.argv.includes('--no-priority'),
const noCoordinates = process.argv.includes('--no-coords'); noSimple: process.argv.includes('--no-simple'),
const writeFile = process.argv.includes('--write'); noCoordinates: process.argv.includes('--no-coords'),
writeFile: process.argv.includes('--write'),
});
// Process ALL stations at once to get the display name map const DEFAULT_OPTIONS = {
let displayNameMap = processAllStations(stationInfo); diffMode: false,
onlyProblems: false,
noProblems: false,
onlyDuplicates: false,
noPriority: false,
noSimple: false,
noCoordinates: false,
writeFile: false,
};
// Apply priority-based deduplication const postProcessor = (_options) => {
displayNameMap = resolveDuplicatesByPriority(displayNameMap, stationInfo); // combine default and provided options
const options = { ...DEFAULT_OPTIONS, ..._options };
const results = []; // Process ALL stations at once to get the display name map
let displayNameMap = processAllStations(stationInfo);
// Now iterate through stations and use the pre-computed display names // Apply priority-based deduplication
const stations = Object.values(stationInfo); displayNameMap = resolveDuplicatesByPriority(displayNameMap, stationInfo);
stations.forEach((station) => {
const originalName = station.city;
const processedName = processingUtils.finalCleanup(displayNameMap[station.id]); // Look up by station ID
// Get airport type and priority for this station const results = [];
const airportType = getAirportType(originalName, station.id); // Pass station ID for enhanced detection
const priority = getAirportPriority(airportType);
const potentialIssues = []; // Now iterate through stations and use the pre-computed display names
// Check if the processed name contains punctuation (a period at the end is OK) const stations = Object.values(stationInfo);
if (/[,;!?/:.]/.test(processedName) && !processedName.endsWith('.')) { stations.forEach((station) => {
potentialIssues.push('punctuation'); const originalName = station.city;
} const processedName = processingUtils.finalCleanup(displayNameMap[station.id]); // Look up by station ID
if (processedName.length > 12) {
potentialIssues.push('long');
}
if (processedName.length > 20) {
potentialIssues.push('reallyLong');
}
// check if it contains any digits
if (/\d/.test(processedName)) {
potentialIssues.push('digits');
}
results.push({ // Get airport type and priority for this station
id: station.id, const airportType = getAirportType(originalName, station.id); // Pass station ID for enhanced detection
lat: station.lat, const priority = getAirportPriority(airportType);
lon: station.lon,
state: station.state, const potentialIssues = [];
location: originalName, // original full location name // Check if the processed name contains punctuation (a period at the end is OK)
city: processedName, // processed city name for display if (/[,;!?/:.]/.test(processedName) && !processedName.endsWith('.')) {
simple: originalName.match(/[^,/;\\-]*/)[0].substr(0, 12).trim(), potentialIssues.push('punctuation');
type: airportType, }
priority, if (processedName.length > 12) {
potentialIssues, potentialIssues.push('long');
}
if (processedName.length > 20) {
potentialIssues.push('reallyLong');
}
// check if it contains any digits
if (/\d/.test(processedName)) {
potentialIssues.push('digits');
}
results.push({
id: station.id,
lat: station.lat,
lon: station.lon,
state: station.state,
location: originalName, // original full location name
city: processedName, // processed city name for display
simple: originalName.match(/[^,/;\\-]*/)[0].substr(0, 12).trim(),
type: airportType,
priority,
potentialIssues,
});
}); });
});
// Check for duplicates by state // Check for duplicates by state
const cleanedMapByState = new Map(); const cleanedMapByState = new Map();
results.forEach((result) => { results.forEach((result) => {
const { state } = result; const { state } = result;
if (!cleanedMapByState.has(state)) { if (!cleanedMapByState.has(state)) {
cleanedMapByState.set(state, new Map()); cleanedMapByState.set(state, new Map());
} }
const stateMap = cleanedMapByState.get(state); const stateMap = cleanedMapByState.get(state);
if (stateMap.has(result.city)) { if (stateMap.has(result.city)) {
stateMap.get(result.city).push(result); stateMap.get(result.city).push(result);
} else { } else {
stateMap.set(result.city, [result]); stateMap.set(result.city, [result]);
}
});
cleanedMapByState.forEach((stateMap, _state) => {
stateMap.forEach((originals, _cleaned) => {
if (originals.length > 1) {
originals.forEach((original) => {
if (!original.potentialIssues.includes('duplicate')) {
original.potentialIssues.push('duplicate');
}
});
} }
}); });
});
// Filter results if requested cleanedMapByState.forEach((stateMap, _state) => {
let finalResults = results; stateMap.forEach((originals, _cleaned) => {
if (onlyProblems) { if (originals.length > 1) {
finalResults = results.filter((r) => r.potentialIssues.length > 0); originals.forEach((original) => {
} if (!original.potentialIssues.includes('duplicate')) {
if (onlyDuplicates) { original.potentialIssues.push('duplicate');
finalResults = finalResults.filter((r) => r.potentialIssues.includes('duplicate')); }
} });
}
});
});
const outputResult = finalResults.map((result) => { // Filter results if requested
let outputItem = result; let finalResults = results;
if (options.onlyProblems) {
// Don't include lat or long in diff mode finalResults = results.filter((r) => r.potentialIssues.length > 0);
if (noCoordinates || diffMode) { }
const { if (options.onlyDuplicates) {
lat: _lat, lon: _lon, ...resultWithoutLocation finalResults = finalResults.filter((r) => r.potentialIssues.includes('duplicate'));
} = result;
outputItem = resultWithoutLocation;
} }
// Don't include potentialIssues when --no-problems is specified const outputResult = finalResults.map((result) => {
if (noProblems || diffMode) { let outputItem = result;
const { potentialIssues: _potentialIssues, ...resultWithoutIssues } = outputItem;
outputItem = resultWithoutIssues;
}
// Remove type and priority if --no-priority is specified // Don't include lat or long in diff mode
if (noPriority || diffMode) { if (options.noCoordinates || options.diffMode) {
const { type: _type, priority: _priority, ...resultWithoutPriority } = outputItem; const {
outputItem = resultWithoutPriority; lat: _lat, lon: _lon, ...resultWithoutLocation
} } = result;
outputItem = resultWithoutLocation;
}
// remove simple field if --no-simple is specified // Don't include potentialIssues when --no-problems is specified
if (noSimple || diffMode) { if (options.noProblems || options.diffMode) {
const { simple: _simple, ...resultWithoutSimple } = outputItem; const { potentialIssues: _potentialIssues, ...resultWithoutIssues } = outputItem;
outputItem = resultWithoutSimple; outputItem = resultWithoutIssues;
} }
return outputItem; // Remove type and priority if --no-priority is specified
}); if (options.noPriority || options.diffMode) {
const { type: _type, priority: _priority, ...resultWithoutPriority } = outputItem;
outputItem = resultWithoutPriority;
}
// remove simple field if --no-simple is specified
if (options.noSimple || options.diffMode) {
const { simple: _simple, ...resultWithoutSimple } = outputItem;
outputItem = resultWithoutSimple;
}
return outputItem;
});
if (writeFile) {
const fileResults = results.map(({ const fileResults = results.map(({
simple: _simple, type: _type, potentialIssues: _potentialIssues, ...rest simple: _simple, type: _type, potentialIssues: _potentialIssues, location: _location, ...rest
}) => rest); }) => rest);
writeFileSync('./datagenerators/output/stations.json', compactStringifyToObject(fileResults)); if (options.writeFile) {
console.log(`Wrote ${fileResults.length} processed stations to datagenerators/output/stations.json`); writeFileSync('./datagenerators/output/stations.json', compactStringifyToObject(fileResults));
} else { console.log(`Wrote ${fileResults.length} processed stations to datagenerators/output/stations.json`);
console.log(compactStringifyToArray(outputResult)); } else {
console.log(compactStringifyToArray(outputResult));
}
// array to output object
const returnObject = {};
fileResults.forEach((item) => {
returnObject[item.id] = item;
});
return returnObject;
};
// determine if running from command line or module
const commandLine = (() => {
if (import.meta.url.startsWith('file:')) { // (A)
const modulePath = url.fileURLToPath(import.meta.url);
if (process.argv[1] === modulePath) { // (B)
return true;
}
}
return false;
} }
)();
// run post processor if called from command line
if (commandLine) {
postProcessor(readArguments());
}
export default postProcessor;

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-loop-func */
// list all stations in a single file // list all stations in a single file
// only find stations with 4 letter codes // only find stations with 4 letter codes
@@ -6,67 +7,91 @@ import https from './https.mjs';
import states from './stations-states.mjs'; import states from './stations-states.mjs';
import chunk from './chunk.mjs'; import chunk from './chunk.mjs';
import overrides from './stations-overrides.mjs'; import overrides from './stations-overrides.mjs';
import postProcessor from './stations-postprocessor.mjs';
// check for cached flag
const USE_CACHE = process.argv.includes('--use-cache');
// skip stations starting with these letters // skip stations starting with these letters
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J']; const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
// chunk the list of states // chunk the list of states
const chunkStates = chunk(states, 1); const chunkStates = chunk(states, 3);
// store output // store output
const output = {}; const output = {};
let completed = 0;
// process all chunks // get data from api if desired
for (let i = 0; i < chunkStates.length; i += 1) { if (!USE_CACHE) {
const stateChunk = chunkStates[i]; // process all chunks
// loop through states for (let i = 0; i < chunkStates.length; i += 1) {
const stateChunk = chunkStates[i];
// loop through states
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await Promise.allSettled(stateChunk.map(async (state) => { await Promise.allSettled(stateChunk.map(async (state) => {
try { try {
let stations; let stations;
let next = `https://api.weather.gov/stations?state=${state}`; let next = `https://api.weather.gov/stations?state=${state}`;
let round = 0; let round = 0;
do { do {
console.log(`Getting: ${state}-${round}`); console.log(`Getting: ${state}-${round}`);
// get list and parse the JSON // get list and parse the JSON
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const stationsRaw = await https(next); const stationsRaw = await https(next);
stations = JSON.parse(stationsRaw); stations = JSON.parse(stationsRaw);
// filter stations for 4 letter identifiers // filter stations for 4 letter identifiers
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/)); const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
// filter against starting letter // filter against starting letter
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1))); const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// add each resulting station to the output // add each resulting station to the output
stationsFiltered.forEach((station) => { stationsFiltered.forEach((station) => {
const id = station.properties.stationIdentifier; const id = station.properties.stationIdentifier;
if (output[id]) { if (output[id]) {
console.log(`Duplicate station: ${state}-${id}`); console.log(`Duplicate station: ${state}-${id}`);
return; return;
} }
// get any overrides if available output[id] = {
const override = overrides[id] ?? {}; id,
output[id] = { city: station.properties.name,
id, state,
city: station.properties.name, lat: station.geometry.coordinates[1],
state, lon: station.geometry.coordinates[0],
lat: station.geometry.coordinates[1], };
lon: station.geometry.coordinates[0], });
// finally add the overrides next = stations?.pagination?.next;
...override, round += 1;
}; // write the output
}); writeFileSync('./datagenerators/output/stations-raw.json', JSON.stringify(output, null, 2));
next = stations?.pagination?.next; }
round += 1; while (next && stations.features.length > 0);
// write the output completed += 1;
writeFileSync('./datagenerators/output/stations-raw.json', JSON.stringify(output, null, 2)); console.log(`Complete: ${state} ${completed}/${states.length}`);
return true;
} catch {
console.error(`Unable to get state: ${state}`);
return false;
} }
while (next && stations.features.length > 0); }));
console.log(`Complete: ${state}`); }
return true;
} catch {
console.error(`Unable to get state: ${state}`);
return false;
}
}));
} }
// run the post processor
// data is passed through the file stations-raw.json
const postProcessed = postProcessor();
// apply any overrides
Object.entries(overrides).forEach(([id, values]) => {
// check for existing value
if (postProcessed[id]) {
// apply the overrides
postProcessed[id] = {
...postProcessed[id],
...values,
};
}
});
// write final file to disk
writeFileSync('./datagenerators/output/stations.json', JSON.stringify(postProcessed, null, 2));

View File

@@ -97,23 +97,27 @@ const copyCss = () => src(cssSources)
const htmlSources = [ const htmlSources = [
'views/*.ejs', 'views/*.ejs',
]; ];
const compressHtml = async () => { const packageJson = await readFile('package.json');
const packageJson = await readFile('package.json'); let { version } = JSON.parse(packageJson);
const { version } = JSON.parse(packageJson); const previewVersion = async () => {
// generate a relatively unique timestamp for cache invalidation of the preview site
return src(htmlSources) const now = new Date();
.pipe(ejs({ const msNow = now.getTime() % 1_000_000;
production: version, version = msNow.toString();
serverAvailable: false,
version,
OVERRIDES,
query: {},
}))
.pipe(rename({ extname: '.html' }))
.pipe(htmlmin({ collapseWhitespace: true }))
.pipe(dest('./dist'));
}; };
const compressHtml = async () => src(htmlSources)
.pipe(ejs({
production: version,
serverAvailable: false,
version,
OVERRIDES,
query: {},
}))
.pipe(rename({ extname: '.html' }))
.pipe(htmlmin({ collapseWhitespace: true }))
.pipe(dest('./dist'));
const otherFiles = [ const otherFiles = [
'server/robots.txt', 'server/robots.txt',
'server/manifest.json', 'server/manifest.json',
@@ -205,7 +209,7 @@ const buildDist = series(clean, parallel(buildJs, compressJsVendor, copyCss, com
// upload_images could be in parallel with upload, but _images logs a lot and has little changes // upload_images could be in parallel with upload, but _images logs a lot and has little changes
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing // by running upload last the majority of the changes will be at the bottom of the log for easy viewing
const publishFrontend = series(buildDist, uploadImages, upload, invalidate); const publishFrontend = series(buildDist, uploadImages, upload, invalidate);
const stageFrontend = series(buildDist, uploadImagesPreview, uploadPreview, invalidatePreview); const stageFrontend = series(previewVersion, buildDist, uploadImagesPreview, uploadPreview, invalidatePreview);
export default publishFrontend; export default publishFrontend;

3653
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "ws4kp", "name": "ws4kp",
"version": "6.1.2", "version": "6.1.11",
"description": "Welcome to the WeatherStar 4000+ project page!", "description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -56,6 +56,7 @@
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"ejs": "^3.1.5", "ejs": "^3.1.5",
"express": "^5.1.0", "express": "^5.1.0",
"metar-taf-parser": "^9.0.0" "metar-taf-parser": "^9.0.0",
"npm": "^11.6.0"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -145,6 +145,8 @@ const init = async () => {
document.querySelector('#spanStationId').innerHTML = ''; document.querySelector('#spanStationId').innerHTML = '';
document.querySelector('#spanRadarId').innerHTML = ''; document.querySelector('#spanRadarId').innerHTML = '';
document.querySelector('#spanZoneId').innerHTML = ''; document.querySelector('#spanZoneId').innerHTML = '';
document.querySelector('#spanOfficeId').innerHTML = '';
document.querySelector('#spanGridPoint').innerHTML = '';
localStorage.removeItem('play'); localStorage.removeItem('play');
postMessage('navButton', 'play'); postMessage('navButton', 'play');

View File

@@ -97,6 +97,12 @@ const drawScreen = async () => {
if (elem.parentElement.id === 'progress-html') return; if (elem.parentElement.id === 'progress-html') return;
thisScreen?.classes?.forEach((cls) => elem.classList.add(cls)); thisScreen?.classes?.forEach((cls) => elem.classList.add(cls));
}); });
// special case for red background on hazard scroll
const mainScrollBg = document.getElementById('scroll-bg');
mainScrollBg.className = '';
if (thisScreen?.classes?.includes('hazard')) {
mainScrollBg.classList.add('hazard');
}
if (typeof thisScreen === 'string') { if (typeof thisScreen === 'string') {
// only a string // only a string

View File

@@ -137,10 +137,6 @@ const parse = (fullForecast, forecastUrl) => {
} }
// get the object to modify/populate // get the object to modify/populate
const fDay = forecast[destIndex]; const fDay = forecast[destIndex];
// high temperature will always be last in the source array so it will overwrite the low values assigned below
fDay.icon = getLargeIcon(period.icon);
fDay.text = shortenExtendedForecastText(period.shortForecast);
fDay.dayName = dates[destIndex];
// preload the icon // preload the icon
preloadImg(fDay.icon); preloadImg(fDay.icon);
@@ -148,6 +144,9 @@ const parse = (fullForecast, forecastUrl) => {
if (period.isDaytime) { if (period.isDaytime) {
// day time is the high temperature // day time is the high temperature
fDay.high = period.temperature; fDay.high = period.temperature;
fDay.icon = getLargeIcon(period.icon);
fDay.text = shortenExtendedForecastText(period.shortForecast);
fDay.dayName = dates[destIndex];
// Wait for the corresponding night period to increment // Wait for the corresponding night period to increment
} else { } else {
// low temperature // low temperature

View File

@@ -71,6 +71,7 @@ class Hazards extends WeatherDisplay {
// get the forecast using centralized safe handling // get the forecast using centralized safe handling
const url = new URL('https://api.weather.gov/alerts/active'); const url = new URL('https://api.weather.gov/alerts/active');
url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`); url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`);
url.searchParams.append('status', 'actual');
const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
if (!alerts) { if (!alerts) {
@@ -103,7 +104,10 @@ class Hazards extends WeatherDisplay {
// show alert indicator // show alert indicator
if (unViewed > 0) alert.classList.add('show'); if (unViewed > 0) alert.classList.add('show');
// draw the canvas to calculate the new timings and activate hazards in the slide deck again // draw the canvas to calculate the new timings and activate hazards in the slide deck again
this.drawLongCanvas(); // unless this has been disabled
if (this.isEnabled) {
this.drawLongCanvas();
}
} catch (error) { } catch (error) {
console.error(`Unexpected Active Alerts error: ${error.message}`); console.error(`Unexpected Active Alerts error: ${error.message}`);
if (this.isEnabled) this.setStatus(STATUS.failed); if (this.isEnabled) this.setStatus(STATUS.failed);
@@ -115,7 +119,7 @@ class Hazards extends WeatherDisplay {
this.getDataCallback(); this.getDataCallback();
if (!superResult) { if (!superResult) {
this.setStatus(STATUS.loaded); // Don't override status - super.getData() already set it to STATUS.disabled
return; return;
} }
this.drawLongCanvas(); this.drawLongCanvas();

View File

@@ -111,7 +111,7 @@ const getWeather = async (latLon, haveDataCallback) => {
weatherParameters.stations = stations.features; weatherParameters.stations = stations.features;
// update the main process for display purposes // update the main process for display purposes
populateWeatherParameters(weatherParameters); populateWeatherParameters(weatherParameters, point.properties);
// reset the scroll // reset the scroll
postMessage({ type: 'current-weather-scroll', method: 'reload' }); postMessage({ type: 'current-weather-scroll', method: 'reload' });
@@ -357,6 +357,17 @@ const isIOS = () => {
let lastAppliedScale = null; let lastAppliedScale = null;
let lastAppliedKioskMode = 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 // resize the container on a page resize
const resize = (force = false) => { const resize = (force = false) => {
// Ignore resize events caused by pinch-to-zoom on mobile // Ignore resize events caused by pinch-to-zoom on mobile
@@ -376,9 +387,8 @@ const resize = (force = false) => {
// Standard scaling: fit within both dimensions // Standard scaling: fit within both dimensions
const scale = Math.min(widthZoomPercent, heightZoomPercent); const scale = Math.min(widthZoomPercent, heightZoomPercent);
// For Mobile Safari in kiosk mode, always use centering behavior regardless of scale // Use centering behavior for fullscreen, kiosk mode, or Mobile Safari kiosk mode
// For other platforms, only use fullscreen/centering behavior for actual fullscreen or kiosk mode where content fits naturally const isKioskLike = isFullscreen || isKioskMode || isMobileSafariKiosk;
const isKioskLike = isFullscreen || (isKioskMode && scale >= 1.0) || isMobileSafariKiosk;
if (debugFlag('resize') || debugFlag('fullscreen')) { 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}`); 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}`);
@@ -412,40 +422,35 @@ const resize = (force = false) => {
console.log('🖥️ Resetting fullscreen/kiosk styles to normal'); console.log('🖥️ Resetting fullscreen/kiosk styles to normal');
} }
// Reset wrapper styles (only properties that are actually set in fullscreen/scaling modes) // Reset all scaling-related styles
wrapper.style.removeProperty('width'); const container = document.querySelector('#container');
wrapper.style.removeProperty('height'); clearElementStyles(wrapper, SCALING_PROPERTIES.wrapper);
wrapper.style.removeProperty('overflow'); clearElementStyles(container, SCALING_PROPERTIES.positioning);
wrapper.style.removeProperty('transform'); clearElementStyles(mainContainer, SCALING_PROPERTIES.positioning);
wrapper.style.removeProperty('transform-origin');
// Reset container styles that might have been applied during fullscreen
mainContainer.style.removeProperty('transform');
mainContainer.style.removeProperty('transform-origin');
mainContainer.style.removeProperty('width');
mainContainer.style.removeProperty('height');
mainContainer.style.removeProperty('position');
mainContainer.style.removeProperty('left');
mainContainer.style.removeProperty('top');
mainContainer.style.removeProperty('margin-left');
mainContainer.style.removeProperty('margin-top');
applyScanlineScaling(1.0); applyScanlineScaling(1.0);
return; return;
} }
// MOBILE SCALING: Use wrapper scaling for mobile devices (but not Mobile Safari kiosk mode) // MOBILE SCALING: Use wrapper scaling for mobile devices (but not when in fullscreen/kiosk mode)
if ((scale < 1.0 || (isKioskMode && !isKioskLike)) && !isMobileSafariKiosk) { if ((scale < 1.0 || (isKioskMode && !isKioskLike)) && !isMobileSafariKiosk && !isKioskLike) {
/* /*
* MOBILE SCALING (Wrapper Scaling) * MOBILE SCALING (Wrapper Scaling)
* *
* This path is used for regular mobile browsing (NOT fullscreen/kiosk modes).
* Why scale the wrapper instead of mainContainer? * Why scale the wrapper instead of mainContainer?
* - For mobile devices where content is larger than viewport, we need to scale the entire layout * - 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 * - 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 * - Scaling the wrapper ensures both elements are scaled together as a unit
* - No centering is applied - content aligns to top-left for typical mobile behavior * - 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 * - 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', `scale(${scale})`);
wrapper.style.setProperty('transform-origin', 'top left'); // Scale from top-left corner wrapper.style.setProperty('transform-origin', 'top left'); // Scale from top-left corner
@@ -458,7 +463,7 @@ const resize = (force = false) => {
const scaledHeight = totalHeight * scale; // Height after scaling const scaledHeight = totalHeight * scale; // Height after scaling
wrapper.style.setProperty('width', `${wrapperWidth}px`); wrapper.style.setProperty('width', `${wrapperWidth}px`);
wrapper.style.setProperty('height', `${scaledHeight}px`); // Use scaled height to eliminate gap wrapper.style.setProperty('height', `${scaledHeight}px`); // Use scaled height to eliminate gap under #divTwc on index page
applyScanlineScaling(scale); applyScanlineScaling(scale);
return; return;
} }
@@ -468,10 +473,7 @@ const resize = (force = false) => {
const wrapperHeight = 480; const wrapperHeight = 480;
// Reset wrapper styles to avoid double scaling (wrapper remains unstyled) // Reset wrapper styles to avoid double scaling (wrapper remains unstyled)
wrapper.style.removeProperty('width'); clearElementStyles(wrapper, SCALING_PROPERTIES.wrapper);
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('transform');
wrapper.style.removeProperty('transform-origin');
// Platform-specific positioning logic // Platform-specific positioning logic
let transformOrigin; let transformOrigin;
@@ -529,7 +531,7 @@ const resize = (force = false) => {
const offsetY = (window.innerHeight - scaledHeight) / 2; const offsetY = (window.innerHeight - scaledHeight) / 2;
if (debugFlag('fullscreen')) { if (debugFlag('fullscreen')) {
console.log(`🖥️ Applying fullscreen/kiosk scaling: wrapper=${wrapperWidth}x${wrapperHeight} scale=${scale.toFixed(3)} offset=${offsetX.toFixed(1)},${offsetY.toFixed(1)} transform: scale(${scale}) translate(${offsetX / scale}px, ${offsetY / scale}px)`); 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 // Set positioning values for CSS-based centering
@@ -540,25 +542,41 @@ const resize = (force = false) => {
marginTop = `-${wrapperHeight / 2}px`; // Pull back by half height marginTop = `-${wrapperHeight / 2}px`; // Pull back by half height
} }
// Apply shared mainContainer properties (same for both kiosk modes) // Chrome fullscreen compatibility: apply transform to #container instead of #divTwcMain
mainContainer.style.setProperty('transform', `scale(${scale})`, 'important'); // This works around Chrome's restriction on styling fullscreen elements directly
mainContainer.style.setProperty('transform-origin', transformOrigin, 'important'); const container = document.querySelector('#container');
mainContainer.style.setProperty('width', `${wrapperWidth}px`, 'important'); const targetElement = isFullscreen ? container : mainContainer;
mainContainer.style.setProperty('height', `${wrapperHeight}px`, 'important');
mainContainer.style.setProperty('position', 'absolute', 'important'); // Reset the other element's styles to avoid conflicts
mainContainer.style.setProperty('left', leftPosition, 'important'); if (isFullscreen) {
mainContainer.style.setProperty('top', topPosition, 'important'); // 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 // Apply or clear margin properties based on positioning method
if (marginLeft !== null) { if (marginLeft !== null) {
mainContainer.style.setProperty('margin-left', marginLeft, 'important'); targetElement.style.setProperty('margin-left', marginLeft, 'important');
} else { } else {
mainContainer.style.removeProperty('margin-left'); targetElement.style.removeProperty('margin-left');
} }
if (marginTop !== null) { if (marginTop !== null) {
mainContainer.style.setProperty('margin-top', marginTop, 'important'); targetElement.style.setProperty('margin-top', marginTop, 'important');
} else { } else {
mainContainer.style.removeProperty('margin-top'); targetElement.style.removeProperty('margin-top');
} }
applyScanlineScaling(scale); applyScanlineScaling(scale);
@@ -753,12 +771,14 @@ const registerProgress = (_progress) => {
progress = _progress; progress = _progress;
}; };
const populateWeatherParameters = (params) => { const populateWeatherParameters = (params, point) => {
document.querySelector('#spanCity').innerHTML = `${params.city}, `; document.querySelector('#spanCity').innerHTML = `${params.city}, `;
document.querySelector('#spanState').innerHTML = params.state; document.querySelector('#spanState').innerHTML = params.state;
document.querySelector('#spanStationId').innerHTML = params.stationId; document.querySelector('#spanStationId').innerHTML = params.stationId;
document.querySelector('#spanRadarId').innerHTML = params.radarId; document.querySelector('#spanRadarId').innerHTML = params.radarId;
document.querySelector('#spanZoneId').innerHTML = params.zoneId; document.querySelector('#spanZoneId').innerHTML = params.zoneId;
document.querySelector('#spanOfficeId').innerHTML = point.cwa;
document.querySelector('#spanGridPoint').innerHTML = `${point.gridX},${point.gridY}`;
}; };
const latLonReceived = (data, haveDataCallback) => { const latLonReceived = (data, haveDataCallback) => {

View File

@@ -3,13 +3,32 @@ import { parseMetar } from '../../vendor/auto/metar-taf-parser.mjs';
// eslint-disable-next-line import/extensions // eslint-disable-next-line import/extensions
import en from '../../vendor/auto/locale/en.js'; import en from '../../vendor/auto/locale/en.js';
// metar-taf-parser requires regex lookbehind
// this does not work in iOS < 16.4
// this is a detection algorithm for iOS versions
const isIos = /iP(ad|od|hone)/i.test(window.navigator.userAgent);
let iosVersionOk = false;
if (isIos) {
// regex match the version string
const iosVersionRaw = /OS (\d+)_(\d+)/.exec(window.navigator.userAgent);
// check for match
if (iosVersionRaw) {
// break into parts
const iosVersionMajor = parseInt(iosVersionRaw[1], 10);
const iosVersionMinor = parseInt(iosVersionRaw[2], 10);
if (iosVersionMajor > 16) iosVersionOk = true;
if (iosVersionMajor === 16 && iosVersionMinor >= 4) iosVersionOk = true;
}
}
/** /**
* Augment observation data by parsing METAR when API fields are missing * Augment observation data by parsing METAR when API fields are missing
* @param {Object} observation - The observation object from the API * @param {Object} observation - The observation object from the API
* @returns {Object} - Augmented observation with parsed METAR data filled in * @returns {Object} - Augmented observation with parsed METAR data filled in
*/ */
const augmentObservationWithMetar = (observation) => { const augmentObservationWithMetar = (observation) => {
if (!observation?.rawMessage) { // check for a metar message and for unusable ios versions
if (!observation?.rawMessage || (isIos && !iosVersionOk)) {
return observation; return observation;
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,9 @@
@use 'shared/_colors' as c; @use 'shared/_colors'as c;
@use 'shared/_utils' as u; @use 'shared/_utils'as u;
#hazards-html.weather-display {
background-image: url('../images/backgrounds/7.png');
}
.weather-display .main.hazards { .weather-display .main.hazards {
&.main { &.main {
@@ -7,6 +11,7 @@
height: 480px; height: 480px;
background-color: rgb(112, 35, 35); background-color: rgb(112, 35, 35);
.hazard-lines { .hazard-lines {
min-height: 400px; min-height: 400px;
padding-top: 10px; padding-top: 10px;
@@ -26,3 +31,7 @@
} }
} }
} }
.wide.hazards #container {
background: url(../images/backgrounds/7-wide.png);
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_utils' as u; @use 'shared/_utils'as u;
@use 'shared/_colors' as c; @use 'shared/_colors'as c;
@font-face { @font-face {
font-family: "Star4000"; font-family: "Star4000";
@@ -161,6 +161,7 @@ body {
#divTwcMain { #divTwcMain {
width: 640px; width: 640px;
height: 480px; height: 480px;
position: relative;
.wide & { .wide & {
width: 854px; width: 854px;
@@ -340,13 +341,14 @@ body {
// overflow: hidden; // overflow: hidden;
background-image: url(../images/backgrounds/1.png); background-image: url(../images/backgrounds/1.png);
transform-origin: 0 0; transform-origin: 0 0;
background-repeat: no-repeat;
} }
.wide #container { .wide #container {
padding-left: 107px; padding-left: 107px;
padding-right: 107px; padding-right: 107px;
background: url(../images/backgrounds/1-wide.png);
background-repeat: no-repeat; background-repeat: no-repeat;
background: url(../images/backgrounds/1-wide.png)
} }
#divTwc:fullscreen #container, #divTwc:fullscreen #container,
@@ -813,4 +815,4 @@ body.kiosk #loading .instructions {
>*:not(#divTwc) { >*:not(#divTwc) {
display: none !important; display: none !important;
} }
} }

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors' as c; @use 'shared/_colors'as c;
@use 'shared/_utils' as u; @use 'shared/_utils'as u;
.weather-display { .weather-display {
width: 640px; width: 640px;
@@ -116,9 +116,11 @@
.scroll { .scroll {
@include u.text-shadow(3px, 1.5px); @include u.text-shadow(3px, 1.5px);
width: 640px; width: 640px;
height: 70px; height: 77px;
overflow: hidden; overflow: hidden;
margin-top: 3px; margin-top: 3px;
position: relative;
z-index: 1;
&.hazard { &.hazard {
background-color: rgb(112, 35, 35); background-color: rgb(112, 35, 35);
@@ -159,3 +161,18 @@
} }
} }
#scroll-bg {
position: absolute;
bottom: 0px;
height: 77px;
width: 640px;
&.hazard {
background-color: rgb(112, 35, 35);
}
}
.wide #scroll-bg {
width: 854px;
}

View File

@@ -134,6 +134,7 @@
<%- include('partials/hazards.ejs') %> <%- include('partials/hazards.ejs') %>
</div> </div>
</div> </div>
<div id="scroll-bg"></div>
</div> </div>
<div id="divTwcBottom"> <div id="divTwcBottom">
<div id="divTwcBottomLeft"> <div id="divTwcBottomLeft">
@@ -191,6 +192,8 @@
Station Id: <span id="spanStationId"></span><br /> Station Id: <span id="spanStationId"></span><br />
Radar Id: <span id="spanRadarId"></span><br /> Radar Id: <span id="spanRadarId"></span><br />
Zone Id: <span id="spanZoneId"></span><br /> Zone Id: <span id="spanZoneId"></span><br />
Office Id: <span id="spanOfficeId"></span><br />
Grid X,Y: <span id="spanGridPoint"></span><br />
Music: <span id="musicTrack">Not playing</span><br /> Music: <span id="musicTrack">Not playing</span><br />
Ws4kp Version: <span><%- version %></span> Ws4kp Version: <span><%- version %></span>
</div> </div>
@@ -198,4 +201,4 @@
</body> </body>
</html> </html>