Add Ground View screen for server edition, powered by the Windy API https://api.windy.com/
This commit is contained in:
parent
8958ef4d38
commit
598a60c7f5
11 changed files with 349 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,3 +17,4 @@ dist/*
|
||||||
#environment variables
|
#environment variables
|
||||||
.env
|
.env
|
||||||
nohup.out
|
nohup.out
|
||||||
|
windy-*.txt
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ const mjsSources = [
|
||||||
'server/scripts/modules/hourly-graph.mjs',
|
'server/scripts/modules/hourly-graph.mjs',
|
||||||
'server/scripts/modules/localforecast.mjs',
|
'server/scripts/modules/localforecast.mjs',
|
||||||
'server/scripts/modules/radar.mjs',
|
'server/scripts/modules/radar.mjs',
|
||||||
|
'server/scripts/modules/groundview.mjs',
|
||||||
'server/scripts/modules/regionalforecast.mjs',
|
'server/scripts/modules/regionalforecast.mjs',
|
||||||
'server/scripts/modules/travelforecast.mjs',
|
'server/scripts/modules/travelforecast.mjs',
|
||||||
'server/scripts/modules/progress.mjs',
|
'server/scripts/modules/progress.mjs',
|
||||||
|
|
|
||||||
32
index.mjs
32
index.mjs
|
|
@ -20,6 +20,7 @@ import OVERRIDES from './src/overrides.mjs';
|
||||||
import cache from './proxy/cache.mjs';
|
import cache from './proxy/cache.mjs';
|
||||||
import devTools from './src/com.chrome.devtools.mjs';
|
import devTools from './src/com.chrome.devtools.mjs';
|
||||||
import { discoverThemes } from './src/theme-discovery.mjs';
|
import { discoverThemes } from './src/theme-discovery.mjs';
|
||||||
|
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -252,6 +253,37 @@ if (!process.env?.STATIC) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/ground-view', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const lat = parseFloat(req.query.lat);
|
||||||
|
const lon = parseFloat(req.query.lon);
|
||||||
|
const city = String(req.query.city ?? '').trim();
|
||||||
|
|
||||||
|
if (Number.isNaN(lat) || Number.isNaN(lon) || !city) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
webcam: null,
|
||||||
|
error: 'Missing or invalid lat/lon/city',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = await loadWindyApiKey();
|
||||||
|
const webcam = await findNearestWindyWebcam(lat, lon, city, apiKey);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
webcam,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
webcam: null,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use('/api/', weatherProxy);
|
app.use('/api/', weatherProxy);
|
||||||
|
|
||||||
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { registerHiddenSetting } from './modules/share.mjs';
|
||||||
import settings from './modules/settings.mjs';
|
import settings from './modules/settings.mjs';
|
||||||
import './modules/utils/theme.mjs';
|
import './modules/utils/theme.mjs';
|
||||||
import './modules/latestobservations.mjs';
|
import './modules/latestobservations.mjs';
|
||||||
|
import './modules/groundview.mjs';
|
||||||
import AutoComplete from './modules/autocomplete.mjs';
|
import AutoComplete from './modules/autocomplete.mjs';
|
||||||
import { loadAllData } from './modules/utils/data-loader.mjs';
|
import { loadAllData } from './modules/utils/data-loader.mjs';
|
||||||
import { debugFlag } from './modules/utils/debug.mjs';
|
import { debugFlag } from './modules/utils/debug.mjs';
|
||||||
|
|
|
||||||
94
server/scripts/modules/groundview.mjs
Normal file
94
server/scripts/modules/groundview.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import STATUS from './status.mjs';
|
||||||
|
import WeatherDisplay from './weatherdisplay.mjs';
|
||||||
|
import { registerDisplay } from './navigation.mjs';
|
||||||
|
import { safeJson } from './utils/fetch.mjs';
|
||||||
|
import { withBasePath } from './utils/base-path.mjs';
|
||||||
|
|
||||||
|
class GroundView extends WeatherDisplay {
|
||||||
|
constructor(navId, elemId) {
|
||||||
|
super(navId, elemId, 'Ground View', true);
|
||||||
|
this.refreshTime = 5 * 60 * 1000;
|
||||||
|
this.requestToken = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData(weatherParameters, refresh) {
|
||||||
|
const superResult = super.getData(weatherParameters, refresh);
|
||||||
|
this.setAutoReload();
|
||||||
|
const requestToken = ++this.requestToken;
|
||||||
|
if (!this.weatherParameters?.latitude || !this.weatherParameters?.longitude || !this.weatherParameters?.city) {
|
||||||
|
this.setStatus(STATUS.noData);
|
||||||
|
return superResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(withBasePath('api/ground-view'), window.location.origin);
|
||||||
|
url.searchParams.set('lat', this.weatherParameters.latitude);
|
||||||
|
url.searchParams.set('lon', this.weatherParameters.longitude);
|
||||||
|
url.searchParams.set('city', this.weatherParameters.city);
|
||||||
|
|
||||||
|
const response = await safeJson(url.toString(), {
|
||||||
|
retryCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestToken !== this.requestToken) {
|
||||||
|
return superResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response?.success) {
|
||||||
|
this.data = null;
|
||||||
|
this.setStatus(STATUS.failed);
|
||||||
|
return superResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = {
|
||||||
|
webcam: response.webcam,
|
||||||
|
hasWebcam: Boolean(response.webcam?.imageUrl),
|
||||||
|
};
|
||||||
|
this.setStatus(STATUS.loaded);
|
||||||
|
return superResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetViewState() {
|
||||||
|
const image = this.elem.querySelector('.ground-view-image');
|
||||||
|
const label = this.elem.querySelector('.ground-view-label');
|
||||||
|
const empty = this.elem.querySelector('.ground-view-empty');
|
||||||
|
const media = this.elem.querySelector('.ground-view-media');
|
||||||
|
|
||||||
|
image.removeAttribute('src');
|
||||||
|
image.alt = '';
|
||||||
|
label.textContent = '';
|
||||||
|
empty.textContent = '';
|
||||||
|
media.classList.add('hidden');
|
||||||
|
label.classList.add('hidden');
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCanvas() {
|
||||||
|
super.drawCanvas();
|
||||||
|
const image = this.elem.querySelector('.ground-view-image');
|
||||||
|
const label = this.elem.querySelector('.ground-view-label');
|
||||||
|
const empty = this.elem.querySelector('.ground-view-empty');
|
||||||
|
const media = this.elem.querySelector('.ground-view-media');
|
||||||
|
|
||||||
|
this.resetViewState();
|
||||||
|
|
||||||
|
if (this.data?.hasWebcam) {
|
||||||
|
image.src = this.data.webcam.imageUrl;
|
||||||
|
image.alt = this.data.webcam.label;
|
||||||
|
label.textContent = this.data.webcam.label;
|
||||||
|
media.classList.remove('hidden');
|
||||||
|
label.classList.remove('hidden');
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
image.removeAttribute('src');
|
||||||
|
label.textContent = '';
|
||||||
|
media.classList.add('hidden');
|
||||||
|
label.classList.add('hidden');
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
empty.textContent = 'No Ground View Available';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.finishDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDisplay(new GroundView(12, 'ground-view'));
|
||||||
57
server/styles/scss/_ground-view.scss
Normal file
57
server/styles/scss/_ground-view.scss
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
@use 'shared/_colors' as c;
|
||||||
|
@use 'shared/_utils' as u;
|
||||||
|
|
||||||
|
.weather-display .main.ground-view {
|
||||||
|
&.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px 18px;
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ground-view-media {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin-right: 60px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ground-view-image {
|
||||||
|
display: block;
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 150px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 2px solid hsl(0deg 0% 100% / 35%);
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ground-view-label,
|
||||||
|
.ground-view-empty {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Star4000';
|
||||||
|
font-size: 15pt;
|
||||||
|
color: #ff0;
|
||||||
|
padding: 10px;
|
||||||
|
margin-right: 80px;
|
||||||
|
text-shadow: 3px 3px 0 #000,-1.5px -1.5px 0 #000,0 -1.5px 0 #000,1.5px -1.5px 0 #000,1.5px 0 0 #000,1.5px 1.5px 0 #000,0 1.5px 0 #000,-1.5px 1.5px 0 #000,-1.5px 0 0 #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ground-view-empty {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
@use 'local-forecast';
|
@use 'local-forecast';
|
||||||
@use 'progress';
|
@use 'progress';
|
||||||
@use 'radar';
|
@use 'radar';
|
||||||
|
@use 'ground-view';
|
||||||
@use 'regional-forecast';
|
@use 'regional-forecast';
|
||||||
@use 'almanac';
|
@use 'almanac';
|
||||||
@use 'hazards';
|
@use 'hazards';
|
||||||
|
|
|
||||||
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
150
src/windy-webcams.mjs
Normal file
150
src/windy-webcams.mjs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
|
let cachedApiKey = null;
|
||||||
|
|
||||||
|
const SEARCH_RADII_KM = [20, 50, 100];
|
||||||
|
|
||||||
|
const truncateTitle = (title, maxLength = 21) => {
|
||||||
|
if (!title || title.length <= maxLength) return title;
|
||||||
|
return `${title.slice(0, maxLength - 3)}...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadWindyApiKey = async () => {
|
||||||
|
if (cachedApiKey !== null) return cachedApiKey;
|
||||||
|
const key = (await readFile('./windy-api-key.txt', 'utf8')).trim();
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Missing Windy API key');
|
||||||
|
}
|
||||||
|
cachedApiKey = key;
|
||||||
|
return cachedApiKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWindyNearbyUrl = (lat, lon, radiusKm, limit = 10, offset = 0) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
lang: 'en',
|
||||||
|
limit: String(limit),
|
||||||
|
offset: String(offset),
|
||||||
|
nearby: `${lat},${lon},${radiusKm}`,
|
||||||
|
include: 'location,images,player',
|
||||||
|
});
|
||||||
|
return `https://api.windy.com/webcams/api/v3/webcams?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWindyWebcamsNearby = async (lat, lon, radiusKm, apiKey, options = {}) => {
|
||||||
|
const url = buildWindyNearbyUrl(lat, lon, radiusKm, options.limit ?? 10, options.offset ?? 0);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'x-windy-api-key': apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Windy webcams request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractWindyWebcamCoordinates = (webcam) => {
|
||||||
|
const lat = webcam?.location?.latitude;
|
||||||
|
const lon = webcam?.location?.longitude;
|
||||||
|
if (typeof lat !== 'number' || typeof lon !== 'number') return null;
|
||||||
|
return { lat, lon };
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractWindyWebcamTitle = (webcam) => webcam?.title?.trim() || 'Nearby Webcam';
|
||||||
|
|
||||||
|
const extractWindyWebcamMedia = (webcam) => {
|
||||||
|
const imageCandidates = [
|
||||||
|
webcam?.images?.current?.preview,
|
||||||
|
webcam?.images?.daylight?.preview,
|
||||||
|
webcam?.images?.current?.thumbnail,
|
||||||
|
webcam?.images?.daylight?.thumbnail,
|
||||||
|
webcam?.images?.current?.icon,
|
||||||
|
webcam?.images?.daylight?.icon,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const timelapseCandidates = [
|
||||||
|
webcam?.player?.day,
|
||||||
|
webcam?.player?.month,
|
||||||
|
webcam?.player?.year,
|
||||||
|
webcam?.player?.lifetime,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const imageUrl = imageCandidates[0] ?? null;
|
||||||
|
const timelapseUrl = timelapseCandidates[0] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageUrl,
|
||||||
|
timelapseUrl,
|
||||||
|
mediaType: imageUrl ? 'image' : 'none',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUsableWindyWebcam = (webcam) => {
|
||||||
|
if ((webcam?.status ?? '').toLowerCase() !== 'active') return false;
|
||||||
|
if (!extractWindyWebcamCoordinates(webcam)) return false;
|
||||||
|
return Boolean(extractWindyWebcamMedia(webcam).imageUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateDistanceKm = (lat1, lon1, lat2, lon2) => {
|
||||||
|
const R = 6371;
|
||||||
|
const toRadians = (degrees) => (degrees * Math.PI) / 180;
|
||||||
|
const dLat = toRadians(lat2 - lat1);
|
||||||
|
const dLon = toRadians(lon2 - lon1);
|
||||||
|
const a = Math.sin(dLat / 2) ** 2
|
||||||
|
+ Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeWindyWebcam = (webcam, city, sourceLat, sourceLon) => {
|
||||||
|
const coords = extractWindyWebcamCoordinates(webcam);
|
||||||
|
if (!coords) return null;
|
||||||
|
const media = extractWindyWebcamMedia(webcam);
|
||||||
|
const title = truncateTitle(extractWindyWebcamTitle(webcam));
|
||||||
|
return {
|
||||||
|
id: String(webcam.webcamId ?? ''),
|
||||||
|
title,
|
||||||
|
city,
|
||||||
|
label: `${title} - ${city}`,
|
||||||
|
distanceKm: calculateDistanceKm(sourceLat, sourceLon, coords.lat, coords.lon),
|
||||||
|
imageUrl: media.imageUrl,
|
||||||
|
timelapseUrl: media.timelapseUrl,
|
||||||
|
mediaType: media.mediaType,
|
||||||
|
location: {
|
||||||
|
city: webcam?.location?.city ?? '',
|
||||||
|
region: webcam?.location?.region ?? '',
|
||||||
|
country: webcam?.location?.country ?? '',
|
||||||
|
},
|
||||||
|
lastUpdatedOn: webcam?.lastUpdatedOn ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickBestWindyWebcam = (webcams, city, sourceLat, sourceLon) => webcams
|
||||||
|
.filter(isUsableWindyWebcam)
|
||||||
|
.map((webcam) => normalizeWindyWebcam(webcam, city, sourceLat, sourceLon))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.distanceKm - b.distanceKm)[0] ?? null;
|
||||||
|
|
||||||
|
const findNearestWindyWebcam = async (lat, lon, city, apiKey) => {
|
||||||
|
for (const radiusKm of SEARCH_RADII_KM) {
|
||||||
|
const result = await fetchWindyWebcamsNearby(lat, lon, radiusKm, apiKey);
|
||||||
|
const webcam = pickBestWindyWebcam(result?.webcams ?? [], city, lat, lon);
|
||||||
|
if (webcam) return webcam;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
loadWindyApiKey,
|
||||||
|
buildWindyNearbyUrl,
|
||||||
|
fetchWindyWebcamsNearby,
|
||||||
|
extractWindyWebcamCoordinates,
|
||||||
|
extractWindyWebcamTitle,
|
||||||
|
extractWindyWebcamMedia,
|
||||||
|
isUsableWindyWebcam,
|
||||||
|
calculateDistanceKm,
|
||||||
|
normalizeWindyWebcam,
|
||||||
|
pickBestWindyWebcam,
|
||||||
|
findNearestWindyWebcam,
|
||||||
|
};
|
||||||
|
|
@ -139,6 +139,9 @@
|
||||||
<div id="radar-html" class="weather-display">
|
<div id="radar-html" class="weather-display">
|
||||||
<%- include('partials/radar.ejs') %>
|
<%- include('partials/radar.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="ground-view-html" class="weather-display">
|
||||||
|
<%- include('partials/ground-view.ejs') %>
|
||||||
|
</div>
|
||||||
<div id="hazards-html" class="weather-display">
|
<div id="hazards-html" class="weather-display">
|
||||||
<%- include('partials/hazards.ejs') %>
|
<%- include('partials/hazards.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
8
views/partials/ground-view.ejs
Normal file
8
views/partials/ground-view.ejs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<%- include('header.ejs', {titleDual:{ top: 'Ground' , bottom: 'View' }, hasTime: true}) %>
|
||||||
|
<div class="main has-box ground-view">
|
||||||
|
<div class="ground-view-media hidden">
|
||||||
|
<img class="ground-view-image" src="" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="ground-view-label hidden"></div>
|
||||||
|
<div class="ground-view-empty hidden"></div>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue