Compare commits

...

72 Commits

Author SHA1 Message Date
Matt Walsh
ea58b5a9c8 6.0.1 2025-08-05 22:51:15 -05:00
Matt Walsh
4bf725413b fix star4000 large font 2025-08-05 22:50:40 -05:00
Matt Walsh
75eb81887f Clean up build around metar parser and locale 2025-08-04 12:03:18 -05:00
Matt Walsh
9b37bc5c52 6.0.0 2025-08-03 22:10:41 -05:00
Matt Walsh
8a22e23d5a Merge remote-tracking branch 'eddyg/station-name-improvements' into code-refactor 2025-08-03 22:10:17 -05:00
Matt Walsh
0d508d7f50 5.27.3 2025-07-30 22:29:25 -05:00
Matt Walsh
d85a5ed3b1 add strings to permalink structure close #129 2025-07-30 22:29:20 -05:00
Matt Walsh
831e1680e9 Merge pull request #128 from jacroe/feature/94-drabbit
Add "No Data" icon
2025-07-30 22:05:09 -05:00
Jacob Roeland
73cbc0aa81 Add "No Data" icon
Supports #94
2025-07-25 21:23:23 -04:00
Eddy G
eb412a0cae Add intelligent station name processor
This is intended to replace the simple regex truncation (`city.match(/[^,/;\\-]*/)[0].substr(0, 12)`)
with a file that has all the station name processing already pre-processed by the logic in the stations
post-processor script. It uses priority-based conflict resolution to avoid duplicate station names by
using airport classification hierarchy. It handles complex naming patterns including military facilities,
county airports, hyphenated cities, slash/comma separators, and "at/near" qualifiers with a minium number
of exceptions.

This changes station data pipeline to generate stations-raw.json first, then process through the
post-processor to create the final stations.json compatible with existing codebase.
2025-07-17 16:10:34 -04:00
Matt Walsh
9150d42802 5.27.2 2025-07-15 22:13:04 -05:00
Matt Walsh
54257e4667 add new naming issue template 2025-07-15 22:07:52 -05:00
Matt Walsh
7d50ce28bd Allow for station data overrides #125 2025-07-15 22:00:33 -05:00
Eddy G
c3d863f89f Fixup mapclick.mjs 2025-07-13 22:45:04 -04:00
Eddy G
996baa78aa Add MapClick adapter and "fallback" logic when observations are stale
- Create utils/mapclick.mjs with centralized MapClick API functionality
- Refactor modules to use the new utility:
  - Current Weather
  - Latest Observations
  - Regional Forecast
- Add staleness checking utility for use by modules
2025-07-13 22:10:41 -04:00
Eddy G
e81c957416 Remove legacy mapclick icon references
- https://api.weather.gov/icons should be the only icons used by api.weather.gov
2025-07-13 19:45:52 -04:00
Eddy G
d25a632f7d Switch back to using j69.ejs-beautify
- Reformat index.mjs
- Don't preload playlist.json to avoid Safari warning about
  unused preloaded resource
2025-07-13 19:45:52 -04:00
Eddy G
4b34ffabcb Fix retry count display in doFetch 2025-07-13 19:45:52 -04:00
Matt Walsh
2db7f30de7 5.27.1 2025-07-11 22:36:57 -05:00
Matt Walsh
5c7a6ab1a4 fix for rss feed encoding types close #124 2025-07-11 22:36:47 -05:00
Eddy G
ee4f84689a Add rule to allow devDependencies in dev-related config files 2025-07-07 12:51:09 -04:00
Eddy G
804d9e9e33 Centralize icon URL parsing; improve icon error handling
- Move common icon parsing logic into module
- Return a "real" icon value during error handling to avoid downstream
  consumers from trying to use an icon named "false"
