v0.2.1! Adds instant Hazard List updates after hazard sync
Some checks are pending
build-docker / Build Image (push) Waiting to run
Some checks are pending
build-docker / Build Image (push) Waiting to run
This commit is contained in:
parent
0f2d64b908
commit
dbb32fd2f9
9 changed files with 362 additions and 5 deletions
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
`ws4kp-linhanced` is a Linux-focused fork of [`netbymatt/ws4kp`](https://github.com/netbymatt/ws4kp) by `markmental`.
|
`ws4kp-linhanced` is a Linux-focused fork of [`netbymatt/ws4kp`](https://github.com/netbymatt/ws4kp) by `markmental`.
|
||||||
|
|
||||||
|
Current release: `v0.2.1`
|
||||||
|
|
||||||
It keeps the stronger, more stable foundation of the original `ws4kp` project while selectively incorporating international weather support and global map ideas from [`mwood77/ws4kp-international`](https://github.com/mwood77/ws4kp-international). The goal is not to become a kitchen-sink weather platform. The goal is a leaner, maintainable, Linux-oriented WeatherStar fork with a clear identity.
|
It keeps the stronger, more stable foundation of the original `ws4kp` project while selectively incorporating international weather support and global map ideas from [`mwood77/ws4kp-international`](https://github.com/mwood77/ws4kp-international). The goal is not to become a kitchen-sink weather platform. The goal is a leaner, maintainable, Linux-oriented WeatherStar fork with a clear identity.
|
||||||
|
|
||||||
This fork also explicitly embraces Slackware Linux / `weatherstar4k` branding as part of its mission. Broad platform neutrality is not a design goal here.
|
This fork also explicitly embraces Slackware Linux / `weatherstar4k` branding as part of its mission. Broad platform neutrality is not a design goal here.
|
||||||
|
|
@ -47,6 +49,7 @@ Major features currently in this fork:
|
||||||
* global RainViewer radar on a cached world basemap
|
* global RainViewer radar on a cached world basemap
|
||||||
* global `Regional Observations` and nearby-city displays backed by expanded worldwide city coverage
|
* global `Regional Observations` and nearby-city displays backed by expanded worldwide city coverage
|
||||||
* `Latest Observations` screen for nearby city temperatures, conditions, and wind
|
* `Latest Observations` screen for nearby city temperatures, conditions, and wind
|
||||||
|
* `Hazard List` screen with MySQL-backed server history for encountered NOAA and derived hazards
|
||||||
* `Ground View` screen powered by nearby Windy webcams (requires API key, see below)
|
* `Ground View` screen powered by nearby Windy webcams (requires API key, see below)
|
||||||
* Travel Forecast rebuilt around region buckets with a global fallback
|
* Travel Forecast rebuilt around region buckets with a global fallback
|
||||||
* optional screen-specific audio playback for supported displays
|
* optional screen-specific audio playback for supported displays
|
||||||
|
|
@ -164,6 +167,10 @@ CREATE TABLE hazard_history (
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The database retains full hazard history. The `Hazard List` UI shows only the latest 7 entries and only the most recent row per stored location. Nearby same-label locations are reconciled during updates to reduce duplicate location spam.
|
||||||
|
|
||||||
|
In the current browser session, `Hazard List` updates immediately after new hazard history is synced.
|
||||||
|
|
||||||
## Running Modes
|
## Running Modes
|
||||||
|
|
||||||
This fork supports two main runtime styles.
|
This fork supports two main runtime styles.
|
||||||
|
|
@ -182,6 +189,7 @@ This mode includes:
|
||||||
* proxying and caching for weather/map requests
|
* proxying and caching for weather/map requests
|
||||||
* Fastfetch-backed Server Observations
|
* Fastfetch-backed Server Observations
|
||||||
* MySQL-backed Hazard List history
|
* MySQL-backed Hazard List history
|
||||||
|
* automatic ongoing-hazard rechecks every 10 minutes for both NOAA and derived hazards
|
||||||
* better shared performance when multiple clients use the same instance
|
* better shared performance when multiple clients use the same instance
|
||||||
|
|
||||||
### Static Mode
|
### Static Mode
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { discoverThemes } from './src/theme-discovery.mjs';
|
||||||
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
||||||
import { checkHazardHistoryTable } from './src/mysql.mjs';
|
import { checkHazardHistoryTable } from './src/mysql.mjs';
|
||||||
import { getHistory, updateHistory } from './src/hazard-history.mjs';
|
import { getHistory, updateHistory } from './src/hazard-history.mjs';
|
||||||
|
import { startHazardHistoryWorker, stopHazardHistoryWorker } from './src/hazard-history-worker.mjs';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -204,12 +205,18 @@ const staticOptions = {
|
||||||
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
|
||||||
// Skip setting up routes for the caching proxy server in static mode
|
// Skip setting up routes for the caching proxy server in static mode
|
||||||
if (!process.env?.STATIC) {
|
if (!process.env?.STATIC) {
|
||||||
|
let hazardHistoryReady = false;
|
||||||
try {
|
try {
|
||||||
await checkHazardHistoryTable();
|
await checkHazardHistoryTable();
|
||||||
|
hazardHistoryReady = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hazardHistoryReady) {
|
||||||
|
startHazardHistoryWorker();
|
||||||
|
}
|
||||||
|
|
||||||
// Server info endpoint for fastfetch output (must be before /api/ weather proxy)
|
// Server info endpoint for fastfetch output (must be before /api/ weather proxy)
|
||||||
app.get('/api/server-info', async (req, res) => {
|
app.get('/api/server-info', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -408,6 +415,7 @@ const server = app.listen(port, () => {
|
||||||
|
|
||||||
// graceful shutdown
|
// graceful shutdown
|
||||||
const gracefulShutdown = () => {
|
const gracefulShutdown = () => {
|
||||||
|
stopHazardHistoryWorker();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log('Server closed');
|
console.log('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "ws4kp-linhanced",
|
"name": "ws4kp-linhanced",
|
||||||
"version": "0.2",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ws4kp-linhanced",
|
"name": "ws4kp-linhanced",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.0.1",
|
"dotenv": "^17.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ws4kp-linhanced",
|
"name": "ws4kp-linhanced",
|
||||||
"version": "0.2",
|
"version": "0.2.1",
|
||||||
"description": "WeatherStar 4000+: Linhanced - A Linux-focused fork of the WeatherStar 4000+ project",
|
"description": "WeatherStar 4000+: Linhanced - A Linux-focused fork of the WeatherStar 4000+ project",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,17 @@ class HazardList extends WeatherDisplay {
|
||||||
constructor(navId, elemId) {
|
constructor(navId, elemId) {
|
||||||
super(navId, elemId, 'Hazard List', true);
|
super(navId, elemId, 'Hazard List', true);
|
||||||
this.history = [];
|
this.history = [];
|
||||||
|
this.handleHazardHistoryUpdated = this.handleHazardHistoryUpdated.bind(this);
|
||||||
|
window.addEventListener('hazard-history-updated', this.handleHazardHistoryUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getData(weatherParameters, refresh) {
|
async getData(weatherParameters, refresh) {
|
||||||
const superResult = super.getData(weatherParameters, refresh);
|
const superResult = super.getData(weatherParameters, refresh);
|
||||||
|
await this.refreshHistoryNow();
|
||||||
|
return superResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshHistoryNow() {
|
||||||
try {
|
try {
|
||||||
// Fetch hazard history from backend
|
// Fetch hazard history from backend
|
||||||
const response = await fetch(withBasePath('api/hazard-history'));
|
const response = await fetch(withBasePath('api/hazard-history'));
|
||||||
|
|
@ -37,8 +43,13 @@ class HazardList extends WeatherDisplay {
|
||||||
this.setStatus(STATUS.failed);
|
this.setStatus(STATUS.failed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return superResult;
|
async handleHazardHistoryUpdated() {
|
||||||
|
await this.refreshHistoryNow();
|
||||||
|
if (this.active) {
|
||||||
|
this.drawCanvas();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async drawCanvas() {
|
async drawCanvas() {
|
||||||
|
|
|
||||||
|
|
@ -290,13 +290,19 @@ class Hazards extends WeatherDisplay {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Send to backend
|
// Send to backend
|
||||||
await fetch(withBasePath('api/hazard-history'), {
|
const response = await fetch(withBasePath('api/hazard-history'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ location, locationKey, hazards }),
|
body: JSON.stringify({ location, locationKey, hazards }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Hazard history sync failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('hazard-history-updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silently fail - hazard history is non-critical
|
// Silently fail - hazard history is non-critical
|
||||||
if (debugFlag('verbose-failures')) {
|
if (debugFlag('verbose-failures')) {
|
||||||
|
|
|
||||||
187
src/hazard-history-worker.mjs
Normal file
187
src/hazard-history-worker.mjs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import deriveHazards from '../server/scripts/modules/utils/derived-hazards.mjs';
|
||||||
|
import {
|
||||||
|
getOngoingHazards,
|
||||||
|
markHazardEndedById,
|
||||||
|
touchHazardStillOngoing,
|
||||||
|
} from './hazard-history.mjs';
|
||||||
|
import { buildWeatherParametersForLocation, parseLocationKey } from './weather-parameters.mjs';
|
||||||
|
|
||||||
|
const HAZARD_HISTORY_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
|
const HAZARD_HISTORY_CHECK_CONCURRENCY = 3;
|
||||||
|
const USER_AGENT = 'WeatherStar 4000+: Linhanced; marky611@gmail.com';
|
||||||
|
|
||||||
|
let hazardHistoryWorkerHandle = null;
|
||||||
|
let isHazardHistoryWorkerRunning = false;
|
||||||
|
|
||||||
|
const buildHazardIdentity = (hazardType, source) => `${hazardType}::${source}`;
|
||||||
|
|
||||||
|
const groupHazardsByLocationKey = (rows) => rows.reduce((groups, row) => {
|
||||||
|
const existing = groups.get(row.locationKey) ?? [];
|
||||||
|
existing.push(row);
|
||||||
|
groups.set(row.locationKey, existing);
|
||||||
|
return groups;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
const fetchJson = async (url) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapNoaaAlertsToActiveHazards = (alertsResponse) => (alertsResponse?.features ?? []).map((feature) => ({
|
||||||
|
hazardType: feature.properties?.event || 'Unknown',
|
||||||
|
latestHazardId: feature.id || null,
|
||||||
|
severity: feature.properties?.severity || null,
|
||||||
|
source: 'noaa',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mapDerivedHazardsToActiveHazards = (derivedHazards) => (derivedHazards ?? []).map((hazard) => ({
|
||||||
|
hazardType: hazard.properties?.event || 'Unknown',
|
||||||
|
latestHazardId: hazard.id || null,
|
||||||
|
severity: hazard.properties?.severity || null,
|
||||||
|
source: 'derived',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchActiveNoaaHazardsForLocation = async ({ latitude, longitude }) => {
|
||||||
|
const url = new URL('https://api.weather.gov/alerts/active');
|
||||||
|
url.searchParams.set('point', `${latitude},${longitude}`);
|
||||||
|
url.searchParams.set('status', 'actual');
|
||||||
|
const alerts = await fetchJson(url.toString());
|
||||||
|
return mapNoaaAlertsToActiveHazards(alerts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchActiveDerivedHazardsForLocation = async ({ locationKey, locationLabel }) => {
|
||||||
|
const weatherParameters = await buildWeatherParametersForLocation({ locationKey, locationLabel });
|
||||||
|
return mapDerivedHazardsToActiveHazards(deriveHazards(weatherParameters));
|
||||||
|
};
|
||||||
|
|
||||||
|
const runWithConcurrency = async (items, limit, worker) => {
|
||||||
|
const queue = [...items];
|
||||||
|
const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const next = queue.shift();
|
||||||
|
if (!next) return;
|
||||||
|
await worker(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconcileHazardsForLocation = async ({ locationKey, rows }) => {
|
||||||
|
const coordinates = parseLocationKey(locationKey);
|
||||||
|
if (!coordinates) {
|
||||||
|
throw new Error(`Invalid location key '${locationKey}' in hazard history worker`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationLabel = rows[0]?.locationLabel || 'Unknown';
|
||||||
|
const summary = {
|
||||||
|
refreshed: 0,
|
||||||
|
ended: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [activeNoaaHazards, activeDerivedHazards] = await Promise.all([
|
||||||
|
rows.some((row) => row.source === 'noaa')
|
||||||
|
? fetchActiveNoaaHazardsForLocation({ latitude: coordinates.lat, longitude: coordinates.lon })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
rows.some((row) => row.source === 'derived')
|
||||||
|
? fetchActiveDerivedHazardsForLocation({ locationKey, locationLabel })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeHazards = [...activeNoaaHazards, ...activeDerivedHazards];
|
||||||
|
const activeHazardMap = new Map(activeHazards.map((hazard) => [buildHazardIdentity(hazard.hazardType, hazard.source), hazard]));
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const activeHazard = activeHazardMap.get(buildHazardIdentity(row.hazardType, row.source));
|
||||||
|
if (activeHazard) {
|
||||||
|
await touchHazardStillOngoing({
|
||||||
|
id: row.id,
|
||||||
|
severity: activeHazard.severity,
|
||||||
|
latestHazardId: activeHazard.latestHazardId,
|
||||||
|
});
|
||||||
|
summary.refreshed += 1;
|
||||||
|
} else {
|
||||||
|
await markHazardEndedById(row.id);
|
||||||
|
summary.ended += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runHazardHistoryCheckOnce = async () => {
|
||||||
|
if (isHazardHistoryWorkerRunning) return;
|
||||||
|
isHazardHistoryWorkerRunning = true;
|
||||||
|
|
||||||
|
const totals = {
|
||||||
|
locationsChecked: 0,
|
||||||
|
refreshed: 0,
|
||||||
|
ended: 0,
|
||||||
|
failures: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ongoingRows = await getOngoingHazards();
|
||||||
|
const grouped = [...groupHazardsByLocationKey(ongoingRows).entries()].map(([locationKey, rows]) => ({ locationKey, rows }));
|
||||||
|
|
||||||
|
await runWithConcurrency(grouped, HAZARD_HISTORY_CHECK_CONCURRENCY, async (group) => {
|
||||||
|
try {
|
||||||
|
const result = await reconcileHazardsForLocation(group);
|
||||||
|
totals.locationsChecked += 1;
|
||||||
|
totals.refreshed += result.refreshed;
|
||||||
|
totals.ended += result.ended;
|
||||||
|
} catch (error) {
|
||||||
|
totals.locationsChecked += 1;
|
||||||
|
totals.failures += 1;
|
||||||
|
console.warn(`Hazard worker location check failed for ${group.locationKey}: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Hazard worker: checked ${totals.locationsChecked} locations, refreshed ${totals.refreshed} hazards, ended ${totals.ended} hazards, ${totals.failures} failure${totals.failures === 1 ? '' : 's'}`);
|
||||||
|
} finally {
|
||||||
|
isHazardHistoryWorkerRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startHazardHistoryWorker = () => {
|
||||||
|
if (hazardHistoryWorkerHandle) return;
|
||||||
|
runHazardHistoryCheckOnce().catch((error) => {
|
||||||
|
console.warn(`Hazard worker initial run failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
hazardHistoryWorkerHandle = setInterval(() => {
|
||||||
|
runHazardHistoryCheckOnce().catch((error) => {
|
||||||
|
console.warn(`Hazard worker run failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
}, HAZARD_HISTORY_CHECK_INTERVAL_MS);
|
||||||
|
if (typeof hazardHistoryWorkerHandle.unref === 'function') {
|
||||||
|
hazardHistoryWorkerHandle.unref();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHazardHistoryWorker = () => {
|
||||||
|
if (!hazardHistoryWorkerHandle) return;
|
||||||
|
clearInterval(hazardHistoryWorkerHandle);
|
||||||
|
hazardHistoryWorkerHandle = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
runHazardHistoryCheckOnce,
|
||||||
|
startHazardHistoryWorker,
|
||||||
|
stopHazardHistoryWorker,
|
||||||
|
};
|
||||||
|
|
@ -133,6 +133,54 @@ const getHistory = async () => {
|
||||||
return rows.map(mapRowToHistoryEntry);
|
return rows.map(mapRowToHistoryEntry);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getOngoingHazards = async () => {
|
||||||
|
const [rows] = await getPool().query(
|
||||||
|
`SELECT
|
||||||
|
id,
|
||||||
|
location_label,
|
||||||
|
location_key,
|
||||||
|
hazard_type,
|
||||||
|
source,
|
||||||
|
severity,
|
||||||
|
latest_hazard_id
|
||||||
|
FROM hazard_history
|
||||||
|
WHERE ongoing = 1
|
||||||
|
ORDER BY last_seen_at DESC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
locationLabel: row.location_label,
|
||||||
|
locationKey: row.location_key,
|
||||||
|
hazardType: row.hazard_type,
|
||||||
|
source: row.source,
|
||||||
|
severity: row.severity,
|
||||||
|
latestHazardId: row.latest_hazard_id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchHazardStillOngoing = async ({ id, severity = null, latestHazardId = null }) => {
|
||||||
|
await getPool().execute(
|
||||||
|
`UPDATE hazard_history
|
||||||
|
SET severity = COALESCE(?, severity),
|
||||||
|
latest_hazard_id = COALESCE(?, latest_hazard_id),
|
||||||
|
last_seen_at = UTC_TIMESTAMP(),
|
||||||
|
ongoing = 1
|
||||||
|
WHERE id = ?`,
|
||||||
|
[severity, latestHazardId, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markHazardEndedById = async (id) => {
|
||||||
|
await getPool().execute(
|
||||||
|
`UPDATE hazard_history
|
||||||
|
SET ongoing = 0,
|
||||||
|
last_seen_at = UTC_TIMESTAMP()
|
||||||
|
WHERE id = ?`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const updateHistory = async (payload) => {
|
const updateHistory = async (payload) => {
|
||||||
const { location, locationKey, hazards = [] } = payload;
|
const { location, locationKey, hazards = [] } = payload;
|
||||||
const validHazards = hazards.filter((hazard) => hazard?.hazardType && hazard?.source);
|
const validHazards = hazards.filter((hazard) => hazard?.hazardType && hazard?.source);
|
||||||
|
|
@ -242,6 +290,9 @@ const updateHistory = async (payload) => {
|
||||||
export {
|
export {
|
||||||
formatLocation,
|
formatLocation,
|
||||||
getHistory,
|
getHistory,
|
||||||
|
getOngoingHazards,
|
||||||
MAX_HISTORY_ENTRIES,
|
MAX_HISTORY_ENTRIES,
|
||||||
|
markHazardEndedById,
|
||||||
|
touchHazardStillOngoing,
|
||||||
updateHistory,
|
updateHistory,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
86
src/weather-parameters.mjs
Normal file
86
src/weather-parameters.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { aggregateWeatherForecastData } from '../server/scripts/modules/utils/weather.mjs';
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
const USER_AGENT = 'WeatherStar 4000+: Linhanced; marky611@gmail.com';
|
||||||
|
const OPEN_METEO_FORECAST_PARAMETERS = [
|
||||||
|
'daily=temperature_2m_max,temperature_2m_min,uv_index_max',
|
||||||
|
'hourly=temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation_probability,precipitation,rain,showers,snowfall,snow_depth,weather_code,pressure_msl,surface_pressure,cloud_cover,visibility,uv_index,is_day,sunshine_duration,wind_speed_10m,wind_direction_10m,wind_gusts_10m',
|
||||||
|
'timezone=auto',
|
||||||
|
'models=best_match',
|
||||||
|
].join('&');
|
||||||
|
|
||||||
|
const parseLocationKey = (locationKey) => {
|
||||||
|
if (typeof locationKey !== 'string') return null;
|
||||||
|
const [latText, lonText] = locationKey.split(',');
|
||||||
|
const lat = Number.parseFloat(latText);
|
||||||
|
const lon = Number.parseFloat(lonText);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
return { lat, lon };
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitLocationLabel = (locationLabel = '') => {
|
||||||
|
const [cityPart = 'Unknown', regionPart = ''] = locationLabel.split(',').map((part) => part.trim());
|
||||||
|
const isUsStyleState = /^[A-Z]{2}$/.test(regionPart);
|
||||||
|
return {
|
||||||
|
city: cityPart || 'Unknown',
|
||||||
|
state: isUsStyleState ? regionPart : '',
|
||||||
|
country: isUsStyleState ? 'United States' : regionPart,
|
||||||
|
countryCode: isUsStyleState ? 'US' : '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchJson = async (url) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWeatherParametersForLocation = async ({ locationKey, locationLabel }) => {
|
||||||
|
const coordinates = parseLocationKey(locationKey);
|
||||||
|
if (!coordinates) {
|
||||||
|
throw new Error(`Invalid location key '${locationKey}' for derived hazard refresh`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lat, lon } = coordinates;
|
||||||
|
const locationParts = splitLocationLabel(locationLabel);
|
||||||
|
const forecast = await fetchJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_FORECAST_PARAMETERS}`);
|
||||||
|
const aggregatedForecast = aggregateWeatherForecastData(forecast);
|
||||||
|
if (!aggregatedForecast) {
|
||||||
|
throw new Error(`Unable to aggregate forecast for ${locationKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
city: locationParts.city,
|
||||||
|
state: locationParts.state,
|
||||||
|
country: locationParts.country,
|
||||||
|
countryCode: locationParts.countryCode,
|
||||||
|
timeZone: forecast?.timezone || 'UTC',
|
||||||
|
forecast: aggregatedForecast,
|
||||||
|
supportsNoaaAlerts: false,
|
||||||
|
primaryForecastSource: 'open-meteo',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildWeatherParametersForLocation,
|
||||||
|
parseLocationKey,
|
||||||
|
splitLocationLabel,
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue