Skip to content

Commit

Permalink
Merge pull request #4 from mwood77/internationalize-ws4kp
Browse files Browse the repository at this point in the history
Adapt ws4kp to use Open Meteo
  • Loading branch information
mwood77 authored Feb 23, 2025
2 parents 8625fe7 + 8673e3a commit c842d77
Show file tree
Hide file tree
Showing 19 changed files with 5,388 additions and 670 deletions.
6 changes: 3 additions & 3 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# These are supported funding model platforms

github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: [mwood77] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
ko_fi: mwood77 # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: ['https://buymeacoffee.com/temp.exp']
custom: ['https://www.paypal.com/paypalme/kklarkson'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
2 changes: 1 addition & 1 deletion .github/workflows/build-docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/netbymatt/ws4kp
ghcr.io/mwood77/ws4kp-international
flavor: |
latest=false
tags: |
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/github-pages.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: GitHub Pages Deployment

on:
push:
branches:
- main

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- name: Checkout Repository
uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 22

- name: Install Dependencies
run: npm install

- name: Start Deployment Server
run: npm run pages
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
**/debug.log
server/scripts/custom.js
server/scripts/custom.js
.DS_Store
41 changes: 31 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,60 @@
# WeatherStar 4000+
# WeatherStar 4000+ (International)

A live version of this project is available at https://weatherstar.netbymatt.com
This project is a fork of [`ws4kp`](https://github.com/netbymatt/ws4kp) by [@netbymatt](https://github.com/netbymatt), which has been refactored to run on [Open Meteo's aggregated forecast API](https://open-meteo.com/en/docs). This means this fork of the `ws4kp` works for locations outside of the USA.

<!-- A live version of this project is available at https://weatherstar.netbymatt.com -->

## About

This project aims to bring back the feel of the 90's with a weather forecast that has the look and feel of The Weather Channel at that time but available in a modern way. This is by no means intended to be a perfect emulation of the WeatherStar 4000, the hardware that produced those wonderful blue and orange graphics you saw during the local forecast on The Weather Channel. If you would like a much more accurate project please see the [WS4000 Simulator](http://www.taiganet.com/). Instead, this project intends to create a simple to use interface with minimal configuration fuss. Some changes have been made to the screens available because either more or less forecast information is available today than was in the 90's. Most of these changes are captured in sections below.
This project aims to bring back the feel of the 90's with a weather forecast that has the look and feel of The Weather Channel at that time but available in a modern way.

This is by no means intended to be a perfect emulation of the WeatherStar 4000, the hardware that produced those wonderful blue and orange graphics you saw during the local forecast on The Weather Channel. If you would like a much more accurate project please see the [WS4000 Simulator](http://www.taiganet.com/).

Instead, this project intends to create a simple to use interface with minimal configuration fuss. Some changes have been made to the screens available because either more or less forecast information is available today than was in the 90's. Most of these changes are captured in sections below.

## Acknowledgements

This project is based on the work of [Mike Battaglia](https://github.com/vbguyny/ws4kp). It was forked from his work in August 2020.
This project is based on the work of [Mike Battaglia](https://github.com/vbguyny/ws4kp) and [@netbymatt](https://github.com/netbymatt). This internationalized version was forked in February 2025.

* Mike Battaglia for the original project and all of the code which draws the weather displays. This code remains largely intact and was a huge amount of work to get exactly right. He's also responsible for all of the background graphics including the maps used in the application.
* The team at [TWCClassics](https://twcclassics.com/) for several resources.
* A [font](https://twcclassics.com/downloads.html) set used on the original WeatherStar 4000
* [Icon](https://twcclassics.com/downloads.html) sets
* Countless photos and videos of WeatherStar 4000 forecasts used as references.
* [@netbymatt](https://github.com/netbymatt) for modernizing & module'ing Mike's original project.

## Run Your WeatherStar
There are a lot of CORS considerations and issues with api.weather.gov that are easiest to deal with by running a local server to see this in action (or use the live link above). You'll need Node.js >12.0 to run the local server.

To run via Node locally:
### To run via Node locally:
```
git clone https://github.com/netbymatt/ws4kp.git
git clone https://github.com/mwood77/ws4kp-international.git
cd ws4kp
npm i
npm run start
```

To run via Docker:
### To run via Docker:
```
docker run -p 8080:8080 ghcr.io/netbymatt/ws4kp
docker run -p 8080:8080 ghcr.io/mwood77/ws4kp-international
```
Open your web browser: http://localhost:8080/

## Updates in 5.0
After running this project in either way, pen your web browser:
- http://localhost:8080/

## Updates in 6.0.0 (Internationalization w/ Open Meteo)
This is a significant divergence from 5.0.0 and is exclusive to this fork.

This migrates the project away from NOAA's USA exclusive weather API to [Open Meteo's global weather API](https://open-meteo.com/en/docs). This means that _this fork_ is capable of rendering weather data across the globe.

However, there are some caveats. Migrating to Open Meteo means that there is some loss of functionality, namely the loss of radar imagery, and observation station data, and hazards. This means that the following screens are no longer functional:
- Hazards
- Latest Observations
- Travel Forecast
- Regional Forecast
- Local Radar

## Updates in 5.0.0
The change to 5.0 changes from drawing the weather graphics on canvas elements and instead uses HTML and CSS to style all of the weather graphics. A lot of other changes and fixes were implemented at the same time.

* Replace all canvas elements with HTML and CSS
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "ws4kp",
"name": "ws4kp-international",
"version": "6.0.0",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.js",
Expand All @@ -8,7 +8,8 @@
"build:css": "sass --style=compressed ./server/styles/scss/main.scss ./server/styles/main.css",
"lint": "eslint ./server/scripts/**/*.mjs",
"lint:fix": "eslint --fix ./server/scripts/**/*.mjs",
"start": "nodemon index.js"
"start": "nodemon index.js",
"pages": "node index.js"
},
"repository": {
"type": "git",
Expand Down
Binary file added server/images/r/Logo3.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
151 changes: 41 additions & 110 deletions server/scripts/modules/currentweather.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
// current weather conditions display
import STATUS from './status.mjs';
import { loadImg, preloadImg } from './utils/image.mjs';
import { json } from './utils/fetch.mjs';
import { loadImg } from './utils/image.mjs';
import { directionToNSEW } from './utils/calc.mjs';
import { locationCleanup } from './utils/string.mjs';
import { getWeatherIconFromIconLink } from './icons.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
import {
celsiusToFahrenheit, kphToMph, pascalToInHg, metersToFeet, kilometersToMiles,
} from './utils/units.mjs';

// some stations prefixed do not provide all the necessary data
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
import { getConditionText } from './utils/weather.mjs';

class CurrentWeather extends WeatherDisplay {
constructor(navId, elemId) {
Expand All @@ -26,98 +19,41 @@ class CurrentWeather extends WeatherDisplay {
const superResult = super.getData(_weatherParameters);
const weatherParameters = _weatherParameters ?? this.weatherParameters;

// filter for 4-letter observation stations, only those contain sky conditions and thus an icon
const filteredStations = weatherParameters.stations.filter((station) => station?.properties?.stationIdentifier?.length === 4 && !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));

// Load the observations
let observations;
let station;

// station number counter
let stationNum = 0;
while (!observations && stationNum < filteredStations.length) {
// get the station
station = filteredStations[stationNum];
stationNum += 1;
try {
// station observations
// eslint-disable-next-line no-await-in-loop
observations = await json(`${station.id}/observations`, {
cors: true,
data: {
limit: 2,
},
retryCount: 3,
stillWaiting: () => this.stillWaiting(),
});

// test data quality
if (observations.features[0].properties.temperature.value === null
|| observations.features[0].properties.windSpeed.value === null
|| observations.features[0].properties.textDescription === null
|| observations.features[0].properties.textDescription === ''
|| observations.features[0].properties.icon === null
|| observations.features[0].properties.dewpoint.value === null
|| observations.features[0].properties.barometricPressure.value === null) {
observations = undefined;
throw new Error(`Unable to get observations: ${station.properties.stationIdentifier}, trying next station`);
}
} catch (error) {
console.error(error);
}
}
// test for data received
if (!observations) {
console.error('All current weather stations exhausted');
if (this.isEnabled) this.setStatus(STATUS.failed);
// send failed to subscribers
this.getDataCallback(undefined);
return;
}

// we only get here if there was no error above
this.data = parseData({ ...observations, station });
this.data = parseData(weatherParameters);
this.getDataCallback();

// stop here if we're disabled
if (!superResult) return;

// preload the icon
preloadImg(getWeatherIconFromIconLink(observations.features[0].properties.icon));
this.setStatus(STATUS.loaded);
}

async drawCanvas() {
super.drawCanvas();

let condition = this.data.observations.textDescription;
let condition = getConditionText(this.data.TextConditions);
if (condition.length > 15) {
condition = shortConditions(condition);
}

const iconImage = getWeatherIconFromIconLink(condition, this.data.timeZone);

const fill = {
temp: this.data.Temperature + String.fromCharCode(176),
condition,
wind: this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' '),
location: locationCleanup(this.data.station.properties.name).substr(0, 20),
location: this.data.city,
humidity: `${this.data.Humidity}%`,
dewpoint: this.data.DewPoint + String.fromCharCode(176),
ceiling: (this.data.Ceiling === 0 ? 'Unlimited' : this.data.Ceiling + this.data.CeilingUnit),
visibility: this.data.Visibility + this.data.VisibilityUnit,
pressure: `${this.data.Pressure} ${this.data.PressureDirection}`,
icon: { type: 'img', src: this.data.Icon },
icon: { type: 'img', src: iconImage },
};

if (this.data.WindGust) fill['wind-gusts'] = `Gusts to ${this.data.WindGust}`;

if (this.data.observations.heatIndex.value && this.data.HeatIndex !== this.data.Temperature) {
fill['heat-index-label'] = 'Heat Index:';
fill['heat-index'] = this.data.HeatIndex + String.fromCharCode(176);
} else if (this.data.observations.windChill.value && this.data.WindChill !== '' && this.data.WindChill < this.data.Temperature) {
fill['heat-index-label'] = 'Wind Chill:';
fill['heat-index'] = this.data.WindChill + String.fromCharCode(176);
}

const area = this.elem.querySelector('.main');

area.innerHTML = '';
Expand Down Expand Up @@ -157,49 +93,44 @@ const shortConditions = (_condition) => {
return condition;
};

const getCurrentWeatherByHourFromTime = (data) => {
const currentTime = new Date();
const onlyDate = currentTime.toISOString().split('T')[0]; // Extracts "YYYY-MM-DD"

const availableTimes = data.forecast[onlyDate].hours;

const closestTime = availableTimes.reduce((prev, curr) => {
const prevDiff = Math.abs(new Date(prev.time) - currentTime);
const currDiff = Math.abs(new Date(curr.time) - currentTime);
return currDiff < prevDiff ? curr : prev;
});

return closestTime;
};

// format the received data
const parseData = (data) => {
const observations = data.features[0].properties;
const currentForecast = getCurrentWeatherByHourFromTime(data);

// values from api are provided in metric
data.observations = observations;
data.Temperature = Math.round(observations.temperature.value);
data.Temperature = currentForecast.temperature_2m;
data.TemperatureUnit = 'C';
data.DewPoint = Math.round(observations.dewpoint.value);
data.Ceiling = Math.round(observations.cloudLayers[0]?.base?.value ?? 0);
data.DewPoint = currentForecast.dew_point_2m;
data.Ceiling = currentForecast.cloud_cover;
data.CeilingUnit = 'm.';
data.Visibility = Math.round(observations.visibility.value / 1000);
data.VisibilityUnit = ' km.';
data.WindSpeed = Math.round(observations.windSpeed.value);
data.WindDirection = directionToNSEW(observations.windDirection.value);
data.Pressure = Math.round(observations.barometricPressure.value);
data.HeatIndex = Math.round(observations.heatIndex.value);
data.WindChill = Math.round(observations.windChill.value);
data.WindGust = Math.round(observations.windGust.value);
data.WindUnit = 'KPH';
data.Humidity = Math.round(observations.relativeHumidity.value);
data.Icon = getWeatherIconFromIconLink(observations.icon);
data.PressureDirection = '';
data.TextConditions = observations.textDescription;

// difference since last measurement (pascals, looking for difference of more than 150)
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
if (pressureDiff > 150) data.PressureDirection = 'R';
if (pressureDiff < -150) data.PressureDirection = 'F';

// convert to us units
data.Temperature = celsiusToFahrenheit(data.Temperature);
data.TemperatureUnit = 'F';
data.DewPoint = celsiusToFahrenheit(data.DewPoint);
data.Ceiling = Math.round(metersToFeet(data.Ceiling) / 100) * 100;
data.CeilingUnit = 'ft.';
data.Visibility = kilometersToMiles(observations.visibility.value / 1000);
data.VisibilityUnit = ' mi.';
data.WindSpeed = kphToMph(data.WindSpeed);
data.WindUnit = 'MPH';
data.Pressure = pascalToInHg(data.Pressure).toFixed(2);
data.HeatIndex = celsiusToFahrenheit(data.HeatIndex);
data.WindChill = celsiusToFahrenheit(data.WindChill);
data.WindGust = kphToMph(data.WindGust);
data.Visibility = currentForecast.visibility;
data.VisibilityUnit = 'm.';
data.WindSpeed = currentForecast.wind_speed_10m;
data.WindDirection = directionToNSEW(currentForecast.wind_direction_10m);
data.Pressure = currentForecast.pressure_msl;
// data.HeatIndex = Math.round(observations.heatIndex.value);
// data.WindChill = Math.round(observations.windChill.value);
data.WindGust = currentForecast.wind_gusts_10m;
data.WindUnit = 'km/h';
data.Humidity = currentForecast.relative_humidity_2m;
data.PressureDirection = 'hPa';
data.TextConditions = currentForecast.weather_code;

return data;
};

Expand Down
10 changes: 2 additions & 8 deletions server/scripts/modules/currentweatherscroll.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { locationCleanup } from './utils/string.mjs';
import { elemForEach } from './utils/elem.mjs';
import getCurrentWeather from './currentweather.mjs';
import { currentDisplay } from './navigation.mjs';
Expand Down Expand Up @@ -54,16 +53,11 @@ const drawScreen = async () => {
// the "screens" are stored in an array for easy addition and removal
const screens = [
// station name
(data) => `Conditions at ${locationCleanup(data.station.properties.name).substr(0, 20)}`,
(data) => `Conditions at ${data.city}`,

// temperature
(data) => {
let text = `Temp: ${data.Temperature}${degree}${data.TemperatureUnit}`;
if (data.observations.heatIndex.value) {
text += ` Heat Index: ${data.HeatIndex}${degree}${data.TemperatureUnit}`;
} else if (data.observations.windChill.value) {
text += ` Wind Chill: ${data.WindChill}${degree}${data.TemperatureUnit}`;
}
const text = `Temp: ${data.Temperature}${degree}${data.TemperatureUnit}`;
return text;
},

Expand Down
Loading

0 comments on commit c842d77

Please sign in to comment.