├── .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 | ![Pi Weather Station ](https://user-images.githubusercontent.com/15202038/91359998-4625bb80-e7bb-11ea-937e-c87eede41f35.JPG) 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 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
ArchitectureAvailable
amd64
arm64
arm64v8
armhf
arm32v7
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 | ![Docker Compose ](https://user-images.githubusercontent.com/111924572/188755814-af9ef5fd-9aa5-44a4-81dc-47bf7a1a5849.png) 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 |
69 |
70 | 71 |
72 |
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 |
46 |
47 |
48 |
49 |
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 | 144 | 145 | 152 | {mapTimestamp ? ( 153 | 162 | ) : null} 163 | {markerIsVisible && markerPosition ? ( 164 | 165 | ) : null} 166 | 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 | ![pws-screenshot3](https://user-images.githubusercontent.com/15202038/91359998-4625bb80-e7bb-11ea-937e-c87eede41f35.JPG) 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 | --------------------------------------------------------------------------------