mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-14 15:49:31 -07:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b37bc5c52 | ||
|
|
8a22e23d5a | ||
|
|
0d508d7f50 | ||
|
|
d85a5ed3b1 | ||
|
|
831e1680e9 | ||
|
|
73cbc0aa81 | ||
|
|
eb412a0cae | ||
|
|
9150d42802 | ||
|
|
54257e4667 | ||
|
|
7d50ce28bd | ||
|
|
c3d863f89f | ||
|
|
996baa78aa | ||
|
|
e81c957416 | ||
|
|
d25a632f7d | ||
|
|
4b34ffabcb | ||
|
|
2db7f30de7 | ||
|
|
5c7a6ab1a4 | ||
|
|
ee4f84689a | ||
|
|
804d9e9e33 | ||
|
|
3e8135a36a | ||
|
|
9c5ed0dcca | ||
|
|
a3c581aa93 | ||
|
|
771ab37eaf | ||
|
|
4b63328b74 | ||
|
|
ae1d004f60 | ||
|
|
2a975d4d6d | ||
|
|
7dd4c1dd24 | ||
|
|
10baefc901 | ||
|
|
46edf1f7e2 | ||
|
|
67dfd7ec08 | ||
|
|
2761f76117 | ||
|
|
13621b6f46 | ||
|
|
b49433f5ff | ||
|
|
1120247c99 | ||
|
|
c5c01e5450 | ||
|
|
0a65221905 | ||
|
|
cc9e613ba7 | ||
|
|
9f9667c895 | ||
|
|
fda44e95fc | ||
|
|
945c12e6c6 | ||
|
|
0fde88cd8f | ||
|
|
c6af9a2913 | ||
|
|
11eba84cdb | ||
|
|
b1c4e6d850 | ||
|
|
90c1ab92b4 | ||
|
|
17585e97c4 | ||
|
|
517cafe40a | ||
|
|
7f7cb96231 | ||
|
|
bfd0c2b02d | ||
|
|
c8b520b752 | ||
|
|
ebface1749 | ||
|
|
137c2f6d08 | ||
|
|
bec80a1ebe | ||
|
|
e34137e430 | ||
|
|
c0e2eaf33a | ||
|
|
975bccdac5 | ||
|
|
8ead95c041 | ||
|
|
8f34aa5139 | ||
|
|
e472b99b44 | ||
|
|
09a21e6f5a | ||
|
|
dd680c61b0 | ||
|
|
79de691eef | ||
|
|
ec83c17ae2 | ||
|
|
5630067530 | ||
|
|
506ac1f4df | ||
|
|
0e0ea3c378 | ||
|
|
bf65b8e426 | ||
|
|
ca272de8bf | ||
|
|
65944dc3b5 | ||
|
|
be41d66de9 | ||
|
|
7a07c67e84 |
13
.github/ISSUE_TEMPLATE/naming _issue.md
vendored
Normal file
13
.github/ISSUE_TEMPLATE/naming _issue.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: Naming issue
|
||||
about: A city, airport or other location is not named correctly
|
||||
title: 'Name Issue: '
|
||||
labels: naming
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
This form is not for reporting a location that you can not find from the search box.
|
||||
|
||||
Use this form to help us rename airports, points of interest and other data provided from the API (rarely updated) to a better name. For example the airport in Broomfield colorado was renamed from "Jeffco" in the API to "Rocky Mountain Metro" it's new name.
|
||||
|
||||
You can also make a pull request on the `[station-overrides.mjs](https://github.com/netbymatt/ws4kp/blob/main/datagenerators/stations-states.mjs)` file which includes instructions on how to make the change directly. This is the preferred method.
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -17,7 +17,4 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript"
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
13
Dockerfile.server
Normal file
13
Dockerfile.server
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --legacy-peer-deps
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV DIST=1
|
||||
CMD ["npm", "start"]
|
||||
215
README.md
215
README.md
@@ -10,7 +10,7 @@ This project aims to bring back the feel of the 90s with a weather forecast that
|
||||
|
||||
Nostalgia. And I enjoy following the weather, especially severe storms.
|
||||
|
||||
It's also a creative outlet for me and keeps my programming skills honed for when I need them for my day job.
|
||||
It's also a creative outlet for me and keeps my programming skills honed for when I need them for my day job.
|
||||
|
||||
### Included technology
|
||||
I've kept this open source, well commented, and made it as library-free as possible to help others interested in programming be able to jump right in and start working with the code.
|
||||
@@ -39,29 +39,95 @@ This project is tightly coupled to [NOAA's Weather API](https://www.weather.gov/
|
||||
If you would like to display weather information for international locations (outside of the USA), please checkout a fork of this project created by [@mwood77](https://github.com/mwood77):
|
||||
- [`ws4kp-international`](https://github.com/mwood77/ws4kp-international)
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
WeatherStar 4000+ supports two deployment modes:
|
||||
|
||||
### Server Deployment (Recommended)
|
||||
|
||||
* Includes Node.js server with caching proxy for better performance (especially when running on a local server for multiple clients)
|
||||
* Server-side request deduplication and caching
|
||||
* Weather API observability and logging
|
||||
* Used by: `npm start`, `DIST=1 npm start`, and `Dockerfile.server`
|
||||
|
||||
### Static Deployment
|
||||
|
||||
* Pure client-side deployment using nginx to serve static files
|
||||
* All API requests are made directly from each browser to the weather services
|
||||
* Browser-based caching
|
||||
* Used by: static file hosting and default `Dockerfile`
|
||||
|
||||
## Run Your WeatherStar
|
||||
To run via Node locally:
|
||||
```
|
||||
|
||||
Ensure you have Node installed. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/netbymatt/ws4kp.git
|
||||
cd ws4kp
|
||||
npm i
|
||||
node index.mjs
|
||||
npm install
|
||||
```
|
||||
|
||||
To run via Docker:
|
||||
### Development Mode (individual JS files, easier debugging)
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### Development Mode without proxy caching
|
||||
```bash
|
||||
STATIC=1 npm start
|
||||
```
|
||||
|
||||
### Production Mode (minified/concatenated JS, faster loading)
|
||||
```bash
|
||||
npm run build
|
||||
DIST=1 npm start
|
||||
```
|
||||
|
||||
### Production Mode without proxy caching (simulates static Docker deployment)
|
||||
```bash
|
||||
npm run build
|
||||
STATIC=1 DIST=1 npm start
|
||||
```
|
||||
|
||||
For all modes, access WeatherStar by going to: http://localhost:8080/
|
||||
|
||||
### Key Differences
|
||||
|
||||
**Development Mode (`npm start`):**
|
||||
- Uses individual JavaScript module files served directly
|
||||
- Easier debugging with source maps and readable code
|
||||
- Slower initial load (many HTTP requests for individual files)
|
||||
- Live file watching and faster development iteration
|
||||
|
||||
**Production Mode (`DIST=1 npm start`):**
|
||||
- Uses minified and concatenated JavaScript bundles
|
||||
- Faster initial load (fewer HTTP requests, smaller file sizes)
|
||||
- Optimized for performance with multiple clients
|
||||
- Requires `npm run build` to generate optimized files
|
||||
|
||||
### Docker Deployments
|
||||
|
||||
To run via Docker using a "static deployment" where everything happens in the browser (no server component, like STATIC=1):
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 ghcr.io/netbymatt/ws4kp
|
||||
```
|
||||
Open your web browser: http://localhost:8080/
|
||||
|
||||
To run via Docker Compose (docker-compose.yaml):
|
||||
To run via Docker using a "server deployment" with a caching proxy server for multi-client performance and enhanced observability (like `npm run build; DIST=1 npm start`):
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.server -t ws4kp-server .
|
||||
docker run -p 8080:8080 ws4kp-server
|
||||
```
|
||||
|
||||
To run via Docker Compose (shown here in static deployment mode):
|
||||
|
||||
```yaml
|
||||
---
|
||||
services:
|
||||
ws4kp:
|
||||
image: ghcr.io/netbymatt/ws4kp
|
||||
container_name: ws4kp
|
||||
environment:
|
||||
environment:
|
||||
# Each argument in the permalink URL can become an environment variable on the Docker host by adding WSQS_
|
||||
# Following the "Sharing a Permalink" example below, here are a few environment variables defined. Visit that section for a
|
||||
# more complete list of configuration options.
|
||||
@@ -73,26 +139,33 @@ services:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Serving static files
|
||||
The app can be served as a static set of files on any web server. Run the provided gulp task to create a set of static distribution files:
|
||||
```
|
||||
### Serving a static app
|
||||
|
||||
There are several ways to deploy WeatherStar as a static app that runs entirely in the browser:
|
||||
|
||||
**Manual static hosting (Apache, nginx, CDN, etc.):**
|
||||
Build static distribution files for upload to any web server:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
The resulting files will be in the /dist folder in the root of the project. These can then be uploaded to a web server for hosting, no server-side scripting is required.
|
||||
|
||||
When using the provided Docker image, the browser will generate `playlist.json`
|
||||
on the fly by scanning the `/music` directory served by nginx. The image
|
||||
intentionally omits this file so the page falls back to scanning the directory.
|
||||
Simply bind mount your music folder and the playlist will be created
|
||||
automatically. If no files are found in `/music`, the built in tracks located in
|
||||
`/music/default/` will be used instead.
|
||||
The resulting files in `/dist` can be uploaded to any web server; no server-side scripting is required.
|
||||
|
||||
The nginx configuration also sets the `X-Weatherstar: true` header on all
|
||||
responses. This uses `add_header ... always` so the header is sent even for
|
||||
404 responses. When `playlist.json` returns a 404 with this header present, the
|
||||
browser falls back to scanning the `/music` directory. If you host the static
|
||||
files elsewhere, be sure to include this header so the playlist can be generated
|
||||
automatically.
|
||||
**Docker static deployment:**
|
||||
The default Docker image uses nginx to serve pre-built static files:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 ghcr.io/netbymatt/ws4kp
|
||||
```
|
||||
|
||||
**Node.js in static mode:**
|
||||
Use the Node.js server as a static file host without the caching proxy:
|
||||
|
||||
```bash
|
||||
STATIC=1 npm start # Use Express to serve development files
|
||||
STATIC=1 DIST=1 npm start # Use Express to serve (minimized) production files
|
||||
```
|
||||
|
||||
## What's different
|
||||
|
||||
@@ -135,7 +208,52 @@ Environment variables that are to be added to the default query string are prefi
|
||||
|
||||
When using the Docker container, these environment variables are read on container start-up to generate the static redirect HTML.
|
||||
|
||||
## Settings
|
||||
|
||||
**Speed:** Controls the playback speed multiplier of the displays, from "Very Fast" (1.5x) to "Very Slow" (0.5x) with "Normal" being 1x
|
||||
|
||||
**Widescreen:** Stretches the background to 16:9 to avoid "pillarboxing" on modern displays
|
||||
|
||||
**Kiosk:** Immediately activates kiosk mode, which hides all settings. Exit by refreshing the page or using `Ctrl-K`. (Kiosk mode is similar to clicking the "Fullscreen" icon, but scales to the current browser viewport instead of activating the browser's actual "Fullscreen" mode.)
|
||||
|
||||
**Sticky Kiosk:** When enabled, stores the kiosk mode preference in local storage so the page automatically enters kiosk mode (maximizing the size of the main weather display without any settings) on subsequent visits. This feature is designed primarily for **iPhone and iPad users** who want to create a Home Screen app experience, since Mobile Safari doesn't support PWA installation via manifest.json or the Fullscreen API:
|
||||
|
||||
**For iOS/iPadOS (Mobile Safari):**
|
||||
|
||||
1. Tap the _Share_ icon and choose **Add to Home Screen**
|
||||
2. Adjust the name as desired and tap **Add**
|
||||
3. Launch the newly-created Home Screen shortcut
|
||||
4. Configure all settings
|
||||
5. Tap to enable **Sticky Kiosk**
|
||||
6. _Make sure everything is configured exactly like you want it!_
|
||||
7. Tap **Kiosk**
|
||||
|
||||
**For Android and Desktop browsers:** The included `manifest.json` file enables PWA (Progressive Web App) installation. To get the best app-like experience:
|
||||
|
||||
1. Configure all your settings first (ignore the "Kiosk" and "Sticky Kiosk" settings)
|
||||
2. Create a permalink using the "Copy Permalink" feature and manually add `&kiosk=true` to the end
|
||||
3. Open the edited permalink URL in your browser
|
||||
4. Look for browser prompts to "Install" or "Add to Home Screen" from the kiosk-enabled URL
|
||||
5. The PWA will launch directly into kiosk mode (without forcing kiosk mode when accessed from the browser)
|
||||
|
||||
For temporary fullscreen during regular browsing, use the fullscreen button in the toolbar.
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
* **iOS/iPadOS limitations**: Mobile Safari strips all URL parameters when adding to Home Screen and runs shortcuts in an isolated environment with separate storage from the main Safari app
|
||||
* After creating a Home Screen app on iOS or iPadOS and activating Kiosk mode, the only way to change settings is to delete the Home Screen shortcut and recreate it
|
||||
* In situations where you _can_ edit a shortcut's URL, you can forcibly remove a "sticky" kiosk setting by adding `&kiosk=false` to the URL (or simply press `Ctrl-K` to exit kiosk mode if a keyboard is available)
|
||||
|
||||
**Scan Lines:** Enables a retro-style scan line effect
|
||||
|
||||
**Scan Lines Style:** Override the "auto" setting in case you prefer a different scale factor than what the automatic heuristics select for your browser and display
|
||||
|
||||
**Units:** Switches between US and metric units. (Note that some text-based products from the National Weather Service APIs contain embedded units that are not converted.)
|
||||
|
||||
**Volume:** Controls the audio level when music is enabled
|
||||
|
||||
## Music
|
||||
|
||||
The WeatherStar had wonderful background music from the smooth jazz and new age genres by artists of the time. Lists of the music that played are available by searching online, but it's all copyrighted music and would be difficult to provide as part of this repository.
|
||||
|
||||
I've used AI tools to create WeatherStar-inspired music tracks that are unencumbered by copyright and are included in this repo. To keep the size down, I've only included 4 tracks. Additional tracks are in a companion repository [ws4kp-music](https://github.com/netbymatt/ws4kp-music).
|
||||
@@ -143,16 +261,42 @@ I've used AI tools to create WeatherStar-inspired music tracks that are unencumb
|
||||
If you're looking for the original music that played during forecasts [TWCClassics](https://twcclassics.com/audio/) has thorough documentation of playlists.
|
||||
|
||||
### Customizing the music
|
||||
Placing .mp3 files in the `/server/music` folder will override the default music included in the repo. Subdirectories will not be scanned. When weatherstar loads in the browser it will load a list if available files and randomize the order when it starts playing. On each loop through the available tracks the order will again be shuffled. If you're using the static files method to host your WeatherStar music is located in `/music`.
|
||||
|
||||
If using Docker, you can bind mount a local folder containing your music files.
|
||||
Mount the folder at `/usr/share/nginx/html/music` so the browser can read the
|
||||
directory listing and build the playlist automatically. If there are no `.mp3`
|
||||
files in `/music`, the built in tracks from `/music/default/` are used.
|
||||
WeatherStar 4000+ supports background music during forecast playback. The music behavior depends on how you deploy the application:
|
||||
|
||||
#### Express server modes (`npm start`, `DIST=1 npm start`, or `Dockerfile.server`)
|
||||
|
||||
When running with Node.js, the server generates a `playlist.json` file by scanning the `./server/music` directory for `.mp3` files. If no files are found in `./server/music`, it falls back to scanning `./server/music/default/`. The playlist is served dynamically at the `/playlist.json` endpoint.
|
||||
|
||||
**Adding your own music:** Place `.mp3` files in `./server/music/`
|
||||
|
||||
**Docker server example:**
|
||||
```bash
|
||||
docker build -f Dockerfile.server -t ws4kp-server .
|
||||
docker run -p 8080:8080 -v /path/to/local/music:/app/server/music ws4kp-server
|
||||
```
|
||||
|
||||
#### Static hosting modes (default `Dockerfile`, nginx, Apache, etc.)
|
||||
|
||||
When hosting static files, there are two scenarios:
|
||||
|
||||
**Static Docker deployment:** The build process creates a `playlist.json` file with default tracks, but the Docker image _intentionally_ removes it to force browser-based directory scanning. The browser attempts to fetch `playlist.json`, receives a 404 response with the `X-Weatherstar` header, which causes it to fallback to scanning the `music/` directory.
|
||||
|
||||
**Manual static hosting:** If you build and upload the files yourself (`npm run build`), `playlist.json` will contain the default tracks unless you customize `./server/music/` before building.
|
||||
|
||||
For directory scanning to work properly:
|
||||
* Your web server must generate directory listings for the `music/` path
|
||||
* Your web server must set the `X-Weatherstar: true` header (the provided nginx configuration does this)
|
||||
|
||||
**Adding your own music:** Place `.mp3` files in `music/` (or bind mount to `/usr/share/nginx/html/music` for Docker)
|
||||
|
||||
**Docker static example:**
|
||||
```bash
|
||||
docker run -p 8080:8080 -v /path/to/local/music:/usr/share/nginx/html/music ghcr.io/netbymatt/ws4kp
|
||||
```
|
||||
|
||||
Subdirectories will not be scanned. When WeatherStar loads in the browser, it randomizes the track order and reshuffles on each loop through the playlist.
|
||||
|
||||
### Music doesn't auto play
|
||||
Ws4kp is muted by default, but if it was unmuted on the last visit it is coded to try and auto play music on subsequent visits. But, it's considered bad form to have a web site play music automatically on load, and I fully agree with this. [Chrome](https://developer.chrome.com/blog/autoplay/#media_engagement_index) and [Firefox](https://hacks.mozilla.org/2019/02/firefox-66-to-block-automatically-playing-audible-video-and-audio/) have extensive details on how and when auto play is allowed.
|
||||
|
||||
@@ -172,9 +316,16 @@ Thanks to the WeatherStar community for providing these discussions to further e
|
||||
* [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg.
|
||||
|
||||
## Customization
|
||||
A hook is provided as `/server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. A sample file is provided at `/server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it.
|
||||
|
||||
When using Docker, mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js` to customize the static build.
|
||||
A hook is provided as `server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. A sample file is provided at `server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it.
|
||||
|
||||
When using Docker:
|
||||
|
||||
* **Static deployment**: Mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js`
|
||||
* **Server deployment**: Mount your `custom.js` file to `/app/server/scripts/custom.js`
|
||||
|
||||
### RSS feeds and custom scroll
|
||||
If you would like your Weatherstar to have custom scrolling text in the bottom blue bar, or show headlines from an rss feed turn on the setting for `Enable RSS Feed/Text` and then enter a URL or text in the resulting text box. Then press set.
|
||||
|
||||
## Issue reporting and feature requests
|
||||
|
||||
|
||||
16130
datagenerators/output/stations-raw.json
Normal file
16130
datagenerators/output/stations-raw.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
19
datagenerators/stations-overrides.mjs
Normal file
19
datagenerators/stations-overrides.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// station overrides are used to change the data for a station that is provided by the api
|
||||
// the most common use is to adjust the city (station name) for formatting or to update an outdated name
|
||||
// a complete station object looks like this:
|
||||
// {
|
||||
// "id": "KMCO", // 4-letter station identifier and key for lookups
|
||||
// "city": "Orlando International Airport", // name displayed for this station
|
||||
// "state": "FL", // state
|
||||
// "lat": 28.41826, // latitude of station
|
||||
// "lon": -81.32413 // longitude of station
|
||||
// }
|
||||
// any or all of the data for a station can be overwritten, follow the existing override patterns below
|
||||
|
||||
const overrides = {
|
||||
KBJC: {
|
||||
city: 'Rocky Mountain Metro',
|
||||
},
|
||||
};
|
||||
|
||||
export default overrides;
|
||||
1247
datagenerators/stations-postprocessor.mjs
Normal file
1247
datagenerators/stations-postprocessor.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { writeFileSync } from 'fs';
|
||||
import https from './https.mjs';
|
||||
import states from './stations-states.mjs';
|
||||
import chunk from './chunk.mjs';
|
||||
import overrides from './stations-overrides.mjs';
|
||||
|
||||
// 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'];
|
||||
@@ -43,23 +44,27 @@ for (let i = 0; i < chunkStates.length; i += 1) {
|
||||
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.json', JSON.stringify(output, null, 2));
|
||||
writeFileSync('./datagenerators/output/stations-raw.json', JSON.stringify(output, null, 2));
|
||||
}
|
||||
while (next && stations.features.length > 0);
|
||||
console.log(`Complete: ${state}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.error(`Unable to get state: ${state}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,14 @@ export default [{
|
||||
allowSamePrecedence: true,
|
||||
},
|
||||
],
|
||||
'no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'import/extensions': [
|
||||
'error',
|
||||
{
|
||||
@@ -97,6 +105,21 @@ export default [{
|
||||
json: 'always',
|
||||
},
|
||||
],
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
devDependencies: [
|
||||
'eslint.config.*',
|
||||
'**/*.config.*',
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'gulpfile.*',
|
||||
'tests/**/*',
|
||||
'gulp/**/*',
|
||||
'datagenerators/**/*',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
ignorePatterns: [
|
||||
'*.min.js',
|
||||
|
||||
@@ -26,11 +26,7 @@ const cloudfront = new CloudFrontClient({ region: 'us-east-1' });
|
||||
|
||||
const RESOURCES_PATH = './dist/resources';
|
||||
|
||||
const jsSourcesData = [
|
||||
'server/scripts/data/travelcities.js',
|
||||
'server/scripts/data/regionalcities.js',
|
||||
'server/scripts/data/stations.js',
|
||||
];
|
||||
// Data is now served as JSON files to avoid redundancy
|
||||
|
||||
const webpackOptions = {
|
||||
mode: 'production',
|
||||
@@ -56,17 +52,21 @@ const webpackOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
const compressJsData = () => src(jsSourcesData)
|
||||
.pipe(concat('data.min.js'))
|
||||
.pipe(terser())
|
||||
.pipe(dest(RESOURCES_PATH));
|
||||
|
||||
const jsVendorSources = [
|
||||
'server/scripts/vendor/auto/nosleep.js',
|
||||
'server/scripts/vendor/auto/swiped-events.js',
|
||||
'server/scripts/vendor/auto/suncalc.js',
|
||||
];
|
||||
|
||||
// Copy metar-taf-parser separately since it's an ES module with locale dependencies
|
||||
const metarVendorSources = [
|
||||
'server/scripts/vendor/auto/metar-taf-parser.mjs',
|
||||
'server/scripts/vendor/auto/locale/en.js',
|
||||
];
|
||||
|
||||
const copyMetarVendor = () => src(metarVendorSources, { base: 'server/scripts/vendor/auto' })
|
||||
.pipe(dest(`${RESOURCES_PATH}/vendor/auto`));
|
||||
|
||||
const compressJsVendor = () => src(jsVendorSources)
|
||||
.pipe(concat('vendor.min.js'))
|
||||
.pipe(terser())
|
||||
@@ -89,6 +89,7 @@ const mjsSources = [
|
||||
'server/scripts/modules/travelforecast.mjs',
|
||||
'server/scripts/modules/progress.mjs',
|
||||
'server/scripts/modules/media.mjs',
|
||||
'server/scripts/modules/custom-rss-feed.mjs',
|
||||
'server/scripts/index.mjs',
|
||||
];
|
||||
|
||||
@@ -113,8 +114,10 @@ const compressHtml = async () => {
|
||||
return src(htmlSources)
|
||||
.pipe(ejs({
|
||||
production: version,
|
||||
serverAvailable: false,
|
||||
version,
|
||||
OVERRIDES,
|
||||
query: {},
|
||||
}))
|
||||
.pipe(rename({ extname: '.html' }))
|
||||
.pipe(htmlmin({ collapseWhitespace: true }))
|
||||
@@ -129,6 +132,13 @@ const otherFiles = [
|
||||
const copyOtherFiles = () => src(otherFiles, { base: 'server/', encoding: false })
|
||||
.pipe(dest('./dist'));
|
||||
|
||||
// Copy JSON data files for static hosting
|
||||
const copyDataFiles = () => src([
|
||||
'datagenerators/output/travelcities.json',
|
||||
'datagenerators/output/regionalcities.json',
|
||||
'datagenerators/output/stations.json',
|
||||
]).pipe(dest('./dist/data'));
|
||||
|
||||
const s3 = s3Upload({
|
||||
useIAM: true,
|
||||
}, {
|
||||
@@ -200,7 +210,7 @@ const buildPlaylist = async () => {
|
||||
return file('playlist.json', JSON.stringify(playlist)).pipe(dest('./dist'));
|
||||
};
|
||||
|
||||
const buildDist = series(clean, parallel(buildJs, compressJsData, compressJsVendor, copyCss, compressHtml, copyOtherFiles, copyImageSources, buildPlaylist));
|
||||
const buildDist = series(clean, parallel(buildJs, compressJsVendor, copyMetarVendor, copyCss, compressHtml, copyOtherFiles, copyDataFiles, copyImageSources, buildPlaylist));
|
||||
|
||||
// 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
|
||||
|
||||
@@ -13,6 +13,12 @@ const vendorFiles = [
|
||||
'./node_modules/swiped-events/src/swiped-events.js',
|
||||
];
|
||||
|
||||
// Special handling for metar-taf-parser - only copy main file and English locale
|
||||
const metarFiles = [
|
||||
'./node_modules/metar-taf-parser/metar-taf-parser.js',
|
||||
'./node_modules/metar-taf-parser/locale/en.js',
|
||||
];
|
||||
|
||||
const copy = () => src(vendorFiles)
|
||||
.pipe(rename((path) => {
|
||||
path.dirname = path.dirname.toLowerCase();
|
||||
@@ -22,6 +28,14 @@ const copy = () => src(vendorFiles)
|
||||
}))
|
||||
.pipe(dest('./server/scripts/vendor/auto'));
|
||||
|
||||
const updateVendor = series(clean, copy);
|
||||
const copyMetar = () => src(metarFiles, { base: './node_modules/metar-taf-parser' })
|
||||
.pipe(rename((path) => {
|
||||
path.basename = path.basename.toLowerCase();
|
||||
path.extname = path.extname.toLowerCase();
|
||||
if (path.basename === 'metar-taf-parser') path.extname = '.mjs';
|
||||
}))
|
||||
.pipe(dest('./server/scripts/vendor/auto'));
|
||||
|
||||
const updateVendor = series(clean, copy, copyMetar);
|
||||
|
||||
export default updateVendor;
|
||||
|
||||
114
index.mjs
114
index.mjs
@@ -1,12 +1,27 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import {
|
||||
weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy,
|
||||
} from './proxy/handlers.mjs';
|
||||
import playlist from './src/playlist.mjs';
|
||||
import OVERRIDES from './src/overrides.mjs';
|
||||
import cache from './proxy/cache.mjs';
|
||||
|
||||
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
|
||||
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
|
||||
const stationInfo = JSON.parse(await readFile('./datagenerators/output/stations.json'));
|
||||
|
||||
const app = express();
|
||||
const port = process.env.WS4KP_PORT ?? 8080;
|
||||
|
||||
// Set X-Weatherstar header globally for playlist fallback detection
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Weatherstar', 'true');
|
||||
next();
|
||||
});
|
||||
|
||||
// template engine
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
@@ -36,6 +51,16 @@ const hasQsVars = Object.entries(qsVars).length > 0;
|
||||
// turn the environment query string into search params
|
||||
const defaultSearchParams = (new URLSearchParams(qsVars)).toString();
|
||||
|
||||
const renderIndex = (req, res, production = false) => {
|
||||
res.render('index', {
|
||||
production,
|
||||
serverAvailable: !process.env?.STATIC, // Disable caching proxy server in static mode
|
||||
version,
|
||||
OVERRIDES,
|
||||
query: req.query,
|
||||
});
|
||||
};
|
||||
|
||||
const index = (req, res) => {
|
||||
// test for no query string in request and if environment query string values were provided
|
||||
if (hasQsVars && Object.keys(req.query).length === 0) {
|
||||
@@ -45,12 +70,8 @@ const index = (req, res) => {
|
||||
res.redirect(307, url.toString());
|
||||
return;
|
||||
}
|
||||
// return the standard page
|
||||
res.render('index', {
|
||||
production: false,
|
||||
version,
|
||||
OVERRIDES,
|
||||
});
|
||||
// return the EJS template page in development mode (serve files from server directory directly)
|
||||
renderIndex(req, res, false);
|
||||
};
|
||||
|
||||
const geoip = (req, res) => {
|
||||
@@ -69,21 +90,85 @@ const geoip = (req, res) => {
|
||||
res.json({});
|
||||
};
|
||||
|
||||
// debugging
|
||||
// Configure static asset caching with proper ETags and cache validation
|
||||
const staticOptions = {
|
||||
etag: true, // Enable ETag generation
|
||||
lastModified: true, // Enable Last-Modified headers
|
||||
setHeaders: (res, path, stat) => {
|
||||
// Generate ETag based on file modification time and size for better cache validation
|
||||
const etag = `"${stat.mtime.getTime().toString(16)}-${stat.size.toString(16)}"`;
|
||||
res.setHeader('ETag', etag);
|
||||
|
||||
if (path.match(/\.(png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$/i)) {
|
||||
// Images and fonts - cache for 1 year (immutable content)
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
} else if (path.match(/\.(css|js|mjs)$/i)) {
|
||||
// Scripts and styles - use cache validation instead of no-cache
|
||||
// This allows browsers to use cached version if ETag matches (304 response)
|
||||
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
} else {
|
||||
// Other files - cache for 1 hour with validation
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600, must-revalidate');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
||||
// Skip setting up routes for the caching proxy server in static mode
|
||||
if (!process.env?.STATIC) {
|
||||
app.use('/api/', weatherProxy);
|
||||
|
||||
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
||||
app.delete(/^\/cache\/.*/, (req, res) => {
|
||||
const path = req.url.replace('/cache', '');
|
||||
const cleared = cache.clearEntry(path);
|
||||
res.json({ cleared, path });
|
||||
});
|
||||
|
||||
// specific proxies for other services
|
||||
app.use('/radar/', radarProxy);
|
||||
app.use('/spc/', outlookProxy);
|
||||
app.use('/mesonet/', mesonetProxy);
|
||||
app.use('/forecast/', forecastProxy);
|
||||
|
||||
// Playlist route is available in server mode (not in static mode)
|
||||
app.get('/playlist.json', playlist);
|
||||
}
|
||||
|
||||
// Data endpoints - serve JSON data with long-term caching
|
||||
const dataEndpoints = {
|
||||
travelcities: travelCities,
|
||||
regionalcities: regionalCities,
|
||||
stations: stationInfo,
|
||||
};
|
||||
|
||||
Object.entries(dataEndpoints).forEach(([name, data]) => {
|
||||
app.get(`/data/${name}.json`, (req, res) => {
|
||||
res.set({
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
res.json(data);
|
||||
});
|
||||
});
|
||||
|
||||
if (process.env?.DIST === '1') {
|
||||
// distribution
|
||||
app.use('/scripts', express.static('./server/scripts'));
|
||||
// Production ("distribution") mode uses pre-baked files in the dist directory
|
||||
// 'npm run build' and then 'DIST=1 npm start'
|
||||
app.use('/scripts', express.static('./server/scripts', staticOptions));
|
||||
app.use('/geoip', geoip);
|
||||
app.use('/', express.static('./dist'));
|
||||
|
||||
// render the EJS template in production mode (serve compressed files from dist directory)
|
||||
app.get('/', (req, res) => { renderIndex(req, res, true); });
|
||||
|
||||
app.use('/', express.static('./dist', staticOptions));
|
||||
} else {
|
||||
// debugging
|
||||
// Development mode serves files from the server directory: 'npm start'
|
||||
app.get('/index.html', index);
|
||||
app.use('/geoip', geoip);
|
||||
app.use('/resources', express.static('./server/scripts/modules'));
|
||||
app.get('/', index);
|
||||
app.get('*name', express.static('./server'));
|
||||
// cors pass-thru to api.weather.gov
|
||||
app.get('/playlist.json', playlist);
|
||||
app.get('*name', express.static('./server', staticOptions));
|
||||
}
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
@@ -94,6 +179,7 @@ const server = app.listen(port, () => {
|
||||
const gracefulShutdown = () => {
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
724
package-lock.json
generated
724
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "5.26.1",
|
||||
"version": "6.0.0",
|
||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.mjs",
|
||||
"stop": "pkill -f 'node index.mjs' || echo 'No process found'",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build:css": "sass --style=compressed ./server/styles/scss/main.scss ./server/styles/main.css",
|
||||
"build": "gulp buildDist",
|
||||
"lint": "eslint ./server/scripts/**/*.mjs",
|
||||
"lint:fix": "eslint --fix ./server/scripts/**/*.mjs"
|
||||
"lint": "eslint ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs",
|
||||
"lint:fix": "eslint --fix ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs",
|
||||
"lintall": "eslint ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs ./datagenerators/**/*.mjs ./gulp/**/*.mjs ./tests/**/*.mjs",
|
||||
"lintall:fix": "eslint --fix ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs ./datagenerators/**/*.mjs ./gulp/**/*.mjs ./tests/**/*.mjs"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -49,8 +53,9 @@
|
||||
"webpack-stream": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.5.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"ejs": "^3.1.5",
|
||||
"express": "^5.1.0"
|
||||
"express": "^5.1.0",
|
||||
"metar-taf-parser": "^6.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
502
proxy/cache.mjs
Normal file
502
proxy/cache.mjs
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* HTTP cache implementation for API proxy that respects cache-control headers
|
||||
* (without external dependencies)
|
||||
*
|
||||
* Features:
|
||||
* - Respects HTTP cache-control headers (s-maxage, max-age)
|
||||
* - Heuristic caching based on Last-Modified headers when no explicit cache directives exist
|
||||
* - Conditional requests using ETags and If-Modified-Since headers to validate stale content
|
||||
* - In-flight request deduplication to prevent multiple simultaneous requests for the same resource
|
||||
* - Comprehensive logging with cache hit/miss statistics and timing information
|
||||
* - Timeout handling and error recovery mechanisms
|
||||
*
|
||||
* The cache uses a three-state system:
|
||||
* - 'fresh': Content is within its TTL and served immediately
|
||||
* - 'stale': Content has expired but can be revalidated with conditional requests (304 Not Modified)
|
||||
* - 'miss': No cached content exists
|
||||
*
|
||||
* @class HttpCache
|
||||
*/
|
||||
|
||||
import https from 'https';
|
||||
|
||||
// Default timeout for upstream requests (matches client-side default)
|
||||
const DEFAULT_REQUEST_TIMEOUT = 15000;
|
||||
|
||||
class HttpCache {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.inFlight = new Map();
|
||||
this.cleanupInterval = null;
|
||||
this.startCleanup();
|
||||
}
|
||||
|
||||
// Parse cache-control header to extract s-maxage or max-age
|
||||
static parseCacheControl(cacheControlHeader) {
|
||||
if (!cacheControlHeader) return 0;
|
||||
|
||||
// Look for s-maxage first (preferred for proxy caches), then max-age
|
||||
const sMaxAgeMatch = cacheControlHeader.match(/s-maxage=(\d+)/i);
|
||||
if (sMaxAgeMatch) {
|
||||
return parseInt(sMaxAgeMatch[1], 10);
|
||||
}
|
||||
|
||||
const maxAgeMatch = cacheControlHeader.match(/max-age=(\d+)/i);
|
||||
if (maxAgeMatch) {
|
||||
return parseInt(maxAgeMatch[1], 10);
|
||||
}
|
||||
|
||||
return 0; // No cache if no cache directives found
|
||||
}
|
||||
|
||||
// Helper method to set filtered headers and our cache policy
|
||||
static setFilteredHeaders(res, headers) {
|
||||
// Strip cache-related headers and pass through others
|
||||
Object.entries(headers || {}).forEach(([key, value]) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
// Skip cache-related headers that should be controlled by our proxy
|
||||
if (!['cache-control', 'expires', 'etag', 'last-modified'].includes(lowerKey)) {
|
||||
res.header(lowerKey, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Set our own cache policy - short cache to ensure browser checks back with our server
|
||||
res.header('cache-control', 'public, max-age=30');
|
||||
}
|
||||
|
||||
// Generate cache key from request
|
||||
static generateKey(req) {
|
||||
const path = req.path || req.url || '/';
|
||||
const url = req.url || req.path || '/';
|
||||
|
||||
// Since this cache is intended only by the frontend, we can use a simple URL-based key
|
||||
return `${path}${url.includes('?') ? url.substring(url.indexOf('?')) : ''}`;
|
||||
}
|
||||
|
||||
// High-level method to handle caching for HTTP proxies
|
||||
async handleRequest(req, res, upstreamUrl, options = {}) {
|
||||
// Check cache status
|
||||
const cacheResult = this.getCachedRequest(req);
|
||||
|
||||
if (cacheResult.status === 'fresh') {
|
||||
const cached = cacheResult.data;
|
||||
res.status(cached.statusCode);
|
||||
HttpCache.setFilteredHeaders(res, cached.headers);
|
||||
res.send(cached.data);
|
||||
return true; // Indicates cache hit
|
||||
}
|
||||
// For 'miss' or 'stale', proceed to upstream request
|
||||
|
||||
// Generate cache key for in-flight tracking
|
||||
const cacheKey = HttpCache.generateKey(req);
|
||||
|
||||
// Build the full URL
|
||||
const queryParams = Object.keys(req.query || {}).reduce((acc, key) => {
|
||||
if (options.skipParams && options.skipParams.includes(key)) return acc;
|
||||
acc[key] = req.query[key];
|
||||
return acc;
|
||||
}, {});
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
const fullUrl = `${upstreamUrl}${req.path}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
// Check if there's already a request in flight for this resource
|
||||
if (this.inFlight.has(cacheKey)) {
|
||||
console.log(`🛫 Wait | ${fullUrl} (request already in flight)`);
|
||||
|
||||
// Track when we start waiting for latency measurement
|
||||
const waitStartTime = Date.now();
|
||||
|
||||
// Wait for the in-flight request to complete
|
||||
try {
|
||||
await this.inFlight.get(cacheKey);
|
||||
|
||||
// After waiting, try cache again (should be populated now if the request was successful)
|
||||
const key = HttpCache.generateKey(req);
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
if (cached && Date.now() <= cached.expiry) {
|
||||
const waitLatency = Date.now() - waitStartTime;
|
||||
|
||||
// Log cache hit with wait latency
|
||||
const age = Math.round((Date.now() - cached.timestamp) / 1000);
|
||||
const remainingTTL = Math.round((cached.expiry - Date.now()) / 1000);
|
||||
const url = cached.url || `${upstreamUrl}${req.path}`;
|
||||
console.log(`🛬 Continue | ${url} (age: ${age}s, remaining: ${remainingTTL}s, waited: ${waitLatency}ms)`);
|
||||
|
||||
res.status(cached.statusCode);
|
||||
HttpCache.setFilteredHeaders(res, cached.headers);
|
||||
res.send(cached.data);
|
||||
return true; // Served from cache after waiting
|
||||
}
|
||||
|
||||
// Fallthrough to make request if cache miss (shouldn't happen but safety net)
|
||||
console.warn(`⚠️ Redo | Cache miss after waiting for in-flight request: ${fullUrl}`);
|
||||
} catch (_error) {
|
||||
// If the in-flight request failed, we'll make our own request
|
||||
console.warn(`⚠️ Redo | In-flight request failed, making new request: ${fullUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create promise for this request
|
||||
const requestPromise = this.makeUpstreamRequest(req, res, fullUrl, options, cacheResult);
|
||||
|
||||
// Store a wrapped promise that doesn't reject for waiters - they just need to know when it's done
|
||||
|
||||
const inflightPromise = requestPromise.catch(() => null);
|
||||
this.inFlight.set(cacheKey, inflightPromise);
|
||||
|
||||
try {
|
||||
// Send the request to the upstream service
|
||||
const result = await requestPromise;
|
||||
return result;
|
||||
} catch (error) {
|
||||
// All errors are handled directly by makeUpstreamRequest so this is a safety net
|
||||
console.error(`💥 Error | Unhandled error in handleRequest: ${error.message}`);
|
||||
return false;
|
||||
} finally {
|
||||
// Always clean up the in-flight tracking
|
||||
this.inFlight.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Make the upstream request, handling caching and conditional requests
|
||||
async makeUpstreamRequest(req, res, fullUrl, options = {}, cacheResult = null) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = {
|
||||
'user-agent': options.userAgent || '(WeatherStar 4000+, ws4000@netbymatt.com)',
|
||||
accept: req.headers?.accept || '*/*',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
// Use the cache result passed from handleRequest (no additional cache call)
|
||||
let staleCache = null;
|
||||
|
||||
if (cacheResult && cacheResult.status === 'stale' && cacheResult.data?.originalHeaders) {
|
||||
staleCache = cacheResult.data;
|
||||
// Add conditional headers based on cached etag or last-modified header
|
||||
if (staleCache.originalHeaders.etag) {
|
||||
headers['if-none-match'] = staleCache.originalHeaders.etag;
|
||||
// console.log(`🏷️ Added | If-None-Match: ${staleCache.originalHeaders.etag} for ${fullUrl}`);
|
||||
} else if (staleCache.originalHeaders['last-modified']) {
|
||||
headers['if-modified-since'] = staleCache.originalHeaders['last-modified'];
|
||||
// console.log(`📅 Added | If-Modified-Since: ${staleCache.originalHeaders['last-modified']} for ${fullUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
let responseHandled = false; // Track if we've already sent a response
|
||||
|
||||
const upstreamReq = https.get(fullUrl, { headers }, (getRes) => {
|
||||
const { statusCode } = getRes;
|
||||
|
||||
// Handle 304 Not Modified responses - refresh stale cache and serve
|
||||
if (statusCode === 304) {
|
||||
if (responseHandled) return; // Prevent double response
|
||||
responseHandled = true;
|
||||
|
||||
if (staleCache) {
|
||||
const newCacheControl = getRes.headers['cache-control'];
|
||||
const newMaxAge = HttpCache.parseCacheControl(newCacheControl);
|
||||
if (newMaxAge > 0) {
|
||||
staleCache.expiry = Date.now() + (newMaxAge * 1000);
|
||||
staleCache.timestamp = Date.now(); // Reset age counter for 304 refresh
|
||||
console.log(`〰️ NoChange | ${fullUrl} (got 304 Not Modified; refreshing cache expiry by ${newMaxAge}s)`);
|
||||
} else {
|
||||
console.log(`📉 NoCache | ${fullUrl} (no valid cache directives in 304, not updating expiry)`);
|
||||
}
|
||||
|
||||
res.status(staleCache.statusCode);
|
||||
HttpCache.setFilteredHeaders(res, staleCache.headers);
|
||||
res.send(staleCache.data);
|
||||
resolve(true); // Cache hit after 304 validation
|
||||
return;
|
||||
}
|
||||
// No stale entry for 304 response (this shouldn't happen!)
|
||||
console.error(`💥 Error | 304 response but no stale cache entry for ${fullUrl}`);
|
||||
res.status(500).json({ error: 'Cache inconsistency error' });
|
||||
resolve(false); // Error handled, response sent
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to handle response after data collection
|
||||
const handleResponse = (data) => {
|
||||
if (responseHandled) return; // Prevent double response
|
||||
responseHandled = true;
|
||||
|
||||
// Log HTTP error status codes
|
||||
if (statusCode >= 400) {
|
||||
console.error(`🚫 ${statusCode} | ${fullUrl}`);
|
||||
}
|
||||
|
||||
// Filter out cache headers before storing - we don't need them in our cache
|
||||
const filteredHeaders = {};
|
||||
Object.entries(getRes.headers || {}).forEach(([key, value]) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (!['cache-control', 'expires', 'etag', 'last-modified'].includes(lowerKey)) {
|
||||
filteredHeaders[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const response = {
|
||||
statusCode,
|
||||
headers: filteredHeaders,
|
||||
data,
|
||||
};
|
||||
|
||||
// Check if this is a server error (5xx) or client error that shouldn't be cached
|
||||
if (statusCode >= 500 && statusCode <= 599) {
|
||||
// For 5xx errors, send response (don't cache, but don't reject since response is sent)
|
||||
res.status(statusCode);
|
||||
HttpCache.setFilteredHeaders(res, getRes.headers);
|
||||
res.send(response.data);
|
||||
resolve(false); // Error response sent successfully
|
||||
return;
|
||||
}
|
||||
|
||||
// For 4xx errors, don't cache but send the response
|
||||
if (statusCode >= 400 && statusCode <= 499) {
|
||||
res.status(statusCode);
|
||||
HttpCache.setFilteredHeaders(res, getRes.headers);
|
||||
res.send(response.data);
|
||||
resolve(true); // Successful HTTP transaction (client error, but valid response; don't retry)
|
||||
return;
|
||||
}
|
||||
|
||||
// Store in cache (pass original headers for cache logic, but store filtered headers)
|
||||
this.storeCachedResponse(req, response, fullUrl, getRes.headers);
|
||||
|
||||
// Send response to client
|
||||
res.status(statusCode);
|
||||
|
||||
// Set filtered headers and our cache policy
|
||||
HttpCache.setFilteredHeaders(res, getRes.headers);
|
||||
|
||||
res.send(response.data);
|
||||
resolve(true); // Indicates successful response from upstream
|
||||
};
|
||||
|
||||
if (options.encoding === 'binary') {
|
||||
// For binary data, collect as Buffer chunks
|
||||
const chunks = [];
|
||||
getRes.on('data', (chunk) => chunks.push(chunk));
|
||||
getRes.on('end', () => handleResponse(Buffer.concat(chunks)));
|
||||
getRes.on('error', (err) => {
|
||||
if (responseHandled) return;
|
||||
responseHandled = true;
|
||||
console.error(`💥 Error | with stream ${fullUrl}: ${err.message}`);
|
||||
res.status(500).json({ error: `Stream error: ${err.message}` });
|
||||
resolve(false); // Error handled, response sent
|
||||
});
|
||||
} else {
|
||||
// For text data, use string encoding
|
||||
let data = '';
|
||||
getRes.setEncoding(options.encoding || 'utf8');
|
||||
getRes.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
getRes.on('end', () => handleResponse(data));
|
||||
getRes.on('error', (err) => {
|
||||
if (responseHandled) return;
|
||||
responseHandled = true;
|
||||
console.error(`💥 Error | with stream ${fullUrl}: ${err.message}`);
|
||||
res.status(500).json({ error: `Stream error: ${err.message}` });
|
||||
resolve(false); // Error handled, response sent
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
upstreamReq.on('error', (err) => {
|
||||
if (responseHandled) return; // Prevent double response
|
||||
responseHandled = true;
|
||||
console.error(`💥 Error | ${fullUrl}: ${err.message}`);
|
||||
res.status(500).json({ error: `Failed to fetch data from ${options.serviceName || 'upstream API'}` });
|
||||
resolve(false); // Error handled, response sent
|
||||
});
|
||||
|
||||
upstreamReq.setTimeout(options.timeout || DEFAULT_REQUEST_TIMEOUT, () => {
|
||||
if (responseHandled) return; // Prevent double response
|
||||
responseHandled = true;
|
||||
|
||||
console.error(`⏲️ Timeout | ${fullUrl} (after ${options.timeout || DEFAULT_REQUEST_TIMEOUT}ms)`);
|
||||
|
||||
// Send timeout response to client
|
||||
res.status(504).json({ error: 'Gateway timeout' });
|
||||
|
||||
// Don't destroy the request immediately - let the response be sent first
|
||||
// Then destroy to clean up the upstream connection
|
||||
setImmediate(() => {
|
||||
if (!upstreamReq.destroyed) {
|
||||
upstreamReq.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
resolve(false); // Timeout handled, response sent
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getCachedRequest(req) {
|
||||
const key = HttpCache.generateKey(req);
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
if (!cached) {
|
||||
return { status: 'miss', data: null };
|
||||
}
|
||||
|
||||
const isExpired = Date.now() > cached.expiry;
|
||||
|
||||
// If fresh, return immediately
|
||||
if (!isExpired) {
|
||||
const age = Math.round((Date.now() - cached.timestamp) / 1000);
|
||||
const remainingTTL = Math.round((cached.expiry - Date.now()) / 1000);
|
||||
console.log(`🎯 Hit | ${cached.url} (age: ${age}s, remaining: ${remainingTTL}s)`);
|
||||
return { status: 'fresh', data: cached };
|
||||
}
|
||||
|
||||
// If stale, return for potential conditional request
|
||||
// const staleAge = Math.round((Date.now() - cached.expiry) / 1000);
|
||||
// console.log(`🕐 Stale | ${cached.url} (expired ${staleAge}s ago, will check upstream)`);
|
||||
return { status: 'stale', data: cached };
|
||||
}
|
||||
|
||||
storeCachedResponse(req, response, url, originalHeaders) {
|
||||
const key = HttpCache.generateKey(req);
|
||||
|
||||
const cacheControl = (originalHeaders || {})['cache-control'];
|
||||
let maxAge = HttpCache.parseCacheControl(cacheControl);
|
||||
let cacheType = '';
|
||||
|
||||
// If no explicit cache directives, try heuristic caching for Last-Modified
|
||||
if (maxAge <= 0) {
|
||||
const lastModified = (originalHeaders || {})['last-modified'];
|
||||
if (lastModified) {
|
||||
maxAge = HttpCache.calculateHeuristicMaxAge(lastModified);
|
||||
cacheType = 'heuristic';
|
||||
}
|
||||
} else {
|
||||
cacheType = 'explicit';
|
||||
}
|
||||
|
||||
// Don't cache if still no valid max-age
|
||||
if (maxAge <= 0) {
|
||||
console.log(`📤 Sent | ${url} (no cache directives; not cached)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = {
|
||||
statusCode: response.statusCode,
|
||||
headers: { ...(response.headers || {}) },
|
||||
data: response.data,
|
||||
expiry: Date.now() + (maxAge * 1000),
|
||||
timestamp: Date.now(),
|
||||
url, // Store the URL for logging
|
||||
originalHeaders: { // Store original headers for conditional requests
|
||||
etag: (originalHeaders || {}).etag,
|
||||
'last-modified': (originalHeaders || {})['last-modified'],
|
||||
},
|
||||
};
|
||||
|
||||
this.cache.set(key, cached);
|
||||
|
||||
console.log(`🌐 Add | ${url} (${cacheType} ${maxAge}s TTL, expires: ${new Date(cached.expiry).toISOString()})`);
|
||||
}
|
||||
|
||||
// Calculate heuristic max-age based on Last-Modified header
|
||||
// RFC 7234: A cache can use heuristic freshness calculation
|
||||
// Common heuristic: 10% of the age of the resource, with limits
|
||||
static calculateHeuristicMaxAge(lastModifiedHeader) {
|
||||
try {
|
||||
const lastModified = new Date(lastModifiedHeader);
|
||||
const now = new Date();
|
||||
const age = (now.getTime() - lastModified.getTime()) / 1000; // age in seconds
|
||||
|
||||
if (age <= 0) return 0;
|
||||
|
||||
// Use 10% of age, but limit between 1 hour and 4 hours
|
||||
const heuristicAge = Math.floor(age * 0.1);
|
||||
const minAge = 60 * 60; // 1 hour
|
||||
const maxAge = 4 * 60 * 60; // 4 hours
|
||||
|
||||
return Math.max(minAge, Math.min(maxAge, heuristicAge));
|
||||
} catch (_error) {
|
||||
return 0; // Invalid date format
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired entries
|
||||
startCleanup() {
|
||||
if (this.cleanupInterval) return;
|
||||
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
|
||||
Array.from(this.cache.entries()).forEach(([key, cached]) => {
|
||||
// Allow stale entries to persist for up to 3 hours before cleanup
|
||||
// This gives us time to make conditional requests and potentially refresh them
|
||||
const staleTimeLimit = 3 * 60 * 60 * 1000;
|
||||
if (now > cached.expiry + staleTimeLimit) {
|
||||
this.cache.delete(key);
|
||||
removedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (removedCount > 0) {
|
||||
console.log(`🧹 Clean | Removed ${removedCount} stale entries (${this.cache.size} remaining)`);
|
||||
}
|
||||
}, 5 * 60 * 1000); // Cleanup every 5 minutes
|
||||
}
|
||||
|
||||
// Cache statistics
|
||||
getStats() {
|
||||
const now = Date.now();
|
||||
let expired = 0;
|
||||
let valid = 0;
|
||||
|
||||
Array.from(this.cache.values()).forEach((cached) => {
|
||||
if (now > cached.expiry) {
|
||||
expired += 1;
|
||||
} else {
|
||||
valid += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
valid,
|
||||
expired,
|
||||
inFlight: this.inFlight.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear all cache entries
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
console.log('🗑️ Clear | Cache cleared');
|
||||
}
|
||||
|
||||
// Clear a specific cache entry by path
|
||||
clearEntry(path) {
|
||||
const key = path;
|
||||
const deleted = this.cache.delete(key);
|
||||
if (deleted) {
|
||||
console.log(`🗑️ Clear | ${path} removed from cache`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop cleanup interval
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
this.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance of our cache
|
||||
const cache = new HttpCache();
|
||||
|
||||
export default cache;
|
||||
52
proxy/handlers.mjs
Normal file
52
proxy/handlers.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
// Consolidated proxy handlers for all external API requests with caching
|
||||
|
||||
import cache from './cache.mjs';
|
||||
import OVERRIDES from '../src/overrides.mjs';
|
||||
|
||||
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
||||
export const weatherProxy = async (req, res) => {
|
||||
await cache.handleRequest(req, res, 'https://api.weather.gov', {
|
||||
serviceName: 'Weather.gov',
|
||||
skipParams: ['u'],
|
||||
});
|
||||
};
|
||||
|
||||
// Radar proxy for weather radar images
|
||||
export const radarProxy = async (req, res) => {
|
||||
await cache.handleRequest(req, res, 'https://radar.weather.gov', {
|
||||
serviceName: 'Radar',
|
||||
skipParams: ['u'],
|
||||
encoding: 'binary', // Radar images are binary data
|
||||
});
|
||||
};
|
||||
|
||||
// SPC (Storm Prediction Center) outlook proxy
|
||||
export const outlookProxy = async (req, res) => {
|
||||
await cache.handleRequest(req, res, 'https://www.spc.noaa.gov', {
|
||||
serviceName: 'SPC Outlook',
|
||||
skipParams: ['u'],
|
||||
});
|
||||
};
|
||||
|
||||
// Iowa State Mesonet proxy with configurable host
|
||||
export const mesonetProxy = async (req, res) => {
|
||||
// Determine if this is a binary file (images)
|
||||
const isBinary = req.path.match(/\.(png|jpg|jpeg|gif|webp|ico)$/i);
|
||||
|
||||
// Use override radar host if provided, otherwise default to mesonet
|
||||
const radarHost = OVERRIDES.RADAR_HOST || 'mesonet.agron.iastate.edu';
|
||||
|
||||
await cache.handleRequest(req, res, `https://${radarHost}`, {
|
||||
serviceName: `Iowa State Mesonet (${radarHost})`,
|
||||
skipParams: [], // No parameters to skip for Mesonet
|
||||
encoding: isBinary ? 'binary' : 'utf8', // Use binary encoding for images
|
||||
});
|
||||
};
|
||||
|
||||
// Legacy forecast.weather.gov API proxy
|
||||
export const forecastProxy = async (req, res) => {
|
||||
await cache.handleRequest(req, res, 'https://forecast.weather.gov', {
|
||||
serviceName: 'Forecast.weather.gov',
|
||||
skipParams: ['u'],
|
||||
});
|
||||
};
|
||||
BIN
server/images/icons/current-conditions/No-Data.png
Normal file
BIN
server/images/icons/current-conditions/No-Data.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
server/images/logos/app-icon-180.png
Normal file
BIN
server/images/logos/app-icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,243 +0,0 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const TravelCities = [
|
||||
{
|
||||
Name: 'Atlanta',
|
||||
Latitude: 33.749,
|
||||
Longitude: -84.388,
|
||||
point: {
|
||||
x: 51,
|
||||
y: 87,
|
||||
wfo: 'FFC',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Boston',
|
||||
Latitude: 42.3584,
|
||||
Longitude: -71.0598,
|
||||
point: {
|
||||
x: 71,
|
||||
y: 90,
|
||||
wfo: 'BOX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Chicago',
|
||||
Latitude: 41.9796,
|
||||
Longitude: -87.9045,
|
||||
point: {
|
||||
x: 66,
|
||||
y: 77,
|
||||
wfo: 'LOT',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Cleveland',
|
||||
Latitude: 41.4995,
|
||||
Longitude: -81.6954,
|
||||
point: {
|
||||
x: 83,
|
||||
y: 65,
|
||||
wfo: 'CLE',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Dallas',
|
||||
Latitude: 32.8959,
|
||||
Longitude: -97.0372,
|
||||
point: {
|
||||
x: 80,
|
||||
y: 109,
|
||||
wfo: 'FWD',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Denver',
|
||||
Latitude: 39.7391,
|
||||
Longitude: -104.9847,
|
||||
point: {
|
||||
x: 63,
|
||||
y: 61,
|
||||
wfo: 'BOU',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Detroit',
|
||||
Latitude: 42.3314,
|
||||
Longitude: -83.0457,
|
||||
point: {
|
||||
x: 66,
|
||||
y: 34,
|
||||
wfo: 'DTX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Hartford',
|
||||
Latitude: 41.7637,
|
||||
Longitude: -72.6851,
|
||||
point: {
|
||||
x: 21,
|
||||
y: 54,
|
||||
wfo: 'BOX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Houston',
|
||||
Latitude: 29.7633,
|
||||
Longitude: -95.3633,
|
||||
point: {
|
||||
x: 65,
|
||||
y: 97,
|
||||
wfo: 'HGX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Indianapolis',
|
||||
Latitude: 39.7684,
|
||||
Longitude: -86.158,
|
||||
point: {
|
||||
x: 58,
|
||||
y: 69,
|
||||
wfo: 'IND',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Los Angeles',
|
||||
Latitude: 34.0522,
|
||||
Longitude: -118.2437,
|
||||
point: {
|
||||
x: 155,
|
||||
y: 45,
|
||||
wfo: 'LOX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Miami',
|
||||
Latitude: 25.7743,
|
||||
Longitude: -80.1937,
|
||||
point: {
|
||||
x: 110,
|
||||
y: 51,
|
||||
wfo: 'MFL',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Minneapolis',
|
||||
Latitude: 44.98,
|
||||
Longitude: -93.2638,
|
||||
point: {
|
||||
x: 108,
|
||||
y: 72,
|
||||
wfo: 'MPX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'New York',
|
||||
Latitude: 40.7142,
|
||||
Longitude: -74.0059,
|
||||
point: {
|
||||
x: 33,
|
||||
y: 35,
|
||||
wfo: 'OKX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Norfolk',
|
||||
Latitude: 36.8468,
|
||||
Longitude: -76.2852,
|
||||
point: {
|
||||
x: 90,
|
||||
y: 52,
|
||||
wfo: 'AKQ',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Orlando',
|
||||
Latitude: 28.5383,
|
||||
Longitude: -81.3792,
|
||||
point: {
|
||||
x: 26,
|
||||
y: 68,
|
||||
wfo: 'MLB',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Philadelphia',
|
||||
Latitude: 39.9523,
|
||||
Longitude: -75.1638,
|
||||
point: {
|
||||
x: 50,
|
||||
y: 76,
|
||||
wfo: 'PHI',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Pittsburgh',
|
||||
Latitude: 40.4406,
|
||||
Longitude: -79.9959,
|
||||
point: {
|
||||
x: 78,
|
||||
y: 66,
|
||||
wfo: 'PBZ',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'St. Louis',
|
||||
Latitude: 38.6273,
|
||||
Longitude: -90.1979,
|
||||
point: {
|
||||
x: 95,
|
||||
y: 74,
|
||||
wfo: 'LSX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'San Francisco',
|
||||
Latitude: 37.7749,
|
||||
Longitude: -122.4194,
|
||||
point: {
|
||||
x: 85,
|
||||
y: 105,
|
||||
wfo: 'MTR',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Seattle',
|
||||
Latitude: 47.6062,
|
||||
Longitude: -122.3321,
|
||||
point: {
|
||||
x: 125,
|
||||
y: 68,
|
||||
wfo: 'SEW',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Syracuse',
|
||||
Latitude: 43.0481,
|
||||
Longitude: -76.1474,
|
||||
point: {
|
||||
x: 52,
|
||||
y: 99,
|
||||
wfo: 'BGM',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Tampa',
|
||||
Latitude: 27.9475,
|
||||
Longitude: -82.4584,
|
||||
point: {
|
||||
x: 71,
|
||||
y: 97,
|
||||
wfo: 'TBW',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Washington DC',
|
||||
Latitude: 38.8951,
|
||||
Longitude: -77.0364,
|
||||
point: {
|
||||
x: 97,
|
||||
y: 71,
|
||||
wfo: 'LWX',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,12 +1,14 @@
|
||||
import { json } from './modules/utils/fetch.mjs';
|
||||
import noSleep from './modules/utils/nosleep.mjs';
|
||||
import {
|
||||
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived,
|
||||
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, isIOS,
|
||||
} from './modules/navigation.mjs';
|
||||
import { round2 } from './modules/utils/units.mjs';
|
||||
import { parseQueryString } from './modules/share.mjs';
|
||||
import settings from './modules/settings.mjs';
|
||||
import AutoComplete from './modules/autocomplete.mjs';
|
||||
import { loadAllData } from './modules/utils/data-loader.mjs';
|
||||
import { debugFlag } from './modules/utils/debug.mjs';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
@@ -24,11 +26,27 @@ const categories = [
|
||||
'Postal', 'Populated Place',
|
||||
];
|
||||
const category = categories.join(',');
|
||||
const TXT_ADDRESS_SELECTOR = '#txtAddress';
|
||||
const TXT_ADDRESS_SELECTOR = '#txtLocation';
|
||||
const TOGGLE_FULL_SCREEN_SELECTOR = '#ToggleFullScreen';
|
||||
const BNT_GET_GPS_SELECTOR = '#btnGetGps';
|
||||
|
||||
const init = () => {
|
||||
const init = async () => {
|
||||
// Load core data first - app cannot function without it
|
||||
try {
|
||||
await loadAllData(typeof OVERRIDES !== 'undefined' && OVERRIDES.VERSION ? OVERRIDES.VERSION : '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load core application data:', error);
|
||||
// Show error message to user and halt initialization
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<h2>Unable to load Weather Data</h2>
|
||||
<p>The application cannot start because core data failed to load.</p>
|
||||
<p>Please check your connection and try refreshing.</p>
|
||||
</div>
|
||||
`;
|
||||
return; // Stop initialization
|
||||
}
|
||||
|
||||
document.querySelector(TXT_ADDRESS_SELECTOR).addEventListener('focus', (e) => {
|
||||
e.target.select();
|
||||
});
|
||||
@@ -39,7 +57,15 @@ const init = () => {
|
||||
document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
|
||||
document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick);
|
||||
document.querySelector('#ToggleScanlines').addEventListener('click', btnNavigateToggleScanlines);
|
||||
document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick);
|
||||
|
||||
// Hide fullscreen button on iOS since it doesn't support true fullscreen
|
||||
const fullscreenButton = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
|
||||
if (isIOS()) {
|
||||
fullscreenButton.style.display = 'none';
|
||||
} else {
|
||||
fullscreenButton.addEventListener('click', btnFullScreenClick);
|
||||
}
|
||||
|
||||
const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR);
|
||||
btnGetGps.addEventListener('click', btnGetGpsClick);
|
||||
if (!navigator.geolocation) btnGetGps.style.display = 'none';
|
||||
@@ -47,9 +73,6 @@ const init = () => {
|
||||
document.querySelector('#divTwc').addEventListener('mousemove', () => {
|
||||
if (document.fullscreenElement) updateFullScreenNavigate();
|
||||
});
|
||||
// local change detection when exiting full screen via ESC key (or other non button click methods)
|
||||
window.addEventListener('resize', fullScreenResizeCheck);
|
||||
fullScreenResizeCheck.wasFull = false;
|
||||
|
||||
document.querySelector('#btnGetLatLng').addEventListener('click', () => autoComplete.directFormSubmit());
|
||||
|
||||
@@ -89,6 +112,7 @@ const init = () => {
|
||||
const query = parsedParameters.latLonQuery ?? localStorage.getItem('latLonQuery');
|
||||
const latLon = parsedParameters.latLon ?? localStorage.getItem('latLon');
|
||||
const fromGPS = localStorage.getItem('latLonFromGPS') && !loadFromParsed;
|
||||
|
||||
if (query && latLon && !fromGPS) {
|
||||
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
|
||||
txtAddress.value = query;
|
||||
@@ -98,9 +122,21 @@ const init = () => {
|
||||
btnGetGpsClick();
|
||||
}
|
||||
|
||||
// if kiosk mode was set via the query string, also play immediately
|
||||
settings.kiosk.value = parsedParameters['settings-kiosk-checkbox'] === 'true';
|
||||
const play = parsedParameters['settings-kiosk-checkbox'] ?? localStorage.getItem('play');
|
||||
// Handle kiosk mode initialization
|
||||
const urlKioskCheckbox = parsedParameters['settings-kiosk-checkbox'];
|
||||
|
||||
// If kiosk=false is specified, disable kiosk mode and clear any stored value
|
||||
if (urlKioskCheckbox === 'false') {
|
||||
settings.kiosk.value = false;
|
||||
// Clear stored value by using conditional storage with false
|
||||
settings.kiosk.conditionalStoreToLocalStorage(false, false);
|
||||
} else if (urlKioskCheckbox === 'true') {
|
||||
// if kiosk mode was set via the query string, enable it
|
||||
settings.kiosk.value = true;
|
||||
}
|
||||
|
||||
// Auto-play logic: also play immediately if kiosk mode is enabled
|
||||
const play = settings.kiosk.value || urlKioskCheckbox === 'true' ? 'true' : localStorage.getItem('play');
|
||||
if (play === null || play === 'true') postMessage('navButton', 'play');
|
||||
|
||||
document.querySelector('#btnClearQuery').addEventListener('click', () => {
|
||||
@@ -125,6 +161,7 @@ const init = () => {
|
||||
};
|
||||
|
||||
const autocompleteOnSelect = async (suggestion) => {
|
||||
// Note: it's fine that this uses json instead of safeJson since it's infrequent and user-initiated
|
||||
const data = await json('https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find', {
|
||||
data: {
|
||||
text: suggestion.value,
|
||||
@@ -171,27 +208,42 @@ const btnFullScreenClick = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const enterFullScreen = () => {
|
||||
// This is async because modern browsers return a Promise from requestFullscreen
|
||||
const enterFullScreen = async () => {
|
||||
const element = document.querySelector('#divTwc');
|
||||
|
||||
// Supports most browsers and their versions.
|
||||
const requestMethod = element.requestFullScreen || element.webkitRequestFullScreen
|
||||
|| element.mozRequestFullScreen || element.msRequestFullscreen;
|
||||
const requestMethod = element.requestFullscreen || element.webkitRequestFullscreen || element.mozRequestFullscreen || element.msRequestFullscreen;
|
||||
|
||||
if (requestMethod) {
|
||||
// Native full screen.
|
||||
requestMethod.call(element, { navigationUI: 'hide' });
|
||||
try {
|
||||
// Native full screen with options for optimal display
|
||||
await requestMethod.call(element, {
|
||||
navigationUI: 'hide',
|
||||
allowsInlineMediaPlayback: true,
|
||||
});
|
||||
|
||||
if (debugFlag('fullscreen')) {
|
||||
setTimeout(() => {
|
||||
console.log(`🖥️ Fullscreen engaged. window=${window.innerWidth}x${window.innerHeight} fullscreenElement=${!!document.fullscreenElement}`);
|
||||
}, 150);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Fullscreen request failed:', error);
|
||||
}
|
||||
} else {
|
||||
// iOS doesn't support FullScreen API.
|
||||
window.scrollTo(0, 0);
|
||||
resize(true); // Force resize for iOS
|
||||
}
|
||||
resize();
|
||||
updateFullScreenNavigate();
|
||||
|
||||
// change hover text and image
|
||||
const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
|
||||
img.src = 'images/nav/ic_fullscreen_exit_white_24dp_2x.png';
|
||||
img.title = 'Exit fullscreen';
|
||||
if (img && img.style.display !== 'none') {
|
||||
img.src = 'images/nav/ic_fullscreen_exit_white_24dp_2x.png';
|
||||
img.title = 'Exit fullscreen';
|
||||
}
|
||||
};
|
||||
|
||||
const exitFullscreen = () => {
|
||||
@@ -202,20 +254,22 @@ const exitFullscreen = () => {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
} else if (document.mozCancelFullscreen) {
|
||||
document.mozCancelFullscreen();
|
||||
} else if (document.msExitFullscreen) {
|
||||
document.msExitFullscreen();
|
||||
}
|
||||
resize();
|
||||
// Note: resize will be called by fullscreenchange event listener
|
||||
exitFullScreenVisibilityChanges();
|
||||
};
|
||||
|
||||
const exitFullScreenVisibilityChanges = () => {
|
||||
// change hover text and image
|
||||
const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
|
||||
img.src = 'images/nav/ic_fullscreen_white_24dp_2x.png';
|
||||
img.title = 'Enter fullscreen';
|
||||
if (img && img.style.display !== 'none') {
|
||||
img.src = 'images/nav/ic_fullscreen_white_24dp_2x.png';
|
||||
img.title = 'Enter fullscreen';
|
||||
}
|
||||
document.querySelector('#divTwc').classList.remove('no-cursor');
|
||||
const divTwcBottom = document.querySelector('#divTwcBottom');
|
||||
divTwcBottom.classList.remove('hidden');
|
||||
@@ -295,10 +349,20 @@ const updateFullScreenNavigate = () => {
|
||||
};
|
||||
|
||||
const documentKeydown = (e) => {
|
||||
// don't trigger on ctrl/alt/shift modified key
|
||||
if (e.altKey || e.ctrlKey || e.shiftKey) return false;
|
||||
const { key } = e;
|
||||
|
||||
// Handle Ctrl+K to exit kiosk mode (even when other modifiers would normally be ignored)
|
||||
if (e.ctrlKey && (key === 'k' || key === 'K')) {
|
||||
e.preventDefault();
|
||||
if (settings.kiosk?.value) {
|
||||
settings.kiosk.value = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// don't trigger on ctrl/alt/shift modified key for other shortcuts
|
||||
if (e.altKey || e.ctrlKey || e.shiftKey) return false;
|
||||
|
||||
if (document.fullscreenElement || document.activeElement === document.body) {
|
||||
switch (key) {
|
||||
case ' ': // Space
|
||||
@@ -397,21 +461,6 @@ const getForecastFromLatLon = (latitude, longitude, fromGps = false) => {
|
||||
});
|
||||
};
|
||||
|
||||
// check for change in full screen triggered by browser and run local functions
|
||||
const fullScreenResizeCheck = () => {
|
||||
if (fullScreenResizeCheck.wasFull && !document.fullscreenElement) {
|
||||
// leaving full screen
|
||||
exitFullScreenVisibilityChanges();
|
||||
}
|
||||
if (!fullScreenResizeCheck.wasFull && document.fullscreenElement) {
|
||||
// entering full screen
|
||||
// can't do much here because a UI interaction is required to change the full screen div element
|
||||
}
|
||||
|
||||
// store state of fullscreen element for next change detection
|
||||
fullScreenResizeCheck.wasFull = !!document.fullscreenElement;
|
||||
};
|
||||
|
||||
const getCustomCode = async () => {
|
||||
// fetch the custom file and see if it returns a 200 status
|
||||
const response = await fetch('scripts/custom.js', { method: 'HEAD' });
|
||||
|
||||
@@ -113,17 +113,28 @@ class Almanac extends WeatherDisplay {
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
const info = this.data;
|
||||
|
||||
// Generate sun data grid in reading order (left-to-right, top-to-bottom)
|
||||
|
||||
// Set day names
|
||||
const Today = DateTime.local();
|
||||
const Tomorrow = Today.plus({ days: 1 });
|
||||
this.elem.querySelector('.day-1').textContent = Today.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.day-2').textContent = Tomorrow.toLocaleString({ weekday: 'long' });
|
||||
|
||||
// sun and moon data
|
||||
this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.day-2').innerHTML = Tomorrow.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.rise-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunrise));
|
||||
this.elem.querySelector('.rise-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunrise));
|
||||
this.elem.querySelector('.set-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunset));
|
||||
this.elem.querySelector('.set-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunset));
|
||||
const todaySunrise = DateTime.fromJSDate(info.sun[0].sunrise);
|
||||
const todaySunset = DateTime.fromJSDate(info.sun[0].sunset);
|
||||
const [todaySunriseFormatted, todaySunsetFormatted] = formatTimesForColumn([todaySunrise, todaySunset]);
|
||||
this.elem.querySelector('.rise-1').textContent = todaySunriseFormatted;
|
||||
this.elem.querySelector('.set-1').textContent = todaySunsetFormatted;
|
||||
|
||||
const tomorrowSunrise = DateTime.fromJSDate(info.sun[1].sunrise);
|
||||
const tomorrowSunset = DateTime.fromJSDate(info.sun[1].sunset);
|
||||
const [tomorrowSunriseFormatted, tomorrowSunsetformatted] = formatTimesForColumn([tomorrowSunrise, tomorrowSunset]);
|
||||
this.elem.querySelector('.rise-2').textContent = tomorrowSunriseFormatted;
|
||||
this.elem.querySelector('.set-2').textContent = tomorrowSunsetformatted;
|
||||
|
||||
// Moon data
|
||||
const days = info.moon.map((MoonPhase) => {
|
||||
const fill = {};
|
||||
|
||||
@@ -168,7 +179,20 @@ const imageName = (type) => {
|
||||
}
|
||||
};
|
||||
|
||||
const timeFormat = (dt) => dt.setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
const formatTimesForColumn = (times) => {
|
||||
const formatted = times.map((dt) => dt.setZone(timeZone()).toFormat('h:mm a').toUpperCase());
|
||||
|
||||
// Check if any time has a 2-digit hour (starts with '1')
|
||||
const hasTwoDigitHour = formatted.some((time) => time.startsWith('1'));
|
||||
|
||||
// If mixed digit lengths, pad single-digit hours with non-breaking space
|
||||
if (hasTwoDigitHour) {
|
||||
return formatted.map((time) => (time.startsWith('1') ? time : `\u00A0${time}`));
|
||||
}
|
||||
|
||||
// Otherwise, no padding needed
|
||||
return formatted;
|
||||
};
|
||||
|
||||
// register display
|
||||
const display = new Almanac(9, 'almanac');
|
||||
|
||||
@@ -192,7 +192,7 @@ class AutoComplete {
|
||||
|
||||
let result = this.cachedResponses[search];
|
||||
if (!result) {
|
||||
// make the request
|
||||
// make the request; using json here instead of safeJson is fine because it's infrequent and user-initiated
|
||||
const resultRaw = await json(url);
|
||||
|
||||
// use the provided parser
|
||||
@@ -296,8 +296,11 @@ class AutoComplete {
|
||||
|
||||
// if a click is detected on the page, generally we hide the suggestions, unless the click was within the autocomplete elements
|
||||
checkOutsideClick(e) {
|
||||
if (e.target.id === 'txtAddress') return;
|
||||
if (e.target?.parentNode?.classList.contains(this.options.containerClass)) return;
|
||||
if (e.target.id === 'txtLocation') return;
|
||||
// Fix autocomplete crash on outside click detection
|
||||
// Add optional chaining to prevent TypeError when checking classList.contains()
|
||||
// on elements that may not have a classList property.
|
||||
if (e.target?.parentNode?.classList?.contains(this.options.containerClass)) return;
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
// current weather conditions display
|
||||
import STATUS from './status.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { directionToNSEW } from './utils/calc.mjs';
|
||||
import { locationCleanup } from './utils/string.mjs';
|
||||
import { getLargeIcon } from './icons.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import augmentObservationWithMetar from './utils/metar.mjs';
|
||||
import {
|
||||
temperature, windSpeed, pressure, distanceMeters, distanceKilometers,
|
||||
} from './utils/units.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import { isDataStale, enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||
|
||||
// some stations prefixed do not provide all the necessary data
|
||||
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
|
||||
|
||||
const REQUIRED_VALUES = [
|
||||
'windSpeed',
|
||||
'dewpoint',
|
||||
'barometricPressure',
|
||||
'visibility',
|
||||
'relativeHumidity',
|
||||
];
|
||||
class CurrentWeather extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Current Conditions', true);
|
||||
@@ -44,48 +40,107 @@ class CurrentWeather extends WeatherDisplay {
|
||||
while (!observations && stationNum < filteredStations.length) {
|
||||
// get the station
|
||||
station = filteredStations[stationNum];
|
||||
const stationId = station.properties.stationIdentifier;
|
||||
|
||||
stationNum += 1;
|
||||
|
||||
let candidateObservation;
|
||||
try {
|
||||
// station observations
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
observations = await json(`${station.id}/observations`, {
|
||||
candidateObservation = await safeJson(`${station.id}/observations`, {
|
||||
data: {
|
||||
limit: 2,
|
||||
limit: 2, // we need the two most recent observations to calculate pressure direction
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
|
||||
if (observations.features.length === 0) throw new Error(`No features returned for station: ${station.properties.stationIdentifier}, trying next station`);
|
||||
|
||||
// one weather value in the right side column is allowed to be missing. Count them up.
|
||||
// eslint-disable-next-line no-loop-func
|
||||
const valuesCount = REQUIRED_VALUES.reduce((prev, cur) => {
|
||||
const value = observations.features[0].properties?.[cur]?.value;
|
||||
if (value !== null && value !== undefined) return prev + 1;
|
||||
// ceiling is a special case :,-(
|
||||
const ceiling = observations.features[0].properties?.cloudLayers[0]?.base?.value;
|
||||
if (cur === 'ceiling' && ceiling !== null && ceiling !== undefined) return prev + 1;
|
||||
return prev;
|
||||
}, 0);
|
||||
|
||||
// test data quality
|
||||
if (observations.features[0].properties.temperature.value === null
|
||||
|| observations.features[0].properties.textDescription === null
|
||||
|| observations.features[0].properties.textDescription === ''
|
||||
|| observations.features[0].properties.icon === null
|
||||
|| valuesCount < REQUIRED_VALUES.length - 1) {
|
||||
observations = undefined;
|
||||
throw new Error(`Incomplete data set for: ${station.properties.stationIdentifier}, trying next station`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
observations = undefined;
|
||||
console.error(`Unexpected error getting Current Conditions for station ${stationId}: ${error.message} (trying next station)`);
|
||||
candidateObservation = undefined;
|
||||
}
|
||||
|
||||
// Check if request was successful and has data
|
||||
if (candidateObservation && candidateObservation.features?.length > 0) {
|
||||
// Attempt making observation data usable with METAR data
|
||||
const originalData = { ...candidateObservation.features[0].properties };
|
||||
candidateObservation.features[0].properties = augmentObservationWithMetar(candidateObservation.features[0].properties);
|
||||
const metarFields = [
|
||||
{ name: 'temperature', check: (orig, metar) => orig.temperature?.value === null && metar.temperature?.value !== null },
|
||||
{ name: 'windSpeed', check: (orig, metar) => orig.windSpeed?.value === null && metar.windSpeed?.value !== null },
|
||||
{ name: 'windDirection', check: (orig, metar) => orig.windDirection?.value === null && metar.windDirection?.value !== null },
|
||||
{ name: 'windGust', check: (orig, metar) => orig.windGust?.value === null && metar.windGust?.value !== null },
|
||||
{ name: 'dewpoint', check: (orig, metar) => orig.dewpoint?.value === null && metar.dewpoint?.value !== null },
|
||||
{ name: 'barometricPressure', check: (orig, metar) => orig.barometricPressure?.value === null && metar.barometricPressure?.value !== null },
|
||||
{ name: 'relativeHumidity', check: (orig, metar) => orig.relativeHumidity?.value === null && metar.relativeHumidity?.value !== null },
|
||||
{ name: 'visibility', check: (orig, metar) => orig.visibility?.value === null && metar.visibility?.value !== null },
|
||||
{ name: 'ceiling', check: (orig, metar) => orig.cloudLayers?.[0]?.base?.value === null && metar.cloudLayers?.[0]?.base?.value !== null },
|
||||
];
|
||||
const augmentedData = candidateObservation.features[0].properties;
|
||||
const metarReplacements = metarFields.filter((field) => field.check(originalData, augmentedData)).map((field) => field.name);
|
||||
if (debugFlag('currentweather') && metarReplacements.length > 0) {
|
||||
console.log(`Current Conditions for station ${stationId} were augmented with METAR data for ${metarReplacements.join(', ')}`);
|
||||
}
|
||||
|
||||
// test data quality - check required fields and allow one optional field to be missing
|
||||
const requiredFields = [
|
||||
{ name: 'temperature', check: (props) => props.temperature?.value === null, required: true },
|
||||
{ name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '', required: true },
|
||||
{ name: 'icon', check: (props) => props.icon === null, required: true },
|
||||
{ name: 'windSpeed', check: (props) => props.windSpeed?.value === null, required: false },
|
||||
{ name: 'dewpoint', check: (props) => props.dewpoint?.value === null, required: false },
|
||||
{ name: 'barometricPressure', check: (props) => props.barometricPressure?.value === null, required: false },
|
||||
{ name: 'visibility', check: (props) => props.visibility?.value === null, required: false },
|
||||
{ name: 'relativeHumidity', check: (props) => props.relativeHumidity?.value === null, required: false },
|
||||
{ name: 'ceiling', check: (props) => props.cloudLayers?.[0]?.base?.value === null, required: false },
|
||||
];
|
||||
|
||||
// Use enhanced observation with MapClick fallback
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const enhancedResult = await enhanceObservationWithMapClick(augmentedData, {
|
||||
requiredFields,
|
||||
maxOptionalMissing: 1, // Allow one optional field to be missing
|
||||
stationId,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
debugContext: 'currentweather',
|
||||
});
|
||||
|
||||
candidateObservation.features[0].properties = enhancedResult.data;
|
||||
const { missingFields } = enhancedResult;
|
||||
const missingRequired = missingFields.filter((fieldName) => {
|
||||
const field = requiredFields.find((f) => f.name === fieldName && f.required);
|
||||
return !!field;
|
||||
});
|
||||
const missingOptional = missingFields.filter((fieldName) => {
|
||||
const field = requiredFields.find((f) => f.name === fieldName && !f.required);
|
||||
return !!field;
|
||||
});
|
||||
const missingOptionalCount = missingOptional.length;
|
||||
|
||||
// Check final data quality
|
||||
// Allow one optional field to be missing
|
||||
if (missingRequired.length === 0 && missingOptionalCount <= 1) {
|
||||
// Station data is good, use it
|
||||
observations = candidateObservation;
|
||||
if (debugFlag('currentweather') && missingOptional.length > 0) {
|
||||
console.log(`Data for station ${stationId} is missing optional fields: ${missingOptional.join(', ')} (acceptable)`);
|
||||
}
|
||||
} else {
|
||||
const allMissing = [...missingRequired, ...missingOptional];
|
||||
if (debugFlag('currentweather')) {
|
||||
console.log(`Data for station ${stationId} is missing fields: ${allMissing.join(', ')} (${missingRequired.length} required, ${missingOptionalCount} optional) (trying next station)`);
|
||||
}
|
||||
}
|
||||
} else if (debugFlag('verbose-failures')) {
|
||||
if (!candidateObservation) {
|
||||
console.log(`Current Conditions for station ${stationId} failed, trying next station`);
|
||||
} else {
|
||||
console.log(`No features returned for station ${stationId}, trying next station`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// test for data received
|
||||
if (!observations) {
|
||||
console.error('All current weather stations exhausted');
|
||||
console.error('Current Conditions failure: all nearby weather stations exhausted!');
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
// send failed to subscribers
|
||||
this.getDataCallback(undefined);
|
||||
@@ -99,26 +154,51 @@ class CurrentWeather extends WeatherDisplay {
|
||||
// stop here if we're disabled
|
||||
if (!superResult) return;
|
||||
|
||||
// preload the icon
|
||||
preloadImg(getLargeIcon(observations.features[0].properties.icon));
|
||||
// Data is available, ensure we're enabled for display
|
||||
this.timing.totalScreens = 1;
|
||||
|
||||
// Check final data age
|
||||
const { isStale, ageInMinutes } = isDataStale(observations.features[0].properties.timestamp, 80); // hourly observation + 20 minute propagation delay
|
||||
this.isStaleData = isStale;
|
||||
|
||||
if (isStale && debugFlag('currentweather')) {
|
||||
console.warn(`Current Conditions: Data is ${ageInMinutes.toFixed(0)} minutes old (from ${new Date(observations.features[0].properties.timestamp).toISOString()})`);
|
||||
}
|
||||
|
||||
// preload the icon if available
|
||||
if (observations.features[0].properties.icon) {
|
||||
const iconResult = getLargeIcon(observations.features[0].properties.icon);
|
||||
if (iconResult) {
|
||||
preloadImg(iconResult);
|
||||
}
|
||||
}
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
|
||||
// Update header text based on data staleness
|
||||
const headerTop = this.elem.querySelector('.header .title .top');
|
||||
if (headerTop) {
|
||||
headerTop.textContent = this.isStaleData ? 'Recent' : 'Current';
|
||||
}
|
||||
|
||||
let condition = this.data.observations.textDescription;
|
||||
if (condition.length > 15) {
|
||||
condition = shortConditions(condition);
|
||||
}
|
||||
|
||||
const wind = (typeof this.data.WindSpeed === 'number') ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ') : '-';
|
||||
const wind = (typeof this.data.WindSpeed === 'number') ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ') : this.data.WindSpeed;
|
||||
|
||||
// get location (city name) from StationInfo if available (allows for overrides)
|
||||
const location = (StationInfo[this.data.station.properties.stationIdentifier]?.city ?? locationCleanup(this.data.station.properties.name)).substr(0, 20);
|
||||
|
||||
const fill = {
|
||||
temp: this.data.Temperature + String.fromCharCode(176),
|
||||
condition,
|
||||
wind,
|
||||
location: locationCleanup(this.data.station.properties.name).substr(0, 20),
|
||||
location,
|
||||
humidity: `${this.data.Humidity}%`,
|
||||
dewpoint: this.data.DewPoint + String.fromCharCode(176),
|
||||
ceiling: (this.data.Ceiling === 0 ? 'Unlimited' : this.data.Ceiling + this.data.CeilingUnit),
|
||||
@@ -206,14 +286,23 @@ const parseData = (data) => {
|
||||
data.WindGust = windConverter(observations.windGust.value);
|
||||
data.WindUnit = windConverter.units;
|
||||
data.Humidity = Math.round(observations.relativeHumidity.value);
|
||||
data.Icon = getLargeIcon(observations.icon);
|
||||
|
||||
// Get the large icon, but provide a fallback if it returns false
|
||||
const iconResult = getLargeIcon(observations.icon);
|
||||
data.Icon = iconResult || observations.icon; // Use original icon if getLargeIcon returns false
|
||||
|
||||
data.PressureDirection = '';
|
||||
data.TextConditions = observations.textDescription;
|
||||
|
||||
// difference since last measurement (pascals, looking for difference of more than 150)
|
||||
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
|
||||
if (pressureDiff > 150) data.PressureDirection = 'R';
|
||||
if (pressureDiff < -150) data.PressureDirection = 'F';
|
||||
// set wind speed of 0 as calm
|
||||
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
|
||||
|
||||
// if two measurements are available, use the difference (in pascals) to determine pressure trend
|
||||
if (data.features.length > 1 && data.features[1].properties.barometricPressure?.value) {
|
||||
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
|
||||
if (pressureDiff > 150) data.PressureDirection = 'R';
|
||||
if (pressureDiff < -150) data.PressureDirection = 'F';
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -3,11 +3,14 @@ import { elemForEach } from './utils/elem.mjs';
|
||||
import getCurrentWeather from './currentweather.mjs';
|
||||
import { currentDisplay } from './navigation.mjs';
|
||||
import getHazards from './hazards.mjs';
|
||||
import settings from './settings.mjs';
|
||||
|
||||
// constants
|
||||
const degree = String.fromCharCode(176);
|
||||
const SCROLL_SPEED = 75; // pixels/second
|
||||
const DEFAULT_UPDATE = 8; // 0.5s ticks
|
||||
const SCROLL_SPEED = 100; // pixels/second
|
||||
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
|
||||
|
||||
// local variables
|
||||
let interval;
|
||||
@@ -15,6 +18,7 @@ let screenIndex = 0;
|
||||
let sinceLastUpdate = 0;
|
||||
let nextUpdate = DEFAULT_UPDATE;
|
||||
let resetFlag;
|
||||
let defaultScreensLoaded = true;
|
||||
|
||||
// start drawing conditions
|
||||
// reset starts from the first item in the text scroll list
|
||||
@@ -28,7 +32,7 @@ const start = () => {
|
||||
resetFlag = false;
|
||||
// set up the interval if needed
|
||||
if (!interval) {
|
||||
interval = setInterval(incrementInterval, 500);
|
||||
interval = setInterval(incrementInterval, TICK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// draw the data
|
||||
@@ -60,7 +64,7 @@ const incrementInterval = (force) => {
|
||||
stop(display?.elemId === 'progress');
|
||||
return;
|
||||
}
|
||||
screenIndex = (screenIndex + 1) % (lastScreen);
|
||||
screenIndex = (screenIndex + 1) % (workingScreens.length);
|
||||
|
||||
// draw new text
|
||||
drawScreen();
|
||||
@@ -70,15 +74,21 @@ const drawScreen = async () => {
|
||||
// get the conditions
|
||||
const data = await getCurrentWeather();
|
||||
|
||||
// create a data object (empty if no valid current weather conditions)
|
||||
const scrollData = data || {};
|
||||
|
||||
// add the hazards if on screen 0
|
||||
if (screenIndex === 0) {
|
||||
data.hazards = await getHazards(() => this.stillWaiting());
|
||||
const hazards = await getHazards();
|
||||
if (hazards && hazards.length > 0) {
|
||||
scrollData.hazards = hazards;
|
||||
}
|
||||
}
|
||||
|
||||
// nothing to do if there's no data yet
|
||||
if (!data) return;
|
||||
// if we have no current weather and no hazards, there's nothing to display
|
||||
if (!data && (!scrollData.hazards || scrollData.hazards.length === 0)) return;
|
||||
|
||||
const thisScreen = screens[screenIndex](data);
|
||||
const thisScreen = workingScreens[screenIndex](scrollData);
|
||||
|
||||
// update classes on the scroll area
|
||||
elemForEach('.weather-display .scroll', (elem) => {
|
||||
@@ -115,7 +125,9 @@ const hazards = (data) => {
|
||||
// test for data
|
||||
if (!data.hazards || data.hazards.length === 0) return false;
|
||||
|
||||
const hazard = `${data.hazards[0].properties.event} ${data.hazards[0].properties.description}`;
|
||||
// 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}`;
|
||||
|
||||
return {
|
||||
text: hazard,
|
||||
@@ -125,12 +137,17 @@ const hazards = (data) => {
|
||||
};
|
||||
};
|
||||
|
||||
// additional screens are stored in a separate for simple clearing/resettings
|
||||
let additionalScreens = [];
|
||||
// the "screens" are stored in an array for easy addition and removal
|
||||
const screens = [
|
||||
const baseScreens = [
|
||||
// hazards
|
||||
hazards,
|
||||
// station name
|
||||
(data) => `Conditions at ${locationCleanup(data.station.properties.name).substr(0, 20)}`,
|
||||
(data) => {
|
||||
const location = (StationInfo[data.station.properties.stationIdentifier]?.city ?? locationCleanup(data.station.properties.name)).substr(0, 20);
|
||||
return `Conditions at ${location}`;
|
||||
},
|
||||
|
||||
// temperature
|
||||
(data) => {
|
||||
@@ -168,6 +185,9 @@ const screens = [
|
||||
},
|
||||
];
|
||||
|
||||
// working screens are the combination of base screens (when active) and additional screens
|
||||
let workingScreens = [...baseScreens, ...additionalScreens];
|
||||
|
||||
// internal draw function with preset parameters
|
||||
const drawCondition = (text) => {
|
||||
// update all html scroll elements
|
||||
@@ -183,19 +203,18 @@ const setHeader = (text) => {
|
||||
});
|
||||
};
|
||||
|
||||
// store the original number of screens
|
||||
const originalScreens = screens.length;
|
||||
let lastScreen = originalScreens;
|
||||
|
||||
// reset the number of screens
|
||||
// reset the screens back to the original set
|
||||
const reset = () => {
|
||||
lastScreen = originalScreens;
|
||||
workingScreens = [...baseScreens];
|
||||
additionalScreens = [];
|
||||
defaultScreensLoaded = true;
|
||||
};
|
||||
|
||||
// add screen
|
||||
const addScreen = (screen) => {
|
||||
screens.push(screen);
|
||||
lastScreen += 1;
|
||||
// add screen, keepBase keeps the regular weather crawl
|
||||
const addScreen = (screen, keepBase = true) => {
|
||||
defaultScreensLoaded = false;
|
||||
additionalScreens.push(screen);
|
||||
workingScreens = [...(keepBase ? baseScreens : []), ...additionalScreens];
|
||||
};
|
||||
|
||||
const drawScrollCondition = (screen) => {
|
||||
@@ -210,25 +229,35 @@ const drawScrollCondition = (screen) => {
|
||||
|
||||
// calculate the scroll distance and set a minimum scroll
|
||||
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
|
||||
// calculate the scroll time
|
||||
const scrollTime = scrollDistance / SCROLL_SPEED;
|
||||
// calculate a new minimum on-screen time +1.0s at start and end
|
||||
nextUpdate = Math.round(Math.ceil(scrollTime / 0.5) + 4);
|
||||
// calculate the scroll time (scaled by global speed setting)
|
||||
const scrollTime = scrollDistance / SCROLL_SPEED * settings.speed.value;
|
||||
// add 1 second pause at the end of the scroll animation
|
||||
const endPauseTime = 1.0;
|
||||
const totalAnimationTime = scrollTime + endPauseTime;
|
||||
// calculate total on-screen time: animation time + start delay + end pause
|
||||
const startDelayTime = 1.0; // setTimeout delay below
|
||||
const totalDisplayTime = totalAnimationTime + startDelayTime;
|
||||
nextUpdate = secondsToTicks(totalDisplayTime);
|
||||
|
||||
// update the element with initial position and transition
|
||||
scrollElement.style.transform = 'translateX(0px)';
|
||||
scrollElement.style.transition = `transform ${scrollTime.toFixed(1)}s linear`;
|
||||
scrollElement.style.willChange = 'transform'; // Hint to browser for hardware acceleration
|
||||
scrollElement.style.backfaceVisibility = 'hidden'; // Force hardware acceleration
|
||||
scrollElement.style.perspective = '1000px'; // Enable 3D rendering context
|
||||
|
||||
// update the element transition and set initial left position
|
||||
scrollElement.style.left = '0px';
|
||||
scrollElement.style.transition = `left linear ${scrollTime.toFixed(1)}s`;
|
||||
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
||||
elem.innerHTML = '';
|
||||
elem.append(scrollElement.cloneNode(true));
|
||||
});
|
||||
// start the scroll after a short delay
|
||||
|
||||
// start the scroll after the specified delay
|
||||
setTimeout(() => {
|
||||
// change the left position to trigger the scroll
|
||||
// change the transform to trigger the scroll
|
||||
elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => {
|
||||
elem.style.left = `-${scrollDistance.toFixed(0)}px`;
|
||||
elem.style.transform = `translateX(-${scrollDistance.toFixed(0)}px)`;
|
||||
});
|
||||
}, 1000);
|
||||
}, startDelayTime * 1000);
|
||||
};
|
||||
|
||||
const parseMessage = (event) => {
|
||||
@@ -238,6 +267,9 @@ const parseMessage = (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
const screenCount = () => workingScreens.length;
|
||||
const atDefault = () => defaultScreensLoaded;
|
||||
|
||||
// add event listener for start message
|
||||
window.addEventListener('message', parseMessage);
|
||||
|
||||
@@ -245,10 +277,14 @@ window.CurrentWeatherScroll = {
|
||||
addScreen,
|
||||
reset,
|
||||
start,
|
||||
screenCount,
|
||||
atDefault,
|
||||
};
|
||||
|
||||
export {
|
||||
addScreen,
|
||||
reset,
|
||||
start,
|
||||
screenCount,
|
||||
atDefault,
|
||||
};
|
||||
|
||||
132
server/scripts/modules/custom-rss-feed.mjs
Normal file
132
server/scripts/modules/custom-rss-feed.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import Setting from './utils/setting.mjs';
|
||||
import { reset as resetScroll, addScreen as addScroll } from './currentweatherscroll.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
|
||||
let firstRun = true;
|
||||
|
||||
const parser = new DOMParser();
|
||||
|
||||
// change of enable handler
|
||||
const changeEnable = (newValue) => {
|
||||
let newDisplay;
|
||||
if (newValue) {
|
||||
// add the feed to the scroll
|
||||
parseFeed(customFeed.value);
|
||||
// show the string box
|
||||
newDisplay = 'block';
|
||||
} else {
|
||||
// set scroll back to original
|
||||
resetScroll();
|
||||
// hide the string entry
|
||||
newDisplay = 'none';
|
||||
}
|
||||
const stringEntry = document.getElementById('settings-customFeed-label');
|
||||
if (stringEntry) {
|
||||
stringEntry.style.display = newDisplay;
|
||||
}
|
||||
};
|
||||
|
||||
// parse the feed/text provided
|
||||
const parseFeed = (textInput) => {
|
||||
// skip getting the feed on first run
|
||||
if (firstRun) return;
|
||||
|
||||
// test validity
|
||||
if (textInput === undefined || textInput === '') {
|
||||
resetScroll();
|
||||
}
|
||||
|
||||
// test for url
|
||||
if (textInput.match(/https?:\/\//)) {
|
||||
getFeed(textInput);
|
||||
return;
|
||||
}
|
||||
|
||||
// add single text scroll
|
||||
resetScroll();
|
||||
addScroll(
|
||||
() => (
|
||||
{
|
||||
type: 'scroll',
|
||||
text: textInput,
|
||||
}),
|
||||
// keep the existing scroll
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
// get the rss feed and then swap out the current weather scroll
|
||||
const getFeed = async (url) => {
|
||||
// get the text as a string
|
||||
// it needs to be proxied, use a free service
|
||||
const rssResponse = await json(`https://api.allorigins.win/get?url=${url}`);
|
||||
|
||||
// this returns a data url
|
||||
// a few sanity checks
|
||||
if (rssResponse.status.content_type.indexOf('xml') < 0) return;
|
||||
// determine return type
|
||||
const isBase64 = rssResponse.status.content_type.substring(0, 8) !== 'text/xml';
|
||||
|
||||
// base 64 decode everything after the comma
|
||||
const rss = isBase64 ? atob(rssResponse.contents.split('base64,')[1]) : rssResponse.contents;
|
||||
|
||||
// parse the rss
|
||||
const doc = parser.parseFromString(rss, 'text/xml');
|
||||
|
||||
// get the title
|
||||
const rssTitle = doc.querySelector('channel title').textContent;
|
||||
|
||||
// get each item
|
||||
const titles = [...doc.querySelectorAll('item title')].map((t) => t.textContent);
|
||||
|
||||
// reset the scroll, then add the screens
|
||||
resetScroll();
|
||||
titles.forEach((title) => {
|
||||
// data is provided to the screen handler, so we return a function
|
||||
addScroll(
|
||||
() => ({
|
||||
header: rssTitle,
|
||||
type: 'scroll',
|
||||
text: title,
|
||||
}),
|
||||
// false parameter does not include the default weather scrolls
|
||||
false,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// change the feed source and re-load if necessary
|
||||
const changeFeed = (newValue) => {
|
||||
// first pass through won't have custom feed enable ready
|
||||
if (firstRun) return;
|
||||
|
||||
if (customFeedEnable.value) {
|
||||
parseFeed(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const customFeed = new Setting('customFeed', {
|
||||
name: 'Custom RSS Feed',
|
||||
defaultValue: '',
|
||||
type: 'string',
|
||||
changeAction: changeFeed,
|
||||
placeholder: 'Text or URL',
|
||||
});
|
||||
|
||||
const customFeedEnable = new Setting('customFeedEnable', {
|
||||
name: 'Enable RSS Feed/Text',
|
||||
defaultValue: false,
|
||||
changeAction: changeEnable,
|
||||
});
|
||||
|
||||
// initialize the custom feed inputs on the page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// add the controls to the page
|
||||
const settingsSection = document.querySelector('#settings');
|
||||
settingsSection.append(customFeedEnable.generate(), customFeed.generate());
|
||||
// clear the first run value
|
||||
firstRun = false;
|
||||
// call change enable with the current value to show/hide the url box
|
||||
// and make the call to get the feed if enabled
|
||||
changeEnable(customFeedEnable.value);
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
// display extended forecast graphically
|
||||
// technically uses the same data as the local forecast, we'll let the browser do the caching of that
|
||||
// (technically this uses the same data as the local forecast, but we'll let the cache deal with that)
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import { getLargeIcon } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import filterExpiredPeriods from './utils/forecast-utils.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class ExtendedForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -21,27 +23,30 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// request us or si units
|
||||
try {
|
||||
this.data = await json(this.weatherParameters.forecast, {
|
||||
// request us or si units using centralized safe handling
|
||||
this.data = await safeJson(this.weatherParameters.forecast, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to get extended forecast');
|
||||
console.error(error.status, error.responseJSON);
|
||||
// if there's no previous data, fail
|
||||
|
||||
// if there's no new data and no previous data, fail
|
||||
if (!this.data) {
|
||||
this.setStatus(STATUS.failed);
|
||||
// console.warn(`Unable to get extended forecast for ${this.weatherParameters.latitude},${this.weatherParameters.longitude} in ${this.weatherParameters.state}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
// we only get here if there was data (new or existing)
|
||||
this.screenIndex = 0;
|
||||
this.setStatus(STATUS.loaded);
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting Extended Forecast: ${error.message}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
}
|
||||
// we only get here if there was no error above
|
||||
this.screenIndex = 0;
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
@@ -49,7 +54,7 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
|
||||
// determine bounds
|
||||
// grab the first three or second set of three array elements
|
||||
const forecast = parse(this.data.properties.periods).slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3);
|
||||
const forecast = parse(this.data.properties.periods, this.weatherParameters.forecast).slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3);
|
||||
|
||||
// create each day template
|
||||
const days = forecast.map((Day) => {
|
||||
@@ -78,19 +83,52 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
}
|
||||
|
||||
// the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures
|
||||
const parse = (fullForecast) => {
|
||||
// create a list of days starting with today
|
||||
const Days = [0, 1, 2, 3, 4, 5, 6];
|
||||
const parse = (fullForecast, forecastUrl) => {
|
||||
// filter out expired periods first
|
||||
const activePeriods = filterExpiredPeriods(fullForecast, forecastUrl);
|
||||
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log('ExtendedForecast: First few active periods:');
|
||||
activePeriods.slice(0, 4).forEach((period, index) => {
|
||||
console.log(` [${index}] ${period.name}: ${period.startTime} to ${period.endTime} (isDaytime: ${period.isDaytime})`);
|
||||
});
|
||||
}
|
||||
|
||||
// Skip the first period if it's nighttime (like "Tonight") since extended forecast
|
||||
// should focus on upcoming full days, not the end of the current day
|
||||
let startIndex = 0;
|
||||
let dateOffset = 0; // offset for date labels when we skip periods
|
||||
|
||||
if (activePeriods.length > 0 && !activePeriods[0].isDaytime) {
|
||||
startIndex = 1;
|
||||
dateOffset = 1; // start date labels from tomorrow since we're skipping tonight
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log(`ExtendedForecast: Skipping first period "${activePeriods[0].name}" because it's nighttime`);
|
||||
}
|
||||
} else if (activePeriods.length > 0) {
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log(`ExtendedForecast: Starting with first period "${activePeriods[0].name}" because it's daytime`);
|
||||
}
|
||||
}
|
||||
|
||||
// create a list of days starting with the appropriate day
|
||||
const Days = [0, 1, 2, 3, 4, 5, 6];
|
||||
const dates = Days.map((shift) => {
|
||||
const date = DateTime.local().startOf('day').plus({ days: shift });
|
||||
const date = DateTime.local().startOf('day').plus({ days: shift + dateOffset });
|
||||
return date.toLocaleString({ weekday: 'short' });
|
||||
});
|
||||
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log(`ExtendedForecast: Generated date labels: [${dates.join(', ')}]`);
|
||||
}
|
||||
|
||||
// track the destination forecast index
|
||||
let destIndex = 0;
|
||||
const forecast = [];
|
||||
fullForecast.forEach((period) => {
|
||||
|
||||
for (let i = startIndex; i < activePeriods.length; i += 1) {
|
||||
const period = activePeriods[i];
|
||||
|
||||
// create the destination object if necessary
|
||||
if (!forecast[destIndex]) {
|
||||
forecast.push({
|
||||
@@ -110,12 +148,21 @@ const parse = (fullForecast) => {
|
||||
if (period.isDaytime) {
|
||||
// day time is the high temperature
|
||||
fDay.high = period.temperature;
|
||||
destIndex += 1;
|
||||
// Wait for the corresponding night period to increment
|
||||
} else {
|
||||
// low temperature
|
||||
fDay.low = period.temperature;
|
||||
// Increment after processing night period
|
||||
destIndex += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log('ExtendedForecast: Final forecast array:');
|
||||
forecast.forEach((day, index) => {
|
||||
console.log(` [${index}] ${day.dayName}: High=${day.high}°, Low=${day.low}°, Text="${day.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
return forecast;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// hourly forecast list
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
const hazardLevels = {
|
||||
Extreme: 10,
|
||||
@@ -32,6 +34,19 @@ class Hazards extends WeatherDisplay {
|
||||
// take note of the already-shown alert ids
|
||||
this.viewedAlerts = new Set();
|
||||
this.viewedGetCount = 0;
|
||||
|
||||
// cache for scroll calculations
|
||||
// This cache is essential because baseCountChange() is called 25 times per second (every 40ms)
|
||||
// during scrolling. Hazard scrolls can vary greatly in length depending on active alerts, but
|
||||
// without caching we'd perform hundreds of expensive DOM layout queries during each scroll cycle.
|
||||
// The cache reduces this to one calculation when content changes, then reuses cached values to try
|
||||
// and get smoother scrolling.
|
||||
this.scrollCache = {
|
||||
displayHeight: 0,
|
||||
contentHeight: 0,
|
||||
maxOffset: 0,
|
||||
hazardLines: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
@@ -53,16 +68,24 @@ class Hazards extends WeatherDisplay {
|
||||
}
|
||||
|
||||
try {
|
||||
// get the forecast
|
||||
// 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}`);
|
||||
const alerts = await json(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
const allUnsortedAlerts = alerts.features ?? [];
|
||||
const unsortedAlerts = allUnsortedAlerts.slice(0, 5);
|
||||
const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false);
|
||||
const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event)));
|
||||
const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate')));
|
||||
this.data = filteredAlerts;
|
||||
const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
|
||||
if (!alerts) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn('Active Alerts request failed; assuming no active alerts');
|
||||
}
|
||||
this.data = [];
|
||||
} else {
|
||||
const allUnsortedAlerts = alerts.features ?? [];
|
||||
const unsortedAlerts = allUnsortedAlerts.slice(0, 5);
|
||||
const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false);
|
||||
const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event)));
|
||||
const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate')));
|
||||
this.data = filteredAlerts;
|
||||
}
|
||||
|
||||
// every 10 times through the get process (10 minutes), reset the viewed messages
|
||||
if (this.viewedGetCount >= 10) {
|
||||
@@ -82,8 +105,7 @@ class Hazards extends WeatherDisplay {
|
||||
// draw the canvas to calculate the new timings and activate hazards in the slide deck again
|
||||
this.drawLongCanvas();
|
||||
} catch (error) {
|
||||
console.error('Get hazards failed');
|
||||
console.error(error.status, error.responseJSON);
|
||||
console.error(`Unexpected Active Alerts error: ${error.message}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
// return undefined to other subscribers
|
||||
this.getDataCallback(undefined);
|
||||
@@ -109,8 +131,12 @@ class Hazards extends WeatherDisplay {
|
||||
|
||||
const lines = unViewed.map((data) => {
|
||||
const fillValues = {};
|
||||
// text
|
||||
fillValues['hazard-text'] = `${data.properties.event}<br/><br/>${data.properties.description.replaceAll('\n\n', '<br/><br/>').replaceAll('\n', ' ')}`;
|
||||
const description = data.properties.description
|
||||
.replaceAll('\n\n', '<br/><br/>')
|
||||
.replaceAll('\n', ' ')
|
||||
.replace(/(\S)\.\.\.(\S)/g, '$1... $2'); // Add space after ... when surrounded by non-whitespace to improve text-wrappability
|
||||
|
||||
fillValues['hazard-text'] = `${data.properties.event}<br/><br/>${description}<br/><br/><br/><br/>`; // Add some padding to scroll off the bottom a bit
|
||||
|
||||
return this.fillTemplate('hazard', fillValues);
|
||||
});
|
||||
@@ -131,16 +157,16 @@ class Hazards extends WeatherDisplay {
|
||||
}
|
||||
|
||||
setTiming(list) {
|
||||
// set up the timing
|
||||
this.timing.baseDelay = 20;
|
||||
// 24 hours = 6 pages
|
||||
const pages = Math.max(Math.ceil(list.scrollHeight / 480) - 4);
|
||||
const timingStep = 480;
|
||||
this.timing.delay = [150 + timingStep];
|
||||
// add additional pages
|
||||
for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep);
|
||||
// add the final 3 second delay
|
||||
this.timing.delay.push(250);
|
||||
const container = this.elem.querySelector('.main');
|
||||
const timingConfig = calculateScrollTiming(list, container, {
|
||||
finalPause: 2.0, // shorter final pause for hazards
|
||||
});
|
||||
|
||||
// Apply the calculated timing
|
||||
this.timing.baseDelay = timingConfig.baseDelay;
|
||||
this.timing.delay = timingConfig.delay;
|
||||
this.scrollTiming = timingConfig.scrollTiming;
|
||||
|
||||
this.calcNavTiming();
|
||||
}
|
||||
|
||||
@@ -162,25 +188,30 @@ class Hazards extends WeatherDisplay {
|
||||
|
||||
// base count change callback
|
||||
baseCountChange(count) {
|
||||
// get the hazard lines element and cache measurements if needed
|
||||
const hazardLines = this.elem.querySelector('.hazard-lines');
|
||||
if (!hazardLines) return;
|
||||
|
||||
// update cache if needed (when content changes or first run)
|
||||
if (this.scrollCache.hazardLines !== hazardLines || this.scrollCache.displayHeight === 0) {
|
||||
this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight;
|
||||
this.scrollCache.contentHeight = hazardLines.offsetHeight;
|
||||
this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight);
|
||||
this.scrollCache.hazardLines = hazardLines;
|
||||
|
||||
// Set up hardware acceleration on the hazard lines element
|
||||
hazardLines.style.willChange = 'transform';
|
||||
hazardLines.style.backfaceVisibility = 'hidden';
|
||||
}
|
||||
|
||||
// calculate scroll offset and don't go past end
|
||||
let offsetY = Math.min(this.elem.querySelector('.hazard-lines').offsetHeight - 390, (count - 150));
|
||||
let offsetY = Math.min(this.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
|
||||
|
||||
// don't let offset go negative
|
||||
if (offsetY < 0) offsetY = 0;
|
||||
|
||||
// move the element
|
||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||
}
|
||||
|
||||
// make data available outside this class
|
||||
// promise allows for data to be requested before it is available
|
||||
async getCurrentData(stillWaiting) {
|
||||
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
|
||||
return new Promise((resolve) => {
|
||||
if (this.data) resolve(this.data);
|
||||
// data not available, put it into the data callback queue
|
||||
this.getDataCallbacks.push(() => resolve(this.data));
|
||||
});
|
||||
// use transform instead of scrollTo for hardware acceleration
|
||||
hazardLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
|
||||
}
|
||||
|
||||
// after we roll through the hazards once, don't display again until the next refresh (10 minutes)
|
||||
|
||||
@@ -31,8 +31,8 @@ class HourlyGraph extends WeatherDisplay {
|
||||
if (!super.getData(undefined, refresh)) return;
|
||||
|
||||
const data = await getHourlyData(() => this.stillWaiting());
|
||||
if (data === undefined) {
|
||||
this.setStatus(STATUS.failed);
|
||||
if (!data) {
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,60 +2,70 @@
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { DateTime, Interval, Duration } from '../vendor/auto/luxon.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit, distanceKilometers } from './utils/units.mjs';
|
||||
import { getHourlyIcon } from './icons.mjs';
|
||||
import { directionToNSEW } from './utils/calc.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import getSun from './almanac.mjs';
|
||||
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class Hourly extends WeatherDisplay {
|
||||
constructor(navId, elemId, defaultActive) {
|
||||
// special height and width for scrolling
|
||||
super(navId, elemId, 'Hourly Forecast', defaultActive);
|
||||
|
||||
// set up the timing
|
||||
this.timing.baseDelay = 20;
|
||||
// 24 hours = 6 pages
|
||||
const pages = 4; // first page is already displayed, last page doesn't happen
|
||||
const timingStep = 75 * 4;
|
||||
this.timing.delay = [150 + timingStep];
|
||||
// add additional pages
|
||||
for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep);
|
||||
// add the final 3 second delay
|
||||
this.timing.delay.push(150);
|
||||
// cache for scroll calculations
|
||||
// This cache is essential because baseCountChange() is called 25 times per second (every 40ms)
|
||||
// during scrolling. Without caching, we'd perform hundreds of expensive DOM layout queries during
|
||||
// the full scroll cycle. The cache reduces this to one calculation when content changes, then
|
||||
// reuses cached values to try and get smoother scrolling.
|
||||
this.scrollCache = {
|
||||
displayHeight: 0,
|
||||
contentHeight: 0,
|
||||
maxOffset: 0,
|
||||
hourlyLines: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
// super checks for enabled
|
||||
const superResponse = super.getData(weatherParameters, refresh);
|
||||
let forecast;
|
||||
|
||||
try {
|
||||
// get the forecast
|
||||
forecast = await json(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
// parse the forecast
|
||||
this.data = await parseForecast(forecast.properties);
|
||||
} catch (error) {
|
||||
console.error('Get hourly forecast failed');
|
||||
console.error(error.status, error.responseJSON);
|
||||
// use old data if available
|
||||
if (this.data) {
|
||||
console.log('Using previous hourly forecast');
|
||||
// don't return, this.data is usable from the previous update
|
||||
} else {
|
||||
const forecast = await safeJson(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
|
||||
if (forecast) {
|
||||
try {
|
||||
// parse the forecast
|
||||
this.data = await parseForecast(forecast.properties);
|
||||
} catch (error) {
|
||||
console.error(`Hourly forecast parsing failed: ${error.message}`);
|
||||
}
|
||||
} else if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Using previous hourly forecast for ${this.weatherParameters.forecastGridData}`);
|
||||
}
|
||||
|
||||
// use old data if available, fail if no data at all
|
||||
if (!this.data) {
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
// return undefined to other subscribers
|
||||
this.getDataCallback(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
this.getDataCallback();
|
||||
if (!superResponse) return;
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
this.drawLongCanvas();
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting hourly forecast: ${error.message}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
this.getDataCallback(undefined);
|
||||
}
|
||||
|
||||
this.getDataCallback();
|
||||
if (!superResponse) return;
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
this.drawLongCanvas();
|
||||
}
|
||||
|
||||
async drawLongCanvas() {
|
||||
@@ -102,6 +112,9 @@ class Hourly extends WeatherDisplay {
|
||||
});
|
||||
|
||||
list.append(...lines);
|
||||
|
||||
// update timing based on actual content
|
||||
this.setTiming(list);
|
||||
}
|
||||
|
||||
drawCanvas() {
|
||||
@@ -122,19 +135,35 @@ class Hourly extends WeatherDisplay {
|
||||
|
||||
// base count change callback
|
||||
baseCountChange(count) {
|
||||
// get the hourly lines element and cache measurements if needed
|
||||
const hourlyLines = this.elem.querySelector('.hourly-lines');
|
||||
if (!hourlyLines) return;
|
||||
|
||||
// update cache if needed (when content changes or first run)
|
||||
if (this.scrollCache.hourlyLines !== hourlyLines || this.scrollCache.displayHeight === 0) {
|
||||
this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight;
|
||||
this.scrollCache.contentHeight = hourlyLines.offsetHeight;
|
||||
this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight);
|
||||
this.scrollCache.hourlyLines = hourlyLines;
|
||||
|
||||
// Set up hardware acceleration on the hourly lines element
|
||||
hourlyLines.style.willChange = 'transform';
|
||||
hourlyLines.style.backfaceVisibility = 'hidden';
|
||||
}
|
||||
|
||||
// calculate scroll offset and don't go past end
|
||||
let offsetY = Math.min(this.elem.querySelector('.hourly-lines').offsetHeight - 289, (count - 150));
|
||||
let offsetY = Math.min(this.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
|
||||
|
||||
// don't let offset go negative
|
||||
if (offsetY < 0) offsetY = 0;
|
||||
|
||||
// copy the scrolled portion of the canvas
|
||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||
// use transform instead of scrollTo for hardware acceleration
|
||||
hourlyLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
|
||||
}
|
||||
|
||||
// make data available outside this class
|
||||
// promise allows for data to be requested before it is available
|
||||
async getCurrentData(stillWaiting) {
|
||||
async getHourlyData(stillWaiting) {
|
||||
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
|
||||
// an external caller has requested data, set up auto reload
|
||||
this.setAutoReload();
|
||||
@@ -144,6 +173,18 @@ class Hourly extends WeatherDisplay {
|
||||
this.getDataCallbacks.push(() => resolve(this.data));
|
||||
});
|
||||
}
|
||||
|
||||
setTiming(list) {
|
||||
const container = this.elem.querySelector('.main');
|
||||
const timingConfig = calculateScrollTiming(list, container);
|
||||
|
||||
// Apply the calculated timing
|
||||
this.timing.baseDelay = timingConfig.baseDelay;
|
||||
this.timing.delay = timingConfig.delay;
|
||||
this.scrollTiming = timingConfig.scrollTiming;
|
||||
|
||||
this.calcNavTiming();
|
||||
}
|
||||
}
|
||||
|
||||
// extract specific values from forecast and format as an array
|
||||
@@ -192,7 +233,7 @@ const determineIcon = async (skyCover, weather, iceAccumulation, probabilityOfPr
|
||||
};
|
||||
|
||||
// expand a set of values with durations to an hour-by-hour array
|
||||
const expand = (data) => {
|
||||
const expand = (data, maxHours = 24) => {
|
||||
const startOfHour = DateTime.utc().startOf('hour').toMillis();
|
||||
const result = []; // resulting expanded values
|
||||
data.forEach((item) => {
|
||||
@@ -202,12 +243,12 @@ const expand = (data) => {
|
||||
// loop through duration at one hour intervals
|
||||
do {
|
||||
// test for timestamp greater than now
|
||||
if (startTime >= startOfHour && result.length < 24) {
|
||||
if (startTime >= startOfHour && result.length < maxHours) {
|
||||
result.push(item.value); // push data array
|
||||
} // timestamp is after now
|
||||
// increment start time by 1 hour
|
||||
startTime += 3_600_000;
|
||||
} while (startTime < endTime && result.length < 24);
|
||||
} while (startTime < endTime && result.length < maxHours);
|
||||
}); // for each value
|
||||
|
||||
return result;
|
||||
@@ -217,4 +258,4 @@ const expand = (data) => {
|
||||
const display = new Hourly(3, 'hourly', false);
|
||||
registerDisplay(display);
|
||||
|
||||
export default display.getCurrentData.bind(display);
|
||||
export default display.getHourlyData.bind(display);
|
||||
|
||||
@@ -1,55 +1,65 @@
|
||||
/* spell-checker: disable */
|
||||
// internal function to add path to returned icon
|
||||
import parseIconUrl from './icons-parse.mjs';
|
||||
|
||||
const addPath = (icon) => `images/icons/current-conditions/${icon}`;
|
||||
|
||||
const largeIcon = (link, _isNightTime) => {
|
||||
if (!link) return false;
|
||||
let conditionIcon;
|
||||
let probability;
|
||||
let isNightTime;
|
||||
|
||||
// extract day or night if not provided
|
||||
const isNightTime = _isNightTime ?? link.indexOf('/night/') >= 0;
|
||||
|
||||
// grab everything after the last slash ending at any of these: ?&,
|
||||
const afterLastSlash = link.toLowerCase().match(/[^/]+$/)[0];
|
||||
let conditionName = afterLastSlash.match(/(.*?)[&,.?]/)[1];
|
||||
// using probability as a crude heavy/light indication where possible
|
||||
const value = +(link.match(/,(\d{2,3})/) ?? [0, 100])[1];
|
||||
|
||||
// if a 'DualImage' is captured, adjust to just the j parameter
|
||||
if (conditionName === 'dualimage') {
|
||||
const match = link.match(/&j=(.*)&/);
|
||||
[, conditionName] = match;
|
||||
try {
|
||||
({ conditionIcon, probability, isNightTime } = parseIconUrl(link, _isNightTime));
|
||||
} catch (error) {
|
||||
console.warn(`largeIcon: ${error.message}`);
|
||||
// Return a fallback icon to prevent downstream errors
|
||||
return addPath(_isNightTime ? 'Clear.gif' : 'Sunny.gif');
|
||||
}
|
||||
|
||||
// find the icon
|
||||
switch (conditionName + (isNightTime ? '-n' : '')) {
|
||||
switch (conditionIcon + (isNightTime ? '-n' : '')) {
|
||||
case 'skc':
|
||||
case 'hot':
|
||||
case 'haze':
|
||||
case 'cold':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'skc-n':
|
||||
case 'nskc':
|
||||
case 'nskc-n':
|
||||
return addPath('Clear.gif');
|
||||
|
||||
case 'haze':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'haze-n':
|
||||
return addPath('Clear.gif');
|
||||
|
||||
case 'cold':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'cold-n':
|
||||
return addPath('Clear.gif');
|
||||
|
||||
case 'sct':
|
||||
case 'dust':
|
||||
case 'dust-n':
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'few':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'few-n':
|
||||
return addPath('Mostly-Clear.gif');
|
||||
|
||||
case 'sct':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'sct-n':
|
||||
return addPath('Mostly-Clear.gif');
|
||||
|
||||
case 'bkn':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'bkn-n':
|
||||
case 'few-n':
|
||||
case 'nfew-n':
|
||||
case 'nfew':
|
||||
case 'sct-n':
|
||||
case 'nsct':
|
||||
case 'nsct-n':
|
||||
return addPath('Mostly-Clear.gif');
|
||||
|
||||
case 'ovc':
|
||||
case 'novc':
|
||||
case 'ovc-n':
|
||||
return addPath('Cloudy.gif');
|
||||
|
||||
@@ -70,8 +80,10 @@ const largeIcon = (link, _isNightTime) => {
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_hi':
|
||||
case 'rain_showers_high':
|
||||
case 'rain_showers-n':
|
||||
case 'rain_showers_hi-n':
|
||||
case 'rain_showers_high-n':
|
||||
return addPath('Shower.gif');
|
||||
|
||||
@@ -81,10 +93,11 @@ const largeIcon = (link, _isNightTime) => {
|
||||
|
||||
case 'snow':
|
||||
case 'snow-n':
|
||||
if (value > 50) return addPath('Heavy-Snow.gif');
|
||||
if (probability > 50) return addPath('Heavy-Snow.gif');
|
||||
return addPath('Light-Snow.gif');
|
||||
|
||||
case 'rain_snow':
|
||||
case 'rain_snow-n':
|
||||
return addPath('Rain-Snow.gif');
|
||||
|
||||
case 'snow_fzra':
|
||||
@@ -98,43 +111,66 @@ const largeIcon = (link, _isNightTime) => {
|
||||
return addPath('Freezing-Rain.gif');
|
||||
|
||||
case 'snow_sleet':
|
||||
case 'snow_sleet-n':
|
||||
return addPath('Snow-Sleet.gif');
|
||||
|
||||
case 'tsra_sct':
|
||||
case 'tsra':
|
||||
return addPath('Scattered-Thunderstorms-Day.gif');
|
||||
|
||||
case 'tsra_sct-n':
|
||||
return addPath('Scattered-Thunderstorms-Night.gif');
|
||||
|
||||
case 'tsra':
|
||||
return addPath('Scattered-Thunderstorms-Day.gif');
|
||||
|
||||
case 'tsra-n':
|
||||
return addPath('Scattered-Thunderstorms-Night.gif');
|
||||
|
||||
case 'tsra_hi':
|
||||
case 'tsra_hi-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'tornado':
|
||||
case 'tornado-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'hurricane':
|
||||
case 'tropical_storm':
|
||||
case 'hurricane-n':
|
||||
case 'tropical_storm':
|
||||
case 'tropical_storm-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind_bkn':
|
||||
case 'wind_ovc':
|
||||
case 'wind_skc':
|
||||
case 'wind_few-n':
|
||||
case 'wind_bkn-n':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_skc-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_few':
|
||||
case 'wind_few-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_sct':
|
||||
case 'wind_sct-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_bkn':
|
||||
case 'wind_bkn-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_ovc':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing-Snow.gif');
|
||||
|
||||
default:
|
||||
console.log(`Unable to locate icon for ${conditionName} ${link} ${isNightTime}`);
|
||||
return false;
|
||||
default: {
|
||||
console.warn(`Unknown weather condition '${conditionIcon}' from ${link}; using fallback icon`);
|
||||
// Return a reasonable fallback instead of false to prevent downstream errors
|
||||
return addPath(isNightTime ? 'Clear.gif' : 'Sunny.gif');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
93
server/scripts/modules/icons/icons-parse.mjs
Normal file
93
server/scripts/modules/icons/icons-parse.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Parses weather.gov icon URLs and extracts weather condition information
|
||||
* Handles both single and dual condition formats according to the weather.gov API spec
|
||||
*
|
||||
* NOTE: The 'icon' properties are marked as deprecated in the API documentation. This
|
||||
* is because it will eventually be replaced with a more generic value that is not a URL.
|
||||
*/
|
||||
|
||||
import { debugFlag } from '../utils/debug.mjs';
|
||||
|
||||
/**
|
||||
* Parses a weather.gov icon URL and extracts condition and timing information
|
||||
* @param {string} iconUrl - Icon URL from weather.gov API (e.g., "/icons/land/day/skc?size=medium")
|
||||
* @param {boolean} _isNightTime - Optional override for night time determination
|
||||
* @returns {Object} Parsed icon data with conditionIcon, probability, and isNightTime
|
||||
*/
|
||||
const parseIconUrl = (iconUrl, _isNightTime) => {
|
||||
if (!iconUrl) {
|
||||
throw new Error('No icon URL provided');
|
||||
}
|
||||
|
||||
// Parse icon URL according to API spec: /icons/{set}/{timeOfDay}/{condition}?{params}
|
||||
// where {condition} might be single (skc) or dual (tsra_hi,20/rain,50)
|
||||
// Each period will have an icon, or two if there is changing weather during that period
|
||||
// see https://github.com/weather-gov/api/discussions/557#discussioncomment-9949521
|
||||
// (On the weather.gov site, changing conditions results in a "dualImage" forecast icon)
|
||||
const iconUrlPattern = /\/icons\/(?<set>\w+)\/(?<timeOfDay>day|night)\/(?<condition>[^?]+)(?:\?(?<params>.*))?$/i;
|
||||
const match = iconUrl.match(iconUrlPattern);
|
||||
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Unable to parse icon URL format: ${iconUrl}`);
|
||||
}
|
||||
|
||||
const { timeOfDay, condition } = match.groups;
|
||||
|
||||
// Determine if it's night time with preference strategy:
|
||||
// 1. Primary: use _isNightTime parameter if provided (such as from API's isDaytime property)
|
||||
// 2. Secondary: use timeOfDay parsed from URL
|
||||
let isNightTime;
|
||||
if (_isNightTime !== undefined) {
|
||||
isNightTime = _isNightTime;
|
||||
} else if (timeOfDay === 'day') {
|
||||
isNightTime = false;
|
||||
} else if (timeOfDay === 'night') {
|
||||
isNightTime = true;
|
||||
} else {
|
||||
console.warn(`parseIconUrl: unexpected timeOfDay value: ${timeOfDay}`);
|
||||
isNightTime = false;
|
||||
}
|
||||
|
||||
// Dual conditions can have a probability
|
||||
// Examples: "tsra_hi,30/sct", "rain_showers,30/tsra_hi,50", "hot/tsra_hi,70"
|
||||
let conditionIcon;
|
||||
let probability;
|
||||
if (condition.includes('/')) { // Two conditions
|
||||
const conditions = condition.split('/');
|
||||
const firstCondition = conditions[0] || '';
|
||||
const secondCondition = conditions[1] || '';
|
||||
|
||||
const [firstIcon, firstProb] = firstCondition.split(',');
|
||||
const [secondIcon, secondProb] = secondCondition.split(',');
|
||||
|
||||
// Default to 100% probability if not specified (high confidence)
|
||||
const firstProbability = parseInt(firstProb, 10) || 100;
|
||||
const secondProbability = parseInt(secondProb, 10) || 100;
|
||||
|
||||
if (secondIcon !== firstIcon) {
|
||||
// When there's more than one condition, use the second condition
|
||||
// QUESTION: should the condition with the higher probability determine which one to use?
|
||||
// if (firstProbability >= secondProbability) { ... }
|
||||
conditionIcon = secondIcon;
|
||||
probability = secondProbability;
|
||||
if (debugFlag('icons')) {
|
||||
console.debug(`2️⃣ Using second condition: '${secondCondition}' instead of first '${firstCondition}'`);
|
||||
}
|
||||
} else {
|
||||
conditionIcon = firstIcon;
|
||||
probability = firstProbability;
|
||||
}
|
||||
} else { // Single condition
|
||||
const [name, prob] = condition.split(',');
|
||||
conditionIcon = name;
|
||||
probability = parseInt(prob, 10) || 100;
|
||||
}
|
||||
|
||||
return {
|
||||
conditionIcon,
|
||||
probability,
|
||||
isNightTime,
|
||||
};
|
||||
};
|
||||
|
||||
export default parseIconUrl;
|
||||
@@ -1,52 +1,46 @@
|
||||
// internal function to add path to returned icon
|
||||
import parseIconUrl from './icons-parse.mjs';
|
||||
|
||||
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
|
||||
|
||||
const smallIcon = (link, _isNightTime) => {
|
||||
// extract day or night if not provided
|
||||
const isNightTime = _isNightTime ?? link.indexOf('/night/') >= 0;
|
||||
let conditionIcon;
|
||||
let probability;
|
||||
let isNightTime;
|
||||
|
||||
// grab everything after the last slash ending at any of these: ?&,
|
||||
const afterLastSlash = link.toLowerCase().match(/[^/]+$/)[0];
|
||||
let conditionName = afterLastSlash.match(/(.*?)[&,.?]/)[1];
|
||||
// using probability as a crude heavy/light indication where possible
|
||||
const value = +(link.match(/,(\d{2,3})/) ?? [0, 100])[1];
|
||||
|
||||
// if a 'DualImage' is captured, adjust to just the j parameter
|
||||
if (conditionName === 'dualimage') {
|
||||
const match = link.match(/&j=(.*)&/);
|
||||
[, conditionName] = match;
|
||||
try {
|
||||
({ conditionIcon, probability, isNightTime } = parseIconUrl(link, _isNightTime));
|
||||
} catch (error) {
|
||||
console.warn(`smallIcon: ${error.message}`);
|
||||
// Return a fallback icon to prevent downstream errors
|
||||
return addPath(_isNightTime ? 'Clear-1992.gif' : 'Sunny.gif');
|
||||
}
|
||||
|
||||
// find the icon
|
||||
switch (conditionName + (isNightTime ? '-n' : '')) {
|
||||
// handle official weather.gov API condition icons
|
||||
switch (conditionIcon + (isNightTime ? '-n' : '')) {
|
||||
case 'skc':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'skc-n':
|
||||
case 'nskc':
|
||||
case 'nskc-n':
|
||||
case 'cold-n':
|
||||
return addPath('Clear-1992.gif');
|
||||
|
||||
case 'few':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'few-n':
|
||||
return addPath('Partly-Clear-1994.gif');
|
||||
|
||||
case 'sct':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'sct-n':
|
||||
return addPath('Partly-Cloudy-Night.gif');
|
||||
|
||||
case 'bkn':
|
||||
return addPath('Mostly-Cloudy-1994.gif');
|
||||
|
||||
case 'bkn-n':
|
||||
case 'few-n':
|
||||
case 'nfew-n':
|
||||
case 'nfew':
|
||||
return addPath('Partly-Clear-1994.gif');
|
||||
|
||||
case 'sct':
|
||||
case 'few':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'sct-n':
|
||||
case 'nsct':
|
||||
case 'nsct-n':
|
||||
case 'haze-n':
|
||||
return addPath('Partly-Cloudy-Night.gif');
|
||||
|
||||
case 'ovc':
|
||||
case 'ovc-n':
|
||||
return addPath('Cloudy.gif');
|
||||
@@ -55,39 +49,33 @@ const smallIcon = (link, _isNightTime) => {
|
||||
case 'fog-n':
|
||||
return addPath('Fog.gif');
|
||||
|
||||
case 'rain_sleet':
|
||||
return addPath('Rain-Sleet.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_high':
|
||||
return addPath('Scattered-Showers-1994.gif');
|
||||
|
||||
case 'rain_showers-n':
|
||||
case 'rain_showers_high-n':
|
||||
return addPath('Scattered-Showers-Night-1994.gif');
|
||||
|
||||
case 'rain':
|
||||
case 'rain-n':
|
||||
return addPath('Rain-1992.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
return addPath('Scattered-Showers-1994.gif');
|
||||
|
||||
case 'rain_showers-n':
|
||||
return addPath('Scattered-Showers-Night-1994.gif');
|
||||
|
||||
case 'rain_showers_hi':
|
||||
return addPath('Scattered-Showers-1994.gif');
|
||||
|
||||
case 'rain_showers_hi-n':
|
||||
return addPath('Scattered-Showers-Night-1994.gif');
|
||||
|
||||
case 'snow':
|
||||
case 'snow-n':
|
||||
if (value > 50) return addPath('Heavy-Snow-1994.gif');
|
||||
if (probability > 50) return addPath('Heavy-Snow-1994.gif');
|
||||
return addPath('Light-Snow.gif');
|
||||
|
||||
case 'rain_snow':
|
||||
case 'rain_snow-n':
|
||||
return addPath('Rain-Snow-1992.gif');
|
||||
|
||||
case 'snow_fzra':
|
||||
case 'snow_fzra-n':
|
||||
return addPath('Freezing-Rain-Snow-1994.gif');
|
||||
|
||||
case 'fzra':
|
||||
case 'fzra-n':
|
||||
case 'rain_fzra':
|
||||
case 'rain_fzra-n':
|
||||
return addPath('Freezing-Rain-1992.gif');
|
||||
case 'rain_sleet':
|
||||
return addPath('Rain-Sleet.gif');
|
||||
|
||||
case 'snow_sleet':
|
||||
case 'snow_sleet-n':
|
||||
@@ -97,64 +85,97 @@ const smallIcon = (link, _isNightTime) => {
|
||||
case 'sleet-n':
|
||||
return addPath('Sleet.gif');
|
||||
|
||||
case 'tsra_sct':
|
||||
case 'fzra':
|
||||
case 'fzra-n':
|
||||
return addPath('Freezing-Rain-1992.gif');
|
||||
|
||||
case 'rain_fzra':
|
||||
case 'rain_fzra-n':
|
||||
return addPath('Freezing-Rain-1992.gif');
|
||||
|
||||
case 'snow_fzra':
|
||||
case 'snow_fzra-n':
|
||||
return addPath('Freezing-Rain-Snow-1994.gif');
|
||||
|
||||
case 'tsra':
|
||||
return addPath('Scattered-Tstorms-1994.gif');
|
||||
|
||||
case 'tsra_sct-n':
|
||||
case 'tsra-n':
|
||||
return addPath('Scattered-Tstorms-Night-1994.gif');
|
||||
|
||||
case 'tsra_sct':
|
||||
return addPath('Scattered-Tstorms-1994.gif');
|
||||
|
||||
case 'tsra_sct-n':
|
||||
return addPath('Scattered-Tstorms-Night-1994.gif');
|
||||
|
||||
case 'tsra_hi':
|
||||
case 'tsra_hi-n':
|
||||
case 'hurricane':
|
||||
case 'tropical_storm':
|
||||
case 'hurricane-n':
|
||||
case 'tropical_storm-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind':
|
||||
case 'wind_':
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind-n':
|
||||
case 'wind_-n':
|
||||
case 'wind_few-n':
|
||||
return addPath('Wind.gif');
|
||||
case 'tornado':
|
||||
case 'tornado-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind_bkn':
|
||||
case 'wind_ovc':
|
||||
case 'wind_bkn-n':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Cloudy-Wind.gif');
|
||||
case 'hurricane':
|
||||
case 'hurricane-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'tropical_storm':
|
||||
case 'tropical_storm-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind_skc':
|
||||
return addPath('Sunny-Wind-1994.gif');
|
||||
|
||||
case 'wind_skc-n':
|
||||
return addPath('Clear-Wind-1994.gif');
|
||||
|
||||
case 'wind_few':
|
||||
case 'wind_few-n':
|
||||
return addPath('Wind.gif');
|
||||
|
||||
case 'wind_sct':
|
||||
return addPath('Wind.gif');
|
||||
|
||||
case 'wind_sct-n':
|
||||
return addPath('Clear-Wind-1994.gif');
|
||||
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing Snow.gif');
|
||||
case 'wind_bkn':
|
||||
case 'wind_bkn-n':
|
||||
return addPath('Cloudy-Wind.gif');
|
||||
|
||||
case 'cold':
|
||||
return addPath('Cold.gif');
|
||||
case 'wind_ovc':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Cloudy-Wind.gif');
|
||||
|
||||
case 'dust':
|
||||
case 'dust-n':
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'smoke':
|
||||
case 'smoke-n':
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'haze':
|
||||
case 'haze-n':
|
||||
return addPath('Haze.gif');
|
||||
|
||||
case 'hot':
|
||||
return addPath('Hot.gif');
|
||||
|
||||
case 'haze':
|
||||
return addPath('Haze.gif');
|
||||
case 'cold':
|
||||
case 'cold-n':
|
||||
return addPath('Cold.gif');
|
||||
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing Snow.gif');
|
||||
|
||||
default:
|
||||
console.log(`Unable to locate regional icon for ${conditionName} ${link} ${isNightTime}`);
|
||||
return false;
|
||||
console.warn(`Unknown weather condition '${conditionIcon}' from ${link}; using fallback icon`);
|
||||
// Return a reasonable fallback instead of false to prevent downstream errors
|
||||
return addPath(isNightTime ? 'Clear-1992.gif' : 'Sunny.gif');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// current weather conditions display
|
||||
import { distance as calcDistance, directionToNSEW } from './utils/calc.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import STATUS from './status.mjs';
|
||||
import { locationCleanup } from './utils/string.mjs';
|
||||
import { temperature, windSpeed } from './utils/units.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import augmentObservationWithMetar from './utils/metar.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import { enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||
|
||||
class LatestObservations extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -32,14 +35,17 @@ class LatestObservations extends WeatherDisplay {
|
||||
// try up to 30 regional stations
|
||||
const regionalStations = sortedStations.slice(0, 30);
|
||||
|
||||
// get data for regional stations
|
||||
// get first 7 stations
|
||||
// Fetch stations sequentially in batches to avoid unnecessary API calls.
|
||||
// We start with the 7 closest stations and only fetch more if some fail,
|
||||
// stopping as soon as we have 7 valid stations with data.
|
||||
const actualConditions = [];
|
||||
let lastStation = Math.min(regionalStations.length, 7);
|
||||
let firstStation = 0;
|
||||
while (actualConditions.length < 7 && (lastStation) <= regionalStations.length) {
|
||||
// Sequential fetching is intentional here - we want to try closest stations first
|
||||
// and only fetch additional batches if needed, rather than hitting all 30 stations at once
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const someStations = await getStations(regionalStations.slice(firstStation, lastStation));
|
||||
const someStations = await this.getStations(regionalStations.slice(firstStation, lastStation));
|
||||
|
||||
actualConditions.push(...someStations);
|
||||
// update counters
|
||||
@@ -58,6 +64,79 @@ class LatestObservations extends WeatherDisplay {
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
// This is a class method because it needs access to the instance's `stillWaiting` method
|
||||
async getStations(stations) {
|
||||
// Use centralized safe Promise handling to avoid unhandled AbortError rejections
|
||||
const stationData = await safePromiseAll(stations.map(async (station) => {
|
||||
try {
|
||||
const data = await safeJson(`https://api.weather.gov/stations/${station.id}/observations/latest`, {
|
||||
retryCount: 1,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.log(`Failed to get Latest Observations for station ${station.id}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enhance observation data with METAR parsing for missing fields
|
||||
const originalData = { ...data.properties };
|
||||
data.properties = augmentObservationWithMetar(data.properties);
|
||||
const metarFields = [
|
||||
{ name: 'temperature', check: (orig, metar) => orig.temperature.value === null && metar.temperature.value !== null },
|
||||
{ name: 'windSpeed', check: (orig, metar) => orig.windSpeed.value === null && metar.windSpeed.value !== null },
|
||||
{ name: 'windDirection', check: (orig, metar) => orig.windDirection.value === null && metar.windDirection.value !== null },
|
||||
];
|
||||
const augmentedData = data.properties;
|
||||
const metarReplacements = metarFields.filter((field) => field.check(originalData, augmentedData)).map((field) => field.name);
|
||||
if (debugFlag('latestobservations') && metarReplacements.length > 0) {
|
||||
console.log(`Latest Observations for station ${station.id} were augmented with METAR data for ${metarReplacements.join(', ')}`);
|
||||
}
|
||||
|
||||
// test data quality
|
||||
const requiredFields = [
|
||||
{ name: 'temperature', check: (props) => props.temperature?.value === null },
|
||||
{ name: 'windSpeed', check: (props) => props.windSpeed?.value === null },
|
||||
{ name: 'windDirection', check: (props) => props.windDirection?.value === null },
|
||||
{ name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '' },
|
||||
];
|
||||
|
||||
// Use enhanced observation with MapClick fallback
|
||||
const enhancedResult = await enhanceObservationWithMapClick(data.properties, {
|
||||
requiredFields,
|
||||
stationId: station.id,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
debugContext: 'latestobservations',
|
||||
});
|
||||
|
||||
data.properties = enhancedResult.data;
|
||||
const { missingFields } = enhancedResult;
|
||||
|
||||
// Check final data quality
|
||||
if (missingFields.length > 0) {
|
||||
if (debugFlag('latestobservations')) {
|
||||
console.log(`Latest Observations for station ${station.id} is missing fields: ${missingFields.join(', ')}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// format the return values
|
||||
return {
|
||||
...data.properties,
|
||||
StationId: station.id,
|
||||
city: station.city,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting latest observations for station ${station.id}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
// filter false (no data or other error)
|
||||
return stationData.filter((d) => d);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
const conditions = this.data;
|
||||
@@ -106,6 +185,7 @@ class LatestObservations extends WeatherDisplay {
|
||||
this.finishDraw();
|
||||
}
|
||||
}
|
||||
|
||||
const shortenCurrentConditions = (_condition) => {
|
||||
let condition = _condition;
|
||||
condition = condition.replace(/Light/, 'L');
|
||||
@@ -124,28 +204,5 @@ const shortenCurrentConditions = (_condition) => {
|
||||
condition = condition.replace(/ with /, '/');
|
||||
return condition;
|
||||
};
|
||||
|
||||
const getStations = async (stations) => {
|
||||
const stationData = await Promise.all(stations.map(async (station) => {
|
||||
try {
|
||||
const data = await json(`https://api.weather.gov/stations/${station.id}/observations/latest`, { retryCount: 1, stillWaiting: () => this.stillWaiting() });
|
||||
// test for temperature, weather and wind values present
|
||||
if (data.properties.temperature.value === null
|
||||
|| data.properties.textDescription === ''
|
||||
|| data.properties.windSpeed.value === null) return false;
|
||||
// format the return values
|
||||
return {
|
||||
...data.properties,
|
||||
StationId: station.id,
|
||||
city: station.city,
|
||||
};
|
||||
} catch {
|
||||
console.log(`Unable to get latest observations for ${station.id}`);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
// filter false (no data or other error)
|
||||
return stationData.filter((d) => d);
|
||||
};
|
||||
// register display
|
||||
registerDisplay(new LatestObservations(2, 'latest-observations'));
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
// display text based local forecast
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import filterExpiredPeriods from './utils/forecast-utils.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class LocalForecast extends WeatherDisplay {
|
||||
static BASE_FORECAST_DURATION_MS = 5000; // Base duration (in ms) for a standard 3-5 line forecast page
|
||||
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Local Forecast', true);
|
||||
|
||||
// set timings
|
||||
this.timing.baseDelay = 5000;
|
||||
this.timing.baseDelay = LocalForecast.BASE_FORECAST_DURATION_MS;
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
@@ -22,13 +26,13 @@ class LocalForecast extends WeatherDisplay {
|
||||
// check for data, or if there's old data available
|
||||
if (!rawData && !this.data) {
|
||||
// fail for no old or new data
|
||||
this.setStatus(STATUS.failed);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
// store the data
|
||||
this.data = rawData || this.data;
|
||||
// parse raw data
|
||||
const conditions = parse(this.data);
|
||||
// parse raw data and filter out expired periods
|
||||
const conditions = parse(this.data, this.weatherParameters.forecast);
|
||||
|
||||
// read each text
|
||||
this.screenTexts = conditions.map((condition) => {
|
||||
@@ -46,34 +50,32 @@ class LocalForecast extends WeatherDisplay {
|
||||
forecastsElem.innerHTML = '';
|
||||
forecastsElem.append(...templates);
|
||||
|
||||
// increase each forecast height to a multiple of container height
|
||||
// Get page height for screen calculations
|
||||
this.pageHeight = forecastsElem.parentNode.offsetHeight;
|
||||
templates.forEach((forecast) => {
|
||||
const newHeight = Math.ceil(forecast.scrollHeight / this.pageHeight) * this.pageHeight;
|
||||
forecast.style.height = `${newHeight}px`;
|
||||
});
|
||||
|
||||
this.timing.totalScreens = forecastsElem.scrollHeight / this.pageHeight;
|
||||
this.calculateContentAwareTiming(templates);
|
||||
|
||||
this.calcNavTiming();
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
// get the unformatted data (also used by extended forecast)
|
||||
async getRawData(weatherParameters) {
|
||||
// request us or si units
|
||||
try {
|
||||
return await json(weatherParameters.forecast, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`);
|
||||
console.error(error.status, error.responseJSON);
|
||||
// request us or si units using centralized safe handling
|
||||
const data = await safeJson(weatherParameters.forecast, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
@@ -84,14 +86,180 @@ class LocalForecast extends WeatherDisplay {
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
|
||||
// calculate dynamic timing based on height measurement template approach
|
||||
calculateContentAwareTiming(templates) {
|
||||
if (!templates || templates.length === 0) {
|
||||
this.timing.delay = 1; // fallback to single delay if no templates
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the original base duration constant for timing calculations
|
||||
const originalBaseDuration = LocalForecast.BASE_FORECAST_DURATION_MS;
|
||||
this.timing.baseDelay = 250; // use 250ms per count for precise timing control
|
||||
|
||||
// Get line height from CSS for accurate calculations
|
||||
const sampleForecast = templates[0];
|
||||
const computedStyle = window.getComputedStyle(sampleForecast);
|
||||
const lineHeight = parseInt(computedStyle.lineHeight, 10);
|
||||
|
||||
// Calculate the actual width that forecast text uses
|
||||
// Use the forecast container that's already been set up
|
||||
const forecastContainer = this.elem.querySelector('.local-forecast .container');
|
||||
let effectiveWidth;
|
||||
|
||||
if (!forecastContainer) {
|
||||
console.error('LocalForecast: Could not find forecast container for width calculation, using fallback width');
|
||||
effectiveWidth = 492; // "magic number" from manual calculations as fallback
|
||||
} else {
|
||||
const containerStyle = window.getComputedStyle(forecastContainer);
|
||||
const containerWidth = forecastContainer.offsetWidth;
|
||||
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
|
||||
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
|
||||
effectiveWidth = containerWidth - paddingLeft - paddingRight;
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Using measurement width of ${effectiveWidth}px (container=${containerWidth}px, padding=${paddingLeft}+${paddingRight}px)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Measure each forecast period to get actual line counts
|
||||
const forecastLineCounts = [];
|
||||
templates.forEach((template, index) => {
|
||||
const currentHeight = template.offsetHeight;
|
||||
const currentLines = Math.round(currentHeight / lineHeight);
|
||||
|
||||
if (currentLines > 7) {
|
||||
// Multi-page forecasts measure correctly, so use the measurement directly
|
||||
forecastLineCounts.push(currentLines);
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Forecast ${index} measured ${currentLines} lines (${currentHeight}px direct measurement, ${lineHeight}px line-height)`);
|
||||
}
|
||||
} else {
|
||||
// If may be 7 lines or less, we need to pad the content to ensure proper height measurement
|
||||
// Short forecasts are capped by CSS min-height: 280px (7 lines)
|
||||
// Add 7 <br> tags to force height beyond the minimum, then subtract the padding
|
||||
const originalHTML = template.innerHTML;
|
||||
const paddingBRs = '<br/>'.repeat(7);
|
||||
template.innerHTML = originalHTML + paddingBRs;
|
||||
|
||||
// Measure the padded height
|
||||
const paddedHeight = template.offsetHeight;
|
||||
const paddedLines = Math.round(paddedHeight / lineHeight);
|
||||
|
||||
// Calculate actual content lines by subtracting the 7 BR lines we added
|
||||
const actualLines = Math.max(1, paddedLines - 7);
|
||||
|
||||
// Restore original content
|
||||
template.innerHTML = originalHTML;
|
||||
|
||||
forecastLineCounts.push(actualLines);
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Forecast ${index} measured ${actualLines} lines (${paddedHeight}px with padding - ${7 * lineHeight}px = ${actualLines * lineHeight}px actual, ${lineHeight}px line-height)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply height padding for proper scrolling display (keep existing system working)
|
||||
templates.forEach((forecast) => {
|
||||
const newHeight = Math.ceil(forecast.offsetHeight / this.pageHeight) * this.pageHeight;
|
||||
forecast.style.height = `${newHeight}px`;
|
||||
});
|
||||
|
||||
// Calculate total screens based on padded height (for navigation system)
|
||||
const forecastsElem = templates[0].parentNode;
|
||||
const totalHeight = forecastsElem.scrollHeight;
|
||||
this.timing.totalScreens = Math.round(totalHeight / this.pageHeight);
|
||||
|
||||
// Now calculate timing based on actual measured line counts, ignoring padding
|
||||
const maxLinesPerScreen = 7; // 280px / 40px line height
|
||||
const screenTimings = []; forecastLineCounts.forEach((lines, forecastIndex) => {
|
||||
if (lines <= maxLinesPerScreen) {
|
||||
// Single screen for this forecast
|
||||
screenTimings.push({ forecastIndex, lines, type: 'single' });
|
||||
} else {
|
||||
// Multiple screens for this forecast
|
||||
let remainingLines = lines;
|
||||
let isFirst = true;
|
||||
|
||||
while (remainingLines > 0) {
|
||||
const linesThisScreen = Math.min(remainingLines, maxLinesPerScreen);
|
||||
const type = isFirst ? 'first-of-multi' : 'remainder';
|
||||
|
||||
screenTimings.push({ forecastIndex, lines: linesThisScreen, type });
|
||||
|
||||
remainingLines -= linesThisScreen;
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create timing array based on measured line counts
|
||||
const screenDelays = screenTimings.map((screenInfo, screenIndex) => {
|
||||
const screenLines = screenInfo.lines;
|
||||
|
||||
// Apply timing rules based on actual screen content lines
|
||||
let timingMultiplier;
|
||||
if (screenLines === 1) {
|
||||
timingMultiplier = 0.6; // 1 line = shortest (3.0s at normal speed)
|
||||
} else if (screenLines === 2) {
|
||||
timingMultiplier = 0.8; // 2 lines = shorter (4.0s at normal speed)
|
||||
} else if (screenLines >= 6) {
|
||||
timingMultiplier = 1.4; // 6+ lines = longer (7.0s at normal speed)
|
||||
} else {
|
||||
timingMultiplier = 1.0; // 3-5 lines = normal (5.0s at normal speed)
|
||||
}
|
||||
|
||||
// Convert to base counts
|
||||
const desiredDurationMs = timingMultiplier * originalBaseDuration;
|
||||
const baseCounts = Math.round(desiredDurationMs / this.timing.baseDelay);
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Screen ${screenIndex}: ${screenLines} lines, ${timingMultiplier.toFixed(2)}x multiplier, ${desiredDurationMs}ms desired, ${baseCounts} counts (forecast ${screenInfo.forecastIndex}, ${screenInfo.type})`);
|
||||
}
|
||||
|
||||
return baseCounts;
|
||||
});
|
||||
|
||||
// Adjust timing array to match actual screen count if needed
|
||||
while (screenDelays.length < this.timing.totalScreens) {
|
||||
// Add fallback timing for extra screens
|
||||
const fallbackCounts = Math.round(originalBaseDuration / this.timing.baseDelay);
|
||||
screenDelays.push(fallbackCounts);
|
||||
console.warn(`LocalForecast: using fallback timing for Screen ${screenDelays.length - 1}: 5 lines, 1.00x multiplier, ${fallbackCounts} counts`);
|
||||
}
|
||||
|
||||
// Truncate if we have too many calculated screens
|
||||
if (screenDelays.length > this.timing.totalScreens) {
|
||||
const removed = screenDelays.splice(this.timing.totalScreens);
|
||||
console.warn(`LocalForecast: Truncated ${removed.length} excess screen timings`);
|
||||
}
|
||||
|
||||
// Set the timing array based on screen content
|
||||
this.timing.delay = screenDelays;
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Final screen count - calculated: ${screenTimings.length}, actual: ${this.timing.totalScreens}, timing array: ${screenDelays.length}`);
|
||||
const multipliers = screenDelays.map((counts) => counts * this.timing.baseDelay / originalBaseDuration);
|
||||
console.log('LocalForecast: Screen multipliers:', multipliers);
|
||||
console.log('LocalForecast: Expected durations (ms):', screenDelays.map((counts) => counts * this.timing.baseDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// format the forecast
|
||||
// only use the first 6 lines
|
||||
const parse = (forecast) => forecast.properties.periods.slice(0, 6).map((text) => ({
|
||||
// format day and text
|
||||
DayName: text.name.toUpperCase(),
|
||||
Text: text.detailedForecast,
|
||||
}));
|
||||
// filter out expired periods, then use the first 6 forecasts
|
||||
const parse = (forecast, forecastUrl) => {
|
||||
const allPeriods = forecast.properties.periods;
|
||||
const activePeriods = filterExpiredPeriods(allPeriods, forecastUrl);
|
||||
|
||||
return activePeriods.slice(0, 6).map((text) => ({
|
||||
// format day and text
|
||||
DayName: text.name.toUpperCase(),
|
||||
Text: text.detailedForecast,
|
||||
}));
|
||||
};
|
||||
// register display
|
||||
registerDisplay(new LocalForecast(7, 'local-forecast'));
|
||||
|
||||
@@ -20,45 +20,55 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
const scanMusicDirectory = async () => {
|
||||
const parseDirectory = async (path, prefix = "") => {
|
||||
const listing = await text(path);
|
||||
const matches = [...listing.matchAll(/href="([^\"]+\.mp3)"/gi)];
|
||||
return matches.map((m) => `${prefix}${m[1]}`);
|
||||
};
|
||||
const parseDirectory = async (path, prefix = '') => {
|
||||
const listing = await text(path);
|
||||
const matches = [...listing.matchAll(/href="([^"]+\.mp3)"/gi)];
|
||||
return matches.map((m) => `${prefix}${m[1]}`);
|
||||
};
|
||||
|
||||
try {
|
||||
let files = await parseDirectory("music/");
|
||||
if (files.length === 0) {
|
||||
files = await parseDirectory("music/default/", "default/");
|
||||
}
|
||||
return { availableFiles: files };
|
||||
} catch (e) {
|
||||
console.error("Unable to scan music directory");
|
||||
console.error(e);
|
||||
return { availableFiles: [] };
|
||||
}
|
||||
try {
|
||||
let files = await parseDirectory('music/');
|
||||
if (files.length === 0) {
|
||||
files = await parseDirectory('music/default/', 'default/');
|
||||
}
|
||||
return { availableFiles: files };
|
||||
} catch (e) {
|
||||
console.error('Unable to scan music directory');
|
||||
console.error(e);
|
||||
return { availableFiles: [] };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getMedia = async () => {
|
||||
try {
|
||||
const response = await fetch('playlist.json');
|
||||
if (response.ok) {
|
||||
playlist = await response.json();
|
||||
} else if (response.status === 404
|
||||
&& response.headers.get('X-Weatherstar') === 'true') {
|
||||
console.warn("Couldn't get playlist.json, falling back to directory scan");
|
||||
playlist = await scanMusicDirectory();
|
||||
} else {
|
||||
console.warn(`Couldn't get playlist.json: ${response.status} ${response.statusText}`);
|
||||
playlist = { availableFiles: [] };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Couldn't get playlist.json, falling back to directory scan");
|
||||
playlist = await scanMusicDirectory();
|
||||
}
|
||||
let playlistSource = '';
|
||||
|
||||
enableMediaPlayer();
|
||||
try {
|
||||
const response = await fetch('playlist.json');
|
||||
if (response.ok) {
|
||||
playlist = await response.json();
|
||||
playlistSource = 'from server';
|
||||
} else if (response.status === 404 && response.headers.get('X-Weatherstar') === 'true') {
|
||||
// Expected behavior in static deployment mode
|
||||
playlist = await scanMusicDirectory();
|
||||
playlistSource = 'via directory scan (static deployment)';
|
||||
} else {
|
||||
playlist = { availableFiles: [] };
|
||||
playlistSource = `failed (${response.status} ${response.statusText})`;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Network error or other fetch failure - fall back to directory scanning
|
||||
playlist = await scanMusicDirectory();
|
||||
playlistSource = 'via directory scan (after fetch failed)';
|
||||
}
|
||||
|
||||
const fileCount = playlist?.availableFiles?.length || 0;
|
||||
if (fileCount > 0) {
|
||||
console.log(`Loaded playlist ${playlistSource} - found ${fileCount} music file${fileCount === 1 ? '' : 's'}`);
|
||||
} else {
|
||||
console.log(`No music files found ${playlistSource}`);
|
||||
}
|
||||
|
||||
enableMediaPlayer();
|
||||
};
|
||||
|
||||
const enableMediaPlayer = () => {
|
||||
@@ -219,11 +229,11 @@ const playerEnded = () => {
|
||||
};
|
||||
|
||||
const setTrackName = (fileName) => {
|
||||
const baseName = fileName.split('/').pop();
|
||||
const trackName = decodeURIComponent(
|
||||
baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, '')
|
||||
);
|
||||
document.getElementById('musicTrack').innerHTML = trackName;
|
||||
const baseName = fileName.split('/').pop();
|
||||
const trackName = decodeURIComponent(
|
||||
baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, ''),
|
||||
);
|
||||
document.getElementById('musicTrack').innerHTML = trackName;
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import noSleep from './utils/nosleep.mjs';
|
||||
import STATUS from './status.mjs';
|
||||
import { wrap } from './utils/calc.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { getPoint } from './utils/weather.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import settings from './settings.mjs';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -16,8 +17,36 @@ let progress;
|
||||
const weatherParameters = {};
|
||||
|
||||
const init = async () => {
|
||||
// set up resize handler
|
||||
window.addEventListener('resize', resize);
|
||||
// set up the resize handler with debounce logic to prevent rapid-fire calls
|
||||
let resizeTimeout;
|
||||
|
||||
// Handle fullscreen change events and trigger an immediate resize calculation
|
||||
const fullscreenEvents = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange'];
|
||||
fullscreenEvents.forEach((eventName) => {
|
||||
document.addEventListener(eventName, () => {
|
||||
if (debugFlag('fullscreen')) {
|
||||
console.log(`🖥️ ${eventName} event fired. fullscreenElement=${!!document.fullscreenElement}`);
|
||||
}
|
||||
resize(true);
|
||||
});
|
||||
});
|
||||
|
||||
// De-bounced resize handler to prevent rapid-fire resize calls
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => resize(), 100);
|
||||
});
|
||||
|
||||
// Handle orientation changes (Mobile Safari doesn't always fire resize events on orientation change)
|
||||
window.addEventListener('orientationchange', () => {
|
||||
if (debugFlag('resize')) {
|
||||
console.log('📱 Orientation change detected, forcing resize after short delay');
|
||||
}
|
||||
clearTimeout(resizeTimeout);
|
||||
// Use a slightly longer delay for orientation changes to allow the browser to settle
|
||||
resizeTimeout = setTimeout(() => resize(true), 200);
|
||||
});
|
||||
|
||||
resize();
|
||||
|
||||
generateCheckboxes();
|
||||
@@ -34,62 +63,87 @@ const getWeather = async (latLon, haveDataCallback) => {
|
||||
// get initial weather data
|
||||
const point = await getPoint(latLon.lat, latLon.lon);
|
||||
|
||||
// check if point data was successfully retrieved
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof haveDataCallback === 'function') haveDataCallback(point);
|
||||
|
||||
// get stations
|
||||
const stations = await json(point.properties.observationStations);
|
||||
try {
|
||||
// get stations using centralized safe handling
|
||||
const stations = await safeJson(point.properties.observationStations);
|
||||
|
||||
const StationId = stations.features[0].properties.stationIdentifier;
|
||||
if (!stations) {
|
||||
console.warn('Failed to get Observation Stations');
|
||||
return;
|
||||
}
|
||||
|
||||
let { city } = point.properties.relativeLocation.properties;
|
||||
const { state } = point.properties.relativeLocation.properties;
|
||||
// check if stations data is valid
|
||||
if (!stations || !stations.features || stations.features.length === 0) {
|
||||
console.warn('No Observation Stations found for this location');
|
||||
return;
|
||||
}
|
||||
|
||||
if (StationId in StationInfo) {
|
||||
city = StationInfo[StationId].city;
|
||||
[city] = city.split('/');
|
||||
city = city.replace(/\s+$/, '');
|
||||
const StationId = stations.features[0].properties.stationIdentifier;
|
||||
|
||||
let { city } = point.properties.relativeLocation.properties;
|
||||
const { state } = point.properties.relativeLocation.properties;
|
||||
|
||||
if (StationId in StationInfo) {
|
||||
city = StationInfo[StationId].city;
|
||||
[city] = city.split('/');
|
||||
city = city.replace(/\s+$/, '');
|
||||
}
|
||||
|
||||
// populate the weather parameters
|
||||
weatherParameters.latitude = latLon.lat;
|
||||
weatherParameters.longitude = latLon.lon;
|
||||
weatherParameters.zoneId = point.properties.forecastZone.substr(-6);
|
||||
weatherParameters.radarId = point.properties.radarStation.substr(-3);
|
||||
weatherParameters.stationId = StationId;
|
||||
weatherParameters.weatherOffice = point.properties.cwa;
|
||||
weatherParameters.city = city;
|
||||
weatherParameters.state = state;
|
||||
weatherParameters.timeZone = point.properties.timeZone;
|
||||
weatherParameters.forecast = point.properties.forecast;
|
||||
weatherParameters.forecastGridData = point.properties.forecastGridData;
|
||||
weatherParameters.stations = stations.features;
|
||||
|
||||
// update the main process for display purposes
|
||||
populateWeatherParameters(weatherParameters);
|
||||
|
||||
// reset the scroll
|
||||
postMessage({ type: 'current-weather-scroll', method: 'reload' });
|
||||
|
||||
// draw the progress canvas and hide others
|
||||
hideAllCanvases();
|
||||
if (!settings?.kiosk?.value) {
|
||||
// In normal mode, hide loading screen and show progress
|
||||
// (In kiosk mode, keep the loading screen visible until autoplay starts)
|
||||
document.querySelector('#loading').style.display = 'none';
|
||||
if (progress) {
|
||||
await progress.drawCanvas();
|
||||
progress.showCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
// call for new data on each display
|
||||
displays.forEach((display) => display.getData(weatherParameters));
|
||||
} catch (error) {
|
||||
console.error(`Failed to get weather data: ${error.message}`);
|
||||
}
|
||||
|
||||
// populate the weather parameters
|
||||
weatherParameters.latitude = latLon.lat;
|
||||
weatherParameters.longitude = latLon.lon;
|
||||
weatherParameters.zoneId = point.properties.forecastZone.substr(-6);
|
||||
weatherParameters.radarId = point.properties.radarStation.substr(-3);
|
||||
weatherParameters.stationId = StationId;
|
||||
weatherParameters.weatherOffice = point.properties.cwa;
|
||||
weatherParameters.city = city;
|
||||
weatherParameters.state = state;
|
||||
weatherParameters.timeZone = point.properties.timeZone;
|
||||
weatherParameters.forecast = point.properties.forecast;
|
||||
weatherParameters.forecastGridData = point.properties.forecastGridData;
|
||||
weatherParameters.stations = stations.features;
|
||||
|
||||
// update the main process for display purposes
|
||||
populateWeatherParameters(weatherParameters);
|
||||
|
||||
// reset the scroll
|
||||
postMessage({ type: 'current-weather-scroll', method: 'reload' });
|
||||
|
||||
// draw the progress canvas and hide others
|
||||
hideAllCanvases();
|
||||
document.querySelector('#loading').style.display = 'none';
|
||||
if (progress) {
|
||||
await progress.drawCanvas();
|
||||
progress.showCanvas();
|
||||
}
|
||||
|
||||
// call for new data on each display
|
||||
displays.forEach((display) => display.getData(weatherParameters));
|
||||
};
|
||||
|
||||
// receive a status update from a module {id, value}
|
||||
const updateStatus = (value) => {
|
||||
if (value.id < 0) return;
|
||||
if (!progress) return;
|
||||
progress.drawCanvas(displays, countLoadedDisplays());
|
||||
if (!progress && !settings?.kiosk?.value) return;
|
||||
|
||||
if (progress) progress.drawCanvas(displays, countLoadedDisplays());
|
||||
|
||||
// first display is hazards and it must load before evaluating the first display
|
||||
if (displays[0].status === STATUS.loading) return;
|
||||
if (!displays[0] || displays[0].status === STATUS.loading) return;
|
||||
|
||||
// calculate first enabled display
|
||||
const firstDisplayIndex = displays.findIndex((display) => display?.enabled && display?.timing?.totalScreens > 0);
|
||||
@@ -102,7 +156,7 @@ const updateStatus = (value) => {
|
||||
}
|
||||
|
||||
// if hazards data arrives after the firstDisplayIndex loads, then we need to hot wire this to the first display
|
||||
if (value.id === 0 && value.status === STATUS.loaded && displays[0].timing.totalScreens === 0) {
|
||||
if (value.id === 0 && value.status === STATUS.loaded && displays[0] && displays[0].timing && displays[0].timing.totalScreens === 0) {
|
||||
value.id = firstDisplayIndex;
|
||||
value.status = displays[firstDisplayIndex].status;
|
||||
}
|
||||
@@ -153,19 +207,30 @@ const displayNavMessage = (myMessage) => {
|
||||
const navTo = (direction) => {
|
||||
// test for a current display
|
||||
const current = currentDisplay();
|
||||
progress.hideCanvas();
|
||||
if (progress) progress.hideCanvas();
|
||||
if (!current) {
|
||||
// special case for no active displays (typically on progress screen)
|
||||
// find the first ready display
|
||||
let firstDisplay;
|
||||
let displayCount = 0;
|
||||
do {
|
||||
if (displays[displayCount].status === STATUS.loaded && displays[displayCount].timing.totalScreens > 0) firstDisplay = displays[displayCount];
|
||||
// Check if displayCount is within bounds and the display exists
|
||||
if (displayCount < displays.length && displays[displayCount]) {
|
||||
const display = displays[displayCount];
|
||||
if (display.status === STATUS.loaded && display.timing?.totalScreens > 0) {
|
||||
firstDisplay = display;
|
||||
}
|
||||
}
|
||||
displayCount += 1;
|
||||
} while (!firstDisplay && displayCount < displays.length);
|
||||
|
||||
if (!firstDisplay) return;
|
||||
|
||||
// In kiosk mode, hide the loading screen when we start showing the first display
|
||||
if (settings?.kiosk?.value) {
|
||||
document.querySelector('#loading').style.display = 'none';
|
||||
}
|
||||
|
||||
firstDisplay.navNext(msg.command.firstFrame);
|
||||
firstDisplay.showCanvas();
|
||||
return;
|
||||
@@ -179,11 +244,32 @@ const loadDisplay = (direction) => {
|
||||
const totalDisplays = displays.length;
|
||||
const curIdx = currentDisplayIndex();
|
||||
let idx;
|
||||
let foundSuitableDisplay = false;
|
||||
|
||||
for (let i = 0; i < totalDisplays; i += 1) {
|
||||
// convert form simple 0-10 to start at current display index +/-1 and wrap
|
||||
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
|
||||
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) break;
|
||||
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) {
|
||||
// Prevent infinite recursion by ensuring we don't select the same display
|
||||
if (idx !== curIdx) {
|
||||
foundSuitableDisplay = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no other suitable display was found, but current display is still suitable (e.g. user only enabled one display), stay on it
|
||||
if (!foundSuitableDisplay && displays[curIdx] && displays[curIdx].status === STATUS.loaded && displays[curIdx].timing.totalScreens > 0) {
|
||||
idx = curIdx;
|
||||
foundSuitableDisplay = true;
|
||||
}
|
||||
|
||||
// if no suitable display was found at all, do NOT proceed to avoid infinite recursion
|
||||
if (!foundSuitableDisplay) {
|
||||
console.warn('No suitable display found for navigation');
|
||||
return;
|
||||
}
|
||||
|
||||
const newDisplay = displays[idx];
|
||||
// hide all displays
|
||||
hideAllCanvases();
|
||||
@@ -202,17 +288,24 @@ const setPlaying = (newValue) => {
|
||||
localStorage.setItem('play', playing);
|
||||
|
||||
if (playing) {
|
||||
noSleep(true);
|
||||
noSleep(true).catch(() => {
|
||||
// Wake lock failed, but continue normally
|
||||
});
|
||||
playButton.title = 'Pause';
|
||||
playButton.src = 'images/nav/ic_pause_white_24dp_2x.png';
|
||||
} else {
|
||||
noSleep(false);
|
||||
noSleep(false).catch(() => {
|
||||
// Wake lock disable failed, but continue normally
|
||||
});
|
||||
playButton.title = 'Play';
|
||||
playButton.src = 'images/nav/ic_play_arrow_white_24dp_2x.png';
|
||||
}
|
||||
// if we're playing and on the progress screen jump to the next screen
|
||||
if (!progress) return;
|
||||
if (playing && !currentDisplay()) navTo(msg.command.firstFrame);
|
||||
// if we're playing and on the progress screen (or in kiosk mode), jump to the next screen
|
||||
if (playing && !currentDisplay()) {
|
||||
if (progress || settings?.kiosk?.value) {
|
||||
navTo(msg.command.firstFrame);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// handle all navigation buttons
|
||||
@@ -237,7 +330,12 @@ const handleNavButton = (button) => {
|
||||
break;
|
||||
case 'menu':
|
||||
setPlaying(false);
|
||||
progress.showCanvas();
|
||||
if (progress) {
|
||||
progress.showCanvas();
|
||||
} else if (settings?.kiosk?.value) {
|
||||
// In kiosk mode without progress, show the loading screen
|
||||
document.querySelector('#loading').style.display = 'flex';
|
||||
}
|
||||
hideAllCanvases();
|
||||
break;
|
||||
default:
|
||||
@@ -248,18 +346,222 @@ const handleNavButton = (button) => {
|
||||
// return the specificed display
|
||||
const getDisplay = (index) => displays[index];
|
||||
|
||||
// resize the container on a page resize
|
||||
const resize = () => {
|
||||
const targetWidth = settings.wide.value ? 640 + 107 + 107 : 640;
|
||||
const widthZoomPercent = (document.querySelector('#divTwcBottom').getBoundingClientRect().width) / targetWidth;
|
||||
const heightZoomPercent = (window.innerHeight) / 480;
|
||||
// Helper function to detect iOS (using technique from nosleep.js)
|
||||
const isIOS = () => {
|
||||
const { userAgent } = navigator;
|
||||
const iOSRegex = /CPU.*OS ([0-9_]{1,})[0-9_]{0,}|(CPU like).*AppleWebKit.*Mobile/i;
|
||||
return iOSRegex.test(userAgent) && !window.MSStream;
|
||||
};
|
||||
|
||||
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
||||
if (scale < 1.0 || document.fullscreenElement || settings.kiosk) {
|
||||
document.querySelector('#container').style.zoom = scale;
|
||||
} else {
|
||||
document.querySelector('#container').style.zoom = 'unset';
|
||||
// Track the last applied scale to avoid redundant operations
|
||||
let lastAppliedScale = null;
|
||||
let lastAppliedKioskMode = null;
|
||||
|
||||
// resize the container on a page resize
|
||||
const resize = (force = false) => {
|
||||
// Ignore resize events caused by pinch-to-zoom on mobile
|
||||
if (window.visualViewport && Math.abs(window.visualViewport.scale - 1) > 0.01) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFullscreen = !!document.fullscreenElement;
|
||||
const isKioskMode = settings.kiosk?.value || false;
|
||||
const isMobileSafariKiosk = isIOS() && isKioskMode; // Detect Mobile Safari in kiosk mode (regardless of standalone status)
|
||||
const targetWidth = settings.wide.value ? 640 + 107 + 107 : 640;
|
||||
|
||||
// Use window width instead of bottom container width to avoid zero-dimension issues
|
||||
const widthZoomPercent = window.innerWidth / targetWidth;
|
||||
const heightZoomPercent = window.innerHeight / 480;
|
||||
|
||||
// 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;
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Prevent zero or negative scale values
|
||||
if (scale <= 0) {
|
||||
console.warn('Invalid scale calculated, skipping resize');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip redundant resize operations if scale and mode haven't changed (unless forced)
|
||||
const scaleChanged = Math.abs((lastAppliedScale || 0) - scale) > 0.001;
|
||||
const modeChanged = lastAppliedKioskMode !== isKioskLike;
|
||||
|
||||
if (!force && !scaleChanged && !modeChanged) {
|
||||
return; // No meaningful change, skip resize operation
|
||||
}
|
||||
|
||||
// Update tracking variables
|
||||
lastAppliedScale = scale;
|
||||
lastAppliedKioskMode = isKioskLike;
|
||||
window.currentScale = scale; // Make scale available to settings module
|
||||
|
||||
const wrapper = document.querySelector('#divTwc');
|
||||
const mainContainer = document.querySelector('#divTwcMain');
|
||||
|
||||
// BASELINE: content fits naturally, no scaling needed
|
||||
if (!isKioskLike && scale >= 1.0 && !isKioskMode) {
|
||||
if (debugFlag('fullscreen')) {
|
||||
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');
|
||||
|
||||
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 (Wrapper Scaling)
|
||||
*
|
||||
* 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
|
||||
* - Uses explicit dimensions to prevent layout issues and eliminate gaps after scaling
|
||||
*/
|
||||
wrapper.style.setProperty('transform', `scale(${scale})`);
|
||||
wrapper.style.setProperty('transform-origin', 'top left'); // Scale from top-left corner
|
||||
|
||||
// Set explicit dimensions to prevent layout issues on mobile
|
||||
const wrapperWidth = settings.wide.value ? 854 : 640;
|
||||
// Calculate total height: main content (480px) + bottom navigation bar
|
||||
const bottomBar = document.querySelector('#divTwcBottom');
|
||||
const bottomBarHeight = bottomBar ? bottomBar.offsetHeight : 40; // fallback to ~40px
|
||||
const totalHeight = 480 + bottomBarHeight;
|
||||
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
|
||||
applyScanlineScaling(scale);
|
||||
return;
|
||||
}
|
||||
|
||||
// KIOSK/FULLSCREEN SCALING: Two different positioning approaches for different platforms
|
||||
const wrapperWidth = settings.wide.value ? 854 : 640;
|
||||
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');
|
||||
|
||||
// Platform-specific positioning logic
|
||||
let transformOrigin;
|
||||
let leftPosition;
|
||||
let topPosition;
|
||||
let marginLeft;
|
||||
let marginTop;
|
||||
|
||||
if (isMobileSafariKiosk) {
|
||||
/*
|
||||
* MOBILE SAFARI KIOSK MODE (Manual offset calculation)
|
||||
*
|
||||
* Why this approach?
|
||||
* - Mobile Safari in kiosk mode has unique viewport behaviors that don't work well with standard CSS centering
|
||||
* - We want orientation-specific centering: vertical in portrait, horizontal in landscape
|
||||
* - The standard CSS centering method can cause layout issues in Mobile Safari's constrained environment
|
||||
*/
|
||||
const scaledWidth = wrapperWidth * scale;
|
||||
const scaledHeight = wrapperHeight * scale;
|
||||
|
||||
// Determine if we're in portrait or landscape
|
||||
const isPortrait = window.innerHeight > window.innerWidth;
|
||||
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (isPortrait) {
|
||||
offsetY = (window.innerHeight - scaledHeight) / 2; // center vertically, align to left edge
|
||||
} else {
|
||||
offsetX = (window.innerWidth - scaledWidth) / 2; // center horizontally, align to top edge
|
||||
}
|
||||
|
||||
if (debugFlag('fullscreen')) {
|
||||
console.log(`📱 Mobile Safari kiosk centering: ${isPortrait ? 'portrait' : 'landscape'} wrapper=${wrapperWidth}x${wrapperHeight} scale=${scale.toFixed(3)} offset=${offsetX.toFixed(1)},${offsetY.toFixed(1)}`);
|
||||
}
|
||||
|
||||
// Set positioning values for manual offset calculation
|
||||
transformOrigin = 'top left'; // Scale from top-left corner
|
||||
leftPosition = `${offsetX}px`; // Exact pixel positioning
|
||||
topPosition = `${offsetY}px`; // Exact pixel positioning
|
||||
marginLeft = null; // Clear any previous centering margins
|
||||
marginTop = null; // Clear any previous centering margins
|
||||
} else {
|
||||
/*
|
||||
* STANDARD FULLSCREEN/KIOSK MODE (CSS-based Centering)
|
||||
*
|
||||
* Why this approach?
|
||||
* - Should work reliably across all other browsers and scenarios (desktop, non-Safari mobile, etc.)
|
||||
* - Uses standard CSS centering techniques that browsers handle efficiently
|
||||
* - Always centers both horizontally and vertically
|
||||
*/
|
||||
const scaledWidth = wrapperWidth * scale;
|
||||
const scaledHeight = wrapperHeight * scale;
|
||||
const offsetX = (window.innerWidth - scaledWidth) / 2;
|
||||
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)`);
|
||||
}
|
||||
|
||||
// Set positioning values for CSS-based centering
|
||||
transformOrigin = 'center center'; // Scale from center point
|
||||
leftPosition = '50%'; // Position at 50% from left
|
||||
topPosition = '50%'; // Position at 50% from top
|
||||
marginLeft = `-${wrapperWidth / 2}px`; // Pull back by half width
|
||||
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');
|
||||
|
||||
// Apply or clear margin properties based on positioning method
|
||||
if (marginLeft !== null) {
|
||||
mainContainer.style.setProperty('margin-left', marginLeft, 'important');
|
||||
} else {
|
||||
mainContainer.style.removeProperty('margin-left');
|
||||
}
|
||||
if (marginTop !== null) {
|
||||
mainContainer.style.setProperty('margin-top', marginTop, 'important');
|
||||
} else {
|
||||
mainContainer.style.removeProperty('margin-top');
|
||||
}
|
||||
|
||||
applyScanlineScaling(scale);
|
||||
};
|
||||
|
||||
// reset all statuses to loading on all displays, used to keep the progress bar accurate during refresh
|
||||
@@ -267,6 +569,164 @@ const resetStatuses = () => {
|
||||
displays.forEach((display) => { display.status = STATUS.loading; });
|
||||
};
|
||||
|
||||
// Apply scanline scaling to try and prevent banding by avoiding fractional scaling
|
||||
const applyScanlineScaling = (scale) => {
|
||||
const container = document.querySelector('#container');
|
||||
if (!container || !container.classList.contains('scanlines')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const currentMode = settings?.scanLineMode?.value || 'auto';
|
||||
let cssThickness;
|
||||
let scanlineDebugInfo = null;
|
||||
|
||||
// Helper function to round CSS values intelligently based on scale and DPR
|
||||
// At high scales, precise fractional pixels render fine; at low scales, alignment matters more
|
||||
const roundCSSValue = (value) => {
|
||||
// On 1x DPI displays, use exact calculated values
|
||||
if (devicePixelRatio === 1) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// At high scales (>2x), the browser scaling dominates and fractional pixels render well
|
||||
// Prioritize nice fractions for better visual consistency
|
||||
if (scale > 2.0) {
|
||||
// Try quarter-pixel boundaries first (0.25, 0.5, 0.75, 1.0, etc.)
|
||||
const quarterRounded = Math.round(value * 4) / 4;
|
||||
if (Math.abs(quarterRounded - value) <= 0.125) { // Within 0.125px tolerance
|
||||
return quarterRounded;
|
||||
}
|
||||
// Fall through to half-pixel boundaries for high scale fallback
|
||||
}
|
||||
|
||||
// At lower scales (and high scale fallback), pixel alignment matters more for crisp rendering
|
||||
// Round UP to the next half-pixel to ensure scanlines are never thinner than intended
|
||||
const halfPixelRounded = Math.ceil(value * 2) / 2;
|
||||
return halfPixelRounded;
|
||||
};
|
||||
|
||||
// Manual modes: use smart rounding in scaled scenarios to avoid banding
|
||||
if (currentMode === 'thin') {
|
||||
const rawValue = 1 / scale;
|
||||
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
|
||||
cssThickness = `${cssValue}px`;
|
||||
scanlineDebugInfo = {
|
||||
css: cssValue,
|
||||
visual: 1,
|
||||
target: '1px visual thickness',
|
||||
reason: scale === 1.0 ? 'Thin: 1px visual user override (exact)' : 'Thin: 1px visual user override (rounded)',
|
||||
isManual: true,
|
||||
};
|
||||
} else if (currentMode === 'medium') {
|
||||
const rawValue = 2 / scale;
|
||||
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
|
||||
cssThickness = `${cssValue}px`;
|
||||
scanlineDebugInfo = {
|
||||
css: cssValue,
|
||||
visual: 2,
|
||||
target: '2px visual thickness',
|
||||
reason: scale === 1.0 ? 'Medium: 2px visual user override (exact)' : 'Medium: 2px visual user override (rounded)',
|
||||
isManual: true,
|
||||
};
|
||||
} else if (currentMode === 'thick') {
|
||||
const rawValue = 3 / scale;
|
||||
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
|
||||
cssThickness = `${cssValue}px`;
|
||||
scanlineDebugInfo = {
|
||||
css: cssValue,
|
||||
visual: 3,
|
||||
target: '3px visual thickness',
|
||||
reason: scale === 1.0 ? 'Thick: 3px visual user override (exact)' : 'Thick: 3px visual user override (rounded)',
|
||||
isManual: true,
|
||||
};
|
||||
} else {
|
||||
// Auto mode: choose thickness based on scaling behavior
|
||||
|
||||
let visualThickness;
|
||||
let reason;
|
||||
|
||||
if (scale === 1.0) {
|
||||
// Unscaled mode: use reasonable thickness based on device characteristics
|
||||
const isHighDPIMobile = devicePixelRatio >= 2 && viewportWidth <= 768 && viewportHeight <= 768;
|
||||
const isHighDPITablet = devicePixelRatio >= 2 && viewportWidth <= 1024 && viewportHeight <= 1024;
|
||||
|
||||
if (isHighDPIMobile) {
|
||||
// High-DPI mobile: use thin scanlines but not too thin
|
||||
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px unscaled (high-DPI mobile, DPR=${devicePixelRatio})`;
|
||||
} else if (isHighDPITablet) {
|
||||
// High-DPI tablets: use slightly thicker scanlines for better visibility
|
||||
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px unscaled (high-DPI tablet, DPR=${devicePixelRatio})`;
|
||||
} else if (devicePixelRatio >= 2) {
|
||||
// High-DPI desktop: use scanlines that look similar to scaled mode
|
||||
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px unscaled (high-DPI desktop, DPR=${devicePixelRatio})`;
|
||||
} else {
|
||||
// Standard DPI desktop: use 2px for better visibility
|
||||
cssThickness = '2px';
|
||||
reason = 'Auto: 2px unscaled (standard DPI desktop)';
|
||||
}
|
||||
} else if (scale < 1.0) {
|
||||
// Mobile scaling: use thinner scanlines for small displays
|
||||
visualThickness = 1;
|
||||
const cssValue = roundCSSValue(visualThickness / scale);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px scaled (mobile, scale=${scale})`;
|
||||
} else if (scale >= 3.0) {
|
||||
// Very high scale (large displays/high DPI): use thick scanlines for visibility
|
||||
visualThickness = 3;
|
||||
const cssValue = roundCSSValue(visualThickness / scale);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px scaled (large display/high scale, scale=${scale})`;
|
||||
} else {
|
||||
// Medium scale kiosk/fullscreen: use medium scanlines with smart rounding
|
||||
visualThickness = 2;
|
||||
const rawValue = visualThickness / scale;
|
||||
const cssValue = roundCSSValue(rawValue);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px scaled (kiosk/fullscreen, scale=${scale})`;
|
||||
|
||||
if (debugFlag('scanlines')) {
|
||||
console.log(`↕️ Kiosk/fullscreen rounding: raw=${rawValue}, rounded=${cssValue}, DPR=${devicePixelRatio}, scale=${scale}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract numeric value from cssThickness for debug info
|
||||
const cssNumericValue = parseFloat(cssThickness);
|
||||
|
||||
scanlineDebugInfo = {
|
||||
css: cssNumericValue,
|
||||
visual: scale === 1.0 ? cssNumericValue : visualThickness, // For unscaled mode, visual thickness equals CSS thickness
|
||||
target: scale === 1.0 ? `${cssNumericValue}px CSS (unscaled)` : `${visualThickness}px visual thickness`,
|
||||
reason,
|
||||
isManual: false,
|
||||
};
|
||||
}
|
||||
|
||||
container.style.setProperty('--scanline-thickness', cssThickness);
|
||||
|
||||
// Output debug information if enabled
|
||||
if (debugFlag('scanlines')) {
|
||||
const actualRendered = scanlineDebugInfo.css * scale;
|
||||
const physicalRendered = actualRendered * devicePixelRatio;
|
||||
const visualThickness = scanlineDebugInfo.visual || actualRendered; // Use visual thickness if available
|
||||
|
||||
console.log(`↕️ Scanline optimization: ${cssThickness} CSS × ${scale.toFixed(3)} scale = ${actualRendered.toFixed(3)}px rendered (${visualThickness}px visual target) × ${devicePixelRatio}x DPI = ${physicalRendered.toFixed(3)}px physical - ${scanlineDebugInfo.reason}`);
|
||||
console.log(`↕️ Display: ${viewportWidth}×${viewportHeight}, Scale factors: width=${(window.innerWidth / (settings.wide.value ? 854 : 640)).toFixed(3)}, height=${(window.innerHeight / 480).toFixed(3)}, DPR=${devicePixelRatio}`);
|
||||
console.log(`↕️ Thickness: CSS=${cssThickness}, Visual=${visualThickness.toFixed(1)}px, Rendered=${actualRendered.toFixed(3)}px, Physical=${physicalRendered.toFixed(3)}px`);
|
||||
}
|
||||
};
|
||||
|
||||
// Make applyScanlineScaling available for direct calls from Settings
|
||||
window.applyScanlineScaling = applyScanlineScaling;
|
||||
|
||||
// allow displays to register themselves
|
||||
const registerDisplay = (display) => {
|
||||
if (displays[display.navId]) console.warn(`Display nav ID ${display.navId} already in use`);
|
||||
@@ -321,4 +781,5 @@ export {
|
||||
message,
|
||||
latLonReceived,
|
||||
timeZone,
|
||||
isIOS,
|
||||
};
|
||||
|
||||
@@ -33,91 +33,108 @@ const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => {
|
||||
};
|
||||
|
||||
const removeDopplerRadarImageNoise = (RadarContext) => {
|
||||
const RadarImageData = RadarContext.getImageData(0, 0, RadarContext.canvas.width, RadarContext.canvas.height);
|
||||
|
||||
// examine every pixel,
|
||||
// change any old rgb to the new-rgb
|
||||
for (let i = 0; i < RadarImageData.data.length; i += 4) {
|
||||
// i + 0 = red
|
||||
// i + 1 = green
|
||||
// i + 2 = blue
|
||||
// i + 3 = alpha (0 = transparent, 255 = opaque)
|
||||
let R = RadarImageData.data[i];
|
||||
let G = RadarImageData.data[i + 1];
|
||||
let B = RadarImageData.data[i + 2];
|
||||
let A = RadarImageData.data[i + 3];
|
||||
|
||||
// is this pixel the old rgb?
|
||||
if ((R === 0 && G === 0 && B === 0)
|
||||
|| (R === 0 && G === 236 && B === 236)
|
||||
|| (R === 1 && G === 160 && B === 246)
|
||||
|| (R === 0 && G === 0 && B === 246)) {
|
||||
// change to your new rgb
|
||||
|
||||
// Transparent
|
||||
R = 0;
|
||||
G = 0;
|
||||
B = 0;
|
||||
A = 0;
|
||||
} else if ((R === 0 && G === 255 && B === 0)) {
|
||||
// Light Green 1
|
||||
R = 49;
|
||||
G = 210;
|
||||
B = 22;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 200 && B === 0)) {
|
||||
// Light Green 2
|
||||
R = 0;
|
||||
G = 142;
|
||||
B = 0;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 144 && B === 0)) {
|
||||
// Dark Green 1
|
||||
R = 20;
|
||||
G = 90;
|
||||
B = 15;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 255 && B === 0)) {
|
||||
// Dark Green 2
|
||||
R = 10;
|
||||
G = 40;
|
||||
B = 10;
|
||||
A = 255;
|
||||
} else if ((R === 231 && G === 192 && B === 0)) {
|
||||
// Yellow
|
||||
R = 196;
|
||||
G = 179;
|
||||
B = 70;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 144 && B === 0)) {
|
||||
// Orange
|
||||
R = 190;
|
||||
G = 72;
|
||||
B = 19;
|
||||
A = 255;
|
||||
} else if ((R === 214 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 0)) {
|
||||
// Red
|
||||
R = 171;
|
||||
G = 14;
|
||||
B = 14;
|
||||
A = 255;
|
||||
} else if ((R === 192 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 255)) {
|
||||
// Brown
|
||||
R = 115;
|
||||
G = 31;
|
||||
B = 4;
|
||||
A = 255;
|
||||
}
|
||||
|
||||
RadarImageData.data[i] = R;
|
||||
RadarImageData.data[i + 1] = G;
|
||||
RadarImageData.data[i + 2] = B;
|
||||
RadarImageData.data[i + 3] = A;
|
||||
// Validate canvas context and dimensions before calling getImageData
|
||||
if (!RadarContext || !RadarContext.canvas) {
|
||||
console.error('Invalid radar context provided to removeDopplerRadarImageNoise');
|
||||
return;
|
||||
}
|
||||
|
||||
RadarContext.putImageData(RadarImageData, 0, 0);
|
||||
const { canvas } = RadarContext;
|
||||
if (canvas.width <= 0 || canvas.height <= 0) {
|
||||
console.error(`Invalid canvas dimensions in removeDopplerRadarImageNoise: ${canvas.width}x${canvas.height}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const RadarImageData = RadarContext.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// examine every pixel,
|
||||
// change any old rgb to the new-rgb
|
||||
for (let i = 0; i < RadarImageData.data.length; i += 4) {
|
||||
// i + 0 = red
|
||||
// i + 1 = green
|
||||
// i + 2 = blue
|
||||
// i + 3 = alpha (0 = transparent, 255 = opaque)
|
||||
let R = RadarImageData.data[i];
|
||||
let G = RadarImageData.data[i + 1];
|
||||
let B = RadarImageData.data[i + 2];
|
||||
let A = RadarImageData.data[i + 3];
|
||||
|
||||
// is this pixel the old rgb?
|
||||
if ((R === 0 && G === 0 && B === 0)
|
||||
|| (R === 0 && G === 236 && B === 236)
|
||||
|| (R === 1 && G === 160 && B === 246)
|
||||
|| (R === 0 && G === 0 && B === 246)) {
|
||||
// change to your new rgb
|
||||
|
||||
// Transparent
|
||||
R = 0;
|
||||
G = 0;
|
||||
B = 0;
|
||||
A = 0;
|
||||
} else if ((R === 0 && G === 255 && B === 0)) {
|
||||
// Light Green 1
|
||||
R = 49;
|
||||
G = 210;
|
||||
B = 22;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 200 && B === 0)) {
|
||||
// Light Green 2
|
||||
R = 0;
|
||||
G = 142;
|
||||
B = 0;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 144 && B === 0)) {
|
||||
// Dark Green 1
|
||||
R = 20;
|
||||
G = 90;
|
||||
B = 15;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 255 && B === 0)) {
|
||||
// Dark Green 2
|
||||
R = 10;
|
||||
G = 40;
|
||||
B = 10;
|
||||
A = 255;
|
||||
} else if ((R === 231 && G === 192 && B === 0)) {
|
||||
// Yellow
|
||||
R = 196;
|
||||
G = 179;
|
||||
B = 70;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 144 && B === 0)) {
|
||||
// Orange
|
||||
R = 190;
|
||||
G = 72;
|
||||
B = 19;
|
||||
A = 255;
|
||||
} else if ((R === 214 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 0)) {
|
||||
// Red
|
||||
R = 171;
|
||||
G = 14;
|
||||
B = 14;
|
||||
A = 255;
|
||||
} else if ((R === 192 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 255)) {
|
||||
// Brown
|
||||
R = 115;
|
||||
G = 31;
|
||||
B = 4;
|
||||
A = 255;
|
||||
}
|
||||
|
||||
RadarImageData.data[i] = R;
|
||||
RadarImageData.data[i + 1] = G;
|
||||
RadarImageData.data[i + 2] = B;
|
||||
RadarImageData.data[i + 3] = A;
|
||||
}
|
||||
|
||||
RadarContext.putImageData(RadarImageData, 0, 0);
|
||||
} catch (error) {
|
||||
console.error(`Error in removeDopplerRadarImageNoise: ${error.message}. Canvas size: ${canvas.width}x${canvas.height}`);
|
||||
// Don't re-throw the error, just log it and continue processing
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// current weather conditions display
|
||||
import STATUS from './status.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import { text } from './utils/fetch.mjs';
|
||||
import { safeText } from './utils/fetch.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import * as utils from './radar-utils.mjs';
|
||||
@@ -57,35 +57,60 @@ class Radar extends WeatherDisplay {
|
||||
}
|
||||
|
||||
const baseUrl = `https://${RADAR_HOST}/archive/data/`;
|
||||
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png';
|
||||
const baseUrls = [];
|
||||
let date = DateTime.utc().minus({ days: 1 }).startOf('day');
|
||||
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png'; // This URL returns an index of .png files for the given date
|
||||
|
||||
// make urls for yesterday and today
|
||||
while (date <= DateTime.utc().startOf('day')) {
|
||||
baseUrls.push(`${baseUrl}${date.toFormat('yyyy/LL/dd')}${baseUrlEnd}`);
|
||||
date = date.plus({ days: 1 });
|
||||
// Always get today's data
|
||||
const today = DateTime.utc().startOf('day');
|
||||
const todayStr = today.toFormat('yyyy/LL/dd');
|
||||
const yesterday = today.minus({ days: 1 });
|
||||
const yesterdayStr = yesterday.toFormat('yyyy/LL/dd');
|
||||
const todayUrl = `${baseUrl}${todayStr}${baseUrlEnd}`;
|
||||
|
||||
// Get today's data, then we'll see if we need yesterday's
|
||||
const todayList = await safeText(todayUrl);
|
||||
|
||||
// Count available images from today
|
||||
let todayImageCount = 0;
|
||||
if (todayList) {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(todayList, 'text/html');
|
||||
const anchors = xmlDoc.querySelectorAll('a');
|
||||
todayImageCount = Array.from(anchors).filter((elem) => elem.innerHTML?.match(/n0r_\d{12}\.png/)).length;
|
||||
}
|
||||
|
||||
const lists = (await Promise.all(baseUrls.map(async (url) => {
|
||||
try {
|
||||
// get a list of available radars
|
||||
return text(url);
|
||||
} catch (error) {
|
||||
console.log('Unable to get list of radars');
|
||||
console.error(error);
|
||||
this.setStatus(STATUS.failed);
|
||||
return false;
|
||||
}
|
||||
}))).filter((d) => d);
|
||||
// Only fetch yesterday's data if we don't have enough images from today
|
||||
// or if it's very early in the day when recent images might still be from yesterday
|
||||
const currentTimeUTC = DateTime.utc();
|
||||
const minutesSinceMidnight = currentTimeUTC.hour * 60 + currentTimeUTC.minute;
|
||||
const requiredTimeWindow = this.dopplerRadarImageMax * 5; // 5 minutes per image
|
||||
const needYesterday = todayImageCount < this.dopplerRadarImageMax || minutesSinceMidnight < requiredTimeWindow;
|
||||
|
||||
// convert to an array of gif urls
|
||||
// Build the final lists array
|
||||
const lists = [];
|
||||
if (needYesterday) {
|
||||
const yesterdayUrl = `${baseUrl}${yesterdayStr}${baseUrlEnd}`;
|
||||
const yesterdayList = await safeText(yesterdayUrl);
|
||||
if (yesterdayList) {
|
||||
lists.push(yesterdayList); // Add yesterday's data first
|
||||
}
|
||||
}
|
||||
if (todayList) {
|
||||
lists.push(todayList); // Add today's data
|
||||
}
|
||||
|
||||
// convert to an array of png urls
|
||||
const pngs = lists.flatMap((html, htmlIdx) => {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(html, 'text/html');
|
||||
// add the base url
|
||||
// add the base url - reconstruct the URL for each list
|
||||
const base = xmlDoc.createElement('base');
|
||||
base.href = baseUrls[htmlIdx];
|
||||
if (htmlIdx === 0 && needYesterday) {
|
||||
// First item is yesterday's data when we fetched it
|
||||
base.href = `${baseUrl}${yesterdayStr}${baseUrlEnd}`;
|
||||
} else {
|
||||
// This is today's data (or the only data if yesterday wasn't fetched)
|
||||
base.href = `${baseUrl}${todayStr}${baseUrlEnd}`;
|
||||
}
|
||||
xmlDoc.head.append(base);
|
||||
const anchors = xmlDoc.querySelectorAll('a');
|
||||
const urls = [];
|
||||
@@ -119,69 +144,73 @@ class Radar extends WeatherDisplay {
|
||||
// reset the "used" flag on pre-processed radars
|
||||
// items that were not used during this process are deleted (either expired via time or change of location)
|
||||
processedRadars.forEach((radar) => { radar.used = false; });
|
||||
// remove any radars that aren't
|
||||
|
||||
// Load the most recent doppler radar images.
|
||||
const radarInfo = await Promise.all(urls.map(async (url) => {
|
||||
// store the time
|
||||
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
|
||||
const [, year, month, day, hour, minute] = timeMatch;
|
||||
try {
|
||||
const radarInfo = await Promise.all(urls.map(async (url) => {
|
||||
// store the time
|
||||
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
|
||||
const [, year, month, day, hour, minute] = timeMatch;
|
||||
|
||||
const radarKeyedTimestamp = `${radarKey}:${year}${month}${day}${hour}${minute}`;
|
||||
const radarKeyedTimestamp = `${radarKey}:${year}${month}${day}${hour}${minute}`;
|
||||
|
||||
// check for a pre-processed radar
|
||||
const preProcessed = processedRadars.find((radar) => radar.key === radarKeyedTimestamp);
|
||||
// check for a pre-processed radar
|
||||
const preProcessed = processedRadars.find((radar) => radar.key === radarKeyedTimestamp);
|
||||
|
||||
// use the pre-processed radar, or get a new one
|
||||
const processedRadar = preProcessed?.dataURL ?? await processRadar({
|
||||
url,
|
||||
RADAR_HOST,
|
||||
OVERRIDES,
|
||||
radarSourceXY,
|
||||
});
|
||||
|
||||
// store the radar
|
||||
if (!preProcessed) {
|
||||
processedRadars.push({
|
||||
key: radarKeyedTimestamp,
|
||||
dataURL: processedRadar,
|
||||
used: true,
|
||||
// use the pre-processed radar, or get a new one
|
||||
const processedRadar = preProcessed?.dataURL ?? await processRadar({
|
||||
url,
|
||||
RADAR_HOST,
|
||||
OVERRIDES,
|
||||
radarSourceXY,
|
||||
});
|
||||
} else {
|
||||
// set used flag
|
||||
preProcessed.used = true;
|
||||
}
|
||||
|
||||
const time = DateTime.fromObject({
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
}, {
|
||||
zone: 'UTC',
|
||||
}).setZone(timeZone());
|
||||
// store the radar
|
||||
if (!preProcessed) {
|
||||
processedRadars.push({
|
||||
key: radarKeyedTimestamp,
|
||||
dataURL: processedRadar,
|
||||
used: true,
|
||||
});
|
||||
} else {
|
||||
// set used flag
|
||||
preProcessed.used = true;
|
||||
}
|
||||
|
||||
const elem = this.fillTemplate('frame', { map: { type: 'img', src: processedRadar } });
|
||||
return {
|
||||
time,
|
||||
elem,
|
||||
};
|
||||
}));
|
||||
const time = DateTime.fromObject({
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
}, {
|
||||
zone: 'UTC',
|
||||
}).setZone(timeZone());
|
||||
|
||||
// put the elements in the container
|
||||
const scrollArea = this.elem.querySelector('.scroll-area');
|
||||
scrollArea.innerHTML = '';
|
||||
scrollArea.append(...radarInfo.map((r) => r.elem));
|
||||
const elem = this.fillTemplate('frame', { map: { type: 'img', src: processedRadar } });
|
||||
return {
|
||||
time,
|
||||
elem,
|
||||
};
|
||||
}));
|
||||
|
||||
// set max length
|
||||
this.timing.totalScreens = radarInfo.length;
|
||||
// put the elements in the container
|
||||
const scrollArea = this.elem.querySelector('.scroll-area');
|
||||
scrollArea.innerHTML = '';
|
||||
scrollArea.append(...radarInfo.map((r) => r.elem));
|
||||
|
||||
this.times = radarInfo.map((radar) => radar.time);
|
||||
this.setStatus(STATUS.loaded);
|
||||
// set max length
|
||||
this.timing.totalScreens = radarInfo.length;
|
||||
|
||||
// clean up any unused stored radars
|
||||
processedRadars = processedRadars.filter((radar) => radar.used);
|
||||
this.times = radarInfo.map((radar) => radar.time);
|
||||
this.setStatus(STATUS.loaded);
|
||||
|
||||
// clean up any unused stored radars
|
||||
processedRadars = processedRadars.filter((radar) => radar.used);
|
||||
} catch (_error) {
|
||||
// Radar fetch failed - skip this display in animation by setting totalScreens = 0
|
||||
this.timing.totalScreens = 0;
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
}
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getSmallIcon } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||
import augmentObservationWithMetar from './utils/metar.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import { enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||
|
||||
const buildForecast = (forecast, city, cityXY) => {
|
||||
// get a unit converter
|
||||
@@ -19,23 +22,66 @@ const buildForecast = (forecast, city, cityXY) => {
|
||||
|
||||
const getRegionalObservation = async (point, city) => {
|
||||
try {
|
||||
// get stations
|
||||
const stations = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=1`);
|
||||
// get stations using centralized safe handling
|
||||
const stations = await safeJson(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=1`);
|
||||
|
||||
if (!stations || !stations.features || stations.features.length === 0) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get regional stations for ${city.city}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// get the first station
|
||||
const station = stations.features[0].id;
|
||||
// get the observation data
|
||||
const observation = await json(`${station}/observations/latest`);
|
||||
const stationId = stations.features[0].properties.stationIdentifier;
|
||||
// get the observation data using centralized safe handling
|
||||
const observation = await safeJson(`${station}/observations/latest`);
|
||||
|
||||
if (!observation) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get regional observations for station ${stationId}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enhance observation data with METAR parsing for missing fields
|
||||
let augmentedObservation = augmentObservationWithMetar(observation.properties);
|
||||
|
||||
// Define required fields for regional observations (more lenient than current weather)
|
||||
const requiredFields = [
|
||||
{ name: 'temperature', check: (props) => props.temperature?.value === null },
|
||||
{ name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '' },
|
||||
{ name: 'icon', check: (props) => props.icon === null },
|
||||
];
|
||||
|
||||
// Use enhanced observation with MapClick fallback
|
||||
const enhancedResult = await enhanceObservationWithMapClick(augmentedObservation, {
|
||||
requiredFields,
|
||||
stationId,
|
||||
debugContext: 'regionalforecast',
|
||||
});
|
||||
|
||||
augmentedObservation = enhancedResult.data;
|
||||
const { missingFields } = enhancedResult;
|
||||
|
||||
// Check final data quality
|
||||
if (missingFields.length > 0) {
|
||||
if (debugFlag('regionalforecast')) {
|
||||
console.log(`Regional Observations for station ${stationId} is missing fields: ${missingFields.join(', ')} (skipping)`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// preload the image
|
||||
if (!observation.properties.icon) return false;
|
||||
const icon = getSmallIcon(observation.properties.icon, !observation.properties.daytime);
|
||||
if (!augmentedObservation.icon) return false;
|
||||
const icon = getSmallIcon(augmentedObservation.icon, !augmentedObservation.daytime);
|
||||
if (!icon) return false;
|
||||
preloadImg(icon);
|
||||
// return the observation
|
||||
return observation.properties;
|
||||
return augmentedObservation;
|
||||
} catch (error) {
|
||||
console.log(`Unable to get regional observations for ${city.Name ?? city.city}`);
|
||||
console.error(error.status, error.responseJSON);
|
||||
console.error(`Unexpected error getting Regional Observation for ${city.city}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { distance as calcDistance } from './utils/calc.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||
import { getSmallIcon } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { DateTime, Interval } from '../vendor/auto/luxon.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import * as utils from './regionalforecast-utils.mjs';
|
||||
import { getPoint } from './utils/weather.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import filterExpiredPeriods from './utils/forecast-utils.mjs';
|
||||
|
||||
// map offset
|
||||
const mapOffsetXY = {
|
||||
@@ -77,19 +79,27 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// get a unit converter
|
||||
const temperatureConverter = temperatureUnit();
|
||||
|
||||
// get now as DateTime for calculations below
|
||||
const now = DateTime.now();
|
||||
|
||||
// get regional forecasts and observations (the two are intertwined due to the design of api.weather.gov)
|
||||
const regionalDataAll = await Promise.all(regionalCities.map(async (city) => {
|
||||
// get regional forecasts and observations using centralized safe Promise handling
|
||||
const regionalDataAll = await safePromiseAll(regionalCities.map(async (city) => {
|
||||
try {
|
||||
const point = city?.point ?? (await getAndFormatPoint(city.lat, city.lon));
|
||||
if (!point) throw new Error('No pre-loaded point');
|
||||
if (!point) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get Points for '${city.Name ?? city.city}'`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// start off the observation task
|
||||
const observationPromise = utils.getRegionalObservation(point, city);
|
||||
|
||||
const forecast = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/forecast`);
|
||||
const forecast = await safeJson(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/forecast`);
|
||||
if (!forecast) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Regional Forecast request for ${city.Name ?? city.city} failed`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// get XY on map for city
|
||||
const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, this.weatherParameters.state);
|
||||
@@ -112,29 +122,23 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// preload the icon
|
||||
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
|
||||
|
||||
// return a pared-down forecast
|
||||
// 0th object should contain the current conditions, but when WFOs go offline or otherwise don't post
|
||||
// an updated forecast it's possible that the 0th object is in the past.
|
||||
// so we go on a search for the current time in the start/end times provided in the forecast periods
|
||||
const { periods } = forecast.properties;
|
||||
const currentPeriod = periods.reduce((prev, period, index) => {
|
||||
const start = DateTime.fromISO(period.startTime);
|
||||
const end = DateTime.fromISO(period.endTime);
|
||||
const interval = Interval.fromDateTimes(start, end);
|
||||
if (interval.contains(now)) {
|
||||
return index;
|
||||
}
|
||||
return prev;
|
||||
}, 0);
|
||||
// filter out expired periods first, then use the next two periods for forecast
|
||||
const activePeriods = filterExpiredPeriods(forecast.properties.periods);
|
||||
|
||||
// ensure we have enough periods for forecast
|
||||
if (activePeriods.length < 3) {
|
||||
console.warn(`Insufficient active periods for ${city.Name ?? city.city}: only ${activePeriods.length} periods available`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// group together the current observation and next two periods
|
||||
return [
|
||||
regionalObservation,
|
||||
utils.buildForecast(forecast.properties.periods[currentPeriod + 1], city, cityXY),
|
||||
utils.buildForecast(forecast.properties.periods[currentPeriod + 2], city, cityXY),
|
||||
utils.buildForecast(activePeriods[1], city, cityXY),
|
||||
utils.buildForecast(activePeriods[2], city, cityXY),
|
||||
];
|
||||
} catch (error) {
|
||||
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
|
||||
console.log(error);
|
||||
console.error(`Unexpected error getting Regional Forecast data for '${city.name ?? city.city}': ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
@@ -215,12 +219,19 @@ class RegionalForecast extends WeatherDisplay {
|
||||
}
|
||||
|
||||
const getAndFormatPoint = async (lat, lon) => {
|
||||
const point = await getPoint(lat, lon);
|
||||
return {
|
||||
x: point.properties.gridX,
|
||||
y: point.properties.gridY,
|
||||
wfo: point.properties.gridId,
|
||||
};
|
||||
try {
|
||||
const point = await getPoint(lat, lon);
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: point.properties.gridX,
|
||||
y: point.properties.gridY,
|
||||
wfo: point.properties.gridId,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Unexpected error getting point for ${lat},${lon}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// register display
|
||||
|
||||
@@ -1,12 +1,115 @@
|
||||
import Setting from './utils/setting.mjs';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
});
|
||||
|
||||
// default speed
|
||||
// Initialize settings immediately so other modules can access them
|
||||
const settings = { speed: { value: 1.0 } };
|
||||
|
||||
// Track settings that need DOM changes after early initialization
|
||||
const deferredDomSettings = new Set();
|
||||
|
||||
// Declare change functions first, before they're referenced in init() to avoid the Temporal Dead Zone (TDZ)
|
||||
const wideScreenChange = (value) => {
|
||||
const container = document.querySelector('#divTwc');
|
||||
if (!container) {
|
||||
// DOM not ready; defer enabling if set
|
||||
if (value) {
|
||||
deferredDomSettings.add('wide');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
container.classList.add('wide');
|
||||
} else {
|
||||
container.classList.remove('wide');
|
||||
}
|
||||
// Trigger resize to recalculate scaling for new width
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
};
|
||||
|
||||
const kioskChange = (value) => {
|
||||
const body = document.querySelector('body');
|
||||
if (!body) {
|
||||
// DOM not ready; defer enabling if set
|
||||
if (value) {
|
||||
deferredDomSettings.add('kiosk');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
body.classList.add('kiosk');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} else {
|
||||
body.classList.remove('kiosk');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
|
||||
// Conditionally store the kiosk setting based on the "Sticky Kiosk" setting
|
||||
// (Need to check if the method exists to handle initialization race condition)
|
||||
if (settings.kiosk?.conditionalStoreToLocalStorage) {
|
||||
settings.kiosk.conditionalStoreToLocalStorage(value, settings.stickyKiosk?.value);
|
||||
}
|
||||
};
|
||||
|
||||
const scanLineChange = (value) => {
|
||||
const container = document.getElementById('container');
|
||||
const navIcons = document.getElementById('ToggleScanlines');
|
||||
|
||||
if (!container || !navIcons) {
|
||||
// DOM not ready; defer enabling if set
|
||||
if (value) {
|
||||
deferredDomSettings.add('scanLines');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
container.classList.add('scanlines');
|
||||
navIcons.classList.add('on');
|
||||
} else {
|
||||
// Remove all scanline classes
|
||||
container.classList.remove('scanlines', 'scanlines-auto', 'scanlines-fine', 'scanlines-normal', 'scanlines-thick', 'scanlines-classic', 'scanlines-retro');
|
||||
navIcons.classList.remove('on');
|
||||
}
|
||||
};
|
||||
|
||||
const scanLineModeChange = (_value) => {
|
||||
// Only apply if scanlines are currently enabled
|
||||
if (settings.scanLines?.value) {
|
||||
// Call the scanline update function directly with current scale
|
||||
if (typeof window.applyScanlineScaling === 'function') {
|
||||
// Get current scale from navigation module or use 1.0 as fallback
|
||||
const scale = window.currentScale || 1.0;
|
||||
window.applyScanlineScaling(scale);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Simple global helper to change scanline mode when remote debugging or in kiosk mode
|
||||
window.changeScanlineMode = (mode) => {
|
||||
if (typeof settings === 'undefined' || !settings.scanLineMode) {
|
||||
console.error('Settings system not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
const validModes = ['auto', 'thin', 'medium', 'thick'];
|
||||
if (!validModes.includes(mode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.scanLineMode.value = mode;
|
||||
return true;
|
||||
};
|
||||
|
||||
const unitChange = () => {
|
||||
// reload the data at the top level to refresh units
|
||||
// after the initial load
|
||||
if (unitChange.firstRunDone) {
|
||||
window.location.reload();
|
||||
}
|
||||
unitChange.firstRunDone = true;
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
// create settings see setting.mjs for defaults
|
||||
settings.wide = new Setting('wide', {
|
||||
@@ -20,6 +123,12 @@ const init = () => {
|
||||
defaultValue: false,
|
||||
changeAction: kioskChange,
|
||||
sticky: false,
|
||||
stickyRead: true,
|
||||
});
|
||||
settings.stickyKiosk = new Setting('stickyKiosk', {
|
||||
name: 'Sticky Kiosk',
|
||||
defaultValue: false,
|
||||
sticky: true,
|
||||
});
|
||||
settings.speed = new Setting('speed', {
|
||||
name: 'Speed',
|
||||
@@ -39,6 +148,19 @@ const init = () => {
|
||||
changeAction: scanLineChange,
|
||||
sticky: true,
|
||||
});
|
||||
settings.scanLineMode = new Setting('scanLineMode', {
|
||||
name: 'Scan Line Style',
|
||||
type: 'select',
|
||||
defaultValue: 'auto',
|
||||
changeAction: scanLineModeChange,
|
||||
sticky: true,
|
||||
values: [
|
||||
['auto', 'Auto (Adaptive)'],
|
||||
['thin', 'Thin (1x)'],
|
||||
['medium', 'Medium (2x)'],
|
||||
['thick', 'Thick (3x)'],
|
||||
],
|
||||
});
|
||||
settings.units = new Setting('units', {
|
||||
name: 'Units',
|
||||
type: 'select',
|
||||
@@ -62,54 +184,32 @@ const init = () => {
|
||||
],
|
||||
visible: false,
|
||||
});
|
||||
};
|
||||
|
||||
// generate html objects
|
||||
init();
|
||||
|
||||
// generate html objects
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Apply any settings that were deferred due to the DOM not being ready when setting were read
|
||||
if (deferredDomSettings.size > 0) {
|
||||
console.log('Applying deferred DOM settings:', Array.from(deferredDomSettings));
|
||||
|
||||
// Re-apply each pending setting by calling its changeAction with current value
|
||||
deferredDomSettings.forEach((settingName) => {
|
||||
const setting = settings[settingName];
|
||||
if (setting && setting.changeAction && typeof setting.changeAction === 'function') {
|
||||
setting.changeAction(setting.value);
|
||||
}
|
||||
});
|
||||
|
||||
deferredDomSettings.clear();
|
||||
}
|
||||
|
||||
// Then generate the settings UI
|
||||
const settingHtml = Object.values(settings).map((d) => d.generate());
|
||||
|
||||
// write to page
|
||||
const settingsSection = document.querySelector('#settings');
|
||||
settingsSection.innerHTML = '';
|
||||
settingsSection.append(...settingHtml);
|
||||
};
|
||||
|
||||
const wideScreenChange = (value) => {
|
||||
const container = document.querySelector('#divTwc');
|
||||
if (value) {
|
||||
container.classList.add('wide');
|
||||
} else {
|
||||
container.classList.remove('wide');
|
||||
}
|
||||
};
|
||||
|
||||
const kioskChange = (value) => {
|
||||
const body = document.querySelector('body');
|
||||
if (value) {
|
||||
body.classList.add('kiosk');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} else {
|
||||
body.classList.remove('kiosk');
|
||||
}
|
||||
};
|
||||
|
||||
const scanLineChange = (value) => {
|
||||
const container = document.getElementById('container');
|
||||
const navIcons = document.getElementById('ToggleScanlines');
|
||||
if (value) {
|
||||
container.classList.add('scanlines');
|
||||
navIcons.classList.add('on');
|
||||
} else {
|
||||
container.classList.remove('scanlines');
|
||||
navIcons.classList.remove('on');
|
||||
}
|
||||
};
|
||||
|
||||
const unitChange = () => {
|
||||
// reload the data at the top level to refresh units
|
||||
// after the initial load
|
||||
if (unitChange.firstRunDone) {
|
||||
window.location.reload();
|
||||
}
|
||||
unitChange.firstRunDone = true;
|
||||
};
|
||||
});
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -34,10 +34,17 @@ const createLink = async (e) => {
|
||||
// get all select boxes
|
||||
elemForEach('select', (elem) => {
|
||||
if (elem?.id) {
|
||||
queryStringElements[elem.id] = elem?.value ?? 0;
|
||||
queryStringElements[elem.id] = encodeURIComponent(elem?.value ?? '');
|
||||
}
|
||||
});
|
||||
|
||||
// get all text boxes
|
||||
elemForEach('input[type=text]', ((elem) => {
|
||||
if (elem?.id) {
|
||||
queryStringElements[elem.id] = elem?.value ?? 0;
|
||||
}
|
||||
}));
|
||||
|
||||
// add the location string
|
||||
queryStringElements.latLonQuery = localStorage.getItem('latLonQuery');
|
||||
queryStringElements.latLon = localStorage.getItem('latLon');
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// display spc outlook in a bar graph
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import testPolygon from './utils/polygon.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
// list of interesting files ordered [0] = today, [1] = tomorrow...
|
||||
const urlPattern = (day) => `https://www.spc.noaa.gov/products/outlook/day${day}otlk_cat.nolyr.geojson`;
|
||||
@@ -18,8 +19,10 @@ const testAllPoints = (point, data) => {
|
||||
data.forEach((day, index) => {
|
||||
// initialize the result
|
||||
result[index] = false;
|
||||
// if there's no data (file didn't load), exit early
|
||||
if (day === undefined) return;
|
||||
// ensure day exists and has features array
|
||||
if (!day || !day.features || !Array.isArray(day.features)) {
|
||||
return;
|
||||
}
|
||||
// loop through each category
|
||||
day.features.forEach((feature) => {
|
||||
if (!feature.geometry.coordinates) return;
|
||||
@@ -46,7 +49,7 @@ class SpcOutlook extends WeatherDisplay {
|
||||
// don't display on progress/navigation screen
|
||||
this.showOnProgress = false;
|
||||
|
||||
// calculate file names
|
||||
// calculate file names, one for each day
|
||||
this.files = [null, null, null].map((v, i) => urlPattern(i + 1));
|
||||
|
||||
// set timings
|
||||
@@ -56,27 +59,43 @@ class SpcOutlook extends WeatherDisplay {
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// initial data does not need to be reloaded on a location change, only during silent refresh
|
||||
if (!this.initialData || refresh) {
|
||||
// SPC outlook data does not need to be reloaded on a location change, only during silent refresh
|
||||
if (!this.rawOutlookData || refresh) {
|
||||
try {
|
||||
// get the three categorical files to get started
|
||||
const filePromises = await Promise.allSettled(this.files.map((file) => json(file)));
|
||||
// store the data, promise will always be fulfilled
|
||||
this.initialData = filePromises.map((outlookDay) => outlookDay.value);
|
||||
} catch (error) {
|
||||
console.error('Unable to get spc outlook');
|
||||
console.error(error.status, error.responseJSON);
|
||||
// if there's no previous data, fail
|
||||
if (!this.initialData) {
|
||||
this.setStatus(STATUS.failed);
|
||||
// get the data for today, tomorrow, and the day after
|
||||
const filePromises = this.files.map((file) => safeJson(file, {
|
||||
retryCount: 1, // Retry one time
|
||||
timeout: 10000, // 10 second timeout for SPC outlook data
|
||||
}));
|
||||
// wait for all the data to be fetched; always returns an array of (potentially null) results
|
||||
this.rawOutlookData = await safePromiseAll(filePromises);
|
||||
|
||||
// Filter out null results (like failed requests) and ensure the response has GeoJSON-looking data
|
||||
this.rawOutlookData = this.rawOutlookData.filter((value) => value && value.features);
|
||||
|
||||
if (this.rawOutlookData.length === 0) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn('SPC Outlook has zero days of data');
|
||||
}
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rawOutlookData.length < this.files.length) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`SPC Outlook only loaded ${this.rawOutlookData.length} of ${this.files.length} days successfully`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting SPC Outlook data: ${error.message}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// do the initial parsing of the data
|
||||
this.data = testAllPoints([weatherParameters.longitude, weatherParameters.latitude], this.initialData);
|
||||
// parse the data
|
||||
this.data = testAllPoints([weatherParameters.longitude, weatherParameters.latitude], this.rawOutlookData);
|
||||
|
||||
// if all the data returns false the there's nothing to do, skip this screen
|
||||
// check if there's a "risk" for any of the three days, otherwise skip the SPC Outlook screen
|
||||
if (this.data.reduce((prev, cur) => prev || !!cur, false)) {
|
||||
this.timing.totalScreens = 1;
|
||||
} else {
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
// travel forecast display
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import { getSmallIcon } from './icons.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class TravelForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId, defaultActive) {
|
||||
// special height and width for scrolling
|
||||
super(navId, elemId, 'Travel Forecast', defaultActive);
|
||||
|
||||
// set up the timing
|
||||
this.timing.baseDelay = 20;
|
||||
// page sizes are 4 cities, calculate the number of pages necessary plus overflow
|
||||
const pagesFloat = TravelCities.length / 4;
|
||||
const pages = Math.floor(pagesFloat) - 2; // first page is already displayed, last page doesn't happen
|
||||
const extra = pages % 1;
|
||||
const timingStep = 75 * 4;
|
||||
this.timing.delay = [150 + timingStep];
|
||||
// add additional pages
|
||||
for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep);
|
||||
// add the extra (not exactly 4 pages portion)
|
||||
if (extra !== 0) this.timing.delay.push(Math.round(this.extra * this.cityHeight));
|
||||
// add the final 3 second delay
|
||||
this.timing.delay.push(150);
|
||||
|
||||
// add previous data cache
|
||||
this.previousData = [];
|
||||
|
||||
// cache for scroll calculations
|
||||
// This cache is essential because baseCountChange() is called 25 times per second (every 40ms)
|
||||
// during scrolling. Travel forecast scroll duration varies based on the number of cities configured.
|
||||
// Without caching, we'd perform hundreds of expensive DOM layout queries during each scroll cycle.
|
||||
// The cache reduces this to one calculation when content changes, then reuses cached values to try
|
||||
// and get smoother scrolling.
|
||||
this.scrollCache = {
|
||||
displayHeight: 0,
|
||||
contentHeight: 0,
|
||||
maxOffset: 0,
|
||||
travelLines: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
@@ -45,22 +45,27 @@ class TravelForecast extends WeatherDisplay {
|
||||
// get point then forecast
|
||||
if (!city.point) throw new Error('No pre-loaded point');
|
||||
let forecast;
|
||||
try {
|
||||
forecast = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
});
|
||||
forecast = await safeJson(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
});
|
||||
|
||||
if (forecast) {
|
||||
// store for the next run
|
||||
this.previousData[index] = forecast;
|
||||
} catch (e) {
|
||||
} else if (this.previousData?.[index]) {
|
||||
// if there's previous data use it
|
||||
if (this.previousData?.[index]) {
|
||||
forecast = this.previousData?.[index];
|
||||
} else {
|
||||
// otherwise re-throw for the standard error handling
|
||||
throw (e);
|
||||
if (debugFlag('travelforecast')) {
|
||||
console.warn(`Using previous forecast data for ${city.Name} travel forecast`);
|
||||
}
|
||||
forecast = this.previousData?.[index];
|
||||
} else {
|
||||
// no current data and no previous data available
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`No travel forecast for ${city.Name} available`);
|
||||
}
|
||||
return { name: city.Name, error: true };
|
||||
}
|
||||
// determine today or tomorrow (shift periods by 1 if tomorrow)
|
||||
const todayShift = forecast.properties.periods[0].isDaytime ? 0 : 1;
|
||||
@@ -73,14 +78,13 @@ class TravelForecast extends WeatherDisplay {
|
||||
icon: getSmallIcon(forecast.properties.periods[todayShift].icon),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`GetTravelWeather for ${city.Name} failed`);
|
||||
console.error(error.status, error.responseJSON);
|
||||
console.error(`Unexpected error getting Travel Forecast for ${city.Name}: ${error.message}`);
|
||||
return { name: city.Name, error: true };
|
||||
}
|
||||
});
|
||||
|
||||
// wait for all forecasts
|
||||
const forecasts = await Promise.all(forecastPromises);
|
||||
// wait for all forecasts using centralized safe Promise handling
|
||||
const forecasts = await safePromiseAll(forecastPromises);
|
||||
this.data = forecasts;
|
||||
|
||||
// test for some data available in at least one forecast
|
||||
@@ -129,6 +133,9 @@ class TravelForecast extends WeatherDisplay {
|
||||
return this.fillTemplate('travel-row', fillValues);
|
||||
}).filter((d) => d);
|
||||
list.append(...lines);
|
||||
|
||||
// update timing based on actual content
|
||||
this.setTiming(list);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
@@ -157,20 +164,50 @@ class TravelForecast extends WeatherDisplay {
|
||||
|
||||
// base count change callback
|
||||
baseCountChange(count) {
|
||||
// get the travel lines element and cache measurements if needed
|
||||
const travelLines = this.elem.querySelector('.travel-lines');
|
||||
if (!travelLines) return;
|
||||
|
||||
// update cache if needed (when content changes or first run)
|
||||
if (this.scrollCache.travelLines !== travelLines || this.scrollCache.displayHeight === 0) {
|
||||
this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight;
|
||||
this.scrollCache.contentHeight = travelLines.offsetHeight;
|
||||
this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight);
|
||||
this.scrollCache.travelLines = travelLines;
|
||||
|
||||
// Set up hardware acceleration on the travel lines element
|
||||
travelLines.style.willChange = 'transform';
|
||||
travelLines.style.backfaceVisibility = 'hidden';
|
||||
}
|
||||
|
||||
// calculate scroll offset and don't go past end
|
||||
let offsetY = Math.min(this.elem.querySelector('.travel-lines').offsetHeight - 289, (count - 150));
|
||||
let offsetY = Math.min(this.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
|
||||
|
||||
// don't let offset go negative
|
||||
if (offsetY < 0) offsetY = 0;
|
||||
|
||||
// copy the scrolled portion of the canvas
|
||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||
// use transform instead of scrollTo for hardware acceleration
|
||||
travelLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
|
||||
}
|
||||
|
||||
// necessary to get the lastest long canvas when scrolling
|
||||
getLongCanvas() {
|
||||
return this.longCanvas;
|
||||
}
|
||||
|
||||
setTiming(list) {
|
||||
const container = this.elem.querySelector('.main');
|
||||
const timingConfig = calculateScrollTiming(list, container, {
|
||||
staticDisplay: 5.0, // special static display time for travel forecast
|
||||
});
|
||||
|
||||
// Apply the calculated timing
|
||||
this.timing.baseDelay = timingConfig.baseDelay;
|
||||
this.timing.delay = timingConfig.delay;
|
||||
this.scrollTiming = timingConfig.scrollTiming;
|
||||
|
||||
this.calcNavTiming();
|
||||
}
|
||||
}
|
||||
|
||||
// effectively returns early on the first found date
|
||||
|
||||
40
server/scripts/modules/utils/cache.mjs
Normal file
40
server/scripts/modules/utils/cache.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import { rewriteUrl } from './url-rewrite.mjs';
|
||||
|
||||
// Clear cache utility for client-side use
|
||||
const clearCacheEntry = async (url, baseUrl = '') => {
|
||||
try {
|
||||
// Rewrite the URL to get the local proxy path
|
||||
const rewrittenUrl = rewriteUrl(url);
|
||||
const urlObj = typeof rewrittenUrl === 'string' ? new URL(rewrittenUrl, baseUrl || window.location.origin) : rewrittenUrl;
|
||||
let cachePath = urlObj.pathname + urlObj.search;
|
||||
|
||||
// Strip the route designator (first path segment) to match actual cache keys
|
||||
const firstSlashIndex = cachePath.indexOf('/', 1); // Find second slash
|
||||
if (firstSlashIndex > 0) {
|
||||
cachePath = cachePath.substring(firstSlashIndex);
|
||||
}
|
||||
|
||||
// Call the cache clear endpoint
|
||||
const fetchUrl = baseUrl ? `${baseUrl}/cache${cachePath}` : `/cache${cachePath}`;
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.cleared) {
|
||||
console.log(`🗑️ Cleared cache entry: ${cachePath}`);
|
||||
return true;
|
||||
}
|
||||
console.log(`🔍 Cache entry not found: ${cachePath}`);
|
||||
return false;
|
||||
}
|
||||
console.warn(`⚠️ Failed to clear cache entry: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`❌ Error clearing cache entry for ${url}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default clearCacheEntry;
|
||||
@@ -1,5 +1,9 @@
|
||||
// wind direction
|
||||
const directionToNSEW = (Direction) => {
|
||||
// Handle null, undefined, or invalid direction values
|
||||
if (Direction === null || Direction === undefined || typeof Direction !== 'number' || Number.isNaN(Direction)) {
|
||||
return 'VAR'; // Variable (or unknown) direction
|
||||
}
|
||||
const val = Math.floor((Direction / 22.5) + 0.5);
|
||||
const arr = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||||
return arr[(val % 16)];
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// rewrite some urls for local server
|
||||
const rewriteUrl = (_url) => {
|
||||
let url = _url;
|
||||
url = url.replace('https://api.weather.gov/', `${window.location.protocol}//${window.location.host}/`);
|
||||
url = url.replace('https://www.cpc.ncep.noaa.gov/', `${window.location.protocol}//${window.location.host}/`);
|
||||
return url;
|
||||
};
|
||||
|
||||
export {
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
rewriteUrl,
|
||||
};
|
||||
53
server/scripts/modules/utils/data-loader.mjs
Normal file
53
server/scripts/modules/utils/data-loader.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
// Data loader utility for fetching JSON data with cache-busting
|
||||
|
||||
let dataCache = {};
|
||||
|
||||
// Load data with version-based cache busting
|
||||
const loadData = async (dataType, version = '') => {
|
||||
if (dataCache[dataType]) {
|
||||
return dataCache[dataType];
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/data/${dataType}.json${version ? `?_=${version}` : ''}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${dataType}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
dataCache[dataType] = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${dataType}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Load all data types
|
||||
const loadAllData = async (version = '') => {
|
||||
const [travelCities, regionalCities, stationInfo] = await Promise.all([
|
||||
loadData('travelcities', version),
|
||||
loadData('regionalcities', version),
|
||||
loadData('stations', version),
|
||||
]);
|
||||
|
||||
// Set global variables for backward compatibility
|
||||
window.TravelCities = travelCities;
|
||||
window.RegionalCities = regionalCities;
|
||||
window.StationInfo = stationInfo;
|
||||
|
||||
return { travelCities, regionalCities, stationInfo };
|
||||
};
|
||||
|
||||
// Clear cache (useful for development)
|
||||
const clearDataCache = () => {
|
||||
dataCache = {};
|
||||
};
|
||||
|
||||
export {
|
||||
loadData,
|
||||
loadAllData,
|
||||
clearDataCache,
|
||||
};
|
||||
148
server/scripts/modules/utils/debug.mjs
Normal file
148
server/scripts/modules/utils/debug.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
// Debug flag management system
|
||||
// Supports comma-separated debug flags or "all" for everything
|
||||
// URL parameter takes priority over OVERRIDES.DEBUG
|
||||
|
||||
let debugFlags = null; // memoized parsed flags
|
||||
let runtimeFlags = null; // runtime modifications via debugEnable/debugDisable/debugSet
|
||||
|
||||
/**
|
||||
* Parse debug flags from URL parameter or environment variable
|
||||
* @returns {Set<string>} Set of enabled debug flags
|
||||
*/
|
||||
const parseDebugFlags = () => {
|
||||
if (debugFlags !== null) return debugFlags;
|
||||
|
||||
let debugString = '';
|
||||
|
||||
// Check URL parameter first
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlDebug = urlParams.get('debug');
|
||||
|
||||
if (urlDebug) {
|
||||
debugString = urlDebug;
|
||||
} else {
|
||||
// Fall back to OVERRIDES.DEBUG
|
||||
debugString = (typeof OVERRIDES !== 'undefined' ? OVERRIDES?.DEBUG : '') || '';
|
||||
}
|
||||
|
||||
// Parse comma-separated values into a Set
|
||||
if (debugString.trim()) {
|
||||
debugFlags = new Set(
|
||||
debugString
|
||||
.split(',')
|
||||
.map((flag) => flag.trim().toLowerCase())
|
||||
.filter((flag) => flag.length > 0),
|
||||
);
|
||||
} else {
|
||||
debugFlags = new Set();
|
||||
}
|
||||
|
||||
return debugFlags;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current active debug flags (including runtime modifications)
|
||||
* @returns {Set<string>} Set of currently active debug flags
|
||||
*/
|
||||
const getActiveFlags = () => {
|
||||
if (runtimeFlags !== null) {
|
||||
return runtimeFlags;
|
||||
}
|
||||
return parseDebugFlags();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a debug flag is enabled
|
||||
* @param {string} flag - The debug flag to check
|
||||
* @returns {boolean} True if the flag is enabled
|
||||
*/
|
||||
const debugFlag = (flag) => {
|
||||
const activeFlags = getActiveFlags();
|
||||
|
||||
// "all" enables everything
|
||||
if (activeFlags.has('all')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific flag
|
||||
return activeFlags.has(flag.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable one or more debug flags at runtime
|
||||
* @param {...string} flags - Debug flags to enable
|
||||
* @returns {string[]} Array of currently active debug flags after enabling
|
||||
*/
|
||||
const debugEnable = (...flags) => {
|
||||
// Initialize runtime flags from current state if not already done
|
||||
if (runtimeFlags === null) {
|
||||
runtimeFlags = new Set(getActiveFlags());
|
||||
}
|
||||
|
||||
// Add new flags
|
||||
flags.forEach((flag) => {
|
||||
runtimeFlags.add(flag.toLowerCase());
|
||||
});
|
||||
|
||||
return debugList();
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable one or more debug flags at runtime
|
||||
* @param {...string} flags - Debug flags to disable
|
||||
* @returns {string[]} Array of currently active debug flags after disabling
|
||||
*/
|
||||
const debugDisable = (...flags) => {
|
||||
// Initialize runtime flags from current state if not already done
|
||||
if (runtimeFlags === null) {
|
||||
runtimeFlags = new Set(getActiveFlags());
|
||||
}
|
||||
|
||||
flags.forEach((flag) => {
|
||||
const lowerFlag = flag.toLowerCase();
|
||||
if (lowerFlag === 'all') {
|
||||
// Special case: disable all flags
|
||||
runtimeFlags.clear();
|
||||
} else {
|
||||
runtimeFlags.delete(lowerFlag);
|
||||
}
|
||||
});
|
||||
|
||||
return debugList();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set debug flags at runtime (overwrites existing flags)
|
||||
* @param {...string} flags - Debug flags to set (replaces all current flags)
|
||||
* @returns {string[]} Array of currently active debug flags after setting
|
||||
*/
|
||||
const debugSet = (...flags) => {
|
||||
runtimeFlags = new Set(
|
||||
flags.map((flag) => flag.toLowerCase()),
|
||||
);
|
||||
|
||||
return debugList();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current debug flags for inspection
|
||||
* @returns {string[]} Array of currently active debug flags
|
||||
*/
|
||||
const debugList = () => Array.from(getActiveFlags()).sort();
|
||||
|
||||
// Make debug functions globally accessible in development for console use
|
||||
if (typeof window !== 'undefined') {
|
||||
window.debugFlag = debugFlag;
|
||||
window.debugEnable = debugEnable;
|
||||
window.debugDisable = debugDisable;
|
||||
window.debugSet = debugSet;
|
||||
window.debugList = debugList;
|
||||
}
|
||||
|
||||
export {
|
||||
debugFlag,
|
||||
debugEnable,
|
||||
debugDisable,
|
||||
debugSet,
|
||||
debugList,
|
||||
};
|
||||
@@ -1,31 +1,111 @@
|
||||
import { rewriteUrl } from './cors.mjs';
|
||||
import { rewriteUrl } from './url-rewrite.mjs';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT = 15000; // For example, with 3 retries: 15s+1s+15s+2s+15s+5s+15s = 68s
|
||||
|
||||
// Centralized utilities for handling errors in Promise contexts
|
||||
const safeJson = async (url, params) => {
|
||||
try {
|
||||
const result = await json(url, params);
|
||||
// Return an object with both data and url if params.returnUrl is true
|
||||
if (params?.returnUrl) {
|
||||
return result;
|
||||
}
|
||||
// If caller didn't specify returnUrl, result is the raw API response
|
||||
return result;
|
||||
} catch (_error) {
|
||||
// Error already logged in fetchAsync; return null to be "safe"
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeText = async (url, params) => {
|
||||
try {
|
||||
const result = await text(url, params);
|
||||
// Return an object with both data and url if params.returnUrl is true
|
||||
if (params?.returnUrl) {
|
||||
return result;
|
||||
}
|
||||
// If caller didn't specify returnUrl, result is the raw API response
|
||||
return result;
|
||||
} catch (_error) {
|
||||
// Error already logged in fetchAsync; return null to be "safe"
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeBlob = async (url, params) => {
|
||||
try {
|
||||
const result = await blob(url, params);
|
||||
// Return an object with both data and url if params.returnUrl is true
|
||||
if (params?.returnUrl) {
|
||||
return result;
|
||||
}
|
||||
// If caller didn't specify returnUrl, result is the raw API response
|
||||
return result;
|
||||
} catch (_error) {
|
||||
// Error already logged in fetchAsync; return null to be "safe"
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safePromiseAll = async (promises) => {
|
||||
try {
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
}
|
||||
// Log rejected promises for debugging (except AbortErrors which are expected)
|
||||
if (result.reason?.name !== 'AbortError') {
|
||||
console.warn(`Promise ${index} rejected:`, result.reason?.message || result.reason);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('safePromiseAll encountered an unexpected error:', error);
|
||||
// Return array of nulls matching the input length
|
||||
return new Array(promises.length).fill(null);
|
||||
}
|
||||
};
|
||||
|
||||
const json = (url, params) => fetchAsync(url, 'json', params);
|
||||
const text = (url, params) => fetchAsync(url, 'text', params);
|
||||
const blob = (url, params) => fetchAsync(url, 'blob', params);
|
||||
|
||||
// Hosts that don't allow custom User-Agent headers due to CORS restrictions
|
||||
const USER_AGENT_EXCLUDED_HOSTS = [
|
||||
'geocode.arcgis.com',
|
||||
'services.arcgis.com',
|
||||
];
|
||||
|
||||
const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
// add user agent header to json request at api.weather.gov
|
||||
const headers = {};
|
||||
if (_url.toString().match(/api\.weather\.gov/)) {
|
||||
|
||||
const checkUrl = new URL(_url, window.location.origin);
|
||||
const shouldExcludeUserAgent = USER_AGENT_EXCLUDED_HOSTS.some((host) => checkUrl.hostname.includes(host));
|
||||
|
||||
// User-Agent handling:
|
||||
// - Server mode (with caching proxy): Add User-Agent for all requests except excluded hosts
|
||||
// - Static mode (direct requests): Only add User-Agent for api.weather.gov, avoiding CORS preflight issues with other services
|
||||
const shouldAddUserAgent = !shouldExcludeUserAgent && (window.WS4KP_SERVER_AVAILABLE || _url.toString().match(/api\.weather\.gov/));
|
||||
if (shouldAddUserAgent) {
|
||||
headers['user-agent'] = 'Weatherstar 4000+; weatherstar@netbymatt.com';
|
||||
}
|
||||
|
||||
// combine default and provided parameters
|
||||
const params = {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
type: 'GET',
|
||||
retryCount: 0,
|
||||
retryCount: 3, // Default to 3 retries for any failed requests (timeout or 5xx server errors)
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT,
|
||||
..._params,
|
||||
headers,
|
||||
};
|
||||
// store original number of retries
|
||||
params.originalRetries = params.retryCount;
|
||||
|
||||
// build a url, including the rewrite for cors if necessary
|
||||
let corsUrl = _url;
|
||||
if (params.cors === true) corsUrl = rewriteUrl(_url);
|
||||
const url = new URL(corsUrl, `${window.location.origin}/`);
|
||||
// rewrite URLs for various services to use the backend proxy server for proper caching (and request logging)
|
||||
const url = rewriteUrl(_url);
|
||||
// match the security protocol when not on localhost
|
||||
// url.protocol = window.location.hostname === 'localhost' ? url.protocol : window.location.protocol;
|
||||
// add parameters if necessary
|
||||
@@ -39,53 +119,174 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
}
|
||||
|
||||
// make the request
|
||||
const response = await doFetch(url, params);
|
||||
try {
|
||||
const response = await doFetch(url, params);
|
||||
|
||||
// check for ok response
|
||||
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
||||
// return the requested response
|
||||
switch (responseType) {
|
||||
case 'json':
|
||||
return response.json();
|
||||
case 'text':
|
||||
return response.text();
|
||||
case 'blob':
|
||||
return response.blob();
|
||||
default:
|
||||
return response;
|
||||
// check for ok response
|
||||
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
||||
// process the response based on type
|
||||
let result;
|
||||
switch (responseType) {
|
||||
case 'json':
|
||||
result = await response.json();
|
||||
break;
|
||||
case 'text':
|
||||
result = await response.text();
|
||||
break;
|
||||
case 'blob':
|
||||
result = await response.blob();
|
||||
break;
|
||||
default:
|
||||
result = response;
|
||||
}
|
||||
|
||||
// Return both data and URL if requested
|
||||
if (params.returnUrl) {
|
||||
return {
|
||||
data: result,
|
||||
url: response.url,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Enhanced error handling for different error types
|
||||
if (error.name === 'AbortError') {
|
||||
// AbortError always happens in the browser, regardless of server vs static mode
|
||||
// Most likely causes include background tab throttling, user navigation, or client timeout
|
||||
console.log(`🛑 Fetch aborted for ${_url} (background tab throttling?)`);
|
||||
return null; // Always return null for AbortError instead of throwing
|
||||
} if (error.name === 'TimeoutError') {
|
||||
console.warn(`⏱️ Request timeout for ${_url} (${error.message})`);
|
||||
} else if (error.message.includes('502')) {
|
||||
console.warn(`🚪 Bad Gateway error for ${_url}`);
|
||||
} else if (error.message.includes('503')) {
|
||||
console.warn(`⌛ Temporarily unavailable for ${_url}`);
|
||||
} else if (error.message.includes('504')) {
|
||||
console.warn(`⏱️ Gateway Timeout for ${_url}`);
|
||||
} else if (error.message.includes('500')) {
|
||||
console.warn(`💥 Internal Server Error for ${_url}`);
|
||||
} else if (error.message.includes('CORS') || error.message.includes('Access-Control')) {
|
||||
console.warn(`🔒 CORS or Access Control error for ${_url}`);
|
||||
} else {
|
||||
console.warn(`❌ Fetch failed for ${_url} (${error.message})`);
|
||||
}
|
||||
|
||||
// Add standard error properties that calling code expects
|
||||
if (!error.status) error.status = 0;
|
||||
if (!error.responseJSON) error.responseJSON = null;
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// fetch with retry and back-off
|
||||
const doFetch = (url, params) => new Promise((resolve, reject) => {
|
||||
fetch(url, params).then((response) => {
|
||||
if (params.retryCount > 0) {
|
||||
// 500 status codes should be retried after a short backoff
|
||||
if (response.status >= 500 && response.status <= 599 && params.retryCount > 0) {
|
||||
// call the "still waiting" function
|
||||
if (typeof params.stillWaiting === 'function' && params.retryCount === params.originalRetries) {
|
||||
params.stillWaiting();
|
||||
}
|
||||
// decrement and retry
|
||||
const newParams = {
|
||||
...params,
|
||||
retryCount: params.retryCount - 1,
|
||||
};
|
||||
return resolve(delay(retryDelay(params.originalRetries - newParams.retryCount), doFetch, url, newParams));
|
||||
}
|
||||
// not 500 status
|
||||
return resolve(response);
|
||||
}
|
||||
// out of retries
|
||||
return resolve(response);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
const doFetch = (url, params, originalRetryCount = null) => new Promise((resolve, reject) => {
|
||||
// On the first call, store the retry count for later logging
|
||||
const initialRetryCount = originalRetryCount ?? params.retryCount;
|
||||
|
||||
const delay = (time, func, ...args) => new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(func(...args));
|
||||
}, time);
|
||||
// Create AbortController for timeout
|
||||
const controller = new AbortController();
|
||||
const startTime = Date.now();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, params.timeout);
|
||||
|
||||
// Add signal to fetch params
|
||||
const fetchParams = {
|
||||
...params,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
// Shared retry logic to avoid duplication
|
||||
const attemptRetry = (reason) => {
|
||||
// Safety check for params
|
||||
if (!params || typeof params.retryCount !== 'number') {
|
||||
console.error(`❌ Invalid params for retry: ${url}`);
|
||||
return reject(new Error('Invalid retry parameters'));
|
||||
}
|
||||
|
||||
const retryAttempt = initialRetryCount - params.retryCount + 1;
|
||||
const remainingRetries = params.retryCount - 1;
|
||||
const delayMs = retryDelay(retryAttempt);
|
||||
|
||||
console.warn(`🔄 Retry ${retryAttempt}/${initialRetryCount} for ${url} - ${reason} (retrying in ${delayMs}ms, ${remainingRetries} retr${remainingRetries === 1 ? 'y' : 'ies'} left)`);
|
||||
|
||||
// call the "still waiting" function on first retry
|
||||
if (params && params.stillWaiting && typeof params.stillWaiting === 'function' && retryAttempt === 1) {
|
||||
try {
|
||||
params.stillWaiting();
|
||||
} catch (callbackError) {
|
||||
console.warn(`⚠️ stillWaiting callback error for ${url}:`, callbackError.message);
|
||||
}
|
||||
}
|
||||
// decrement and retry with safe parameter copying
|
||||
const newParams = {
|
||||
...params,
|
||||
retryCount: Math.max(0, params.retryCount - 1), // Ensure retryCount doesn't go negative
|
||||
};
|
||||
// Use setTimeout directly instead of the delay wrapper to avoid Promise resolution issues
|
||||
setTimeout(() => {
|
||||
doFetch(url, newParams, initialRetryCount).then(resolve).catch(reject);
|
||||
}, delayMs);
|
||||
return undefined; // Explicit return for linter
|
||||
};
|
||||
|
||||
fetch(url, fetchParams).then((response) => {
|
||||
clearTimeout(timeoutId); // Clear timeout on successful response
|
||||
|
||||
// Retry 500 status codes if we have retries left
|
||||
if (params && params.retryCount > 0 && response.status >= 500 && response.status <= 599) {
|
||||
let errorType = 'Server error';
|
||||
if (response.status === 502) {
|
||||
errorType = 'Bad Gateway';
|
||||
} else if (response.status === 503) {
|
||||
errorType = 'Service Unavailable';
|
||||
} else if (response.status === 504) {
|
||||
errorType = 'Gateway Timeout';
|
||||
}
|
||||
return attemptRetry(`${errorType} ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Log when we're out of retries for server errors
|
||||
// if (response.status >= 500 && response.status <= 599) {
|
||||
// console.warn(`⚠️ Server error ${response.status} ${response.statusText} for ${url} - no retries remaining`);
|
||||
// }
|
||||
|
||||
// successful response or out of retries
|
||||
return resolve(response);
|
||||
}).catch((error) => {
|
||||
clearTimeout(timeoutId); // Clear timeout on error
|
||||
|
||||
// Enhance AbortError detection by checking if we're near the timeout duration
|
||||
if (error.name === 'AbortError') {
|
||||
const duration = Date.now() - startTime;
|
||||
const isLikelyTimeout = duration >= (params.timeout - 1000); // Within 1 second of timeout
|
||||
|
||||
// Convert likely timeouts to TimeoutError for better error reporting
|
||||
if (isLikelyTimeout) {
|
||||
const reason = `Request timeout after ${Math.round(duration / 1000)}s`;
|
||||
if (params && params.retryCount > 0) {
|
||||
return attemptRetry(reason);
|
||||
}
|
||||
// Convert to a timeout error for better error reporting
|
||||
const timeoutError = new Error(`Request timeout after ${Math.round(duration / 1000)}s`);
|
||||
timeoutError.name = 'TimeoutError';
|
||||
reject(timeoutError);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry network errors if we have retries left
|
||||
if (params && params.retryCount > 0 && error.name !== 'AbortError') {
|
||||
const reason = error.name === 'TimeoutError' ? 'Request timeout' : `Network error: ${error.message}`;
|
||||
return attemptRetry(reason);
|
||||
}
|
||||
|
||||
// out of retries or AbortError - reject
|
||||
reject(error);
|
||||
return undefined; // Explicit return for linter
|
||||
});
|
||||
});
|
||||
|
||||
const retryDelay = (retryNumber) => {
|
||||
@@ -102,4 +303,8 @@ export {
|
||||
json,
|
||||
text,
|
||||
blob,
|
||||
safeJson,
|
||||
safeText,
|
||||
safeBlob,
|
||||
safePromiseAll,
|
||||
};
|
||||
|
||||
30
server/scripts/modules/utils/forecast-utils.mjs
Normal file
30
server/scripts/modules/utils/forecast-utils.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
// shared utility functions for forecast processing
|
||||
|
||||
/**
|
||||
* Filter out expired periods from forecast data
|
||||
* @param {Array} periods - Array of forecast periods
|
||||
* @param {string} forecastUrl - URL used for logging (optional)
|
||||
* @returns {Array} - Array of active (non-expired) periods
|
||||
*/
|
||||
const filterExpiredPeriods = (periods, forecastUrl = '') => {
|
||||
const now = new Date();
|
||||
|
||||
const { activePeriods, removedPeriods } = periods.reduce((acc, period) => {
|
||||
const endTime = new Date(period.endTime);
|
||||
if (endTime > now) {
|
||||
acc.activePeriods.push(period);
|
||||
} else {
|
||||
acc.removedPeriods.push(period);
|
||||
}
|
||||
return acc;
|
||||
}, { activePeriods: [], removedPeriods: [] });
|
||||
|
||||
if (removedPeriods.length > 0) {
|
||||
const source = forecastUrl ? ` from ${forecastUrl}` : '';
|
||||
console.log(`🚮 Forecast: Removed expired periods${source}: ${removedPeriods.map((p) => `${p.name} (ended ${p.endTime})`).join(', ')}`);
|
||||
}
|
||||
|
||||
return activePeriods;
|
||||
};
|
||||
|
||||
export default filterExpiredPeriods;
|
||||
@@ -5,6 +5,11 @@ import { blob } from './fetch.mjs';
|
||||
// a list of cached icons is used to avoid hitting the cache multiple times
|
||||
const cachedImages = [];
|
||||
const preloadImg = (src) => {
|
||||
if (!src || typeof src !== 'string') {
|
||||
console.warn(`preloadImg expects a URL string, received: '${src}' (${typeof src})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cachedImages.includes(src)) return false;
|
||||
blob(src);
|
||||
cachedImages.push(src);
|
||||
|
||||
669
server/scripts/modules/utils/mapclick.mjs
Normal file
669
server/scripts/modules/utils/mapclick.mjs
Normal file
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* MapClick API Fallback Utility
|
||||
*
|
||||
* Provides fallback functionality to fetch weather data from forecast.weather.gov's MapClick API
|
||||
* when the primary api.weather.gov data is stale or incomplete.
|
||||
*
|
||||
* MapClick uses the SBN feed which typically has faster METAR (airport) station updates
|
||||
* but is limited to airport stations only. The primary API uses MADIS which is more
|
||||
* comprehensive but can have delayed ingestion.
|
||||
*/
|
||||
|
||||
import { safeJson } from './fetch.mjs';
|
||||
import { debugFlag } from './debug.mjs';
|
||||
|
||||
/**
|
||||
* Parse MapClick date format to JavaScript Date
|
||||
* @param {string} dateString - Format: "18 Jun 23:53 pm EDT"
|
||||
* @returns {Date|null} - Parsed date or null if invalid
|
||||
*/
|
||||
export const parseMapClickDate = (dateString) => {
|
||||
try {
|
||||
// Extract components using regex
|
||||
const match = dateString.match(/(\d{1,2})\s+(\w{3})\s+(\d{1,2}):(\d{2})\s+(am|pm)\s+(\w{3})/i);
|
||||
if (!match) return null;
|
||||
|
||||
const [, day, month, hour, minute, ampm, timezone] = match;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Convert to 12-hour format since we have AM/PM
|
||||
let hour12 = parseInt(hour, 10);
|
||||
// If it's in 24-hour format but we have AM/PM, convert it
|
||||
if (hour12 > 12) {
|
||||
hour12 -= 12;
|
||||
}
|
||||
|
||||
// Reconstruct in a format that Date.parse understands (12-hour format with AM/PM)
|
||||
const standardFormat = `${month} ${day}, ${currentYear} ${hour12}:${minute}:00 ${ampm.toUpperCase()} ${timezone}`;
|
||||
|
||||
const parsedDate = new Date(standardFormat);
|
||||
|
||||
// Check if the date is valid
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
console.warn(`MapClick: Invalid date parsed from: ${dateString} -> ${standardFormat}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedDate;
|
||||
} catch (error) {
|
||||
console.warn(`MapClick: Failed to parse date: ${dateString}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize icon name to determine if it's night and get base name for mapping
|
||||
* @param {string} iconName - Icon name without extension
|
||||
* @returns {Object} - { isNightTime: boolean, baseIconName: string }
|
||||
*/
|
||||
const normalizeIconName = (iconName) => {
|
||||
// Handle special cases where 'n' is not a prefix (hi_nshwrs, hi_ntsra)
|
||||
const hiNightMatch = iconName.match(/^hi_n(.+)/);
|
||||
if (hiNightMatch) {
|
||||
return {
|
||||
isNightTime: true,
|
||||
baseIconName: `hi_${hiNightMatch[1]}`, // Reconstruct as hi_[condition]
|
||||
};
|
||||
}
|
||||
|
||||
// Handle the general 'n' prefix rule (including nra, nwind_skc, etc.)
|
||||
if (iconName.startsWith('n')) {
|
||||
return {
|
||||
isNightTime: true,
|
||||
baseIconName: iconName.substring(1), // Strip the 'n' prefix
|
||||
};
|
||||
}
|
||||
|
||||
// Not a night icon
|
||||
return {
|
||||
isNightTime: false,
|
||||
baseIconName: iconName,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert MapClick weather image filename to weather.gov API icon format
|
||||
* @param {string} weatherImage - MapClick weather image filename (e.g., 'bkn.png')
|
||||
* @returns {string|null} - Weather.gov API icon URL or null if invalid/missing
|
||||
*/
|
||||
const convertMapClickIcon = (weatherImage) => {
|
||||
// Return null for missing, invalid, or NULL values - let caller handle defaults
|
||||
if (!weatherImage || weatherImage === 'NULL' || weatherImage === 'NA') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove .png extension if present
|
||||
const iconName = weatherImage.replace('.png', '');
|
||||
|
||||
// Determine if this is a night icon and get the base name for mapping
|
||||
const { isNightTime, baseIconName } = normalizeIconName(iconName);
|
||||
const timeOfDay = isNightTime ? 'night' : 'day';
|
||||
|
||||
// MapClick icon filename to weather.gov API condition mapping
|
||||
// This maps MapClick specific icon names to standard API icon names
|
||||
// Night variants are handled by stripping 'n' prefix before lookup
|
||||
// based on https://www.weather.gov/forecast-icons/
|
||||
const iconMapping = {
|
||||
// Clear/Fair conditions
|
||||
skc: 'skc', // Clear sky condition
|
||||
|
||||
// Cloud coverage
|
||||
few: 'few', // A few clouds
|
||||
sct: 'sct', // Scattered clouds / Partly cloudy
|
||||
bkn: 'bkn', // Broken clouds / Mostly cloudy
|
||||
ovc: 'ovc', // Overcast
|
||||
|
||||
// Light Rain + Drizzle
|
||||
minus_ra: 'rain', // Light rain -> rain
|
||||
ra: 'rain', // Rain
|
||||
// Note: nra.png is used for both light rain and rain at night
|
||||
// but normalizeIconName strips the 'n' to get 'ra' which maps to 'rain'
|
||||
|
||||
// Snow variants
|
||||
sn: 'snow', // Snow
|
||||
|
||||
// Rain + Snow combinations
|
||||
ra_sn: 'rain_snow', // Rain snow
|
||||
rasn: 'rain_snow', // Standard rain snow
|
||||
|
||||
// Ice Pellets/Sleet
|
||||
raip: 'rain_sleet', // Rain ice pellets -> rain_sleet
|
||||
ip: 'sleet', // Ice pellets
|
||||
|
||||
// Freezing Rain
|
||||
ra_fzra: 'rain_fzra', // Rain freezing rain -> rain_fzra
|
||||
fzra: 'fzra', // Freezing rain
|
||||
|
||||
// Freezing Rain + Snow
|
||||
fzra_sn: 'snow_fzra', // Freezing rain snow -> snow_fzra
|
||||
|
||||
// Snow + Ice Pellets
|
||||
snip: 'snow_sleet', // Snow ice pellets -> snow_sleet
|
||||
|
||||
// Showers
|
||||
hi_shwrs: 'rain_showers_hi', // Isolated showers -> rain_showers_hi
|
||||
shra: 'rain_showers', // Showers -> rain_showers
|
||||
|
||||
// Thunderstorms
|
||||
tsra: 'tsra', // Thunderstorm
|
||||
scttsra: 'tsra_sct', // Scattered thunderstorm -> tsra_sct
|
||||
hi_tsra: 'tsra_hi', // Isolated thunderstorm -> tsra_hi
|
||||
|
||||
// Fog
|
||||
fg: 'fog', // Fog
|
||||
|
||||
// Wind conditions
|
||||
wind_skc: 'wind_skc', // Clear and windy
|
||||
wind_few: 'wind_few', // Few clouds and windy
|
||||
wind_sct: 'wind_sct', // Scattered clouds and windy
|
||||
wind_bkn: 'wind_bkn', // Broken clouds and windy
|
||||
wind_ovc: 'wind_ovc', // Overcast and windy
|
||||
|
||||
// Extreme weather
|
||||
blizzard: 'blizzard', // Blizzard
|
||||
cold: 'cold', // Cold
|
||||
hot: 'hot', // Hot
|
||||
du: 'dust', // Dust
|
||||
fu: 'smoke', // Smoke
|
||||
hz: 'haze', // Haze
|
||||
|
||||
// Tornadoes
|
||||
fc: 'tornado', // Funnel cloud
|
||||
tor: 'tornado', // Tornado
|
||||
};
|
||||
|
||||
// Get the mapped condition, return null if not found in the mapping
|
||||
const condition = iconMapping[baseIconName];
|
||||
if (!condition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/icons/land/${timeOfDay}/${condition}?size=medium`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert MapClick observation data to match the standard API format
|
||||
*
|
||||
* This is NOT intended to be a full replacment process, but rather a minimal
|
||||
* fallback for the data used in WS4KP.
|
||||
*
|
||||
* @param {Object} mapClickObs - MapClick observation data
|
||||
* @returns {Object} - Data formatted to match api.weather.gov structure
|
||||
*/
|
||||
export const convertMapClickObservationsToApiFormat = (mapClickObs) => {
|
||||
// Convert temperature from Fahrenheit to Celsius (only if valid)
|
||||
const tempF = parseFloat(mapClickObs.Temp);
|
||||
const tempC = !Number.isNaN(tempF) ? (tempF - 32) * 5 / 9 : null;
|
||||
|
||||
const dewpF = parseFloat(mapClickObs.Dewp);
|
||||
const dewpC = !Number.isNaN(dewpF) ? (dewpF - 32) * 5 / 9 : null;
|
||||
|
||||
// Convert wind speed from mph to km/h (only if valid)
|
||||
const windMph = parseFloat(mapClickObs.Winds);
|
||||
const windKmh = !Number.isNaN(windMph) ? windMph * 1.60934 : null;
|
||||
|
||||
// Convert wind gust from mph to km/h (only if valid and not "NA")
|
||||
const gustMph = mapClickObs.Gust !== 'NA' ? parseFloat(mapClickObs.Gust) : NaN;
|
||||
const windGust = !Number.isNaN(gustMph) ? gustMph * 1.60934 : null;
|
||||
|
||||
// Convert wind direction (only if valid)
|
||||
const windDir = parseFloat(mapClickObs.Windd);
|
||||
const windDirection = !Number.isNaN(windDir) ? windDir : null;
|
||||
|
||||
// Convert pressure from inHg to Pa (only if valid)
|
||||
const pressureInHg = parseFloat(mapClickObs.SLP);
|
||||
const pressurePa = !Number.isNaN(pressureInHg) ? pressureInHg * 3386.39 : null;
|
||||
|
||||
// Convert visibility from miles to meters (only if valid)
|
||||
const visibilityMiles = parseFloat(mapClickObs.Visibility);
|
||||
const visibilityMeters = !Number.isNaN(visibilityMiles) ? visibilityMiles * 1609.34 : null;
|
||||
|
||||
// Convert relative humidity (only if valid)
|
||||
const relh = parseFloat(mapClickObs.Relh);
|
||||
const relativeHumidity = !Number.isNaN(relh) ? relh : null;
|
||||
|
||||
// Convert wind chill from Fahrenheit to Celsius (only if valid and not "NA")
|
||||
const windChillF = mapClickObs.WindChill !== 'NA' ? parseFloat(mapClickObs.WindChill) : NaN;
|
||||
const windChill = !Number.isNaN(windChillF) ? (windChillF - 32) * 5 / 9 : null;
|
||||
|
||||
// Convert MapClick weather image to weather.gov API icon format
|
||||
const iconUrl = convertMapClickIcon(mapClickObs.Weatherimage);
|
||||
|
||||
return {
|
||||
features: [
|
||||
{
|
||||
properties: {
|
||||
timestamp: parseMapClickDate(mapClickObs.Date)?.toISOString() || new Date().toISOString(),
|
||||
temperature: { value: tempC, unitCode: 'wmoUnit:degC' },
|
||||
dewpoint: { value: dewpC, unitCode: 'wmoUnit:degC' },
|
||||
windDirection: { value: windDirection, unitCode: 'wmoUnit:degree_(angle)' },
|
||||
windSpeed: { value: windKmh, unitCode: 'wmoUnit:km_h-1' },
|
||||
windGust: { value: windGust, unitCode: 'wmoUnit:km_h-1' },
|
||||
barometricPressure: { value: pressurePa, unitCode: 'wmoUnit:Pa' },
|
||||
visibility: { value: visibilityMeters, unitCode: 'wmoUnit:m' },
|
||||
relativeHumidity: { value: relativeHumidity, unitCode: 'wmoUnit:percent' },
|
||||
textDescription: mapClickObs.Weather || null,
|
||||
icon: iconUrl, // Can be null if no valid icon available
|
||||
heatIndex: { value: null },
|
||||
windChill: { value: windChill },
|
||||
cloudLayers: [], // no cloud layer data available from MapClick
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert MapClick forecast data to weather.gov API forecast format
|
||||
* @param {Object} mapClickData - Raw MapClick response data
|
||||
* @returns {Object|null} - Forecast data in API format or null if invalid
|
||||
*/
|
||||
export const convertMapClickForecastToApiFormat = (mapClickData) => {
|
||||
if (!mapClickData?.data || !mapClickData?.time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, time } = mapClickData;
|
||||
const {
|
||||
temperature, weather, iconLink, text, pop,
|
||||
} = data;
|
||||
|
||||
if (!temperature || !weather || !iconLink || !text || !time.startValidTime || !time.startPeriodName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert each forecast period
|
||||
const periods = temperature.map((temp, index) => {
|
||||
if (index >= weather.length || index >= iconLink.length || index >= text.length || index >= time.startValidTime.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if this is a daytime period based on the period name
|
||||
const periodName = time.startPeriodName[index] || '';
|
||||
const isDaytime = !periodName.toLowerCase().includes('night');
|
||||
|
||||
// Convert icon from MapClick format to API format
|
||||
let icon = iconLink[index];
|
||||
if (icon) {
|
||||
let filename = null;
|
||||
|
||||
// Handle DualImage.php URLs: extract from 'i' parameter
|
||||
if (icon.includes('DualImage.php')) {
|
||||
const iMatch = icon.match(/[?&]i=([^&]+)/);
|
||||
if (iMatch) {
|
||||
[, filename] = iMatch;
|
||||
}
|
||||
} else {
|
||||
// Handle regular image URLs: extract filename from path, removing percentage numbers
|
||||
const pathMatch = icon.match(/\/([^/]+?)(?:\d+)?(?:\.png)?$/);
|
||||
if (pathMatch) {
|
||||
[, filename] = pathMatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (filename) {
|
||||
icon = convertMapClickIcon(filename);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
number: index + 1,
|
||||
name: periodName,
|
||||
startTime: time.startValidTime[index],
|
||||
endTime: index + 1 < time.startValidTime.length ? time.startValidTime[index + 1] : null,
|
||||
isDaytime,
|
||||
temperature: parseInt(temp, 10),
|
||||
temperatureUnit: 'F',
|
||||
temperatureTrend: null,
|
||||
probabilityOfPrecipitation: {
|
||||
unitCode: 'wmoUnit:percent',
|
||||
value: pop[index] ? parseInt(pop[index], 10) : null,
|
||||
},
|
||||
dewpoint: {
|
||||
unitCode: 'wmoUnit:degC',
|
||||
value: null, // MapClick doesn't provide dewpoint in forecast
|
||||
},
|
||||
relativeHumidity: {
|
||||
unitCode: 'wmoUnit:percent',
|
||||
value: null, // MapClick doesn't provide humidity in forecast
|
||||
},
|
||||
windSpeed: null, // MapClick doesn't provide wind speed in forecast
|
||||
windDirection: null, // MapClick doesn't provide wind direction in forecast
|
||||
icon,
|
||||
shortForecast: weather[index],
|
||||
detailedForecast: text[index],
|
||||
};
|
||||
}).filter((period) => period !== null);
|
||||
|
||||
// Return in API forecast format
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [mapClickData.location?.longitude, mapClickData.location?.latitude],
|
||||
},
|
||||
properties: {
|
||||
units: 'us',
|
||||
forecastGenerator: 'MapClick',
|
||||
generatedAt: new Date().toISOString(),
|
||||
updateTime: parseMapClickDate(mapClickData.creationDateLocal)?.toISOString() || new Date().toISOString(),
|
||||
validTimes: `${time.startValidTime[0]}/${time.startValidTime[time.startValidTime.length - 1]}`,
|
||||
elevation: {
|
||||
unitCode: 'wmoUnit:m',
|
||||
value: mapClickData.location?.elevation ? parseFloat(mapClickData.location.elevation) : null,
|
||||
},
|
||||
periods,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if API data is stale and should trigger a MapClick fallback
|
||||
* @param {string|Date} timestamp - ISO timestamp string or Date object from API data
|
||||
* @param {number} maxAgeMinutes - Maximum age in minutes before considering stale (default: 60)
|
||||
* @returns {Object} - { isStale: boolean, ageInMinutes: number }
|
||||
*/
|
||||
export const isDataStale = (timestamp, maxAgeMinutes = 60) => {
|
||||
// Handle both Date objects and timestamp strings
|
||||
const observationTime = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||
const now = new Date();
|
||||
const ageInMinutes = (now - observationTime) / (1000 * 60);
|
||||
|
||||
return {
|
||||
isStale: ageInMinutes > maxAgeMinutes,
|
||||
ageInMinutes,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch MapClick data from the MapClick API
|
||||
* @param {number} latitude - Latitude coordinate
|
||||
* @param {number} longitude - Longitude coordinate
|
||||
* @param {Object} options - Optional parameters
|
||||
* @param {string} stationId - Station identifier (used for URL logging)
|
||||
* @param {Function} options.stillWaiting - Callback for loading status
|
||||
* @param {number} options.retryCount - Number of retries (default: 3)
|
||||
* @returns {Object|null} - MapClick data or null if failed
|
||||
*/
|
||||
export const getMapClickData = async (latitude, longitude, stationId, options = {}) => {
|
||||
const { stillWaiting, retryCount = 3 } = options;
|
||||
|
||||
// Round coordinates to 4 decimal places to match weather.gov API precision
|
||||
const lat = latitude.toFixed(4);
|
||||
const lon = longitude.toFixed(4);
|
||||
|
||||
// &unit=0&lg=english are default parameters for MapClick API
|
||||
const mapClickUrl = `https://forecast.weather.gov/MapClick.php?FcstType=json&lat=${lat}&lon=${lon}&station=${stationId}`;
|
||||
|
||||
try {
|
||||
const mapClickData = await safeJson(mapClickUrl, {
|
||||
retryCount,
|
||||
stillWaiting,
|
||||
});
|
||||
|
||||
if (mapClickData) {
|
||||
return mapClickData;
|
||||
}
|
||||
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.log(`MapClick: No data available for ${lat},${lon}`);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error fetching MapClick data for ${lat},${lon}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current observation from MapClick API in weather.gov API format
|
||||
* @param {number} latitude - Latitude coordinate
|
||||
* @param {number} longitude - Longitude coordinate
|
||||
* @param {string} stationId - Station identifier (used for URL logging)
|
||||
* @param {Object} options - Optional parameters
|
||||
* @param {Function} options.stillWaiting - Callback for loading status
|
||||
* @param {number} options.retryCount - Number of retries (default: 3)
|
||||
* @returns {Object|null} - Current observation in API format or null if failed
|
||||
*/
|
||||
export const getMapClickCurrentObservation = async (latitude, longitude, stationId, options = {}) => {
|
||||
const { stillWaiting, retryCount = 3 } = options;
|
||||
|
||||
const mapClickData = await getMapClickData(latitude, longitude, stationId, { stillWaiting, retryCount });
|
||||
|
||||
if (!mapClickData?.currentobservation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to API format
|
||||
return convertMapClickObservationsToApiFormat(mapClickData.currentobservation);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get forecast data from MapClick API in weather.gov API format
|
||||
* @param {number} latitude - Latitude coordinate
|
||||
* @param {number} longitude - Longitude coordinate
|
||||
* @param {string} stationId - Station identifier (used for URL logging)
|
||||
* @param {Object} options - Optional parameters
|
||||
* @param {Function} options.stillWaiting - Callback for loading status
|
||||
* @param {number} options.retryCount - Number of retries (default: 3)
|
||||
* @returns {Object|null} - Forecast data in API format or null if failed
|
||||
*/
|
||||
export const getMapClickForecast = async (latitude, longitude, stationId, options = {}) => {
|
||||
const { stillWaiting, retryCount = 3 } = options;
|
||||
|
||||
const mapClickData = await getMapClickData(latitude, longitude, stationId, { stillWaiting, retryCount });
|
||||
|
||||
if (!mapClickData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to API format
|
||||
return convertMapClickForecastToApiFormat(mapClickData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced observation fetcher with MapClick fallback
|
||||
* Centralized logic for checking data quality and falling back to MapClick when needed
|
||||
* @param {Object} observationData - Original API observation data
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {Array} options.requiredFields - Array of field definitions with { name, check, required? }
|
||||
* @param {number} options.maxOptionalMissing - Max missing optional fields allowed (default: 0)
|
||||
* @param {string} options.stationId - Station identifier for looking up coordinates (e.g., 'KORD')
|
||||
* @param {Function} options.stillWaiting - Loading callback
|
||||
* @param {string} options.debugContext - Debug logging context name
|
||||
* @param {number} options.maxAgeMinutes - Max age before considering stale (default: 60)
|
||||
* @returns {Object} - { data, wasImproved, improvements, missingFields }
|
||||
*/
|
||||
export const enhanceObservationWithMapClick = async (observationData, options = {}) => {
|
||||
const {
|
||||
requiredFields = [],
|
||||
maxOptionalMissing = 0,
|
||||
stationId,
|
||||
stillWaiting,
|
||||
debugContext = 'mapclick',
|
||||
maxAgeMinutes = 80, // hourly observation plus 20 minute ingestion delay
|
||||
} = options;
|
||||
|
||||
// Helper function to return original data with consistent logging
|
||||
const returnOriginalData = (reason, missingRequired = [], missingOptional = [], isStale = false, ageInMinutes = 0) => {
|
||||
if (debugFlag(debugContext)) {
|
||||
const issues = [];
|
||||
if (isStale) issues.push(`API data is stale: ${ageInMinutes.toFixed(0)} minutes old`);
|
||||
if (missingRequired.length > 0) issues.push(`API data missing required: ${missingRequired.join(', ')}`);
|
||||
if (missingOptional.length > maxOptionalMissing) issues.push(`API data missing optional: ${missingOptional.join(', ')}`);
|
||||
|
||||
if (reason) {
|
||||
if (issues.length > 0) {
|
||||
console.log(`🚫 ${debugContext}: Station ${stationId} ${reason} (${issues.join(', ')})`);
|
||||
} else {
|
||||
console.log(`🚫 ${debugContext}: Station ${stationId} ${reason}`);
|
||||
}
|
||||
} else if (issues.length > 0) {
|
||||
console.log(`🚫 ${debugContext}: Station ${stationId} ${issues.join('; ')}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: observationData,
|
||||
wasImproved: false,
|
||||
improvements: [],
|
||||
missingFields: [...missingRequired, ...missingOptional],
|
||||
};
|
||||
};
|
||||
|
||||
if (!observationData) {
|
||||
return returnOriginalData('no original observation data');
|
||||
}
|
||||
|
||||
// Look up station coordinates from global StationInfo
|
||||
if (!stationId || typeof window === 'undefined' || !window.StationInfo) {
|
||||
return returnOriginalData('no station ID');
|
||||
}
|
||||
|
||||
const stationLookup = Object.values(window.StationInfo).find((s) => s.id === stationId);
|
||||
if (!stationLookup) {
|
||||
let reason = null;
|
||||
if (stationId.length === 4) { // MapClick only supports 4-letter station IDs, so other failures are "expected"
|
||||
reason = `station ${stationId} not found in StationInfo`;
|
||||
}
|
||||
return returnOriginalData(reason);
|
||||
}
|
||||
|
||||
// Check data staleness
|
||||
const observationTime = new Date(observationData.timestamp);
|
||||
const { isStale, ageInMinutes } = isDataStale(observationTime, maxAgeMinutes);
|
||||
|
||||
// Categorize fields by required/optional
|
||||
const requiredFieldDefs = requiredFields.filter((field) => field.required !== false);
|
||||
const optionalFieldDefs = requiredFields.filter((field) => field.required === false);
|
||||
|
||||
// Check current data quality
|
||||
const missingRequired = requiredFieldDefs.filter((field) => field.check(observationData)).map((field) => field.name);
|
||||
const missingOptional = optionalFieldDefs.filter((field) => field.check(observationData)).map((field) => field.name);
|
||||
const missingOptionalCount = missingOptional.length;
|
||||
|
||||
// Determine if we should try MapClick
|
||||
const shouldTryMapClick = isStale || missingRequired.length > 0 || missingOptionalCount > maxOptionalMissing;
|
||||
|
||||
if (!shouldTryMapClick) {
|
||||
return returnOriginalData(null, missingRequired, missingOptional, isStale, ageInMinutes);
|
||||
}
|
||||
|
||||
// Try MapClick API
|
||||
const mapClickData = await getMapClickCurrentObservation(stationLookup.lat, stationLookup.lon, stationId, {
|
||||
stillWaiting,
|
||||
retryCount: 1,
|
||||
});
|
||||
|
||||
if (!mapClickData) {
|
||||
return returnOriginalData('MapClick fetch failed', missingRequired, missingOptional, isStale, ageInMinutes);
|
||||
}
|
||||
|
||||
// Evaluate MapClick data quality
|
||||
const mapClickProps = mapClickData.features[0].properties;
|
||||
const mapClickTimestamp = new Date(mapClickProps.timestamp);
|
||||
const isFresher = mapClickTimestamp > observationTime;
|
||||
|
||||
const mapClickMissingRequired = requiredFieldDefs.filter((field) => field.check(mapClickProps)).map((field) => field.name);
|
||||
const mapClickMissingOptional = optionalFieldDefs.filter((field) => field.check(mapClickProps)).map((field) => field.name);
|
||||
const mapClickMissingOptionalCount = mapClickMissingOptional.length;
|
||||
|
||||
// Determine if MapClick data is better
|
||||
let hasBetterQuality = false;
|
||||
if (optionalFieldDefs.length > 0) {
|
||||
// For modules with optional fields (like currentweather)
|
||||
hasBetterQuality = (mapClickMissingRequired.length < missingRequired.length)
|
||||
|| (missingOptionalCount > maxOptionalMissing && mapClickMissingOptionalCount <= maxOptionalMissing);
|
||||
} else {
|
||||
// For modules with only required fields (like latestobservations, regionalforecast)
|
||||
hasBetterQuality = mapClickMissingRequired.length < missingRequired.length;
|
||||
}
|
||||
|
||||
// Only use MapClick if:
|
||||
// 1. It doesn't make required fields worse AND
|
||||
// 2. It's either fresher OR has better quality
|
||||
const doesNotWorsenRequired = mapClickMissingRequired.length <= missingRequired.length;
|
||||
const shouldUseMapClick = doesNotWorsenRequired && (isFresher || hasBetterQuality);
|
||||
if (!shouldUseMapClick) {
|
||||
// Build brief rejection reason only when debugging is enabled
|
||||
let rejectionReason = 'MapClick data rejected';
|
||||
if (debugFlag(debugContext)) {
|
||||
const rejectionDetails = [];
|
||||
|
||||
if (!doesNotWorsenRequired) {
|
||||
rejectionDetails.push(`has ${mapClickMissingRequired.length - missingRequired.length} missing fields`);
|
||||
if (mapClickMissingRequired.length > 0) {
|
||||
rejectionDetails.push(`required: ${mapClickMissingRequired.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
// MapClick doesn't worsen required fields, but wasn't good enough
|
||||
if (!hasBetterQuality) {
|
||||
if (optionalFieldDefs.length > 0 && mapClickMissingOptional.length > missingOptional.length) {
|
||||
rejectionDetails.push(`optional: ${mapClickMissingOptional.length} vs ${missingOptional.length}`);
|
||||
}
|
||||
}
|
||||
if (!isFresher) {
|
||||
const mapClickAgeInMinutes = Math.round((Date.now() - mapClickTimestamp) / (1000 * 60));
|
||||
rejectionDetails.push(`older: ${mapClickAgeInMinutes}min`);
|
||||
}
|
||||
}
|
||||
|
||||
if (rejectionDetails.length > 0) {
|
||||
rejectionReason += `: ${rejectionDetails.join('; ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return returnOriginalData(rejectionReason, missingRequired, missingOptional, isStale, ageInMinutes);
|
||||
}
|
||||
|
||||
// Build improvements list for logging
|
||||
const improvements = [];
|
||||
if (isFresher) {
|
||||
// NOTE: for the forecast version, we'd want to use the `updateTime` property instead of `timestamp`
|
||||
const mapClickAgeInMinutes = Math.round((Date.now() - mapClickTimestamp) / (1000 * 60));
|
||||
improvements.push(`${mapClickAgeInMinutes} minutes old vs. ${ageInMinutes.toFixed(0)} minutes old`);
|
||||
}
|
||||
|
||||
if (hasBetterQuality) {
|
||||
const nowPresentRequired = missingRequired.filter((fieldName) => {
|
||||
const field = requiredFieldDefs.find((f) => f.name === fieldName);
|
||||
return field && !field.check(mapClickProps);
|
||||
});
|
||||
const nowPresentOptional = missingOptional.filter((fieldName) => {
|
||||
const field = optionalFieldDefs.find((f) => f.name === fieldName);
|
||||
return field && !field.check(mapClickProps);
|
||||
});
|
||||
|
||||
if (nowPresentRequired.length > 0) {
|
||||
improvements.push(`provides missing required: ${nowPresentRequired.join(', ')}`);
|
||||
}
|
||||
if (nowPresentOptional.length > 0) {
|
||||
improvements.push(`provides missing optional: ${nowPresentOptional.join(', ')}`);
|
||||
}
|
||||
if (nowPresentRequired.length === 0 && nowPresentOptional.length === 0 && mapClickMissingRequired.length < missingRequired.length) {
|
||||
improvements.push('better data quality');
|
||||
}
|
||||
}
|
||||
|
||||
// Log the improvements
|
||||
if (debugFlag(debugContext)) {
|
||||
console.log(`🗺️ ${debugContext}: preferring MapClick data for station ${stationId} (${improvements.join('; ')})`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: mapClickProps,
|
||||
wasImproved: true,
|
||||
improvements,
|
||||
missingFields: [...mapClickMissingRequired, ...mapClickMissingOptional],
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
parseMapClickDate,
|
||||
convertMapClickObservationsToApiFormat,
|
||||
convertMapClickForecastToApiFormat,
|
||||
isDataStale,
|
||||
getMapClickData,
|
||||
getMapClickCurrentObservation,
|
||||
getMapClickForecast,
|
||||
enhanceObservationWithMapClick,
|
||||
};
|
||||
153
server/scripts/modules/utils/metar.mjs
Normal file
153
server/scripts/modules/utils/metar.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
// METAR parsing utilities using metar-taf-parser library
|
||||
import { parseMetar } from '../../vendor/auto/metar-taf-parser.mjs';
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return observation;
|
||||
}
|
||||
|
||||
const metar = { ...observation };
|
||||
|
||||
try {
|
||||
const metarData = parseMetar(observation.rawMessage);
|
||||
|
||||
if (observation.windSpeed?.value === null && metarData.wind?.speed !== undefined) {
|
||||
metar.windSpeed = {
|
||||
...observation.windSpeed,
|
||||
value: metarData.wind.speed * 1.852, // Convert knots to km/h (API uses km/h)
|
||||
qualityControl: 'M', // M for METAR-derived
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.windDirection?.value === null && metarData.wind?.degrees !== undefined) {
|
||||
metar.windDirection = {
|
||||
...observation.windDirection,
|
||||
value: metarData.wind.degrees,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.windGust?.value === null && metarData.wind?.gust !== undefined) {
|
||||
metar.windGust = {
|
||||
...observation.windGust,
|
||||
value: metarData.wind.gust * 1.852, // Convert knots to km/h
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.temperature?.value === null && metarData.temperature !== undefined) {
|
||||
metar.temperature = {
|
||||
...observation.temperature,
|
||||
value: metarData.temperature,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.dewpoint?.value === null && metarData.dewPoint !== undefined) {
|
||||
metar.dewpoint = {
|
||||
...observation.dewpoint,
|
||||
value: metarData.dewPoint,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.barometricPressure?.value === null && metarData.altimeter !== undefined) {
|
||||
// Convert inHg to Pascals
|
||||
const pascals = Math.round(metarData.altimeter * 3386.39);
|
||||
metar.barometricPressure = {
|
||||
...observation.barometricPressure,
|
||||
value: pascals,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate relative humidity if missing from API but we have temp and dewpoint
|
||||
if (observation.relativeHumidity?.value === null && metar.temperature?.value !== null && metar.dewpoint?.value !== null) {
|
||||
const humidity = calculateRelativeHumidity(metar.temperature.value, metar.dewpoint.value);
|
||||
metar.relativeHumidity = {
|
||||
...observation.relativeHumidity,
|
||||
value: humidity,
|
||||
qualityControl: 'M', // M for METAR-derived
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.visibility?.value === null && metarData.visibility?.value !== undefined) {
|
||||
let visibilityKm;
|
||||
if (metarData.visibility.unit === 'SM') {
|
||||
// Convert statute miles to kilometers
|
||||
visibilityKm = metarData.visibility.value * 1.609344;
|
||||
} else if (metarData.visibility.unit === 'm') {
|
||||
// Convert meters to kilometers
|
||||
visibilityKm = metarData.visibility.value / 1000;
|
||||
} else {
|
||||
// Assume it's already in the right unit
|
||||
visibilityKm = metarData.visibility.value;
|
||||
}
|
||||
|
||||
metar.visibility = {
|
||||
...observation.visibility,
|
||||
value: Math.round(visibilityKm * 10) / 10, // Round to 1 decimal place
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.cloudLayers?.[0]?.base?.value === null && metarData.clouds?.length > 0) {
|
||||
// Find the lowest broken (BKN) or overcast (OVC) layer for ceiling
|
||||
const ceilingLayer = metarData.clouds
|
||||
.filter((cloud) => cloud.type === 'BKN' || cloud.type === 'OVC')
|
||||
.sort((a, b) => a.height - b.height)[0];
|
||||
|
||||
if (ceilingLayer) {
|
||||
// Convert feet to meters
|
||||
const heightMeters = Math.round(ceilingLayer.height * 0.3048);
|
||||
|
||||
// Create cloud layer structure if it doesn't exist
|
||||
if (!metar.cloudLayers || !metar.cloudLayers[0]) {
|
||||
metar.cloudLayers = [{
|
||||
base: {
|
||||
value: heightMeters,
|
||||
qualityControl: 'M',
|
||||
},
|
||||
}];
|
||||
} else {
|
||||
metar.cloudLayers[0].base = {
|
||||
...observation.cloudLayers[0].base,
|
||||
value: heightMeters,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If METAR parsing fails, just return the original observation
|
||||
console.warn(`Failed to parse METAR: ${error.message}`);
|
||||
return observation;
|
||||
}
|
||||
|
||||
return metar;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate relative humidity from temperature and dewpoint
|
||||
* @param {number} temperature - Temperature in Celsius
|
||||
* @param {number} dewpoint - Dewpoint in Celsius
|
||||
* @returns {number} Relative humidity as a percentage (0-100)
|
||||
*/
|
||||
const calculateRelativeHumidity = (temperature, dewpoint) => {
|
||||
// Using the Magnus formula approximation
|
||||
const a = 17.625;
|
||||
const b = 243.04;
|
||||
|
||||
const alpha = Math.log(Math.exp((a * dewpoint) / (b + dewpoint)) / Math.exp((a * temperature) / (b + temperature)));
|
||||
const relativeHumidity = Math.exp(alpha) * 100;
|
||||
|
||||
// Clamp between 0 and 100 and round to nearest integer
|
||||
return Math.round(Math.max(0, Math.min(100, relativeHumidity)));
|
||||
};
|
||||
|
||||
export default augmentObservationWithMetar;
|
||||
@@ -7,12 +7,19 @@ const noSleep = (enable = false) => {
|
||||
// get a nosleep controller
|
||||
if (!noSleep.controller) noSleep.controller = new NoSleep();
|
||||
// don't call anything if the states match
|
||||
if (wakeLock === enable) return false;
|
||||
if (wakeLock === enable) return Promise.resolve(false);
|
||||
// store the value
|
||||
wakeLock = enable;
|
||||
// call the function
|
||||
if (enable) return noSleep.controller.enable();
|
||||
return noSleep.controller.disable();
|
||||
if (enable) {
|
||||
return noSleep.controller.enable().catch((error) => {
|
||||
// Handle wake lock request failures gracefully
|
||||
console.warn('Wake lock request failed:', error.message);
|
||||
wakeLock = false;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(noSleep.controller.disable());
|
||||
};
|
||||
|
||||
export default noSleep;
|
||||
|
||||
66
server/scripts/modules/utils/scroll-timing.mjs
Normal file
66
server/scripts/modules/utils/scroll-timing.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
// Utility functions for dynamic scroll timing calculations
|
||||
|
||||
/**
|
||||
* Calculate dynamic scroll timing based on actual content dimensions
|
||||
* @param {HTMLElement} list - The scrollable content element
|
||||
* @param {HTMLElement} container - The container element (for measuring display height)
|
||||
* @param {Object} options - Timing configuration options
|
||||
* @param {number} options.scrollSpeed - Pixels per second scroll speed (default: 50)
|
||||
* @param {number} options.initialDelay - Seconds before scrolling starts (default: 3.0)
|
||||
* @param {number} options.finalPause - Seconds after scrolling ends (default: 3.0)
|
||||
* @param {number} options.staticDisplay - Seconds for static display when no scrolling needed (default: same as initialDelay + finalPause)
|
||||
* @param {number} options.baseDelay - Milliseconds per timing count (default: 40)
|
||||
* @returns {Object} Timing configuration object with delay array, scrollTiming, and baseDelay
|
||||
*/
|
||||
const calculateScrollTiming = (list, container, options = {}) => {
|
||||
const {
|
||||
scrollSpeed = 50,
|
||||
initialDelay = 3.0,
|
||||
finalPause = 3.0,
|
||||
staticDisplay = initialDelay + finalPause,
|
||||
baseDelay = 40,
|
||||
} = options;
|
||||
|
||||
// timing conversion helper
|
||||
const secondsToTimingCounts = (seconds) => Math.ceil(seconds * 1000 / baseDelay);
|
||||
|
||||
// calculate actual scroll distance needed
|
||||
const displayHeight = container.offsetHeight;
|
||||
const contentHeight = list.scrollHeight;
|
||||
const scrollableHeight = Math.max(0, contentHeight - displayHeight);
|
||||
|
||||
// calculate scroll time based on actual distance and speed
|
||||
const scrollTimeSeconds = scrollableHeight > 0 ? scrollableHeight / scrollSpeed : 0;
|
||||
|
||||
// convert seconds to timing counts
|
||||
const initialCounts = secondsToTimingCounts(initialDelay);
|
||||
const scrollCounts = secondsToTimingCounts(scrollTimeSeconds);
|
||||
const finalCounts = secondsToTimingCounts(finalPause);
|
||||
const staticCounts = secondsToTimingCounts(staticDisplay);
|
||||
|
||||
// calculate pixels per count based on our actual scroll distance and time
|
||||
// This ensures the scroll animation matches our timing perfectly
|
||||
const pixelsPerCount = scrollCounts > 0 ? scrollableHeight / scrollCounts : 0;
|
||||
|
||||
// Build timing array - simple approach
|
||||
const delay = [];
|
||||
|
||||
if (scrollableHeight === 0) {
|
||||
// No scrolling needed - just show static content
|
||||
delay.push(staticCounts);
|
||||
} else {
|
||||
// Initial delay + scroll time + final pause
|
||||
delay.push(initialCounts + scrollCounts + finalCounts);
|
||||
}
|
||||
|
||||
return {
|
||||
baseDelay,
|
||||
delay,
|
||||
scrollTiming: {
|
||||
initialCounts,
|
||||
pixelsPerCount,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default calculateScrollTiming;
|
||||
@@ -9,8 +9,10 @@ const DEFAULTS = {
|
||||
defaultValue: undefined,
|
||||
changeAction: () => { },
|
||||
sticky: true,
|
||||
stickyRead: false,
|
||||
values: [],
|
||||
visible: true,
|
||||
placeholder: '',
|
||||
};
|
||||
|
||||
class Setting {
|
||||
@@ -28,9 +30,11 @@ class Setting {
|
||||
this.myValue = this.defaultValue;
|
||||
this.type = options?.type;
|
||||
this.sticky = options.sticky;
|
||||
this.stickyRead = options.stickyRead;
|
||||
this.values = options.values;
|
||||
this.visible = options.visible;
|
||||
this.changeAction = options.changeAction;
|
||||
this.placeholder = options.placeholder;
|
||||
|
||||
// get value from url
|
||||
const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`];
|
||||
@@ -48,10 +52,13 @@ class Setting {
|
||||
// couldn't parse as a float, store as a string
|
||||
urlState = urlValue;
|
||||
}
|
||||
if (this.type === 'string' && urlValue !== undefined) {
|
||||
urlState = urlValue;
|
||||
}
|
||||
|
||||
// get existing value if present
|
||||
const storedValue = urlState ?? this.getFromLocalStorage();
|
||||
if ((this.sticky || urlValue !== undefined) && storedValue !== null) {
|
||||
if ((this.sticky || this.stickyRead || urlValue !== undefined) && storedValue !== null) {
|
||||
this.myValue = storedValue;
|
||||
}
|
||||
|
||||
@@ -60,6 +67,9 @@ class Setting {
|
||||
case 'select':
|
||||
this.selectChange({ target: { value: this.myValue } });
|
||||
break;
|
||||
case 'string':
|
||||
this.stringChange({ target: { value: this.myValue } });
|
||||
break;
|
||||
case 'checkbox':
|
||||
default:
|
||||
this.checkboxChange({ target: { checked: this.myValue } });
|
||||
@@ -124,6 +134,34 @@ class Setting {
|
||||
return label;
|
||||
}
|
||||
|
||||
generateString() {
|
||||
// create a string input and accompanying set button
|
||||
const label = document.createElement('label');
|
||||
label.for = `settings-${this.shortName}-string`;
|
||||
label.id = `settings-${this.shortName}-label`;
|
||||
// text input box
|
||||
const textInput = document.createElement('input');
|
||||
textInput.type = 'text';
|
||||
textInput.value = this.myValue;
|
||||
textInput.id = `settings-${this.shortName}-string`;
|
||||
textInput.name = `settings-${this.shortName}-string`;
|
||||
textInput.placeholder = this.placeholder;
|
||||
// set button
|
||||
const setButton = document.createElement('input');
|
||||
setButton.type = 'button';
|
||||
setButton.value = 'Set';
|
||||
setButton.id = `settings-${this.shortName}-button`;
|
||||
setButton.name = `settings-${this.shortName}-button`;
|
||||
setButton.addEventListener('click', () => {
|
||||
this.stringChange({ target: { value: textInput.value } });
|
||||
});
|
||||
// assemble
|
||||
label.append(textInput, setButton);
|
||||
|
||||
this.element = label;
|
||||
return label;
|
||||
}
|
||||
|
||||
checkboxChange(e) {
|
||||
// update the state
|
||||
this.myValue = e.target.checked;
|
||||
@@ -146,6 +184,15 @@ class Setting {
|
||||
this.changeAction(this.myValue);
|
||||
}
|
||||
|
||||
stringChange(e) {
|
||||
// update the value
|
||||
this.myValue = e.target.value;
|
||||
this.storeToLocalStorage(this.myValue);
|
||||
|
||||
// call the change action
|
||||
this.changeAction(this.myValue);
|
||||
}
|
||||
|
||||
storeToLocalStorage(value) {
|
||||
if (!this.sticky) return;
|
||||
const allSettingsString = localStorage?.getItem(SETTINGS_KEY) ?? '{}';
|
||||
@@ -154,6 +201,20 @@ class Setting {
|
||||
localStorage?.setItem(SETTINGS_KEY, JSON.stringify(allSettings));
|
||||
}
|
||||
|
||||
// Conditional storage method for stickyRead settings
|
||||
conditionalStoreToLocalStorage(value, shouldStore) {
|
||||
if (!this.stickyRead) return;
|
||||
const allSettingsString = localStorage?.getItem(SETTINGS_KEY) ?? '{}';
|
||||
const allSettings = JSON.parse(allSettingsString);
|
||||
|
||||
if (shouldStore) {
|
||||
allSettings[this.shortName] = value;
|
||||
} else {
|
||||
delete allSettings[this.shortName];
|
||||
}
|
||||
localStorage?.setItem(SETTINGS_KEY, JSON.stringify(allSettings));
|
||||
}
|
||||
|
||||
getFromLocalStorage() {
|
||||
const allSettings = localStorage?.getItem(SETTINGS_KEY);
|
||||
try {
|
||||
@@ -163,16 +224,17 @@ class Setting {
|
||||
switch (this.type) {
|
||||
case 'boolean':
|
||||
case 'checkbox':
|
||||
return storedValue;
|
||||
case 'select':
|
||||
case 'string':
|
||||
return storedValue;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse settings from localStorage: ${error} - allSettings=${allSettings}`);
|
||||
localStorage?.removeItem(SETTINGS_KEY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -214,6 +276,8 @@ class Setting {
|
||||
switch (this.type) {
|
||||
case 'select':
|
||||
return this.generateSelect();
|
||||
case 'string':
|
||||
return this.generateString();
|
||||
case 'checkbox':
|
||||
default:
|
||||
return this.generateCheckbox();
|
||||
|
||||
58
server/scripts/modules/utils/url-rewrite.mjs
Normal file
58
server/scripts/modules/utils/url-rewrite.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
// rewrite URLs to use local proxy server
|
||||
const rewriteUrl = (_url) => {
|
||||
if (!_url) {
|
||||
throw new Error(`rewriteUrl called with invalid argument: '${_url}' (${typeof _url})`);
|
||||
}
|
||||
|
||||
// Handle relative URLs early: return them as-is since they don't need rewriting
|
||||
if (typeof _url === 'string' && !_url.startsWith('http')) {
|
||||
return _url;
|
||||
}
|
||||
|
||||
if (typeof _url !== 'string' && !(_url instanceof URL)) {
|
||||
throw new Error(`rewriteUrl expects a URL string or URL object, received: ${typeof _url}`);
|
||||
}
|
||||
|
||||
// Convert to URL object (for URL objects, creates a copy to avoid mutating the original)
|
||||
const url = new URL(_url);
|
||||
|
||||
if (!window.WS4KP_SERVER_AVAILABLE) {
|
||||
// If running standalone in the browser, simply return a URL object without rewriting
|
||||
return url;
|
||||
}
|
||||
|
||||
// Rewrite the origin to use local proxy server
|
||||
if (url.origin === 'https://api.weather.gov') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/api${url.pathname}`;
|
||||
} else if (url.origin === 'https://forecast.weather.gov') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/forecast${url.pathname}`;
|
||||
} else if (url.origin === 'https://www.spc.noaa.gov') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/spc${url.pathname}`;
|
||||
} else if (url.origin === 'https://radar.weather.gov') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/radar${url.pathname}`;
|
||||
} else if (url.origin === 'https://mesonet.agron.iastate.edu') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/mesonet${url.pathname}`;
|
||||
} else if (typeof OVERRIDES !== 'undefined' && OVERRIDES?.RADAR_HOST && url.origin === `https://${OVERRIDES.RADAR_HOST}`) {
|
||||
// Handle override radar host
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/mesonet${url.pathname}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export {
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
rewriteUrl,
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { json } from './fetch.mjs';
|
||||
import { safeJson } from './fetch.mjs';
|
||||
import { debugFlag } from './debug.mjs';
|
||||
|
||||
const getPoint = async (lat, lon) => {
|
||||
try {
|
||||
return await json(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
|
||||
} catch (error) {
|
||||
console.log(`Unable to get point ${lat}, ${lon}`);
|
||||
console.error(error);
|
||||
const point = await safeJson(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
|
||||
if (!point) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get points for ${lat},${lon}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return point;
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { parseQueryString } from './share.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import { elemForEach } from './utils/elem.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class WeatherDisplay {
|
||||
constructor(navId, elemId, name, defaultEnabled) {
|
||||
@@ -28,7 +29,7 @@ class WeatherDisplay {
|
||||
// default navigation timing
|
||||
this.timing = {
|
||||
totalScreens: 1,
|
||||
baseDelay: 9000, // 5 seconds
|
||||
baseDelay: 9000, // 9 seconds
|
||||
delay: 1, // 1*1second = 1 second total display time
|
||||
};
|
||||
this.navBaseCount = 0;
|
||||
@@ -249,6 +250,23 @@ class WeatherDisplay {
|
||||
// increment the base count
|
||||
this.navBaseCount += 1;
|
||||
|
||||
if (debugFlag('weatherdisplay')) {
|
||||
const now = Date.now();
|
||||
if (!this.timingDebug) {
|
||||
this.timingDebug = { startTime: now, lastTransition: now, baseCountLog: [] };
|
||||
if (this.navBaseCount !== 1) {
|
||||
console.log(`⏱️ [${this.constructor.name}] Starting at baseCount ${this.navBaseCount}`);
|
||||
}
|
||||
}
|
||||
const elapsed = now - this.timingDebug.lastTransition;
|
||||
this.timingDebug.baseCountLog.push({
|
||||
baseCount: this.navBaseCount,
|
||||
timestamp: now,
|
||||
elapsedMs: elapsed,
|
||||
screenIndex: this.screenIndex,
|
||||
});
|
||||
}
|
||||
|
||||
// call base count change if available for this function
|
||||
if (this.baseCountChange) this.baseCountChange(this.navBaseCount);
|
||||
|
||||
@@ -270,6 +288,30 @@ class WeatherDisplay {
|
||||
// test for no change and exit early
|
||||
if (nextScreenIndex === this.screenIndex) return;
|
||||
|
||||
if (debugFlag('weatherdisplay') && this.timingDebug) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.timingDebug.lastTransition;
|
||||
this.timingDebug.lastTransition = now;
|
||||
console.log(`⏱️ [${this.constructor.name}] Screen Transition: ${this.screenIndex} → ${nextScreenIndex === -1 ? 0 : nextScreenIndex}, baseCount=${this.navBaseCount}, duration=${elapsed}ms`);
|
||||
if (this.screenIndex !== -1 && this.timing && this.timing.delay !== undefined) { // Skip expected duration calculation for the first transition (screenIndex -1 → 0)
|
||||
let expectedMs;
|
||||
if (Array.isArray(this.timing.delay)) { // Array-based timing (different delay per screen/period)
|
||||
// Find the timing index for the screen we just LEFT (the one that just finished displaying)
|
||||
// For transition "X → Y", we want the timing for screen X (which is this.screenIndex before it gets updated)
|
||||
const timingIndex = this.screenIndex;
|
||||
if (timingIndex >= 0 && timingIndex < this.timing.delay.length) { // Handle both simple number delays and object delays with time property (radar)
|
||||
const delayValue = typeof this.timing.delay[timingIndex] === 'object' ? this.timing.delay[timingIndex].time : this.timing.delay[timingIndex];
|
||||
expectedMs = this.timing.baseDelay * delayValue * (settings?.speed?.value || 1);
|
||||
}
|
||||
} else if (typeof this.timing.delay === 'number') { // Simple number-based timing (same delay for all screens)
|
||||
expectedMs = this.timing.baseDelay * this.timing.delay * (settings?.speed?.value || 1);
|
||||
}
|
||||
if (expectedMs !== undefined) {
|
||||
console.log(`⏱️ [${this.constructor.name}] Expected duration: ${expectedMs}ms, Actual: ${elapsed}ms, Diff: ${elapsed - expectedMs}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// test for -1 (no screen displayed yet)
|
||||
this.screenIndex = nextScreenIndex === -1 ? 0 : nextScreenIndex;
|
||||
|
||||
@@ -369,10 +411,29 @@ class WeatherDisplay {
|
||||
|
||||
// start and stop base counter
|
||||
startNavCount() {
|
||||
if (!this.navInterval) this.navInterval = setInterval(() => this.navBaseTime(), this.timing.baseDelay * settings.speed.value);
|
||||
if (!this.navInterval) {
|
||||
if (debugFlag('weatherdisplay')) {
|
||||
console.log(`⏱️ [${this.constructor.name}] Starting navigation:`, {
|
||||
baseDelay: this.timing.baseDelay,
|
||||
intervalMs: this.timing.baseDelay * (settings?.speed?.value || 1),
|
||||
totalScreens: this.timing.totalScreens,
|
||||
delayArray: this.timing.delay,
|
||||
fullDelayArray: this.timing.fullDelay,
|
||||
screenIndexes: this.timing.screenIndexes,
|
||||
});
|
||||
}
|
||||
this.navInterval = setInterval(() => this.navBaseTime(), this.timing.baseDelay * settings.speed.value);
|
||||
}
|
||||
}
|
||||
|
||||
resetNavBaseCount() {
|
||||
if (debugFlag('weatherdisplay') && this.timingDebug && this.timingDebug.baseCountLog.length > 1) {
|
||||
const totalDuration = this.timingDebug.baseCountLog[this.timingDebug.baseCountLog.length - 1].timestamp - this.timingDebug.baseCountLog[0].timestamp;
|
||||
const avgInterval = totalDuration / (this.timingDebug.baseCountLog.length - 1);
|
||||
console.log(`⏱️ [${this.constructor.name}] Total duration: ${totalDuration}ms, Avg base interval: ${avgInterval.toFixed(1)}ms, Base count range: ${this.timingDebug.baseCountLog[0].baseCount}-${this.timingDebug.baseCountLog[this.timingDebug.baseCountLog.length - 1].baseCount}`);
|
||||
this.timingDebug = null;
|
||||
}
|
||||
|
||||
this.navBaseCount = 0;
|
||||
this.screenIndex = -1;
|
||||
// reset the timing so we don't short-change the first screen
|
||||
@@ -446,7 +507,7 @@ class WeatherDisplay {
|
||||
setAutoReload() {
|
||||
// refresh time can be forced by the user (for hazards)
|
||||
const refreshTime = this.refreshTime ?? settings.refreshTime.value;
|
||||
this.autoRefreshHandle = this.autoRefreshHandle ?? setInterval(() => this.getData(false, true), refreshTime);
|
||||
this.autoRefreshHandle = this.autoRefreshHandle ?? setInterval(() => this.getData(this.weatherParameters, true), refreshTime);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
398
server/scripts/vendor/auto/locale/en.js
vendored
Normal file
398
server/scripts/vendor/auto/locale/en.js
vendored
Normal file
@@ -0,0 +1,398 @@
|
||||
var en = {
|
||||
CloudQuantity: {
|
||||
BKN: "broken",
|
||||
FEW: "few",
|
||||
NSC: "no significant clouds.",
|
||||
OVC: "overcast",
|
||||
SCT: "scattered",
|
||||
SKC: "sky clear",
|
||||
},
|
||||
CloudType: {
|
||||
AC: "Altocumulus",
|
||||
AS: "Altostratus",
|
||||
CB: "Cumulonimbus",
|
||||
CC: "CirroCumulus",
|
||||
CI: "Cirrus",
|
||||
CS: "Cirrostratus",
|
||||
CU: "Cumulus",
|
||||
NS: "Nimbostratus",
|
||||
SC: "Stratocumulus",
|
||||
ST: "Stratus",
|
||||
TCU: "Towering cumulus",
|
||||
},
|
||||
Converter: {
|
||||
D: "decreasing",
|
||||
E: "East",
|
||||
ENE: "East North East",
|
||||
ESE: "East South East",
|
||||
N: "North",
|
||||
NE: "North East",
|
||||
NNE: "North North East",
|
||||
NNW: "North North West",
|
||||
NSC: "no significant change",
|
||||
NW: "North West",
|
||||
S: "South",
|
||||
SE: "South East",
|
||||
SSE: "South South East",
|
||||
SSW: "South South West",
|
||||
SW: "South West",
|
||||
U: "up rising",
|
||||
VRB: "Variable",
|
||||
W: "West",
|
||||
WNW: "West North West",
|
||||
WSW: "West South West",
|
||||
},
|
||||
DepositBrakingCapacity: {
|
||||
GOOD: "good",
|
||||
MEDIUM: "medium",
|
||||
MEDIUM_GOOD: "medium/good",
|
||||
MEDIUM_POOR: "poor/medium",
|
||||
NOT_REPORTED: "not reported",
|
||||
POOR: "poor",
|
||||
UNRELIABLE: "figures unreliable",
|
||||
},
|
||||
DepositCoverage: {
|
||||
FROM_11_TO_25: "from 11% to 25%",
|
||||
FROM_26_TO_50: "from 26% to 50%",
|
||||
FROM_51_TO_100: "from 51% to 100%",
|
||||
LESS_10: "less than 10%",
|
||||
NOT_REPORTED: "not reported",
|
||||
},
|
||||
DepositThickness: {
|
||||
CLOSED: "closed",
|
||||
LESS_1_MM: "less than 1 mm",
|
||||
NOT_REPORTED: "not reported",
|
||||
THICKNESS_10: "10 cm",
|
||||
THICKNESS_15: "15 cm",
|
||||
THICKNESS_20: "20 cm",
|
||||
THICKNESS_25: "25 cm",
|
||||
THICKNESS_30: "30 cm",
|
||||
THICKNESS_35: "35 cm",
|
||||
THICKNESS_40: "40 cm or more",
|
||||
},
|
||||
DepositType: {
|
||||
CLEAR_DRY: "clear and dry",
|
||||
COMPACTED_SNOW: "compacted or rolled snow",
|
||||
DAMP: "damp",
|
||||
DRY_SNOW: "dry snow",
|
||||
FROZEN_RIDGES: "frozen ruts or ridges",
|
||||
ICE: "ice",
|
||||
NOT_REPORTED: "not reported",
|
||||
RIME_FROST_COVERED: "rime or frost covered",
|
||||
SLUSH: "slush",
|
||||
WET_SNOW: "wet snow",
|
||||
WET_WATER_PATCHES: "wet or water patches",
|
||||
},
|
||||
Descriptive: {
|
||||
BC: "patches",
|
||||
BL: "blowing",
|
||||
DR: "low drifting",
|
||||
FZ: "freezing",
|
||||
MI: "shallow",
|
||||
PR: "partial",
|
||||
SH: "showers of",
|
||||
TS: "thunderstorm",
|
||||
},
|
||||
Error: {
|
||||
prefix: "An error occured. Error code n°",
|
||||
},
|
||||
ErrorCode: {
|
||||
AirportNotFound: "The airport was not found for this message.",
|
||||
InvalidMessage: "The entered message is invalid.",
|
||||
},
|
||||
Indicator: {
|
||||
M: "less than",
|
||||
P: "greater than",
|
||||
},
|
||||
"intensity-plus": "Heavy",
|
||||
Intensity: {
|
||||
"-": "Light",
|
||||
VC: "In the vicinity",
|
||||
},
|
||||
MetarFacade: {
|
||||
InvalidIcao: "Icao code is invalid.",
|
||||
},
|
||||
Phenomenon: {
|
||||
BR: "mist",
|
||||
DS: "duststorm",
|
||||
DU: "widespread dust",
|
||||
DZ: "drizzle",
|
||||
FC: "funnel cloud",
|
||||
FG: "fog",
|
||||
FU: "smoke",
|
||||
GR: "hail",
|
||||
GS: "small hail and/or snow pellets",
|
||||
HZ: "haze",
|
||||
IC: "ice crystals",
|
||||
PL: "ice pellets",
|
||||
PO: "dust or sand whirls",
|
||||
PY: "spray",
|
||||
RA: "rain",
|
||||
SA: "sand",
|
||||
SG: "snow grains",
|
||||
SN: "snow",
|
||||
SQ: "squall",
|
||||
SS: "sandstorm",
|
||||
TS: "thunderstorm",
|
||||
UP: "unknown precipitation",
|
||||
VA: "volcanic ash",
|
||||
},
|
||||
Remark: {
|
||||
ALQDS: "all quadrants",
|
||||
AO1: "automated stations without a precipitation discriminator",
|
||||
AO2: "automated station with a precipitation discriminator",
|
||||
BASED: "based",
|
||||
Barometer: [
|
||||
"Increase, then decrease",
|
||||
"Increase, then steady, or increase then Increase more slowly",
|
||||
"steady or unsteady increase",
|
||||
"Decrease or steady, then increase; or increase then increase more rapidly",
|
||||
"Steady",
|
||||
"Decrease, then increase",
|
||||
"Decrease then steady; or decrease then decrease more slowly",
|
||||
"Steady or unsteady decrease",
|
||||
"Steady or increase, then decrease; or decrease then decrease more rapidly",
|
||||
],
|
||||
Ceiling: {
|
||||
Height: "ceiling varying between {0} and {1} feet",
|
||||
Second: {
|
||||
Location: "ceiling of {0} feet mesured by a second sensor located at {1}",
|
||||
},
|
||||
},
|
||||
DSNT: "distant",
|
||||
FCST: "forecast",
|
||||
FUNNELCLOUD: "funnel cloud",
|
||||
HVY: "heavy",
|
||||
Hail: {
|
||||
"0": "largest hailstones with a diameter of {0} inches",
|
||||
LesserThan: "largest hailstones with a diameter less than {0} inches",
|
||||
},
|
||||
Hourly: {
|
||||
Maximum: {
|
||||
Minimum: {
|
||||
Temperature: "24-hour maximum temperature of {0}°C and 24-hour minimum temperature of {1}°C",
|
||||
},
|
||||
Temperature: "6-hourly maximum temperature of {0}°C",
|
||||
},
|
||||
Minimum: {
|
||||
Temperature: "6-hourly minimum temperature of {0}°C",
|
||||
},
|
||||
Temperature: {
|
||||
"0": "hourly temperature of {0}°C",
|
||||
Dew: {
|
||||
Point: "hourly temperature of {0}°C and dew point of {1}°C",
|
||||
},
|
||||
},
|
||||
},
|
||||
Ice: {
|
||||
Accretion: {
|
||||
Amount: "{0}/100 of an inch of ice accretion in the past {1} hour(s)",
|
||||
},
|
||||
},
|
||||
LGT: "light",
|
||||
LTG: "lightning",
|
||||
MOD: "moderate",
|
||||
NXT: "next",
|
||||
ON: "on",
|
||||
Obscuration: "{0} layer at {1} feet composed of {2}",
|
||||
PRESFR: "pressure falling rapidly",
|
||||
PRESRR: "pressure rising rapidly",
|
||||
PeakWind: "peak wind of {1} knots from {0} degrees at {2}:{3}",
|
||||
Precipitation: {
|
||||
Amount: {
|
||||
"24": "{0} inches of precipitation fell in the last 24 hours",
|
||||
"3": {
|
||||
"6": "{1} inches of precipitation fell in the last {0} hours",
|
||||
},
|
||||
Hourly: "{0}/100 of an inch of precipitation fell in the last hour",
|
||||
},
|
||||
Beg: {
|
||||
"0": "{0} {1} beginning at {2}:{3}",
|
||||
End: "{0} {1} beginning at {2}:{3} ending at {4}:{5}",
|
||||
},
|
||||
End: "{0} {1} ending at {2}:{3}",
|
||||
},
|
||||
Pressure: {
|
||||
Tendency: "of {0} hectopascals in the past 3 hours",
|
||||
},
|
||||
SLPNO: "sea level pressure not available",
|
||||
Sea: {
|
||||
Level: {
|
||||
Pressure: "sea level pressure of {0} HPa",
|
||||
},
|
||||
},
|
||||
Second: {
|
||||
Location: {
|
||||
Visibility: "visibility of {0} SM mesured by a second sensor located at {1}",
|
||||
},
|
||||
},
|
||||
Sector: {
|
||||
Visibility: "visibility of {1} SM in the {0} direction",
|
||||
},
|
||||
Snow: {
|
||||
Depth: "snow depth of {0} inches",
|
||||
Increasing: {
|
||||
Rapidly: "snow depth increase of {0} inches in the past hour with a total depth on the ground of {1} inches",
|
||||
},
|
||||
Pellets: "{0} snow pellets",
|
||||
},
|
||||
Sunshine: {
|
||||
Duration: "{0} minutes of sunshine",
|
||||
},
|
||||
Surface: {
|
||||
Visibility: "surface visibility of {0} statute miles",
|
||||
},
|
||||
TORNADO: "tornado",
|
||||
Thunderstorm: {
|
||||
Location: {
|
||||
"0": "thunderstorm {0} of the station",
|
||||
Moving: "thunderstorm {0} of the station moving towards {1}",
|
||||
},
|
||||
},
|
||||
Tornadic: {
|
||||
Activity: {
|
||||
BegEnd: "{0} beginning at {1}:{2} ending at {3}:{4} {5} SM {6} of the station",
|
||||
Beginning: "{0} beginning at {1}:{2} {3} SM {4} of the station",
|
||||
Ending: "{0} ending at {1}:{2} {3} SM {4} of the station",
|
||||
},
|
||||
},
|
||||
Tower: {
|
||||
Visibility: "control tower visibility of {0} statute miles",
|
||||
},
|
||||
VIRGA: "virga",
|
||||
Variable: {
|
||||
Prevailing: {
|
||||
Visibility: "variable prevailing visibility between {0} and {1} SM",
|
||||
},
|
||||
Sky: {
|
||||
Condition: {
|
||||
"0": "cloud layer varying between {0} and {1}",
|
||||
Height: "cloud layer at {0} feet varying between {1} and {2}",
|
||||
},
|
||||
},
|
||||
},
|
||||
Virga: {
|
||||
Direction: "virga {0} from the station",
|
||||
},
|
||||
WATERSPOUT: "waterspout",
|
||||
Water: {
|
||||
Equivalent: {
|
||||
Snow: {
|
||||
Ground: "water equivalent of {0} inches of snow",
|
||||
},
|
||||
},
|
||||
},
|
||||
WindShift: {
|
||||
"0": "wind shift at {0}:{1}",
|
||||
FROPA: "wind shift accompanied by frontal passage at {0}:{1}",
|
||||
},
|
||||
},
|
||||
TimeIndicator: {
|
||||
AT: "at",
|
||||
FM: "From",
|
||||
TL: "until",
|
||||
},
|
||||
ToString: {
|
||||
airport: "airport",
|
||||
altimeter: "altimeter (hPa)",
|
||||
amendment: "amendment",
|
||||
auto: "auto",
|
||||
cavok: "cavok",
|
||||
clouds: "clouds",
|
||||
day: {
|
||||
hour: "hour of the day",
|
||||
month: "day of the month",
|
||||
},
|
||||
deposit: {
|
||||
braking: "braking capacity",
|
||||
coverage: "coverage",
|
||||
thickness: "thickness",
|
||||
type: "type of deposit",
|
||||
},
|
||||
descriptive: "descriptive",
|
||||
dew: {
|
||||
point: "dew point",
|
||||
},
|
||||
end: {
|
||||
day: {
|
||||
month: "end day of the month",
|
||||
},
|
||||
hour: {
|
||||
day: "end hour of the day",
|
||||
},
|
||||
},
|
||||
height: {
|
||||
feet: "height (ft)",
|
||||
meter: "height (m)",
|
||||
},
|
||||
indicator: "indicator",
|
||||
intensity: "intensity",
|
||||
message: "original message",
|
||||
name: "name",
|
||||
nosig: "nosig",
|
||||
phenomenons: "phenomenons",
|
||||
probability: "probability",
|
||||
quantity: "quantity",
|
||||
remark: "remarks",
|
||||
report: {
|
||||
time: "time of report",
|
||||
},
|
||||
runway: {
|
||||
info: "runways information",
|
||||
},
|
||||
start: {
|
||||
day: {
|
||||
month: "starting day of the month",
|
||||
},
|
||||
hour: {
|
||||
day: "starting hour of the day",
|
||||
},
|
||||
minute: "starting minute",
|
||||
},
|
||||
temperature: {
|
||||
"0": "temperature (°C)",
|
||||
max: "maximum temperature (°C)",
|
||||
min: "minimum temperature (°C)",
|
||||
},
|
||||
trend: "trend",
|
||||
trends: "trends",
|
||||
type: "type",
|
||||
vertical: {
|
||||
visibility: "vertical visibility (ft)",
|
||||
},
|
||||
visibility: {
|
||||
main: "main visibility",
|
||||
max: "maximum visibility",
|
||||
min: {
|
||||
"0": "minimum visibility",
|
||||
direction: "minimum visibility direction",
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
conditions: "weather conditions",
|
||||
},
|
||||
wind: {
|
||||
direction: {
|
||||
"0": "direction",
|
||||
degrees: "direction (degrees)",
|
||||
},
|
||||
gusts: "gusts",
|
||||
max: {
|
||||
variation: "maximal wind variation",
|
||||
},
|
||||
min: {
|
||||
variation: "minimal wind variation",
|
||||
},
|
||||
speed: "speed",
|
||||
unit: "unit",
|
||||
},
|
||||
},
|
||||
WeatherChangeType: {
|
||||
BECMG: "Becoming",
|
||||
FM: "From",
|
||||
PROB: "Probability",
|
||||
TEMPO: "Temporary",
|
||||
},
|
||||
};
|
||||
|
||||
export { en as default };
|
||||
2840
server/scripts/vendor/auto/metar-taf-parser.mjs
vendored
Normal file
2840
server/scripts/vendor/auto/metar-taf-parser.mjs
vendored
Normal file
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,5 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
#almanac-html.weather-display {
|
||||
background-image: url('../images/backgrounds/3.png');
|
||||
@@ -11,62 +11,57 @@
|
||||
@include u.text-shadow();
|
||||
|
||||
.sun {
|
||||
display: table;
|
||||
margin-left: 50px;
|
||||
height: 100px;
|
||||
// Use CSS Grid for cross-browser consistency
|
||||
// Grid is populated in reading order (left-to-right, top-to-bottom):
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 0px 90px;
|
||||
margin: 3px auto 5px auto; // align the bottom of the div with the background
|
||||
width: fit-content;
|
||||
line-height: 30px;
|
||||
|
||||
|
||||
&>div {
|
||||
display: table-row;
|
||||
.grid-item {
|
||||
// Reset inherited styles that interfere with grid layout
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
|
||||
&>div {
|
||||
display: table-cell;
|
||||
}
|
||||
}
|
||||
|
||||
.days {
|
||||
color: c.$column-header-text;
|
||||
text-align: right;
|
||||
top: -5px;
|
||||
|
||||
.day {
|
||||
padding-right: 10px;
|
||||
// Column headers (day names)
|
||||
&.header {
|
||||
color: c.$column-header-text;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.times {
|
||||
text-align: right;
|
||||
|
||||
.sun-time {
|
||||
width: 200px;
|
||||
// Row labels (Sunrise:, Sunset:)
|
||||
&.row-label {
|
||||
// color: c.$column-header-text; // screenshots show labels were white
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&.times-1 {
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
&.times-2 {
|
||||
top: -15px;
|
||||
// Time values (sunrise/sunset)
|
||||
&.time {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.moon {
|
||||
position: relative;
|
||||
top: -10px;
|
||||
|
||||
padding: 0px 60px;
|
||||
padding: 7px 50px;
|
||||
line-height: 36px;
|
||||
|
||||
.title {
|
||||
color: c.$column-header-text;
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.day {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 130px;
|
||||
width: 132px;
|
||||
|
||||
.icon {
|
||||
// shadow in image make it look off center
|
||||
@@ -82,4 +77,4 @@
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 .main.current-weather {
|
||||
&.main {
|
||||
@@ -58,27 +58,19 @@
|
||||
font-size: 24pt;
|
||||
}
|
||||
|
||||
.condition {}
|
||||
|
||||
.icon {
|
||||
height: 100px;
|
||||
|
||||
img {
|
||||
max-width: 126px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.wind-container {
|
||||
margin-bottom: 10px;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
|
||||
&>div {
|
||||
width: 45%;
|
||||
display: inline-block;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.wind-label {
|
||||
margin-left: 5px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.wind {
|
||||
@@ -87,7 +79,8 @@
|
||||
}
|
||||
|
||||
.wind-gusts {
|
||||
margin-left: 5px;
|
||||
text-align: right;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.location {
|
||||
@@ -99,4 +92,4 @@
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
.weather-display .main.hazards {
|
||||
&.main {
|
||||
overflow-y: hidden;
|
||||
height: 480px;
|
||||
background-color: rgb(112, 35, 35);
|
||||
|
||||
.hazard-lines {
|
||||
min-height: 400px;
|
||||
padding-top: 10px;
|
||||
|
||||
background-color: rgb(112, 35, 35);
|
||||
|
||||
.hazard {
|
||||
font-family: 'Star4000';
|
||||
font-size: 24pt;
|
||||
@@ -26,4 +25,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
body {
|
||||
font-family: "Star4000";
|
||||
margin: 0;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000000;
|
||||
@@ -23,13 +24,17 @@ body {
|
||||
|
||||
&.kiosk {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
// Always use black background in kiosk mode, regardless of light/dark preference
|
||||
background-color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
#divQuery {
|
||||
max-width: 640px;
|
||||
padding: 8px;
|
||||
|
||||
.buttons {
|
||||
display: inline-block;
|
||||
@@ -89,17 +94,22 @@ body {
|
||||
font-family: "Star4000";
|
||||
}
|
||||
|
||||
#txtAddress {
|
||||
#txtLocation {
|
||||
width: calc(100% - 170px);
|
||||
max-width: 490px;
|
||||
font-size: 16pt;
|
||||
min-width: 200px;
|
||||
display: inline-block;
|
||||
|
||||
// Ensure consistent styling across light and dark modes
|
||||
background-color: white;
|
||||
color: black;
|
||||
border: 2px inset #808080;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
border: 1px solid darkgray;
|
||||
border: 2px inset #808080;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,12 +147,26 @@ body {
|
||||
color: #ffffff;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0; // Ensure edge-to-edge display
|
||||
|
||||
&.wide {
|
||||
max-width: 854px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#divTwcMain {
|
||||
width: 640px;
|
||||
height: 480px;
|
||||
|
||||
.wide & {
|
||||
width: 854px;
|
||||
}
|
||||
}
|
||||
|
||||
.kiosk #divTwc {
|
||||
max-width: unset;
|
||||
}
|
||||
@@ -184,7 +208,11 @@ body {
|
||||
background-color: #000000;
|
||||
|
||||
color: #ffffff;
|
||||
width: 100%;
|
||||
width: 640px;
|
||||
|
||||
.wide & {
|
||||
width: 854px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: rgb(48, 48, 48);
|
||||
@@ -196,25 +224,26 @@ body {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
|
||||
// scale down the buttons on narrower screens
|
||||
// Use font-size scaling instead of zoom/transform to avoid layout gaps and preserve icon tap targets.
|
||||
// While not semantically ideal, it works well for our fixed-layout design.
|
||||
@media (max-width: 550px) {
|
||||
zoom: 0.90;
|
||||
font-size: 0.90em;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
zoom: 0.80;
|
||||
font-size: 0.80em;
|
||||
}
|
||||
|
||||
@media (max-width: 450px) {
|
||||
zoom: 0.70;
|
||||
font-size: 0.70em;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
zoom: 0.60;
|
||||
font-size: 0.60em;
|
||||
}
|
||||
|
||||
@media (max-width: 350px) {
|
||||
zoom: 0.50;
|
||||
font-size: 0.50em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +354,6 @@ body {
|
||||
// background-image: none;
|
||||
width: unset;
|
||||
height: unset;
|
||||
transform-origin: unset;
|
||||
}
|
||||
|
||||
#loading {
|
||||
@@ -399,7 +427,8 @@ body {
|
||||
|
||||
label {
|
||||
display: block;
|
||||
max-width: 300px;
|
||||
max-width: fit-content;
|
||||
cursor: pointer;
|
||||
|
||||
.alert {
|
||||
display: none;
|
||||
@@ -414,6 +443,13 @@ body {
|
||||
|
||||
#divTwcBottom img {
|
||||
transform: scale(0.75);
|
||||
|
||||
// Make icons larger in widescreen mode on mobile
|
||||
@media (max-width: 550px) {
|
||||
.wide & {
|
||||
transform: scale(1.0); // Larger icons in widescreen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#divTwc:fullscreen,
|
||||
@@ -446,9 +482,7 @@ body {
|
||||
|
||||
.kiosk {
|
||||
#divTwc #divTwcBottom {
|
||||
>div {
|
||||
display: none;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,15 +802,15 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Hide instructions in kiosk mode (higher specificity than the show rule)
|
||||
body.kiosk #loading .instructions {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.kiosk {
|
||||
|
||||
#divQuery,
|
||||
>.info,
|
||||
>.related-links,
|
||||
>.heading,
|
||||
#enabledDisplays,
|
||||
#settings,
|
||||
#divInfo {
|
||||
display: none;
|
||||
// In kiosk mode, hide everything except the main weather display
|
||||
>*: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 .progress {
|
||||
@include u.text-shadow();
|
||||
@@ -13,6 +13,7 @@
|
||||
box-sizing: border-box;
|
||||
height: 310px;
|
||||
overflow: hidden;
|
||||
line-height: 28px;
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
@@ -117,4 +118,4 @@
|
||||
transition: width 1s steps(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 .main.travel {
|
||||
&.main {
|
||||
@@ -8,14 +8,11 @@
|
||||
.column-headers {
|
||||
background-color: c.$column-header;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.column-headers {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
z-index: 5;
|
||||
overflow: hidden; // prevent thin gaps between header and content
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
@@ -100,4 +97,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -94,11 +94,13 @@
|
||||
|
||||
&.has-scroll {
|
||||
width: 640px;
|
||||
margin-top: 0;
|
||||
height: 310px;
|
||||
overflow: hidden;
|
||||
|
||||
&.no-header {
|
||||
height: 400px;
|
||||
margin-top: 0; // Reset for no-header case since the gap issue is header-related
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +131,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Remove margins for hazard scrolls to maximize text space
|
||||
&.hazard .fixed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.scroll-header {
|
||||
height: 26px;
|
||||
font-family: "Star4000 Small";
|
||||
@@ -150,4 +158,4 @@
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* REGULAR SCANLINES SETTINGS */
|
||||
|
||||
// width of 1 scanline (min.: 1px)
|
||||
// width of 1 scanline (responsive units to prevent banding)
|
||||
$scan-width: 1px;
|
||||
$scan-width-scaled: 0.15vh; // viewport-relative unit for better scaling
|
||||
|
||||
// emulates a damage-your-eyes bad pre-2000 CRT screen ♥ (true, false)
|
||||
$scan-crt: false;
|
||||
@@ -75,18 +76,41 @@ $scan-opacity: .75;
|
||||
@include scan-moving($scan-moving-line);
|
||||
}
|
||||
|
||||
// the scanlines, so!
|
||||
// the scanlines, so! - with responsive scaling for low-res displays
|
||||
&:after {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: $scan-z-index;
|
||||
background: linear-gradient(to bottom,
|
||||
transparent 50%,
|
||||
$scan-color 51%);
|
||||
background-size: 100% $scan-width*2;
|
||||
// repeating-linear-gradient is more efficient than linear-gradient+background-size because it doesn't require the browser to calculate tiling
|
||||
background: repeating-linear-gradient(to bottom,
|
||||
transparent 0,
|
||||
transparent $scan-width,
|
||||
$scan-color $scan-width,
|
||||
$scan-color calc($scan-width * 2));
|
||||
@include scan-crt($scan-crt);
|
||||
|
||||
// Prevent sub-pixel aliasing on scaled displays
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
// Scanlines use dynamic thickness calculated by JavaScript
|
||||
// JavaScript calculates optimal thickness to prevent banding at any scale factor
|
||||
// The --scanline-thickness custom property is set by applyScanlineScaling()
|
||||
// The modes (hairline, thin, medium, thick) force the base thickness selection
|
||||
// Some modes may appear the same (e.g. hairline and thin) depending on the display
|
||||
&:before {
|
||||
height: var(--scanline-thickness, $scan-width);
|
||||
}
|
||||
|
||||
&:after {
|
||||
background: repeating-linear-gradient(to bottom,
|
||||
transparent 0,
|
||||
transparent var(--scanline-thickness, $scan-width),
|
||||
$scan-color var(--scanline-thickness, $scan-width),
|
||||
$scan-color calc(var(--scanline-thickness, $scan-width) * 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,4 +127,4 @@ $scan-opacity: .75;
|
||||
background-position: 0 50%;
|
||||
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
static-env-handler.sh
Normal file → Executable file
35
static-env-handler.sh
Normal file → Executable file
@@ -4,22 +4,27 @@ set -eu
|
||||
ROOT="/usr/share/nginx/html"
|
||||
QS=""
|
||||
|
||||
# URL encode a string
|
||||
url_encode() {
|
||||
local string="$1"
|
||||
printf '%s' "$string" | sed 's/ /%20/g; s/"/%22/g; s/</%3C/g; s/>/%3E/g; s/&/%26/g; s/#/%23/g; s/+/%2B/g'
|
||||
}
|
||||
|
||||
# build query string from WSQS_ env vars
|
||||
for var in $(env); do
|
||||
case "$var" in
|
||||
WSQS_*=*)
|
||||
key="${var%%=*}"
|
||||
val="${var#*=}"
|
||||
key="${key#WSQS_}"
|
||||
key="${key//_/-}"
|
||||
if [ -n "$QS" ]; then
|
||||
QS="$QS&${key}=${val}"
|
||||
else
|
||||
QS="${key}=${val}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
while IFS='=' read -r key val; do
|
||||
# Remove WSQS_ prefix and convert underscores to hyphens
|
||||
key="${key#WSQS_}"
|
||||
key="${key//_/-}"
|
||||
# URL encode the value
|
||||
encoded_val=$(url_encode "$val")
|
||||
if [ -n "$QS" ]; then
|
||||
QS="$QS&${key}=${encoded_val}"
|
||||
else
|
||||
QS="${key}=${encoded_val}"
|
||||
fi
|
||||
done << EOF
|
||||
$(env | grep '^WSQS_')
|
||||
EOF
|
||||
|
||||
|
||||
if [ -n "$QS" ]; then
|
||||
|
||||
@@ -21,7 +21,7 @@ page.on('console', messageFormatter);
|
||||
|
||||
const tester = async (location, testPage) => {
|
||||
// Set the address
|
||||
await testPage.type('#txtAddress', location);
|
||||
await testPage.type('#txtLocation', location);
|
||||
await setTimeout(500);
|
||||
// get the page
|
||||
await testPage.click('#btnGetLatLng');
|
||||
|
||||
252
views/index.ejs
252
views/index.ejs
@@ -14,26 +14,34 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="icon" href="images/logos/logo192.png" />
|
||||
<link rel="preload" href="playlist.json" as="fetch" crossorigin="anonymous"/>
|
||||
<meta property="og:image" content="https://weatherstar.netbymatt.com/images/social/1200x600.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="627">
|
||||
<link rel="prefetch" href="fonts/Star4000.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Extended.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Large.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Small.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="images/logos/app-icon-180.png" />
|
||||
<meta property="og:image" content="https://weatherstar.netbymatt.com/images/social/1200x600.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="627">
|
||||
<link rel="prefetch" href="fonts/Star4000.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Extended.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Large.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="prefetch" href="fonts/Star4000 Small.woff" as="font" type="font/woff" crossorigin>
|
||||
<% if (typeof serverAvailable !== 'undefined' && serverAvailable) { %>
|
||||
<script>
|
||||
window.WS4KP_SERVER_AVAILABLE = true;
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<% if (production) { %>
|
||||
<link rel="stylesheet" type="text/css" href="resources/ws.min.css?_=<%=production%>" />
|
||||
<script type="text/javascript" src="resources/data.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript" src="resources/vendor.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript" src="resources/ws.min.js?_=<%=production%>"></script>
|
||||
<script type="text/javascript">const OVERRIDES=<%-JSON.stringify(OVERRIDES)%>;</script>
|
||||
<script type="text/javascript">
|
||||
const OVERRIDES = <%- JSON.stringify(OVERRIDES ?? {}) %>;
|
||||
</script>
|
||||
<% } else { %>
|
||||
<link rel="stylesheet" type="text/css" href="styles/main.css" />
|
||||
<!--<script type="text/javascript">const OVERRIDES={};</script>-->
|
||||
<script type="text/javascript">OVERRIDES=<%-JSON.stringify(OVERRIDES)%>;</script>
|
||||
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></script>
|
||||
<!--<script type="text/javascript">const OVERRIDES={};</script>-->
|
||||
<script type="text/javascript">
|
||||
OVERRIDES = <%- JSON.stringify(OVERRIDES ?? {}) %>;
|
||||
</script>
|
||||
<script type="text/javascript" src="scripts/vendor/auto/nosleep.js"></script>
|
||||
<script type="text/javascript" src="scripts/vendor/auto/swiped-events.js"></script>
|
||||
<script type="text/javascript" src="scripts/vendor/auto/suncalc.js"></script>
|
||||
<script type="module" src="scripts/modules/hazards.mjs"></script>
|
||||
@@ -53,81 +61,78 @@
|
||||
<script type="module" src="scripts/modules/progress.mjs"></script>
|
||||
<script type="module" src="scripts/modules/radar.mjs"></script>
|
||||
<script type="module" src="scripts/modules/settings.mjs"></script>
|
||||
<script type="module" src="scripts/modules/media.mjs"></script>
|
||||
<script type="module" src="scripts/index.mjs"></script>
|
||||
<!-- data -->
|
||||
<script type="text/javascript" src="scripts/data/travelcities.js"></script>
|
||||
<script type="text/javascript" src="scripts/data/regionalcities.js"></script>
|
||||
<script type="text/javascript" src="scripts/data/stations.js"></script>
|
||||
<script type="module" src="scripts/modules/media.mjs"></script>
|
||||
<script type="module" src="scripts/modules/custom-rss-feed.mjs"></script>
|
||||
<script type="module" src="scripts/index.mjs"></script>
|
||||
<% } %>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body <% if (query && query['settings-kiosk-checkbox'] === 'true' ) { %>class="kiosk" <% }%>>
|
||||
|
||||
<div id="divQuery">
|
||||
<input id="txtLocation" type="text" value="" placeholder="ZIP Code or City, State" data-1p-ignore />
|
||||
<div class="buttons">
|
||||
<button id="btnGetGps" type="button" title="Get GPS Location"><img src="images/nav/ic_gps_fixed_black_18dp_1x.png" class="light" />
|
||||
<img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark" />
|
||||
</button>
|
||||
<button id="btnGetLatLng" type="submit">GO</button>
|
||||
<button id="btnClearQuery" type="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="version" style="display:none">
|
||||
<%- version %>
|
||||
</div>
|
||||
|
||||
<div id="divQuery">
|
||||
<input id="txtAddress" type="text" value="" placeholder="Zip or City, State" />
|
||||
<div class="buttons">
|
||||
<button id="btnGetGps" type="button" title="Get GPS Location"><img src="images/nav/ic_gps_fixed_black_18dp_1x.png"
|
||||
class="light" />
|
||||
<img src="images/nav/ic_gps_fixed_white_18dp_1x.png" class="dark" />
|
||||
</button>
|
||||
<button id="btnGetLatLng" type="submit">GO</button>
|
||||
<button id="btnClearQuery" type="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="version" style="display:none">
|
||||
<%- version %>
|
||||
</div>
|
||||
|
||||
<div id="divTwc">
|
||||
<div id="container">
|
||||
<div id="loading" width="640" height="480">
|
||||
<div>
|
||||
<div class="title">WeatherStar 4000+</div>
|
||||
<div class="version">v<%- version %></div>
|
||||
<div class="instructions">Enter your location above to continue</div>
|
||||
<div id="divTwc">
|
||||
<div id="divTwcMain">
|
||||
<div id="container">
|
||||
<div id="loading" width="640" height="480">
|
||||
<div>
|
||||
<div class="title">WeatherStar 4000+</div>
|
||||
<div class="version">v<%- version %></div>
|
||||
<div class="instructions">Enter your location above to continue</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="progress-html" class="weather-display">
|
||||
<%- include('partials/progress.ejs') %>
|
||||
</div>
|
||||
<div id="hourly-html" class="weather-display">
|
||||
<%- include('partials/hourly.ejs') %>
|
||||
</div>
|
||||
<div id="hourly-graph-html" class="weather-display">
|
||||
<%- include('partials/hourly-graph.ejs') %>
|
||||
</div>
|
||||
<div id="travel-html" class="weather-display">
|
||||
<%- include('partials/travel.ejs') %>
|
||||
</div>
|
||||
<div id="current-weather-html" class="weather-display">
|
||||
<%- include('partials/current-weather.ejs') %>
|
||||
</div>
|
||||
<div id="local-forecast-html" class="weather-display">
|
||||
<%- include('partials/local-forecast.ejs') %>
|
||||
</div>
|
||||
<div id="latest-observations-html" class="weather-display">
|
||||
<%- include('partials/latest-observations.ejs') %>
|
||||
</div>
|
||||
<div id="regional-forecast-html" class="weather-display">
|
||||
<%- include('partials/regional-forecast.ejs') %>
|
||||
</div>
|
||||
<div id="almanac-html" class="weather-display">
|
||||
<%- include('partials/almanac.ejs') %>
|
||||
</div>
|
||||
<div id="spc-outlook-html" class="weather-display">
|
||||
<%- include('partials/spc-outlook.ejs') %>
|
||||
</div>
|
||||
<div id="extended-forecast-html" class="weather-display">
|
||||
<%- include('partials/extended-forecast.ejs') %>
|
||||
</div>
|
||||
<div id="radar-html" class="weather-display">
|
||||
<%- include('partials/radar.ejs') %>
|
||||
</div>
|
||||
<div id="hazards-html" class="weather-display">
|
||||
<%- include('partials/hazards.ejs') %>
|
||||
</div>
|
||||
</div>
|
||||
<div id="progress-html" class="weather-display">
|
||||
<%- include('partials/progress.ejs') %>
|
||||
</div>
|
||||
<div id="hourly-html" class="weather-display">
|
||||
<%- include('partials/hourly.ejs') %>
|
||||
</div>
|
||||
<div id="hourly-graph-html" class="weather-display">
|
||||
<%- include('partials/hourly-graph.ejs') %>
|
||||
</div>
|
||||
<div id="travel-html" class="weather-display">
|
||||
<%- include('partials/travel.ejs') %>
|
||||
</div>
|
||||
<div id="current-weather-html" class="weather-display">
|
||||
<%- include('partials/current-weather.ejs') %>
|
||||
</div>
|
||||
<div id="local-forecast-html" class="weather-display">
|
||||
<%- include('partials/local-forecast.ejs') %>
|
||||
</div>
|
||||
<div id="latest-observations-html" class="weather-display">
|
||||
<%- include('partials/latest-observations.ejs') %>
|
||||
</div>
|
||||
<div id="regional-forecast-html" class="weather-display">
|
||||
<%- include('partials/regional-forecast.ejs') %>
|
||||
</div>
|
||||
<div id="almanac-html" class="weather-display">
|
||||
<%- include('partials/almanac.ejs') %>
|
||||
</div>
|
||||
<div id="spc-outlook-html" class="weather-display">
|
||||
<%- include('partials/spc-outlook.ejs') %>
|
||||
</div>
|
||||
<div id="extended-forecast-html" class="weather-display">
|
||||
<%- include('partials/extended-forecast.ejs') %>
|
||||
</div>
|
||||
<div id="radar-html" class="weather-display">
|
||||
<%- include('partials/radar.ejs') %>
|
||||
</div>
|
||||
<div id="hazards-html" class="weather-display">
|
||||
<%- include('partials/hazards.ejs') %>
|
||||
</div>
|
||||
</div>
|
||||
<div id="divTwcBottom">
|
||||
@@ -141,55 +146,56 @@
|
||||
<img id="NavigateRefresh" class="navButton" src="images/nav/ic_refresh_white_24dp_2x.png" title="Refresh" />
|
||||
</div>
|
||||
<div id="divTwcBottomRight">
|
||||
<div id="ToggleMedia">
|
||||
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
|
||||
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" />
|
||||
</div>
|
||||
<div id="ToggleScanlines">
|
||||
<img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" />
|
||||
<img class="navButton on" src="images/nav/ic_scanlines_on_white_24dp_2x.png" title="Scan lines off" />
|
||||
</div>
|
||||
<div id="ToggleMedia">
|
||||
<img class="navButton off" src="images/nav/ic_volume_off_white_24dp_2x.png" title="Unmute" />
|
||||
<img class="navButton on" src="images/nav/ic_volume_on_white_24dp_2x.png" title="Mute" />
|
||||
</div>
|
||||
<div id="ToggleScanlines">
|
||||
<img class="navButton off" src="images/nav/ic_scanlines_off_white_24dp_2x.png" title="Scan lines on" />
|
||||
<img class="navButton on" src="images/nav/ic_scanlines_on_white_24dp_2x.png" title="Scan lines off" />
|
||||
</div>
|
||||
<img id="ToggleFullScreen" class="navButton" src="images/nav/ic_fullscreen_white_24dp_2x.png" title="Enter Fullscreen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<div class="content-wrapper">
|
||||
<br />
|
||||
|
||||
<div class="info">
|
||||
<a href="https://github.com/netbymatt/ws4kp#weatherstar-4000">More information</a>
|
||||
<div class="info">
|
||||
<a href="https://github.com/netbymatt/ws4kp#weatherstar-4000">More information</a>
|
||||
</div>
|
||||
<div class="media"></div>
|
||||
|
||||
<div class='heading'>Selected displays</div>
|
||||
<div id='enabledDisplays'>
|
||||
|
||||
</div>
|
||||
|
||||
<div class='heading'>Settings</div>
|
||||
<div id='settings'>
|
||||
</div>
|
||||
|
||||
<div class='heading'>Sharing</div>
|
||||
<div class='info'>
|
||||
<a href='' id='share-link'>Copy Permalink</a> <span id="share-link-copied">Link copied to clipboard!</span>
|
||||
<div id="share-link-instructions">
|
||||
Copy this long URL:
|
||||
<input type='text' id="share-link-url">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='heading'>Forecast 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 />
|
||||
Music: <span id="musicTrack">Not playing</span><br />
|
||||
Ws4kp Version: <span><%- version %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media"></div>
|
||||
|
||||
<div class='heading'>Selected displays</div>
|
||||
<div id='enabledDisplays'>
|
||||
|
||||
</div>
|
||||
|
||||
<div class='heading'>Settings</div>
|
||||
<div id='settings'>
|
||||
</div>
|
||||
|
||||
<div class='heading'>Sharing</div>
|
||||
<div class='info'>
|
||||
<a href='' id='share-link'>Copy Permalink</a> <span id="share-link-copied">Link copied to clipboard!</span>
|
||||
<div id="share-link-instructions">
|
||||
Copy this long URL:
|
||||
<input type='text' id="share-link-url">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='heading'>Forecast 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 />
|
||||
Music: <span id="musicTrack">Not playing</span><br />
|
||||
Ws4kp Version: <span><%- version %></span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
<%- include('header.ejs', {title:'Almanac', hasTime: true}) %>
|
||||
<div class="main has-scroll almanac">
|
||||
<div class="sun">
|
||||
<div class="days">
|
||||
<div class="day"></div>
|
||||
<div class="day day-1">Monday</div>
|
||||
<div class="day day-2">Tuesday</div>
|
||||
</div>
|
||||
<div class="times times-1">
|
||||
<div class="name">Sunrise:</div>
|
||||
<div class="sun-time rise-1">6:24 am</div>
|
||||
<div class="sun-time rise-2">6:25 am</div>
|
||||
</div>
|
||||
<div class="times times-2">
|
||||
<div class="name">Sunset:</div>
|
||||
<div class="sun-time set-1">6:24 am</div>
|
||||
<div class="sun-time set-2">6:25 am</div>
|
||||
</div>
|
||||
<div class="grid-item empty"></div>
|
||||
<div class="grid-item header day-1"></div>
|
||||
<div class="grid-item header day-2"></div>
|
||||
<div class="grid-item row-label">Sunrise:</div>
|
||||
<div class="grid-item time rise-1"></div>
|
||||
<div class="grid-item time rise-2"></div>
|
||||
<div class="grid-item row-label">Sunset:</div>
|
||||
<div class="grid-item time set-1"></div>
|
||||
<div class="grid-item time set-2"></div>
|
||||
</div>
|
||||
<div class="moon">
|
||||
<div class="title">Moon Data:</div>
|
||||
@@ -28,4 +22,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('scroll.ejs') %>
|
||||
<%- include('scroll.ejs') %>
|
||||
|
||||
@@ -1,72 +1,85 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"search.exclude": {
|
||||
"**/*.code-search": true,
|
||||
"**/*.css": true,
|
||||
"**/*.min.js": true,
|
||||
"**/bower_components": true,
|
||||
"**/node_modules": true,
|
||||
"**/vendor": true,
|
||||
"dist/**": true
|
||||
},
|
||||
"cSpell.enabledFileTypes": {
|
||||
"markdown": true,
|
||||
"JavaScript": true
|
||||
},
|
||||
"cSpell.enabled": true,
|
||||
"cSpell.ignoreWords": [
|
||||
"'storm",
|
||||
"arcgis",
|
||||
"Battaglia",
|
||||
"devbridge",
|
||||
"gifs",
|
||||
"ltrim",
|
||||
"mbar",
|
||||
"Noaa",
|
||||
"nosleep",
|
||||
"Pngs",
|
||||
"PRECIP",
|
||||
"rtrim",
|
||||
"sonarjs",
|
||||
"T",
|
||||
"T'storm",
|
||||
"uscomp",
|
||||
"Visib",
|
||||
"Waukegan",
|
||||
"WSQS",
|
||||
"Tucsan",
|
||||
"Malek",
|
||||
"mwood",
|
||||
"unmuted",
|
||||
"dumpio",
|
||||
"mesonet"
|
||||
],
|
||||
"cSpell.ignorePaths": [
|
||||
"**/package-lock.json",
|
||||
"**/node_modules/**",
|
||||
"**/vscode-extension/**",
|
||||
"**/.git/objects/**",
|
||||
".vscode",
|
||||
".vscode-insiders",
|
||||
"**/vendor/auto/**",
|
||||
],
|
||||
"editor.tabSize": 2,
|
||||
"emmet.includeLanguages": {
|
||||
"ejs": "html",
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "j69.ejs-beautify"
|
||||
},
|
||||
"files.exclude": {},
|
||||
"files.eol": "\n",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
},
|
||||
}
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"search.exclude": {
|
||||
"**/*.code-search": true,
|
||||
"**/*.css": true,
|
||||
"**/*.min.js": true,
|
||||
"**/bower_components": true,
|
||||
"**/node_modules": true,
|
||||
"**/vendor": true,
|
||||
"dist/**": true
|
||||
},
|
||||
"cSpell.enabledFileTypes": {
|
||||
"markdown": true,
|
||||
"JavaScript": true
|
||||
},
|
||||
"cSpell.enabled": true,
|
||||
"cSpell.ignoreWords": [
|
||||
"'storm",
|
||||
"arcgis",
|
||||
"Battaglia",
|
||||
"devbridge",
|
||||
"gifs",
|
||||
"ltrim",
|
||||
"mbar",
|
||||
"Noaa",
|
||||
"nosleep",
|
||||
"Pngs",
|
||||
"PRECIP",
|
||||
"rtrim",
|
||||
"sonarjs",
|
||||
"T",
|
||||
"T'storm",
|
||||
"uscomp",
|
||||
"Visib",
|
||||
"Waukegan",
|
||||
"WSQS",
|
||||
"Tucsan",
|
||||
"Malek",
|
||||
"mwood",
|
||||
"unmuted",
|
||||
"dumpio",
|
||||
"mesonet"
|
||||
],
|
||||
"cSpell.ignorePaths": [
|
||||
"**/package-lock.json",
|
||||
"**/node_modules/**",
|
||||
"**/vscode-extension/**",
|
||||
"**/.git/objects/**",
|
||||
".vscode",
|
||||
".vscode-insiders",
|
||||
"**/vendor/auto/**",
|
||||
],
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": false, // .eslintrc wants tabs instead of spaces
|
||||
"emmet.includeLanguages": {
|
||||
"ejs": "html",
|
||||
},
|
||||
"[ejs]": {
|
||||
"editor.occurrencesHighlight": "off",
|
||||
"editor.defaultFormatter": "j69.ejs-beautify"
|
||||
},
|
||||
"files.exclude": {},
|
||||
"files.eol": "\n",
|
||||
"files.associations": {
|
||||
"*.ejs": "ejs"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"recommendations": [
|
||||
"digitalbrainstem.javascript-ejs-support", // EJS (Embedded JavaScript) template language support
|
||||
"dbaeumer.vscode-eslint", // ESLint JavaScript linting integration
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"j69.ejs-beautify",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user