Compare commits

...

37 Commits

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

View File

@@ -11,4 +11,4 @@ Please do not report issues with api.weather.gov being down. It's a new service
Please include:
* 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
View File

@@ -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"
},
{

View File

@@ -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

View File

@@ -32,6 +32,18 @@ From a learning standpoint, this codebase make use of a lot of different methods
* Hand written CSS made easier to mange with SASS
* 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

View File

@@ -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

View File

@@ -115,7 +115,7 @@
"lon": -82.5329
},
{
"city": "Washington DC",
"city": "Washington",
"lat": 38.8951,
"lon": -77.0364
},

View File

@@ -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;

View File

@@ -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));

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

View File

@@ -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 = '&nbsp;'.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,
};

View File

@@ -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

View File

@@ -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();

View File

@@ -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) => {

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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() {

View File

@@ -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: {

File diff suppressed because one or more lines are too long

View File

@@ -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

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

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -21,5 +21,4 @@
</div>
</div>
</div>
</div>
<%- include('scroll.ejs') %>
</div>

View File

@@ -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>

View File

@@ -19,5 +19,4 @@
</div>
</div>
</div>
</div>
<%- include('scroll.ejs') %>
</div>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">&deg;F</div>
<div class="temp metric">&deg;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">&deg;F</div>
<div class="temp metric">&deg;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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -12,5 +12,4 @@
<div class="temp high"></div>
</div>
</div>
</div>
<%- include('scroll.ejs') %>
</div>