Compare commits
5 commits
8958ef4d38
...
9f94bd439e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f94bd439e | |||
| 845b34992e | |||
| cb4bdbf92d | |||
| 552526d387 | |||
| 598a60c7f5 |
24 changed files with 393 additions and 32 deletions
2
.github/ISSUE_TEMPLATE/naming _issue.md
vendored
2
.github/ISSUE_TEMPLATE/naming _issue.md
vendored
|
|
@ -10,4 +10,4 @@ This form is not for reporting a location that you can not find from the search
|
|||
|
||||
Use this form to help us rename airports, points of interest and other data provided from the API (rarely updated) to a better name. For example the airport in Broomfield colorado was renamed from "Jeffco" in the API to "Rocky Mountain Metro" it's new name.
|
||||
|
||||
You can also make a pull request on the `[station-overrides.mjs](https://github.com/netbymatt/ws4kp/blob/main/datagenerators/stations-states.mjs)` file which includes instructions on how to make the change directly. This is the preferred method.
|
||||
You can also make a pull request on the `[station-overrides.mjs](https://codeberg.org/markmental/ws4kp-linhanced/src/branch/main/datagenerators/stations-states.mjs)` file which includes instructions on how to make the change directly. This is the preferred method.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,3 +17,4 @@ dist/*
|
|||
#environment variables
|
||||
.env
|
||||
nohup.out
|
||||
windy-*.txt
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -1,5 +1,3 @@
|
|||

|
||||
|
||||
# ws4kp-linhanced
|
||||
|
||||
`ws4kp-linhanced` is a Linux-focused fork of [`netbymatt/ws4kp`](https://github.com/netbymatt/ws4kp) by `markmental`.
|
||||
|
|
@ -49,6 +47,7 @@ Major features currently in this fork:
|
|||
* global RainViewer radar on a cached world basemap
|
||||
* global `Regional Observations` and nearby-city displays backed by expanded worldwide city coverage
|
||||
* `Latest Observations` screen for nearby city temperatures, conditions, and wind
|
||||
* `Ground View` screen powered by nearby Windy webcams (requires API key, see below)
|
||||
* Travel Forecast rebuilt around region buckets with a global fallback
|
||||
* optional screen-specific audio playback for supported displays
|
||||
* wind/gust-based condition inference for better condition names and icon matches when upstream data is too generic
|
||||
|
|
@ -59,8 +58,10 @@ Major features currently in this fork:
|
|||
|
||||
Some NOAA-only products are still retained where they remain useful and there is no replacement yet:
|
||||
|
||||
* Hazards
|
||||
* SPC Outlook
|
||||
* Hazards (US only)
|
||||
* SPC Outlook (US only)
|
||||
|
||||
For locations outside the US, a derived alert system provides best-effort hazard warnings based on available meteorological data when official NOAA products do not apply.
|
||||
|
||||
## Themes
|
||||
|
||||
|
|
@ -109,6 +110,14 @@ Then open:
|
|||
http://localhost:8080/
|
||||
```
|
||||
|
||||
## API Keys
|
||||
|
||||
### Windy Webcams (Ground View)
|
||||
|
||||
The `Ground View` screen requires a Windy Webcams API key. Create a file named `windy-api-key.txt` in the project root and paste your API key as plain text. If this file is missing, the `Ground View` screen will not work.
|
||||
|
||||
You can obtain a free API key from [Windy Webcams API](https://api.windy.com/webcams).
|
||||
|
||||
## Running Modes
|
||||
|
||||
This fork supports two main runtime styles.
|
||||
|
|
@ -143,7 +152,9 @@ Or upload the generated `dist/` directory to your web server after running:
|
|||
npm run build
|
||||
```
|
||||
|
||||
The static build has been adjusted so frontend-generated paths no longer assume deployment at `/`, which makes subdirectory hosting more practical. **Also, features that require a backend server like the on-disk cache and the fastfetch + LWN Linux News integration will not work when running the static build by itself.**
|
||||
The static build has been adjusted so frontend-generated paths no longer assume deployment at `/`, which makes subdirectory hosting more practical. **Also, features that require a backend server like the on-disk cache, Fastfetch-backed Server Observations, LWN Linux News, and `Ground View` will not work when running the static build by itself.**
|
||||
|
||||
The public demo at [https://mentalnet.xyz/ws4kp-linhanced-demo/](https://mentalnet.xyz/ws4kp-linhanced-demo/) is intentionally served as a static build, so the `Linux News`, `Server Observations`, and `Ground View` screens will not work there.
|
||||
|
||||
## International Support
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import https from 'https';
|
|||
|
||||
const get = (url) => new Promise((resolve, reject) => {
|
||||
const headers = {};
|
||||
headers['user-agent'] = '(WeatherStar 4000+ data generator, ws4000@netbymatt.com)';
|
||||
headers['user-agent'] = '(WeatherStar 4000+: Linhanced data generator, marky611@gmail.com)';
|
||||
|
||||
https.get(url, {
|
||||
headers,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const mjsSources = [
|
|||
'server/scripts/modules/hourly-graph.mjs',
|
||||
'server/scripts/modules/localforecast.mjs',
|
||||
'server/scripts/modules/radar.mjs',
|
||||
'server/scripts/modules/groundview.mjs',
|
||||
'server/scripts/modules/regionalforecast.mjs',
|
||||
'server/scripts/modules/travelforecast.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 devTools from './src/com.chrome.devtools.mjs';
|
||||
import { discoverThemes } from './src/theme-discovery.mjs';
|
||||
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
||||
|
||||
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);
|
||||
|
||||
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
||||
|
|
|
|||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "ws4kp",
|
||||
"version": "6.5.4",
|
||||
"name": "ws4kp-linhanced",
|
||||
"version": "0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ws4kp",
|
||||
"version": "6.5.4",
|
||||
"name": "ws4kp-linhanced",
|
||||
"version": "0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.0.1",
|
||||
|
|
|
|||
14
package.json
14
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "ws4kp",
|
||||
"version": "6.5.4",
|
||||
"description": "Welcome to the WeatherStar 4000+ project page!",
|
||||
"name": "ws4kp-linhanced",
|
||||
"version": "0.1",
|
||||
"description": "WeatherStar 4000+: Linhanced - A Linux-focused fork of the WeatherStar 4000+ project",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -18,14 +18,14 @@
|
|||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/netbymatt/ws4kp.git"
|
||||
"url": "git+https://codeberg.org/markmental/ws4kp-linhanced"
|
||||
},
|
||||
"author": "Matt Walsh",
|
||||
"author": "markmental",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/netbymatt/ws4kp/issues"
|
||||
"url": "https://codeberg.org/markmental/ws4kp-linhanced/issues"
|
||||
},
|
||||
"homepage": "https://github.com/netbymatt/ws4kp#readme",
|
||||
"homepage": "https://codeberg.org/markmental/ws4kp-linhanced",
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"ajv": "^8.17.1",
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ class HttpCache {
|
|||
async makeUpstreamRequest(req, res, fullUrl, options = {}, cacheResult = null) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = {
|
||||
'user-agent': options.userAgent || '(WeatherStar 4000+, ws4000@netbymatt.com)',
|
||||
'user-agent': options.userAgent || '(WeatherStar 4000+: Linhanced, marky611@gmail.com)',
|
||||
accept: req.headers?.accept || '*/*',
|
||||
...options.headers,
|
||||
};
|
||||
|
|
|
|||
BIN
server/alert/server-obs.mp3
Normal file
BIN
server/alert/server-obs.mp3
Normal file
Binary file not shown.
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "WeatherStar 4000+",
|
||||
"name": "WeatherStar 4000+: Linhanced",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/logos/logo192.png",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { registerHiddenSetting } from './modules/share.mjs';
|
|||
import settings from './modules/settings.mjs';
|
||||
import './modules/utils/theme.mjs';
|
||||
import './modules/latestobservations.mjs';
|
||||
import './modules/groundview.mjs';
|
||||
import AutoComplete from './modules/autocomplete.mjs';
|
||||
import { loadAllData } from './modules/utils/data-loader.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'));
|
||||
|
|
@ -381,6 +381,7 @@ const screenAudioMap = {
|
|||
'travel': 'travel-forecast.mp3',
|
||||
'hourly-graph': 'hourly-graph.mp3',
|
||||
'hourly': 'hourly-forecast.mp3',
|
||||
'server-observations': 'server-obs.mp3',
|
||||
'current-weather': 'current-conditions.mp3',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
|||
// - Static mode (direct requests): Only add User-Agent for api.weather.gov, avoiding CORS preflight issues with other services
|
||||
const shouldAddUserAgent = !shouldExcludeUserAgent && (window.WS4KP_SERVER_AVAILABLE || _url.toString().match(/api\.weather\.gov/));
|
||||
if (shouldAddUserAgent) {
|
||||
headers['user-agent'] = 'Weatherstar 4000+; weatherstar@netbymatt.com';
|
||||
headers['user-agent'] = 'WeatherStar 4000+: Linhanced; marky611@gmail.com';
|
||||
}
|
||||
|
||||
// combine default and provided parameters
|
||||
|
|
|
|||
56
server/styles/scss/_ground-view.scss
Normal file
56
server/styles/scss/_ground-view.scss
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
@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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -380,7 +380,7 @@ body {
|
|||
|
||||
.title {
|
||||
font-family: Star4000 Large;
|
||||
font-size: 36px;
|
||||
font-size: 26px;
|
||||
color: yellow;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@
|
|||
box-sizing: border-box;
|
||||
height: 310px;
|
||||
overflow: hidden;
|
||||
line-height: 26px;
|
||||
line-height: 20px;
|
||||
width: 90%;
|
||||
margin-top: 25px;
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
@use 'local-forecast';
|
||||
@use 'progress';
|
||||
@use 'radar';
|
||||
@use 'ground-view';
|
||||
@use 'regional-forecast';
|
||||
@use 'almanac';
|
||||
@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,
|
||||
};
|
||||
|
|
@ -4,18 +4,18 @@
|
|||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>WeatherStar 4000+</title>
|
||||
<title>WeatherStar 4000+: Linhanced</title>
|
||||
<meta name="description" content="Web based WeatherStar 4000 simulator that reports current and forecast weather conditions plus a few extras!" />
|
||||
<meta name="keywords" content="WeatherStar 4000+" />
|
||||
<meta name="author" content="Matt Walsh" />
|
||||
<meta name="application-name" content="WeatherStar 4000+" />
|
||||
<meta name="keywords" content="WeatherStar 4000+, Linhanced, WeatherStar" />
|
||||
<meta name="author" content="markmental" />
|
||||
<meta name="application-name" content="WeatherStar 4000+: Linhanced" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1">
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="icon" href="images/logos/logo192.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="images/logos/app-icon-180.png" />
|
||||
<meta property="og:image" content="https://weatherstar.netbymatt.com/images/social/1200x600.png">
|
||||
<meta property="og:image" content="https://codeberg.org/markmental/ws4kp-linhanced/raw/branch/main/server/images/social/1200x600.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="627">
|
||||
<link rel="prefetch" href="fonts/Star4000.woff" as="font" type="font/woff" crossorigin>
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
<div id="container">
|
||||
<div id="loading" width="640" height="480">
|
||||
<div>
|
||||
<div class="title">WeatherStar 4000+</div>
|
||||
<div class="title">WeatherStar 4000+: Linhanced</div>
|
||||
<div class="version">v<%- version %></div>
|
||||
<div class="instructions">Enter your location above to continue</div>
|
||||
</div>
|
||||
|
|
@ -139,6 +139,9 @@
|
|||
<div id="radar-html" class="weather-display">
|
||||
<%- include('partials/radar.ejs') %>
|
||||
</div>
|
||||
<div id="ground-view-html" class="weather-display">
|
||||
<%- include('partials/ground-view.ejs') %>
|
||||
</div>
|
||||
<div id="hazards-html" class="weather-display">
|
||||
<%- include('partials/hazards.ejs') %>
|
||||
</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>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<%- include('header.ejs', {titleDual:{ top: 'WeatherStar' , bottom: '4000+ v' + version }, hasTime: true}) %>
|
||||
<%- include('header.ejs', {titleDual:{ top: 'WeatherStar' , bottom: '4000+: LH v' + version }, hasTime: true}) %>
|
||||
<div class="main has-box progress">
|
||||
<div class="container">
|
||||
<div class="item template">
|
||||
|
|
@ -19,4 +19,4 @@
|
|||
<div class="progress-bar"></div>
|
||||
<div class="cover"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue