Compare commits
10 commits
1faa580b18
...
fcf17224de
| Author | SHA1 | Date | |
|---|---|---|---|
| fcf17224de | |||
| e84b7fc21f | |||
| 0d7240c707 | |||
| 437100c433 | |||
| 5f18e14631 | |||
| dd31dd5853 | |||
| c67809f62d | |||
| bb87c836b6 | |||
| 0090e8f30e | |||
| 6d82d5d9df |
22 changed files with 392 additions and 411 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
node_modules
|
||||
**/debug.log
|
||||
server/scripts/custom.js
|
||||
cache/
|
||||
|
||||
#music folder
|
||||
server/music/*
|
||||
|
|
|
|||
81
README.md
81
README.md
|
|
@ -1,34 +1,54 @@
|
|||

|
||||
|
||||
# WeatherStar 4000+
|
||||
# ws4kp-linhanced
|
||||
|
||||
A live version of this project is available at https://weatherstar.netbymatt.com
|
||||
`ws4kp-linhanced` is a Linux-focused fork of [`netbymatt/ws4kp`](https://github.com/netbymatt/ws4kp) by `markmental`.
|
||||
|
||||
## About
|
||||
|
||||
This project aims to bring back the feel of the 90s with a weather forecast that has the look and feel of The Weather Channel at that time but available in a modern way. This is by no means intended to be a perfect emulation of the WeatherStar 4000, the hardware that produced those wonderful blue and orange graphics you saw during the local forecast on The Weather Channel. If you would like a much more accurate project please see the [WS4000 Simulator](http://www.taiganet.com/). Instead, this project intends to create a simple to use interface with minimal configuration fuss. Some changes have been made to the screens available because either more or less forecast information is available today than was in the 90s. Most of these changes are captured in sections below.
|
||||
This fork keeps the classic WeatherStar-style experience and stable base architecture from the original `ws4kp` project, while selectively integrating international weather support and other practical improvements. It is not intended to be a perfect hardware emulation of the WeatherStar 4000. If you want a more exact recreation of the original hardware behavior, see the [WS4000 Simulator](http://www.taiganet.com/).
|
||||
|
||||
This fork also explicitly adopts Slackware Linux `weatherstar4k` branding as part of its mission. The goal is not broad platform neutrality. The goal is a lean, self-hostable, Linux-oriented weatherstar fork with a strong visual identity and a codebase that stays practical to maintain.
|
||||
|
||||
## About This Fork
|
||||
|
||||
This project is based on:
|
||||
|
||||
* [`netbymatt/ws4kp`](https://github.com/netbymatt/ws4kp) for the core WeatherStar implementation and the more stable upstream foundation.
|
||||
* [`mwood77/ws4kp-international`](https://github.com/mwood77/ws4kp-international) for the Open-Meteo international weather direction and some global map ideas.
|
||||
|
||||
This fork intentionally diverges from `ws4kp-international`. That project proved out the international weather concept, but this fork aims to keep a narrower scope and avoid the feature creep that can make ongoing work harder. The original `ws4kp` codebase provided a better base for that approach, so this fork builds from there and pulls in only the parts that fit.
|
||||
|
||||
## Current Direction
|
||||
|
||||
This fork is focused on:
|
||||
|
||||
* preserving the classic WeatherStar feel while staying maintainable
|
||||
* Linux-first identity and presentation
|
||||
* Open-Meteo-based international weather support for the core forecast screens
|
||||
* global radar and global regional observations on the newer map stack
|
||||
* pragmatic feature additions instead of broad platform expansion
|
||||
|
||||
## What's your motivation
|
||||
|
||||
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.
|
||||
Nostalgia, Linux affinity, and an interest in keeping a practical retro weather display alive without turning it into a sprawling platform.
|
||||
|
||||
### 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.
|
||||
This fork still keeps the original project's straightforward architecture and relatively low dependency surface so it stays approachable to modify.
|
||||
|
||||
From a learning standpoint, this codebase make use of a lot of different methods and technologies common on the internet including:
|
||||
From a learning standpoint, this codebase makes use of a lot of different methods and technologies common on the internet including:
|
||||
|
||||
* The https://api.weather.gov REST API. ([documentation](https://www.weather.gov/documentation/services-web-api)).
|
||||
* The [Open-Meteo API](https://open-meteo.com/) for core forecast data.
|
||||
* NOAA APIs and products where legacy US-only displays still require them.
|
||||
* ES 6 functionality
|
||||
* Arrow functions
|
||||
* Promises
|
||||
* Async/await and parallel loading of all forecast resources
|
||||
* Async/await and parallel loading of forecast resources
|
||||
* Classes and extensions
|
||||
* Javascript modules
|
||||
* Separation between API code and user interface code
|
||||
* Use of a modern date parsing library [luxon](https://moment.github.io/luxon/)
|
||||
* Practical API rates and static asset caching
|
||||
* Server-side proxy caching plus browser/static asset caching
|
||||
* Very straight-forward hand written HTML
|
||||
* Build system integration (Gulp, Webpack) to reduce the number of scripts that need to be loaded
|
||||
* Hand written CSS made easier to mange with SASS
|
||||
|
|
@ -38,33 +58,33 @@ From a learning standpoint, this codebase make use of a lot of different methods
|
|||
|
||||
Ensure you have Node installed.
|
||||
```bash
|
||||
git clone https://github.com/netbymatt/ws4kp.git
|
||||
cd ws4kp
|
||||
git clone <your fork url here>
|
||||
cd ws4kp-linhanced
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Open your browser and navigate to https://localhost:8080
|
||||
|
||||
## Does WeatherStar 4000+ work outside of the USA?
|
||||
## Does ws4kp-linhanced work outside of the USA?
|
||||
|
||||
Yes for the core forecast screens. The main weather flow now uses Open-Meteo so search, current conditions, hourly, local forecast, extended forecast, and almanac work internationally.
|
||||
Yes for the core forecast screens, and more than that.
|
||||
|
||||
The main weather flow now uses Open-Meteo, so search, current conditions, hourly, local forecast, extended forecast, almanac, travel forecast, radar, and regional observations all work internationally.
|
||||
|
||||
Some legacy displays still rely on [NOAA's Weather API](https://www.weather.gov/documentation/services-web-api) and remain available only for United States locations for now:
|
||||
|
||||
* Hazards
|
||||
* Latest Observations
|
||||
* Regional Forecast
|
||||
* Travel Forecast
|
||||
* SPC Outlook
|
||||
* Local Radar
|
||||
|
||||
This fork no longer treats `ws4kp-international` as a drop-in upstream. Instead, it selectively incorporates the Open-Meteo international weather work while keeping a leaner feature set and a more stable base.
|
||||
|
||||
Earlier international work on this idea was explored in a fork created by [@mwood77](https://github.com/mwood77):
|
||||
- [`ws4kp-international`](https://github.com/mwood77/ws4kp-international)
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
WeatherStar 4000+ supports two deployment modes:
|
||||
`ws4kp-linhanced` supports two deployment modes:
|
||||
|
||||
### Server Deployment (Recommended)
|
||||
|
||||
|
|
@ -80,7 +100,7 @@ WeatherStar 4000+ supports two deployment modes:
|
|||
* Browser-based caching
|
||||
* Used by: static file hosting and default `Dockerfile`
|
||||
|
||||
## Other methods to run Ws4kp
|
||||
## Other methods to run ws4kp-linhanced
|
||||
|
||||
### Development Mode (individual JS files, easier debugging)
|
||||
```bash
|
||||
|
|
@ -104,7 +124,7 @@ npm run build
|
|||
STATIC=1 DIST=1 npm start
|
||||
```
|
||||
|
||||
For all modes, access WeatherStar by going to: http://localhost:8080/
|
||||
For all modes, access `ws4kp-linhanced` by going to: http://localhost:8080/
|
||||
|
||||
### Key Differences
|
||||
|
||||
|
|
@ -185,16 +205,15 @@ STATIC=1 DIST=1 npm start # Use Express to serve (minimized) production files
|
|||
|
||||
## What's different
|
||||
|
||||
I've made several changes to this Weather Star 4000 simulation compared to the original hardware unit and the code that this was forked from.
|
||||
This fork has diverged significantly from upstream in a few important ways:
|
||||
|
||||
* Radar displays the timestamp of the image.
|
||||
* A new hour-by-hour graph of the temperature, cloud cover and precipitation chances for the next 24 hours.
|
||||
* A new hourly forecast display for the next 24 hours is available, and is shown in the style of the travel cities forecast. (off by default because it duplicates the hourly graph)
|
||||
* The SPC Outlook is shown in the style of the old air quality screen. This shows the probability of severe weather over the next 3 days at your location. SPC outlook only displays if you're within one of the highlight areas over the next 3 day. You can view the [maps](https://www.weather.gov/crh/outlooks) and pick a location within one of the risk categories to see if the screen is working for you.
|
||||
* The "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90s.
|
||||
* The original music has been replaced. More info in [Music](#music).
|
||||
* Marine forecast (tides) is not available as it is not reliably part of the new API.
|
||||
* "Flavors" are not present in this simulation. Flavors refer to the order of the weather information that was shown on the original units. Instead, the order of the displays has been fixed and a checkboxes can be used to turn on and off individual displays. The travel forecast has been defaulted to off so only local information shows for new users.
|
||||
* Core weather and forecast screens now use Open-Meteo instead of being tied entirely to weather.gov.
|
||||
* Travel Forecast has been rebuilt on region buckets with a global fallback instead of remaining a US-only NOAA-driven screen.
|
||||
* Local Radar now uses global RainViewer radar imagery on top of a cached world basemap instead of the older US-only radar path.
|
||||
* Regional Forecast has been temporarily replaced by a global `Regional Observations` map display using nearby city observations on the new map stack.
|
||||
* Latest Observations has been removed.
|
||||
* Some NOAA-based displays are still retained where there is no equivalent replacement yet, especially Hazards and SPC Outlook.
|
||||
* This fork explicitly embraces Linux and Slackware-flavored `weatherstar4k` branding instead of aiming for broad neutral presentation.
|
||||
|
||||
## Sharing a permalink (bookmarking)
|
||||
Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it, click the "Copy Permalink" (or get "Get Permalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks.
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ const mjsSources = [
|
|||
'server/scripts/modules/extendedforecast.mjs',
|
||||
'server/scripts/modules/hourly.mjs',
|
||||
'server/scripts/modules/hourly-graph.mjs',
|
||||
'server/scripts/modules/latestobservations.mjs',
|
||||
'server/scripts/modules/localforecast.mjs',
|
||||
'server/scripts/modules/radar.mjs',
|
||||
'server/scripts/modules/regionalforecast.mjs',
|
||||
|
|
|
|||
12
index.mjs
12
index.mjs
|
|
@ -5,7 +5,15 @@ import { readFile } from 'fs/promises';
|
|||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import {
|
||||
weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy, openMeteoProxy, rainViewerProxy,
|
||||
weatherProxy,
|
||||
radarProxy,
|
||||
outlookProxy,
|
||||
mesonetProxy,
|
||||
forecastProxy,
|
||||
openMeteoProxy,
|
||||
rainViewerProxy,
|
||||
arcGisServerProxy,
|
||||
arcGisServicesProxy,
|
||||
} from './proxy/handlers.mjs';
|
||||
import playlist from './src/playlist.mjs';
|
||||
import OVERRIDES from './src/overrides.mjs';
|
||||
|
|
@ -256,6 +264,8 @@ if (!process.env?.STATIC) {
|
|||
app.use('/forecast/', forecastProxy);
|
||||
app.use('/open-meteo/', openMeteoProxy);
|
||||
app.use('/rainviewer/', rainViewerProxy);
|
||||
app.use('/arcgis-server/', arcGisServerProxy);
|
||||
app.use('/arcgis-services/', arcGisServicesProxy);
|
||||
|
||||
// Playlist route is available in server mode (not in static mode)
|
||||
app.get('/playlist.json', playlist);
|
||||
|
|
|
|||
185
proxy/cache.mjs
185
proxy/cache.mjs
|
|
@ -19,18 +19,164 @@
|
|||
*/
|
||||
|
||||
import https from 'https';
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
mkdir, readdir, readFile, rm, unlink, writeFile,
|
||||
} from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Default timeout for upstream requests (matches client-side default)
|
||||
const DEFAULT_REQUEST_TIMEOUT = 15000;
|
||||
const CACHE_DIR = path.resolve('./cache');
|
||||
const STALE_TIME_LIMIT_MS = 3 * 60 * 60 * 1000;
|
||||
const PERSISTED_HOSTS = new Set([
|
||||
'api.open-meteo.com',
|
||||
'api.rainviewer.com',
|
||||
'server.arcgisonline.com',
|
||||
'services.arcgisonline.com',
|
||||
]);
|
||||
const BINARY_PERSISTED_HOSTS = new Set([
|
||||
'server.arcgisonline.com',
|
||||
'services.arcgisonline.com',
|
||||
]);
|
||||
const HOST_FALLBACK_TTLS = {
|
||||
'api.open-meteo.com': 10 * 60,
|
||||
'api.rainviewer.com': 2 * 60,
|
||||
'server.arcgisonline.com': 7 * 24 * 60 * 60,
|
||||
'services.arcgisonline.com': 7 * 24 * 60 * 60,
|
||||
};
|
||||
|
||||
class HttpCache {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.inFlight = new Map();
|
||||
this.cleanupInterval = null;
|
||||
this.hydrationPromise = this.loadPersistedEntries();
|
||||
this.startCleanup();
|
||||
}
|
||||
|
||||
static hashKey(key) {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
static getCacheFilePath(key) {
|
||||
return path.join(CACHE_DIR, `${HttpCache.hashKey(key)}.json`);
|
||||
}
|
||||
|
||||
static getCacheBodyFilePath(key) {
|
||||
return path.join(CACHE_DIR, `${HttpCache.hashKey(key)}.bin`);
|
||||
}
|
||||
|
||||
static getPersistedHost(url) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static shouldPersist(url, options, response) {
|
||||
const host = HttpCache.getPersistedHost(url);
|
||||
if (!host || !PERSISTED_HOSTS.has(host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options?.encoding === 'binary') {
|
||||
return BINARY_PERSISTED_HOSTS.has(host) && Buffer.isBuffer(response?.data);
|
||||
}
|
||||
|
||||
return typeof response?.data === 'string';
|
||||
}
|
||||
|
||||
static getHostFallbackTtl(url) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return HOST_FALLBACK_TTLS[parsedUrl.hostname] ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureHydrated() {
|
||||
await this.hydrationPromise;
|
||||
}
|
||||
|
||||
async loadPersistedEntries() {
|
||||
try {
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
const files = await readdir(CACHE_DIR);
|
||||
const now = Date.now();
|
||||
|
||||
await Promise.allSettled(files.filter((file) => file.endsWith('.json')).map(async (file) => {
|
||||
const filePath = path.join(CACHE_DIR, file);
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed?.key || !parsed?.entry) {
|
||||
await unlink(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (now > parsed.entry.expiry + STALE_TIME_LIMIT_MS) {
|
||||
if (parsed.entry.binaryBody === true) {
|
||||
await unlink(HttpCache.getCacheBodyFilePath(parsed.key)).catch(() => null);
|
||||
}
|
||||
await unlink(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.entry.binaryBody === true) {
|
||||
parsed.entry.data = await readFile(HttpCache.getCacheBodyFilePath(parsed.key));
|
||||
}
|
||||
|
||||
this.cache.set(parsed.key, parsed.entry);
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Cache load | Failed to hydrate disk cache: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
static async persistEntry(key, entry) {
|
||||
try {
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
|
||||
if (Buffer.isBuffer(entry.data)) {
|
||||
const metadata = {
|
||||
...entry,
|
||||
data: undefined,
|
||||
binaryBody: true,
|
||||
};
|
||||
await writeFile(HttpCache.getCacheBodyFilePath(key), entry.data);
|
||||
await writeFile(HttpCache.getCacheFilePath(key), JSON.stringify({ key, entry: metadata }));
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(HttpCache.getCacheFilePath(key), JSON.stringify({ key, entry }));
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Cache save | Failed to persist cache entry ${key}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
static async deletePersistedEntry(key) {
|
||||
try {
|
||||
await unlink(HttpCache.getCacheBodyFilePath(key)).catch(() => null);
|
||||
await unlink(HttpCache.getCacheFilePath(key));
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.warn(`⚠️ Cache del | Failed to delete cache entry ${key}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async clearPersistedEntries() {
|
||||
try {
|
||||
await rm(CACHE_DIR, { recursive: true, force: true });
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Cache clear| Failed to clear disk cache: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse cache-control header to extract s-maxage or max-age
|
||||
static parseCacheControl(cacheControlHeader) {
|
||||
if (!cacheControlHeader) return 0;
|
||||
|
|
@ -66,15 +212,17 @@ class HttpCache {
|
|||
|
||||
// Generate cache key from request
|
||||
static generateKey(req) {
|
||||
const path = req.path || req.url || '/';
|
||||
const requestPath = 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('?')) : ''}`;
|
||||
return `${requestPath}${url.includes('?') ? url.substring(url.indexOf('?')) : ''}`;
|
||||
}
|
||||
|
||||
// High-level method to handle caching for HTTP proxies
|
||||
async handleRequest(req, res, upstreamUrl, options = {}) {
|
||||
await this.ensureHydrated();
|
||||
|
||||
// Check cache status
|
||||
const cacheResult = this.getCachedRequest(req);
|
||||
|
||||
|
|
@ -262,7 +410,7 @@ class HttpCache {
|
|||
}
|
||||
|
||||
// Store in cache (pass original headers for cache logic, but store filtered headers)
|
||||
this.storeCachedResponse(req, response, fullUrl, getRes.headers);
|
||||
this.storeCachedResponse(req, response, fullUrl, getRes.headers, options);
|
||||
|
||||
// Send response to client
|
||||
res.status(statusCode);
|
||||
|
|
@ -358,7 +506,7 @@ class HttpCache {
|
|||
return { status: 'stale', data: cached };
|
||||
}
|
||||
|
||||
storeCachedResponse(req, response, url, originalHeaders) {
|
||||
storeCachedResponse(req, response, url, originalHeaders, options = {}) {
|
||||
const key = HttpCache.generateKey(req);
|
||||
|
||||
const cacheControl = (originalHeaders || {})['cache-control'];
|
||||
|
|
@ -376,6 +524,13 @@ class HttpCache {
|
|||
cacheType = 'explicit';
|
||||
}
|
||||
|
||||
if (maxAge <= 0) {
|
||||
maxAge = HttpCache.getHostFallbackTtl(url);
|
||||
if (maxAge > 0) {
|
||||
cacheType = 'override';
|
||||
}
|
||||
}
|
||||
|
||||
// Don't cache if still no valid max-age
|
||||
if (maxAge <= 0) {
|
||||
console.log(`📤 Sent | ${url} (no cache directives; not cached)`);
|
||||
|
|
@ -396,6 +551,9 @@ class HttpCache {
|
|||
};
|
||||
|
||||
this.cache.set(key, cached);
|
||||
if (HttpCache.shouldPersist(url, options, response)) {
|
||||
HttpCache.persistEntry(key, cached);
|
||||
}
|
||||
|
||||
console.log(`🌐 Add | ${url} (${cacheType} ${maxAge}s TTL, expires: ${new Date(cached.expiry).toISOString()})`);
|
||||
}
|
||||
|
|
@ -426,20 +584,25 @@ class HttpCache {
|
|||
startCleanup() {
|
||||
if (this.cleanupInterval) return;
|
||||
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupInterval = setInterval(async () => {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
const deletePromises = [];
|
||||
|
||||
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) {
|
||||
if (now > cached.expiry + STALE_TIME_LIMIT_MS) {
|
||||
this.cache.delete(key);
|
||||
deletePromises.push(HttpCache.deletePersistedEntry(key));
|
||||
removedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (deletePromises.length > 0) {
|
||||
await Promise.allSettled(deletePromises);
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
console.log(`🧹 Clean | Removed ${removedCount} stale entries (${this.cache.size} remaining)`);
|
||||
}
|
||||
|
|
@ -471,15 +634,17 @@ class HttpCache {
|
|||
// Clear all cache entries
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
HttpCache.clearPersistedEntries();
|
||||
console.log('🗑️ Clear | Cache cleared');
|
||||
}
|
||||
|
||||
// Clear a specific cache entry by path
|
||||
clearEntry(path) {
|
||||
const key = path;
|
||||
clearEntry(cachePath) {
|
||||
const key = cachePath;
|
||||
const deleted = this.cache.delete(key);
|
||||
if (deleted) {
|
||||
console.log(`🗑️ Clear | ${path} removed from cache`);
|
||||
HttpCache.deletePersistedEntry(key);
|
||||
console.log(`🗑️ Clear | ${cachePath} removed from cache`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -64,3 +64,19 @@ export const rainViewerProxy = async (req, res) => {
|
|||
skipParams: ['u'],
|
||||
});
|
||||
};
|
||||
|
||||
export const arcGisServerProxy = async (req, res) => {
|
||||
await cache.handleRequest(req, res, 'https://server.arcgisonline.com', {
|
||||
serviceName: 'ArcGIS Server',
|
||||
skipParams: ['u'],
|
||||
encoding: 'binary',
|
||||
});
|
||||
};
|
||||
|
||||
export const arcGisServicesProxy = async (req, res) => {
|
||||
await cache.handleRequest(req, res, 'https://services.arcgisonline.com', {
|
||||
serviceName: 'ArcGIS Services',
|
||||
skipParams: ['u'],
|
||||
encoding: 'binary',
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
// current weather conditions display
|
||||
import { distance as calcDistance, directionToNSEW } from './utils/calc.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) {
|
||||
super(navId, elemId, 'Latest Observations', true);
|
||||
|
||||
// constants
|
||||
this.MaximumRegionalStations = 7;
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
if (!this.weatherParameters?.supportsNoaaDisplays) {
|
||||
this.data = [];
|
||||
this.timing.totalScreens = 0;
|
||||
this.setStatus(STATUS.loaded);
|
||||
return;
|
||||
}
|
||||
this.timing.totalScreens = 1;
|
||||
// latest observations does a silent refresh but will not fall back to previously fetched data
|
||||
// this is intentional because up to 30 stations are available to pull data from
|
||||
|
||||
// calculate distance to each station
|
||||
const stationsByDistance = Object.values(StationInfo).map((station) => {
|
||||
const distance = calcDistance(station.lat, station.lon, this.weatherParameters.latitude, this.weatherParameters.longitude);
|
||||
return { ...station, distance };
|
||||
});
|
||||
|
||||
// sort the stations by distance
|
||||
const sortedStations = stationsByDistance.sort((a, b) => a.distance - b.distance);
|
||||
// try up to 30 regional stations
|
||||
const regionalStations = sortedStations.slice(0, 30);
|
||||
|
||||
// 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 this.getStations(regionalStations.slice(firstStation, lastStation));
|
||||
|
||||
actualConditions.push(...someStations);
|
||||
// update counters
|
||||
firstStation += lastStation;
|
||||
lastStation = Math.min(regionalStations.length + 1, firstStation + 7 - actualConditions.length);
|
||||
}
|
||||
|
||||
// cut down to the maximum of 7
|
||||
this.data = actualConditions.slice(0, this.MaximumRegionalStations);
|
||||
|
||||
// test for at least one station
|
||||
if (this.data.length === 0) {
|
||||
this.setStatus(STATUS.noData);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
// sort array by station name
|
||||
const sortedConditions = conditions.sort((a, b) => ((a.Name < b.Name) ? -1 : 1));
|
||||
|
||||
if (settings.units.value === 'us') {
|
||||
this.elem.querySelector('.column-headers .temp.english').classList.add('show');
|
||||
this.elem.querySelector('.column-headers .temp.metric').classList.remove('show');
|
||||
} else {
|
||||
this.elem.querySelector('.column-headers .temp.english').classList.remove('show');
|
||||
this.elem.querySelector('.column-headers .temp.metric').classList.add('show');
|
||||
}
|
||||
// get unit converters
|
||||
const windConverter = windSpeed();
|
||||
const temperatureConverter = temperature();
|
||||
|
||||
const lines = sortedConditions.map((condition) => {
|
||||
const windDirection = directionToNSEW(condition.windDirection.value);
|
||||
|
||||
const Temperature = temperatureConverter(condition.temperature.value);
|
||||
const WindSpeed = windConverter(condition.windSpeed.value);
|
||||
|
||||
const fill = {
|
||||
location: locationCleanup(condition.city).substr(0, 14),
|
||||
temp: Temperature,
|
||||
weather: shortenCurrentConditions(condition.textDescription).substr(0, 9),
|
||||
};
|
||||
|
||||
if (WindSpeed > 0) {
|
||||
fill.wind = windDirection + (Array(6 - windDirection.length - WindSpeed.toString().length).join(' ')) + WindSpeed.toString();
|
||||
} else if (WindSpeed === 'NA') {
|
||||
fill.wind = 'NA';
|
||||
} else {
|
||||
fill.wind = 'Calm';
|
||||
}
|
||||
|
||||
return this.fillTemplate('observation-row', fill);
|
||||
});
|
||||
|
||||
const linesContainer = this.elem.querySelector('.observation-lines');
|
||||
linesContainer.innerHTML = '';
|
||||
linesContainer.append(...lines);
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
}
|
||||
|
||||
const shortenCurrentConditions = (_condition) => {
|
||||
let condition = _condition;
|
||||
condition = condition.replace(/Light/, 'L');
|
||||
condition = condition.replace(/Heavy/, 'H');
|
||||
condition = condition.replace(/Partly/, 'P');
|
||||
condition = condition.replace(/Mostly/, 'M');
|
||||
condition = condition.replace(/Few/, 'F');
|
||||
condition = condition.replace(/Thunderstorm/, 'T\'storm');
|
||||
condition = condition.replace(/ in /, '');
|
||||
condition = condition.replace(/Vicinity/, '');
|
||||
condition = condition.replace(/ and /, ' ');
|
||||
condition = condition.replace(/Freezing Rain/, 'Frz Rn');
|
||||
condition = condition.replace(/Freezing/, 'Frz');
|
||||
condition = condition.replace(/Unknown Precip/, '');
|
||||
condition = condition.replace(/L Snow Fog/, 'L Snw/Fog');
|
||||
condition = condition.replace(/ with /, '/');
|
||||
return condition;
|
||||
};
|
||||
// register display
|
||||
registerDisplay(new LatestObservations(2, 'latest-observations'));
|
||||
|
|
@ -11,9 +11,44 @@ import {
|
|||
clearMarkers,
|
||||
} from './utils/leaflet-weather-map.mjs';
|
||||
|
||||
class Radar extends WeatherDisplay {
|
||||
static metadataUrl = 'https://api.rainviewer.com/public/weather-maps.json';
|
||||
const RADAR_METADATA_URL = 'https://api.rainviewer.com/public/weather-maps.json';
|
||||
const RADAR_METADATA_CACHE_TTL_MS = 2 * 60 * 1000;
|
||||
let radarMetadataCache = null;
|
||||
|
||||
const getRadarMetadataCached = async (stillWaiting) => {
|
||||
const now = Date.now();
|
||||
if (radarMetadataCache && (now - radarMetadataCache.fetchedAt) < RADAR_METADATA_CACHE_TTL_MS) {
|
||||
return radarMetadataCache.data;
|
||||
}
|
||||
|
||||
const radarMetadata = await safeJson(RADAR_METADATA_URL, {
|
||||
retryCount: 2,
|
||||
stillWaiting,
|
||||
});
|
||||
|
||||
if (radarMetadata?.host && radarMetadata?.radar?.past?.length) {
|
||||
radarMetadataCache = {
|
||||
data: radarMetadata,
|
||||
fetchedAt: now,
|
||||
};
|
||||
return radarMetadata;
|
||||
}
|
||||
|
||||
if (radarMetadataCache) {
|
||||
return radarMetadataCache.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const haveRadarFramesChanged = (currentHost, currentFrames, nextHost, nextFrames) => {
|
||||
if (currentHost !== nextHost) return true;
|
||||
if (currentFrames.length !== nextFrames.length) return true;
|
||||
|
||||
return currentFrames.some((frame, index) => frame?.path !== nextFrames[index]?.path || frame?.time !== nextFrames[index]?.time);
|
||||
};
|
||||
|
||||
class Radar extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Local Radar');
|
||||
|
||||
|
|
@ -48,10 +83,7 @@ class Radar extends WeatherDisplay {
|
|||
this.updateLocationMarker();
|
||||
await this.updateNearbyMarkers();
|
||||
|
||||
const radarMetadata = await safeJson(Radar.metadataUrl, {
|
||||
retryCount: 2,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
const radarMetadata = await getRadarMetadataCached(() => this.stillWaiting());
|
||||
|
||||
const frames = radarMetadata?.radar?.past?.slice(-this.maxFrames) ?? [];
|
||||
if (!frames.length || !radarMetadata?.host) {
|
||||
|
|
@ -61,11 +93,25 @@ class Radar extends WeatherDisplay {
|
|||
return;
|
||||
}
|
||||
|
||||
const currentFrameIndex = Math.max(0, Math.min(this.screenIndex < 0 ? this.mapFrames.length - 1 : this.screenIndex, frames.length - 1));
|
||||
const framesChanged = haveRadarFramesChanged(this.radarHost, this.mapFrames, radarMetadata.host, frames);
|
||||
this.radarHost = radarMetadata.host;
|
||||
this.mapFrames = frames;
|
||||
this.resetRadarLayers();
|
||||
this.timing.delay = this.buildTiming();
|
||||
this.calcNavTiming();
|
||||
|
||||
if (framesChanged) {
|
||||
this.resetRadarLayers();
|
||||
}
|
||||
|
||||
if (refresh) {
|
||||
if (framesChanged && this.active) {
|
||||
this.showFrame(currentFrameIndex);
|
||||
}
|
||||
this.setStatus(STATUS.loaded);
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetNavBaseCount();
|
||||
this.showFrame(this.mapFrames.length - 1);
|
||||
this.setStatus(STATUS.loaded);
|
||||
|
|
|
|||
|
|
@ -8,16 +8,15 @@ import { registerDisplay } from './navigation.mjs';
|
|||
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import { temperature } from './utils/units.mjs';
|
||||
import { getAggregatedOpenMeteoForecast } from './utils/weather.mjs';
|
||||
import { getCachedAggregatedOpenMeteoForecast } from './utils/weather.mjs';
|
||||
|
||||
const MIN_TRAVEL_CITIES = 5;
|
||||
|
||||
class TravelForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId, defaultActive) {
|
||||
// special height and width for scrolling
|
||||
super(navId, elemId, 'Travel Forecast', defaultActive);
|
||||
|
||||
// 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.
|
||||
|
|
@ -45,30 +44,13 @@ class TravelForecast extends WeatherDisplay {
|
|||
// super checks for enabled
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// clear stored data if not refresh
|
||||
if (!refresh) {
|
||||
this.previousData = [];
|
||||
}
|
||||
|
||||
const temperatureConverter = temperature();
|
||||
const selectedTravelCities = getTravelCitiesForLocation(this.weatherParameters);
|
||||
|
||||
const forecastPromises = selectedTravelCities.map(async (city, index) => {
|
||||
const forecastPromises = selectedTravelCities.map(async (city) => {
|
||||
try {
|
||||
let forecast;
|
||||
forecast = await getAggregatedOpenMeteoForecast(city.Latitude, city.Longitude);
|
||||
|
||||
if (forecast) {
|
||||
// store for the next run
|
||||
this.previousData[index] = forecast;
|
||||
} else if (this.previousData?.[index]) {
|
||||
// if there's previous data use it
|
||||
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
|
||||
const forecast = await getCachedAggregatedOpenMeteoForecast(city.Latitude, city.Longitude);
|
||||
if (!forecast) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`No travel forecast for ${city.Name} available`);
|
||||
}
|
||||
|
|
@ -100,7 +82,11 @@ class TravelForecast extends WeatherDisplay {
|
|||
|
||||
// wait for all forecasts using centralized safe Promise handling
|
||||
const forecasts = await safePromiseAll(forecastPromises);
|
||||
this.data = forecasts;
|
||||
const validForecasts = forecasts.filter((forecast) => forecast && !forecast.error && forecast.high !== undefined);
|
||||
const invalidForecasts = forecasts.filter((forecast) => forecast && forecast.error);
|
||||
this.data = validForecasts.length >= MIN_TRAVEL_CITIES
|
||||
? validForecasts
|
||||
: [...validForecasts, ...invalidForecasts].slice(0, Math.max(validForecasts.length, MIN_TRAVEL_CITIES));
|
||||
|
||||
// test for some data available in at least one forecast
|
||||
const hasData = this.data.some((forecast) => forecast.high);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ import { safePromiseAll } from './fetch.mjs';
|
|||
import { loadData } from './data-loader.mjs';
|
||||
import { getSmallIconFromWmoCode } from '../icons.mjs';
|
||||
import { getOpenMeteoObservationSnapshot } from './weather.mjs';
|
||||
import { temperature } from './units.mjs';
|
||||
|
||||
const BASE_MAP_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}';
|
||||
const BOUNDARY_MAP_URL = 'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}';
|
||||
const getBaseMapUrl = () => (window.WS4KP_SERVER_AVAILABLE
|
||||
? '/arcgis-server/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}'
|
||||
: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}');
|
||||
const getBoundaryMapUrl = () => (window.WS4KP_SERVER_AVAILABLE
|
||||
? '/arcgis-services/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}'
|
||||
: 'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}');
|
||||
const DEFAULT_MAX_NEARBY_MARKERS = 7;
|
||||
const MIN_CITY_DISTANCE_METERS = 25000;
|
||||
const MIN_MARKER_PIXEL_DISTANCE = 85;
|
||||
|
|
@ -25,14 +30,14 @@ const createMap = (mapElement) => window.L.map(mapElement, {
|
|||
});
|
||||
|
||||
const addBaseLayers = (map) => {
|
||||
const baseLayer = window.L.tileLayer(BASE_MAP_URL, {
|
||||
const baseLayer = window.L.tileLayer(getBaseMapUrl(), {
|
||||
maxZoom: 10,
|
||||
minZoom: 1,
|
||||
crossOrigin: true,
|
||||
className: 'radar-base-layer',
|
||||
}).addTo(map);
|
||||
|
||||
const boundaryLayer = window.L.tileLayer(BOUNDARY_MAP_URL, {
|
||||
const boundaryLayer = window.L.tileLayer(getBoundaryMapUrl(), {
|
||||
maxZoom: 10,
|
||||
minZoom: 1,
|
||||
opacity: 0.6,
|
||||
|
|
@ -100,12 +105,13 @@ const selectNearbyCities = (map, sourceLocation, cities, options = {}) => {
|
|||
};
|
||||
|
||||
const buildNearbyWeatherMarker = (city, observation) => {
|
||||
const temperatureConverter = temperature();
|
||||
const icon = getSmallIconFromWmoCode(observation.weatherCode, observation.isDay);
|
||||
const markerHtml = `
|
||||
<div class="nearby-weather-marker-inner">
|
||||
<div class="city">${city.name}</div>
|
||||
<div class="details">
|
||||
<div class="temp">${Math.round(observation.temperature)}</div>
|
||||
<div class="temp">${temperatureConverter(observation.temperature)}</div>
|
||||
<img src="${icon}" alt="${city.name} weather" />
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@ const rewriteUrl = (_url) => {
|
|||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/rainviewer${url.pathname}`;
|
||||
} else if (url.origin === 'https://server.arcgisonline.com') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/arcgis-server${url.pathname}`;
|
||||
} else if (url.origin === 'https://services.arcgisonline.com') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/arcgis-services${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;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ const OPEN_METEO_RADAR_OBSERVATION_PARAMETERS = [
|
|||
'models=best_match',
|
||||
].join('&');
|
||||
|
||||
const OPEN_METEO_OBSERVATION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const OPEN_METEO_TRAVEL_FORECAST_CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
const openMeteoObservationCache = new Map();
|
||||
const openMeteoTravelForecastCache = new Map();
|
||||
|
||||
const getPoint = async (lat, lon) => {
|
||||
const point = await safeJson(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
|
||||
if (!point) {
|
||||
|
|
@ -55,16 +60,49 @@ const getAggregatedOpenMeteoForecast = async (lat, lon) => {
|
|||
};
|
||||
};
|
||||
|
||||
const getCachedAggregatedOpenMeteoForecast = async (lat, lon) => {
|
||||
const cacheKey = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
const cachedEntry = openMeteoTravelForecastCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
if (cachedEntry && (now - cachedEntry.fetchedAt) < OPEN_METEO_TRAVEL_FORECAST_CACHE_TTL_MS) {
|
||||
return cachedEntry.data;
|
||||
}
|
||||
|
||||
const forecast = await getAggregatedOpenMeteoForecast(lat, lon);
|
||||
if (forecast) {
|
||||
openMeteoTravelForecastCache.set(cacheKey, {
|
||||
data: forecast,
|
||||
fetchedAt: now,
|
||||
});
|
||||
return forecast;
|
||||
}
|
||||
|
||||
if (cachedEntry) {
|
||||
return cachedEntry.data;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getOpenMeteoObservationSnapshot = async (lat, lon) => {
|
||||
const cacheKey = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
const cachedEntry = openMeteoObservationCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
if (cachedEntry && (now - cachedEntry.fetchedAt) < OPEN_METEO_OBSERVATION_CACHE_TTL_MS) {
|
||||
return cachedEntry.data;
|
||||
}
|
||||
|
||||
const forecast = await safeJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_RADAR_OBSERVATION_PARAMETERS}`);
|
||||
if (!forecast?.hourly?.time?.length) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get Open-Meteo radar observation snapshot for ${lat},${lon}`);
|
||||
}
|
||||
if (cachedEntry) {
|
||||
return cachedEntry.data;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let nearestIndex = 0;
|
||||
let nearestDelta = Number.POSITIVE_INFINITY;
|
||||
|
||||
|
|
@ -76,13 +114,20 @@ const getOpenMeteoObservationSnapshot = async (lat, lon) => {
|
|||
}
|
||||
});
|
||||
|
||||
return {
|
||||
const snapshot = {
|
||||
time: forecast.hourly.time[nearestIndex],
|
||||
temperature: forecast.hourly.temperature_2m?.[nearestIndex] ?? null,
|
||||
weatherCode: forecast.hourly.weather_code?.[nearestIndex] ?? 0,
|
||||
isDay: Boolean(forecast.hourly.is_day?.[nearestIndex] ?? 1),
|
||||
timezone: forecast.timezone,
|
||||
};
|
||||
|
||||
openMeteoObservationCache.set(cacheKey, {
|
||||
data: snapshot,
|
||||
fetchedAt: now,
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const weatherConditions = [
|
||||
|
|
@ -180,6 +225,7 @@ export {
|
|||
getPoint,
|
||||
getOpenMeteoForecast,
|
||||
getAggregatedOpenMeteoForecast,
|
||||
getCachedAggregatedOpenMeteoForecast,
|
||||
getOpenMeteoObservationSnapshot,
|
||||
aggregateWeatherForecastData,
|
||||
getConditionText,
|
||||
|
|
|
|||
|
|
@ -44,12 +44,7 @@
|
|||
.hourly-lines {
|
||||
min-height: 338px;
|
||||
padding-top: 10px;
|
||||
|
||||
background: repeating-linear-gradient(0deg, c.$gradient-main-background-2 0px,
|
||||
c.$gradient-main-background-1 136px,
|
||||
c.$gradient-main-background-1 202px,
|
||||
c.$gradient-main-background-2 338px,
|
||||
);
|
||||
background: #0b0b39;
|
||||
|
||||
.hourly-row {
|
||||
font-family: 'Star4000 Large';
|
||||
|
|
@ -100,4 +95,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
|
||||
.weather-display .latest-observations {
|
||||
|
||||
&.main {
|
||||
overflow-y: hidden;
|
||||
|
||||
.column-headers {
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.column-headers {
|
||||
top: 0px;
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
font-family: 'Star4000 Small';
|
||||
font-size: 24pt;
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
@include u.text-shadow();
|
||||
}
|
||||
|
||||
.temp {
|
||||
// hidden initially for english/metric switching
|
||||
display: none;
|
||||
|
||||
&.show {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.temp {
|
||||
left: 230px;
|
||||
}
|
||||
|
||||
.weather {
|
||||
left: 280px;
|
||||
}
|
||||
|
||||
.wind {
|
||||
left: 430px;
|
||||
}
|
||||
|
||||
.observation-lines {
|
||||
min-height: 338px;
|
||||
padding-top: 10px;
|
||||
|
||||
.observation-row {
|
||||
font-family: 'Star4000';
|
||||
font-size: 24pt;
|
||||
@include u.text-shadow();
|
||||
position: relative;
|
||||
height: 40px;
|
||||
|
||||
>div {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.wind {
|
||||
white-space: pre;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
.scale-table {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
gap: 4px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -44,12 +44,7 @@
|
|||
.travel-lines {
|
||||
min-height: 338px;
|
||||
padding-top: 10px;
|
||||
|
||||
background: repeating-linear-gradient(0deg, c.$gradient-main-background-2 0px,
|
||||
c.$gradient-main-background-1 136px,
|
||||
c.$gradient-main-background-1 202px,
|
||||
c.$gradient-main-background-2 338px,
|
||||
);
|
||||
background: #0b0b39;
|
||||
|
||||
.travel-row {
|
||||
font-family: 'Star4000 Large';
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@
|
|||
&.has-scroll {
|
||||
width: 640px;
|
||||
margin-top: 0;
|
||||
height: 310px;
|
||||
height: 320px;
|
||||
overflow: hidden;
|
||||
|
||||
&.no-header {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
@use 'hourly';
|
||||
@use 'hourly-graph';
|
||||
@use 'travel';
|
||||
@use 'latest-observations';
|
||||
@use 'local-forecast';
|
||||
@use 'progress';
|
||||
@use 'radar';
|
||||
|
|
|
|||
2
server/styles/ws.min.css
vendored
2
server/styles/ws.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -57,7 +57,6 @@
|
|||
<script type="module" src="scripts/modules/extendedforecast.mjs"></script>
|
||||
<script type="module" src="scripts/modules/hourly-graph.mjs"></script>
|
||||
<script type="module" src="scripts/modules/hourly.mjs"></script>
|
||||
<script type="module" src="scripts/modules/latestobservations.mjs"></script>
|
||||
<script type="module" src="scripts/modules/localforecast.mjs"></script>
|
||||
<script type="module" src="scripts/modules/radar.mjs"></script>
|
||||
<script type="module" src="scripts/modules/regionalforecast.mjs"></script>
|
||||
|
|
@ -118,9 +117,6 @@
|
|||
<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>
|
||||
|
|
@ -182,7 +178,7 @@
|
|||
<br />
|
||||
|
||||
<div class="info">
|
||||
<a href="https://github.com/netbymatt/ws4kp#weatherstar-4000">More information</a>
|
||||
<a href="https://mentalnet.xyz/forgejo/markmental/ws4kp-linhanced">More information</a>
|
||||
</div>
|
||||
<div class="media"></div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
<%- include('header.ejs', {titleDual:{ top: 'Latest' , bottom: 'Observations' }, noaaLogo: true, hasTime: true }) %>
|
||||
<div class="main has-scroll latest-observations has-box">
|
||||
<div class="container">
|
||||
<div class="column-headers">
|
||||
<div class="temp english">°F</div>
|
||||
<div class="temp metric">°C</div>
|
||||
<div class="weather">Weather</div>
|
||||
<div class="wind">Wind</div>
|
||||
</div>
|
||||
<div class="observation-lines">
|
||||
<div class="observation-row template">
|
||||
<div class="location"></div>
|
||||
<div class="temp"></div>
|
||||
<div class="weather"></div>
|
||||
<div class="wind"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue