mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-15 08:09:31 -07:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57b9bcb20 | ||
|
|
e27750e915 | ||
|
|
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 | ||
|
|
bef42a3da2 | ||
|
|
13ff0317e6 | ||
|
|
5cc85840a9 | ||
|
|
190e50e2f3 | ||
|
|
aa7ac64827 | ||
|
|
2ab737d5a5 | ||
|
|
ecf0999675 | ||
|
|
6a49b7b6ce |
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:
|
||||
* Web browser and OS
|
||||
* Location for which you are viewing a forecast
|
||||
* 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": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"args": [
|
||||
"--use-cache"
|
||||
],
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"name": "Data:stations-api",
|
||||
"program": "${workspaceFolder}/datagenerators/stations.mjs",
|
||||
"request": "launch",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY . .
|
||||
|
||||
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
|
||||
* 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?
|
||||
|
||||
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
|
||||
* Used by: static file hosting and default `Dockerfile`
|
||||
|
||||
## Run Your WeatherStar
|
||||
|
||||
Ensure you have Node installed. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/netbymatt/ws4kp.git
|
||||
cd ws4kp
|
||||
npm install
|
||||
```
|
||||
## Other methods to run Ws4kp
|
||||
|
||||
### Development Mode (individual JS files, easier debugging)
|
||||
```bash
|
||||
@@ -309,11 +314,13 @@ If you're unable to pre-set the play state before entering kiosk mode (such as w
|
||||
|
||||
## 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)
|
||||
* [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.
|
||||
* [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
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
"lat": 29.7633,
|
||||
"lon": -95.3633,
|
||||
"point": {
|
||||
"x": 65,
|
||||
"y": 97,
|
||||
"x": 63,
|
||||
"y": 95,
|
||||
"wfo": "HGX"
|
||||
}
|
||||
},
|
||||
@@ -230,7 +230,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"city": "Washington DC",
|
||||
"city": "Washington",
|
||||
"lat": 38.8951,
|
||||
"lon": -77.0364,
|
||||
"point": {
|
||||
@@ -274,7 +274,7 @@
|
||||
"lat": 61.2181,
|
||||
"lon": -149.9003,
|
||||
"point": {
|
||||
"x": 125,
|
||||
"x": 143,
|
||||
"y": 236,
|
||||
"wfo": "AER"
|
||||
}
|
||||
@@ -734,8 +734,8 @@
|
||||
"lat": 42.9956,
|
||||
"lon": -71.4548,
|
||||
"point": {
|
||||
"x": 42,
|
||||
"y": 21,
|
||||
"x": 38,
|
||||
"y": 20,
|
||||
"wfo": "GYX"
|
||||
}
|
||||
},
|
||||
@@ -884,8 +884,8 @@
|
||||
"lat": 43.6615,
|
||||
"lon": -70.2553,
|
||||
"point": {
|
||||
"x": 76,
|
||||
"y": 59,
|
||||
"x": 72,
|
||||
"y": 58,
|
||||
"wfo": "GYX"
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -115,7 +115,7 @@
|
||||
"lon": -82.5329
|
||||
},
|
||||
{
|
||||
"city": "Washington DC",
|
||||
"city": "Washington",
|
||||
"lat": 38.8951,
|
||||
"lon": -77.0364
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
import * as url from 'node:url';
|
||||
|
||||
// Load station data
|
||||
const stationInfo = JSON.parse(readFileSync('./datagenerators/output/stations-raw.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}'
|
||||
*/
|
||||
|
||||
const diffMode = process.argv.includes('--diff');
|
||||
const onlyProblems = process.argv.includes('--only-problems');
|
||||
const noProblems = process.argv.includes('--no-problems');
|
||||
const onlyDuplicates = process.argv.includes('--only-dupes');
|
||||
const noPriority = process.argv.includes('--no-priority');
|
||||
const noSimple = process.argv.includes('--no-simple');
|
||||
const noCoordinates = process.argv.includes('--no-coords');
|
||||
const writeFile = process.argv.includes('--write');
|
||||
const readArguments = () => ({
|
||||
diffMode: process.argv.includes('--diff'),
|
||||
onlyProblems: process.argv.includes('--only-problems'),
|
||||
noProblems: process.argv.includes('--no-problems'),
|
||||
onlyDuplicates: process.argv.includes('--only-dupes'),
|
||||
noPriority: process.argv.includes('--no-priority'),
|
||||
noSimple: process.argv.includes('--no-simple'),
|
||||
noCoordinates: process.argv.includes('--no-coords'),
|
||||
writeFile: process.argv.includes('--write'),
|
||||
});
|
||||
|
||||
// Process ALL stations at once to get the display name map
|
||||
let displayNameMap = processAllStations(stationInfo);
|
||||
const DEFAULT_OPTIONS = {
|
||||
diffMode: false,
|
||||
onlyProblems: false,
|
||||
noProblems: false,
|
||||
onlyDuplicates: false,
|
||||
noPriority: false,
|
||||
noSimple: false,
|
||||
noCoordinates: false,
|
||||
writeFile: false,
|
||||
};
|
||||
|
||||
// Apply priority-based deduplication
|
||||
displayNameMap = resolveDuplicatesByPriority(displayNameMap, stationInfo);
|
||||
const postProcessor = (_options) => {
|
||||
// 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
|
||||
const stations = Object.values(stationInfo);
|
||||
stations.forEach((station) => {
|
||||
const originalName = station.city;
|
||||
const processedName = processingUtils.finalCleanup(displayNameMap[station.id]); // Look up by station ID
|
||||
// Apply priority-based deduplication
|
||||
displayNameMap = resolveDuplicatesByPriority(displayNameMap, stationInfo);
|
||||
|
||||
// Get airport type and priority for this station
|
||||
const airportType = getAirportType(originalName, station.id); // Pass station ID for enhanced detection
|
||||
const priority = getAirportPriority(airportType);
|
||||
const results = [];
|
||||
|
||||
const potentialIssues = [];
|
||||
// Check if the processed name contains punctuation (a period at the end is OK)
|
||||
if (/[,;!?/:.]/.test(processedName) && !processedName.endsWith('.')) {
|
||||
potentialIssues.push('punctuation');
|
||||
}
|
||||
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');
|
||||
}
|
||||
// Now iterate through stations and use the pre-computed display names
|
||||
const stations = Object.values(stationInfo);
|
||||
stations.forEach((station) => {
|
||||
const originalName = station.city;
|
||||
const processedName = processingUtils.finalCleanup(displayNameMap[station.id]); // Look up by station ID
|
||||
|
||||
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,
|
||||
// Get airport type and priority for this station
|
||||
const airportType = getAirportType(originalName, station.id); // Pass station ID for enhanced detection
|
||||
const priority = getAirportPriority(airportType);
|
||||
|
||||
const potentialIssues = [];
|
||||
// Check if the processed name contains punctuation (a period at the end is OK)
|
||||
if (/[,;!?/:.]/.test(processedName) && !processedName.endsWith('.')) {
|
||||
potentialIssues.push('punctuation');
|
||||
}
|
||||
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({
|
||||
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
|
||||
const cleanedMapByState = new Map();
|
||||
// Check for duplicates by state
|
||||
const cleanedMapByState = new Map();
|
||||
|
||||
results.forEach((result) => {
|
||||
const { state } = result;
|
||||
if (!cleanedMapByState.has(state)) {
|
||||
cleanedMapByState.set(state, new Map());
|
||||
}
|
||||
const stateMap = cleanedMapByState.get(state);
|
||||
if (stateMap.has(result.city)) {
|
||||
stateMap.get(result.city).push(result);
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
});
|
||||
results.forEach((result) => {
|
||||
const { state } = result;
|
||||
if (!cleanedMapByState.has(state)) {
|
||||
cleanedMapByState.set(state, new Map());
|
||||
}
|
||||
const stateMap = cleanedMapByState.get(state);
|
||||
if (stateMap.has(result.city)) {
|
||||
stateMap.get(result.city).push(result);
|
||||
} else {
|
||||
stateMap.set(result.city, [result]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Filter results if requested
|
||||
let finalResults = results;
|
||||
if (onlyProblems) {
|
||||
finalResults = results.filter((r) => r.potentialIssues.length > 0);
|
||||
}
|
||||
if (onlyDuplicates) {
|
||||
finalResults = finalResults.filter((r) => r.potentialIssues.includes('duplicate'));
|
||||
}
|
||||
cleanedMapByState.forEach((stateMap, _state) => {
|
||||
stateMap.forEach((originals, _cleaned) => {
|
||||
if (originals.length > 1) {
|
||||
originals.forEach((original) => {
|
||||
if (!original.potentialIssues.includes('duplicate')) {
|
||||
original.potentialIssues.push('duplicate');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const outputResult = finalResults.map((result) => {
|
||||
let outputItem = result;
|
||||
|
||||
// Don't include lat or long in diff mode
|
||||
if (noCoordinates || diffMode) {
|
||||
const {
|
||||
lat: _lat, lon: _lon, ...resultWithoutLocation
|
||||
} = result;
|
||||
outputItem = resultWithoutLocation;
|
||||
// Filter results if requested
|
||||
let finalResults = results;
|
||||
if (options.onlyProblems) {
|
||||
finalResults = results.filter((r) => r.potentialIssues.length > 0);
|
||||
}
|
||||
if (options.onlyDuplicates) {
|
||||
finalResults = finalResults.filter((r) => r.potentialIssues.includes('duplicate'));
|
||||
}
|
||||
|
||||
// Don't include potentialIssues when --no-problems is specified
|
||||
if (noProblems || diffMode) {
|
||||
const { potentialIssues: _potentialIssues, ...resultWithoutIssues } = outputItem;
|
||||
outputItem = resultWithoutIssues;
|
||||
}
|
||||
const outputResult = finalResults.map((result) => {
|
||||
let outputItem = result;
|
||||
|
||||
// Remove type and priority if --no-priority is specified
|
||||
if (noPriority || diffMode) {
|
||||
const { type: _type, priority: _priority, ...resultWithoutPriority } = outputItem;
|
||||
outputItem = resultWithoutPriority;
|
||||
}
|
||||
// Don't include lat or long in diff mode
|
||||
if (options.noCoordinates || options.diffMode) {
|
||||
const {
|
||||
lat: _lat, lon: _lon, ...resultWithoutLocation
|
||||
} = result;
|
||||
outputItem = resultWithoutLocation;
|
||||
}
|
||||
|
||||
// remove simple field if --no-simple is specified
|
||||
if (noSimple || diffMode) {
|
||||
const { simple: _simple, ...resultWithoutSimple } = outputItem;
|
||||
outputItem = resultWithoutSimple;
|
||||
}
|
||||
// Don't include potentialIssues when --no-problems is specified
|
||||
if (options.noProblems || options.diffMode) {
|
||||
const { potentialIssues: _potentialIssues, ...resultWithoutIssues } = outputItem;
|
||||
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(({
|
||||
simple: _simple, type: _type, potentialIssues: _potentialIssues, ...rest
|
||||
simple: _simple, type: _type, potentialIssues: _potentialIssues, location: _location, ...rest
|
||||
}) => rest);
|
||||
|
||||
writeFileSync('./datagenerators/output/stations.json', compactStringifyToObject(fileResults));
|
||||
console.log(`Wrote ${fileResults.length} processed stations to datagenerators/output/stations.json`);
|
||||
} else {
|
||||
console.log(compactStringifyToArray(outputResult));
|
||||
if (options.writeFile) {
|
||||
writeFileSync('./datagenerators/output/stations.json', compactStringifyToObject(fileResults));
|
||||
console.log(`Wrote ${fileResults.length} processed stations to datagenerators/output/stations.json`);
|
||||
} 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
|
||||
// only find stations with 4 letter codes
|
||||
|
||||
@@ -6,67 +7,91 @@ import https from './https.mjs';
|
||||
import states from './stations-states.mjs';
|
||||
import chunk from './chunk.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
|
||||
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
|
||||
const chunkStates = chunk(states, 1);
|
||||
const chunkStates = chunk(states, 3);
|
||||
|
||||
// store output
|
||||
const output = {};
|
||||
let completed = 0;
|
||||
|
||||
// process all chunks
|
||||
for (let i = 0; i < chunkStates.length; i += 1) {
|
||||
const stateChunk = chunkStates[i];
|
||||
// loop through states
|
||||
// get data from api if desired
|
||||
if (!USE_CACHE) {
|
||||
// process all chunks
|
||||
for (let i = 0; i < chunkStates.length; i += 1) {
|
||||
const stateChunk = chunkStates[i];
|
||||
// loop through states
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.allSettled(stateChunk.map(async (state) => {
|
||||
try {
|
||||
let stations;
|
||||
let next = `https://api.weather.gov/stations?state=${state}`;
|
||||
let round = 0;
|
||||
do {
|
||||
console.log(`Getting: ${state}-${round}`);
|
||||
// get list and parse the JSON
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const stationsRaw = await https(next);
|
||||
stations = JSON.parse(stationsRaw);
|
||||
// filter stations for 4 letter identifiers
|
||||
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
|
||||
// filter against starting letter
|
||||
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
|
||||
// add each resulting station to the output
|
||||
stationsFiltered.forEach((station) => {
|
||||
const id = station.properties.stationIdentifier;
|
||||
if (output[id]) {
|
||||
console.log(`Duplicate station: ${state}-${id}`);
|
||||
return;
|
||||
}
|
||||
// get any overrides if available
|
||||
const override = overrides[id] ?? {};
|
||||
output[id] = {
|
||||
id,
|
||||
city: station.properties.name,
|
||||
state,
|
||||
lat: station.geometry.coordinates[1],
|
||||
lon: station.geometry.coordinates[0],
|
||||
// finally add the overrides
|
||||
...override,
|
||||
};
|
||||
});
|
||||
next = stations?.pagination?.next;
|
||||
round += 1;
|
||||
// write the output
|
||||
writeFileSync('./datagenerators/output/stations-raw.json', JSON.stringify(output, null, 2));
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.allSettled(stateChunk.map(async (state) => {
|
||||
try {
|
||||
let stations;
|
||||
let next = `https://api.weather.gov/stations?state=${state}`;
|
||||
let round = 0;
|
||||
do {
|
||||
console.log(`Getting: ${state}-${round}`);
|
||||
// get list and parse the JSON
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const stationsRaw = await https(next);
|
||||
stations = JSON.parse(stationsRaw);
|
||||
// filter stations for 4 letter identifiers
|
||||
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
|
||||
// filter against starting letter
|
||||
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
|
||||
// add each resulting station to the output
|
||||
stationsFiltered.forEach((station) => {
|
||||
const id = station.properties.stationIdentifier;
|
||||
if (output[id]) {
|
||||
console.log(`Duplicate station: ${state}-${id}`);
|
||||
return;
|
||||
}
|
||||
output[id] = {
|
||||
id,
|
||||
city: station.properties.name,
|
||||
state,
|
||||
lat: station.geometry.coordinates[1],
|
||||
lon: station.geometry.coordinates[0],
|
||||
};
|
||||
});
|
||||
next = stations?.pagination?.next;
|
||||
round += 1;
|
||||
// write the output
|
||||
writeFileSync('./datagenerators/output/stations-raw.json', JSON.stringify(output, null, 2));
|
||||
}
|
||||
while (next && stations.features.length > 0);
|
||||
completed += 1;
|
||||
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 = [
|
||||
'views/*.ejs',
|
||||
];
|
||||
const compressHtml = async () => {
|
||||
const packageJson = await readFile('package.json');
|
||||
const { version } = JSON.parse(packageJson);
|
||||
|
||||
return src(htmlSources)
|
||||
.pipe(ejs({
|
||||
production: version,
|
||||
serverAvailable: false,
|
||||
version,
|
||||
OVERRIDES,
|
||||
query: {},
|
||||
}))
|
||||
.pipe(rename({ extname: '.html' }))
|
||||
.pipe(htmlmin({ collapseWhitespace: true }))
|
||||
.pipe(dest('./dist'));
|
||||
const packageJson = await readFile('package.json');
|
||||
let { version } = JSON.parse(packageJson);
|
||||
const previewVersion = async () => {
|
||||
// generate a relatively unique timestamp for cache invalidation of the preview site
|
||||
const now = new Date();
|
||||
const msNow = now.getTime() % 1_000_000;
|
||||
version = msNow.toString();
|
||||
};
|
||||
|
||||
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 = [
|
||||
'server/robots.txt',
|
||||
'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
|
||||
// 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 stageFrontend = series(buildDist, uploadImagesPreview, uploadPreview, invalidatePreview);
|
||||
const stageFrontend = series(previewVersion, buildDist, uploadImagesPreview, uploadPreview, invalidatePreview);
|
||||
|
||||
export default publishFrontend;
|
||||
|
||||
|
||||
4123
package-lock.json
generated
4123
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "6.1.0",
|
||||
"version": "6.2.1",
|
||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
@@ -56,6 +56,7 @@
|
||||
"dotenv": "^17.0.1",
|
||||
"ejs": "^3.1.5",
|
||||
"express": "^5.1.0",
|
||||
"metar-taf-parser": "^6.1.2"
|
||||
"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 |
@@ -145,6 +145,8 @@ const init = async () => {
|
||||
document.querySelector('#spanStationId').innerHTML = '';
|
||||
document.querySelector('#spanRadarId').innerHTML = '';
|
||||
document.querySelector('#spanZoneId').innerHTML = '';
|
||||
document.querySelector('#spanOfficeId').innerHTML = '';
|
||||
document.querySelector('#spanGridPoint').innerHTML = '';
|
||||
|
||||
localStorage.removeItem('play');
|
||||
postMessage('navButton', 'play');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { locationCleanup } from './utils/string.mjs';
|
||||
import { elemForEach } from './utils/elem.mjs';
|
||||
import getCurrentWeather from './currentweather.mjs';
|
||||
import { currentDisplay } from './navigation.mjs';
|
||||
import getHazards from './hazards.mjs';
|
||||
@@ -12,6 +11,16 @@ const TICK_INTERVAL_MS = 500; // milliseconds per tick
|
||||
const secondsToTicks = (seconds) => Math.ceil((seconds * 1000) / TICK_INTERVAL_MS);
|
||||
const DEFAULT_UPDATE = secondsToTicks(4.0); // 4 second default for each current conditions
|
||||
|
||||
// items on page
|
||||
let mainScroll;
|
||||
let fixedScroll;
|
||||
let header;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
mainScroll = document.querySelector('#container>.scroll');
|
||||
fixedScroll = document.querySelector('#container>.scroll .fixed');
|
||||
header = document.querySelector('#container>.scroll .scroll-header');
|
||||
});
|
||||
|
||||
// local variables
|
||||
let interval;
|
||||
let screenIndex = 0;
|
||||
@@ -23,6 +32,8 @@ let defaultScreensLoaded = true;
|
||||
// start drawing conditions
|
||||
// reset starts from the first item in the text scroll list
|
||||
const start = () => {
|
||||
// show the block
|
||||
show();
|
||||
// if already started, draw the screen on a reset flag and return
|
||||
if (interval) {
|
||||
if (resetFlag) drawScreen();
|
||||
@@ -62,6 +73,7 @@ const incrementInterval = (force) => {
|
||||
const display = currentDisplay();
|
||||
if (!display?.okToDrawCurrentConditions) {
|
||||
stop(display?.elemId === 'progress');
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
screenIndex = (screenIndex + 1) % (workingScreens.length);
|
||||
@@ -91,12 +103,8 @@ const drawScreen = async () => {
|
||||
const thisScreen = workingScreens[screenIndex](scrollData);
|
||||
|
||||
// update classes on the scroll area
|
||||
elemForEach('.weather-display .scroll', (elem) => {
|
||||
elem.classList.forEach((cls) => { if (cls !== 'scroll') elem.classList.remove(cls); });
|
||||
// no scroll on progress
|
||||
if (elem.parentElement.id === 'progress-html') return;
|
||||
thisScreen?.classes?.forEach((cls) => elem.classList.add(cls));
|
||||
});
|
||||
mainScroll.classList.forEach((cls) => { if (cls !== 'scroll') mainScroll.classList.remove(cls); });
|
||||
thisScreen?.classes?.forEach((cls) => mainScroll.classList.add(cls));
|
||||
|
||||
if (typeof thisScreen === 'string') {
|
||||
// only a string
|
||||
@@ -125,9 +133,7 @@ const hazards = (data) => {
|
||||
// test for data
|
||||
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 padding = ' '.repeat(4);
|
||||
const hazard = `${padding}${data.hazards[0].properties.event} ${data.hazards[0].properties.description}${padding}`;
|
||||
const hazard = `${data.hazards[0].properties.event} ${data.hazards[0].properties.description}`;
|
||||
|
||||
return {
|
||||
text: hazard,
|
||||
@@ -190,17 +196,12 @@ let workingScreens = [...baseScreens, ...additionalScreens];
|
||||
|
||||
// internal draw function with preset parameters
|
||||
const drawCondition = (text) => {
|
||||
// update all html scroll elements
|
||||
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
||||
elem.innerHTML = text;
|
||||
});
|
||||
fixedScroll.innerHTML = text;
|
||||
setHeader('');
|
||||
};
|
||||
|
||||
const setHeader = (text) => {
|
||||
elemForEach('.weather-display .scroll .scroll-header', (elem) => {
|
||||
elem.innerHTML = text ?? '';
|
||||
});
|
||||
header.innerHTML = text ?? '';
|
||||
};
|
||||
|
||||
// reset the screens back to the original set
|
||||
@@ -223,14 +224,14 @@ const drawScrollCondition = (screen) => {
|
||||
scrollElement.classList.add('scroll-area');
|
||||
scrollElement.innerHTML = screen.text;
|
||||
// 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
|
||||
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
|
||||
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
|
||||
// calculate the scroll time (scaled by global speed setting)
|
||||
const scrollTime = scrollDistance / SCROLL_SPEED * settings.speed.value;
|
||||
// calculate the scroll time (scaled by global speed setting), minimum 2s (4s when added to start and end delays)
|
||||
const scrollTime = Math.max(scrollDistance / SCROLL_SPEED * settings.speed.value, 2);
|
||||
// add 1 second pause at the end of the scroll animation
|
||||
const endPauseTime = 1.0;
|
||||
const totalAnimationTime = scrollTime + endPauseTime;
|
||||
@@ -246,17 +247,13 @@ const drawScrollCondition = (screen) => {
|
||||
scrollElement.style.backfaceVisibility = 'hidden'; // Force hardware acceleration
|
||||
scrollElement.style.perspective = '1000px'; // Enable 3D rendering context
|
||||
|
||||
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
||||
elem.innerHTML = '';
|
||||
elem.append(scrollElement.cloneNode(true));
|
||||
});
|
||||
fixedScroll.innerHTML = '';
|
||||
fixedScroll.append(scrollElement.cloneNode(true));
|
||||
|
||||
// start the scroll after the specified delay
|
||||
setTimeout(() => {
|
||||
// change the transform to trigger the scroll
|
||||
elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => {
|
||||
elem.style.transform = `translateX(-${scrollDistance.toFixed(0)}px)`;
|
||||
});
|
||||
document.querySelector('#container>.scroll .fixed .scroll-area').style.transform = `translateX(-${scrollDistance.toFixed(0)}px)`;
|
||||
}, startDelayTime * 1000);
|
||||
};
|
||||
|
||||
@@ -264,9 +261,19 @@ const parseMessage = (event) => {
|
||||
if (event?.data?.type === 'current-weather-scroll') {
|
||||
if (event.data?.method === 'start') start();
|
||||
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 atDefault = () => defaultScreensLoaded;
|
||||
|
||||
@@ -277,6 +284,8 @@ window.CurrentWeatherScroll = {
|
||||
addScreen,
|
||||
reset,
|
||||
start,
|
||||
show,
|
||||
hide,
|
||||
screenCount,
|
||||
atDefault,
|
||||
};
|
||||
@@ -285,6 +294,8 @@ export {
|
||||
addScreen,
|
||||
reset,
|
||||
start,
|
||||
show,
|
||||
hide,
|
||||
screenCount,
|
||||
atDefault,
|
||||
};
|
||||
|
||||
@@ -137,10 +137,6 @@ const parse = (fullForecast, forecastUrl) => {
|
||||
}
|
||||
// get the object to modify/populate
|
||||
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
|
||||
preloadImg(fDay.icon);
|
||||
@@ -148,6 +144,9 @@ const parse = (fullForecast, forecastUrl) => {
|
||||
if (period.isDaytime) {
|
||||
// day time is the high 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
|
||||
} else {
|
||||
// low temperature
|
||||
|
||||
@@ -71,6 +71,7 @@ class Hazards extends WeatherDisplay {
|
||||
// get the forecast using centralized safe handling
|
||||
const url = new URL('https://api.weather.gov/alerts/active');
|
||||
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() });
|
||||
|
||||
if (!alerts) {
|
||||
@@ -103,7 +104,10 @@ class Hazards extends WeatherDisplay {
|
||||
// show alert indicator
|
||||
if (unViewed > 0) alert.classList.add('show');
|
||||
// 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) {
|
||||
console.error(`Unexpected Active Alerts error: ${error.message}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
@@ -115,7 +119,7 @@ class Hazards extends WeatherDisplay {
|
||||
this.getDataCallback();
|
||||
|
||||
if (!superResult) {
|
||||
this.setStatus(STATUS.loaded);
|
||||
// Don't override status - super.getData() already set it to STATUS.disabled
|
||||
return;
|
||||
}
|
||||
this.drawLongCanvas();
|
||||
|
||||
@@ -111,7 +111,7 @@ const getWeather = async (latLon, haveDataCallback) => {
|
||||
weatherParameters.stations = stations.features;
|
||||
|
||||
// update the main process for display purposes
|
||||
populateWeatherParameters(weatherParameters);
|
||||
populateWeatherParameters(weatherParameters, point.properties);
|
||||
|
||||
// reset the scroll
|
||||
postMessage({ type: 'current-weather-scroll', method: 'reload' });
|
||||
@@ -330,6 +330,7 @@ const handleNavButton = (button) => {
|
||||
break;
|
||||
case 'menu':
|
||||
setPlaying(false);
|
||||
postMessage({ type: 'current-weather-scroll', method: 'hide' });
|
||||
if (progress) {
|
||||
progress.showCanvas();
|
||||
} else if (settings?.kiosk?.value) {
|
||||
@@ -357,6 +358,17 @@ const isIOS = () => {
|
||||
let lastAppliedScale = null;
|
||||
let lastAppliedKioskMode = null;
|
||||
|
||||
// Helper function to clear CSS properties from elements
|
||||
const clearElementStyles = (element, properties) => {
|
||||
properties.forEach((prop) => element.style.removeProperty(prop));
|
||||
};
|
||||
|
||||
// Define property groups for different scaling modes
|
||||
const SCALING_PROPERTIES = {
|
||||
wrapper: ['width', 'height', 'transform', 'transform-origin'],
|
||||
positioning: ['transform', 'transform-origin', 'width', 'height', 'position', 'left', 'top', 'margin-left', 'margin-top'],
|
||||
};
|
||||
|
||||
// resize the container on a page resize
|
||||
const resize = (force = false) => {
|
||||
// Ignore resize events caused by pinch-to-zoom on mobile
|
||||
@@ -376,9 +388,8 @@ const resize = (force = false) => {
|
||||
// Standard scaling: fit within both dimensions
|
||||
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
||||
|
||||
// For Mobile Safari in kiosk mode, always use centering behavior regardless of scale
|
||||
// For other platforms, only use fullscreen/centering behavior for actual fullscreen or kiosk mode where content fits naturally
|
||||
const isKioskLike = isFullscreen || (isKioskMode && scale >= 1.0) || isMobileSafariKiosk;
|
||||
// Use centering behavior for fullscreen, kiosk mode, or Mobile Safari kiosk mode
|
||||
const isKioskLike = isFullscreen || isKioskMode || isMobileSafariKiosk;
|
||||
|
||||
if (debugFlag('resize') || debugFlag('fullscreen')) {
|
||||
console.log(`🖥️ Resize: force=${force} isKioskLike=${isKioskLike} window=${window.innerWidth}x${window.innerHeight} targetWidth=${targetWidth} widthZoom=${widthZoomPercent.toFixed(3)} heightZoom=${heightZoomPercent.toFixed(3)} finalScale=${scale.toFixed(3)} fullscreenElement=${!!document.fullscreenElement} isIOS=${isIOS()} standalone=${window.navigator.standalone} isMobileSafariKiosk=${isMobileSafariKiosk} kioskMode=${settings.kiosk?.value} wideMode=${settings.wide.value}`);
|
||||
@@ -412,40 +423,35 @@ const resize = (force = false) => {
|
||||
console.log('🖥️ Resetting fullscreen/kiosk styles to normal');
|
||||
}
|
||||
|
||||
// Reset wrapper styles (only properties that are actually set in fullscreen/scaling modes)
|
||||
wrapper.style.removeProperty('width');
|
||||
wrapper.style.removeProperty('height');
|
||||
wrapper.style.removeProperty('overflow');
|
||||
wrapper.style.removeProperty('transform');
|
||||
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');
|
||||
// Reset all scaling-related styles
|
||||
const container = document.querySelector('#container');
|
||||
clearElementStyles(wrapper, SCALING_PROPERTIES.wrapper);
|
||||
clearElementStyles(container, SCALING_PROPERTIES.positioning);
|
||||
clearElementStyles(mainContainer, SCALING_PROPERTIES.positioning);
|
||||
|
||||
applyScanlineScaling(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// MOBILE SCALING: Use wrapper scaling for mobile devices (but not Mobile Safari kiosk mode)
|
||||
if ((scale < 1.0 || (isKioskMode && !isKioskLike)) && !isMobileSafariKiosk) {
|
||||
// MOBILE SCALING: Use wrapper scaling for mobile devices (but not when in fullscreen/kiosk mode)
|
||||
if ((scale < 1.0 || (isKioskMode && !isKioskLike)) && !isMobileSafariKiosk && !isKioskLike) {
|
||||
/*
|
||||
* MOBILE SCALING (Wrapper Scaling)
|
||||
*
|
||||
* This path is used for regular mobile browsing (NOT fullscreen/kiosk modes).
|
||||
* Why scale the wrapper instead of mainContainer?
|
||||
* - For mobile devices where content is larger than viewport, we need to scale the entire layout
|
||||
* - The wrapper (#divTwc) contains both the main content AND the bottom navigation bar
|
||||
* - Scaling the wrapper ensures both elements are scaled together as a unit
|
||||
* - 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
|
||||
*/
|
||||
|
||||
// Reset any container/mainContainer styles that might have been set during fullscreen/kiosk mode
|
||||
const container = document.querySelector('#container');
|
||||
clearElementStyles(container, SCALING_PROPERTIES.positioning);
|
||||
clearElementStyles(mainContainer, SCALING_PROPERTIES.positioning);
|
||||
|
||||
wrapper.style.setProperty('transform', `scale(${scale})`);
|
||||
wrapper.style.setProperty('transform-origin', 'top left'); // Scale from top-left corner
|
||||
|
||||
@@ -458,7 +464,7 @@ const resize = (force = false) => {
|
||||
const scaledHeight = totalHeight * scale; // Height after scaling
|
||||
|
||||
wrapper.style.setProperty('width', `${wrapperWidth}px`);
|
||||
wrapper.style.setProperty('height', `${scaledHeight}px`); // Use scaled height to eliminate gap
|
||||
wrapper.style.setProperty('height', `${scaledHeight}px`); // Use scaled height to eliminate gap under #divTwc on index page
|
||||
applyScanlineScaling(scale);
|
||||
return;
|
||||
}
|
||||
@@ -468,10 +474,7 @@ const resize = (force = false) => {
|
||||
const wrapperHeight = 480;
|
||||
|
||||
// Reset wrapper styles to avoid double scaling (wrapper remains unstyled)
|
||||
wrapper.style.removeProperty('width');
|
||||
wrapper.style.removeProperty('height');
|
||||
wrapper.style.removeProperty('transform');
|
||||
wrapper.style.removeProperty('transform-origin');
|
||||
clearElementStyles(wrapper, SCALING_PROPERTIES.wrapper);
|
||||
|
||||
// Platform-specific positioning logic
|
||||
let transformOrigin;
|
||||
@@ -529,7 +532,7 @@ const resize = (force = false) => {
|
||||
const offsetY = (window.innerHeight - scaledHeight) / 2;
|
||||
|
||||
if (debugFlag('fullscreen')) {
|
||||
console.log(`🖥️ Applying fullscreen/kiosk scaling: wrapper=${wrapperWidth}x${wrapperHeight} scale=${scale.toFixed(3)} offset=${offsetX.toFixed(1)},${offsetY.toFixed(1)} 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
|
||||
@@ -540,25 +543,41 @@ const resize = (force = false) => {
|
||||
marginTop = `-${wrapperHeight / 2}px`; // Pull back by half height
|
||||
}
|
||||
|
||||
// Apply shared mainContainer properties (same for both kiosk modes)
|
||||
mainContainer.style.setProperty('transform', `scale(${scale})`, 'important');
|
||||
mainContainer.style.setProperty('transform-origin', transformOrigin, 'important');
|
||||
mainContainer.style.setProperty('width', `${wrapperWidth}px`, 'important');
|
||||
mainContainer.style.setProperty('height', `${wrapperHeight}px`, 'important');
|
||||
mainContainer.style.setProperty('position', 'absolute', 'important');
|
||||
mainContainer.style.setProperty('left', leftPosition, 'important');
|
||||
mainContainer.style.setProperty('top', topPosition, 'important');
|
||||
// Chrome fullscreen compatibility: apply transform to #container instead of #divTwcMain
|
||||
// This works around Chrome's restriction on styling fullscreen elements directly
|
||||
const container = document.querySelector('#container');
|
||||
const targetElement = isFullscreen ? container : mainContainer;
|
||||
|
||||
// Reset the other element's styles to avoid conflicts
|
||||
if (isFullscreen) {
|
||||
// Reset mainContainer styles when using container for fullscreen
|
||||
clearElementStyles(mainContainer, SCALING_PROPERTIES.positioning);
|
||||
} else {
|
||||
// Reset container styles when using mainContainer for kiosk mode
|
||||
clearElementStyles(container, SCALING_PROPERTIES.positioning);
|
||||
}
|
||||
|
||||
// Apply shared properties to the target element
|
||||
targetElement.style.setProperty('transform', `scale(${scale})`, 'important');
|
||||
targetElement.style.setProperty('transform-origin', transformOrigin, 'important');
|
||||
// the width of the target element does not change it is the fixed width of the 4:3 display which is then scaled
|
||||
// the wrapper adds margins and padding to achieve widescreen
|
||||
// targetElement.style.setProperty('width', `${wrapperWidth}px`, 'important');
|
||||
targetElement.style.setProperty('height', `${wrapperHeight}px`, 'important');
|
||||
targetElement.style.setProperty('position', 'absolute', 'important');
|
||||
targetElement.style.setProperty('left', leftPosition, 'important');
|
||||
targetElement.style.setProperty('top', topPosition, 'important');
|
||||
|
||||
// Apply or clear margin properties based on positioning method
|
||||
if (marginLeft !== null) {
|
||||
mainContainer.style.setProperty('margin-left', marginLeft, 'important');
|
||||
targetElement.style.setProperty('margin-left', marginLeft, 'important');
|
||||
} else {
|
||||
mainContainer.style.removeProperty('margin-left');
|
||||
targetElement.style.removeProperty('margin-left');
|
||||
}
|
||||
if (marginTop !== null) {
|
||||
mainContainer.style.setProperty('margin-top', marginTop, 'important');
|
||||
targetElement.style.setProperty('margin-top', marginTop, 'important');
|
||||
} else {
|
||||
mainContainer.style.removeProperty('margin-top');
|
||||
targetElement.style.removeProperty('margin-top');
|
||||
}
|
||||
|
||||
applyScanlineScaling(scale);
|
||||
@@ -753,12 +772,14 @@ const registerProgress = (_progress) => {
|
||||
progress = _progress;
|
||||
};
|
||||
|
||||
const populateWeatherParameters = (params) => {
|
||||
const populateWeatherParameters = (params, point) => {
|
||||
document.querySelector('#spanCity').innerHTML = `${params.city}, `;
|
||||
document.querySelector('#spanState').innerHTML = params.state;
|
||||
document.querySelector('#spanStationId').innerHTML = params.stationId;
|
||||
document.querySelector('#spanRadarId').innerHTML = params.radarId;
|
||||
document.querySelector('#spanZoneId').innerHTML = params.zoneId;
|
||||
document.querySelector('#spanOfficeId').innerHTML = point.cwa;
|
||||
document.querySelector('#spanGridPoint').innerHTML = `${point.gridX},${point.gridY}`;
|
||||
};
|
||||
|
||||
const latLonReceived = (data, haveDataCallback) => {
|
||||
|
||||
@@ -22,7 +22,9 @@ class Progress extends WeatherDisplay {
|
||||
}
|
||||
|
||||
async drawCanvas(displays, loadedCount) {
|
||||
// skip drawing if not displayed, or not yet available
|
||||
if (!this.elem) return;
|
||||
if (this.elem.classList.contains('show') === false) return;
|
||||
super.drawCanvas();
|
||||
|
||||
// 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
|
||||
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
|
||||
* @param {Object} observation - The observation object from the API
|
||||
* @returns {Object} - Augmented observation with parsed METAR data filled in
|
||||
*/
|
||||
const augmentObservationWithMetar = (observation) => {
|
||||
if (!observation?.rawMessage) {
|
||||
// check for a metar message and for unusable ios versions
|
||||
if (!observation?.rawMessage || (isIos && !iosVersionOk)) {
|
||||
return observation;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ class WeatherDisplay {
|
||||
if (this.screenIndex < 0) this.screenIndex = 0;
|
||||
if (this.okToDrawCurrentDateTime) this.drawCurrentDateTime();
|
||||
if (this.okToDrawCurrentConditions) postMessage({ type: 'current-weather-scroll', method: 'start' });
|
||||
if (this.okToDrawCurrentConditions === false) postMessage({ type: 'current-weather-scroll', method: 'hide' });
|
||||
}
|
||||
|
||||
finishDraw() {
|
||||
|
||||
13
server/scripts/vendor/auto/locale/en.js
vendored
13
server/scripts/vendor/auto/locale/en.js
vendored
@@ -94,7 +94,7 @@ var en = {
|
||||
TS: "thunderstorm",
|
||||
},
|
||||
Error: {
|
||||
prefix: "An error occured. Error code n°",
|
||||
prefix: "An error occurred. Error code n°",
|
||||
},
|
||||
ErrorCode: {
|
||||
AirportNotFound: "The airport was not found for this message.",
|
||||
@@ -136,11 +136,13 @@ var en = {
|
||||
TS: "thunderstorm",
|
||||
UP: "unknown precipitation",
|
||||
VA: "volcanic ash",
|
||||
NSW: 'no significant weather'
|
||||
},
|
||||
Remark: {
|
||||
ALQDS: "all quadrants",
|
||||
AO1: "automated stations without a precipitation discriminator",
|
||||
AO2: "automated station with a precipitation discriminator",
|
||||
AO2A: "automated station with a precipitation discriminator (augmented)",
|
||||
BASED: "based",
|
||||
Barometer: [
|
||||
"Increase, then decrease",
|
||||
@@ -156,7 +158,7 @@ var en = {
|
||||
Ceiling: {
|
||||
Height: "ceiling varying between {0} and {1} feet",
|
||||
Second: {
|
||||
Location: "ceiling of {0} feet mesured by a second sensor located at {1}",
|
||||
Location: "ceiling of {0} feet measured by a second sensor located at {1}",
|
||||
},
|
||||
},
|
||||
DSNT: "distant",
|
||||
@@ -192,6 +194,11 @@ var en = {
|
||||
LGT: "light",
|
||||
LTG: "lightning",
|
||||
MOD: "moderate",
|
||||
Next: {
|
||||
Forecast: {
|
||||
By: "next forecast by {0}, {1}:{2}Z"
|
||||
},
|
||||
},
|
||||
NXT: "next",
|
||||
ON: "on",
|
||||
Obscuration: "{0} layer at {1} feet composed of {2}",
|
||||
@@ -223,7 +230,7 @@ var en = {
|
||||
},
|
||||
Second: {
|
||||
Location: {
|
||||
Visibility: "visibility of {0} SM mesured by a second sensor located at {1}",
|
||||
Visibility: "visibility of {0} SM measured by a second sensor located at {1}",
|
||||
},
|
||||
},
|
||||
Sector: {
|
||||
|
||||
2
server/scripts/vendor/auto/luxon.js.map
vendored
2
server/scripts/vendor/auto/luxon.js.map
vendored
File diff suppressed because one or more lines are too long
228
server/scripts/vendor/auto/luxon.mjs
vendored
228
server/scripts/vendor/auto/luxon.mjs
vendored
@@ -1053,10 +1053,18 @@ class Locale {
|
||||
|
||||
months(length, format = false) {
|
||||
return listStuff(this, length, months, () => {
|
||||
// Workaround for "ja" locale: formatToParts does not label all parts of the month
|
||||
// as "month" and for this locale there is no difference between "format" and "non-format".
|
||||
// As such, just use format() instead of formatToParts() and take the whole string
|
||||
const monthSpecialCase = this.intl === "ja" || this.intl.startsWith("ja-");
|
||||
format &= !monthSpecialCase;
|
||||
const intl = format ? { month: length, day: "numeric" } : { month: length },
|
||||
formatStr = format ? "format" : "standalone";
|
||||
if (!this.monthsCache[formatStr][length]) {
|
||||
this.monthsCache[formatStr][length] = mapMonths((dt) => this.extract(dt, intl, "month"));
|
||||
const mapper = !monthSpecialCase
|
||||
? (dt) => this.extract(dt, intl, "month")
|
||||
: (dt) => this.dtFormatter(dt, intl).format();
|
||||
this.monthsCache[formatStr][length] = mapMonths(mapper);
|
||||
}
|
||||
return this.monthsCache[formatStr][length];
|
||||
});
|
||||
@@ -2040,10 +2048,24 @@ function parseMillis(fraction) {
|
||||
}
|
||||
}
|
||||
|
||||
function roundTo(number, digits, towardZero = false) {
|
||||
const factor = 10 ** digits,
|
||||
rounder = towardZero ? Math.trunc : Math.round;
|
||||
return rounder(number * factor) / factor;
|
||||
function roundTo(number, digits, rounding = "round") {
|
||||
const factor = 10 ** digits;
|
||||
switch (rounding) {
|
||||
case "expand":
|
||||
return number > 0
|
||||
? Math.ceil(number * factor) / factor
|
||||
: Math.floor(number * factor) / factor;
|
||||
case "trunc":
|
||||
return Math.trunc(number * factor) / factor;
|
||||
case "round":
|
||||
return Math.round(number * factor) / factor;
|
||||
case "floor":
|
||||
return Math.floor(number * factor) / factor;
|
||||
case "ceil":
|
||||
return Math.ceil(number * factor) / factor;
|
||||
default:
|
||||
throw new RangeError(`Value rounding ${rounding} is out of range`);
|
||||
}
|
||||
}
|
||||
|
||||
// DATE BASICS
|
||||
@@ -2151,7 +2173,7 @@ function signedOffset(offHourStr, offMinuteStr) {
|
||||
|
||||
function asNumber(value) {
|
||||
const numericValue = Number(value);
|
||||
if (typeof value === "boolean" || value === "" || Number.isNaN(numericValue))
|
||||
if (typeof value === "boolean" || value === "" || !Number.isFinite(numericValue))
|
||||
throw new InvalidArgumentError(`Invalid unit value ${value}`);
|
||||
return numericValue;
|
||||
}
|
||||
@@ -2410,8 +2432,12 @@ class Formatter {
|
||||
for (let i = 0; i < fmt.length; i++) {
|
||||
const c = fmt.charAt(i);
|
||||
if (c === "'") {
|
||||
if (currentFull.length > 0) {
|
||||
splits.push({ literal: bracketed || /^\s+$/.test(currentFull), val: currentFull });
|
||||
// turn '' into a literal signal quote instead of just skipping the empty literal
|
||||
if (currentFull.length > 0 || bracketed) {
|
||||
splits.push({
|
||||
literal: bracketed || /^\s+$/.test(currentFull),
|
||||
val: currentFull === "" ? "'" : currentFull,
|
||||
});
|
||||
}
|
||||
current = null;
|
||||
currentFull = "";
|
||||
@@ -2475,7 +2501,7 @@ class Formatter {
|
||||
return this.dtFormatter(dt, opts).resolvedOptions();
|
||||
}
|
||||
|
||||
num(n, p = 0) {
|
||||
num(n, p = 0, signDisplay = undefined) {
|
||||
// we get some perf out of doing this here, annoyingly
|
||||
if (this.opts.forceSimple) {
|
||||
return padStart(n, p);
|
||||
@@ -2486,6 +2512,9 @@ class Formatter {
|
||||
if (p > 0) {
|
||||
opts.padTo = p;
|
||||
}
|
||||
if (signDisplay) {
|
||||
opts.signDisplay = signDisplay;
|
||||
}
|
||||
|
||||
return this.loc.numberFormatter(opts).format(n);
|
||||
}
|
||||
@@ -2721,32 +2750,44 @@ class Formatter {
|
||||
}
|
||||
|
||||
formatDurationFromString(dur, fmt) {
|
||||
const invertLargest = this.opts.signMode === "negativeLargestOnly" ? -1 : 1;
|
||||
const tokenToField = (token) => {
|
||||
switch (token[0]) {
|
||||
case "S":
|
||||
return "millisecond";
|
||||
return "milliseconds";
|
||||
case "s":
|
||||
return "second";
|
||||
return "seconds";
|
||||
case "m":
|
||||
return "minute";
|
||||
return "minutes";
|
||||
case "h":
|
||||
return "hour";
|
||||
return "hours";
|
||||
case "d":
|
||||
return "day";
|
||||
return "days";
|
||||
case "w":
|
||||
return "week";
|
||||
return "weeks";
|
||||
case "M":
|
||||
return "month";
|
||||
return "months";
|
||||
case "y":
|
||||
return "year";
|
||||
return "years";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
tokenToString = (lildur) => (token) => {
|
||||
tokenToString = (lildur, info) => (token) => {
|
||||
const mapped = tokenToField(token);
|
||||
if (mapped) {
|
||||
return this.num(lildur.get(mapped), token.length);
|
||||
const inversionFactor =
|
||||
info.isNegativeDuration && mapped !== info.largestUnit ? invertLargest : 1;
|
||||
let signDisplay;
|
||||
if (this.opts.signMode === "negativeLargestOnly" && mapped !== info.largestUnit) {
|
||||
signDisplay = "never";
|
||||
} else if (this.opts.signMode === "all") {
|
||||
signDisplay = "always";
|
||||
} else {
|
||||
// "auto" and "negative" are the same, but "auto" has better support
|
||||
signDisplay = "auto";
|
||||
}
|
||||
return this.num(lildur.get(mapped) * inversionFactor, token.length, signDisplay);
|
||||
} else {
|
||||
return token;
|
||||
}
|
||||
@@ -2756,8 +2797,14 @@ class Formatter {
|
||||
(found, { literal, val }) => (literal ? found : found.concat(val)),
|
||||
[]
|
||||
),
|
||||
collapsed = dur.shiftTo(...realTokens.map(tokenToField).filter((t) => t));
|
||||
return stringifyTokens(tokens, tokenToString(collapsed));
|
||||
collapsed = dur.shiftTo(...realTokens.map(tokenToField).filter((t) => t)),
|
||||
durationInfo = {
|
||||
isNegativeDuration: collapsed < 0,
|
||||
// this relies on "collapsed" being based on "shiftTo", which builds up the object
|
||||
// in order
|
||||
largestUnit: Object.keys(collapsed.values)[0],
|
||||
};
|
||||
return stringifyTokens(tokens, tokenToString(collapsed, durationInfo));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2818,11 +2865,11 @@ function simpleParse(...keys) {
|
||||
}
|
||||
|
||||
// ISO and SQL parsing
|
||||
const offsetRegex = /(?:(Z)|([+-]\d\d)(?::?(\d\d))?)/;
|
||||
const offsetRegex = /(?:([Zz])|([+-]\d\d)(?::?(\d\d))?)/;
|
||||
const isoExtendedZone = `(?:${offsetRegex.source}?(?:\\[(${ianaRegex.source})\\])?)?`;
|
||||
const isoTimeBaseRegex = /(\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?/;
|
||||
const isoTimeRegex = RegExp(`${isoTimeBaseRegex.source}${isoExtendedZone}`);
|
||||
const isoTimeExtensionRegex = RegExp(`(?:T${isoTimeRegex.source})?`);
|
||||
const isoTimeExtensionRegex = RegExp(`(?:[Tt]${isoTimeRegex.source})?`);
|
||||
const isoYmdRegex = /([+-]\d{6}|\d{4})(?:-?(\d\d)(?:-?(\d\d))?)?/;
|
||||
const isoWeekRegex = /(\d{4})-?W(\d\d)(?:-?(\d))?/;
|
||||
const isoOrdinalRegex = /(\d{4})-?(\d{3})/;
|
||||
@@ -3537,9 +3584,13 @@ class Duration {
|
||||
* @param {string} fmt - the format string
|
||||
* @param {Object} opts - options
|
||||
* @param {boolean} [opts.floor=true] - floor numerical values
|
||||
* @param {'negative'|'all'|'negativeLargestOnly'} [opts.signMode=negative] - How to handle signs
|
||||
* @example Duration.fromObject({ years: 1, days: 6, seconds: 2 }).toFormat("y d s") //=> "1 6 2"
|
||||
* @example Duration.fromObject({ years: 1, days: 6, seconds: 2 }).toFormat("yy dd sss") //=> "01 06 002"
|
||||
* @example Duration.fromObject({ years: 1, days: 6, seconds: 2 }).toFormat("M S") //=> "12 518402000"
|
||||
* @example Duration.fromObject({ days: 6, seconds: 2 }).toFormat("d s", { signMode: "all" }) //=> "+6 +2"
|
||||
* @example Duration.fromObject({ days: -6, seconds: -2 }).toFormat("d s", { signMode: "all" }) //=> "-6 -2"
|
||||
* @example Duration.fromObject({ days: -6, seconds: -2 }).toFormat("d s", { signMode: "negativeLargestOnly" }) //=> "-6 2"
|
||||
* @return {string}
|
||||
*/
|
||||
toFormat(fmt, opts = {}) {
|
||||
@@ -3559,21 +3610,25 @@ class Duration {
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#options
|
||||
* @param {Object} opts - Formatting options. Accepts the same keys as the options parameter of the native `Intl.NumberFormat` constructor, as well as `listStyle`.
|
||||
* @param {string} [opts.listStyle='narrow'] - How to format the merged list. Corresponds to the `style` property of the options parameter of the native `Intl.ListFormat` constructor.
|
||||
* @param {boolean} [opts.showZeros=true] - Show all units previously used by the duration even if they are zero
|
||||
* @example
|
||||
* ```js
|
||||
* var dur = Duration.fromObject({ days: 1, hours: 5, minutes: 6 })
|
||||
* dur.toHuman() //=> '1 day, 5 hours, 6 minutes'
|
||||
* dur.toHuman({ listStyle: "long" }) //=> '1 day, 5 hours, and 6 minutes'
|
||||
* dur.toHuman({ unitDisplay: "short" }) //=> '1 day, 5 hr, 6 min'
|
||||
* var dur = Duration.fromObject({ months: 1, weeks: 0, hours: 5, minutes: 6 })
|
||||
* dur.toHuman() //=> '1 month, 0 weeks, 5 hours, 6 minutes'
|
||||
* dur.toHuman({ listStyle: "long" }) //=> '1 month, 0 weeks, 5 hours, and 6 minutes'
|
||||
* dur.toHuman({ unitDisplay: "short" }) //=> '1 mth, 0 wks, 5 hr, 6 min'
|
||||
* dur.toHuman({ showZeros: false }) //=> '1 month, 5 hours, 6 minutes'
|
||||
* ```
|
||||
*/
|
||||
toHuman(opts = {}) {
|
||||
if (!this.isValid) return INVALID$2;
|
||||
|
||||
const showZeros = opts.showZeros !== false;
|
||||
|
||||
const l = orderedUnits$1
|
||||
.map((unit) => {
|
||||
const val = this.values[unit];
|
||||
if (isUndefined(val)) {
|
||||
if (isUndefined(val) || (val === 0 && !showZeros)) {
|
||||
return null;
|
||||
}
|
||||
return this.loc
|
||||
@@ -3933,6 +3988,17 @@ class Duration {
|
||||
return clone$1(this, { values: negated }, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all units with values equal to 0 from this Duration.
|
||||
* @example Duration.fromObject({ years: 2, days: 0, hours: 0, minutes: 0 }).removeZeros().toObject() //=> { years: 2 }
|
||||
* @return {Duration}
|
||||
*/
|
||||
removeZeros() {
|
||||
if (!this.isValid) return this;
|
||||
const vals = removeZeroes(this.values);
|
||||
return clone$1(this, { values: vals }, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the years.
|
||||
* @type {number}
|
||||
@@ -4243,7 +4309,8 @@ class Interval {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the end of the Interval
|
||||
* Returns the end of the Interval. This is the first instant which is not part of the interval
|
||||
* (Interval is half-open).
|
||||
* @type {DateTime}
|
||||
*/
|
||||
get end() {
|
||||
@@ -5674,21 +5741,22 @@ function toTechFormat(dt, format, allowZ = true) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function toISODate(o, extended) {
|
||||
function toISODate(o, extended, precision) {
|
||||
const longFormat = o.c.year > 9999 || o.c.year < 0;
|
||||
let c = "";
|
||||
if (longFormat && o.c.year >= 0) c += "+";
|
||||
c += padStart(o.c.year, longFormat ? 6 : 4);
|
||||
|
||||
if (precision === "year") return c;
|
||||
if (extended) {
|
||||
c += "-";
|
||||
c += padStart(o.c.month);
|
||||
if (precision === "month") return c;
|
||||
c += "-";
|
||||
c += padStart(o.c.day);
|
||||
} else {
|
||||
c += padStart(o.c.month);
|
||||
c += padStart(o.c.day);
|
||||
if (precision === "month") return c;
|
||||
}
|
||||
c += padStart(o.c.day);
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -5698,26 +5766,39 @@ function toISOTime(
|
||||
suppressSeconds,
|
||||
suppressMilliseconds,
|
||||
includeOffset,
|
||||
extendedZone
|
||||
extendedZone,
|
||||
precision
|
||||
) {
|
||||
let c = padStart(o.c.hour);
|
||||
if (extended) {
|
||||
c += ":";
|
||||
c += padStart(o.c.minute);
|
||||
if (o.c.millisecond !== 0 || o.c.second !== 0 || !suppressSeconds) {
|
||||
c += ":";
|
||||
}
|
||||
} else {
|
||||
c += padStart(o.c.minute);
|
||||
}
|
||||
|
||||
if (o.c.millisecond !== 0 || o.c.second !== 0 || !suppressSeconds) {
|
||||
c += padStart(o.c.second);
|
||||
|
||||
if (o.c.millisecond !== 0 || !suppressMilliseconds) {
|
||||
c += ".";
|
||||
c += padStart(o.c.millisecond, 3);
|
||||
}
|
||||
let showSeconds = !suppressSeconds || o.c.millisecond !== 0 || o.c.second !== 0,
|
||||
c = "";
|
||||
switch (precision) {
|
||||
case "day":
|
||||
case "month":
|
||||
case "year":
|
||||
break;
|
||||
default:
|
||||
c += padStart(o.c.hour);
|
||||
if (precision === "hour") break;
|
||||
if (extended) {
|
||||
c += ":";
|
||||
c += padStart(o.c.minute);
|
||||
if (precision === "minute") break;
|
||||
if (showSeconds) {
|
||||
c += ":";
|
||||
c += padStart(o.c.second);
|
||||
}
|
||||
} else {
|
||||
c += padStart(o.c.minute);
|
||||
if (precision === "minute") break;
|
||||
if (showSeconds) {
|
||||
c += padStart(o.c.second);
|
||||
}
|
||||
}
|
||||
if (precision === "second") break;
|
||||
if (showSeconds && (!suppressMilliseconds || o.c.millisecond !== 0)) {
|
||||
c += ".";
|
||||
c += padStart(o.c.millisecond, 3);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeOffset) {
|
||||
@@ -5909,8 +5990,9 @@ function quickDT(obj, opts) {
|
||||
|
||||
function diffRelative(start, end, opts) {
|
||||
const round = isUndefined(opts.round) ? true : opts.round,
|
||||
rounding = isUndefined(opts.rounding) ? "trunc" : opts.rounding,
|
||||
format = (c, unit) => {
|
||||
c = roundTo(c, round || opts.calendary ? 0 : 2, true);
|
||||
c = roundTo(c, round || opts.calendary ? 0 : 2, opts.calendary ? "round" : rounding);
|
||||
const formatter = end.loc.clone(opts).relFormatter(opts);
|
||||
return formatter.format(c, unit);
|
||||
},
|
||||
@@ -7289,10 +7371,13 @@ class DateTime {
|
||||
* @param {boolean} [opts.includeOffset=true] - include the offset, such as 'Z' or '-04:00'
|
||||
* @param {boolean} [opts.extendedZone=false] - add the time zone format extension
|
||||
* @param {string} [opts.format='extended'] - choose between the basic and extended format
|
||||
* @param {string} [opts.precision='milliseconds'] - truncate output to desired presicion: 'years', 'months', 'days', 'hours', 'minutes', 'seconds' or 'milliseconds'. When precision and suppressSeconds or suppressMilliseconds are used together, precision sets the maximum unit shown in the output, however seconds or milliseconds will still be suppressed if they are 0.
|
||||
* @example DateTime.utc(1983, 5, 25).toISO() //=> '1982-05-25T00:00:00.000Z'
|
||||
* @example DateTime.now().toISO() //=> '2017-04-22T20:47:05.335-04:00'
|
||||
* @example DateTime.now().toISO({ includeOffset: false }) //=> '2017-04-22T20:47:05.335'
|
||||
* @example DateTime.now().toISO({ format: 'basic' }) //=> '20170422T204705.335-0400'
|
||||
* @example DateTime.now().toISO({ precision: 'day' }) //=> '2017-04-22Z'
|
||||
* @example DateTime.now().toISO({ precision: 'minute' }) //=> '2017-04-22T20:47Z'
|
||||
* @return {string|null}
|
||||
*/
|
||||
toISO({
|
||||
@@ -7301,16 +7386,26 @@ class DateTime {
|
||||
suppressMilliseconds = false,
|
||||
includeOffset = true,
|
||||
extendedZone = false,
|
||||
precision = "milliseconds",
|
||||
} = {}) {
|
||||
if (!this.isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
precision = normalizeUnit(precision);
|
||||
const ext = format === "extended";
|
||||
|
||||
let c = toISODate(this, ext);
|
||||
c += "T";
|
||||
c += toISOTime(this, ext, suppressSeconds, suppressMilliseconds, includeOffset, extendedZone);
|
||||
let c = toISODate(this, ext, precision);
|
||||
if (orderedUnits.indexOf(precision) >= 3) c += "T";
|
||||
c += toISOTime(
|
||||
this,
|
||||
ext,
|
||||
suppressSeconds,
|
||||
suppressMilliseconds,
|
||||
includeOffset,
|
||||
extendedZone,
|
||||
precision
|
||||
);
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -7318,16 +7413,17 @@ class DateTime {
|
||||
* Returns an ISO 8601-compliant string representation of this DateTime's date component
|
||||
* @param {Object} opts - options
|
||||
* @param {string} [opts.format='extended'] - choose between the basic and extended format
|
||||
* @param {string} [opts.precision='day'] - truncate output to desired precision: 'years', 'months', or 'days'.
|
||||
* @example DateTime.utc(1982, 5, 25).toISODate() //=> '1982-05-25'
|
||||
* @example DateTime.utc(1982, 5, 25).toISODate({ format: 'basic' }) //=> '19820525'
|
||||
* @example DateTime.utc(1982, 5, 25).toISODate({ precision: 'month' }) //=> '1982-05'
|
||||
* @return {string|null}
|
||||
*/
|
||||
toISODate({ format = "extended" } = {}) {
|
||||
toISODate({ format = "extended", precision = "day" } = {}) {
|
||||
if (!this.isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toISODate(this, format === "extended");
|
||||
return toISODate(this, format === "extended", normalizeUnit(precision));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7348,10 +7444,12 @@ class DateTime {
|
||||
* @param {boolean} [opts.extendedZone=true] - add the time zone format extension
|
||||
* @param {boolean} [opts.includePrefix=false] - include the `T` prefix
|
||||
* @param {string} [opts.format='extended'] - choose between the basic and extended format
|
||||
* @param {string} [opts.precision='milliseconds'] - truncate output to desired presicion: 'hours', 'minutes', 'seconds' or 'milliseconds'. When precision and suppressSeconds or suppressMilliseconds are used together, precision sets the maximum unit shown in the output, however seconds or milliseconds will still be suppressed if they are 0.
|
||||
* @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime() //=> '07:34:19.361Z'
|
||||
* @example DateTime.utc().set({ hour: 7, minute: 34, seconds: 0, milliseconds: 0 }).toISOTime({ suppressSeconds: true }) //=> '07:34Z'
|
||||
* @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime({ format: 'basic' }) //=> '073419.361Z'
|
||||
* @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime({ includePrefix: true }) //=> 'T07:34:19.361Z'
|
||||
* @example DateTime.utc().set({ hour: 7, minute: 34, second: 56 }).toISOTime({ precision: 'minute' }) //=> '07:34Z'
|
||||
* @return {string}
|
||||
*/
|
||||
toISOTime({
|
||||
@@ -7361,12 +7459,14 @@ class DateTime {
|
||||
includePrefix = false,
|
||||
extendedZone = false,
|
||||
format = "extended",
|
||||
precision = "milliseconds",
|
||||
} = {}) {
|
||||
if (!this.isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let c = includePrefix ? "T" : "";
|
||||
precision = normalizeUnit(precision);
|
||||
let c = includePrefix && orderedUnits.indexOf(precision) >= 3 ? "T" : "";
|
||||
return (
|
||||
c +
|
||||
toISOTime(
|
||||
@@ -7375,7 +7475,8 @@ class DateTime {
|
||||
suppressSeconds,
|
||||
suppressMilliseconds,
|
||||
includeOffset,
|
||||
extendedZone
|
||||
extendedZone,
|
||||
precision
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -7653,12 +7754,13 @@ class DateTime {
|
||||
|
||||
/**
|
||||
* Returns a string representation of a this time relative to now, such as "in two days". Can only internationalize if your
|
||||
* platform supports Intl.RelativeTimeFormat. Rounds down by default.
|
||||
* platform supports Intl.RelativeTimeFormat. Rounds towards zero by default.
|
||||
* @param {Object} options - options that affect the output
|
||||
* @param {DateTime} [options.base=DateTime.now()] - the DateTime to use as the basis to which this time is compared. Defaults to now.
|
||||
* @param {string} [options.style="long"] - the style of units, must be "long", "short", or "narrow"
|
||||
* @param {string|string[]} options.unit - use a specific unit or array of units; if omitted, or an array, the method will pick the best unit. Use an array or one of "years", "quarters", "months", "weeks", "days", "hours", "minutes", or "seconds"
|
||||
* @param {boolean} [options.round=true] - whether to round the numbers in the output.
|
||||
* @param {string} [options.rounding="trunc"] - rounding method to use when rounding the numbers in the output. Can be "trunc" (toward zero), "expand" (away from zero), "round", "floor", or "ceil".
|
||||
* @param {number} [options.padding=0] - padding in milliseconds. This allows you to round up the result if it fits inside the threshold. Don't use in combination with {round: false} because the decimal output will include the padding.
|
||||
* @param {string} options.locale - override the locale of this DateTime
|
||||
* @param {string} options.numberingSystem - override the numberingSystem of this DateTime. The Intl system may choose not to honor this
|
||||
@@ -8025,7 +8127,7 @@ function friendlyDateTime(dateTimeish) {
|
||||
}
|
||||
}
|
||||
|
||||
const VERSION = "3.6.1";
|
||||
const VERSION = "3.7.1";
|
||||
|
||||
export { DateTime, Duration, FixedOffsetZone, IANAZone, Info, Interval, InvalidZone, Settings, SystemZone, VERSION, Zone };
|
||||
//# sourceMappingURL=luxon.js.map
|
||||
|
||||
627
server/scripts/vendor/auto/metar-taf-parser.mjs
vendored
627
server/scripts/vendor/auto/metar-taf-parser.mjs
vendored
File diff suppressed because it is too large
Load Diff
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/_utils' as u;
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
|
||||
#hazards-html.weather-display {
|
||||
background-image: url('../images/backgrounds/7.png');
|
||||
}
|
||||
|
||||
.weather-display .main.hazards {
|
||||
&.main {
|
||||
@@ -7,6 +11,7 @@
|
||||
height: 480px;
|
||||
background-color: rgb(112, 35, 35);
|
||||
|
||||
|
||||
.hazard-lines {
|
||||
min-height: 400px;
|
||||
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/_colors' as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors'as c;
|
||||
|
||||
@font-face {
|
||||
font-family: "Star4000";
|
||||
@@ -161,6 +161,7 @@ body {
|
||||
#divTwcMain {
|
||||
width: 640px;
|
||||
height: 480px;
|
||||
position: relative;
|
||||
|
||||
.wide & {
|
||||
width: 854px;
|
||||
@@ -340,13 +341,14 @@ body {
|
||||
// overflow: hidden;
|
||||
background-image: url(../images/backgrounds/1.png);
|
||||
transform-origin: 0 0;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.wide #container {
|
||||
padding-left: 107px;
|
||||
padding-right: 107px;
|
||||
background: url(../images/backgrounds/1-wide.png);
|
||||
background-repeat: no-repeat;
|
||||
background: url(../images/backgrounds/1-wide.png)
|
||||
}
|
||||
|
||||
#divTwc:fullscreen #container,
|
||||
@@ -813,4 +815,4 @@ body.kiosk #loading .instructions {
|
||||
>*:not(#divTwc) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
|
||||
.weather-display {
|
||||
width: 640px;
|
||||
@@ -112,29 +112,32 @@
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.scroll {
|
||||
@include u.text-shadow(3px, 1.5px);
|
||||
#container>.scroll {
|
||||
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;
|
||||
height: 70px;
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
|
||||
&.hazard {
|
||||
background-color: rgb(112, 35, 35);
|
||||
}
|
||||
|
||||
.fixed,
|
||||
.scroll-header {
|
||||
margin-left: 55px;
|
||||
margin-right: 55px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Remove margins for hazard scrolls to maximize text space
|
||||
&.hazard .fixed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scroll-header {
|
||||
@@ -156,6 +159,17 @@
|
||||
// left: calc((elem width) - 640px);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
.wide #container>.scroll {
|
||||
width: 854px;
|
||||
margin-left: -107px;
|
||||
|
||||
.scroll-container {
|
||||
margin-left: 107px;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ const tester = async (location, testPage) => {
|
||||
// run all the locations
|
||||
for (let i = 0; i < LOCATIONS.length; i += 1) {
|
||||
const location = LOCATIONS[i];
|
||||
console.log(location);
|
||||
console.log(`${i + 1}/${LOCATIONS.length} ${location}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await tester(location, page);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
<div id="hazards-html" class="weather-display">
|
||||
<%- include('partials/hazards.ejs') %>
|
||||
</div>
|
||||
<%- include('partials/scroll.ejs') %>
|
||||
</div>
|
||||
</div>
|
||||
<div id="divTwcBottom">
|
||||
@@ -185,12 +186,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='heading'>Forecast Information</div>
|
||||
<div class='heading'>Headend Information</div>
|
||||
<div id="divInfo">
|
||||
Location: <span id="spanCity"></span> <span id="spanState"></span><br />
|
||||
Station Id: <span id="spanStationId"></span><br />
|
||||
Radar Id: <span id="spanRadarId"></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 />
|
||||
Ws4kp Version: <span><%- version %></span>
|
||||
</div>
|
||||
@@ -198,4 +201,4 @@
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -21,5 +21,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
</div>
|
||||
@@ -1,43 +1,42 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Current' , bottom: 'Conditions' }, noaaLogo: true, hasTime: true}) %>
|
||||
<div class="main has-scroll has-box current-weather">
|
||||
<div class="weather template">
|
||||
<div class="left col">
|
||||
<div class="temp center"></div>
|
||||
<div class="condition center"></div>
|
||||
<div class="icon center"><img src="" /></div>
|
||||
<div class="wind-container">
|
||||
<div class="wind-label">Wind:</div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
<div class="wind-gusts"></div>
|
||||
</div>
|
||||
<div class="right col">
|
||||
<div class="location"></div>
|
||||
<div class="row">
|
||||
<div class="label">Humidity:</div>
|
||||
<div class="humidity value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Dewpoint:</div>
|
||||
<div class="dewpoint value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Ceiling:</div>
|
||||
<div class="ceiling value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Visibility:</div>
|
||||
<div class="visibility value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Pressure:</div>
|
||||
<div class="pressure value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="heat-index-label label"></div>
|
||||
<div class="heat-index value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll has-box current-weather">
|
||||
<div class="weather template">
|
||||
<div class="left col">
|
||||
<div class="temp center"></div>
|
||||
<div class="condition center"></div>
|
||||
<div class="icon center"><img src="" /></div>
|
||||
<div class="wind-container">
|
||||
<div class="wind-label">Wind:</div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
<div class="wind-gusts"></div>
|
||||
</div>
|
||||
<div class="right col">
|
||||
<div class="location"></div>
|
||||
<div class="row">
|
||||
<div class="label">Humidity:</div>
|
||||
<div class="humidity value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Dewpoint:</div>
|
||||
<div class="dewpoint value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Ceiling:</div>
|
||||
<div class="ceiling value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Visibility:</div>
|
||||
<div class="visibility value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">Pressure:</div>
|
||||
<div class="pressure value"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="heat-index-label label"></div>
|
||||
<div class="heat-index value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,5 +19,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
</div>
|
||||
@@ -1,24 +1,23 @@
|
||||
<%- include('header.ejs', {title: 'Hourly Graph' , hasTime: false }) %>
|
||||
<div class="main has-scroll hourly-graph">
|
||||
<div class="top-right template ">
|
||||
<div class="temperature">Temperature</div>
|
||||
<div class="cloud">Cloud %</div>
|
||||
<div class="rain">Precip %</div>
|
||||
</div>
|
||||
<div class="y-axis">
|
||||
<div class="label l-1">75</div>
|
||||
<div class="label l-2">65</div>
|
||||
<div class="label l-3">55</div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<img id="chart-area"></img>
|
||||
</div>
|
||||
<div class="x-axis">
|
||||
<div class="label l-1">12a</div>
|
||||
<div class="label l-2">6a</div>
|
||||
<div class="label l-3">12p</div>
|
||||
<div class="label l-4">6p</div>
|
||||
<div class="label l-5">12a</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="top-right template ">
|
||||
<div class="temperature">Temperature</div>
|
||||
<div class="cloud">Cloud %</div>
|
||||
<div class="rain">Precip %</div>
|
||||
</div>
|
||||
<div class="y-axis">
|
||||
<div class="label l-1">75</div>
|
||||
<div class="label l-2">65</div>
|
||||
<div class="label l-3">55</div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<img id="chart-area"></img>
|
||||
</div>
|
||||
<div class="x-axis">
|
||||
<div class="label l-1">12a</div>
|
||||
<div class="label l-2">6a</div>
|
||||
<div class="label l-3">12p</div>
|
||||
<div class="label l-4">6p</div>
|
||||
<div class="label l-5">12a</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,18 +1,17 @@
|
||||
<%- include('header.ejs', {title: 'Hourly Forecast' , hasTime: true }) %>
|
||||
<div class="main has-scroll hourly">
|
||||
<div class="column-headers">
|
||||
<div class="temp">TEMP</div>
|
||||
<div class="like">LIKE</div>
|
||||
<div class="wind">WIND</div>
|
||||
</div>
|
||||
<div class="hourly-lines">
|
||||
<div class="hourly-row template">
|
||||
<div class="hour"></div>
|
||||
<div class="icon"><img /></div>
|
||||
<div class="temp"></div>
|
||||
<div class="like"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll hourly">
|
||||
<div class="column-headers">
|
||||
<div class="temp">TEMP</div>
|
||||
<div class="like">LIKE</div>
|
||||
<div class="wind">WIND</div>
|
||||
</div>
|
||||
<div class="hourly-lines">
|
||||
<div class="hourly-row template">
|
||||
<div class="hour"></div>
|
||||
<div class="icon"><img /></div>
|
||||
<div class="temp"></div>
|
||||
<div class="like"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,19 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true, hasTime: true }) %>
|
||||
<div class="main has-scroll latest-observations has-box">
|
||||
<div class="container">
|
||||
<div class="column-headers">
|
||||
<div class="temp english">°F</div>
|
||||
<div class="temp metric">°C</div>
|
||||
<div class="weather">Weather</div>
|
||||
<div class="wind">Wind</div>
|
||||
</div>
|
||||
<div class="observation-lines">
|
||||
<div class="observation-row template">
|
||||
<div class="location"></div>
|
||||
<div class="temp"></div>
|
||||
<div class="weather"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll latest-observations has-box">
|
||||
<div class="container">
|
||||
<div class="column-headers">
|
||||
<div class="temp english">°F</div>
|
||||
<div class="temp metric">°C</div>
|
||||
<div class="weather">Weather</div>
|
||||
<div class="wind">Wind</div>
|
||||
</div>
|
||||
<div class="observation-lines">
|
||||
<div class="observation-row template">
|
||||
<div class="location"></div>
|
||||
<div class="temp"></div>
|
||||
<div class="weather"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,14 +1,13 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Regional' , bottom: 'Observations' }, hasTime: true }) %>
|
||||
<div class="main has-scroll regional-forecast">
|
||||
<div class="map"><img src="images/maps/basemap.webp" /></div>
|
||||
<div class="location-container">
|
||||
<div class="location template">
|
||||
<div class="icon">
|
||||
<img src="" />
|
||||
</div>
|
||||
<div class="city"></div>
|
||||
<div class="temp"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll regional-forecast">
|
||||
<div class="map"><img src="images/maps/basemap.webp" /></div>
|
||||
<div class="location-container">
|
||||
<div class="location template">
|
||||
<div class="icon">
|
||||
<img src="" />
|
||||
</div>
|
||||
<div class="city"></div>
|
||||
<div class="temp"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<div class="scroll">
|
||||
<div class="scrolling template"></div>
|
||||
<div class="scroll-header"></div>
|
||||
<div class="fixed"></div>
|
||||
<div class="scroll-container">
|
||||
<div class="scroll-header"></div>
|
||||
<div class="fixed"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,19 @@
|
||||
<%- include('header.ejs', {titleDual:{ top: 'Storm Prediction' , bottom: 'Center Outlook' }, hasTime: true}) %>
|
||||
<div class="main has-scroll spc-outlook">
|
||||
<div class="container">
|
||||
<div class="risk-levels">
|
||||
<div class="risk-level">High</div>
|
||||
<div class="risk-level">Moderate</div>
|
||||
<div class="risk-level">Enhanced</div>
|
||||
<div class="risk-level">Slight</div>
|
||||
<div class="risk-level">Marginal</div>
|
||||
<div class="risk-level">T'Storm</div>
|
||||
</div>
|
||||
<div class="days">
|
||||
<div class="day template">
|
||||
<div class="day-name">Monday</div>
|
||||
<div class="risk-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<div class="main has-scroll spc-outlook">
|
||||
<div class="container">
|
||||
<div class="risk-levels">
|
||||
<div class="risk-level">High</div>
|
||||
<div class="risk-level">Moderate</div>
|
||||
<div class="risk-level">Enhanced</div>
|
||||
<div class="risk-level">Slight</div>
|
||||
<div class="risk-level">Marginal</div>
|
||||
<div class="risk-level">T'Storm</div>
|
||||
</div>
|
||||
<div class="days">
|
||||
<div class="day template">
|
||||
<div class="day-name">Monday</div>
|
||||
<div class="risk-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,5 +12,4 @@
|
||||
<div class="temp high"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
</div>
|
||||
Reference in New Issue
Block a user