- Use named regex to parse icon URLs based on API specification
2025-07-07 12:43:32 -04:00
Eddy G
3e8135a36a Improve settings initialization timing with deferred DOM updates
Defer DOM updates to ensure settings are properly applied
even when read from localStorage before the DOM is available.
2025-07-07 12:43:32 -04:00
Eddy G
9c5ed0dcca Improve error handling to help prevent runtime errors
Adds input validation and safe property access to utility functions
to handle edge cases and invalid arguments gracefully
2025-07-07 12:43:32 -04:00
Eddy G
a3c581aa93 Clarify instructions for iOS/iPadOS and Android 2025-07-07 12:43:32 -04:00
Eddy G
771ab37eaf Improve cache error handling in various edge cases
- Add null-safe operations and fallbacks for missing properties
- Simplify retry logic and enhance error logging
- Enable default retries and improve timeout handling
2025-07-07 12:43:32 -04:00
Matt Walsh
4b63328b74 update dependencies 2025-07-06 10:54:20 -05:00
Matt Walsh
ae1d004f60 Merge pull request #123 from rmitchellscott/fix-static-envs
fix: url encode envs in static-env-handler. Fixes #122.
2025-07-06 10:36:51 -05:00
Eddy G
2a975d4d6d Fix auto-play logic for sticky kiosk mode
The previous logic only checked URL parameters for kiosk mode,
but sticky kiosk can enable kiosk mode from localStorage without
URL parameters. Now checks actual kiosk setting value.

Fix typo in scan line settings name.
2025-07-04 14:55:57 -04:00
Mitchell Scott
7dd4c1dd24 fix: url encode envs in static-env-handler. Fixes #122. 2025-07-04 04:41:56 -06:00
Eddy G
10baefc901 Merge remote-tracking branch 'upstream/main' into modernization-and-refactor 2025-07-02 09:08:07 -04:00
Eddy G
46edf1f7e2 Add "Settings" section to README 2025-07-02 08:44:12 -04:00
Eddy G
67dfd7ec08 Add "Sticky Kiosk" setting that stores "Kiosk" mode when activate
- This setting is important to allow creation ofa Home Screen app on iOS/iPadOS
- If kiosk mode is accidentally made sticky, it can be cleared by adding '&kiosk=false` to the URL
- Ctrl-K will now also exit kiosk mode

This adds a `stickyRead` parameter to settings, that means "read it if it's there, but don't write it"
2025-07-02 08:11:52 -04:00
Eddy G
2761f76117 Build CSS changes 2025-07-02 08:11:52 -04:00
Eddy G
13621b6f46 Stop Mobile Safari from trying to auto-fill the location box
This change prevents the "No items to show" popup that obscures the first
couple suggested matches.
2025-07-02 08:11:52 -04:00
Eddy G
b49433f5ff Add responsive scaling; improve scanlines and Mobile Safari support
- Replace CSS zoom with CSS transform scaling for better mobile compatibility
- Implement wrapper-based scaling approach that includes both content and navigation bar
- Replace Almanac layout with CSS Grid for better cross-browser layout
- Greatly improve scanline algorithm to handle a wide variety of displays
- Add setting to override automatic scanlines to user-specified scale factor
- Remove scanline scaling debug functions
- Refactor settings module: initialize settings upfront and improve change handler declarations
- Enhance scanline SCSS with repeating-linear-gradient for better performance
- Add app icon for iOS/iPadOS
- Add 'fullscreen' event listener
- De-bounce 'resize' event listener
- Add 'orientationchange' event listener
- Implement three resize scaling algorithms:
  - Baseline (when no scaling is needed, like on the index page)
  - Mobile scaling (except Mobile Safari kiosk mode)
  - Mobile Safari kiosk mode (using manual offset calculations)
  - Standard fullscreen/kiosk mode (using CSS centering)
