├── .gitignore
├── Docker Image
├── Docker Hub Link.txt
├── Docker YML AMD64
│ └── docker-compose.yml
├── Docker YML ARM
│ └── docker-compose.yml
├── Docker YML ARMv7
│ └── docker-compose.yml
└── Docker-ReadMe.md
├── LICENSE
├── client
├── .babelrc
├── .eslintrc
├── dist
│ ├── 1.bundle.min.js
│ ├── bundle.min.js
│ └── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── src
│ ├── AppContext.js
│ ├── components
│ │ ├── App
│ │ │ ├── index.js
│ │ │ ├── overrides.css
│ │ │ └── styles.css
│ │ ├── Clock
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── ControlButtons
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── CurrentWeather
│ │ │ ├── index.js
│ │ │ ├── openweathermap_index.js
│ │ │ └── styles.css
│ │ ├── InfoPanel
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── LocationName
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── Settings
│ │ │ ├── animations.css
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── Spinner
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── SunRiseSet
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── WeatherInfo
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ ├── WeatherMap
│ │ │ ├── index.js
│ │ │ └── styles.css
│ │ └── weatherCharts
│ │ │ ├── DailyChart
│ │ │ └── index.js
│ │ │ ├── HourlyChart
│ │ │ └── index.js
│ │ │ ├── common.js
│ │ │ └── styles.css
│ ├── index.html
│ ├── index.js
│ ├── services
│ │ ├── conversions.js
│ │ ├── formatting.js
│ │ ├── geolocation.js
│ │ └── reverseGeocode.js
│ ├── settings.js
│ └── styles
│ │ ├── index.js
│ │ └── main.css
└── webpack.config.js
├── package-lock.json
├── package.json
├── readme.md
├── server
├── geolocationCtrl.js
├── index.js
└── settingsCtrl.js
└── settings.example.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | settings.json
--------------------------------------------------------------------------------
/Docker Image/Docker Hub Link.txt:
--------------------------------------------------------------------------------
1 | https://hub.docker.com/repository/docker/seanriggs/pi-weather-station
2 |
--------------------------------------------------------------------------------
/Docker Image/Docker YML AMD64/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | weather-station:
4 | image: seanriggs/pi-weather-station:latest
5 | container_name: weather-station
6 | ports:
7 | - "8080:8080"
8 | volumes:
9 | - appdata:/app
10 | restart: unless-stopped
11 | volumes:
12 | appdata:
13 |
--------------------------------------------------------------------------------
/Docker Image/Docker YML ARM/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | weather-station:
4 | image: seanriggs/pi-weather-station:arm64
5 | container_name: weather-station
6 | ports:
7 | - "8080:8080"
8 | volumes:
9 | - appdata:/app
10 | restart: unless-stopped
11 | volumes:
12 | appdata:
13 |
--------------------------------------------------------------------------------
/Docker Image/Docker YML ARMv7/docker-compose.yml:
--------------------------------------------------------------------------------
1 |
2 | version: '3'
3 | services:
4 | weather-station:
5 | image: seanriggs/pi-weather-station:armv7-armhf
6 | container_name: weather-station
7 | ports:
8 | - "8080:8080"
9 | volumes:
10 | - appdata:/app
11 | restart: unless-stopped
12 | volumes:
13 | appdata:
14 |
--------------------------------------------------------------------------------
/Docker Image/Docker-ReadMe.md:
--------------------------------------------------------------------------------
1 | # Pi Weather Station *DOCKERIZED*
2 |
3 | This Docker Image is a containerized application originally built by Eric Elewin. Containerizing his application captures all the dependencies required to make it run. Launching the application in Docker allows for easy deployment!
4 |
5 | These Docker images will work on RaspberryPi still but is also built to work on AMD64 infrastructure, depending on the tag you specify of course ;)
6 |
7 | Just so you know, the original application was pre-configured to access the application from the hosting machine only. I have modified index.js to allow any machine on the local network to access the application from the web browser. By exposing the app to your entire network, users on that network access the app and retrieve your API keys from the settings page. I use this at home with my family, so I am ok with that risk.
8 |
9 | Eric developed this application as a weather station running on RaspberryPI on the official 7" 800x480 touchscreen. See Eric's Github located here: https://github.com/elewin/pi-weather-station#pi-weather-station
10 |
11 | 
12 |
13 | Compiled app data to run as a lightweight container in docker. Uses Node:12.12-alpine.
14 |
15 | Images will work on ARM/aarch64 and x86 Linux/AMD infrastructure.
16 |
17 | The compose file example will be below and includes an example for persistent volumes, so API data is recovered on container recreation.
18 |
19 | Options will allow you to run on any physical Linux machine (including RaspberryPi), Virtual Machines, or Windows. Tested on Windows 10, Debian, and Ubuntu.
20 |
21 |
22 |
23 | Architecture |
24 | Available |
25 |
26 |
27 | amd64 |
28 | ✅ |
29 |
30 |
31 | arm64 |
32 | ✅ |
33 |
34 |
35 | arm64v8 |
36 | ✅ |
37 |
38 |
39 | armhf |
40 | ✅ |
41 |
42 | arm32v7 |
43 | ✅ |
44 |
45 |
46 | Docker Run
47 |
48 | You can spin up the container using docker run with the following example below:
49 |
50 | Create a Docker Volume first so that you can save persistent API Data:
51 | ```bash
52 | docker volume create appdata
53 | ```
54 | Using the volume example above, create the container and pull the image:
55 |
56 | ```bash
57 | docker run -itd --name weather-station -p 8080:8080 -v appdata:/app seanriggs/pi-weather-station
58 | ```
59 | Remember to specify the arm64 tag if you use an arm or aarch64 based infrastructure.
60 |
61 | 
62 | ## docker-compose.yml
63 | ```docker
64 | version: '3'
65 | services:
66 | weather-station:
67 | image: seanriggs/pi-weather-station:latest #or arm64 (i.e. RaspberryPi)
68 | container_name: weather-station
69 | ports:
70 | - "8080:8080"
71 | volumes:
72 | - appdata:/app
73 | restart: unless-stopped
74 | volumes:
75 | appdata:
76 | ```
77 | # Bringing up with Docker-Compose
78 |
79 | The docker-compose command will pull the image based on the updated docker-compose.yml file you saved from above. The file should be ready to go in the directory you chose for it (i.e. pi-weather-station). The next step is to spin up your containers using docker-compose and starting the docker daemon:
80 |
81 | ```bash
82 | docker-compose up -d
83 | ```
84 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 Eric Lewin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/client/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react"]
3 | }
4 |
--------------------------------------------------------------------------------
/client/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "parserOptions": {
4 | "ecmaVersion": 9,
5 | "sourceType": "module",
6 | "ecmaFeatures": {
7 | "jsx": true
8 | }
9 | },
10 | "globals": {
11 | "Atomics": "readonly",
12 | "SharedArrayBuffer": "readonly"
13 | },
14 | "rules": {
15 | "react/jsx-uses-react": 1,
16 | "babel/semi": 1,
17 | "babel/camelcase": 1,
18 | "no-useless-return": 2,
19 | "no-self-compare": 2,
20 | "no-implicit-globals" : 2,
21 | "no-eval": 2,
22 | "no-alert": 2,
23 | "no-multiple-empty-lines" : 1,
24 | "no-empty-function": 2,
25 | "guard-for-in": 2,
26 | "babel/no-unused-expressions": 2,
27 | "jsx-quotes" : ["warn", "prefer-double"],
28 | "comma-spacing" : 1,
29 | "arrow-spacing": 1,
30 | "prefer-destructuring" : 1,
31 | "multiline-comment-style" : 0,
32 | "react-hooks/rules-of-hooks": "error",
33 | "react-hooks/exhaustive-deps": "warn",
34 | "react/display-name": 0,
35 | "eslint-comments/no-unused-disable": 1,
36 | "eslint-comments/disable-enable-pair": 0,
37 | "react/no-unused-prop-types": 2,
38 | "jsdoc/require-jsdoc": [
39 | "error", {
40 | "publicOnly": true,
41 | "require": {
42 | "ClassDeclaration": true,
43 | "ArrowFunctionExpression": true,
44 | "MethodDefinition": true
45 | }
46 | }
47 | ],
48 | "jsdoc/require-param-description": 0,
49 | "jsdoc/no-undefined-types": 0,
50 | "jsdoc/check-types" : ["warn", {"noDefaults": true}]
51 | },
52 | "env": {
53 | "es6": true,
54 | "browser": true,
55 | "jest": true,
56 | "node": true
57 | },
58 | "extends": [
59 | "eslint:recommended",
60 | "plugin:react/recommended",
61 | "plugin:eslint-comments/recommended",
62 | "plugin:jsdoc/recommended"
63 | ],
64 | "plugins": [
65 | "babel",
66 | "react-hooks",
67 | "react",
68 | "jsdoc"
69 | ],
70 | "settings": {
71 | "react":{
72 | "version": "detect"
73 | },
74 | "jsdoc": {
75 | "tagNamePreference": {
76 | "property": "prop",
77 | "augments": "extends"
78 | }
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/client/dist/1.bundle.min.js:
--------------------------------------------------------------------------------
1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[1],{373:function(n,i,o){var e=o(7),r=o(376);"string"==typeof(r=r.__esModule?r.default:r)&&(r=[[n.i,r,""]]);var s={insert:"head",singleton:!1};e(r,s);n.exports=r.locals||{}},376:function(n,i,o){"use strict";o.r(i);var e=o(3),r=o.n(e)()(!1);r.push([n.i,"body {\r\n font-family: Rubik; \r\n margin: 0px; \r\n }",""]),i.default=r}}]);
--------------------------------------------------------------------------------
/client/dist/index.html:
--------------------------------------------------------------------------------
1 | Pi Weather Station
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pi-weather-station-client",
3 | "version": "1.0.0",
4 | "description": "Client for pi-weather-station",
5 | "main": "webpack.config.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "dev": "webpack --progress --watch --mode development",
9 | "prod": "webpack -p --env.BUILD_PRODUCTION=1"
10 | },
11 | "author": "Eric Lewin",
12 | "license": "MIT",
13 | "dependencies": {
14 | "@babel/core": "^7.11.1",
15 | "@babel/preset-env": "^7.11.0",
16 | "@babel/preset-react": "^7.10.4",
17 | "@iconify/icons-bx": "^1.0.3",
18 | "@iconify/icons-carbon": "^1.0.4",
19 | "@iconify/icons-dashicons": "^1.0.11",
20 | "@iconify/icons-gridicons": "^1.0.6",
21 | "@iconify/icons-ic": "^1.0.15",
22 | "@iconify/icons-ion": "^1.0.13",
23 | "@iconify/icons-map": "^1.0.5",
24 | "@iconify/icons-wi": "^1.0.3",
25 | "@iconify/react": "^1.1.3",
26 | "autoprefixer": "^9.8.6",
27 | "axios": "^0.19.2",
28 | "babel-eslint": "^10.1.0",
29 | "babel-loader": "^8.1.0",
30 | "chart.js": "^2.9.3",
31 | "css-loader": "^4.2.1",
32 | "date-fns": "^2.15.0",
33 | "debounce": "^1.2.0",
34 | "eslint": "^7.6.0",
35 | "eslint-loader": "^4.0.2",
36 | "eslint-plugin-babel": "^5.3.1",
37 | "eslint-plugin-eslint-comments": "^3.2.0",
38 | "eslint-plugin-jsdoc": "^30.2.1",
39 | "eslint-plugin-react": "^7.20.6",
40 | "eslint-plugin-react-hooks": "^4.0.8",
41 | "file-loader": "^6.0.0",
42 | "html-loader": "^1.1.0",
43 | "html-webpack-plugin": "^4.3.0",
44 | "leaflet": "^1.6.0",
45 | "postcss": "^7.0.32",
46 | "postcss-loader": "^3.0.0",
47 | "postcss-preset-env": "^6.7.0",
48 | "prop-types": "^15.7.2",
49 | "react": "^16.13.1",
50 | "react-chartjs-2": "^2.10.0",
51 | "react-dom": "^16.13.1",
52 | "react-leaflet": "^2.7.0",
53 | "react-transition-group": "^4.4.1",
54 | "style-loader": "^1.2.1",
55 | "url-loader": "^4.1.0",
56 | "webpack": "^4.44.1",
57 | "webpack-cli": "^3.3.12"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | const postcssPresetEnv = require("postcss-preset-env");
2 | const autoprefixer = require("autoprefixer");
3 |
4 | const config = {
5 | plugins: [
6 | autoprefixer,
7 | postcssPresetEnv()
8 | ]
9 | };
10 |
11 | module.exports = config;
12 |
--------------------------------------------------------------------------------
/client/src/AppContext.js:
--------------------------------------------------------------------------------
1 | import React, { createContext, useState } from "react";
2 | import { getSettings } from "~/settings";
3 | import PropTypes from "prop-types";
4 | import { getCoordsFromApi } from "~/services/geolocation";
5 | import axios from "axios";
6 |
7 | export const AppContext = createContext();
8 |
9 | const TEMP_UNIT_STORAGE_KEY = "tempUnit";
10 | const SPEED_UNIT_STORAGE_KEY = "speedUnit";
11 | const LENGTH_UNIT_STORAGE_KEY = "lengthUnit";
12 | const CLOCK_UNIT_STORAGE_KEY = "clockTime";
13 | const MOUSE_HIDE_STORAGE_KEY = "mouseHide";
14 |
15 | /**
16 | * App context provider
17 | *
18 | * @param {Object} props
19 | * @param {Node} props.children
20 | * @returns {JSX.Element} Context provider
21 | */
22 | export function AppContextProvider({ children }) {
23 | const [weatherApiKey, setWeatherApiKey] = useState(null);
24 | const [mapApiKey, setMapApiKey] = useState(null);
25 | const [reverseGeoApiKey, setReverseGeoApiKey] = useState(null);
26 | const [browserGeo, setBrowserGeo] = useState(null);
27 | const [mapGeo, setMapGeo] = useState(null);
28 | const [darkMode, setDarkMode] = useState(true);
29 | const [currentWeatherData, setCurrentWeatherData] = useState(null);
30 | const [currentWeatherDataErr, setCurrentWeatherDataErr] = useState(null);
31 | const [currentWeatherDataErrMsg, setCurrentWeatherDataErrMsg] = useState(
32 | null
33 | );
34 | const [hourlyWeatherData, setHourlyWeatherData] = useState(null);
35 | const [hourlyWeatherDataErr, setHourlyWeatherDataErr] = useState(null);
36 | const [hourlyWeatherDataErrMsg, setHourlyWeatherDataErrMsg] = useState(null);
37 | const [dailyWeatherData, setDailyWeatherData] = useState(null);
38 | const [dailyWeatherDataErr, setDailyWeatherDataErr] = useState(null);
39 | const [dailyWeatherDataErrMsg, setDailyWeatherDataErrMsg] = useState(null);
40 | const [panToCoords, setPanToCoords] = useState(null);
41 | const [markerIsVisible, setMarkerIsVisible] = useState(true);
42 | const [tempUnit, setTempUnit] = useState("f"); // fahrenheit or celsius
43 | const [speedUnit, setSpeedUnit] = useState("mph"); // mph or ms for m/s
44 | const [lengthUnit, setLengthUnit] = useState("in"); // in or mm
45 | const [clockTime, setClockTime] = useState("12"); // 12h or 24h time for clock
46 | const [animateWeatherMap, setAnimateWeatherMap] = useState(false);
47 | const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
48 | const [customLat, setCustomLat] = useState(null);
49 | const [customLon, setCustomLon] = useState(null);
50 | const [mouseHide, setMouseHide] = useState(false);
51 | const [sunriseTime, setSunriseTime] = useState(null);
52 | const [sunsetTime, setSunsetTime] = useState(null);
53 |
54 | /**
55 | * Save mouse hide state
56 | *
57 | * @param {Boolean} newVal
58 | */
59 | function saveMouseHide(newVal) {
60 | let newState;
61 | try {
62 | newState = JSON.parse(newVal);
63 | } catch (e) {
64 | console.log("saveMouseHide", e);
65 | return;
66 | }
67 | setMouseHide(newState);
68 | window.localStorage.setItem(MOUSE_HIDE_STORAGE_KEY, newState);
69 | }
70 |
71 | /**
72 | * Save clock time
73 | *
74 | * @param {String} newVal `12` or `24`
75 | */
76 | function saveClockTime(newVal) {
77 | setClockTime(newVal);
78 | window.localStorage.setItem(CLOCK_UNIT_STORAGE_KEY, newVal);
79 | }
80 |
81 | /**
82 | * Save temp unit
83 | *
84 | * @param {String} newVal `f` or `c`
85 | */
86 | function saveTempUnit(newVal) {
87 | setTempUnit(newVal);
88 | window.localStorage.setItem(TEMP_UNIT_STORAGE_KEY, newVal);
89 | }
90 |
91 | /**
92 | * Save speed unit
93 | *
94 | * @param {String} newVal `mph` or `ms`
95 | */
96 | function saveSpeedUnit(newVal) {
97 | setSpeedUnit(newVal);
98 | window.localStorage.setItem(SPEED_UNIT_STORAGE_KEY, newVal);
99 | }
100 |
101 | /**
102 | * Save length unit
103 | *
104 | * @param {String} newVal `in` or `mm`
105 | */
106 | function saveLengthUnit(newVal) {
107 | setLengthUnit(newVal);
108 | window.localStorage.setItem(LENGTH_UNIT_STORAGE_KEY, newVal);
109 | }
110 |
111 | function loadStoredData() {
112 | const temp = window.localStorage.getItem(TEMP_UNIT_STORAGE_KEY);
113 | const speed = window.localStorage.getItem(SPEED_UNIT_STORAGE_KEY);
114 | const length = window.localStorage.getItem(LENGTH_UNIT_STORAGE_KEY);
115 | const clock = window.localStorage.getItem(CLOCK_UNIT_STORAGE_KEY);
116 |
117 | let mouseHide;
118 | try {
119 | mouseHide = JSON.parse(
120 | window.localStorage.getItem(MOUSE_HIDE_STORAGE_KEY)
121 | );
122 | } catch (e) {
123 | console.log("mouseHide", e);
124 | }
125 |
126 | setMouseHide(!!mouseHide);
127 | if (temp) {
128 | setTempUnit(temp);
129 | }
130 | if (speed) {
131 | setSpeedUnit(speed);
132 | }
133 | if (length) {
134 | setLengthUnit(length);
135 | }
136 | if (clock) {
137 | setClockTime(clock);
138 | }
139 | }
140 |
141 | /**
142 | * Set custom starting lat/lon
143 | *
144 | * @returns {Promise} lat/lon
145 | * @private
146 | */
147 | function getCustomLatLon() {
148 | return new Promise((resolve, reject) => {
149 | getSettings()
150 | .then((res) => {
151 | if (res) {
152 | const { startingLat, startingLon } = res;
153 | if (startingLat) {
154 | setCustomLat(startingLat);
155 | }
156 | if (startingLon) {
157 | setCustomLon(startingLon);
158 | }
159 | }
160 | resolve(res);
161 | })
162 | .catch((err) => {
163 | console.log("could not read settings.json", err);
164 | reject(err);
165 | });
166 | });
167 | }
168 |
169 | /**
170 | * Set the map to a given position
171 | *
172 | * @param {Object} coords coordinates
173 | * @param {String} coords.latitude
174 | * @param {String} coords.longitude
175 | */
176 | function setMapPosition(coords) {
177 | updateCurrentWeatherData(coords);
178 | updateHourlyWeatherData(coords);
179 | updateDailyWeatherData(coords);
180 | setMapGeo(coords);
181 | setPanToCoords(coords);
182 | }
183 |
184 | /**
185 | * Return the map position to browser geolocation coordinates
186 | */
187 | function resetMapPosition() {
188 | setMapPosition(browserGeo);
189 | }
190 |
191 | /**
192 | * Gets geolocation and sets it, unless custom starting coordinates are provided.
193 | *
194 | * @returns {Object} coords
195 | */
196 | function getBrowserGeo() {
197 | return new Promise((resolve, reject) => {
198 | getCustomLatLon()
199 | .then((res) => {
200 | const { startingLat, startingLon } = res;
201 | if (startingLat && startingLon) {
202 | const latLon = {
203 | latitude: parseFloat(startingLat),
204 | longitude: parseFloat(startingLon),
205 | };
206 | setBrowserGeo(latLon);
207 | setMapGeo(latLon); //Set initial map coords to custom lat/lon
208 | resolve(latLon);
209 | } else {
210 | getCoordsFromApi()
211 | .then((res) => {
212 | if (!res) {
213 | return reject("Could not get browser geolocation data");
214 | }
215 | const { latitude, longitude } = res;
216 | setBrowserGeo({ latitude, longitude });
217 | setMapGeo({ latitude, longitude }); //Set initial map coords to browser geolocation
218 | resolve(res);
219 | })
220 | .catch((err) => {
221 | reject(err);
222 | });
223 | }
224 | })
225 | .catch((err) => {
226 | console.log("err!", err);
227 | });
228 | });
229 | }
230 |
231 | /**
232 | * Retrieves weather API key and sets it
233 | *
234 | * @returns {Promise} Weather API Key
235 | */
236 | function getWeatherApiKey() {
237 | return new Promise((resolve, reject) => {
238 | getSettings()
239 | .then((res) => {
240 | if (!res || (res && !res.weatherApiKey)) {
241 | setSettingsMenuOpen(true);
242 | return reject("Weather API key missing");
243 | }
244 | setWeatherApiKey(res && res.weatherApiKey ? res.weatherApiKey : null);
245 | resolve();
246 | })
247 | .catch((err) => {
248 | reject(err);
249 | });
250 | });
251 | }
252 |
253 | /**
254 | * Retrieves map API key and sets it
255 | *
256 | * @returns {Promise} Weather API Key
257 | */
258 | function getMapApiKey() {
259 | return new Promise((resolve, reject) => {
260 | getSettings()
261 | .then((res) => {
262 | if (!res || (res && !res.mapApiKey)) {
263 | setSettingsMenuOpen(true);
264 | return reject("Map API key missing!");
265 | }
266 | setMapApiKey(res && res.mapApiKey ? res.mapApiKey : null);
267 | resolve();
268 | })
269 | .catch((err) => {
270 | reject(err);
271 | });
272 | });
273 | }
274 |
275 | /**
276 | * Retrieves reverse geolocation API key and sets it
277 | *
278 | * @returns {Promise} Weather API Key
279 | */
280 | function getReverseGeoApiKey() {
281 | return new Promise((resolve, reject) => {
282 | getSettings()
283 | .then((res) => {
284 | if (!res || (res && !res.reverseGeoApiKey)) {
285 | return reject("Reverse geolocation API key missing!");
286 | }
287 | setReverseGeoApiKey(
288 | res && res.reverseGeoApiKey ? res.reverseGeoApiKey : null
289 | );
290 | resolve();
291 | })
292 | .catch((err) => {
293 | reject(err);
294 | });
295 | });
296 | }
297 |
298 | /**
299 | * Updates hourly weather data
300 | *
301 | * @param {Object} coords
302 | * @param {Number} coords.latitude latitude
303 | * @param {Number} coords.longitude longitude
304 | *
305 | * @returns {Promise} hourly weather data
306 | */
307 | function updateHourlyWeatherData(coords) {
308 | setHourlyWeatherDataErr(null);
309 | setHourlyWeatherDataErrMsg(null);
310 | const { latitude, longitude } = coords;
311 | const fields = [
312 | "temperature",
313 | "precipitationProbability",
314 | "precipitationIntensity",
315 | "windSpeed",
316 | ].join("%2c");
317 |
318 | const endTime = new Date(
319 | new Date().getTime() + 60 * 60 * 23 * 1000
320 | ).toISOString();
321 |
322 | return new Promise((resolve, reject) => {
323 | if (!coords) {
324 | setHourlyWeatherDataErr(true);
325 | return reject("No coords");
326 | }
327 | if (!weatherApiKey) {
328 | setHourlyWeatherDataErr(true);
329 | setSettingsMenuOpen(true);
330 | return reject("Missing weather API key");
331 | }
332 |
333 | axios
334 | .get(
335 | `https://data.climacell.co/v4/timelines?location=${latitude}%2C${longitude}&fields=${fields}×teps=1h&apikey=${weatherApiKey}&endTime=${endTime}`
336 | )
337 | .then((res) => {
338 | if (!res) {
339 | return reject({ message: "No response" });
340 | }
341 | const { data } = res;
342 | setHourlyWeatherData(data);
343 | resolve(data);
344 | })
345 | .catch((err) => {
346 | setHourlyWeatherDataErr(true);
347 | if (err && err.message) {
348 | setHourlyWeatherDataErrMsg(err.message);
349 | }
350 |
351 | reject(err);
352 | });
353 | });
354 | }
355 |
356 | /**
357 | * Updates daily weather data
358 | *
359 | * @param {Object} coords
360 | * @param {Number} coords.latitude latitude
361 | * @param {Number} coords.longitude longitude
362 | *
363 | * @returns {Promise} daily weather data
364 | */
365 | function updateDailyWeatherData(coords) {
366 | setDailyWeatherDataErr(null);
367 | setDailyWeatherDataErrMsg(null);
368 | const { latitude, longitude } = coords;
369 | const fields = [
370 | "temperature",
371 | "precipitationProbability",
372 | "precipitationIntensity",
373 | "windSpeed",
374 | ].join("%2c");
375 |
376 | const endTime = new Date(
377 | new Date().getTime() + 4 * 60 * 60 * 24 * 1000
378 | ).toISOString();
379 |
380 | return new Promise((resolve, reject) => {
381 | if (!coords) {
382 | setDailyWeatherDataErr(true);
383 | return reject("No coords");
384 | }
385 | if (!weatherApiKey) {
386 | setDailyWeatherDataErr(true);
387 | setSettingsMenuOpen(true);
388 | return reject("Missing weather API key");
389 | }
390 | axios
391 | .get(
392 | `https://data.climacell.co/v4/timelines?location=${latitude}%2C${longitude}&fields=${fields}×teps=1d&apikey=${weatherApiKey}&endTime=${endTime}`
393 | )
394 | .then((res) => {
395 | if (!res) {
396 | return reject({ message: "No response" });
397 | }
398 | const { data } = res;
399 | setDailyWeatherData(data);
400 | resolve(data);
401 | })
402 | .catch((err) => {
403 | setDailyWeatherDataErr(true);
404 | if (err && err.message) {
405 | setDailyWeatherDataErrMsg(err.message);
406 | }
407 | reject(err);
408 | });
409 | });
410 | }
411 |
412 | function updateSunriseSunset(coords) {
413 | return new Promise((resolve, reject) => {
414 | if (!coords) {
415 | setSunriseTime(null);
416 | setSunsetTime(null);
417 | return reject("No coords");
418 | }
419 | const { latitude, longitude } = coords;
420 |
421 | axios
422 | .get(
423 | `https://api.sunrise-sunset.org/json?lat=${latitude}&lng=${longitude}&formatted=0`
424 | )
425 | .then((res) => {
426 | const { results } = res?.data;
427 | if (results) {
428 | const { sunrise, sunset } = results;
429 | setSunriseTime(sunrise);
430 | setSunsetTime(sunset);
431 | } else {
432 | setSunriseTime(null);
433 | setSunsetTime(null);
434 | }
435 | resolve(results);
436 | })
437 | .catch((err) => {
438 | setSunriseTime(null);
439 | setSunsetTime(null);
440 | reject(err);
441 | });
442 | });
443 | }
444 |
445 | /**
446 | * Updates current weather data
447 | *
448 | * @param {Object} coords
449 | * @param {Number} coords.latitude latitude
450 | * @param {Number} coords.longitude longitude
451 | *
452 | * @returns {Promise} current weather data
453 | */
454 | function updateCurrentWeatherData(coords) {
455 | setCurrentWeatherDataErr(null);
456 | setCurrentWeatherDataErrMsg(null);
457 | const { latitude, longitude } = coords;
458 |
459 | const fields = [
460 | "temperature",
461 | "humidity",
462 | "windSpeed",
463 | "precipitationIntensity",
464 | "precipitationType",
465 | "precipitationProbability",
466 | "cloudCover",
467 | "weatherCode",
468 | ].join("%2c");
469 | return new Promise((resolve, reject) => {
470 | if (!coords) {
471 | setCurrentWeatherDataErr(true);
472 | return reject("No coords");
473 | }
474 | if (!weatherApiKey) {
475 | setCurrentWeatherDataErr(true);
476 | setSettingsMenuOpen(true);
477 | return reject("Missing weather API key");
478 | }
479 |
480 | axios
481 | .get(
482 | `https://data.climacell.co/v4/timelines?location=${latitude}%2C${longitude}&fields=${fields}×teps=current&apikey=${weatherApiKey}`
483 | )
484 | .then((res) => {
485 | if (!res) {
486 | return reject({ message: "No response" });
487 | }
488 | const { data } = res;
489 | setCurrentWeatherData(data);
490 | resolve(data);
491 | })
492 | .catch((err) => {
493 | setCurrentWeatherDataErr(true);
494 | if (err && err.message) {
495 | setCurrentWeatherDataErrMsg(err.message);
496 | }
497 | reject(err);
498 | });
499 | });
500 | }
501 |
502 | /**
503 | * Toggles the marker on and off
504 | */
505 | function toggleMarker() {
506 | setMarkerIsVisible(!markerIsVisible);
507 | }
508 |
509 | /**
510 | * Toggles weather map animation on/off
511 | */
512 | function toggleAnimateWeatherMap() {
513 | setAnimateWeatherMap(!animateWeatherMap);
514 | }
515 |
516 | /**
517 | * Toggles settings menu open/closed
518 | */
519 | function toggleSettingsMenuOpen() {
520 | setSettingsMenuOpen(!settingsMenuOpen);
521 | }
522 |
523 | /**
524 | * Saves settings to `settings.json`
525 | *
526 | * @param {Object} settings
527 | * @param {String} [settings.mapsKey]
528 | * @param {String} [settings.weatherKey]
529 | * @param {String} [settings.geoKey]
530 | * @param {String} [settings.lat]
531 | * @param {String} [settings.lon]
532 | * @returns {Promise} Resolves when complete
533 | */
534 | function saveSettingsToJson({ mapsKey, weatherKey, geoKey, lat, lon }) {
535 | return new Promise((resolve, reject) => {
536 | axios
537 | .put("/settings", {
538 | weatherApiKey: weatherKey,
539 | mapApiKey: mapsKey,
540 | reverseGeoApiKey: geoKey,
541 | startingLat: lat,
542 | startingLon: lon,
543 | })
544 | .then((res) => {
545 | resolve(res);
546 | setMapApiKey(mapsKey);
547 | setWeatherApiKey(weatherKey);
548 | setReverseGeoApiKey(geoKey);
549 | setCustomLat(lat);
550 | setCustomLon(lon);
551 | })
552 | .catch((err) => {
553 | reject(err);
554 | });
555 | });
556 | }
557 |
558 | const defaultContext = {
559 | weatherApiKey,
560 | getWeatherApiKey,
561 | reverseGeoApiKey,
562 | getReverseGeoApiKey,
563 | mapApiKey,
564 | getMapApiKey,
565 | browserGeo,
566 | getBrowserGeo,
567 | darkMode,
568 | setDarkMode,
569 | mapGeo,
570 | setMapGeo,
571 | setMapPosition,
572 | resetMapPosition,
573 | panToCoords,
574 | setPanToCoords,
575 | markerIsVisible,
576 | toggleMarker,
577 | tempUnit,
578 | saveTempUnit,
579 | speedUnit,
580 | saveSpeedUnit,
581 | lengthUnit,
582 | saveLengthUnit,
583 | animateWeatherMap,
584 | toggleAnimateWeatherMap,
585 | settingsMenuOpen,
586 | setSettingsMenuOpen,
587 | toggleSettingsMenuOpen,
588 | getCustomLatLon,
589 | customLat,
590 | customLon,
591 | loadStoredData,
592 | clockTime,
593 | saveClockTime,
594 | saveSettingsToJson,
595 | updateCurrentWeatherData,
596 | updateDailyWeatherData,
597 | updateHourlyWeatherData,
598 | currentWeatherData,
599 | currentWeatherDataErr,
600 | currentWeatherDataErrMsg,
601 | hourlyWeatherData,
602 | hourlyWeatherDataErr,
603 | hourlyWeatherDataErrMsg,
604 | dailyWeatherData,
605 | dailyWeatherDataErr,
606 | dailyWeatherDataErrMsg,
607 | mouseHide,
608 | saveMouseHide,
609 | updateSunriseSunset,
610 | sunriseTime,
611 | sunsetTime,
612 | };
613 |
614 | return (
615 | {children}
616 | );
617 | }
618 |
619 | AppContextProvider.propTypes = {
620 | children: PropTypes.oneOfType([
621 | PropTypes.arrayOf(PropTypes.node),
622 | PropTypes.node,
623 | ]).isRequired,
624 | };
625 |
--------------------------------------------------------------------------------
/client/src/components/App/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useContext } from "react";
2 | import styles from "./styles.css";
3 | import { AppContext } from "~/AppContext";
4 |
5 | import WeatherMap from "~/components/WeatherMap";
6 | import InfoPanel from "~/components/InfoPanel";
7 | import Settings from "~/components/Settings";
8 |
9 | import "!style-loader!css-loader!./overrides.css";
10 |
11 | /**
12 | * Main component
13 | *
14 | * @returns {JSX.Element} Main component
15 | */
16 | const App = () => {
17 | const {
18 | getBrowserGeo,
19 | getCustomLatLon,
20 | loadStoredData,
21 | darkMode,
22 | mouseHide,
23 | } = useContext(AppContext);
24 |
25 | useEffect(() => {
26 | getCustomLatLon();
27 | getBrowserGeo();
28 | loadStoredData();
29 | }, []);
30 |
31 | return (
32 |
37 |
38 |
39 |
40 |
41 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default App;
57 |
--------------------------------------------------------------------------------
/client/src/components/App/overrides.css:
--------------------------------------------------------------------------------
1 | .map-container.map-mouse-hide .leaflet-grab {
2 | cursor: none;
3 | }
4 |
5 | .map-container.map-mouse-hide .leaflet-interactive {
6 | cursor: none;
7 | }
8 |
9 | .map-container.map-dark-mode .leaflet-bar a {
10 | background-color: #3a3938;
11 | color: #f6f6f4;
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/components/App/styles.css:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 100vw;
3 | height: 100vh;
4 | min-width: 800px;
5 | min-height: 480px;
6 | display: grid;
7 | grid-template-columns: auto 300px;
8 | grid-template-rows: auto;
9 | grid-template-areas: "map info";
10 | user-select: none;
11 | position: relative;
12 | }
13 |
14 | .dark .container {
15 | background-color: #3a3938;
16 | }
17 |
18 | .light .container {
19 | background-color: #f7f7f7;
20 | }
21 |
22 | .weather-map {
23 | grid-area: map;
24 | width: 100%;
25 | height: 100%;
26 | }
27 |
28 | .info-container {
29 | grid-area: info;
30 | }
31 |
32 | .dark .info-container {
33 | border-left: 1px solid #484848;
34 | }
35 |
36 | .light .info-container {
37 | border-left: 1px solid #dadada;
38 | }
39 |
40 | .settings-container {
41 | position: fixed;
42 | z-index: 5000;
43 | }
44 |
45 | .hide-mouse{
46 | cursor: none !important;
47 | }
--------------------------------------------------------------------------------
/client/src/components/Clock/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useContext } from "react";
2 | import { AppContext } from "~/AppContext";
3 | import { format } from "date-fns";
4 | import styles from "./styles.css";
5 | import SunRiseSet from "~/components/SunRiseSet";
6 |
7 | /**
8 | * Displays time and date
9 | *
10 | * @returns {JSX.Element} Clock component
11 | */
12 | const Clock = () => {
13 | const { clockTime } = useContext(AppContext);
14 | const [date, setDate] = useState(new Date().getTime());
15 |
16 | useEffect(() => {
17 | const clockInterval = setInterval(() => {
18 | setDate(new Date().getTime());
19 | }, 1000);
20 | return () => {
21 | clearInterval(clockInterval);
22 | };
23 | }, []);
24 |
25 | return (
26 |
27 |
28 | {format(date, "cccc").toUpperCase()}{" "}
29 | {format(date, "LLLL").toUpperCase()} {format(date, "d")}
30 |
31 |
{format(date, clockTime === "12" ? "p" : "HH:mm")}
32 |
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | export default Clock;
40 |
--------------------------------------------------------------------------------
/client/src/components/Clock/styles.css:
--------------------------------------------------------------------------------
1 | .date{
2 | display: flex;
3 | justify-content: flex-end;
4 | font-size: 14px;
5 | }
6 |
7 | .time{
8 | display: flex;
9 | justify-content: flex-end;
10 | font-size: 45px;
11 | }
12 |
13 | .sun-rise-set-container{
14 | display: flex;
15 | justify-content: flex-end;
16 | }
--------------------------------------------------------------------------------
/client/src/components/ControlButtons/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { AppContext } from "~/AppContext";
3 | import styles from "./styles.css";
4 | import { InlineIcon } from "@iconify/react";
5 | import locationArrow from "@iconify/icons-map/location-arrow";
6 | import contrastIcon from "@iconify/icons-carbon/contrast";
7 | import sharpSettings from "@iconify/icons-ic/sharp-settings";
8 | import roundLocationOn from "@iconify/icons-ic/round-location-on";
9 | import roundLocationOff from "@iconify/icons-ic/round-location-off";
10 | import playFilledAlt from "@iconify/icons-carbon/play-filled-alt";
11 | import stopFilledAlt from "@iconify/icons-carbon/stop-filled-alt";
12 |
13 | /**
14 | * Buttons group component
15 | *
16 | * @returns {JSX.Element} Control buttons
17 | */
18 | const ControlButtons = () => {
19 | const {
20 | darkMode,
21 | setDarkMode,
22 | resetMapPosition,
23 | markerIsVisible,
24 | toggleMarker,
25 | toggleAnimateWeatherMap,
26 | animateWeatherMap,
27 | toggleSettingsMenuOpen,
28 | settingsMenuOpen,
29 | mouseHide,
30 | } = useContext(AppContext);
31 |
32 | return (
33 |
38 |
39 |
40 |
41 |
42 |
45 |
46 |
50 |
51 |
52 |
setDarkMode(!darkMode)}>
53 |
54 |
55 |
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default ControlButtons;
66 |
--------------------------------------------------------------------------------
/client/src/components/ControlButtons/styles.css:
--------------------------------------------------------------------------------
1 | .container {
2 | height: 100%;
3 | display: flex;
4 | justify-content: space-evenly;
5 | }
6 |
7 | .container > div {
8 | display: flex;
9 | width: 100%;
10 | height: 100%;
11 | justify-content: center;
12 | align-items: center;
13 | }
14 |
15 | .show-mouse.container > div{
16 | cursor: pointer;
17 | }
18 |
19 | .container.dark > div {
20 | background: linear-gradient(#5d5c5c, #4e4e4e);
21 | border-right: 1px solid #4c4c4c;
22 | border-left: 1px solid #757575;
23 | }
24 |
25 | .container.dark > div:active {
26 | background: linear-gradient(#4e4e4e, #5d5c5c);
27 | }
28 |
29 | .container.light > div {
30 | background: linear-gradient(#d6d6d6, #c7c7c7);
31 | border-right: 1px solid #bdbdbd;
32 | border-left: 1px solid #dadada;
33 | }
34 |
35 | .container.light > div:active {
36 | background: linear-gradient(#d7d7d7, #e6e6e6);
37 | }
38 |
39 | .container.light > div:first-child {
40 | border-left: none;
41 | }
42 |
43 | .light .button-down {
44 | background: linear-gradient(#E4E4E4, #F3F3F3) !important;
45 | }
46 |
47 | .dark .button-down {
48 | background: linear-gradient(#686868, #777676) !important;
49 | }
--------------------------------------------------------------------------------
/client/src/components/CurrentWeather/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { AppContext } from "~/AppContext";
3 | import styles from "./styles.css";
4 | import {
5 | convertTemp,
6 | convertSpeed,
7 | } from "~/services/conversions";
8 |
9 | import { InlineIcon } from "@iconify/react";
10 | import degreesIcon from "@iconify/icons-wi/degrees";
11 | import nightClear from "@iconify/icons-wi/night-clear";
12 | import daySunny from "@iconify/icons-wi/day-sunny";
13 | import dayCloudy from "@iconify/icons-wi/day-cloudy";
14 | import nightAltCloudy from "@iconify/icons-wi/night-alt-cloudy";
15 | import dayRain from "@iconify/icons-wi/day-rain";
16 | import nightRain from "@iconify/icons-wi/night-rain";
17 | import humidityAlt from "@iconify/icons-carbon/humidity-alt";
18 | import cloudIcon from "@iconify/icons-wi/cloud";
19 | import strongWind from "@iconify/icons-wi/strong-wind";
20 | import snowIcon from "@iconify/icons-ion/snow";
21 | import rainIcon from "@iconify/icons-wi/rain";
22 | import rainMix from "@iconify/icons-wi/rain-mix";
23 | import thunderstormIcon from "@iconify/icons-wi/thunderstorm";
24 | import fogIcon from "@iconify/icons-wi/fog";
25 | import cloudyIcon from "@iconify/icons-wi/cloudy";
26 | import daySunnyOvercast from "@iconify/icons-wi/day-sunny-overcast";
27 |
28 | /**
29 | * Current weather conditions
30 | * https://developer.climacell.co/v3/reference#data-layers-weather
31 | *
32 | * @returns {JSX.Element} Current weather conditions component
33 | */
34 | const CurrentWeather = () => {
35 | const { currentWeatherData, tempUnit, speedUnit, sunriseTime, sunsetTime } = useContext(
36 | AppContext
37 | );
38 | const weatherData =
39 | currentWeatherData?.data?.timelines?.[0]?.intervals[0]?.values;
40 | if (weatherData) {
41 | const {
42 | cloudCover,
43 | humidity,
44 | precipitationType,
45 | precipitationProbability,
46 | temperature,
47 | weatherCode,
48 | windSpeed,
49 | } = weatherData;
50 | const daylight = sunriseTime && sunsetTime ? isDaylight(new Date(sunriseTime), new Date(sunsetTime)) : true;
51 | const { icon: weatherIcon, desc: weatherDesc } =
52 | parseWeatherCode(weatherCode, daylight) || {};
53 |
54 | return (
55 |
56 |
57 | {convertTemp(temperature, tempUnit)}
58 |
59 |
60 |
61 |
62 | {weatherIcon ? : null}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
72 |
73 |
{precipitationProbability}%
74 |
75 |
76 |
77 |
78 |
79 |
{parseInt(cloudCover)}%
80 |
81 |
82 |
83 |
84 |
85 |
86 |
{convertSpeed(windSpeed, speedUnit)}
87 |
88 | {speedUnit === "mph" ? " mph" : " m/s"}
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
{parseInt(humidity)}%
97 |
98 |
99 |
100 |
{weatherDesc || ""}
101 |
102 | );
103 | } else {
104 | return ;
105 | }
106 | };
107 |
108 | /**
109 | * Parse weather code
110 | *
111 | * https://docs.climacell.co/reference/data-layers-overview
112 | *
113 | * @param {String} code
114 | * @param {Boolean} [isDay] if it is currently day
115 | * @returns {Object} weather description and icon
116 | */
117 | const parseWeatherCode = (code, isDay) => {
118 | switch (code) {
119 | case 6201:
120 | return { desc: "Heavy freezing rain", icon: isDay ? dayRain : nightRain };
121 | case 6001:
122 | return { desc: "Freezing rain", icon: isDay ? dayRain : nightRain };
123 | case 6200:
124 | return { desc: "Light freezing rain", icon: isDay ? dayRain : nightRain };
125 | case 6000:
126 | return { desc: "Freezing drizzle", icon: rainMix };
127 | case 7101:
128 | return { desc: "Heavy ice pellets", icon: rainMix };
129 | case 7000:
130 | return { desc: "Ice pellets", icon: rainMix };
131 | case 7102:
132 | return { desc: "Light ice pellets", icon: rainMix };
133 | case 5101:
134 | return { desc: "Heavy snow", icon: snowIcon };
135 | case 5000:
136 | return { desc: "Show", icon: snowIcon };
137 | case 5100:
138 | return { desc: "Light snow", icon: snowIcon };
139 | case 5001:
140 | return { desc: "Flurries", icon: snowIcon };
141 | case 8000:
142 | return { desc: "Thunder storm", icon: thunderstormIcon };
143 | case 4201:
144 | return { desc: "Heavy rain", icon: isDay ? dayRain : nightRain };
145 | case 4001:
146 | return { desc: "Rain", icon: isDay ? dayRain : nightRain };
147 | case 4200:
148 | return { desc: "Light rain", icon: isDay ? dayRain : nightRain };
149 | case 4000:
150 | return { desc: "Drizzle", icon: rainMix };
151 | case 2100:
152 | return { desc: "Light fog", icon: fogIcon };
153 | case 2000:
154 | return { desc: "Fog", icon: fogIcon };
155 | case 1001:
156 | return { desc: "Cloudy", icon: cloudyIcon };
157 | case 1102:
158 | return { desc: "Mostly cloudy", icon: cloudyIcon };
159 | case 1101:
160 | return {
161 | desc: "Partly cloudy",
162 | icon: isDay ? daySunnyOvercast : nightAltCloudy,
163 | };
164 | case 1100:
165 | return { desc: "Mostly clear", icon: isDay ? dayCloudy : nightAltCloudy };
166 | case 1000:
167 | return { desc: "Clear", icon: isDay ? daySunny : nightClear };
168 | case 3001:
169 | return { desc: "Wind", icon: strongWind };
170 | case 3000:
171 | return { desc: "Light wind", icon: strongWind };
172 | case 3002:
173 | return { desc: "Strong wind", icon: strongWind };
174 | }
175 | };
176 |
177 | /**
178 | * Determine if it is currently daylight
179 | *
180 | * @param {Date} sunrise
181 | * @param {Date} sunset
182 | * @returns {Boolean} if current time is during daylight
183 | */
184 | function isDaylight(sunrise, sunset) {
185 | const sunriseTime = new Date(sunrise).getTime();
186 | const sunsetTime = new Date(sunset).getTime();
187 | const now = new Date().getTime();
188 | return !!(now > sunriseTime && now < sunsetTime);
189 | }
190 |
191 | export default CurrentWeather;
192 |
--------------------------------------------------------------------------------
/client/src/components/CurrentWeather/openweathermap_index.js:
--------------------------------------------------------------------------------
1 |
2 | import React, { useContext } from "react";
3 | import { AppContext } from "~/AppContext";
4 | import styles from "./styles.css";
5 | import {
6 | convertTemp,
7 | convertSpeed,
8 | convertLength,
9 | } from "~/services/conversions";
10 | import { capitalizeFirstLetter } from "~/services/formatting";
11 | import { InlineIcon } from "@iconify/react";
12 | import PropTypes from "prop-types";
13 |
14 | import degreesIcon from "@iconify/icons-wi/degrees";
15 | import nightClear from "@iconify/icons-wi/night-clear";
16 | import daySunny from "@iconify/icons-wi/day-sunny";
17 | import nightPartlyCloudy from "@iconify/icons-wi/night-partly-cloudy";
18 | import nightAltPartlyCloudy from "@iconify/icons-wi/night-alt-partly-cloudy";
19 | import dayCloudy from "@iconify/icons-wi/day-cloudy";
20 | import nightAltCloudy from "@iconify/icons-wi/night-alt-cloudy";
21 | import dayRain from "@iconify/icons-wi/day-rain";
22 | import nightRain from "@iconify/icons-wi/night-rain";
23 | import dayThunderstorm from "@iconify/icons-wi/day-thunderstorm";
24 | import nightAltThunderstorm from "@iconify/icons-wi/night-alt-thunderstorm";
25 | import daySnow from "@iconify/icons-wi/day-snow";
26 | import nightAltSnow from "@iconify/icons-wi/night-alt-snow";
27 | import dayFog from "@iconify/icons-wi/day-fog";
28 | import nightFog from "@iconify/icons-wi/night-fog";
29 | import humidityIcon from "@iconify/icons-carbon/humidity";
30 | import cloudIcon from "@iconify/icons-wi/cloud";
31 | import strongWind from "@iconify/icons-wi/strong-wind";
32 | import snowIcon from "@iconify/icons-ion/snow";
33 | import rainIcon from "@iconify/icons-wi/rain";
34 |
35 | /**
36 | * Current weather conditions
37 | * see https://openweathermap.org/api/one-call-api
38 | *
39 | * @returns {JSX.Element} Current weather conditions component
40 | */
41 | const CurrentWeather = () => {
42 | const { weatherData, tempUnit, speedUnit, lengthUnit } = useContext(
43 | AppContext
44 | );
45 | if (weatherData) {
46 | const {
47 | current: {
48 | temp,
49 | humidity,
50 | clouds,
51 | wind_speed, // eslint-disable-line babel/camelcase
52 | rain,
53 | snow,
54 | weather: currentWeather,
55 | },
56 | } = weatherData;
57 | const [{ icon: iconCode, description }] = currentWeather;
58 |
59 | const rainFall = rain ? rain["1h"] : null;
60 | const snowFall = snow ? snow["1h"] : null;
61 |
62 | return (
63 |
64 |
65 | {convertTemp(temp, tempUnit)}
66 |
67 |
68 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
{humidity}%
80 |
81 |
82 |
83 |
84 |
85 |
86 |
{convertSpeed(wind_speed, speedUnit)}
87 |
88 | {speedUnit === "mph" ? " mph" : " m/s"}
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
{clouds}%
97 |
98 |
103 |
104 |
105 |
106 | {capitalizeFirstLetter(description)}
107 |
108 |
109 | );
110 | } else {
111 | return no weather data / loading
;
112 | }
113 | };
114 |
115 | /**
116 | * Precipitation, if any
117 | *
118 | * @param {Object} props
119 | * @param {Number} props.rainFall
120 | * @param {Number} props.snowFall
121 | * @param {String} props.lengthUnit
122 | * @returns {JSX.Element} precipitation component
123 | */
124 | const Precipitation = ({ rainFall, snowFall, lengthUnit }) => {
125 | if ((rainFall && rainFall !== 0) || (snowFall && snowFall !== 0)) {
126 | return (
127 |
128 |
129 |
130 |
131 |
132 |
{convertLength(rainFall ? rainFall : snowFall, lengthUnit)}
133 |
{lengthUnit}
134 |
135 |
136 | );
137 | } else {
138 | return null;
139 | }
140 | };
141 |
142 | Precipitation.propTypes = {
143 | rainFall: PropTypes.number,
144 | snowFall: PropTypes.number,
145 | lengthUnit: PropTypes.string,
146 | };
147 |
148 | /**
149 | * Maps weather codes to icons
150 | * see https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2
151 | *
152 | * @param {String} iconCode
153 | * @returns {Object} Icon
154 | */
155 | function getWeatherIcon(iconCode) {
156 | switch (iconCode) {
157 | case "01d":
158 | return daySunny;
159 | case "01n":
160 | return nightClear;
161 | case "02d":
162 | return nightPartlyCloudy;
163 | case "02n":
164 | return nightAltPartlyCloudy;
165 | case "03d":
166 | return dayCloudy;
167 | case "03n":
168 | return nightAltCloudy;
169 | case "04d":
170 | return dayCloudy;
171 | case "04n":
172 | return nightAltCloudy;
173 | case "09d":
174 | return dayRain;
175 | case "09n":
176 | return nightRain;
177 | case "10d":
178 | return dayRain;
179 | case "10n":
180 | return nightRain;
181 | case "11d":
182 | return dayThunderstorm;
183 | case "11n":
184 | return nightAltThunderstorm;
185 | case "13d":
186 | return daySnow;
187 | case "13n":
188 | return nightAltSnow;
189 | case "50d":
190 | return dayFog;
191 | case "50n":
192 | return nightFog;
193 | }
194 | }
195 |
196 | export default CurrentWeather;
--------------------------------------------------------------------------------
/client/src/components/CurrentWeather/styles.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: grid;
3 | grid-template-columns: 1fr 1fr 1fr;
4 | grid-template-rows: auto auto;
5 | grid-template-areas:
6 | "weather-icon current-temp stats"
7 | "description description description"
8 | }
9 |
10 | .current-temp {
11 | grid-area: current-temp;
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | font-size: 55px;
16 | }
17 |
18 | .icon-container {
19 | grid-area: weather-icon;
20 | display: flex;
21 | justify-content: flex-start;
22 | align-items: center;
23 | }
24 |
25 | .weather-icon {
26 | font-size: 45px;
27 | }
28 |
29 | .description {
30 | grid-area: description;
31 | font-size: 14px;
32 | }
33 |
34 | .stats {
35 | grid-area: stats;
36 | font-size: 14px;
37 | display: flex;
38 | align-items: center;
39 | justify-content: flex-end;
40 | }
41 |
42 | .stat-item {
43 | display: flex;
44 | }
45 |
46 | .stat-item > div:first-child {
47 | display: flex;
48 | justify-content: center;
49 | align-items: center;
50 | margin-right: 5px;
51 | width: 25px;
52 | }
53 |
54 | .uvi-label {
55 | font-size: 12px;
56 | }
57 |
58 | .text-unit {
59 | display: flex;
60 | align-items: baseline;
61 | }
62 |
63 | .text-unit > div:last-child {
64 | margin-left: 2px;
65 | font-size: 8px;
66 | }
67 |
--------------------------------------------------------------------------------
/client/src/components/InfoPanel/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { AppContext } from "~/AppContext";
3 | import Clock from "~/components/Clock";
4 | import WeatherInfo from "~/components/WeatherInfo";
5 | import ControlButtons from "~/components/ControlButtons";
6 | import styles from "./styles.css";
7 |
8 | /**
9 | * Info Panel
10 | *
11 | * @returns {JSX.Element} Info Panel
12 | */
13 | const InfoPanel = () => {
14 | const { darkMode } = useContext(AppContext);
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default InfoPanel;
34 |
--------------------------------------------------------------------------------
/client/src/components/InfoPanel/styles.css:
--------------------------------------------------------------------------------
1 | .panel {
2 | height: 100%;
3 | }
4 | .container {
5 | height: calc(100% - 20px); /* subtract padding */
6 | padding: 10px;
7 | font-family: "Rubik", sans-serif;
8 | display: grid;
9 | grid-template-columns: auto;
10 | grid-template-rows: 90px auto 10px;
11 | grid-template-areas:
12 | "clock"
13 | "weather-info"
14 | "controls";
15 | }
16 |
17 | .dark .container {
18 | color: #f6f6f4;
19 | }
20 |
21 | .light .container {
22 | color: #3a3938;
23 | }
24 |
25 | .clock-container {
26 | grid-area: clock;
27 | }
28 |
29 | .weather-info-container {
30 | grid-area: weather-info;
31 | }
32 |
33 | .controls {
34 | grid-area: controls;
35 | margin: -10px; /* subtract padding */
36 | }
37 |
38 | .dark .controls{
39 | border-top: 1px solid #757575;
40 | }
41 |
42 | .light .controls{
43 | border-top: 1px solid #f1f1f1;
44 | }
--------------------------------------------------------------------------------
/client/src/components/LocationName/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useContext } from "react";
2 | import { AppContext } from "~/AppContext";
3 | import { InlineIcon } from "@iconify/react";
4 | import reverseGeocode from "~/services/reverseGeocode";
5 | import locationIcon from "@iconify/icons-gridicons/location";
6 | import styles from "./styles.css";
7 |
8 | /**
9 | * Map location
10 | *
11 | * @returns {JSX.Element} Location name
12 | */
13 | const LocationName = () => {
14 | const { mapGeo, reverseGeoApiKey } = useContext(AppContext);
15 | const [name, setName] = useState(null);
16 |
17 | useEffect(() => {
18 | if (mapGeo && reverseGeoApiKey) {
19 | const { latitude: lat, longitude: lon } = mapGeo;
20 | reverseGeocode({ lat, lon, apiKey: reverseGeoApiKey })
21 | .then((res) => {
22 | setName(getName(res));
23 | })
24 | .catch((err) => {
25 | setName(`${lat}, ${lon}`);
26 | console.log("err!", err);
27 | });
28 | } else if (mapGeo && !reverseGeoApiKey) {
29 | const { latitude: lat, longitude: lon } = mapGeo;
30 | setName(`${lat}, ${lon}`);
31 | }
32 | }, [mapGeo, reverseGeoApiKey]);
33 |
34 | return (
35 |
36 | {name ? (
37 |
38 | {name}
39 |
40 | ) : null}
41 |
42 | );
43 | };
44 |
45 | /**
46 | * Parses name data from results
47 | *
48 | * @param {Object} res
49 | * @returns {String} Display name
50 | */
51 | const getName = (res) => {
52 | // eslint-disable-next-line babel/camelcase
53 | const { city, country, state, country_code, county, region } = res.address;
54 | // eslint-disable-next-line babel/camelcase
55 | if (country_code === "us") {
56 | if (city) {
57 | return `${city}, ${state}`;
58 | } else if (county) {
59 | return `${county}, ${state}`;
60 | } else if (state) {
61 | return `${state}`;
62 | } else {
63 | return `${country}`;
64 | }
65 | } else {
66 | if (city) {
67 | return `${city}, ${country}`;
68 | } else {
69 | return `${
70 | county
71 | ? `${county}, `
72 | : region
73 | ? `${region}, `
74 | : state
75 | ? `${state}, `
76 | : ""
77 | }${country}`;
78 | }
79 | }
80 | };
81 |
82 | export default LocationName;
83 |
--------------------------------------------------------------------------------
/client/src/components/LocationName/styles.css:
--------------------------------------------------------------------------------
1 | .container {
2 | max-height: 36px;
3 | min-height: 20px;
4 | overflow: hidden;
5 | }
6 |
7 | .err {
8 | font-size: 11px;
9 | color: rgba(128, 128, 128, 0.6);
10 | }
11 |
--------------------------------------------------------------------------------
/client/src/components/Settings/animations.css:
--------------------------------------------------------------------------------
1 | .animate-enter {
2 | -webkit-transform: translateX(-200%);
3 | -moz-transform: translateX(-200%);
4 | transform: translateX(-100%);
5 | }
6 | .animate-enter-active {
7 | -webkit-transform: translateX(0px);
8 | -moz-transform: translateX(0px);
9 | transform: translateX(0px);
10 | -webkit-transition: all 0.3s ease-in-out;
11 | }
12 | .animate-exit {
13 | -webkit-transform: translateX(0px);
14 | -moz-transform: translateX(0px);
15 | transform: translateX(0px);
16 | -webkit-transition: all 0.3s ease-in-out;
17 | }
18 |
19 | .animate-exit-active {
20 | -webkit-transform: translateX(-200%);
21 | -moz-transform: translateX(-200%);
22 | transform: translateX(-100%);
23 | }
--------------------------------------------------------------------------------
/client/src/components/Settings/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState, useEffect } from "react";
2 | import styles from "./styles.css";
3 | import { AppContext } from "~/AppContext";
4 | import { CSSTransition } from "react-transition-group";
5 | import { InlineIcon } from "@iconify/react";
6 | import closeFilled from "@iconify/icons-carbon/close-filled";
7 | import roundSaveAlt from "@iconify/icons-ic/round-save-alt";
8 | import undoIcon from "@iconify/icons-dashicons/undo";
9 | import closeSharp from "@iconify/icons-ion/close-sharp";
10 | import PropTypes from "prop-types";
11 | import "!style-loader!css-loader!./animations.css";
12 |
13 | /**
14 | * Settings page
15 | *
16 | * @returns {JSX.Element} Settings page
17 | */
18 | const Settings = () => {
19 | const {
20 | settingsMenuOpen,
21 | weatherApiKey,
22 | mapApiKey,
23 | reverseGeoApiKey,
24 | customLat,
25 | customLon,
26 | setSettingsMenuOpen,
27 | mouseHide,
28 | saveMouseHide,
29 | } = useContext(AppContext);
30 |
31 | const [mapsKey, setMapsKey] = useState(null);
32 | const [weatherKey, setWeatherKey] = useState(null);
33 | const [geoKey, setGeoKey] = useState(null);
34 | const [lat, setLat] = useState(null);
35 | const [lon, setLon] = useState(null);
36 |
37 | const [currentMapsKey, setCurrentMapsKey] = useState(null);
38 | const [currentWeatherKey, setCurrentWeatherKey] = useState(null);
39 | const [currentGeoKey, setCurrentGeoKey] = useState(null);
40 | const [currentLat, setCurrentLat] = useState(null);
41 | const [currentLon, setCurrentLon] = useState(null);
42 |
43 | useEffect(() => {
44 | setCurrentMapsKey(mapApiKey);
45 | setCurrentWeatherKey(weatherApiKey);
46 | setCurrentGeoKey(reverseGeoApiKey);
47 | setCurrentLat(customLat);
48 | setCurrentLon(customLon);
49 | }, [
50 | mapApiKey,
51 | weatherApiKey,
52 | reverseGeoApiKey,
53 | customLat,
54 | customLon,
55 | currentGeoKey,
56 | mouseHide,
57 | saveMouseHide,
58 | ]);
59 |
60 | useEffect(() => {
61 | if (mapApiKey) {
62 | setMapsKey(mapApiKey);
63 | }
64 | if (weatherApiKey) {
65 | setWeatherKey(weatherApiKey);
66 | }
67 | if (reverseGeoApiKey) {
68 | setGeoKey(reverseGeoApiKey);
69 | }
70 | if (customLat) {
71 | setLat(customLat);
72 | }
73 | if (customLon) {
74 | setLon(customLon);
75 | }
76 | }, [mapApiKey, weatherApiKey, reverseGeoApiKey, customLon, customLat]);
77 |
78 | return (
79 |
85 |
86 |
SETTINGS
87 |
{
90 | setSettingsMenuOpen(false);
91 | }}
92 | >
93 |
94 |
95 |
96 |
97 |
104 |
111 |
117 |
123 |
129 |
130 |
131 |
HIDE MOUSE
132 |
140 |
141 |
142 |
149 |
150 |
151 |
152 |
153 |
154 | );
155 | };
156 |
157 | export default Settings;
158 |
159 | /**
160 | * Save button
161 | *
162 | * @param {Object} props
163 | * @param {String} [props.mapsKey]
164 | * @param {String} [props.weatherKey]
165 | * @param {String} [props.geoKey]
166 | * @param {String} [props.lat]
167 | * @param {String} [props.lon]
168 | * @returns {JSX.Element} Save button
169 | */
170 | const SaveButton = ({ mapsKey, weatherKey, geoKey, lat, lon }) => {
171 | const { saveSettingsToJson, setSettingsMenuOpen, mouseHide } = useContext(
172 | AppContext
173 | );
174 | return (
175 | {
180 | saveSettingsToJson({ mapsKey, weatherKey, geoKey, lat, lon })
181 | .then(() => {
182 | setSettingsMenuOpen(false);
183 | })
184 | .catch((err) => {
185 | console.log("err!", err);
186 | });
187 | }}
188 | >
189 |
SAVE
190 |
191 |
192 |
193 |
194 | );
195 | };
196 |
197 | SaveButton.propTypes = {
198 | mapsKey: PropTypes.string,
199 | weatherKey: PropTypes.string,
200 | geoKey: PropTypes.string,
201 | lat: PropTypes.string,
202 | lon: PropTypes.string,
203 | };
204 |
205 | /**
206 | * Toggle Buttons Group
207 | *
208 | * @returns {JSX.Element} A grouping of toggle buttons
209 | */
210 | const ToggleButtons = () => {
211 | const {
212 | tempUnit,
213 | saveTempUnit,
214 | speedUnit,
215 | saveSpeedUnit,
216 | lengthUnit,
217 | saveLengthUnit,
218 | clockTime,
219 | saveClockTime,
220 | } = useContext(AppContext);
221 |
222 | return (
223 |
224 |
UNITS
225 |
226 |
227 |
235 |
236 |
237 |
245 |
246 |
247 |
255 |
256 |
257 |
265 |
266 |
267 |
268 | );
269 | };
270 |
271 | /**
272 | * Toggle buttons
273 | *
274 | * @param {Object} props
275 | * @param {String} props.button1Label PropTypes.string.isRequired,
276 | * @param {String} props.button2Label PropTypes.string.isRequired,
277 | * @param {*} props.val PropTypes.string.isRequired,
278 | * @param {*} props.button1Val PropTypes.string.isRequired,
279 | * @param {*} props.button2Val PropTypes.string.isRequired,
280 | * @param {Function} props.cb PropTypes.func.isRequired,
281 | * @returns {JSX.Element} Toggle buttons
282 | */
283 | const ToggleButton = ({
284 | button1Label,
285 | button2Label,
286 | val,
287 | button1Val,
288 | button2Val,
289 | cb,
290 | }) => {
291 | return (
292 |
293 |
{
296 | cb(button1Val);
297 | }}
298 | >
299 | {button1Label}
300 |
301 |
{
304 | cb(button2Val);
305 | }}
306 | >
307 | {button2Label}
308 |
309 |
310 | );
311 | };
312 |
313 | ToggleButton.propTypes = {
314 | button1Label: PropTypes.string.isRequired,
315 | button2Label: PropTypes.string.isRequired,
316 | val: PropTypes.any.isRequired,
317 | button1Val: PropTypes.any.isRequired,
318 | button2Val: PropTypes.any.isRequired,
319 | cb: PropTypes.func.isRequired,
320 | };
321 |
322 | /**
323 | * Delete button
324 | *
325 | * @param {Object} props
326 | * @param {Function} props.cb callback
327 | * @returns {JSX.Element} Delete button
328 | */
329 | const DeleteButton = ({ cb }) => {
330 | return (
331 |
332 |
333 |
334 | );
335 | };
336 |
337 | DeleteButton.propTypes = {
338 | cb: PropTypes.func.isRequired,
339 | };
340 |
341 | /**
342 | * Undo button, restores input to default value
343 | *
344 | * @param {Object} props
345 | * @param {Function} props.cb callback
346 | * @returns {JSX.Element} Undo button
347 | */
348 | const UndoButton = ({ cb }) => {
349 | return (
350 |
351 |
352 |
353 | );
354 | };
355 |
356 | UndoButton.propTypes = {
357 | cb: PropTypes.func.isRequired,
358 | };
359 |
360 | /**
361 | * Settings input
362 | *
363 | * @param {Object} props
364 | * @param {String} props.label Label
365 | * @param {String} props.val value
366 | * @param {Function} props.cb change callback
367 | * @param {String} props.current current default value
368 | * @param {Boolean} [props.required] If input is required
369 | * @returns {JSX.Element} Input
370 | */
371 | const Input = ({ label, val, cb, required, current }) => {
372 | const [inputValue, setInputValue] = useState(val);
373 | const [defaultValue, setDefaultValue] = useState(null);
374 |
375 | useEffect(() => {
376 | if ((val || val === "") && (!defaultValue || defaultValue === "")) {
377 | setDefaultValue(val);
378 | }
379 | setInputValue(val);
380 | }, [val, defaultValue]);
381 | return (
382 |
383 |
{label}
384 |
389 |
{
394 | const { value } = e.target;
395 | setInputValue(value);
396 | cb(value);
397 | }}
398 | />
399 |
400 |
401 | {
403 | setInputValue("");
404 | cb("");
405 | }}
406 | />
407 | {
409 | setInputValue(current);
410 | cb(current);
411 | }}
412 | />
413 |
414 |
415 |
416 | );
417 | };
418 |
419 | Input.propTypes = {
420 | label: PropTypes.string,
421 | val: PropTypes.string,
422 | cb: PropTypes.func.isRequired,
423 | required: PropTypes.bool,
424 | current: PropTypes.string,
425 | };
426 |
--------------------------------------------------------------------------------
/client/src/components/Settings/styles.css:
--------------------------------------------------------------------------------
1 | .container {
2 | height: calc(100vh - 20px); /* subtract padding */
3 | width: calc(100vw - 320px); /* subtract info panel and padding */
4 | background-color: rgba(0, 0, 0, 0.65);
5 | backdrop-filter: blur(3px);
6 | padding: 10px;
7 | color: #f6f6f4;
8 | font-family: "Rubik", sans-serif;
9 | border-right: 1px solid rgba(0, 0, 0, 0.15);
10 | position: relative;
11 | }
12 |
13 | .header {
14 | font-size: 14px;
15 | }
16 |
17 | .settings-container {
18 | margin-top: 20px;
19 | font-size: 14px;
20 | }
21 |
22 | .input-container {
23 | display: flex;
24 | align-items: center;
25 | }
26 |
27 | .input-container input {
28 | height: 30px;
29 | width: 100%;
30 | max-width: 800px;
31 | margin-right: 10px;
32 | outline: none;
33 | border: 2px solid #5d5c5c;
34 | }
35 |
36 | .input-container.invalid input {
37 | border: 2px solid #c20808;
38 | }
39 |
40 | .button-container {
41 | display: flex;
42 | }
43 |
44 | .button-container > div {
45 | margin-right: 10px;
46 | }
47 |
48 | .button-container > div:last-child {
49 | margin-right: 0px;
50 | }
51 |
52 | .label {
53 | font-size: 13px;
54 | }
55 |
56 | .button {
57 | height: 34px;
58 | width: 34px;
59 | font-size: 20px;
60 | display: flex;
61 | align-items: center;
62 | justify-content: center;
63 | background: linear-gradient(#5d5c5c, #4e4e4e);
64 | border-top: 1px solid #757575;
65 | border-left: 1px solid #757575;
66 | border-right: 1px solid #4c4c4c;
67 | border-bottom: 1px solid #4c4c4c;
68 | }
69 |
70 | .show-mouse.button {
71 | cursor: pointer;
72 | }
73 |
74 | .button.down {
75 | color: #4e4e4e;
76 | background: linear-gradient(#d6d6d6, #c7c7c7);
77 | }
78 |
79 | .button:active {
80 | background: linear-gradient(#4e4e4e, #5d5c5c);
81 | }
82 |
83 | .settings-item {
84 | margin: 10px 0px;
85 | }
86 |
87 | .toggle-container {
88 | display: flex;
89 | }
90 |
91 | .toggle-buttons {
92 | display: flex;
93 | max-width: 900px;
94 |
95 | justify-content: space-between;
96 | }
97 |
98 | .toggle-buttons > div {
99 | margin-right: 10px;
100 | }
101 |
102 | .toggle-buttons > div:last-child {
103 | margin-right: 0px;
104 | }
105 |
106 | .toggle-container > .button {
107 | width: 50px;
108 | }
109 |
110 | .bottom-button-container {
111 | width: 100%;
112 | margin-top: 10px;
113 | display: flex;
114 | justify-content: space-between;
115 | }
116 |
117 | .save-button {
118 | width: 80px;
119 | display: flex;
120 | align-items: center;
121 | }
122 |
123 | .save-button .label {
124 | margin-right: 5px;
125 | }
126 |
127 | .close-button {
128 | font-size: 20px;
129 | position: absolute;
130 | top: 10px;
131 | right: 10px;
132 | }
133 |
134 | .show-mouse.close-button {
135 | cursor: pointer;
136 | }
137 |
138 | .close-button:active {
139 | color: #c7c7c7;
140 | }
141 |
142 | .save-button-container {
143 | margin-top: 15px;
144 | }
--------------------------------------------------------------------------------
/client/src/components/Spinner/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styles from "./styles.css";
3 | import PropTypes from "prop-types";
4 |
5 | /*
6 | https://github.com/tobiasahlin/SpinKit
7 | The MIT License (MIT)
8 |
9 | Copyright (c) 2020 Tobias Ahlin
10 |
11 | Permission is hereby granted, free of charge, to any person obtaining a copy of
12 | this software and associated documentation files (the "Software"), to deal in
13 | the Software without restriction, including without limitation the rights to
14 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
15 | the Software, and to permit persons to whom the Software is furnished to do so,
16 | subject to the following conditions:
17 |
18 | The above copyright notice and this permission notice shall be included in all
19 | copies or substantial portions of the Software.
20 |
21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
23 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
24 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
25 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
26 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27 | */
28 |
29 | /**
30 | * A spinner based off of https://github.com/tobiasahlin/SpinKit
31 | *
32 | * @param {Object} props
33 | * @param {String} props.color
34 | * @param {String} props.size
35 | * @returns {JSX.Element} Spinner
36 | */
37 | function Spinner({ color, size }) {
38 | const style = {
39 | height: size,
40 | width: size,
41 | backgroundColor: color,
42 | };
43 |
44 | return (
45 |
50 | );
51 | }
52 |
53 | Spinner.propTypes = {
54 | size: PropTypes.string,
55 | color: PropTypes.string,
56 | };
57 |
58 | export default Spinner;
59 |
--------------------------------------------------------------------------------
/client/src/components/Spinner/styles.css:
--------------------------------------------------------------------------------
1 | .spinner {
2 | display: flex;
3 | justify-content: center;
4 | }
5 |
6 | .spinner > div {
7 | border-radius: 100%;
8 | display: inline-block;
9 | -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
10 | animation: sk-bouncedelay 1.4s infinite ease-in-out both;
11 | }
12 |
13 | .spinner .bounce1 {
14 | -webkit-animation-delay: -0.32s;
15 | animation-delay: -0.32s;
16 | }
17 |
18 | .spinner .bounce2 {
19 | -webkit-animation-delay: -0.16s;
20 | animation-delay: -0.16s;
21 | }
22 |
23 | @-webkit-keyframes sk-bouncedelay {
24 | 0%,
25 | 80%,
26 | 100% {
27 | -webkit-transform: scale(0);
28 | }
29 | 40% {
30 | -webkit-transform: scale(1);
31 | }
32 | }
33 |
34 | @keyframes sk-bouncedelay {
35 | 0%,
36 | 80%,
37 | 100% {
38 | -webkit-transform: scale(0);
39 | transform: scale(0);
40 | }
41 | 40% {
42 | -webkit-transform: scale(1);
43 | transform: scale(1);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/client/src/components/SunRiseSet/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { AppContext } from "~/AppContext";
3 | import { InlineIcon } from "@iconify/react";
4 | import bxsMoon from '@iconify/icons-bx/bxs-moon';
5 | import bxsSun from '@iconify/icons-bx/bxs-sun';
6 |
7 | import { format } from "date-fns";
8 | import styles from "./styles.css";
9 |
10 | /**
11 | * Sunrise / Sunset component
12 | *
13 | * @returns {JSX.Element} Sunrise / Sunset component
14 | */
15 | const SunRiseSet = () => {
16 | const { sunriseTime, sunsetTime, clockTime } = useContext(AppContext);
17 | if (sunriseTime && sunsetTime) {
18 | return (
19 |
20 |
21 |
22 |
23 | {format(new Date(sunriseTime), clockTime === "12" ? "p" : "HH:mm")}
24 |
25 |
26 |
27 |
28 |
29 | {format(new Date(sunsetTime), clockTime === "12" ? "p" : "HH:mm")}
30 |
31 |
32 |
33 | );
34 | } else {
35 | return null;
36 | }
37 | };
38 |
39 | export default SunRiseSet;
40 |
--------------------------------------------------------------------------------
/client/src/components/SunRiseSet/styles.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | font-size: 14px;
4 | /* padding-top: 5px; */
5 | }
6 |
7 | .container > div:first-child {
8 | margin-right: 5px;
9 | }
10 |
11 | .container > div > span {
12 | margin-left: 2px;
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/components/WeatherInfo/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useContext, useState, useCallback } from "react";
2 | import Spinner from "~/components/Spinner";
3 | import { AppContext } from "~/AppContext";
4 | import styles from "./styles.css";
5 | import LocationName from "~/components/LocationName";
6 | import CurrentWeather from "~/components/CurrentWeather";
7 | import DailyChart from "~/components/weatherCharts/DailyChart";
8 | import HourlyChart from "~/components/weatherCharts/HourlyChart";
9 |
10 | const CURRENT_WEATHER_DATA_UPDATE_INTERVAL = 10 * 60 * 1000; //every 10 minutes
11 | const HOURLY_WEATHER_DATA_UPDATE_INTERVAL = 60 * 60 * 1000; //every hour
12 | const DAILY_WEATHER_DATA_UPDATE_INTERVAL = 24 * 60 * 60 * 1000; //every day
13 |
14 | /**
15 | * Creates an interval to call a weather update callback
16 | *
17 | * @param {Object} params
18 | * @param {Object} params.stateInterval Interval in state
19 | * @param {Function} params.stateIntervalSetter state interval setter
20 | * @param {Function} params.cb callback to invoke on each interval
21 | * @param {Number} params.intervalTime interval frequency, ms
22 | * @param {String} params.weatherApiKey weather API key
23 | * @param {Object} params.mapGeo coordinates to get weather for
24 | */
25 | function createWeatherUpdateInterval({
26 | stateInterval,
27 | stateIntervalSetter,
28 | cb,
29 | intervalTime,
30 | weatherApiKey,
31 | mapGeo,
32 | }) {
33 | if (stateInterval) {
34 | clearInterval(stateInterval);
35 | stateIntervalSetter(null);
36 | }
37 | if (weatherApiKey && mapGeo) {
38 | const interval = setInterval(cb, intervalTime);
39 | cb();
40 | stateIntervalSetter(interval);
41 | }
42 | }
43 |
44 | /**
45 | * Displays weather info
46 | *
47 | * @returns {JSX.Element} Clock component
48 | */
49 | const WeatherInfo = () => {
50 | const {
51 | getWeatherApiKey,
52 | getReverseGeoApiKey,
53 | reverseGeoApiKey,
54 | updateCurrentWeatherData,
55 | updateHourlyWeatherData,
56 | updateDailyWeatherData,
57 | mapGeo,
58 | weatherApiKey,
59 | currentWeatherDataErr,
60 | currentWeatherDataErrMsg,
61 | darkMode,
62 | setSettingsMenuOpen,
63 | currentWeatherData,
64 | updateSunriseSunset
65 | } = useContext(AppContext);
66 |
67 | const [
68 | currentWeatherUpdateInterval,
69 | setCurrentWeatherUpdateInterval,
70 | ] = useState(null);
71 | const [
72 | hourlyWeatherUpdateInterval,
73 | setHourlyWeatherUpdateInterval,
74 | ] = useState(null);
75 | const [dailyWeatherUpdateInterval, setDailyWeatherUpdateInterval] = useState(
76 | null
77 | );
78 | const [err, setErr] = useState(null);
79 |
80 | const hourlyWeatherUpdateCb = useCallback(() => {
81 | updateSunriseSunset(mapGeo);
82 | updateHourlyWeatherData(mapGeo).catch((err) => {
83 | console.log("err", err);
84 | });
85 | }, [updateHourlyWeatherData, updateSunriseSunset, mapGeo]);
86 |
87 | const dailyWeatherUpdateCb = useCallback(() => {
88 | updateDailyWeatherData(mapGeo).catch((err) => {
89 | console.log("err", err);
90 | });
91 | }, [updateDailyWeatherData, mapGeo]);
92 |
93 | const currentWeatherUpdateCb = useCallback(() => {
94 | updateCurrentWeatherData(mapGeo).catch((err) => {
95 | console.log("err", err);
96 | });
97 | }, [updateCurrentWeatherData, mapGeo]);
98 |
99 | useEffect(() => {
100 | setErr(false);
101 | if (!weatherApiKey) {
102 | getWeatherApiKey().catch((err) => {
103 | console.log("error getting weather api key:", err);
104 | setErr(true);
105 | setSettingsMenuOpen(true);
106 | });
107 | }
108 | if (!reverseGeoApiKey) {
109 | getReverseGeoApiKey().catch((err) => {
110 | console.log("error getting reverse geo api key:", err);
111 | });
112 | }
113 | }, [weatherApiKey, reverseGeoApiKey]); // eslint-disable-line react-hooks/exhaustive-deps
114 |
115 | useEffect(() => {
116 | createWeatherUpdateInterval({
117 | stateInterval: currentWeatherUpdateInterval,
118 | stateIntervalSetter: setCurrentWeatherUpdateInterval,
119 | cb: currentWeatherUpdateCb,
120 | intervalTime: CURRENT_WEATHER_DATA_UPDATE_INTERVAL,
121 | weatherApiKey,
122 | mapGeo,
123 | });
124 | createWeatherUpdateInterval({
125 | stateInterval: hourlyWeatherUpdateInterval,
126 | stateIntervalSetter: setHourlyWeatherUpdateInterval,
127 | cb: hourlyWeatherUpdateCb,
128 | intervalTime: HOURLY_WEATHER_DATA_UPDATE_INTERVAL,
129 | weatherApiKey,
130 | mapGeo,
131 | });
132 | createWeatherUpdateInterval({
133 | stateInterval: dailyWeatherUpdateInterval,
134 | stateIntervalSetter: setDailyWeatherUpdateInterval,
135 | cb: dailyWeatherUpdateCb,
136 | intervalTime: DAILY_WEATHER_DATA_UPDATE_INTERVAL,
137 | weatherApiKey,
138 | mapGeo,
139 | });
140 | return () => {
141 | clearInterval(currentWeatherUpdateInterval);
142 | clearInterval(hourlyWeatherUpdateInterval);
143 | clearInterval(dailyWeatherUpdateInterval);
144 | };
145 | }, [weatherApiKey, mapGeo]); // eslint-disable-line react-hooks/exhaustive-deps
146 |
147 | if (currentWeatherData) {
148 | return (
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 | );
164 | } else if (currentWeatherData || currentWeatherDataErr || err) {
165 | return (
166 |
171 |
Could not retrieve weather data.
172 |
Is your weather API key valid?
173 | {currentWeatherDataErr ? (
174 |
{currentWeatherDataErrMsg}
175 | ) : null}
176 |
177 | );
178 | } else {
179 | return (
180 |
181 |
182 |
183 | );
184 | }
185 | };
186 |
187 | export default WeatherInfo;
188 |
--------------------------------------------------------------------------------
/client/src/components/WeatherInfo/styles.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 0px 10px;
3 | }
4 |
5 | .location {
6 | margin-bottom: 3px;
7 | }
8 |
9 | .weather-chart {
10 | display: flex;
11 | justify-content: center;
12 | }
13 |
14 | .loading-container {
15 | display: flex;
16 | justify-content: center;
17 | margin-top: 60px;
18 | }
19 |
20 | .err-container {
21 | display: flex;
22 | justify-content: center;
23 | align-items: center;
24 | margin-top: 60px;
25 | font-size: 12px;
26 | flex-direction: column;
27 | }
28 |
29 | .dark {
30 | color: #f6f6f444;
31 | }
32 |
33 | .light {
34 | color: #3a393888;
35 | }
36 |
37 | .err-container .message{
38 | margin-top: 10px;
39 | word-break: break-word;
40 | }
--------------------------------------------------------------------------------
/client/src/components/WeatherMap/index.js:
--------------------------------------------------------------------------------
1 | import React, {
2 | useEffect,
3 | useContext,
4 | useState,
5 | useCallback,
6 | useRef,
7 | } from "react";
8 | import { Map, TileLayer, AttributionControl, Marker } from "react-leaflet";
9 | import PropTypes from "prop-types";
10 | import { AppContext } from "~/AppContext";
11 | import debounce from "debounce";
12 | import axios from "axios";
13 | import styles from "./styles.css";
14 |
15 | /**
16 | * Weather map
17 | *
18 | * @param {Object} props
19 | * @param {Number} props.zoom zoom level
20 | * @param {Boolean} [props.dark] dark mode
21 | * @returns {JSX.Element} Weather map
22 | */
23 | const WeatherMap = ({ zoom, dark }) => {
24 | const MAP_CLICK_DEBOUNCE_TIME = 200; //ms
25 | const {
26 | setMapPosition,
27 | panToCoords,
28 | setPanToCoords,
29 | browserGeo,
30 | mapGeo,
31 | mapApiKey,
32 | getMapApiKey,
33 | markerIsVisible,
34 | animateWeatherMap,
35 | } = useContext(AppContext);
36 | const mapRef = useRef();
37 |
38 | const mapClickHandler = useCallback(
39 | debounce((e) => {
40 | const { lat: latitude, lng: longitude } = e.latlng;
41 | const newCoords = { latitude, longitude };
42 | setMapPosition(newCoords);
43 | }, MAP_CLICK_DEBOUNCE_TIME),
44 | [setMapPosition]
45 | );
46 |
47 | const [mapTimestamps, setMapTimestamps] = useState(null);
48 | const [mapTimestamp, setMapTimestamp] = useState(null);
49 | const [currentMapTimestampIdx, setCurrentMapTimestampIdx] = useState(0);
50 |
51 | const MAP_TIMESTAMP_REFRESH_FREQUENCY = 1000 * 60 * 10; //update every 10 minutes
52 | const MAP_CYCLE_RATE = 1000; //ms
53 |
54 | const getMapApiKeyCallback = useCallback(() => getMapApiKey(), [
55 | getMapApiKey,
56 | ]);
57 |
58 | useEffect(() => {
59 | getMapApiKeyCallback().catch((err) => {
60 | console.log("err!", err);
61 | });
62 |
63 | const updateTimeStamps = () => {
64 | getMapTimestamps()
65 | .then((res) => {
66 | setMapTimestamps(res);
67 | })
68 | .catch((err) => {
69 | console.log("err", err);
70 | });
71 | };
72 |
73 | const mapTimestampsInterval = setInterval(
74 | updateTimeStamps,
75 | MAP_TIMESTAMP_REFRESH_FREQUENCY
76 | );
77 | updateTimeStamps(); //initial update
78 | return () => {
79 | clearInterval(mapTimestampsInterval);
80 | };
81 | }, []); // eslint-disable-line react-hooks/exhaustive-deps
82 |
83 | // Pan the screen to a a specific location when `panToCoords` is updated with grid coordinates
84 | useEffect(() => {
85 | if (panToCoords && mapRef.current) {
86 | const { leafletElement } = mapRef.current;
87 | leafletElement.panTo([panToCoords.latitude, panToCoords.longitude]);
88 | setPanToCoords(null); //reset back to null so we can observe a change next time its fired for the same coords
89 | }
90 | }, [panToCoords, mapRef]); // eslint-disable-line react-hooks/exhaustive-deps
91 |
92 | const { latitude, longitude } = browserGeo || {};
93 |
94 | useEffect(() => {
95 | if (mapTimestamps) {
96 | setMapTimestamp(mapTimestamps[currentMapTimestampIdx]);
97 | }
98 | }, [currentMapTimestampIdx, mapTimestamps]);
99 |
100 | // cycle through weather maps when animated is enabled
101 | useEffect(() => {
102 | if (mapTimestamps) {
103 | if (animateWeatherMap) {
104 | const interval = setInterval(() => {
105 | let nextIdx;
106 | if (currentMapTimestampIdx + 1 >= mapTimestamps.length) {
107 | nextIdx = 0;
108 | } else {
109 | nextIdx = currentMapTimestampIdx + 1;
110 | }
111 | setCurrentMapTimestampIdx(nextIdx);
112 | }, MAP_CYCLE_RATE);
113 | return () => {
114 | clearInterval(interval);
115 | };
116 | } else {
117 | setCurrentMapTimestampIdx(mapTimestamps.length - 1);
118 | }
119 | }
120 | }, [currentMapTimestampIdx, animateWeatherMap, mapTimestamps]);
121 |
122 | if (!hasVal(latitude) || !hasVal(longitude) || !zoom || !mapApiKey) {
123 | return (
124 |
125 |
Cannot retrieve map data.
126 |
Did you enter an API key?
127 |
128 | );
129 | }
130 | const markerPosition = mapGeo ? [mapGeo.latitude, mapGeo.longitude] : null;
131 |
132 | return (
133 |
167 | );
168 | };
169 |
170 | WeatherMap.propTypes = {
171 | zoom: PropTypes.number.isRequired,
172 | dark: PropTypes.bool,
173 | };
174 |
175 | /**
176 | * Weather layer
177 | *
178 | * @param {Object} props
179 | * @param {String} props.layer
180 | * @param {String} props.weatherApiKey
181 | * @returns {JSX.Element} Weather layer
182 | */
183 | const WeatherLayer = ({ layer, weatherApiKey }) => {
184 | return (
185 |
190 | );
191 | };
192 |
193 | WeatherLayer.propTypes = {
194 | layer: PropTypes.string.isRequired,
195 | weatherApiKey: PropTypes.string,
196 | };
197 |
198 | /**
199 | * Determines if truthy, but returns true for 0
200 | *
201 | * @param {*} i
202 | * @returns {Boolean} If truthy or zero
203 | */
204 | function hasVal(i) {
205 | return !!(i || i === 0);
206 | }
207 |
208 | /**
209 | * Get timestamps for weather map
210 | *
211 | * @returns {Promise} Promise of timestamps
212 | */
213 | function getMapTimestamps() {
214 | return new Promise((resolve, reject) => {
215 | axios
216 | .get("https://api.rainviewer.com/public/maps.json")
217 | .then((res) => {
218 | resolve(res.data);
219 | })
220 | .catch((err) => {
221 | reject(err);
222 | });
223 | });
224 | }
225 |
226 | export default WeatherMap;
227 |
--------------------------------------------------------------------------------
/client/src/components/WeatherMap/styles.css:
--------------------------------------------------------------------------------
1 | .no-map {
2 | height: 100%;
3 | width: 100%;
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | font-family: Rubik;
8 | flex-direction: column;
9 | font-size: 12px;
10 | }
11 |
12 | .dark {
13 | color: #f6f6f444;
14 | }
15 |
16 | .light {
17 | color: #3a393888;
18 | }
19 |
--------------------------------------------------------------------------------
/client/src/components/weatherCharts/DailyChart/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState, useEffect, useCallback } from "react";
2 | import { AppContext } from "~/AppContext";
3 | import styles from "../styles.css";
4 | import { Line } from "react-chartjs-2";
5 | import { format } from "date-fns";
6 | import {
7 | convertTemp,
8 | convertLength,
9 | convertSpeed,
10 | } from "~/services/conversions";
11 | import { fontColor } from "../common";
12 |
13 | const createChartOptions = ({
14 | darkMode,
15 | tempUnit,
16 | speedUnit,
17 | lengthUnit,
18 | altMode,
19 | }) => {
20 | return {
21 | maintainAspectRatio: false,
22 | legend: {
23 | display: false,
24 | },
25 | responsive: true,
26 | hoverMode: "index",
27 | stacked: false,
28 | title: {
29 | display: true,
30 | text: `5 Day ${
31 | altMode
32 | ? `Wind Speed / Precipitation (${lengthUnit})`
33 | : `Temp / Precipitation`
34 | }`,
35 | fontColor: fontColor(darkMode),
36 | fontFamily: "Rubik, sans-serif",
37 | },
38 | scales: {
39 | xAxes: [
40 | {
41 | ticks: {
42 | fontColor: fontColor(darkMode),
43 | fontFamily: "Rubik, sans-serif",
44 | },
45 | },
46 | ],
47 | yAxes: [
48 | {
49 | type: "linear",
50 | display: true,
51 | position: "left",
52 | id: "y-axis-1",
53 | ticks: {
54 | fontColor: fontColor(darkMode),
55 | fontFamily: "Rubik, sans-serif",
56 | maxTicksLimit: 5,
57 | callback: (val) => {
58 | return altMode
59 | ? `${val} ${speedUnit === "mph" ? "mph" : "m/s"}`
60 | : `${val} ${tempUnit.toUpperCase()}`;
61 | },
62 | },
63 | },
64 | {
65 | type: "linear",
66 | display: true,
67 | position: "right",
68 | id: "y-axis-2",
69 | ticks: {
70 | fontColor: fontColor(darkMode),
71 | fontFamily: "Rubik, sans-serif",
72 | maxTicksLimit: 5,
73 | suggestedMin: 0,
74 | callback: (val) => {
75 | return `${val}${altMode ? ` ${lengthUnit}` : "%"}`;
76 | },
77 | },
78 | gridLines: {
79 | drawOnChartArea: false,
80 | },
81 | },
82 | ],
83 | },
84 | };
85 | };
86 |
87 | const chartColors = {
88 | blue: "rgba(63, 127, 191, 0.5)",
89 | gray: "rgba(127, 127, 127, 0.5)",
90 | };
91 |
92 | const mapChartData = ({
93 | data: weatherData,
94 | tempUnit,
95 | speedUnit,
96 | altMode,
97 | lengthUnit,
98 | }) => {
99 | const data = weatherData?.data?.timelines?.[0]?.intervals;
100 | if (!data) {
101 | return null;
102 | }
103 | return {
104 | labels: data.map((e) => {
105 | const date = new Date(e.startTime);
106 | const adjustedTimestamp =
107 | date.getTime() + date.getTimezoneOffset() * 60 * 1000;
108 | return format(new Date(adjustedTimestamp), "EEEEE");
109 | }),
110 | datasets: [
111 | {
112 | radius: 0,
113 | label: altMode ? "Wind Speed" : "Temp",
114 | data: data.map((e) => {
115 | const {
116 | values: { windSpeed, temperature },
117 | } = e;
118 | return altMode
119 | ? convertSpeed(windSpeed, speedUnit)
120 | : convertTemp(temperature, tempUnit);
121 | }),
122 | yAxisID: "y-axis-1",
123 | borderColor: chartColors.gray,
124 | backgroundColor: chartColors.gray,
125 | fill: false,
126 | },
127 | {
128 | radius: 0,
129 | label: "Precipitation",
130 | data: data.map((e) => {
131 | const {
132 | values: { precipitationIntensity, precipitationProbability },
133 | } = e;
134 | return altMode
135 | ? convertLength(precipitationIntensity, lengthUnit)
136 | : precipitationProbability;
137 | }),
138 | yAxisID: "y-axis-2",
139 | borderColor: chartColors.blue,
140 | backgroundColor: chartColors.blue,
141 | fill: false,
142 | },
143 | ],
144 | };
145 | };
146 |
147 | /**
148 | * Daily forecast chart
149 | *
150 | * @returns {JSX.Element} Hourly forecast chart
151 | */
152 | const DailyChart = () => {
153 | const {
154 | dailyWeatherData,
155 | dailyWeatherDataErr,
156 | dailyWeatherDataErrMsg,
157 | tempUnit,
158 | darkMode,
159 | lengthUnit,
160 | speedUnit,
161 | } = useContext(AppContext);
162 |
163 | const [altMode, setAltMode] = useState(false);
164 | const [chartData, setChartData] = useState(null);
165 | const [chartOptions, setChartOptions] = useState(null);
166 |
167 | const setChartDataCallback = useCallback((e) => setChartData(e), []);
168 | const setChartOptionsCallback = useCallback((e) => setChartOptions(e), []);
169 |
170 | useEffect(() => {
171 | if (dailyWeatherData) {
172 | setChartDataCallback(
173 | mapChartData({
174 | data: dailyWeatherData,
175 | tempUnit,
176 | lengthUnit,
177 | speedUnit,
178 | altMode,
179 | })
180 | );
181 |
182 | setChartOptionsCallback(
183 | createChartOptions({
184 | tempUnit,
185 | darkMode,
186 | lengthUnit,
187 | speedUnit,
188 | altMode,
189 | })
190 | );
191 | }
192 | }, [
193 | dailyWeatherData,
194 | tempUnit,
195 | lengthUnit,
196 | altMode,
197 | speedUnit,
198 | darkMode,
199 | setChartOptionsCallback,
200 | setChartDataCallback,
201 | ]);
202 |
203 | if (chartData && chartOptions) {
204 | return (
205 | {
208 | setAltMode(!altMode);
209 | }}
210 | >
211 |
212 |
213 | );
214 | } else if (dailyWeatherDataErr) {
215 | return (
216 |
221 |
Cannot get 5 day weather forecast
222 |
{dailyWeatherDataErrMsg}
223 |
224 | );
225 | } else {
226 | return null;
227 | }
228 | };
229 |
230 | export default DailyChart;
231 |
--------------------------------------------------------------------------------
/client/src/components/weatherCharts/HourlyChart/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState, useEffect } from "react";
2 | import { AppContext } from "~/AppContext";
3 | import styles from "../styles.css";
4 | import { Line } from "react-chartjs-2";
5 | import { format } from "date-fns";
6 | import {
7 | convertTemp,
8 | convertLength,
9 | convertSpeed,
10 | } from "~/services/conversions";
11 | import { fontColor } from "../common";
12 |
13 | const chartOptions = ({
14 | darkMode,
15 | tempUnit,
16 | speedUnit,
17 | lengthUnit,
18 | altMode,
19 | }) => {
20 | return {
21 | maintainAspectRatio: false,
22 | legend: {
23 | display: false,
24 | },
25 | responsive: true,
26 | hoverMode: "index",
27 | stacked: false,
28 | title: {
29 | display: true,
30 | text: `24 Hour ${
31 | altMode
32 | ? `Wind Speed / Precipitation (${lengthUnit})`
33 | : `Temp / Precipitation`
34 | }`,
35 | fontColor: fontColor(darkMode),
36 | fontFamily: "Rubik, sans-serif",
37 | },
38 | scales: {
39 | xAxes: [
40 | {
41 | ticks: {
42 | fontColor: fontColor(darkMode),
43 | fontFamily: "Rubik, sans-serif",
44 | },
45 | },
46 | ],
47 | yAxes: [
48 | {
49 | type: "linear",
50 | display: true,
51 | position: "left",
52 | id: "y-axis-1",
53 | ticks: {
54 | fontColor: fontColor(darkMode),
55 | fontFamily: "Rubik, sans-serif",
56 | maxTicksLimit: 5,
57 | callback: (val) => {
58 | return altMode
59 | ? `${val} ${speedUnit === "mph" ? "mph" : "m/s"}`
60 | : `${val} ${tempUnit.toUpperCase()}`;
61 | },
62 | },
63 | },
64 | {
65 | type: "linear",
66 | display: true,
67 | position: "right",
68 | id: "y-axis-2",
69 | ticks: {
70 | fontColor: fontColor(darkMode),
71 | fontFamily: "Rubik, sans-serif",
72 | maxTicksLimit: 5,
73 | suggestedMin: 0,
74 | callback: (val) => {
75 | return `${val}${altMode ? ` ${lengthUnit}` : "%"}`;
76 | },
77 | },
78 | gridLines: {
79 | drawOnChartArea: false,
80 | },
81 | },
82 | ],
83 | },
84 | };
85 | };
86 |
87 | const chartColors = {
88 | blue: "rgba(63, 127, 191, 0.5)",
89 | gray: "rgba(127, 127, 127, 0.5)",
90 | };
91 |
92 | const mapChartData = ({
93 | data: weatherData,
94 | tempUnit,
95 | speedUnit,
96 | clockTime,
97 | altMode,
98 | lengthUnit,
99 | }) => {
100 | const data = weatherData?.data?.timelines?.[0]?.intervals;
101 | if (!data) {
102 | return null;
103 | }
104 | return {
105 | labels: data.map((e) => {
106 | if (clockTime === "12") {
107 | return `${format(new Date(e.startTime), "h")}${format(
108 | new Date(e.startTime),
109 | "aaaaa"
110 | )}`;
111 | } else {
112 | return `${format(new Date(e.startTime), "HH")}`;
113 | }
114 | }),
115 | datasets: [
116 | {
117 | radius: 0,
118 | label: altMode ? "Wind Speed" : "Temp",
119 | data: data.map((e) => {
120 | const {
121 | values: { windSpeed, temperature },
122 | } = e;
123 | return altMode
124 | ? convertSpeed(windSpeed, speedUnit)
125 | : convertTemp(temperature, tempUnit);
126 | }),
127 | yAxisID: "y-axis-1",
128 | borderColor: chartColors.gray,
129 | backgroundColor: chartColors.gray,
130 | fill: false,
131 | },
132 | {
133 | radius: 0,
134 | label: "Precipitation",
135 | data: data.map((e) => {
136 | const {
137 | values: { precipitationIntensity, precipitationProbability },
138 | } = e;
139 | return altMode
140 | ? convertLength(precipitationIntensity, lengthUnit)
141 | : precipitationProbability;
142 | }),
143 | yAxisID: "y-axis-2",
144 | borderColor: chartColors.blue,
145 | backgroundColor: chartColors.blue,
146 | fill: false,
147 | },
148 | ],
149 | };
150 | };
151 |
152 | /**
153 | * Hourly forecast chart
154 | *
155 | * @returns {JSX.Element} Hourly forecast chart
156 | */
157 | const HourlyChart = () => {
158 | const {
159 | hourlyWeatherData,
160 | tempUnit,
161 | darkMode,
162 | clockTime,
163 | lengthUnit,
164 | speedUnit,
165 | hourlyWeatherDataErr,
166 | hourlyWeatherDataErrMsg,
167 | } = useContext(AppContext);
168 |
169 | const [altMode, setAltMode] = useState(false);
170 | const [chartData, setChartData] = useState(null);
171 | useEffect(() => {
172 | if (hourlyWeatherData) {
173 | setChartData(
174 | mapChartData({
175 | data: hourlyWeatherData,
176 | tempUnit,
177 | clockTime,
178 | lengthUnit,
179 | speedUnit,
180 | altMode,
181 | })
182 | );
183 | }
184 | }, [hourlyWeatherData, tempUnit, clockTime, lengthUnit, altMode, speedUnit]);
185 |
186 | if (chartData) {
187 | return (
188 | {
191 | setAltMode(!altMode);
192 | }}
193 | >
194 |
204 |
205 | );
206 | } else if (hourlyWeatherDataErr) {
207 | return (
208 |
213 |
Cannot get 24 hour weather forecast
214 |
{hourlyWeatherDataErrMsg}
215 |
216 | );
217 | } else {
218 | return null;
219 | }
220 | };
221 |
222 | export default HourlyChart;
223 |
--------------------------------------------------------------------------------
/client/src/components/weatherCharts/common.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns the appropriate font color depending on whether or not dark mode is enabled or not
3 | * see `InfoPanel/styles.css`
4 | *
5 | * @param {Boolean} darkMode
6 | * @returns {String} font color
7 | */
8 | export const fontColor = (darkMode) => {
9 | return darkMode ? "rgba(246, 246, 244, 0.8)" : "rgba(58, 57, 56, 0.8)";
10 | };
11 |
--------------------------------------------------------------------------------
/client/src/components/weatherCharts/styles.css:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 255px;
3 | height: 115px;
4 | }
5 |
6 | .dark {
7 | color: #f6f6f444;
8 | }
9 |
10 | .light {
11 | color: #3a393888;
12 | }
13 |
14 | .err-container {
15 | padding: 20px;
16 | display: flex;
17 | justify-content: center;
18 | align-items: center;
19 | font-size: 12px;
20 | flex-direction: column;
21 | }
22 |
23 | .err-container .message{
24 | margin-top: 10px;
25 | word-break: break-word;
26 | }
--------------------------------------------------------------------------------
/client/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
15 |
16 |
17 |
18 | Pi Weather Station
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import App from "~/components/App";
4 | import { AppContextProvider } from "~/AppContext";
5 | import "~/styles";
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById("root")
12 | );
13 |
--------------------------------------------------------------------------------
/client/src/services/conversions.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Convert celsius to fahrenheit
3 | *
4 | * @param {Number} c degrees celsius
5 | * @returns {Number} degrees fahrenheit
6 | * @private
7 | */
8 | const cToF = (c) => {
9 | return c * (9/5) + 32;
10 | };
11 |
12 | /**
13 | * Convert temperature to celsius if needed
14 | *
15 | * @param {Number} c temperature in celsius
16 | * @param {String} units `f` for fahrenheit, `c` for celsius
17 | * @returns {Number} converted temperature
18 | */
19 | export const convertTemp = (c, units) => {
20 | if (!c && c !== 0) {
21 | console.log("missing input temp!");
22 | return null;
23 | }
24 |
25 | if (units && units.toLowerCase() === "c") {
26 | return parseInt(c);
27 | } else if (units && units.toLowerCase() === "f") {
28 | return parseInt(cToF(c));
29 | } else {
30 | console.log("Missing / invalid target unit!", units);
31 | return null;
32 | }
33 | };
34 |
35 | /**
36 | * Convert m/s to mph
37 | *
38 | * @param {Number} ms
39 | * @returns {Number} mph
40 | * @private
41 | */
42 | const msToMph = (ms) => {
43 | return ms / 0.44704;
44 | };
45 |
46 | /**
47 | * Converts speed
48 | *
49 | * @param {Number} speed
50 | * @param {String} units mph or ms for m/s
51 | * @returns {Number} converted speed
52 | */
53 | export const convertSpeed = (speed, units) => {
54 | if (!speed && speed !== 0) {
55 | console.log("missing input speed");
56 | return null;
57 | }
58 | if (units && units.toLowerCase() === "mph") {
59 | return parseInt(msToMph(speed));
60 | } else if (units && units.toLowerCase() === "ms") {
61 | return parseInt(speed);
62 | } else {
63 | console.log("Missing / invalid target unit!", units);
64 | return null;
65 | }
66 | };
67 |
68 | /**
69 | * Converts mm to inches
70 | *
71 | * @param {Number} mm
72 | * @returns {Number} inches
73 | * @private
74 | */
75 | const mmToIn = (mm) => {
76 | return mm / 25.4;
77 | };
78 |
79 | /**
80 | * Convert length
81 | *
82 | * @param {Number} len mm
83 | * @param {String} units in or mm
84 | * @returns {Number} converted length
85 | */
86 | export const convertLength = (len, units) => {
87 | if (!len && len !== 0) {
88 | console.log("missing input length!");
89 | return null;
90 | }
91 | if (units && units.toLowerCase() === "in") {
92 | return parseInt(mmToIn(len) * 100) / 100;
93 | } else if (units && units.toLowerCase() === "mm") {
94 | return len;
95 | } else {
96 | console.log("Missing / invalid target unit!", units);
97 | return null;
98 | }
99 | };
100 |
--------------------------------------------------------------------------------
/client/src/services/formatting.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Capitalizes the first letter of a string
3 | *
4 | * @param {String} string
5 | * @returns {String} String with first letter capitalized
6 | */
7 | export const capitalizeFirstLetter = (string) => {
8 | if (string && typeof string === "string") {
9 | return string.charAt(0).toUpperCase() + string.slice(1);
10 | } else {
11 | return string;
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/client/src/services/geolocation.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | /**
4 | * Gets coordinates from `navigator.geolocation` (currently not supported on aspbian Chromium)
5 | *
6 | * @returns {Promise} coordinates
7 | */
8 | export function getCoordsFromBrowser() {
9 | return new Promise((resolve, reject) => {
10 | if (navigator.geolocation) {
11 | navigator.geolocation.getCurrentPosition((pos) => {
12 | if (!pos || (pos && !pos.coords)) {
13 | reject("Could not get current position");
14 | } else {
15 | resolve(pos.coords);
16 | }
17 | });
18 | } else {
19 | reject(null);
20 | }
21 | });
22 | }
23 |
24 | /**
25 | * Gets coordinates from an external API
26 | *
27 | * @returns {Promise} coordinates
28 | */
29 | export function getCoordsFromApi() {
30 | return new Promise((resolve, reject) => {
31 | axios
32 | .get("/geolocation")
33 | .then((res) => {
34 | const { latitude, longitude } = res.data;
35 | if (
36 | !latitude ||
37 | (!latitude && latitude !== 0) ||
38 | !longitude ||
39 | (!longitude && longitude !== 0)
40 | ) {
41 | reject("Could not get lan/lon");
42 | } else {
43 | resolve({
44 | latitude,
45 | longitude,
46 | });
47 | }
48 | })
49 | .catch((err) => {
50 | reject(err);
51 | });
52 | });
53 | }
54 |
--------------------------------------------------------------------------------
/client/src/services/reverseGeocode.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | let prevCoords = {};
4 | let prevResult = null;
5 |
6 | /**
7 | * Reverse geocode lookup
8 | *
9 | * @param {Object} params
10 | * @param {Number} params.lat latitude
11 | * @param {Number} params.lon longitude
12 | * @param {String} params.apiKey api key
13 | * @returns {Promise} API result
14 | */
15 | function reverseGeocode({ lat, lon, apiKey }) {
16 | return new Promise((resolve, reject) => {
17 | if (prevCoords.lat === lat && prevCoords.lon === lon) {
18 | return resolve(prevResult);
19 | }
20 |
21 | axios
22 | .get(
23 | `https://us1.locationiq.com/v1/reverse.php?key=${apiKey}&lat=${lat}&lon=${lon}&format=json`
24 | )
25 | .then((res) => {
26 | prevCoords = { lat, lon };
27 | prevResult = res.data;
28 | resolve(res.data);
29 | })
30 | .catch((err) => {
31 | reject(err);
32 | });
33 |
34 | // return reject(); //tempoarily disabling
35 | // axios
36 | // .get(
37 | // `https://api.bigdatacloud.net/data/reverse-geocode?latitude=${lat}&longitude=${lon}&localityLanguage=en`
38 | // )
39 | // .then((res) => {
40 | // prevCoords = { lat, lon };
41 | // prevResult = res.data;
42 | // resolve(res.data);
43 | // })
44 | // .catch((err) => {
45 | // reject(err);
46 | // });
47 | });
48 | }
49 |
50 | export default reverseGeocode;
51 |
--------------------------------------------------------------------------------
/client/src/settings.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | /**
4 | * Gets settings
5 | *
6 | * @returns {Promise} resolves settings
7 | */
8 | export function getSettings() {
9 | return new Promise((resolve, reject) => {
10 | axios
11 | .get("/settings")
12 | .then((res) => {
13 | resolve(res.data);
14 | })
15 | .catch((err) => {
16 | reject(err);
17 | });
18 | });
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/styles/index.js:
--------------------------------------------------------------------------------
1 | import("!style-loader!css-loader!~/styles/main.css");
2 |
--------------------------------------------------------------------------------
/client/src/styles/main.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Rubik;
3 | margin: 0px;
4 | }
--------------------------------------------------------------------------------
/client/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const webpack = require("webpack");
3 | const HtmlWebPackPlugin = require("html-webpack-plugin");
4 |
5 | module.exports = (env) => {
6 | const PRODUCTION = !!(env && env.BUILD_PRODUCTION);
7 | process.env.NODE_ENV = PRODUCTION ? "production" : "development";
8 |
9 | const definePlugin = new webpack.DefinePlugin({
10 | __PRODUCTION__: JSON.stringify(
11 | JSON.parse(env ? env.BUILD_PRODUCTION || "false" : "false")
12 | ),
13 | });
14 |
15 | return {
16 | output: {
17 | path: path.resolve(__dirname, "dist"),
18 | filename: "bundle.min.js",
19 | publicPath: "/",
20 | },
21 | module: {
22 | rules: [
23 | {
24 | enforce: "pre",
25 | test: /\.js$/,
26 | include: [path.resolve(__dirname, "src")],
27 | exclude: /node_modules/,
28 | use: {
29 | loader: "eslint-loader"
30 | }
31 | },
32 | {
33 | test: /\.(js|jsx)$/,
34 | include: [path.resolve(__dirname, "src")],
35 | exclude: /node_modules/,
36 | use: {
37 | loader: "babel-loader",
38 | },
39 | },
40 | {
41 | test: /\.html$/,
42 | use: [
43 | {
44 | loader: "html-loader",
45 | },
46 | ],
47 | },
48 | {
49 | test: /\.css$/,
50 | use: [
51 | "style-loader",
52 | {
53 | loader: "css-loader",
54 | options: {
55 | sourceMap: !PRODUCTION,
56 | modules: {
57 | exportLocalsConvention: "camelCase",
58 | localIdentName: "[path][name]__[local]--[hash:base64:5]",
59 | },
60 | },
61 | },
62 | { loader: "postcss-loader", options: { sourceMap: !PRODUCTION } },
63 | ],
64 | },
65 | {
66 | test: /\.(png|svg|jpg|gif)$/,
67 | use: [
68 | {
69 | loader: "url-loader",
70 | options: {
71 | limit: 8192,
72 | sourceMap: !PRODUCTION,
73 | name: PRODUCTION
74 | ? "[contenthash].[ext]"
75 | : "[path][name].[ext]?[contenthash]"
76 | },
77 | },
78 | ],
79 | },
80 | {
81 | test: /\.(woff|woff2|eot|ttf|otf)$/,
82 | use: [
83 | {
84 | loader: "file-loader",
85 | options: {
86 | sourceMap: !PRODUCTION,
87 | name: PRODUCTION
88 | ? "[contenthash].[ext]"
89 | : "[path][name].[ext]?[contenthash]"
90 | },
91 | },
92 | ],
93 | },
94 | ],
95 | },
96 | resolve: {
97 | extensions: [".js", ".scss"],
98 | alias: {
99 | ["~"]: path.resolve(__dirname, "src"),
100 | },
101 | },
102 | plugins: [
103 | new HtmlWebPackPlugin({
104 | template: "./src/index.html",
105 | filename: "./index.html",
106 | }),
107 | definePlugin,
108 | ],
109 | watchOptions: {
110 | ignored: /node_modules/
111 | }
112 | };
113 | };
114 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pi-weather-station",
3 | "version": "1.0.0",
4 | "lockfileVersion": 1,
5 | "requires": true,
6 | "dependencies": {
7 | "accepts": {
8 | "version": "1.3.7",
9 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
10 | "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
11 | "requires": {
12 | "mime-types": "~2.1.24",
13 | "negotiator": "0.6.2"
14 | }
15 | },
16 | "anymatch": {
17 | "version": "3.1.1",
18 | "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
19 | "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
20 | "dev": true,
21 | "requires": {
22 | "normalize-path": "^3.0.0",
23 | "picomatch": "^2.0.4"
24 | }
25 | },
26 | "array-flatten": {
27 | "version": "1.1.1",
28 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
29 | "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
30 | },
31 | "async-limiter": {
32 | "version": "1.0.1",
33 | "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
34 | "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
35 | "dev": true
36 | },
37 | "axios": {
38 | "version": "0.19.2",
39 | "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
40 | "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
41 | "requires": {
42 | "follow-redirects": "1.5.10"
43 | }
44 | },
45 | "binary-extensions": {
46 | "version": "2.1.0",
47 | "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz",
48 | "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
49 | "dev": true
50 | },
51 | "body-parser": {
52 | "version": "1.19.0",
53 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
54 | "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
55 | "requires": {
56 | "bytes": "3.1.0",
57 | "content-type": "~1.0.4",
58 | "debug": "2.6.9",
59 | "depd": "~1.1.2",
60 | "http-errors": "1.7.2",
61 | "iconv-lite": "0.4.24",
62 | "on-finished": "~2.3.0",
63 | "qs": "6.7.0",
64 | "raw-body": "2.4.0",
65 | "type-is": "~1.6.17"
66 | }
67 | },
68 | "braces": {
69 | "version": "3.0.2",
70 | "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
71 | "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
72 | "dev": true,
73 | "requires": {
74 | "fill-range": "^7.0.1"
75 | }
76 | },
77 | "bytes": {
78 | "version": "3.1.0",
79 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
80 | "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
81 | },
82 | "chokidar": {
83 | "version": "3.4.2",
84 | "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz",
85 | "integrity": "sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A==",
86 | "dev": true,
87 | "requires": {
88 | "anymatch": "~3.1.1",
89 | "braces": "~3.0.2",
90 | "fsevents": "~2.1.2",
91 | "glob-parent": "~5.1.0",
92 | "is-binary-path": "~2.1.0",
93 | "is-glob": "~4.0.1",
94 | "normalize-path": "~3.0.0",
95 | "readdirp": "~3.4.0"
96 | }
97 | },
98 | "connect-livereload": {
99 | "version": "0.6.1",
100 | "resolved": "https://registry.npmjs.org/connect-livereload/-/connect-livereload-0.6.1.tgz",
101 | "integrity": "sha512-3R0kMOdL7CjJpU66fzAkCe6HNtd3AavCS4m+uW4KtJjrdGPT0SQEZieAYd+cm+lJoBznNQ4lqipYWkhBMgk00g==",
102 | "dev": true
103 | },
104 | "content-disposition": {
105 | "version": "0.5.3",
106 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
107 | "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
108 | "requires": {
109 | "safe-buffer": "5.1.2"
110 | }
111 | },
112 | "content-type": {
113 | "version": "1.0.4",
114 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
115 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
116 | },
117 | "cookie": {
118 | "version": "0.4.0",
119 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
120 | "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
121 | },
122 | "cookie-signature": {
123 | "version": "1.0.6",
124 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
125 | "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
126 | },
127 | "cors": {
128 | "version": "2.8.5",
129 | "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
130 | "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
131 | "requires": {
132 | "object-assign": "^4",
133 | "vary": "^1"
134 | }
135 | },
136 | "debug": {
137 | "version": "2.6.9",
138 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
139 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
140 | "requires": {
141 | "ms": "2.0.0"
142 | }
143 | },
144 | "depd": {
145 | "version": "1.1.2",
146 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
147 | "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak="
148 | },
149 | "destroy": {
150 | "version": "1.0.4",
151 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
152 | "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
153 | },
154 | "ee-first": {
155 | "version": "1.1.1",
156 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
157 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
158 | },
159 | "encodeurl": {
160 | "version": "1.0.2",
161 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
162 | "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
163 | },
164 | "escape-html": {
165 | "version": "1.0.3",
166 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
167 | "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
168 | },
169 | "etag": {
170 | "version": "1.8.1",
171 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
172 | "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
173 | },
174 | "express": {
175 | "version": "4.17.1",
176 | "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
177 | "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
178 | "requires": {
179 | "accepts": "~1.3.7",
180 | "array-flatten": "1.1.1",
181 | "body-parser": "1.19.0",
182 | "content-disposition": "0.5.3",
183 | "content-type": "~1.0.4",
184 | "cookie": "0.4.0",
185 | "cookie-signature": "1.0.6",
186 | "debug": "2.6.9",
187 | "depd": "~1.1.2",
188 | "encodeurl": "~1.0.2",
189 | "escape-html": "~1.0.3",
190 | "etag": "~1.8.1",
191 | "finalhandler": "~1.1.2",
192 | "fresh": "0.5.2",
193 | "merge-descriptors": "1.0.1",
194 | "methods": "~1.1.2",
195 | "on-finished": "~2.3.0",
196 | "parseurl": "~1.3.3",
197 | "path-to-regexp": "0.1.7",
198 | "proxy-addr": "~2.0.5",
199 | "qs": "6.7.0",
200 | "range-parser": "~1.2.1",
201 | "safe-buffer": "5.1.2",
202 | "send": "0.17.1",
203 | "serve-static": "1.14.1",
204 | "setprototypeof": "1.1.1",
205 | "statuses": "~1.5.0",
206 | "type-is": "~1.6.18",
207 | "utils-merge": "1.0.1",
208 | "vary": "~1.1.2"
209 | }
210 | },
211 | "fill-range": {
212 | "version": "7.0.1",
213 | "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
214 | "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
215 | "dev": true,
216 | "requires": {
217 | "to-regex-range": "^5.0.1"
218 | }
219 | },
220 | "finalhandler": {
221 | "version": "1.1.2",
222 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
223 | "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
224 | "requires": {
225 | "debug": "2.6.9",
226 | "encodeurl": "~1.0.2",
227 | "escape-html": "~1.0.3",
228 | "on-finished": "~2.3.0",
229 | "parseurl": "~1.3.3",
230 | "statuses": "~1.5.0",
231 | "unpipe": "~1.0.0"
232 | }
233 | },
234 | "follow-redirects": {
235 | "version": "1.5.10",
236 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
237 | "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
238 | "requires": {
239 | "debug": "=3.1.0"
240 | },
241 | "dependencies": {
242 | "debug": {
243 | "version": "3.1.0",
244 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
245 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
246 | "requires": {
247 | "ms": "2.0.0"
248 | }
249 | }
250 | }
251 | },
252 | "forwarded": {
253 | "version": "0.1.2",
254 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
255 | "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ="
256 | },
257 | "fresh": {
258 | "version": "0.5.2",
259 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
260 | "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
261 | },
262 | "fsevents": {
263 | "version": "2.1.3",
264 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
265 | "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
266 | "dev": true,
267 | "optional": true
268 | },
269 | "glob-parent": {
270 | "version": "5.1.1",
271 | "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
272 | "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
273 | "dev": true,
274 | "requires": {
275 | "is-glob": "^4.0.1"
276 | }
277 | },
278 | "http-errors": {
279 | "version": "1.7.2",
280 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
281 | "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
282 | "requires": {
283 | "depd": "~1.1.2",
284 | "inherits": "2.0.3",
285 | "setprototypeof": "1.1.1",
286 | "statuses": ">= 1.5.0 < 2",
287 | "toidentifier": "1.0.0"
288 | }
289 | },
290 | "iconv-lite": {
291 | "version": "0.4.24",
292 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
293 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
294 | "requires": {
295 | "safer-buffer": ">= 2.1.2 < 3"
296 | }
297 | },
298 | "inherits": {
299 | "version": "2.0.3",
300 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
301 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
302 | },
303 | "ipaddr.js": {
304 | "version": "1.9.1",
305 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
306 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
307 | },
308 | "is-binary-path": {
309 | "version": "2.1.0",
310 | "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
311 | "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
312 | "dev": true,
313 | "requires": {
314 | "binary-extensions": "^2.0.0"
315 | }
316 | },
317 | "is-docker": {
318 | "version": "2.1.1",
319 | "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.1.1.tgz",
320 | "integrity": "sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw=="
321 | },
322 | "is-extglob": {
323 | "version": "2.1.1",
324 | "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
325 | "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
326 | "dev": true
327 | },
328 | "is-glob": {
329 | "version": "4.0.1",
330 | "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
331 | "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
332 | "dev": true,
333 | "requires": {
334 | "is-extglob": "^2.1.1"
335 | }
336 | },
337 | "is-number": {
338 | "version": "7.0.0",
339 | "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
340 | "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
341 | "dev": true
342 | },
343 | "livereload": {
344 | "version": "0.9.1",
345 | "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.1.tgz",
346 | "integrity": "sha512-9g7sua11kkyZNo2hLRCG3LuZZwqexoyEyecSlV8cAsfAVVCZqLzVir6XDqmH0r+Vzgnd5LrdHDMyjtFnJQLAYw==",
347 | "dev": true,
348 | "requires": {
349 | "chokidar": "^3.3.0",
350 | "livereload-js": "^3.1.0",
351 | "opts": ">= 1.2.0",
352 | "ws": "^6.2.1"
353 | }
354 | },
355 | "livereload-js": {
356 | "version": "3.3.1",
357 | "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.3.1.tgz",
358 | "integrity": "sha512-CBu1gTEfzVhlOK1WASKAAJ9Qx1fHECTq0SUB67sfxwQssopTyvzqTlgl+c0h9pZ6V+Fzd2rc510ppuNusg9teQ==",
359 | "dev": true
360 | },
361 | "media-typer": {
362 | "version": "0.3.0",
363 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
364 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
365 | },
366 | "merge-descriptors": {
367 | "version": "1.0.1",
368 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
369 | "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
370 | },
371 | "methods": {
372 | "version": "1.1.2",
373 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
374 | "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
375 | },
376 | "mime": {
377 | "version": "1.6.0",
378 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
379 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
380 | },
381 | "mime-db": {
382 | "version": "1.44.0",
383 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
384 | "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg=="
385 | },
386 | "mime-types": {
387 | "version": "2.1.27",
388 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
389 | "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
390 | "requires": {
391 | "mime-db": "1.44.0"
392 | }
393 | },
394 | "ms": {
395 | "version": "2.0.0",
396 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
397 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
398 | },
399 | "negotiator": {
400 | "version": "0.6.2",
401 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
402 | "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
403 | },
404 | "normalize-path": {
405 | "version": "3.0.0",
406 | "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
407 | "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
408 | "dev": true
409 | },
410 | "object-assign": {
411 | "version": "4.1.1",
412 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
413 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
414 | },
415 | "on-finished": {
416 | "version": "2.3.0",
417 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
418 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
419 | "requires": {
420 | "ee-first": "1.1.1"
421 | }
422 | },
423 | "open": {
424 | "version": "7.1.0",
425 | "resolved": "https://registry.npmjs.org/open/-/open-7.1.0.tgz",
426 | "integrity": "sha512-lLPI5KgOwEYCDKXf4np7y1PBEkj7HYIyP2DY8mVDRnx0VIIu6bNrRB0R66TuO7Mack6EnTNLm4uvcl1UoklTpA==",
427 | "requires": {
428 | "is-docker": "^2.0.0",
429 | "is-wsl": "^2.1.1"
430 | },
431 | "dependencies": {
432 | "is-wsl": {
433 | "version": "2.2.0",
434 | "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
435 | "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
436 | "requires": {
437 | "is-docker": "^2.0.0"
438 | }
439 | }
440 | }
441 | },
442 | "opts": {
443 | "version": "2.0.0",
444 | "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.0.tgz",
445 | "integrity": "sha512-rPleeyX48sBEc4aj7rAok5dCbvRdYpdbIdSRR4gnIK98a7Rvd4l3wlv4YHQr2mwPQTpKQiw8uipi/WoyItDINg==",
446 | "dev": true
447 | },
448 | "parseurl": {
449 | "version": "1.3.3",
450 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
451 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
452 | },
453 | "path-to-regexp": {
454 | "version": "0.1.7",
455 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
456 | "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
457 | },
458 | "picomatch": {
459 | "version": "2.2.2",
460 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
461 | "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
462 | "dev": true
463 | },
464 | "proxy-addr": {
465 | "version": "2.0.6",
466 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
467 | "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==",
468 | "requires": {
469 | "forwarded": "~0.1.2",
470 | "ipaddr.js": "1.9.1"
471 | }
472 | },
473 | "qs": {
474 | "version": "6.7.0",
475 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
476 | "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
477 | },
478 | "range-parser": {
479 | "version": "1.2.1",
480 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
481 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
482 | },
483 | "raw-body": {
484 | "version": "2.4.0",
485 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
486 | "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
487 | "requires": {
488 | "bytes": "3.1.0",
489 | "http-errors": "1.7.2",
490 | "iconv-lite": "0.4.24",
491 | "unpipe": "1.0.0"
492 | }
493 | },
494 | "readdirp": {
495 | "version": "3.4.0",
496 | "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz",
497 | "integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==",
498 | "dev": true,
499 | "requires": {
500 | "picomatch": "^2.2.1"
501 | }
502 | },
503 | "safe-buffer": {
504 | "version": "5.1.2",
505 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
506 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
507 | },
508 | "safer-buffer": {
509 | "version": "2.1.2",
510 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
511 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
512 | },
513 | "send": {
514 | "version": "0.17.1",
515 | "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
516 | "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
517 | "requires": {
518 | "debug": "2.6.9",
519 | "depd": "~1.1.2",
520 | "destroy": "~1.0.4",
521 | "encodeurl": "~1.0.2",
522 | "escape-html": "~1.0.3",
523 | "etag": "~1.8.1",
524 | "fresh": "0.5.2",
525 | "http-errors": "~1.7.2",
526 | "mime": "1.6.0",
527 | "ms": "2.1.1",
528 | "on-finished": "~2.3.0",
529 | "range-parser": "~1.2.1",
530 | "statuses": "~1.5.0"
531 | },
532 | "dependencies": {
533 | "ms": {
534 | "version": "2.1.1",
535 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
536 | "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
537 | }
538 | }
539 | },
540 | "serve-static": {
541 | "version": "1.14.1",
542 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
543 | "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
544 | "requires": {
545 | "encodeurl": "~1.0.2",
546 | "escape-html": "~1.0.3",
547 | "parseurl": "~1.3.3",
548 | "send": "0.17.1"
549 | }
550 | },
551 | "setprototypeof": {
552 | "version": "1.1.1",
553 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
554 | "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
555 | },
556 | "statuses": {
557 | "version": "1.5.0",
558 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
559 | "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
560 | },
561 | "to-regex-range": {
562 | "version": "5.0.1",
563 | "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
564 | "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
565 | "dev": true,
566 | "requires": {
567 | "is-number": "^7.0.0"
568 | }
569 | },
570 | "toidentifier": {
571 | "version": "1.0.0",
572 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
573 | "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
574 | },
575 | "type-is": {
576 | "version": "1.6.18",
577 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
578 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
579 | "requires": {
580 | "media-typer": "0.3.0",
581 | "mime-types": "~2.1.24"
582 | }
583 | },
584 | "unpipe": {
585 | "version": "1.0.0",
586 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
587 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
588 | },
589 | "utils-merge": {
590 | "version": "1.0.1",
591 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
592 | "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
593 | },
594 | "vary": {
595 | "version": "1.1.2",
596 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
597 | "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
598 | },
599 | "ws": {
600 | "version": "6.2.1",
601 | "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
602 | "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
603 | "dev": true,
604 | "requires": {
605 | "async-limiter": "~1.0.0"
606 | }
607 | }
608 | }
609 | }
610 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pi-weather-station",
3 | "version": "2.0.0",
4 | "description": "A weather station designed for the Raspberry Pi 7 inch touchscreen",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "node ./server/index.js"
9 | },
10 | "author": "Eric Lewin",
11 | "license": "MIT",
12 | "dependencies": {
13 | "axios": "^0.19.2",
14 | "body-parser": "^1.19.0",
15 | "cors": "^2.8.5",
16 | "express": "^4.17.1",
17 | "open": "^7.1.0"
18 | },
19 | "devDependencies": {
20 | "connect-livereload": "^0.6.1",
21 | "livereload": "^0.9.1"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 | # Pi Weather Station
3 |
4 | This is a weather station designed to be used with a Raspberry Pi on the official 7" 800x480 touchscreen.
5 |
6 | 
7 |
8 | The weather station will require you to have API keys from [Mapbox](https://www.mapbox.com/) and [ClimaCell (v4)](https://www.climacell.co/). Optionally, you can use an API key from [LocationIQ](https://locationiq.com/) to preform reverse geocoding.
9 |
10 | Weather maps are provided by the [RainViewer](https://www.rainviewer.com/) API, which generously does not require an [API key](https://www.rainviewer.com/api.html).
11 |
12 | Sunrise and Sunset times are provided by [Sunrise-Sunset](https://sunrise-sunset.org/), which generously does not require an [API key](https://sunrise-sunset.org/api).
13 |
14 | See it in action [here](https://www.youtube.com/watch?v=dvM6cyqYSw8).
15 |
16 | > Be mindful of the plan limits for your API keys and understand the terms of each provider, as scrolling around the map and selecting different locations will incur API calls for every location. Additionally, the weather station will periodically make additional api calls to get weather updates throughout the day.
17 |
18 | # v2.0.0
19 |
20 | 1-22-2021: Now uses [ClimaCell](https://www.climacell.co/) API v4. For ClimaCell API v3 keys, use [Pi Weather Station v1](https://github.com/elewin/pi-weather-station/releases/tag/v1.0).
21 |
22 | # Setup
23 |
24 | > You will need to have [Node.js](https://nodejs.org/) installed.
25 |
26 | To install, clone the repo and run
27 |
28 | $ npm install
29 |
30 | Start the server with
31 |
32 | $ npm start
33 |
34 | Now set point your browser to `http://localhost:8080` and put it in full screen mode (`F11` in Chromium).
35 |
36 | ## Access from another machine
37 |
38 | It's possible to access the app from another machine, but beware that by doing so you'll be exposing the app to your entire network, and someone else could potentially access the app and retreive your API keys from the settings page. By default the app is only accessible to `localhost`, but if you would like to open it up to your network (at your own risk!), open `/server/index.js` and remove `"localhost"` from the line that contains:
39 |
40 | ```js
41 | app.listen(PORT, "localhost", async () => {
42 | ```
43 |
44 | so that it becomes:
45 |
46 | ```js
47 | app.listen(PORT, async () => {
48 | ```
49 |
50 | The server will now serve the app across your network.
51 |
52 | # Settings
53 |
54 | - Your API keys are saved locally (in plain text) to `settings.json`.
55 | - The server will attempt to get your default location, but if it cannot or you wish to choose a different default location, enter the latitude and longitude under `Custom Latitude` and `Custom Longitude` in settings, which can be accessed by tapping the gear button in the lower right hand corner.
56 | - To hide the mouse cursor when using a touch screen, set `Hide Mouse` to `On`.
57 |
58 | # Do you want to Host this Application in Docker?
59 |
60 | Pi Weather Station is available as a Docker Image for AMD64 and ARM infrastructures. see the *ReadME* here for more: https://github.com/SeanRiggs/pi-weather-station/blob/master/Docker%20Image/Docker-ReadMe.md
61 |
62 | # License
63 |
64 | The MIT License (MIT)
65 |
66 | Copyright (c) 2020 Eric Lewin
67 |
68 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
69 |
70 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
71 |
72 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
73 |
--------------------------------------------------------------------------------
/server/geolocationCtrl.js:
--------------------------------------------------------------------------------
1 | const axios = require("axios");
2 |
3 | /**
4 | * Gets coordinates from an external API
5 | *
6 | */
7 | function getCoords(req, res) {
8 | axios
9 | .get("https://freegeoip.app/json/")
10 | .then((result) => {
11 | return res.status(result.status).json(result.data).end();
12 | })
13 | .catch((err) => {
14 | return res.status(500).json(err).end();
15 | });
16 | }
17 |
18 | module.exports = {
19 | getCoords,
20 | };
21 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const bodyParser = require("body-parser");
3 | const path = require("path");
4 | const cors = require("cors");
5 | const open = require("open");
6 | const ver = require("../package.json").version;
7 | const appName = require("../package.json").name;
8 |
9 | const settingsCtrl = require("./settingsCtrl");
10 | const geolocationCtrl = require("./geolocationCtrl");
11 |
12 | const {
13 | getSettings,
14 | setSetting,
15 | deleteSetting,
16 | createSettingsFile,
17 | replaceSettings,
18 | } = settingsCtrl;
19 | const { getCoords } = geolocationCtrl;
20 |
21 | const DIST_DIR = "/../client/dist";
22 | const PORT = 8080;
23 | const app = express();
24 |
25 | // ***** dev only:
26 | // const livereload = require("livereload");
27 | // const connectLivereload = require("connect-livereload");
28 | // const liveReloadServer = livereload.createServer();
29 | // liveReloadServer.watch(path.join(`${__dirname}/${DIST_DIR}`));
30 | // liveReloadServer.server.once("connection", () => {
31 | // setTimeout(() => {
32 | // liveReloadServer.refresh("/");
33 | // }, 100);
34 | // });
35 | // app.use(connectLivereload());
36 | // *****
37 |
38 | app.use(cors());
39 | app.use(bodyParser.json());
40 | app.use(express.static(path.join(`${__dirname}/${DIST_DIR}`)));
41 | app.listen(PORT, "localhost", async () => {
42 | await open(`http://localhost:${PORT}`);
43 | console.log(`${appName} v${ver} has started on port ${PORT}`);
44 | });
45 |
46 | app.get("/settings", getSettings);
47 | app.post("/settings", createSettingsFile);
48 | app.put("/settings", replaceSettings);
49 | app.patch("/setting", setSetting);
50 | app.delete("/setting", deleteSetting);
51 |
52 | app.get("/geolocation", getCoords);
53 |
--------------------------------------------------------------------------------
/server/settingsCtrl.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 |
4 | const SETTINGS_FILE = "../settings.json";
5 | const FILE_PATH = path.join(`${__dirname}/${SETTINGS_FILE}`);
6 | const ENCODING = "utf8";
7 |
8 | /**
9 | * Read the settings.json file
10 | *
11 | * @param {Object} callbacks
12 | * @param {Function} callbacks.successCb
13 | * @param {Function} callbacks.errorCb
14 | */
15 | function readSettingsFile({ successCb, errorCb }) {
16 | fs.readFile(FILE_PATH, (err, data) => {
17 | if (err) {
18 | errorCb(err);
19 | } else {
20 | successCb(JSON.parse(data));
21 | }
22 | });
23 | }
24 |
25 | /**
26 | * Creates a `settings.json` file
27 | *
28 | * @param {Object} req
29 | * @param {Object} [req.body]
30 | * @param {Object} res
31 | */
32 | function createSettingsFile(req, res) {
33 | const contents = req.body || {};
34 |
35 | if (fs.existsSync(FILE_PATH)) {
36 | return res.status(409).json("settings file already exists").end();
37 | } else {
38 | fs.writeFile(FILE_PATH, JSON.stringify(contents), ENCODING, (err) => {
39 | if (err) {
40 | return res.status(500).json(err).end();
41 | } else {
42 | return res.status(201).json(contents).end();
43 | }
44 | });
45 | }
46 | }
47 |
48 | /**
49 | * Return the settings.json file
50 | */
51 | function getSettings(req, res) {
52 | if (!fs.existsSync(FILE_PATH)) {
53 | return res.status(404).json("settings.json not found!").end();
54 | }
55 |
56 | readSettingsFile({
57 | successCb: (data) => {
58 | return res.status(200).json(data).end();
59 | },
60 | errorCb: (err) => {
61 | return res.status(500).end();
62 | },
63 | });
64 | }
65 |
66 | /**
67 | * Sets a single setting. Creates a new `settings.json` file if none exists.
68 | *
69 | * @param {Object} req
70 | * @param {Object} res
71 | */
72 | function setSetting(req, res) {
73 | const { key, val } = req.body;
74 | if (!key || !val) {
75 | return res.status(400).json("You must supply a key and val").end();
76 | }
77 |
78 | /**
79 | * Writes file contents
80 | *
81 | * @param {Object} newSettings
82 | * @param {Boolean} [newFile] If file is new
83 | */
84 | const writeContents = (newSettings, newFile) => {
85 | fs.writeFile(FILE_PATH, JSON.stringify(newSettings), ENCODING, (err) => {
86 | if (err) {
87 | return res.status(500).json(err).end();
88 | } else {
89 | return res
90 | .status(newFile ? 201 : 200)
91 | .json(newSettings)
92 | .end();
93 | }
94 | });
95 | };
96 |
97 | /**
98 | * Read success callback
99 | *
100 | * @param {Object} currentSettings
101 | */
102 | const readSuccess = (currentSettings) => {
103 | const newSettings = {
104 | ...currentSettings,
105 | [key]: val,
106 | };
107 | writeContents(newSettings);
108 | };
109 |
110 | /**
111 | * Read error callback
112 | *
113 | * @param {Object} [err]
114 | */
115 | const readError = (err) => {
116 | return res.status(500).json(err).end();
117 | };
118 |
119 | if (!fs.existsSync(FILE_PATH)) {
120 | writeContents({ [key]: val }, true);
121 | } else {
122 | readSettingsFile({
123 | successCb: readSuccess,
124 | errorCb: readError,
125 | });
126 | }
127 | }
128 |
129 | function replaceSettings(req, res) {
130 | const { body } = req;
131 | if (!body) {
132 | return res.status(400).json("You must provide settings contents").end();
133 | }
134 | const fileExists = fs.existsSync(FILE_PATH);
135 |
136 | fs.writeFile(FILE_PATH, JSON.stringify(body), ENCODING, (err) => {
137 | if (err) {
138 | return res.status(500).json(err).end();
139 | } else {
140 | return res
141 | .status(fileExists ? 200 : 201)
142 | .json(body)
143 | .end();
144 | }
145 | });
146 | }
147 |
148 | /**
149 | * Deletes a specific setting
150 | *
151 | * @param {Object} req
152 | * @param {Object} req.query
153 | * @param {Object} req.query.key The key to be deleted
154 | * @param {Object} res
155 | */
156 | function deleteSetting(req, res) {
157 | const { key } = req.query;
158 | if (!key) {
159 | return res.status(400).json("You must supply a key to delete").end();
160 | }
161 |
162 | /**
163 | * Read success callback
164 | *
165 | * @param {Object} currentSettings
166 | */
167 | const readSuccess = (currentSettings) => {
168 | if (!Object.prototype.hasOwnProperty.call(currentSettings, key)) {
169 | return res.status(404).end();
170 | }
171 |
172 | delete currentSettings[key];
173 |
174 | fs.writeFile(
175 | FILE_PATH,
176 | JSON.stringify(currentSettings),
177 | ENCODING,
178 | (err) => {
179 | if (err) {
180 | return res.status(500).json(err).end();
181 | } else {
182 | return res.status(200).json(currentSettings).end();
183 | }
184 | }
185 | );
186 | };
187 |
188 | /**
189 | * Error callback
190 | *
191 | * @param {Object} err
192 | */
193 | const readError = (err) => {
194 | return res.status(500).json(err).end();
195 | };
196 |
197 | readSettingsFile({
198 | successCb: readSuccess,
199 | errorCb: readError,
200 | });
201 | }
202 |
203 | module.exports = {
204 | getSettings,
205 | setSetting,
206 | deleteSetting,
207 | createSettingsFile,
208 | replaceSettings,
209 | };
210 |
--------------------------------------------------------------------------------
/settings.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "weatherApiKey": "key",
3 | "mapApiKey": "key",
4 | "reverseGeoApiKey": "key",
5 | "startingLat": "32.7473",
6 | "startingLon": "-97.0945"
7 | }
8 |
--------------------------------------------------------------------------------