mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-17 09:09:30 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5431a04c7 | ||
|
|
5117a9d475 | ||
|
|
28baa022a9 | ||
|
|
e8b8890260 | ||
|
|
b797a10b9e | ||
|
|
2a64cda383 | ||
|
|
e6e357c51b | ||
|
|
24deb4dce4 | ||
|
|
f17f69f60e | ||
|
|
fa16095355 | ||
|
|
cc3dbeb043 | ||
|
|
8ee1e954eb | ||
|
|
bfc4bddfef | ||
|
|
567325e3c5 | ||
|
|
4903b95fec | ||
|
|
b43fb32820 | ||
|
|
0d0c4ec452 | ||
|
|
49d18c2fbe | ||
|
|
1732a3381f | ||
|
|
cc05aafb95 | ||
|
|
093b6ac239 | ||
|
|
12d068d740 | ||
|
|
517c560ef6 | ||
|
|
3eb571bed4 | ||
|
|
52ca161bdb | ||
|
|
ee5690dcad | ||
|
|
c05b827593 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
|
||||||
* Forecast Information text block from the very bottom of the web page
|
* Headend Information text block from the very bottom of the web page
|
||||||
|
|||||||
12
.vscode/launch.json
vendored
12
.vscode/launch.json
vendored
@@ -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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
3653
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ws4kp",
|
"name": "ws4kp",
|
||||||
"version": "6.1.4",
|
"version": "6.2.0",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
server/images/backgrounds/7-wide.png
Normal file
BIN
server/images/backgrounds/7-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
server/images/backgrounds/7.png
Normal file
BIN
server/images/backgrounds/7.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,5 +1,4 @@
|
|||||||
import { locationCleanup } from './utils/string.mjs';
|
import { locationCleanup } from './utils/string.mjs';
|
||||||
import { elemForEach } from './utils/elem.mjs';
|
|
||||||
import getCurrentWeather from './currentweather.mjs';
|
import getCurrentWeather from './currentweather.mjs';
|
||||||
import { currentDisplay } from './navigation.mjs';
|
import { currentDisplay } from './navigation.mjs';
|
||||||
import getHazards from './hazards.mjs';
|
import getHazards from './hazards.mjs';
|
||||||
@@ -12,6 +11,11 @@ const TICK_INTERVAL_MS = 500; // milliseconds per tick
|
|||||||
const secondsToTicks = (seconds) => Math.ceil((seconds * 1000) / TICK_INTERVAL_MS);
|
const secondsToTicks = (seconds) => Math.ceil((seconds * 1000) / TICK_INTERVAL_MS);
|
||||||
const DEFAULT_UPDATE = secondsToTicks(4.0); // 4 second default for each current conditions
|
const DEFAULT_UPDATE = secondsToTicks(4.0); // 4 second default for each current conditions
|
||||||
|
|
||||||
|
// items on page
|
||||||
|
const mainScroll = document.querySelector('#container>.scroll');
|
||||||
|
const fixedScroll = document.querySelector('#container>.scroll .fixed');
|
||||||
|
const header = document.querySelector('#container>.scroll .scroll-header');
|
||||||
|
|
||||||
// local variables
|
// local variables
|
||||||
let interval;
|
let interval;
|
||||||
let screenIndex = 0;
|
let screenIndex = 0;
|
||||||
@@ -23,6 +27,8 @@ let defaultScreensLoaded = true;
|
|||||||
// start drawing conditions
|
// start drawing conditions
|
||||||
// reset starts from the first item in the text scroll list
|
// reset starts from the first item in the text scroll list
|
||||||
const start = () => {
|
const start = () => {
|
||||||
|
// show the block
|
||||||
|
show();
|
||||||
// if already started, draw the screen on a reset flag and return
|
// if already started, draw the screen on a reset flag and return
|
||||||
if (interval) {
|
if (interval) {
|
||||||
if (resetFlag) drawScreen();
|
if (resetFlag) drawScreen();
|
||||||
@@ -62,6 +68,7 @@ const incrementInterval = (force) => {
|
|||||||
const display = currentDisplay();
|
const display = currentDisplay();
|
||||||
if (!display?.okToDrawCurrentConditions) {
|
if (!display?.okToDrawCurrentConditions) {
|
||||||
stop(display?.elemId === 'progress');
|
stop(display?.elemId === 'progress');
|
||||||
|
hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
screenIndex = (screenIndex + 1) % (workingScreens.length);
|
screenIndex = (screenIndex + 1) % (workingScreens.length);
|
||||||
@@ -91,12 +98,8 @@ const drawScreen = async () => {
|
|||||||
const thisScreen = workingScreens[screenIndex](scrollData);
|
const thisScreen = workingScreens[screenIndex](scrollData);
|
||||||
|
|
||||||
// update classes on the scroll area
|
// update classes on the scroll area
|
||||||
elemForEach('.weather-display .scroll', (elem) => {
|
mainScroll.classList.forEach((cls) => { if (cls !== 'scroll') mainScroll.classList.remove(cls); });
|
||||||
elem.classList.forEach((cls) => { if (cls !== 'scroll') elem.classList.remove(cls); });
|
thisScreen?.classes?.forEach((cls) => mainScroll.classList.add(cls));
|
||||||
// no scroll on progress
|
|
||||||
if (elem.parentElement.id === 'progress-html') return;
|
|
||||||
thisScreen?.classes?.forEach((cls) => elem.classList.add(cls));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof thisScreen === 'string') {
|
if (typeof thisScreen === 'string') {
|
||||||
// only a string
|
// only a string
|
||||||
@@ -125,9 +128,7 @@ const hazards = (data) => {
|
|||||||
// test for data
|
// test for data
|
||||||
if (!data.hazards || data.hazards.length === 0) return false;
|
if (!data.hazards || data.hazards.length === 0) return false;
|
||||||
|
|
||||||
// since the hazard scroll element has no left/right margins, pad the beginning and end with non-breaking spaces
|
const hazard = `${data.hazards[0].properties.event} ${data.hazards[0].properties.description}`;
|
||||||
const padding = ' '.repeat(4);
|
|
||||||
const hazard = `${padding}${data.hazards[0].properties.event} ${data.hazards[0].properties.description}${padding}`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: hazard,
|
text: hazard,
|
||||||
@@ -190,17 +191,12 @@ let workingScreens = [...baseScreens, ...additionalScreens];
|
|||||||
|
|
||||||
// internal draw function with preset parameters
|
// internal draw function with preset parameters
|
||||||
const drawCondition = (text) => {
|
const drawCondition = (text) => {
|
||||||
// update all html scroll elements
|
fixedScroll.innerHTML = text;
|
||||||
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
|
||||||
elem.innerHTML = text;
|
|
||||||
});
|
|
||||||
setHeader('');
|
setHeader('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const setHeader = (text) => {
|
const setHeader = (text) => {
|
||||||
elemForEach('.weather-display .scroll .scroll-header', (elem) => {
|
header.innerHTML = text ?? '';
|
||||||
elem.innerHTML = text ?? '';
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// reset the screens back to the original set
|
// reset the screens back to the original set
|
||||||
@@ -223,14 +219,14 @@ const drawScrollCondition = (screen) => {
|
|||||||
scrollElement.classList.add('scroll-area');
|
scrollElement.classList.add('scroll-area');
|
||||||
scrollElement.innerHTML = screen.text;
|
scrollElement.innerHTML = screen.text;
|
||||||
// add it to the page to get the width
|
// add it to the page to get the width
|
||||||
document.querySelector('.weather-display .scroll .fixed').innerHTML = scrollElement.outerHTML;
|
fixedScroll.innerHTML = scrollElement.outerHTML;
|
||||||
// grab the width
|
// grab the width
|
||||||
const { scrollWidth, clientWidth } = document.querySelector('.weather-display .scroll .fixed .scroll-area');
|
const { scrollWidth, clientWidth } = document.querySelector('#container>.scroll .fixed .scroll-area');
|
||||||
|
|
||||||
// calculate the scroll distance and set a minimum scroll
|
// calculate the scroll distance and set a minimum scroll
|
||||||
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
|
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
|
||||||
// calculate the scroll time (scaled by global speed setting)
|
// calculate the scroll time (scaled by global speed setting), minimum 2s (4s when added to start and end delays)
|
||||||
const scrollTime = scrollDistance / SCROLL_SPEED * settings.speed.value;
|
const scrollTime = Math.max(scrollDistance / SCROLL_SPEED * settings.speed.value, 2);
|
||||||
// add 1 second pause at the end of the scroll animation
|
// add 1 second pause at the end of the scroll animation
|
||||||
const endPauseTime = 1.0;
|
const endPauseTime = 1.0;
|
||||||
const totalAnimationTime = scrollTime + endPauseTime;
|
const totalAnimationTime = scrollTime + endPauseTime;
|
||||||
@@ -246,17 +242,13 @@ const drawScrollCondition = (screen) => {
|
|||||||
scrollElement.style.backfaceVisibility = 'hidden'; // Force hardware acceleration
|
scrollElement.style.backfaceVisibility = 'hidden'; // Force hardware acceleration
|
||||||
scrollElement.style.perspective = '1000px'; // Enable 3D rendering context
|
scrollElement.style.perspective = '1000px'; // Enable 3D rendering context
|
||||||
|
|
||||||
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
fixedScroll.innerHTML = '';
|
||||||
elem.innerHTML = '';
|
fixedScroll.append(scrollElement.cloneNode(true));
|
||||||
elem.append(scrollElement.cloneNode(true));
|
|
||||||
});
|
|
||||||
|
|
||||||
// start the scroll after the specified delay
|
// start the scroll after the specified delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// change the transform to trigger the scroll
|
// change the transform to trigger the scroll
|
||||||
elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => {
|
document.querySelector('#container>.scroll .fixed .scroll-area').style.transform = `translateX(-${scrollDistance.toFixed(0)}px)`;
|
||||||
elem.style.transform = `translateX(-${scrollDistance.toFixed(0)}px)`;
|
|
||||||
});
|
|
||||||
}, startDelayTime * 1000);
|
}, startDelayTime * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,9 +256,19 @@ const parseMessage = (event) => {
|
|||||||
if (event?.data?.type === 'current-weather-scroll') {
|
if (event?.data?.type === 'current-weather-scroll') {
|
||||||
if (event.data?.method === 'start') start();
|
if (event.data?.method === 'start') start();
|
||||||
if (event.data?.method === 'reload') stop(true);
|
if (event.data?.method === 'reload') stop(true);
|
||||||
|
if (event.data?.method === 'show') show();
|
||||||
|
if (event.data?.method === 'hide') hide();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
mainScroll.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
mainScroll.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
const screenCount = () => workingScreens.length;
|
const screenCount = () => workingScreens.length;
|
||||||
const atDefault = () => defaultScreensLoaded;
|
const atDefault = () => defaultScreensLoaded;
|
||||||
|
|
||||||
@@ -277,6 +279,8 @@ window.CurrentWeatherScroll = {
|
|||||||
addScreen,
|
addScreen,
|
||||||
reset,
|
reset,
|
||||||
start,
|
start,
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
screenCount,
|
screenCount,
|
||||||
atDefault,
|
atDefault,
|
||||||
};
|
};
|
||||||
@@ -285,6 +289,8 @@ export {
|
|||||||
addScreen,
|
addScreen,
|
||||||
reset,
|
reset,
|
||||||
start,
|
start,
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
screenCount,
|
screenCount,
|
||||||
atDefault,
|
atDefault,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ const handleNavButton = (button) => {
|
|||||||
break;
|
break;
|
||||||
case 'menu':
|
case 'menu':
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
|
postMessage({ type: 'current-weather-scroll', method: 'hide' });
|
||||||
if (progress) {
|
if (progress) {
|
||||||
progress.showCanvas();
|
progress.showCanvas();
|
||||||
} else if (settings?.kiosk?.value) {
|
} else if (settings?.kiosk?.value) {
|
||||||
@@ -357,6 +358,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 +388,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 +423,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 +464,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 +474,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 +532,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 +543,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);
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ class Progress extends WeatherDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async drawCanvas(displays, loadedCount) {
|
async drawCanvas(displays, loadedCount) {
|
||||||
|
// skip drawing if not displayed, or not yet available
|
||||||
if (!this.elem) return;
|
if (!this.elem) return;
|
||||||
|
if (this.elem.classList.contains('show') === false) return;
|
||||||
super.drawCanvas();
|
super.drawCanvas();
|
||||||
|
|
||||||
// get the progress bar cover (makes percentage)
|
// get the progress bar cover (makes percentage)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ class WeatherDisplay {
|
|||||||
if (this.screenIndex < 0) this.screenIndex = 0;
|
if (this.screenIndex < 0) this.screenIndex = 0;
|
||||||
if (this.okToDrawCurrentDateTime) this.drawCurrentDateTime();
|
if (this.okToDrawCurrentDateTime) this.drawCurrentDateTime();
|
||||||
if (this.okToDrawCurrentConditions) postMessage({ type: 'current-weather-scroll', method: 'start' });
|
if (this.okToDrawCurrentConditions) postMessage({ type: 'current-weather-scroll', method: 'start' });
|
||||||
|
if (this.okToDrawCurrentConditions === false) postMessage({ type: 'current-weather-scroll', method: 'hide' });
|
||||||
}
|
}
|
||||||
|
|
||||||
finishDraw() {
|
finishDraw() {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -112,29 +112,32 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
#container>.scroll {
|
||||||
@include u.text-shadow(3px, 1.5px);
|
display: none;
|
||||||
|
@include u.text-shadow(3px, 1.5px);
|
||||||
|
width: 640px;
|
||||||
|
height: 77px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 3px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0px;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&.hazard {
|
||||||
|
background-color: rgb(112, 35, 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container {
|
||||||
width: 640px;
|
width: 640px;
|
||||||
height: 70px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 3px;
|
|
||||||
|
|
||||||
&.hazard {
|
|
||||||
background-color: rgb(112, 35, 35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixed,
|
.fixed,
|
||||||
.scroll-header {
|
.scroll-header {
|
||||||
margin-left: 55px;
|
margin-left: 55px;
|
||||||
margin-right: 55px;
|
margin-right: 55px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
|
||||||
// Remove margins for hazard scrolls to maximize text space
|
|
||||||
&.hazard .fixed {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-header {
|
.scroll-header {
|
||||||
@@ -156,6 +159,17 @@
|
|||||||
// left: calc((elem width) - 640px);
|
// left: calc((elem width) - 640px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wide #container>.scroll {
|
||||||
|
width: 854px;
|
||||||
|
margin-left: -107px;
|
||||||
|
|
||||||
|
.scroll-container {
|
||||||
|
margin-left: 107px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@
|
|||||||
<div id="hazards-html" class="weather-display">
|
<div id="hazards-html" class="weather-display">
|
||||||
<%- include('partials/hazards.ejs') %>
|
<%- include('partials/hazards.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
|
<%- include('partials/scroll.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="divTwcBottom">
|
<div id="divTwcBottom">
|
||||||
@@ -185,7 +186,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='heading'>Forecast Information</div>
|
<div class='heading'>Headend Information</div>
|
||||||
<div id="divInfo">
|
<div id="divInfo">
|
||||||
Location: <span id="spanCity"></span> <span id="spanState"></span><br />
|
Location: <span id="spanCity"></span> <span id="spanState"></span><br />
|
||||||
Station Id: <span id="spanStationId"></span><br />
|
Station Id: <span id="spanStationId"></span><br />
|
||||||
|
|||||||
@@ -21,5 +21,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
@@ -1,43 +1,42 @@
|
|||||||
<%- include('header.ejs', {titleDual:{ top: 'Current' , bottom: 'Conditions' }, noaaLogo: true, hasTime: true}) %>
|
<%- include('header.ejs', {titleDual:{ top: 'Current' , bottom: 'Conditions' }, noaaLogo: true, hasTime: true}) %>
|
||||||
<div class="main has-scroll has-box current-weather">
|
<div class="main has-scroll has-box current-weather">
|
||||||
<div class="weather template">
|
<div class="weather template">
|
||||||
<div class="left col">
|
<div class="left col">
|
||||||
<div class="temp center"></div>
|
<div class="temp center"></div>
|
||||||
<div class="condition center"></div>
|
<div class="condition center"></div>
|
||||||
<div class="icon center"><img src="" /></div>
|
<div class="icon center"><img src="" /></div>
|
||||||
<div class="wind-container">
|
<div class="wind-container">
|
||||||
<div class="wind-label">Wind:</div>
|
<div class="wind-label">Wind:</div>
|
||||||
<div class="wind"></div>
|
<div class="wind"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wind-gusts"></div>
|
<div class="wind-gusts"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="right col">
|
<div class="right col">
|
||||||
<div class="location"></div>
|
<div class="location"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="label">Humidity:</div>
|
<div class="label">Humidity:</div>
|
||||||
<div class="humidity value"></div>
|
<div class="humidity value"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="label">Dewpoint:</div>
|
<div class="label">Dewpoint:</div>
|
||||||
<div class="dewpoint value"></div>
|
<div class="dewpoint value"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="label">Ceiling:</div>
|
<div class="label">Ceiling:</div>
|
||||||
<div class="ceiling value"></div>
|
<div class="ceiling value"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="label">Visibility:</div>
|
<div class="label">Visibility:</div>
|
||||||
<div class="visibility value"></div>
|
<div class="visibility value"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="label">Pressure:</div>
|
<div class="label">Pressure:</div>
|
||||||
<div class="pressure value"></div>
|
<div class="pressure value"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="heat-index-label label"></div>
|
<div class="heat-index-label label"></div>
|
||||||
<div class="heat-index value"></div>
|
<div class="heat-index value"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
@@ -19,5 +19,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
<%- include('header.ejs', {title: 'Hourly Graph' , hasTime: false }) %>
|
<%- include('header.ejs', {title: 'Hourly Graph' , hasTime: false }) %>
|
||||||
<div class="main has-scroll hourly-graph">
|
<div class="main has-scroll hourly-graph">
|
||||||
<div class="top-right template ">
|
<div class="top-right template ">
|
||||||
<div class="temperature">Temperature</div>
|
<div class="temperature">Temperature</div>
|
||||||
<div class="cloud">Cloud %</div>
|
<div class="cloud">Cloud %</div>
|
||||||
<div class="rain">Precip %</div>
|
<div class="rain">Precip %</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="y-axis">
|
<div class="y-axis">
|
||||||
<div class="label l-1">75</div>
|
<div class="label l-1">75</div>
|
||||||
<div class="label l-2">65</div>
|
<div class="label l-2">65</div>
|
||||||
<div class="label l-3">55</div>
|
<div class="label l-3">55</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<img id="chart-area"></img>
|
<img id="chart-area"></img>
|
||||||
</div>
|
</div>
|
||||||
<div class="x-axis">
|
<div class="x-axis">
|
||||||
<div class="label l-1">12a</div>
|
<div class="label l-1">12a</div>
|
||||||
<div class="label l-2">6a</div>
|
<div class="label l-2">6a</div>
|
||||||
<div class="label l-3">12p</div>
|
<div class="label l-3">12p</div>
|
||||||
<div class="label l-4">6p</div>
|
<div class="label l-4">6p</div>
|
||||||
<div class="label l-5">12a</div>
|
<div class="label l-5">12a</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
<%- include('header.ejs', {title: 'Hourly Forecast' , hasTime: true }) %>
|
<%- include('header.ejs', {title: 'Hourly Forecast' , hasTime: true }) %>
|
||||||
<div class="main has-scroll hourly">
|
<div class="main has-scroll hourly">
|
||||||
<div class="column-headers">
|
<div class="column-headers">
|
||||||
<div class="temp">TEMP</div>
|
<div class="temp">TEMP</div>
|
||||||
<div class="like">LIKE</div>
|
<div class="like">LIKE</div>
|
||||||
<div class="wind">WIND</div>
|
<div class="wind">WIND</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hourly-lines">
|
<div class="hourly-lines">
|
||||||
<div class="hourly-row template">
|
<div class="hourly-row template">
|
||||||
<div class="hour"></div>
|
<div class="hour"></div>
|
||||||
<div class="icon"><img /></div>
|
<div class="icon"><img /></div>
|
||||||
<div class="temp"></div>
|
<div class="temp"></div>
|
||||||
<div class="like"></div>
|
<div class="like"></div>
|
||||||
<div class="wind"></div>
|
<div class="wind"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
<%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true, hasTime: true }) %>
|
<%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true, hasTime: true }) %>
|
||||||
<div class="main has-scroll latest-observations has-box">
|
<div class="main has-scroll latest-observations has-box">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="column-headers">
|
<div class="column-headers">
|
||||||
<div class="temp english">°F</div>
|
<div class="temp english">°F</div>
|
||||||
<div class="temp metric">°C</div>
|
<div class="temp metric">°C</div>
|
||||||
<div class="weather">Weather</div>
|
<div class="weather">Weather</div>
|
||||||
<div class="wind">Wind</div>
|
<div class="wind">Wind</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="observation-lines">
|
<div class="observation-lines">
|
||||||
<div class="observation-row template">
|
<div class="observation-row template">
|
||||||
<div class="location"></div>
|
<div class="location"></div>
|
||||||
<div class="temp"></div>
|
<div class="temp"></div>
|
||||||
<div class="weather"></div>
|
<div class="weather"></div>
|
||||||
<div class="wind"></div>
|
<div class="wind"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
<%- include('header.ejs', {titleDual:{ top: 'Regional' , bottom: 'Observations' }, hasTime: true }) %>
|
<%- include('header.ejs', {titleDual:{ top: 'Regional' , bottom: 'Observations' }, hasTime: true }) %>
|
||||||
<div class="main has-scroll regional-forecast">
|
<div class="main has-scroll regional-forecast">
|
||||||
<div class="map"><img src="images/maps/basemap.webp" /></div>
|
<div class="map"><img src="images/maps/basemap.webp" /></div>
|
||||||
<div class="location-container">
|
<div class="location-container">
|
||||||
<div class="location template">
|
<div class="location template">
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<img src="" />
|
<img src="" />
|
||||||
</div>
|
</div>
|
||||||
<div class="city"></div>
|
<div class="city"></div>
|
||||||
<div class="temp"></div>
|
<div class="temp"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<div class="scrolling template"></div>
|
<div class="scroll-container">
|
||||||
<div class="scroll-header"></div>
|
<div class="scroll-header"></div>
|
||||||
<div class="fixed"></div>
|
<div class="fixed"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
<%- include('header.ejs', {titleDual:{ top: 'Storm Prediction' , bottom: 'Center Outlook' }, hasTime: true}) %>
|
<%- include('header.ejs', {titleDual:{ top: 'Storm Prediction' , bottom: 'Center Outlook' }, hasTime: true}) %>
|
||||||
<div class="main has-scroll spc-outlook">
|
<div class="main has-scroll spc-outlook">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="risk-levels">
|
<div class="risk-levels">
|
||||||
<div class="risk-level">High</div>
|
<div class="risk-level">High</div>
|
||||||
<div class="risk-level">Moderate</div>
|
<div class="risk-level">Moderate</div>
|
||||||
<div class="risk-level">Enhanced</div>
|
<div class="risk-level">Enhanced</div>
|
||||||
<div class="risk-level">Slight</div>
|
<div class="risk-level">Slight</div>
|
||||||
<div class="risk-level">Marginal</div>
|
<div class="risk-level">Marginal</div>
|
||||||
<div class="risk-level">T'Storm</div>
|
<div class="risk-level">T'Storm</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="days">
|
<div class="days">
|
||||||
<div class="day template">
|
<div class="day template">
|
||||||
<div class="day-name">Monday</div>
|
<div class="day-name">Monday</div>
|
||||||
<div class="risk-bar"></div>
|
<div class="risk-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
@@ -12,5 +12,4 @@
|
|||||||
<div class="temp high"></div>
|
<div class="temp high"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%- include('scroll.ejs') %>
|
|
||||||
Reference in New Issue
Block a user