2025-07-02 00:22:06 -04:00
Matt Walsh
1120247c99 include custom rss feed in build #57 2025-06-30 23:32:30 -05:00
Matt Walsh
c5c01e5450 5.27.0 2025-06-30 23:29:46 -05:00
Matt Walsh
0a65221905 update readme for custom rss close #57 2025-06-30 23:29:39 -05:00
Eddy G
cc9e613ba7 Merge remote-tracking branch 'upstream/main' into modernization-and-refactor 2025-06-29 22:42:03 -04:00
Matt Walsh
9f9667c895 add single text scroll option #57 2025-06-28 09:20:36 -05:00
Matt Walsh
fda44e95fc rss feeds scroll, needs additional testing #57 2025-06-28 00:59:40 -05:00
Matt Walsh
945c12e6c6 parse rss feed #57 2025-06-28 00:29:55 -05:00
Matt Walsh
0fde88cd8f restructure current weather scroll to allow add/remove of rss feed #57 2025-06-28 00:29:47 -05:00
Eddy G
b1c4e6d850 Merge remote-tracking branch 'upstream/main' into modernization-and-refactor 2025-06-27 18:21:32 -04:00
Eddy G
90c1ab92b4 Merge upstream/main: integrate radar worker removal and improvements
- Remove radar-worker.mjs and integrate functionality into radar-processor.mjs
- Update package.json and gulpfile.mjs with upstream changes
2025-06-27 11:05:32 -04:00
Eddy G
17585e97c4 Update README to clarify deployment modes and music handling
- Add clear distinction between Server and Static deployment modes
- Restructure run instructions with specific npm commands and their purposes
- Clarify Docker deployment scenarios (static vs server, with examples)
- Rewrite music section to explain server-side vs browser-side playlist generation
2025-06-26 20:39:18 -04:00
Eddy G
517cafe40a Refactor data loading: move from inline JSON to client-side fetch
- Remove large JSON data injection from EJS templates
- Add client-side data-loader utility with cache-busting support
- Create server endpoints for JSON data with long-term caching
- Add graceful failure handling if core data fails to load
- Copy JSON data files to dist/data for static hosting
- Update app initialization to load data asynchronously
- Set serverAvailable flag for static builds in gulp task

This reduces HTML payload size and enables better caching strategies
for both server and static deployment modes.
2025-06-26 20:10:15 -04:00
Eddy G
7f7cb96231 Add STATIC environment variable for browser-only deployment mode
Implement STATIC=1 environment variable to enable browser-only deployment
without proxy server infrastructure. Uses WS4KP_SERVER_AVAILABLE flag to
distinguish between server-backed and static deployments for proper URL
rewriting and User-Agent header handling.

- Add STATIC env var to skip proxy route registration at startup
- Inject WS4KP_SERVER_AVAILABLE flag via EJS template based on STATIC mode
- Update fetch.mjs to conditionally send User-Agent headers based on server availability
- Update url-rewrite.mjs to skip proxy rewriting when server is unavailable
- Use renderIndex helper for consistent template data across dev/prod modes
- Improve music playlist logging

Benefits of integrated approach:
- Single environment variable controls both server and client behavior
- Flag injection happens once at render time, not on every request
- No runtime HTML string manipulation overhead
- Clean separation between server-backed and static deployment logic
- Same codebase supports both deployment modes without duplication

Static mode (STATIC=1): Direct API calls to external services, no caching
Server mode (default): Local proxy with caching and API request observability
2025-06-26 20:10:11 -04:00
Eddy G
bfd0c2b02d Add 'no-unused-vars' rule for vars starting with _ 2025-06-24 23:54:59 -04:00
Eddy G
c8b520b752 Build CSS changes 2025-06-24 23:54:23 -04:00
Eddy G
ebface1749 Add comment about not needing to use safeJson()
- Fix indentation in media.mjs
2025-06-24 23:53:44 -04:00
Eddy G
137c2f6d08 Don't set to failed if not enabled
- Allow any falsey value to indicate failure
2025-06-24 23:42:22 -04:00
Eddy G
bec80a1ebe Refactor alert/hazard system with timing and display improvements
- Replace magic numbers with seconds-based timing constants
- Switch from scrollTo() to hardware-accelerated transform
- Add scroll caching to prevent repeated DOM queries every scroll cycle
- Switch to safeJson() for centralized error handling across alert modules
- Horizontal alert scroll now goes edge-to-edge
- Integrate global speed settings into horizontal scroll timing
- Improve error handling flow with better fallback behavior for missing data
- Remvoe unused getCurrentData() function in hazards.mjs
- Move background color from scrolling element to container to avoid
    showing the underlying content when scrolling with trasnform
2025-06-24 23:41:44 -04:00
Eddy G
e34137e430 Refactor timing calculations and improve scroll performance
- Replace magic numbers with seconds-based timing constants
- Switch from scrollTo() to hardware-accelerated transform for smooth scrolling
- Add scroll caching to prevent repeated DOM queries every scroll cycle
- Fix calculations to support flexible hourly forecast lengths
    (i.e. in the future, could offer every other hour)
- Switch to safeJson() for centralized error handling
2025-06-24 23:40:07 -04:00
Eddy G
c0e2eaf33a Refactor timing calculations and improve scroll performance
- Replace magic numbers with seconds-based timing constants
- Switch from scrollTo() to hardware-accelerated transform
- Add scroll caching to prevent repeated  DOM queries every scroll cycle
- Fix calculations to allow an arbitrarily-sized list of travel forecast cities
- Switch to safeJson() and safePromiseAll() for centralized error handling
2025-06-24 23:39:40 -04:00
Eddy G
975bccdac5 Switch from json() to safeJson() for centralized error handling
- Prevent infinitie recursion by ensuring the same display is not
    selected in loadDisplay()
2025-06-24 23:37:47 -04:00
Eddy G
8ead95c041 Improve error handling and API efficiency
- Switch to safe*() methods for centralized error handling
- Add error handling and validation
- Optimize radar API usage by only fetching yesterday's data when needed
- Use centralized URL rewriting for caching proxy support
- Add debug logging throughout radar processing pipeline
- Improve canvas context validation and error recovery
- Handle worker errors gracefully by setting totalScreens = 0 to skip in animation
- Remove unused OVERRIDES parameter passing to workers
2025-06-24 23:35:41 -04:00
Eddy G
8f34aa5139 Improve error handling
- Use safeJson() and safePromiseAll() for centralized error handling
- Enhance logging with structured debug flags
2025-06-24 23:10:52 -04:00
Eddy G
e472b99b44 Improve SPC Outlook data fetching and error handling
- Switch to safeJson() and safePromiseAll() for centralized error handling
- Only set failed state if enabled
2025-06-24 23:09:18 -04:00
Eddy G
09a21e6f5a Add content-aware transition timing; remove expired forecasts
- DOM-based measurement system for accurate forecast lines
- Replace fixed-timing with dynamic timing based on actual forecast lines
- Filter out expired forecasts
- Improve error handling and only set failed state if enabled
- Debug logging for timing calculations and content measurement
- Switch from json() to safeJson() for centralized error handling
2025-06-24 23:08:25 -04:00
Eddy G
dd680c61b0 Enhance extended forecast parsing and error handling
- Add module for expired period filtering
- Switch from json() to safeJson() for centralized error handling
- Improve nighttime period handling to focus on full days
- Fix day/night temperature pairing logic
- Add debug logging
2025-06-24 23:07:47 -04:00
Eddy G
79de691eef Augment missing weather data from METAR when possible; use centralized error handling
- Add utility function to augment missing weather observation data from METAR
- Switch from json() to safeJson() for centralized error handling
- Data quality validation and age checks
- Add null/undefined value handling for wind direction calculations
2025-06-24 23:05:51 -04:00
Eddy G
ec83c17ae2 Add 'metar-taf-parser' npm dependency
- Adds METAR (Meteorological Aerodrome Report) parsing capabilities
- metar-taf-parser has no dependencies, so impact is small
2025-06-24 22:58:51 -04:00
Eddy G
5630067530 Fix auto-refresh to use weather parameters; add timing debug logging
- Fixes issue where auto-refresh could fail due to missing weather parameters
2025-06-24 22:56:08 -04:00
Eddy G
506ac1f4df Add 'start'/'stop' and more files to lint targets 2025-06-24 22:54:37 -04:00
Eddy G
0e0ea3c378 Add debug flag management system
- Default debug flags settable by query param or env variable
- Allows comma-separated list of debug flags
- Runtime modification (enable/disable/set) via console
- Support "all" flag for enabling/disabling everything
2025-06-24 22:53:25 -04:00
Eddy G
bf65b8e426 Update VS Code settings
- "eslint.validate": [ "javascript" ] is no longer needed
    (Modern versions of the ESLint VS Code extensions
    automatically validate JavaScript files by default.)
- Configure editor to use tabs instead of spaces per ESLint rules
- Switch to VS Code's built-in HTML formatter
- Add EJS file associations for proper syntax highlighting
- Add recommended extensions for development workflow
2025-06-24 22:52:59 -04:00
Eddy G
ca272de8bf Replace separate .js data files with server-side JSON injection
- Remove need to maintain duplicate .js data files alongside JSON sources
- Load JSON data once at server startup and inject into templates
- Dev and production modes use the same data sources

