Compare commits

...

5 commits

Author SHA1 Message Date
9f94bd439e README update for 0.1
Some checks are pending
build-docker / Build Image (push) Waiting to run
2026-04-14 13:56:12 -04:00
845b34992e Rebrand for linhanced 0.1 2026-04-14 13:47:51 -04:00
cb4bdbf92d Tweak to make ground view subtitle smaller 2026-04-14 13:09:22 -04:00
552526d387 Fix CSS layout on intro screen 2026-04-14 12:53:01 -04:00
598a60c7f5 Add Ground View screen for server edition, powered by the Windy API https://api.windy.com/ 2026-04-14 12:17:42 -04:00
24 changed files with 393 additions and 32 deletions

View file

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

@ -17,3 +17,4 @@ dist/*
#environment variables #environment variables
.env .env
nohup.out nohup.out
windy-*.txt

View file

@ -1,5 +1,3 @@
![Weatherstar 4000+ Current Conditions](https://github.com/netbymatt/ws4kp/blob/main/server/images/social/1200x600.png)
# ws4kp-linhanced # ws4kp-linhanced
`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`.
@ -49,6 +47,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
* `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
* wind/gust-based condition inference for better condition names and icon matches when upstream data is too generic * 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: Some NOAA-only products are still retained where they remain useful and there is no replacement yet:
* Hazards * Hazards (US only)
* SPC Outlook * 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 ## Themes
@ -109,6 +110,14 @@ Then open:
http://localhost:8080/ 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 ## Running Modes
This fork supports two main runtime styles. 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 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 ## International Support

View file

@ -3,7 +3,7 @@ import https from 'https';
const get = (url) => new Promise((resolve, reject) => { const get = (url) => new Promise((resolve, reject) => {
const headers = {}; 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, { https.get(url, {
headers, headers,

View file

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

View file

@ -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
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "ws4kp", "name": "ws4kp-linhanced",
"version": "6.5.4", "version": "0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ws4kp", "name": "ws4kp-linhanced",
"version": "6.5.4", "version": "0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dotenv": "^17.0.1", "dotenv": "^17.0.1",

View file

@ -1,7 +1,7 @@
{ {
"name": "ws4kp", "name": "ws4kp-linhanced",
"version": "6.5.4", "version": "0.1",
"description": "Welcome to the WeatherStar 4000+ project page!", "description": "WeatherStar 4000+: Linhanced - A Linux-focused fork of the WeatherStar 4000+ project",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -18,14 +18,14 @@
}, },
"repository": { "repository": {
"type": "git", "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", "license": "MIT",
"bugs": { "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": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"ajv": "^8.17.1", "ajv": "^8.17.1",

View file

@ -311,7 +311,7 @@ class HttpCache {
async makeUpstreamRequest(req, res, fullUrl, options = {}, cacheResult = null) { async makeUpstreamRequest(req, res, fullUrl, options = {}, cacheResult = null) {
return new Promise((resolve) => { return new Promise((resolve) => {
const headers = { 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 || '*/*', accept: req.headers?.accept || '*/*',
...options.headers, ...options.headers,
}; };

BIN
server/alert/server-obs.mp3 Normal file

Binary file not shown.

View file

@ -1,5 +1,5 @@
{ {
"name": "WeatherStar 4000+", "name": "WeatherStar 4000+: Linhanced",
"icons": [ "icons": [
{ {
"src": "/images/logos/logo192.png", "src": "/images/logos/logo192.png",

View file

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

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

View file

@ -381,6 +381,7 @@ const screenAudioMap = {
'travel': 'travel-forecast.mp3', 'travel': 'travel-forecast.mp3',
'hourly-graph': 'hourly-graph.mp3', 'hourly-graph': 'hourly-graph.mp3',
'hourly': 'hourly-forecast.mp3', 'hourly': 'hourly-forecast.mp3',
'server-observations': 'server-obs.mp3',
'current-weather': 'current-conditions.mp3', 'current-weather': 'current-conditions.mp3',
}; };

View file

@ -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 // - 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/)); const shouldAddUserAgent = !shouldExcludeUserAgent && (window.WS4KP_SERVER_AVAILABLE || _url.toString().match(/api\.weather\.gov/));
if (shouldAddUserAgent) { if (shouldAddUserAgent) {
headers['user-agent'] = 'Weatherstar 4000+; weatherstar@netbymatt.com'; headers['user-agent'] = 'WeatherStar 4000+: Linhanced; marky611@gmail.com';
} }
// combine default and provided parameters // combine default and provided parameters

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

View file

@ -380,7 +380,7 @@ body {
.title { .title {
font-family: Star4000 Large; font-family: Star4000 Large;
font-size: 36px; font-size: 26px;
color: yellow; color: yellow;
margin-bottom: 0px; margin-bottom: 0px;
} }

View file

@ -13,7 +13,9 @@
box-sizing: border-box; box-sizing: border-box;
height: 310px; height: 310px;
overflow: hidden; overflow: hidden;
line-height: 26px; line-height: 20px;
width: 90%;
margin-top: 25px;
.item { .item {
position: relative; position: relative;

View file

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

File diff suppressed because one or more lines are too long

150
src/windy-webcams.mjs Normal file
View 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,
};

View file

@ -4,18 +4,18 @@
<head> <head>
<meta charset="utf-8" /> <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="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="keywords" content="WeatherStar 4000+, Linhanced, WeatherStar" />
<meta name="author" content="Matt Walsh" /> <meta name="author" content="markmental" />
<meta name="application-name" content="WeatherStar 4000+" /> <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="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1">
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<link rel="icon" href="images/logos/logo192.png" /> <link rel="icon" href="images/logos/logo192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="images/logos/app-icon-180.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:width" content="1200">
<meta property="og:image:height" content="627"> <meta property="og:image:height" content="627">
<link rel="prefetch" href="fonts/Star4000.woff" as="font" type="font/woff" crossorigin> <link rel="prefetch" href="fonts/Star4000.woff" as="font" type="font/woff" crossorigin>
@ -98,7 +98,7 @@
<div id="container"> <div id="container">
<div id="loading" width="640" height="480"> <div id="loading" width="640" height="480">
<div> <div>
<div class="title">WeatherStar 4000+</div> <div class="title">WeatherStar 4000+: Linhanced</div>
<div class="version">v<%- version %></div> <div class="version">v<%- version %></div>
<div class="instructions">Enter your location above to continue</div> <div class="instructions">Enter your location above to continue</div>
</div> </div>
@ -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>

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

View file

@ -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="main has-box progress">
<div class="container"> <div class="container">
<div class="item template"> <div class="item template">