Removes:
- server/scripts/data/{travelcities,regionalcities,stations}.js
- gulp compressJsData task for bundling data files
2025-06-24 22:51:09 -04:00
Eddy G
65944dc3b5 Add comprehensive responsive scanline scaling system with anti-aliasing
- Attempt pixel-perfect scanline rendering for scaled displays and zoom scenarios
- Implement dynamic scanline thickness calculation to prevent sub-pixel rendering issues
- Add enhanced kiosk detection via isKioskLike for better fullscreen optimization
- Optimize scanlines for specific kiosk resolutions (1024x768, 1023x767)
- Add responsive SCSS media queries for different display densities
- Include extensive debugging utilities for scanline troubleshooting
- Improve noSleep error handling with proper promise rejection handling
- Update to modern fullscreen API method names
- Add async/await error handling for fullscreen requests
- Trigger resize after fullscreen engagement to apply optimal scaling
2025-06-24 22:38:25 -04:00
Eddy G
be41d66de9 Improve kiosk mode startup experience
- Pass query parameters to EJS template for kiosk mode detection
- Add kiosk class to body when enabled via query parameter
- Simplify kiosk mode CSS to hide all elements except main weather display
- Add null checks for progress object to prevent errors in kiosk mode
- Prevent navigation errors when no suitable displays are available
2025-06-24 22:30:16 -04:00
Eddy G
7a07c67e84 Replace CORS proxy with complete server-side cache
- Replace cors/ directory and cors.mjs utility with comprehensive
    HTTP caching proxy in proxy/ directory
- Implement RFC-compliant caching with cache-control headers,
    conditional requests, and in-flight deduplication
- Centralized error handling with "safe" fetch utilities
- Add unified proxy handlers for weather.gov, SPC, radar, and mesonet APIs
- Include cache management endpoint and extensive diagnostic logging
- Migrate client-side URL rewriting from cors.mjs to url-rewrite.mjs
2025-06-24 20:45:43 -04:00
82 changed files with 28642 additions and 35322 deletions

13
.github/ISSUE_TEMPLATE/naming _issue.md vendored Normal file
View 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.

View File

@@ -17,7 +17,4 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": [
"javascript"
],
}
}

13
Dockerfile.server Normal file
View 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
View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/no-extraneous-dependencies */
import 'dotenv/config';
import {
src, dest, series, parallel,
@@ -26,11 +25,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,11 +51,6 @@ 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',
@@ -89,6 +79,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 +104,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 +122,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 +200,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, 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

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/no-extraneous-dependencies */
import { src, series, dest } from 'gulp';
import { deleteAsync } from 'del';
import rename from 'gulp-rename';
@@ -13,6 +12,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 +27,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
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,19 @@
{
"name": "ws4kp",
"version": "5.26.2",
"version": "6.0.1",
"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
View 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
View 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'],
});
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

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

View File

@@ -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',
},
},
];

View File

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

View File

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

View File

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

View File

@@ -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,14 +154,36 @@ 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);
@@ -114,11 +191,14 @@ class CurrentWeather extends WeatherDisplay {
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,17 +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;
// set wind speed of 0 as calm
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
// 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';
// 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;
};

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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,
};

View 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,
};

View File

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

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

View File

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

View 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,
};

View File

@@ -0,0 +1,155 @@
// METAR parsing utilities using metar-taf-parser library
import { parseMetar } from '../../vendor/auto/metar-taf-parser.mjs';
// eslint-disable-next-line import/extensions
import en from '../../vendor/auto/locale/en.js';
/**
* 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, { locale: en });
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;

View File

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

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

View File

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

View 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,
};

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,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 @@
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display .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;
}
}
}
}

View File

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

View File

@@ -1,5 +1,5 @@
@use 'shared/_utils'as u;
@use 'shared/_colors'as c;
@use 'shared/_utils' as u;
@use 'shared/_colors' as c;
@font-face {
font-family: "Star4000";
@@ -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;
}
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display .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);
}
}
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display .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 @@
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display {
width: 640px;
@@ -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 @@
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,72 +1,86 @@
{
"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",
"metar"
],
"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",
]
}
}