Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"Atomics": "readonly",
|
||||
"SharedArrayBuffer": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
"react/jsx-uses-react": 1,
|
||||
"babel/semi": 1,
|
||||
"babel/camelcase": 1,
|
||||
"no-useless-return": 2,
|
||||
"no-self-compare": 2,
|
||||
"no-implicit-globals" : 2,
|
||||
"no-eval": 2,
|
||||
"no-alert": 2,
|
||||
"no-multiple-empty-lines" : 1,
|
||||
"no-empty-function": 2,
|
||||
"guard-for-in": 2,
|
||||
"babel/no-unused-expressions": 2,
|
||||
"jsx-quotes" : ["warn", "prefer-double"],
|
||||
"comma-spacing" : 1,
|
||||
"arrow-spacing": 1,
|
||||
"prefer-destructuring" : 1,
|
||||
"multiline-comment-style" : 0,
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react/display-name": 0,
|
||||
"eslint-comments/no-unused-disable": 1,
|
||||
"eslint-comments/disable-enable-pair": 0,
|
||||
"react/no-unused-prop-types": 2,
|
||||
"jsdoc/require-jsdoc": [
|
||||
"error", {
|
||||
"publicOnly": true,
|
||||
"require": {
|
||||
"ClassDeclaration": true,
|
||||
"ArrowFunctionExpression": true,
|
||||
"MethodDefinition": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"jsdoc/require-param-description": 0,
|
||||
"jsdoc/no-undefined-types": 0,
|
||||
"jsdoc/check-types" : ["warn", {"noDefaults": true}]
|
||||
},
|
||||
"env": {
|
||||
"es6": true,
|
||||
"browser": true,
|
||||
"jest": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:eslint-comments/recommended",
|
||||
"plugin:jsdoc/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"babel",
|
||||
"react-hooks",
|
||||
"react",
|
||||
"jsdoc"
|
||||
],
|
||||
"settings": {
|
||||
"react":{
|
||||
"version": "detect"
|
||||
},
|
||||
"jsdoc": {
|
||||
"tagNamePreference": {
|
||||
"property": "prop",
|
||||
"augments": "extends"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +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}}]);
|
||||
Vendored
+318
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css" integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ==" crossorigin=""/><script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js" integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew==" crossorigin=""></script><link href="https://fonts.googleapis.com/css2?family=Rubik:wght@300&display=swap" rel="stylesheet"><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>Pi Weather Station</title></head><body><div id="root"></div><script src="/bundle.min.js"></script></body></html>
|
||||
Generated
+7073
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "pi-weather-station-client",
|
||||
"version": "1.0.0",
|
||||
"description": "Client for pi-weather-station",
|
||||
"main": "webpack.config.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "webpack --progress --watch --mode development",
|
||||
"prod": "webpack -p --env.BUILD_PRODUCTION=1"
|
||||
},
|
||||
"author": "Eric Lewin",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.11.1",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@iconify/icons-bx": "^1.0.3",
|
||||
"@iconify/icons-carbon": "^1.0.4",
|
||||
"@iconify/icons-dashicons": "^1.0.11",
|
||||
"@iconify/icons-gridicons": "^1.0.6",
|
||||
"@iconify/icons-ic": "^1.0.15",
|
||||
"@iconify/icons-ion": "^1.0.13",
|
||||
"@iconify/icons-map": "^1.0.5",
|
||||
"@iconify/icons-wi": "^1.0.3",
|
||||
"@iconify/react": "^1.1.3",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"axios": "^0.19.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-loader": "^8.1.0",
|
||||
"chart.js": "^2.9.3",
|
||||
"css-loader": "^4.2.1",
|
||||
"date-fns": "^2.15.0",
|
||||
"debounce": "^1.2.0",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-loader": "^4.0.2",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||
"eslint-plugin-jsdoc": "^30.2.1",
|
||||
"eslint-plugin-react": "^7.20.6",
|
||||
"eslint-plugin-react-hooks": "^4.0.8",
|
||||
"file-loader": "^6.0.0",
|
||||
"html-loader": "^1.1.0",
|
||||
"html-webpack-plugin": "^4.3.0",
|
||||
"leaflet": "^1.6.0",
|
||||
"postcss": "^7.0.32",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.13.1",
|
||||
"react-chartjs-2": "^2.10.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-leaflet": "^2.7.0",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"style-loader": "^1.2.1",
|
||||
"url-loader": "^4.1.0",
|
||||
"webpack": "^4.44.1",
|
||||
"webpack-cli": "^3.3.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const postcssPresetEnv = require("postcss-preset-env");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
|
||||
const config = {
|
||||
plugins: [
|
||||
autoprefixer,
|
||||
postcssPresetEnv()
|
||||
]
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,587 @@
|
||||
import React, { createContext, useState } from "react";
|
||||
import { getSettings } from "~/settings";
|
||||
import PropTypes from "prop-types";
|
||||
import { getCoordsFromApi } from "~/services/geolocation";
|
||||
import axios from "axios";
|
||||
|
||||
export const AppContext = createContext();
|
||||
|
||||
const TEMP_UNIT_STORAGE_KEY = "tempUnit";
|
||||
const SPEED_UNIT_STORAGE_KEY = "speedUnit";
|
||||
const LENGTH_UNIT_STORAGE_KEY = "lengthUnit";
|
||||
const CLOCK_UNIT_STORAGE_KEY = "clockTime";
|
||||
const MOUSE_HIDE_STORAGE_KEY = "mouseHide";
|
||||
|
||||
/**
|
||||
* App context provider
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Node} props.children
|
||||
* @returns {JSX.Element} Context provider
|
||||
*/
|
||||
export function AppContextProvider({ children }) {
|
||||
const [weatherApiKey, setWeatherApiKey] = useState(null);
|
||||
const [mapApiKey, setMapApiKey] = useState(null);
|
||||
const [reverseGeoApiKey, setReverseGeoApiKey] = useState(null);
|
||||
const [browserGeo, setBrowserGeo] = useState(null);
|
||||
const [mapGeo, setMapGeo] = useState(null);
|
||||
const [darkMode, setDarkMode] = useState(true);
|
||||
const [currentWeatherData, setCurrentWeatherData] = useState(null);
|
||||
const [currentWeatherDataErr, setCurrentWeatherDataErr] = useState(null);
|
||||
const [currentWeatherDataErrMsg, setCurrentWeatherDataErrMsg] = useState(
|
||||
null
|
||||
);
|
||||
const [hourlyWeatherData, setHourlyWeatherData] = useState(null);
|
||||
const [hourlyWeatherDataErr, setHourlyWeatherDataErr] = useState(null);
|
||||
const [hourlyWeatherDataErrMsg, setHourlyWeatherDataErrMsg] = useState(null);
|
||||
const [dailyWeatherData, setDailyWeatherData] = useState(null);
|
||||
const [dailyWeatherDataErr, setDailyWeatherDataErr] = useState(null);
|
||||
const [dailyWeatherDataErrMsg, setDailyWeatherDataErrMsg] = useState(null);
|
||||
const [panToCoords, setPanToCoords] = useState(null);
|
||||
const [markerIsVisible, setMarkerIsVisible] = useState(true);
|
||||
const [tempUnit, setTempUnit] = useState("f"); // fahrenheit or celsius
|
||||
const [speedUnit, setSpeedUnit] = useState("mph"); // mph or ms for m/s
|
||||
const [lengthUnit, setLengthUnit] = useState("in"); // in or mm
|
||||
const [clockTime, setClockTime] = useState("12"); // 12h or 24h time for clock
|
||||
const [animateWeatherMap, setAnimateWeatherMap] = useState(false);
|
||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||
const [customLat, setCustomLat] = useState(null);
|
||||
const [customLon, setCustomLon] = useState(null);
|
||||
const [mouseHide, setMouseHide] = useState(false);
|
||||
|
||||
/**
|
||||
* Save mouse hide state
|
||||
*
|
||||
* @param {Boolean} newVal
|
||||
*/
|
||||
function saveMouseHide(newVal) {
|
||||
let newState;
|
||||
try {
|
||||
newState = JSON.parse(newVal);
|
||||
} catch (e) {
|
||||
console.log("saveMouseHide", e);
|
||||
return;
|
||||
}
|
||||
setMouseHide(newState);
|
||||
window.localStorage.setItem(MOUSE_HIDE_STORAGE_KEY, newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save clock time
|
||||
*
|
||||
* @param {String} newVal `12` or `24`
|
||||
*/
|
||||
function saveClockTime(newVal) {
|
||||
setClockTime(newVal);
|
||||
window.localStorage.setItem(CLOCK_UNIT_STORAGE_KEY, newVal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save temp unit
|
||||
*
|
||||
* @param {String} newVal `f` or `c`
|
||||
*/
|
||||
function saveTempUnit(newVal) {
|
||||
setTempUnit(newVal);
|
||||
window.localStorage.setItem(TEMP_UNIT_STORAGE_KEY, newVal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save speed unit
|
||||
*
|
||||
* @param {String} newVal `mph` or `ms`
|
||||
*/
|
||||
function saveSpeedUnit(newVal) {
|
||||
setSpeedUnit(newVal);
|
||||
window.localStorage.setItem(SPEED_UNIT_STORAGE_KEY, newVal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save length unit
|
||||
*
|
||||
* @param {String} newVal `in` or `mm`
|
||||
*/
|
||||
function saveLengthUnit(newVal) {
|
||||
setLengthUnit(newVal);
|
||||
window.localStorage.setItem(LENGTH_UNIT_STORAGE_KEY, newVal);
|
||||
}
|
||||
|
||||
function loadStoredData() {
|
||||
const temp = window.localStorage.getItem(TEMP_UNIT_STORAGE_KEY);
|
||||
const speed = window.localStorage.getItem(SPEED_UNIT_STORAGE_KEY);
|
||||
const length = window.localStorage.getItem(LENGTH_UNIT_STORAGE_KEY);
|
||||
const clock = window.localStorage.getItem(CLOCK_UNIT_STORAGE_KEY);
|
||||
|
||||
let mouseHide;
|
||||
try {
|
||||
mouseHide = JSON.parse(
|
||||
window.localStorage.getItem(MOUSE_HIDE_STORAGE_KEY)
|
||||
);
|
||||
} catch (e) {
|
||||
console.log("mouseHide", e);
|
||||
}
|
||||
|
||||
setMouseHide(!!mouseHide);
|
||||
if (temp) {
|
||||
setTempUnit(temp);
|
||||
}
|
||||
if (speed) {
|
||||
setSpeedUnit(speed);
|
||||
}
|
||||
if (length) {
|
||||
setLengthUnit(length);
|
||||
}
|
||||
if (clock) {
|
||||
setClockTime(clock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom starting lat/lon
|
||||
*
|
||||
* @returns {Promise} lat/lon
|
||||
* @private
|
||||
*/
|
||||
function getCustomLatLon() {
|
||||
return new Promise((resolve, reject) => {
|
||||
getSettings()
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
const { startingLat, startingLon } = res;
|
||||
if (startingLat) {
|
||||
setCustomLat(startingLat);
|
||||
}
|
||||
if (startingLon) {
|
||||
setCustomLon(startingLon);
|
||||
}
|
||||
}
|
||||
resolve(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("could not read settings.json", err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the map to a given position
|
||||
*
|
||||
* @param {Object} coords coordinates
|
||||
* @param {String} coords.latitude
|
||||
* @param {String} coords.longitude
|
||||
*/
|
||||
function setMapPosition(coords) {
|
||||
updateCurrentWeatherData(coords);
|
||||
updateHourlyWeatherData(coords);
|
||||
updateDailyWeatherData(coords);
|
||||
setMapGeo(coords);
|
||||
setPanToCoords(coords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the map position to browser geolocation coordinates
|
||||
*/
|
||||
function resetMapPosition() {
|
||||
setMapPosition(browserGeo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets geolocation and sets it, unless custom starting coordinates are provided.
|
||||
*
|
||||
* @returns {Object} coords
|
||||
*/
|
||||
function getBrowserGeo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
getCustomLatLon()
|
||||
.then((res) => {
|
||||
const { startingLat, startingLon } = res;
|
||||
if (startingLat && startingLon) {
|
||||
const latLon = {
|
||||
latitude: parseFloat(startingLat),
|
||||
longitude: parseFloat(startingLon),
|
||||
};
|
||||
setBrowserGeo(latLon);
|
||||
setMapGeo(latLon); //Set initial map coords to custom lat/lon
|
||||
resolve(latLon);
|
||||
} else {
|
||||
getCoordsFromApi()
|
||||
.then((res) => {
|
||||
if (!res) {
|
||||
return reject("Could not get browser geolocation data");
|
||||
}
|
||||
const { latitude, longitude } = res;
|
||||
setBrowserGeo({ latitude, longitude });
|
||||
setMapGeo({ latitude, longitude }); //Set initial map coords to browser geolocation
|
||||
resolve(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("err!", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves weather API key and sets it
|
||||
*
|
||||
* @returns {Promise} Weather API Key
|
||||
*/
|
||||
function getWeatherApiKey() {
|
||||
return new Promise((resolve, reject) => {
|
||||
getSettings()
|
||||
.then((res) => {
|
||||
if (!res || (res && !res.weatherApiKey)) {
|
||||
setSettingsMenuOpen(true);
|
||||
return reject("Weather API key missing");
|
||||
}
|
||||
setWeatherApiKey(res && res.weatherApiKey ? res.weatherApiKey : null);
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves map API key and sets it
|
||||
*
|
||||
* @returns {Promise} Weather API Key
|
||||
*/
|
||||
function getMapApiKey() {
|
||||
return new Promise((resolve, reject) => {
|
||||
getSettings()
|
||||
.then((res) => {
|
||||
if (!res || (res && !res.mapApiKey)) {
|
||||
setSettingsMenuOpen(true);
|
||||
return reject("Map API key missing!");
|
||||
}
|
||||
setMapApiKey(res && res.mapApiKey ? res.mapApiKey : null);
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves reverse geolocation API key and sets it
|
||||
*
|
||||
* @returns {Promise} Weather API Key
|
||||
*/
|
||||
function getReverseGeoApiKey() {
|
||||
return new Promise((resolve, reject) => {
|
||||
getSettings()
|
||||
.then((res) => {
|
||||
if (!res || (res && !res.reverseGeoApiKey)) {
|
||||
return reject("Reverse geolocation API key missing!");
|
||||
}
|
||||
setReverseGeoApiKey(
|
||||
res && res.reverseGeoApiKey ? res.reverseGeoApiKey : null
|
||||
);
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates hourly weather data
|
||||
*
|
||||
* @param {Object} coords
|
||||
* @param {Number} coords.latitude latitude
|
||||
* @param {Number} coords.longitude longitude
|
||||
*
|
||||
* @returns {Promise} hourly weather data
|
||||
*/
|
||||
function updateHourlyWeatherData(coords) {
|
||||
setHourlyWeatherDataErr(null);
|
||||
setHourlyWeatherDataErrMsg(null);
|
||||
const { latitude, longitude } = coords;
|
||||
const fields = [
|
||||
"temp",
|
||||
"precipitation_probability",
|
||||
"precipitation",
|
||||
"wind_speed",
|
||||
].join("%2c");
|
||||
|
||||
const endTime = new Date(
|
||||
new Date().getTime() + 60 * 60 * 23 * 1000
|
||||
).toISOString();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!coords) {
|
||||
setHourlyWeatherDataErr(true);
|
||||
return reject("No coords");
|
||||
}
|
||||
if (!weatherApiKey) {
|
||||
setHourlyWeatherDataErr(true);
|
||||
setSettingsMenuOpen(true);
|
||||
return reject("Missing weather API key");
|
||||
}
|
||||
|
||||
axios
|
||||
.get(
|
||||
`https://api.climacell.co/v3/weather/forecast/hourly?unit_system=si&fields=${fields}&apikey=${weatherApiKey}&lat=${latitude}&lon=${longitude}&end_time=${endTime}`
|
||||
)
|
||||
.then((res) => {
|
||||
if (!res) {
|
||||
return reject({ message: "No response" });
|
||||
}
|
||||
const { data } = res;
|
||||
setHourlyWeatherData(data);
|
||||
resolve(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
setHourlyWeatherDataErr(true);
|
||||
if (err && err.message) {
|
||||
setHourlyWeatherDataErrMsg(err.message);
|
||||
}
|
||||
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates daily weather data
|
||||
*
|
||||
* @param {Object} coords
|
||||
* @param {Number} coords.latitude latitude
|
||||
* @param {Number} coords.longitude longitude
|
||||
*
|
||||
* @returns {Promise} daily weather data
|
||||
*/
|
||||
function updateDailyWeatherData(coords) {
|
||||
setDailyWeatherDataErr(null);
|
||||
setDailyWeatherDataErrMsg(null);
|
||||
const { latitude, longitude } = coords;
|
||||
const fields = [
|
||||
"temp",
|
||||
"precipitation_probability",
|
||||
"precipitation",
|
||||
"wind_speed",
|
||||
].join("%2c");
|
||||
|
||||
const endTime = new Date(
|
||||
new Date().getTime() + 6 * 60 * 60 * 24 * 1000
|
||||
).toISOString();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!coords) {
|
||||
setDailyWeatherDataErr(true);
|
||||
return reject("No coords");
|
||||
}
|
||||
if (!weatherApiKey) {
|
||||
setDailyWeatherDataErr(true);
|
||||
setSettingsMenuOpen(true);
|
||||
return reject("Missing weather API key");
|
||||
}
|
||||
axios
|
||||
.get(
|
||||
`https://api.climacell.co/v3/weather/forecast/daily?unit_system=si&fields=${fields}&apikey=${weatherApiKey}&lat=${latitude}&lon=${longitude}&end_time=${endTime}`
|
||||
)
|
||||
.then((res) => {
|
||||
if (!res) {
|
||||
return reject({ message: "No response" });
|
||||
}
|
||||
const { data } = res;
|
||||
setDailyWeatherData(data);
|
||||
resolve(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
setDailyWeatherDataErr(true);
|
||||
if (err && err.message) {
|
||||
setDailyWeatherDataErrMsg(err.message);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates current weather data
|
||||
*
|
||||
* @param {Object} coords
|
||||
* @param {Number} coords.latitude latitude
|
||||
* @param {Number} coords.longitude longitude
|
||||
*
|
||||
* @returns {Promise} current weather data
|
||||
*/
|
||||
function updateCurrentWeatherData(coords) {
|
||||
setCurrentWeatherDataErr(null);
|
||||
setCurrentWeatherDataErrMsg(null);
|
||||
const { latitude, longitude } = coords;
|
||||
|
||||
// https://developer.climacell.co/v3/reference#data-layers-weather
|
||||
const fields = [
|
||||
"temp",
|
||||
"humidity",
|
||||
"wind_speed",
|
||||
"precipitation",
|
||||
"precipitation_type",
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"cloud_cover",
|
||||
"weather_code",
|
||||
].join("%2c");
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!coords) {
|
||||
setCurrentWeatherDataErr(true);
|
||||
return reject("No coords");
|
||||
}
|
||||
if (!weatherApiKey) {
|
||||
setCurrentWeatherDataErr(true);
|
||||
setSettingsMenuOpen(true);
|
||||
return reject("Missing weather API key");
|
||||
}
|
||||
axios
|
||||
.get(
|
||||
`https://api.climacell.co/v3/weather/realtime?unit_system=si&fields=${fields}&apikey=${weatherApiKey}&lat=${latitude}&lon=${longitude};`
|
||||
)
|
||||
.then((res) => {
|
||||
if (!res) {
|
||||
return reject({ message: "No response" });
|
||||
}
|
||||
const { data } = res;
|
||||
setCurrentWeatherData(data);
|
||||
resolve(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
setCurrentWeatherDataErr(true);
|
||||
if (err && err.message) {
|
||||
setCurrentWeatherDataErrMsg(err.message);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the marker on and off
|
||||
*/
|
||||
function toggleMarker() {
|
||||
setMarkerIsVisible(!markerIsVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles weather map animation on/off
|
||||
*/
|
||||
function toggleAnimateWeatherMap() {
|
||||
setAnimateWeatherMap(!animateWeatherMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles settings menu open/closed
|
||||
*/
|
||||
function toggleSettingsMenuOpen() {
|
||||
setSettingsMenuOpen(!settingsMenuOpen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves settings to `settings.json`
|
||||
*
|
||||
* @param {Object} settings
|
||||
* @param {String} [settings.mapsKey]
|
||||
* @param {String} [settings.weatherKey]
|
||||
* @param {String} [settings.geoKey]
|
||||
* @param {String} [settings.lat]
|
||||
* @param {String} [settings.lon]
|
||||
* @returns {Promise} Resolves when complete
|
||||
*/
|
||||
function saveSettingsToJson({ mapsKey, weatherKey, geoKey, lat, lon }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.put("/settings", {
|
||||
weatherApiKey: weatherKey,
|
||||
mapApiKey: mapsKey,
|
||||
reverseGeoApiKey: geoKey,
|
||||
startingLat: lat,
|
||||
startingLon: lon,
|
||||
})
|
||||
.then((res) => {
|
||||
resolve(res);
|
||||
setMapApiKey(mapsKey);
|
||||
setWeatherApiKey(weatherKey);
|
||||
setReverseGeoApiKey(geoKey);
|
||||
setCustomLat(lat);
|
||||
setCustomLon(lon);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const defaultContext = {
|
||||
weatherApiKey,
|
||||
getWeatherApiKey,
|
||||
reverseGeoApiKey,
|
||||
getReverseGeoApiKey,
|
||||
mapApiKey,
|
||||
getMapApiKey,
|
||||
browserGeo,
|
||||
getBrowserGeo,
|
||||
darkMode,
|
||||
setDarkMode,
|
||||
mapGeo,
|
||||
setMapGeo,
|
||||
setMapPosition,
|
||||
resetMapPosition,
|
||||
panToCoords,
|
||||
setPanToCoords,
|
||||
markerIsVisible,
|
||||
toggleMarker,
|
||||
tempUnit,
|
||||
saveTempUnit,
|
||||
speedUnit,
|
||||
saveSpeedUnit,
|
||||
lengthUnit,
|
||||
saveLengthUnit,
|
||||
animateWeatherMap,
|
||||
toggleAnimateWeatherMap,
|
||||
settingsMenuOpen,
|
||||
setSettingsMenuOpen,
|
||||
toggleSettingsMenuOpen,
|
||||
getCustomLatLon,
|
||||
customLat,
|
||||
customLon,
|
||||
loadStoredData,
|
||||
clockTime,
|
||||
saveClockTime,
|
||||
saveSettingsToJson,
|
||||
updateCurrentWeatherData,
|
||||
updateDailyWeatherData,
|
||||
updateHourlyWeatherData,
|
||||
currentWeatherData,
|
||||
currentWeatherDataErr,
|
||||
currentWeatherDataErrMsg,
|
||||
hourlyWeatherData,
|
||||
hourlyWeatherDataErr,
|
||||
hourlyWeatherDataErrMsg,
|
||||
dailyWeatherData,
|
||||
dailyWeatherDataErr,
|
||||
dailyWeatherDataErrMsg,
|
||||
mouseHide,
|
||||
saveMouseHide,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={defaultContext}>{children}</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
AppContextProvider.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node,
|
||||
]).isRequired,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useEffect, useContext } from "react";
|
||||
import styles from "./styles.css";
|
||||
import { AppContext } from "~/AppContext";
|
||||
|
||||
import WeatherMap from "~/components/WeatherMap";
|
||||
import InfoPanel from "~/components/InfoPanel";
|
||||
import Settings from "~/components/Settings";
|
||||
|
||||
import "!style-loader!css-loader!./overrides.css";
|
||||
|
||||
/**
|
||||
* Main component
|
||||
*
|
||||
* @returns {JSX.Element} Main component
|
||||
*/
|
||||
const App = () => {
|
||||
const {
|
||||
getBrowserGeo,
|
||||
getCustomLatLon,
|
||||
loadStoredData,
|
||||
darkMode,
|
||||
mouseHide,
|
||||
} = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
getCustomLatLon();
|
||||
getBrowserGeo();
|
||||
loadStoredData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${darkMode ? styles.dark : styles.light} ${
|
||||
mouseHide ? styles.hideMouse : ""
|
||||
}`}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.settingsContainer}>
|
||||
<Settings />
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.weatherMap} map-container ${
|
||||
mouseHide ? "map-mouse-hide" : ""
|
||||
} ${darkMode ? "map-dark-mode" : ""}`}
|
||||
>
|
||||
<WeatherMap zoom={9} dark={darkMode} />
|
||||
</div>
|
||||
<div className={styles.infoContainer}>
|
||||
<InfoPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,12 @@
|
||||
.map-container.map-mouse-hide .leaflet-grab {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.map-container.map-mouse-hide .leaflet-interactive {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.map-container.map-dark-mode .leaflet-bar a {
|
||||
background-color: #3a3938;
|
||||
color: #f6f6f4;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-width: 800px;
|
||||
min-height: 480px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 300px;
|
||||
grid-template-rows: auto;
|
||||
grid-template-areas: "map info";
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dark .container {
|
||||
background-color: #3a3938;
|
||||
}
|
||||
|
||||
.light .container {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
.weather-map {
|
||||
grid-area: map;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.info-container {
|
||||
grid-area: info;
|
||||
}
|
||||
|
||||
.dark .info-container {
|
||||
border-left: 1px solid #484848;
|
||||
}
|
||||
|
||||
.light .info-container {
|
||||
border-left: 1px solid #dadada;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
position: fixed;
|
||||
z-index: 5000;
|
||||
}
|
||||
|
||||
.hide-mouse{
|
||||
cursor: none !important;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import { format } from "date-fns";
|
||||
import styles from "./styles.css";
|
||||
import SunRiseSet from "~/components/SunRiseSet";
|
||||
|
||||
/**
|
||||
* Displays time and date
|
||||
*
|
||||
* @returns {JSX.Element} Clock component
|
||||
*/
|
||||
const Clock = () => {
|
||||
const { clockTime } = useContext(AppContext);
|
||||
const [date, setDate] = useState(new Date().getTime());
|
||||
|
||||
useEffect(() => {
|
||||
const clockInterval = setInterval(() => {
|
||||
setDate(new Date().getTime());
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearInterval(clockInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.date}>
|
||||
{format(date, "cccc").toUpperCase()}{" "}
|
||||
{format(date, "LLLL").toUpperCase()} {format(date, "d")}
|
||||
</div>
|
||||
<div className={styles.time}>{format(date, clockTime === "12" ? "p" : "HH:mm")}</div>
|
||||
<div className={styles.sunRiseSetContainer}>
|
||||
<SunRiseSet/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Clock;
|
||||
@@ -0,0 +1,16 @@
|
||||
.date{
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.time{
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
font-size: 45px;
|
||||
}
|
||||
|
||||
.sun-rise-set-container{
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useContext } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import styles from "./styles.css";
|
||||
import { InlineIcon } from "@iconify/react";
|
||||
import locationArrow from "@iconify/icons-map/location-arrow";
|
||||
import contrastIcon from "@iconify/icons-carbon/contrast";
|
||||
import sharpSettings from "@iconify/icons-ic/sharp-settings";
|
||||
import roundLocationOn from "@iconify/icons-ic/round-location-on";
|
||||
import roundLocationOff from "@iconify/icons-ic/round-location-off";
|
||||
import playFilledAlt from "@iconify/icons-carbon/play-filled-alt";
|
||||
import stopFilledAlt from "@iconify/icons-carbon/stop-filled-alt";
|
||||
|
||||
/**
|
||||
* Buttons group component
|
||||
*
|
||||
* @returns {JSX.Element} Control buttons
|
||||
*/
|
||||
const ControlButtons = () => {
|
||||
const {
|
||||
darkMode,
|
||||
setDarkMode,
|
||||
resetMapPosition,
|
||||
markerIsVisible,
|
||||
toggleMarker,
|
||||
toggleAnimateWeatherMap,
|
||||
animateWeatherMap,
|
||||
toggleSettingsMenuOpen,
|
||||
settingsMenuOpen,
|
||||
mouseHide,
|
||||
} = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.container} ${
|
||||
darkMode ? styles.dark : styles.light
|
||||
} ${!mouseHide ? styles.showMouse : ""}`}
|
||||
>
|
||||
<div onClick={resetMapPosition}>
|
||||
<InlineIcon icon={locationArrow} />
|
||||
</div>
|
||||
<div onClick={toggleMarker}>
|
||||
<InlineIcon
|
||||
icon={markerIsVisible ? roundLocationOff : roundLocationOn}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onClick={toggleAnimateWeatherMap}
|
||||
className={`${animateWeatherMap ? styles.buttonDown : ""}`}
|
||||
>
|
||||
<InlineIcon icon={animateWeatherMap ? stopFilledAlt : playFilledAlt} />
|
||||
</div>
|
||||
<div onClick={() => setDarkMode(!darkMode)}>
|
||||
<InlineIcon icon={contrastIcon} />
|
||||
</div>
|
||||
<div
|
||||
onClick={toggleSettingsMenuOpen}
|
||||
className={`${settingsMenuOpen ? styles.buttonDown : ""}`}
|
||||
>
|
||||
<InlineIcon icon={sharpSettings} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlButtons;
|
||||
@@ -0,0 +1,49 @@
|
||||
.container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.container > div {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.show-mouse.container > div{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container.dark > div {
|
||||
background: linear-gradient(#5d5c5c, #4e4e4e);
|
||||
border-right: 1px solid #4c4c4c;
|
||||
border-left: 1px solid #757575;
|
||||
}
|
||||
|
||||
.container.dark > div:active {
|
||||
background: linear-gradient(#4e4e4e, #5d5c5c);
|
||||
}
|
||||
|
||||
.container.light > div {
|
||||
background: linear-gradient(#d6d6d6, #c7c7c7);
|
||||
border-right: 1px solid #bdbdbd;
|
||||
border-left: 1px solid #dadada;
|
||||
}
|
||||
|
||||
.container.light > div:active {
|
||||
background: linear-gradient(#d7d7d7, #e6e6e6);
|
||||
}
|
||||
|
||||
.container.light > div:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.light .button-down {
|
||||
background: linear-gradient(#E4E4E4, #F3F3F3) !important;
|
||||
}
|
||||
|
||||
.dark .button-down {
|
||||
background: linear-gradient(#686868, #777676) !important;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useContext } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import styles from "./styles.css";
|
||||
import {
|
||||
convertTemp,
|
||||
convertSpeed,
|
||||
convertLength,
|
||||
} from "~/services/conversions";
|
||||
|
||||
import { InlineIcon } from "@iconify/react";
|
||||
import degreesIcon from "@iconify/icons-wi/degrees";
|
||||
import nightClear from "@iconify/icons-wi/night-clear";
|
||||
import daySunny from "@iconify/icons-wi/day-sunny";
|
||||
import dayCloudy from "@iconify/icons-wi/day-cloudy";
|
||||
import nightAltCloudy from "@iconify/icons-wi/night-alt-cloudy";
|
||||
import dayRain from "@iconify/icons-wi/day-rain";
|
||||
import nightRain from "@iconify/icons-wi/night-rain";
|
||||
import humidityAlt from "@iconify/icons-carbon/humidity-alt";
|
||||
import cloudIcon from "@iconify/icons-wi/cloud";
|
||||
import strongWind from "@iconify/icons-wi/strong-wind";
|
||||
import snowIcon from "@iconify/icons-ion/snow";
|
||||
import rainIcon from "@iconify/icons-wi/rain";
|
||||
import rainMix from "@iconify/icons-wi/rain-mix";
|
||||
import thunderstormIcon from "@iconify/icons-wi/thunderstorm";
|
||||
import fogIcon from "@iconify/icons-wi/fog";
|
||||
import cloudyIcon from "@iconify/icons-wi/cloudy";
|
||||
import daySunnyOvercast from "@iconify/icons-wi/day-sunny-overcast";
|
||||
|
||||
/**
|
||||
* Current weather conditions
|
||||
* https://developer.climacell.co/v3/reference#data-layers-weather
|
||||
*
|
||||
* @returns {JSX.Element} Current weather conditions component
|
||||
*/
|
||||
const CurrentWeather = () => {
|
||||
const { currentWeatherData, tempUnit, speedUnit, lengthUnit } = useContext(
|
||||
AppContext
|
||||
);
|
||||
if (currentWeatherData) {
|
||||
const {
|
||||
cloud_cover: { value: cloudCover },
|
||||
humidity: { value: humidity },
|
||||
precipitation: { value: precipitation },
|
||||
precipitation_type: { value: precipitationType },
|
||||
temp: { value: temp },
|
||||
weather_code: { value: weatherCode },
|
||||
wind_speed: { value: windSpeed },
|
||||
sunrise: { value: sunrise },
|
||||
sunset: { value: sunset },
|
||||
} = currentWeatherData;
|
||||
|
||||
const { icon: weatherIcon, desc: weatherDesc } =
|
||||
parseWeatherCode(weatherCode, isDaylight(sunrise, sunset)) || {};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.currentTemp}>
|
||||
{convertTemp(temp, tempUnit)}
|
||||
<InlineIcon icon={degreesIcon} />
|
||||
</div>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.weatherIcon}>
|
||||
{weatherIcon ? <InlineIcon icon={weatherIcon} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div>
|
||||
<div className={styles.statItem}>
|
||||
<div>
|
||||
<InlineIcon icon={cloudIcon} />
|
||||
</div>
|
||||
<div>{parseInt(cloudCover)}%</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div>
|
||||
<InlineIcon icon={strongWind} />
|
||||
</div>
|
||||
<div className={styles.textUnit}>
|
||||
<div>{convertSpeed(windSpeed, speedUnit)}</div>
|
||||
<div className={styles.statUnit}>
|
||||
{speedUnit === "mph" ? " mph" : " m/s"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div>
|
||||
<InlineIcon icon={humidityAlt} />
|
||||
</div>
|
||||
<div>{parseInt(humidity)}%</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div>
|
||||
<InlineIcon
|
||||
icon={precipitationType === "snow" ? snowIcon : rainIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.textUnit}>
|
||||
<div>{convertLength(precipitation, lengthUnit)}</div>
|
||||
<div>{lengthUnit}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.description}>{weatherDesc || ""}</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <div></div>;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse weather code
|
||||
*
|
||||
* @param {String} code
|
||||
* @param {Boolean} [isDay] if it is currently day
|
||||
* @returns {Object} weather description and icon
|
||||
*/
|
||||
const parseWeatherCode = (code, isDay) => {
|
||||
switch (code) {
|
||||
case "freezing_rain_heavy":
|
||||
return { desc: "Heavy freezing rain", icon: isDay ? dayRain : nightRain };
|
||||
case "freezing_rain":
|
||||
return { desc: "Freezing rain", icon: isDay ? dayRain : nightRain };
|
||||
case "freezing_rain_light":
|
||||
return { desc: "Light freezing rain", icon: isDay ? dayRain : nightRain };
|
||||
case "freezing_drizzle":
|
||||
return { desc: "Freezing drizzle", icon: rainMix };
|
||||
case "ice_pellets_heavy":
|
||||
return { desc: "Heavy ice pellets", icon: rainMix };
|
||||
case "ice_pellets":
|
||||
return { desc: "Ice pellets", icon: rainMix };
|
||||
case "ice_pellets_light":
|
||||
return { desc: "Light ice pellets", icon: rainMix };
|
||||
case "snow_heavy":
|
||||
return { desc: "Heavy snow", icon: snowIcon };
|
||||
case "snow":
|
||||
return { desc: "Show", icon: snowIcon };
|
||||
case "snow_light":
|
||||
return { desc: "Light snow", icon: snowIcon };
|
||||
case "flurries":
|
||||
return { desc: "Flurries", icon: snowIcon };
|
||||
case "tstorm":
|
||||
return { desc: "Thunder storm", icon: thunderstormIcon };
|
||||
case "rain_heavy":
|
||||
return { desc: "Heavy rain", icon: isDay ? dayRain : nightRain };
|
||||
case "rain":
|
||||
return { desc: "Rain", icon: isDay ? dayRain : nightRain };
|
||||
case "rain_light":
|
||||
return { desc: "Light rain", icon: isDay ? dayRain : nightRain };
|
||||
case "drizzle":
|
||||
return { desc: "Drizzle", icon: rainMix };
|
||||
case "fog_light":
|
||||
return { desc: "Light fog", icon: fogIcon };
|
||||
case "fog":
|
||||
return { desc: "Fog", icon: fogIcon };
|
||||
case "cloudy":
|
||||
return { desc: "Cloudy", icon: cloudyIcon };
|
||||
case "mostly_cloudy":
|
||||
return { desc: "Mostly cloudy", icon: cloudyIcon };
|
||||
case "partly_cloudy":
|
||||
return {
|
||||
desc: "Partly cloudy",
|
||||
icon: isDay ? daySunnyOvercast : nightAltCloudy,
|
||||
};
|
||||
case "mostly_clear":
|
||||
return { desc: "Mostly clear", icon: isDay ? dayCloudy : nightAltCloudy };
|
||||
case "clear":
|
||||
return { desc: "Clear", icon: isDay ? daySunny : nightClear };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if it is currently daylight
|
||||
*
|
||||
* @param {Date} sunrise
|
||||
* @param {Date} sunset
|
||||
* @returns {Boolean} if current time is during daylight
|
||||
*/
|
||||
function isDaylight(sunrise, sunset) {
|
||||
const sunriseTime = new Date(sunrise).getTime();
|
||||
const sunsetTime = new Date(sunset).getTime();
|
||||
const now = new Date().getTime();
|
||||
return !!(now > sunriseTime && now < sunsetTime);
|
||||
}
|
||||
|
||||
export default CurrentWeather;
|
||||
@@ -0,0 +1,196 @@
|
||||
|
||||
import React, { useContext } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import styles from "./styles.css";
|
||||
import {
|
||||
convertTemp,
|
||||
convertSpeed,
|
||||
convertLength,
|
||||
} from "~/services/conversions";
|
||||
import { capitalizeFirstLetter } from "~/services/formatting";
|
||||
import { InlineIcon } from "@iconify/react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import degreesIcon from "@iconify/icons-wi/degrees";
|
||||
import nightClear from "@iconify/icons-wi/night-clear";
|
||||
import daySunny from "@iconify/icons-wi/day-sunny";
|
||||
import nightPartlyCloudy from "@iconify/icons-wi/night-partly-cloudy";
|
||||
import nightAltPartlyCloudy from "@iconify/icons-wi/night-alt-partly-cloudy";
|
||||
import dayCloudy from "@iconify/icons-wi/day-cloudy";
|
||||
import nightAltCloudy from "@iconify/icons-wi/night-alt-cloudy";
|
||||
import dayRain from "@iconify/icons-wi/day-rain";
|
||||
import nightRain from "@iconify/icons-wi/night-rain";
|
||||
import dayThunderstorm from "@iconify/icons-wi/day-thunderstorm";
|
||||
import nightAltThunderstorm from "@iconify/icons-wi/night-alt-thunderstorm";
|
||||
import daySnow from "@iconify/icons-wi/day-snow";
|
||||
import nightAltSnow from "@iconify/icons-wi/night-alt-snow";
|
||||
import dayFog from "@iconify/icons-wi/day-fog";
|
||||
import nightFog from "@iconify/icons-wi/night-fog";
|
||||
import humidityIcon from "@iconify/icons-carbon/humidity";
|
||||
import cloudIcon from "@iconify/icons-wi/cloud";
|
||||
import strongWind from "@iconify/icons-wi/strong-wind";
|
||||
import snowIcon from "@iconify/icons-ion/snow";
|
||||
import rainIcon from "@iconify/icons-wi/rain";
|
||||
|
||||
/**
|
||||
* Current weather conditions
|
||||
* see https://openweathermap.org/api/one-call-api
|
||||
*
|
||||
* @returns {JSX.Element} Current weather conditions component
|
||||
*/
|
||||
const CurrentWeather = () => {
|
||||
const { weatherData, tempUnit, speedUnit, lengthUnit } = useContext(
|
||||
AppContext
|
||||
);
|
||||
if (weatherData) {
|
||||
const {
|
||||
current: {
|
||||
temp,
|
||||
humidity,
|
||||
clouds,
|
||||
wind_speed, // eslint-disable-line babel/camelcase
|
||||
rain,
|
||||
snow,
|
||||
weather: currentWeather,
|
||||
},
|
||||
} = weatherData;
|
||||
const [{ icon: iconCode, description }] = currentWeather;
|
||||
|
||||
const rainFall = rain ? rain["1h"] : null;
|
||||
const snowFall = snow ? snow["1h"] : null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.currentTemp}>
|
||||
{convertTemp(temp, tempUnit)}
|
||||
<InlineIcon icon={degreesIcon} />
|
||||
</div>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.weatherIcon}>
|
||||
<InlineIcon icon={getWeatherIcon(iconCode)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div>
|
||||
<div className={styles.statItem}>
|
||||
<div>
|
||||
<InlineIcon icon={cloudIcon} />
|
||||
</div>
|
||||
<div>{humidity}%</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div>
|
||||
<InlineIcon icon={strongWind} />
|
||||
</div>
|
||||
<div className={styles.textUnit}>
|
||||
<div>{convertSpeed(wind_speed, speedUnit)}</div>
|
||||
<div className={styles.statUnit}>
|
||||
{speedUnit === "mph" ? " mph" : " m/s"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div>
|
||||
<InlineIcon icon={humidityIcon} />
|
||||
</div>
|
||||
<div>{clouds}%</div>
|
||||
</div>
|
||||
<Precipitation
|
||||
snowFall={snowFall}
|
||||
rainFall={rainFall}
|
||||
lengthUnit={lengthUnit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{capitalizeFirstLetter(description)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <div>no weather data / loading</div>;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Precipitation, if any
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Number} props.rainFall
|
||||
* @param {Number} props.snowFall
|
||||
* @param {String} props.lengthUnit
|
||||
* @returns {JSX.Element} precipitation component
|
||||
*/
|
||||
const Precipitation = ({ rainFall, snowFall, lengthUnit }) => {
|
||||
if ((rainFall && rainFall !== 0) || (snowFall && snowFall !== 0)) {
|
||||
return (
|
||||
<div className={styles.statItem}>
|
||||
<div>
|
||||
<InlineIcon icon={rainFall ? rainIcon : snowIcon} />
|
||||
</div>
|
||||
<div className={styles.textUnit}>
|
||||
<div>{convertLength(rainFall ? rainFall : snowFall, lengthUnit)}</div>
|
||||
<div>{lengthUnit}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
Precipitation.propTypes = {
|
||||
rainFall: PropTypes.number,
|
||||
snowFall: PropTypes.number,
|
||||
lengthUnit: PropTypes.string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps weather codes to icons
|
||||
* see https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2
|
||||
*
|
||||
* @param {String} iconCode
|
||||
* @returns {Object} Icon
|
||||
*/
|
||||
function getWeatherIcon(iconCode) {
|
||||
switch (iconCode) {
|
||||
case "01d":
|
||||
return daySunny;
|
||||
case "01n":
|
||||
return nightClear;
|
||||
case "02d":
|
||||
return nightPartlyCloudy;
|
||||
case "02n":
|
||||
return nightAltPartlyCloudy;
|
||||
case "03d":
|
||||
return dayCloudy;
|
||||
case "03n":
|
||||
return nightAltCloudy;
|
||||
case "04d":
|
||||
return dayCloudy;
|
||||
case "04n":
|
||||
return nightAltCloudy;
|
||||
case "09d":
|
||||
return dayRain;
|
||||
case "09n":
|
||||
return nightRain;
|
||||
case "10d":
|
||||
return dayRain;
|
||||
case "10n":
|
||||
return nightRain;
|
||||
case "11d":
|
||||
return dayThunderstorm;
|
||||
case "11n":
|
||||
return nightAltThunderstorm;
|
||||
case "13d":
|
||||
return daySnow;
|
||||
case "13n":
|
||||
return nightAltSnow;
|
||||
case "50d":
|
||||
return dayFog;
|
||||
case "50n":
|
||||
return nightFog;
|
||||
}
|
||||
}
|
||||
|
||||
export default CurrentWeather;
|
||||
@@ -0,0 +1,66 @@
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
"weather-icon current-temp stats"
|
||||
"description description description"
|
||||
}
|
||||
|
||||
.current-temp {
|
||||
grid-area: current-temp;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 55px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
grid-area: weather-icon;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: 45px;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-area: description;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-area: stats;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.stat-item > div:first-child {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 5px;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
.uvi-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-unit {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.text-unit > div:last-child {
|
||||
margin-left: 2px;
|
||||
font-size: 8px;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useContext } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import Clock from "~/components/Clock";
|
||||
import WeatherInfo from "~/components/WeatherInfo";
|
||||
import ControlButtons from "~/components/ControlButtons";
|
||||
import styles from "./styles.css";
|
||||
|
||||
/**
|
||||
* Info Panel
|
||||
*
|
||||
* @returns {JSX.Element} Info Panel
|
||||
*/
|
||||
const InfoPanel = () => {
|
||||
const { darkMode } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<div className={`${darkMode ? styles.dark : styles.light} ${styles.panel}`}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.clockContainer}>
|
||||
<Clock />
|
||||
</div>
|
||||
<div className={styles.weatherInfoContainer}>
|
||||
<WeatherInfo />
|
||||
</div>
|
||||
<div className={styles.controls}>
|
||||
<ControlButtons />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoPanel;
|
||||
@@ -0,0 +1,44 @@
|
||||
.panel {
|
||||
height: 100%;
|
||||
}
|
||||
.container {
|
||||
height: calc(100% - 20px); /* subtract padding */
|
||||
padding: 10px;
|
||||
font-family: "Rubik", sans-serif;
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: 90px auto 10px;
|
||||
grid-template-areas:
|
||||
"clock"
|
||||
"weather-info"
|
||||
"controls";
|
||||
}
|
||||
|
||||
.dark .container {
|
||||
color: #f6f6f4;
|
||||
}
|
||||
|
||||
.light .container {
|
||||
color: #3a3938;
|
||||
}
|
||||
|
||||
.clock-container {
|
||||
grid-area: clock;
|
||||
}
|
||||
|
||||
.weather-info-container {
|
||||
grid-area: weather-info;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-area: controls;
|
||||
margin: -10px; /* subtract padding */
|
||||
}
|
||||
|
||||
.dark .controls{
|
||||
border-top: 1px solid #757575;
|
||||
}
|
||||
|
||||
.light .controls{
|
||||
border-top: 1px solid #f1f1f1;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import { InlineIcon } from "@iconify/react";
|
||||
import reverseGeocode from "~/services/reverseGeocode";
|
||||
import locationIcon from "@iconify/icons-gridicons/location";
|
||||
import styles from "./styles.css";
|
||||
|
||||
/**
|
||||
* Map location
|
||||
*
|
||||
* @returns {JSX.Element} Location name
|
||||
*/
|
||||
const LocationName = () => {
|
||||
const { currentWeatherData, reverseGeoApiKey } = useContext(AppContext);
|
||||
const [name, setName] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentWeatherData && reverseGeoApiKey) {
|
||||
const { lat, lon } = currentWeatherData;
|
||||
reverseGeocode({ lat, lon, apiKey: reverseGeoApiKey })
|
||||
.then((res) => {
|
||||
setName(getName(res));
|
||||
})
|
||||
.catch((err) => {
|
||||
setName(`${lat}, ${lon}`);
|
||||
console.log("err!", err);
|
||||
});
|
||||
} else if (currentWeatherData && !reverseGeoApiKey) {
|
||||
const { lat, lon } = currentWeatherData;
|
||||
setName(`${lat}, ${lon}`);
|
||||
}
|
||||
}, [currentWeatherData, reverseGeoApiKey]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.container}`}>
|
||||
{name ? (
|
||||
<div>
|
||||
<InlineIcon icon={locationIcon} /> {name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses name data from results
|
||||
*
|
||||
* @param {Object} res
|
||||
* @returns {String} Display name
|
||||
*/
|
||||
const getName = (res) => {
|
||||
// eslint-disable-next-line babel/camelcase
|
||||
const { city, country, state, country_code, county, region } = res.address;
|
||||
// eslint-disable-next-line babel/camelcase
|
||||
if (country_code === "us") {
|
||||
if (city) {
|
||||
return `${city}, ${state}`;
|
||||
} else if (county) {
|
||||
return `${county}, ${state}`;
|
||||
} else if (state) {
|
||||
return `${state}`;
|
||||
} else {
|
||||
return `${country}`;
|
||||
}
|
||||
} else {
|
||||
if (city) {
|
||||
return `${city}, ${country}`;
|
||||
} else {
|
||||
return `${
|
||||
county
|
||||
? `${county}, `
|
||||
: region
|
||||
? `${region}, `
|
||||
: state
|
||||
? `${state}, `
|
||||
: ""
|
||||
}${country}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default LocationName;
|
||||
@@ -0,0 +1,10 @@
|
||||
.container {
|
||||
max-height: 36px;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.err {
|
||||
font-size: 11px;
|
||||
color: rgba(128, 128, 128, 0.6);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.animate-enter {
|
||||
-webkit-transform: translateX(-200%);
|
||||
-moz-transform: translateX(-200%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.animate-enter-active {
|
||||
-webkit-transform: translateX(0px);
|
||||
-moz-transform: translateX(0px);
|
||||
transform: translateX(0px);
|
||||
-webkit-transition: all 0.3s ease-in-out;
|
||||
}
|
||||
.animate-exit {
|
||||
-webkit-transform: translateX(0px);
|
||||
-moz-transform: translateX(0px);
|
||||
transform: translateX(0px);
|
||||
-webkit-transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-exit-active {
|
||||
-webkit-transform: translateX(-200%);
|
||||
-moz-transform: translateX(-200%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import React, { useContext, useState, useEffect } from "react";
|
||||
import styles from "./styles.css";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
import { InlineIcon } from "@iconify/react";
|
||||
import closeFilled from "@iconify/icons-carbon/close-filled";
|
||||
import roundSaveAlt from "@iconify/icons-ic/round-save-alt";
|
||||
import undoIcon from "@iconify/icons-dashicons/undo";
|
||||
import closeSharp from "@iconify/icons-ion/close-sharp";
|
||||
import PropTypes from "prop-types";
|
||||
import "!style-loader!css-loader!./animations.css";
|
||||
|
||||
/**
|
||||
* Settings page
|
||||
*
|
||||
* @returns {JSX.Element} Settings page
|
||||
*/
|
||||
const Settings = () => {
|
||||
const {
|
||||
settingsMenuOpen,
|
||||
weatherApiKey,
|
||||
mapApiKey,
|
||||
reverseGeoApiKey,
|
||||
customLat,
|
||||
customLon,
|
||||
setSettingsMenuOpen,
|
||||
mouseHide,
|
||||
saveMouseHide,
|
||||
} = useContext(AppContext);
|
||||
|
||||
const [mapsKey, setMapsKey] = useState(null);
|
||||
const [weatherKey, setWeatherKey] = useState(null);
|
||||
const [geoKey, setGeoKey] = useState(null);
|
||||
const [lat, setLat] = useState(null);
|
||||
const [lon, setLon] = useState(null);
|
||||
|
||||
const [currentMapsKey, setCurrentMapsKey] = useState(null);
|
||||
const [currentWeatherKey, setCurrentWeatherKey] = useState(null);
|
||||
const [currentGeoKey, setCurrentGeoKey] = useState(null);
|
||||
const [currentLat, setCurrentLat] = useState(null);
|
||||
const [currentLon, setCurrentLon] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentMapsKey(mapApiKey);
|
||||
setCurrentWeatherKey(weatherApiKey);
|
||||
setCurrentGeoKey(reverseGeoApiKey);
|
||||
setCurrentLat(customLat);
|
||||
setCurrentLon(customLon);
|
||||
}, [
|
||||
mapApiKey,
|
||||
weatherApiKey,
|
||||
reverseGeoApiKey,
|
||||
customLat,
|
||||
customLon,
|
||||
currentGeoKey,
|
||||
mouseHide,
|
||||
saveMouseHide,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapApiKey) {
|
||||
setMapsKey(mapApiKey);
|
||||
}
|
||||
if (weatherApiKey) {
|
||||
setWeatherKey(weatherApiKey);
|
||||
}
|
||||
if (reverseGeoApiKey) {
|
||||
setGeoKey(reverseGeoApiKey);
|
||||
}
|
||||
if (customLat) {
|
||||
setLat(customLat);
|
||||
}
|
||||
if (customLon) {
|
||||
setLon(customLon);
|
||||
}
|
||||
}, [mapApiKey, weatherApiKey, reverseGeoApiKey, customLon, customLat]);
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
in={settingsMenuOpen}
|
||||
unmountOnExit
|
||||
timeout={300}
|
||||
classNames="animate"
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>SETTINGS</div>
|
||||
<div
|
||||
className={styles.closeButton}
|
||||
onClick={() => {
|
||||
setSettingsMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<InlineIcon icon={closeSharp} />
|
||||
</div>
|
||||
<div className={styles.settingsContainer}>
|
||||
<ToggleButtons />
|
||||
<Input
|
||||
label={"MAPS API KEY"}
|
||||
val={mapsKey}
|
||||
current={currentMapsKey}
|
||||
cb={setMapsKey}
|
||||
required={true}
|
||||
/>
|
||||
<Input
|
||||
label={"WEATHER API KEY"}
|
||||
val={weatherKey}
|
||||
current={currentWeatherKey}
|
||||
cb={setWeatherKey}
|
||||
required={true}
|
||||
/>
|
||||
<Input
|
||||
label={"GEOLOCATION API KEY"}
|
||||
val={geoKey}
|
||||
current={currentGeoKey}
|
||||
cb={setGeoKey}
|
||||
/>
|
||||
<Input
|
||||
label={"CUSTOM STARTING LATITUDE"}
|
||||
val={lat}
|
||||
cb={setLat}
|
||||
current={currentLat}
|
||||
/>
|
||||
<Input
|
||||
label={"CUSTOM STARTING LONGITUDE"}
|
||||
val={lon}
|
||||
cb={setLon}
|
||||
current={currentLon}
|
||||
/>
|
||||
<div className={styles.bottomButtonContainer}>
|
||||
<div>
|
||||
<div className={styles.label}>HIDE MOUSE</div>
|
||||
<ToggleButton
|
||||
button1Label={"ON"}
|
||||
button2Label={"OFF"}
|
||||
val={mouseHide}
|
||||
button1Val={true}
|
||||
button2Val={false}
|
||||
cb={saveMouseHide}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.saveButtonContainer}>
|
||||
<SaveButton
|
||||
mapsKey={mapsKey}
|
||||
weatherKey={weatherKey}
|
||||
geoKey={geoKey}
|
||||
lat={lat}
|
||||
lon={lon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
/**
|
||||
* Save button
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {String} [props.mapsKey]
|
||||
* @param {String} [props.weatherKey]
|
||||
* @param {String} [props.geoKey]
|
||||
* @param {String} [props.lat]
|
||||
* @param {String} [props.lon]
|
||||
* @returns {JSX.Element} Save button
|
||||
*/
|
||||
const SaveButton = ({ mapsKey, weatherKey, geoKey, lat, lon }) => {
|
||||
const { saveSettingsToJson, setSettingsMenuOpen, mouseHide } = useContext(
|
||||
AppContext
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`${styles.button} ${styles.saveButton} ${
|
||||
!mouseHide ? styles.showMouse : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
saveSettingsToJson({ mapsKey, weatherKey, geoKey, lat, lon })
|
||||
.then(() => {
|
||||
setSettingsMenuOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("err!", err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className={styles.label}>SAVE</div>
|
||||
<div>
|
||||
<InlineIcon icon={roundSaveAlt} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SaveButton.propTypes = {
|
||||
mapsKey: PropTypes.string,
|
||||
weatherKey: PropTypes.string,
|
||||
geoKey: PropTypes.string,
|
||||
lat: PropTypes.string,
|
||||
lon: PropTypes.string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle Buttons Group
|
||||
*
|
||||
* @returns {JSX.Element} A grouping of toggle buttons
|
||||
*/
|
||||
const ToggleButtons = () => {
|
||||
const {
|
||||
tempUnit,
|
||||
saveTempUnit,
|
||||
speedUnit,
|
||||
saveSpeedUnit,
|
||||
lengthUnit,
|
||||
saveLengthUnit,
|
||||
clockTime,
|
||||
saveClockTime,
|
||||
} = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.label}>UNITS</div>
|
||||
<div className={styles.toggleButtons}>
|
||||
<div>
|
||||
<ToggleButton
|
||||
button1Label={"F"}
|
||||
button2Label={"C"}
|
||||
val={tempUnit}
|
||||
button1Val={"f"}
|
||||
button2Val={"c"}
|
||||
cb={saveTempUnit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ToggleButton
|
||||
button1Label={"mph"}
|
||||
button2Label={"m/s"}
|
||||
val={speedUnit}
|
||||
button1Val={"mph"}
|
||||
button2Val={"ms"}
|
||||
cb={saveSpeedUnit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ToggleButton
|
||||
button1Label={"in"}
|
||||
button2Label={"mm"}
|
||||
val={lengthUnit}
|
||||
button1Val={"in"}
|
||||
button2Val={"mm"}
|
||||
cb={saveLengthUnit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ToggleButton
|
||||
button1Label={"12h"}
|
||||
button2Label={"24h"}
|
||||
val={clockTime}
|
||||
button1Val={"12"}
|
||||
button2Val={"24"}
|
||||
cb={saveClockTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle buttons
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {String} props.button1Label PropTypes.string.isRequired,
|
||||
* @param {String} props.button2Label PropTypes.string.isRequired,
|
||||
* @param {*} props.val PropTypes.string.isRequired,
|
||||
* @param {*} props.button1Val PropTypes.string.isRequired,
|
||||
* @param {*} props.button2Val PropTypes.string.isRequired,
|
||||
* @param {Function} props.cb PropTypes.func.isRequired,
|
||||
* @returns {JSX.Element} Toggle buttons
|
||||
*/
|
||||
const ToggleButton = ({
|
||||
button1Label,
|
||||
button2Label,
|
||||
val,
|
||||
button1Val,
|
||||
button2Val,
|
||||
cb,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.toggleContainer}>
|
||||
<div
|
||||
className={` ${styles.button} ${button1Val === val ? styles.down : ""}`}
|
||||
onClick={() => {
|
||||
cb(button1Val);
|
||||
}}
|
||||
>
|
||||
{button1Label}
|
||||
</div>
|
||||
<div
|
||||
className={` ${styles.button} ${button2Val === val ? styles.down : ""}`}
|
||||
onClick={() => {
|
||||
cb(button2Val);
|
||||
}}
|
||||
>
|
||||
{button2Label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ToggleButton.propTypes = {
|
||||
button1Label: PropTypes.string.isRequired,
|
||||
button2Label: PropTypes.string.isRequired,
|
||||
val: PropTypes.any.isRequired,
|
||||
button1Val: PropTypes.any.isRequired,
|
||||
button2Val: PropTypes.any.isRequired,
|
||||
cb: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete button
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Function} props.cb callback
|
||||
* @returns {JSX.Element} Delete button
|
||||
*/
|
||||
const DeleteButton = ({ cb }) => {
|
||||
return (
|
||||
<div className={styles.button} onClick={cb}>
|
||||
<InlineIcon icon={closeFilled} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteButton.propTypes = {
|
||||
cb: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* Undo button, restores input to default value
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Function} props.cb callback
|
||||
* @returns {JSX.Element} Undo button
|
||||
*/
|
||||
const UndoButton = ({ cb }) => {
|
||||
return (
|
||||
<div className={styles.button} onClick={cb}>
|
||||
<InlineIcon icon={undoIcon} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UndoButton.propTypes = {
|
||||
cb: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings input
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {String} props.label Label
|
||||
* @param {String} props.val value
|
||||
* @param {Function} props.cb change callback
|
||||
* @param {String} props.current current default value
|
||||
* @param {Boolean} [props.required] If input is required
|
||||
* @returns {JSX.Element} Input
|
||||
*/
|
||||
const Input = ({ label, val, cb, required, current }) => {
|
||||
const [inputValue, setInputValue] = useState(val);
|
||||
const [defaultValue, setDefaultValue] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ((val || val === "") && (!defaultValue || defaultValue === "")) {
|
||||
setDefaultValue(val);
|
||||
}
|
||||
setInputValue(val);
|
||||
}, [val, defaultValue]);
|
||||
return (
|
||||
<div className={styles.settingsItem}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
<div
|
||||
className={`${styles.inputContainer} ${
|
||||
required && !val ? styles.invalid : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={"None"}
|
||||
value={inputValue || ""}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
setInputValue(value);
|
||||
cb(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<DeleteButton
|
||||
cb={() => {
|
||||
setInputValue("");
|
||||
cb("");
|
||||
}}
|
||||
/>
|
||||
<UndoButton
|
||||
cb={() => {
|
||||
setInputValue(current);
|
||||
cb(current);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Input.propTypes = {
|
||||
label: PropTypes.string,
|
||||
val: PropTypes.string,
|
||||
cb: PropTypes.func.isRequired,
|
||||
required: PropTypes.bool,
|
||||
current: PropTypes.string,
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
.container {
|
||||
height: calc(100vh - 20px); /* subtract padding */
|
||||
width: calc(100vw - 320px); /* subtract info panel and padding */
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(3px);
|
||||
padding: 10px;
|
||||
color: #f6f6f4;
|
||||
font-family: "Rubik", sans-serif;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-container input {
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin-right: 10px;
|
||||
outline: none;
|
||||
border: 2px solid #5d5c5c;
|
||||
}
|
||||
|
||||
.input-container.invalid input {
|
||||
border: 2px solid #c20808;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button-container > div {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.button-container > div:last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(#5d5c5c, #4e4e4e);
|
||||
border-top: 1px solid #757575;
|
||||
border-left: 1px solid #757575;
|
||||
border-right: 1px solid #4c4c4c;
|
||||
border-bottom: 1px solid #4c4c4c;
|
||||
}
|
||||
|
||||
.show-mouse.button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button.down {
|
||||
color: #4e4e4e;
|
||||
background: linear-gradient(#d6d6d6, #c7c7c7);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
background: linear-gradient(#4e4e4e, #5d5c5c);
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
margin: 10px 0px;
|
||||
}
|
||||
|
||||
.toggle-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toggle-buttons {
|
||||
display: flex;
|
||||
max-width: 900px;
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-buttons > div {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.toggle-buttons > div:last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.toggle-container > .button {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.bottom-button-container {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.save-button .label {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
font-size: 20px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.show-mouse.close-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
color: #c7c7c7;
|
||||
}
|
||||
|
||||
.save-button-container {
|
||||
margin-top: 15px;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import styles from "./styles.css";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/*
|
||||
https://github.com/tobiasahlin/SpinKit
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020 Tobias Ahlin
|
||||
|
||||
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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A spinner based off of https://github.com/tobiasahlin/SpinKit
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {String} props.color
|
||||
* @param {String} props.size
|
||||
* @returns {JSX.Element} Spinner
|
||||
*/
|
||||
function Spinner({ color, size }) {
|
||||
const style = {
|
||||
height: size,
|
||||
width: size,
|
||||
backgroundColor: color,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.spinner}>
|
||||
<div className={styles.bounce1} style={style} />
|
||||
<div className={styles.bounce2} style={style} />
|
||||
<div className={styles.bounce3} style={style} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Spinner.propTypes = {
|
||||
size: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
@@ -0,0 +1,46 @@
|
||||
.spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner > div {
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
-webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.spinner .bounce1 {
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.spinner .bounce2 {
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sk-bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useContext } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import { InlineIcon } from "@iconify/react";
|
||||
import bxsMoon from '@iconify/icons-bx/bxs-moon';
|
||||
import bxsSun from '@iconify/icons-bx/bxs-sun';
|
||||
|
||||
import { format } from "date-fns";
|
||||
import styles from "./styles.css";
|
||||
|
||||
/**
|
||||
* Sunrise / Sunset component
|
||||
*
|
||||
* @returns {JSX.Element} Sunrise / Sunset component
|
||||
*/
|
||||
const SunRiseSet = () => {
|
||||
const { currentWeatherData, clockTime } = useContext(AppContext);
|
||||
if (currentWeatherData) {
|
||||
const {
|
||||
sunrise: { value: sunrise },
|
||||
sunset: { value: sunset },
|
||||
} = currentWeatherData;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
<InlineIcon icon={bxsSun} />
|
||||
<span>
|
||||
{format(new Date(sunrise), clockTime === "12" ? "p" : "HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<InlineIcon icon={bxsMoon} />
|
||||
<span>
|
||||
{format(new Date(sunset), clockTime === "12" ? "p" : "HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default SunRiseSet;
|
||||
@@ -0,0 +1,13 @@
|
||||
.container {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
/* padding-top: 5px; */
|
||||
}
|
||||
|
||||
.container > div:first-child {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.container > div > span {
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useContext, useState, useCallback } from "react";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import styles from "./styles.css";
|
||||
import LocationName from "~/components/LocationName";
|
||||
import CurrentWeather from "~/components/CurrentWeather";
|
||||
import DailyChart from "~/components/weatherCharts/DailyChart";
|
||||
import HourlyChart from "~/components/weatherCharts/HourlyChart";
|
||||
|
||||
const CURRENT_WEATHER_DATA_UPDATE_INTERVAL = 10 * 60 * 1000; //every 10 minutes
|
||||
const HOURLY_WEATHER_DATA_UPDATE_INTERVAL = 60 * 60 * 1000; //every hour
|
||||
const DAILY_WEATHER_DATA_UPDATE_INTERVAL = 24 * 60 * 60 * 1000; //every day
|
||||
|
||||
/**
|
||||
* Creates an interval to call a weather update callback
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} params.stateInterval Interval in state
|
||||
* @param {Function} params.stateIntervalSetter state interval setter
|
||||
* @param {Function} params.cb callback to invoke on each interval
|
||||
* @param {Number} params.intervalTime interval frequency, ms
|
||||
* @param {String} params.weatherApiKey weather API key
|
||||
* @param {Object} params.mapGeo coordinates to get weather for
|
||||
*/
|
||||
function createWeatherUpdateInterval({
|
||||
stateInterval,
|
||||
stateIntervalSetter,
|
||||
cb,
|
||||
intervalTime,
|
||||
weatherApiKey,
|
||||
mapGeo,
|
||||
}) {
|
||||
if (stateInterval) {
|
||||
clearInterval(stateInterval);
|
||||
stateIntervalSetter(null);
|
||||
}
|
||||
if (weatherApiKey && mapGeo) {
|
||||
const interval = setInterval(cb, intervalTime);
|
||||
cb();
|
||||
stateIntervalSetter(interval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays weather info
|
||||
*
|
||||
* @returns {JSX.Element} Clock component
|
||||
*/
|
||||
const WeatherInfo = () => {
|
||||
const {
|
||||
getWeatherApiKey,
|
||||
getReverseGeoApiKey,
|
||||
reverseGeoApiKey,
|
||||
updateCurrentWeatherData,
|
||||
updateHourlyWeatherData,
|
||||
updateDailyWeatherData,
|
||||
mapGeo,
|
||||
weatherApiKey,
|
||||
currentWeatherDataErr,
|
||||
currentWeatherDataErrMsg,
|
||||
darkMode,
|
||||
setSettingsMenuOpen,
|
||||
currentWeatherData,
|
||||
} = useContext(AppContext);
|
||||
|
||||
const [
|
||||
currentWeatherUpdateInterval,
|
||||
setCurrentWeatherUpdateInterval,
|
||||
] = useState(null);
|
||||
const [
|
||||
hourlyWeatherUpdateInterval,
|
||||
setHourlyWeatherUpdateInterval,
|
||||
] = useState(null);
|
||||
const [dailyWeatherUpdateInterval, setDailyWeatherUpdateInterval] = useState(
|
||||
null
|
||||
);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const hourlyWeatherUpdateCb = useCallback(() => {
|
||||
updateHourlyWeatherData(mapGeo).catch((err) => {
|
||||
console.log("err", err);
|
||||
});
|
||||
}, [updateHourlyWeatherData, mapGeo]);
|
||||
|
||||
const dailyWeatherUpdateCb = useCallback(() => {
|
||||
updateDailyWeatherData(mapGeo).catch((err) => {
|
||||
console.log("err", err);
|
||||
});
|
||||
}, [updateDailyWeatherData, mapGeo]);
|
||||
|
||||
const currentWeatherUpdateCb = useCallback(() => {
|
||||
updateCurrentWeatherData(mapGeo).catch((err) => {
|
||||
console.log("err", err);
|
||||
});
|
||||
}, [updateCurrentWeatherData, mapGeo]);
|
||||
|
||||
useEffect(() => {
|
||||
setErr(false);
|
||||
if (!weatherApiKey) {
|
||||
getWeatherApiKey().catch((err) => {
|
||||
console.log("error getting weather api key:", err);
|
||||
setErr(true);
|
||||
setSettingsMenuOpen(true);
|
||||
});
|
||||
}
|
||||
if (!reverseGeoApiKey) {
|
||||
getReverseGeoApiKey().catch((err) => {
|
||||
console.log("error getting reverse geo api key:", err);
|
||||
});
|
||||
}
|
||||
}, [weatherApiKey, reverseGeoApiKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
createWeatherUpdateInterval({
|
||||
stateInterval: currentWeatherUpdateInterval,
|
||||
stateIntervalSetter: setCurrentWeatherUpdateInterval,
|
||||
cb: currentWeatherUpdateCb,
|
||||
intervalTime: CURRENT_WEATHER_DATA_UPDATE_INTERVAL,
|
||||
weatherApiKey,
|
||||
mapGeo,
|
||||
});
|
||||
createWeatherUpdateInterval({
|
||||
stateInterval: hourlyWeatherUpdateInterval,
|
||||
stateIntervalSetter: setHourlyWeatherUpdateInterval,
|
||||
cb: hourlyWeatherUpdateCb,
|
||||
intervalTime: HOURLY_WEATHER_DATA_UPDATE_INTERVAL,
|
||||
weatherApiKey,
|
||||
mapGeo,
|
||||
});
|
||||
createWeatherUpdateInterval({
|
||||
stateInterval: dailyWeatherUpdateInterval,
|
||||
stateIntervalSetter: setDailyWeatherUpdateInterval,
|
||||
cb: dailyWeatherUpdateCb,
|
||||
intervalTime: DAILY_WEATHER_DATA_UPDATE_INTERVAL,
|
||||
weatherApiKey,
|
||||
mapGeo,
|
||||
});
|
||||
return () => {
|
||||
clearInterval(currentWeatherUpdateInterval);
|
||||
clearInterval(hourlyWeatherUpdateInterval);
|
||||
clearInterval(dailyWeatherUpdateInterval);
|
||||
};
|
||||
}, [weatherApiKey, mapGeo]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (currentWeatherData) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.location}>
|
||||
<LocationName />
|
||||
</div>
|
||||
<div>
|
||||
<CurrentWeather />
|
||||
</div>
|
||||
<div className={styles.weatherChart}>
|
||||
<HourlyChart />
|
||||
</div>
|
||||
<div className={styles.weatherChart}>
|
||||
<DailyChart />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (currentWeatherData || currentWeatherDataErr || err) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.errContainer} ${
|
||||
darkMode ? styles.dark : styles.light
|
||||
}`}
|
||||
>
|
||||
<div>Could not retrieve weather data.</div>
|
||||
<div>Is your weather API key valid?</div>
|
||||
{currentWeatherDataErr ? (
|
||||
<div className={styles.message}>{currentWeatherDataErrMsg}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner size={"20px"} color={darkMode ? "#f6f6f444" : "#3a393844"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default WeatherInfo;
|
||||
@@ -0,0 +1,40 @@
|
||||
.container {
|
||||
padding: 0px 10px;
|
||||
}
|
||||
|
||||
.location {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.weather-chart {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.err-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 60px;
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color: #f6f6f444;
|
||||
}
|
||||
|
||||
.light {
|
||||
color: #3a393888;
|
||||
}
|
||||
|
||||
.err-container .message{
|
||||
margin-top: 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Map, TileLayer, AttributionControl, Marker } from "react-leaflet";
|
||||
import PropTypes from "prop-types";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import debounce from "debounce";
|
||||
import axios from "axios";
|
||||
import styles from "./styles.css";
|
||||
|
||||
/**
|
||||
* Weather map
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Number} props.zoom zoom level
|
||||
* @param {Boolean} [props.dark] dark mode
|
||||
* @returns {JSX.Element} Weather map
|
||||
*/
|
||||
const WeatherMap = ({ zoom, dark }) => {
|
||||
const MAP_CLICK_DEBOUNCE_TIME = 200; //ms
|
||||
const {
|
||||
setMapPosition,
|
||||
panToCoords,
|
||||
setPanToCoords,
|
||||
browserGeo,
|
||||
mapGeo,
|
||||
mapApiKey,
|
||||
getMapApiKey,
|
||||
markerIsVisible,
|
||||
animateWeatherMap,
|
||||
} = useContext(AppContext);
|
||||
const mapRef = useRef();
|
||||
|
||||
const mapClickHandler = useCallback(
|
||||
debounce((e) => {
|
||||
const { lat: latitude, lng: longitude } = e.latlng;
|
||||
const newCoords = { latitude, longitude };
|
||||
setMapPosition(newCoords);
|
||||
}, MAP_CLICK_DEBOUNCE_TIME),
|
||||
[setMapPosition]
|
||||
);
|
||||
|
||||
const [mapTimestamps, setMapTimestamps] = useState(null);
|
||||
const [mapTimestamp, setMapTimestamp] = useState(null);
|
||||
const [currentMapTimestampIdx, setCurrentMapTimestampIdx] = useState(0);
|
||||
|
||||
const MAP_TIMESTAMP_REFRESH_FREQUENCY = 1000 * 60 * 10; //update every 10 minutes
|
||||
const MAP_CYCLE_RATE = 1000; //ms
|
||||
|
||||
const getMapApiKeyCallback = useCallback(() => getMapApiKey(), [
|
||||
getMapApiKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
getMapApiKeyCallback().catch((err) => {
|
||||
console.log("err!", err);
|
||||
});
|
||||
|
||||
const updateTimeStamps = () => {
|
||||
getMapTimestamps()
|
||||
.then((res) => {
|
||||
setMapTimestamps(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("err", err);
|
||||
});
|
||||
};
|
||||
|
||||
const mapTimestampsInterval = setInterval(
|
||||
updateTimeStamps,
|
||||
MAP_TIMESTAMP_REFRESH_FREQUENCY
|
||||
);
|
||||
updateTimeStamps(); //initial update
|
||||
return () => {
|
||||
clearInterval(mapTimestampsInterval);
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Pan the screen to a a specific location when `panToCoords` is updated with grid coordinates
|
||||
useEffect(() => {
|
||||
if (panToCoords && mapRef.current) {
|
||||
const { leafletElement } = mapRef.current;
|
||||
leafletElement.panTo([panToCoords.latitude, panToCoords.longitude]);
|
||||
setPanToCoords(null); //reset back to null so we can observe a change next time its fired for the same coords
|
||||
}
|
||||
}, [panToCoords, mapRef]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { latitude, longitude } = browserGeo || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (mapTimestamps) {
|
||||
setMapTimestamp(mapTimestamps[currentMapTimestampIdx]);
|
||||
}
|
||||
}, [currentMapTimestampIdx, mapTimestamps]);
|
||||
|
||||
// cycle through weather maps when animated is enabled
|
||||
useEffect(() => {
|
||||
if (mapTimestamps) {
|
||||
if (animateWeatherMap) {
|
||||
const interval = setInterval(() => {
|
||||
let nextIdx;
|
||||
if (currentMapTimestampIdx + 1 >= mapTimestamps.length) {
|
||||
nextIdx = 0;
|
||||
} else {
|
||||
nextIdx = currentMapTimestampIdx + 1;
|
||||
}
|
||||
setCurrentMapTimestampIdx(nextIdx);
|
||||
}, MAP_CYCLE_RATE);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
} else {
|
||||
setCurrentMapTimestampIdx(mapTimestamps.length - 1);
|
||||
}
|
||||
}
|
||||
}, [currentMapTimestampIdx, animateWeatherMap, mapTimestamps]);
|
||||
|
||||
if (!hasVal(latitude) || !hasVal(longitude) || !zoom || !mapApiKey) {
|
||||
return (
|
||||
<div className={`${styles.noMap} ${dark ? styles.dark : styles.light}`}>
|
||||
<div>Cannot retrieve map data.</div>
|
||||
<div>Did you enter an API key?</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const markerPosition = mapGeo ? [mapGeo.latitude, mapGeo.longitude] : null;
|
||||
|
||||
return (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
center={[latitude, longitude]}
|
||||
zoom={zoom}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
attributionControl={false}
|
||||
touchZoom={true}
|
||||
dragging={true}
|
||||
fadeAnimation={false}
|
||||
onClick={mapClickHandler}
|
||||
>
|
||||
<AttributionControl position={"bottomleft"} />
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.mapbox.com/feedback/">Mapbox</a>'
|
||||
url={`https://api.mapbox.com/styles/v1/mapbox/${
|
||||
dark ? "dark-v10" : "light-v10"
|
||||
}/tiles/{z}/{x}/{y}?access_token={apiKey}`}
|
||||
apiKey={mapApiKey}
|
||||
/>
|
||||
{mapTimestamp ? (
|
||||
<TileLayer
|
||||
attribution='<a href="https://www.rainviewer.com/">RainViewer</a>'
|
||||
url={`https://tilecache.rainviewer.com/v2/radar/${mapTimestamp}/{size}/{z}/{x}/{y}/{color}/{smooth}_{snow}.png`}
|
||||
opacity={0.3}
|
||||
size={512}
|
||||
color={6} // https://www.rainviewer.com/api.html#colorSchemes
|
||||
smooth={1}
|
||||
snow={1}
|
||||
/>
|
||||
) : null}
|
||||
{markerIsVisible && markerPosition ? (
|
||||
<Marker position={markerPosition} opacity={0.65}></Marker>
|
||||
) : null}
|
||||
</Map>
|
||||
);
|
||||
};
|
||||
|
||||
WeatherMap.propTypes = {
|
||||
zoom: PropTypes.number.isRequired,
|
||||
dark: PropTypes.bool,
|
||||
};
|
||||
|
||||
/**
|
||||
* Weather layer
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {String} props.layer
|
||||
* @param {String} props.weatherApiKey
|
||||
* @returns {JSX.Element} Weather layer
|
||||
*/
|
||||
const WeatherLayer = ({ layer, weatherApiKey }) => {
|
||||
return (
|
||||
<TileLayer
|
||||
attribution='&copy <a href="https://openweathermap.org/">OpenWeather</a>'
|
||||
url={`https://tile.openweathermap.org/map/${layer}/{z}/{x}/{y}.png?appid=${weatherApiKey}`}
|
||||
apiKey
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
WeatherLayer.propTypes = {
|
||||
layer: PropTypes.string.isRequired,
|
||||
weatherApiKey: PropTypes.string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if truthy, but returns true for 0
|
||||
*
|
||||
* @param {*} i
|
||||
* @returns {Boolean} If truthy or zero
|
||||
*/
|
||||
function hasVal(i) {
|
||||
return !!(i || i === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timestamps for weather map
|
||||
*
|
||||
* @returns {Promise} Promise of timestamps
|
||||
*/
|
||||
function getMapTimestamps() {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.get("https://api.rainviewer.com/public/maps.json")
|
||||
.then((res) => {
|
||||
resolve(res.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default WeatherMap;
|
||||
@@ -0,0 +1,18 @@
|
||||
.no-map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: Rubik;
|
||||
flex-direction: column;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color: #f6f6f444;
|
||||
}
|
||||
|
||||
.light {
|
||||
color: #3a393888;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useContext, useState, useEffect, useCallback } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import styles from "../styles.css";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
convertTemp,
|
||||
convertLength,
|
||||
convertSpeed,
|
||||
} from "~/services/conversions";
|
||||
import { fontColor } from "../common";
|
||||
|
||||
const createChartOptions = ({
|
||||
darkMode,
|
||||
tempUnit,
|
||||
speedUnit,
|
||||
lengthUnit,
|
||||
altMode,
|
||||
}) => {
|
||||
return {
|
||||
maintainAspectRatio: false,
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
responsive: true,
|
||||
hoverMode: "index",
|
||||
stacked: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: `7 Day ${
|
||||
altMode
|
||||
? `Wind Speed / Precipitation (${lengthUnit})`
|
||||
: `Temp / Precipitation`
|
||||
}`,
|
||||
fontColor: fontColor(darkMode),
|
||||
fontFamily: "Rubik, sans-serif",
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: {
|
||||
fontColor: fontColor(darkMode),
|
||||
fontFamily: "Rubik, sans-serif",
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
type: "linear",
|
||||
display: true,
|
||||
position: "left",
|
||||
id: "y-axis-1",
|
||||
ticks: {
|
||||
fontColor: fontColor(darkMode),
|
||||
fontFamily: "Rubik, sans-serif",
|
||||
maxTicksLimit: 5,
|
||||
callback: (val) => {
|
||||
return altMode
|
||||
? `${val} ${speedUnit === "mph" ? "mph" : "m/s"}`
|
||||
: `${val} ${tempUnit.toUpperCase()}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "linear",
|
||||
display: true,
|
||||
position: "right",
|
||||
id: "y-axis-2",
|
||||
ticks: {
|
||||
fontColor: fontColor(darkMode),
|
||||
fontFamily: "Rubik, sans-serif",
|
||||
maxTicksLimit: 5,
|
||||
suggestedMin: 0,
|
||||
callback: (val) => {
|
||||
return `${val}${altMode ? ` ${lengthUnit}` : "%"}`;
|
||||
},
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const chartColors = {
|
||||
blue: "rgba(63, 127, 191, 0.5)",
|
||||
gray: "rgba(127, 127, 127, 0.5)",
|
||||
gray2: "rgba(127, 127, 127, 0.3)",
|
||||
};
|
||||
|
||||
const mapChartData = ({ data, tempUnit, speedUnit, altMode, lengthUnit }) => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
labels: data.map((e) => {
|
||||
const date = new Date(e.observation_time.value);
|
||||
const adjustedTimestamp =
|
||||
date.getTime() + date.getTimezoneOffset() * 60 * 1000;
|
||||
return format(new Date(adjustedTimestamp), "EEEEE");
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
radius: 0,
|
||||
label: altMode ? "High Wind Speed" : "High Temp",
|
||||
data: data.map((e) => {
|
||||
return altMode
|
||||
? convertSpeed(e.wind_speed[1].max.value, speedUnit)
|
||||
: convertTemp(e.temp[1].max.value, tempUnit);
|
||||
}),
|
||||
yAxisID: "y-axis-1",
|
||||
borderColor: chartColors.gray,
|
||||
backgroundColor: chartColors.gray,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
radius: 0,
|
||||
label: altMode ? "Low Wind Speed" : "Low Temp",
|
||||
data: data.map((e) => {
|
||||
return altMode
|
||||
? convertSpeed(e.wind_speed[0].min.value, speedUnit)
|
||||
: convertTemp(e.temp[0].min.value, tempUnit);
|
||||
}),
|
||||
yAxisID: "y-axis-1",
|
||||
borderColor: chartColors.gray2,
|
||||
backgroundColor: chartColors.gray2,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
radius: 0,
|
||||
label: "Precipitation",
|
||||
data: data.map((e) => {
|
||||
return altMode
|
||||
? convertLength(e.precipitation[0].max.value, lengthUnit)
|
||||
: e.precipitation_probability.value;
|
||||
}),
|
||||
yAxisID: "y-axis-2",
|
||||
borderColor: chartColors.blue,
|
||||
backgroundColor: chartColors.blue,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Daily forecast chart
|
||||
*
|
||||
* @returns {JSX.Element} Hourly forecast chart
|
||||
*/
|
||||
const DailyChart = () => {
|
||||
const {
|
||||
dailyWeatherData,
|
||||
dailyWeatherDataErr,
|
||||
dailyWeatherDataErrMsg,
|
||||
tempUnit,
|
||||
darkMode,
|
||||
lengthUnit,
|
||||
speedUnit,
|
||||
} = useContext(AppContext);
|
||||
|
||||
const [altMode, setAltMode] = useState(false);
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [chartOptions, setChartOptions] = useState(null);
|
||||
|
||||
const setChartDataCallback = useCallback((e) => setChartData(e), []);
|
||||
const setChartOptionsCallback = useCallback((e) => setChartOptions(e), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dailyWeatherData) {
|
||||
setChartDataCallback(
|
||||
mapChartData({
|
||||
data: dailyWeatherData,
|
||||
tempUnit,
|
||||
lengthUnit,
|
||||
speedUnit,
|
||||
altMode,
|
||||
})
|
||||
);
|
||||
|
||||
setChartOptionsCallback(
|
||||
createChartOptions({
|
||||
tempUnit,
|
||||
darkMode,
|
||||
lengthUnit,
|
||||
speedUnit,
|
||||
altMode,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [
|
||||
dailyWeatherData,
|
||||
tempUnit,
|
||||
lengthUnit,
|
||||
altMode,
|
||||
speedUnit,
|
||||
darkMode,
|
||||
setChartOptionsCallback,
|
||||
setChartDataCallback,
|
||||
]);
|
||||
|
||||
if (chartData && chartOptions) {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => {
|
||||
setAltMode(!altMode);
|
||||
}}
|
||||
>
|
||||
<Line options={chartOptions} data={chartData} />
|
||||
</div>
|
||||
);
|
||||
} else if (dailyWeatherDataErr) {
|
||||
return (
|
||||
<div
|
||||
className={`${darkMode ? styles.dark : styles.light} ${
|
||||
styles.errContainer
|
||||
}`}
|
||||
>
|
||||
<div>Cannot get 7 day weather forecast</div>
|
||||
<div className={styles.message}>{dailyWeatherDataErrMsg}</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default DailyChart;
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useContext, useState, useEffect } from "react";
|
||||
import { AppContext } from "~/AppContext";
|
||||
import styles from "../styles.css";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
convertTemp,
|
||||
convertLength,
|
||||
convertSpeed,
|
||||
} from "~/services/conversions";
|
||||
import { fontColor } from "../common";
|
||||
|
||||
const chartOptions = ({
|
||||
darkMode,
|
||||
tempUnit,
|
||||
speedUnit,
|
||||
lengthUnit,
|
||||
altMode,
|
||||
}) => {
|
||||
return {
|
||||
maintainAspectRatio: false,
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
responsive: true,
|
||||
hoverMode: "index",
|
||||
stacked: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: `24 Hour ${
|
||||
altMode
|
||||
? `Wind Speed / Precipitation (${lengthUnit})`
|
||||
: `Temp / Precipitation`
|
||||
}`,
|
||||
fontColor: fontColor(darkMode),
|
||||
fontFamily: "Rubik, sans-serif",
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: {
|
||||
fontColor: fontColor(darkMode),
|
||||
fontFamily: "Rubik, sans-serif",
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
type: "linear",
|
||||
display: true,
|
||||
position: "left",
|
||||
id: "y-axis-1",
|
||||
ticks: {
|
||||
fontColor: fontColor(darkMode),
|
||||
fontFamily: "Rubik, sans-serif",
|
||||
maxTicksLimit: 5,
|
||||
callback: (val) => {
|
||||
return altMode
|
||||
? `${val} ${speedUnit === "mph" ? "mph" : "m/s"}`
|
||||
: `${val} ${tempUnit.toUpperCase()}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "linear",
|
||||
display: true,
|
||||
position: "right",
|
||||
id: "y-axis-2",
|
||||
ticks: {
|
||||
fontColor: fontColor(darkMode),
|
||||
fontFamily: "Rubik, sans-serif",
|
||||
maxTicksLimit: 5,
|
||||
suggestedMin: 0,
|
||||
callback: (val) => {
|
||||
return `${val}${altMode ? ` ${lengthUnit}` : "%"}`;
|
||||
},
|
||||
},
|
||||
gridLines: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const chartColors = {
|
||||
blue: "rgba(63, 127, 191, 0.5)",
|
||||
gray: "rgba(127, 127, 127, 0.5)",
|
||||
};
|
||||
|
||||
const mapChartData = ({
|
||||
data,
|
||||
tempUnit,
|
||||
speedUnit,
|
||||
clockTime,
|
||||
altMode,
|
||||
lengthUnit,
|
||||
}) => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
labels: data.map((e) => {
|
||||
if (clockTime === "12") {
|
||||
return `${format(new Date(e.observation_time.value), "h")}${format(
|
||||
new Date(e.observation_time.value),
|
||||
"aaaaa"
|
||||
)}`;
|
||||
} else {
|
||||
return `${format(new Date(e.observation_time.value), "HH")}`;
|
||||
}
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
radius: 0,
|
||||
label: altMode ? "Wind Speed" : "Temp",
|
||||
data: data.map((e) => {
|
||||
return altMode
|
||||
? convertSpeed(e.wind_speed.value, speedUnit)
|
||||
: convertTemp(e.temp.value, tempUnit);
|
||||
}),
|
||||
yAxisID: "y-axis-1",
|
||||
borderColor: chartColors.gray,
|
||||
backgroundColor: chartColors.gray,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
radius: 0,
|
||||
label: "Precipitation",
|
||||
data: data.map((e) => {
|
||||
return altMode
|
||||
? convertLength(e.precipitation.value, lengthUnit)
|
||||
: e.precipitation_probability.value;
|
||||
}),
|
||||
yAxisID: "y-axis-2",
|
||||
borderColor: chartColors.blue,
|
||||
backgroundColor: chartColors.blue,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hourly forecast chart
|
||||
*
|
||||
* @returns {JSX.Element} Hourly forecast chart
|
||||
*/
|
||||
const HourlyChart = () => {
|
||||
const {
|
||||
hourlyWeatherData,
|
||||
tempUnit,
|
||||
darkMode,
|
||||
clockTime,
|
||||
lengthUnit,
|
||||
speedUnit,
|
||||
hourlyWeatherDataErr,
|
||||
hourlyWeatherDataErrMsg,
|
||||
} = useContext(AppContext);
|
||||
|
||||
const [altMode, setAltMode] = useState(false);
|
||||
const [chartData, setChartData] = useState(null);
|
||||
useEffect(() => {
|
||||
if (hourlyWeatherData) {
|
||||
setChartData(
|
||||
mapChartData({
|
||||
data: hourlyWeatherData,
|
||||
tempUnit,
|
||||
clockTime,
|
||||
lengthUnit,
|
||||
speedUnit,
|
||||
altMode,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [hourlyWeatherData, tempUnit, clockTime, lengthUnit, altMode, speedUnit]);
|
||||
|
||||
if (chartData) {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => {
|
||||
setAltMode(!altMode);
|
||||
}}
|
||||
>
|
||||
<Line
|
||||
data={chartData}
|
||||
options={chartOptions({
|
||||
tempUnit,
|
||||
darkMode,
|
||||
lengthUnit,
|
||||
speedUnit,
|
||||
altMode,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}else if (hourlyWeatherDataErr) {
|
||||
return (
|
||||
<div
|
||||
className={`${darkMode ? styles.dark : styles.light} ${
|
||||
styles.errContainer
|
||||
}`}
|
||||
>
|
||||
<div>Cannot get 24 hour weather forecast</div>
|
||||
<div className={styles.message}>{hourlyWeatherDataErrMsg}</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default HourlyChart;
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Returns the appropriate font color depending on whether or not dark mode is enabled or not
|
||||
* see `InfoPanel/styles.css`
|
||||
*
|
||||
* @param {Boolean} darkMode
|
||||
* @returns {String} font color
|
||||
*/
|
||||
export const fontColor = (darkMode) => {
|
||||
return darkMode ? "rgba(246, 246, 244, 0.8)" : "rgba(58, 57, 56, 0.8)";
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
.container {
|
||||
width: 255px;
|
||||
height: 115px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color: #f6f6f444;
|
||||
}
|
||||
|
||||
.light {
|
||||
color: #3a393888;
|
||||
}
|
||||
|
||||
.err-container {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.err-container .message{
|
||||
margin-top: 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
|
||||
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
|
||||
crossorigin=""
|
||||
/>
|
||||
<script
|
||||
src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"
|
||||
integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew=="
|
||||
crossorigin=""
|
||||
></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Rubik:wght@300&display=swap" rel="stylesheet">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pi Weather Station</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import App from "~/components/App";
|
||||
import { AppContextProvider } from "~/AppContext";
|
||||
import "~/styles";
|
||||
|
||||
ReactDOM.render(
|
||||
<AppContextProvider>
|
||||
<App />
|
||||
</AppContextProvider>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Convert celsius to fahrenheit
|
||||
*
|
||||
* @param {Number} c degrees celsius
|
||||
* @returns {Number} degrees fahrenheit
|
||||
* @private
|
||||
*/
|
||||
const cToF = (c) => {
|
||||
return c * (9/5) + 32;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert temperature to celsius if needed
|
||||
*
|
||||
* @param {Number} c temperature in celsius
|
||||
* @param {String} units `f` for fahrenheit, `c` for celsius
|
||||
* @returns {Number} converted temperature
|
||||
*/
|
||||
export const convertTemp = (c, units) => {
|
||||
if (!c && c !== 0) {
|
||||
console.log("missing input temp!");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (units && units.toLowerCase() === "c") {
|
||||
return parseInt(c);
|
||||
} else if (units && units.toLowerCase() === "f") {
|
||||
return parseInt(cToF(c));
|
||||
} else {
|
||||
console.log("Missing / invalid target unit!", units);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert m/s to mph
|
||||
*
|
||||
* @param {Number} ms
|
||||
* @returns {Number} mph
|
||||
* @private
|
||||
*/
|
||||
const msToMph = (ms) => {
|
||||
return ms / 0.44704;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts speed
|
||||
*
|
||||
* @param {Number} speed
|
||||
* @param {String} units mph or ms for m/s
|
||||
* @returns {Number} converted speed
|
||||
*/
|
||||
export const convertSpeed = (speed, units) => {
|
||||
if (!speed && speed !== 0) {
|
||||
console.log("missing input speed");
|
||||
return null;
|
||||
}
|
||||
if (units && units.toLowerCase() === "mph") {
|
||||
return parseInt(msToMph(speed));
|
||||
} else if (units && units.toLowerCase() === "ms") {
|
||||
return parseInt(speed);
|
||||
} else {
|
||||
console.log("Missing / invalid target unit!", units);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts mm to inches
|
||||
*
|
||||
* @param {Number} mm
|
||||
* @returns {Number} inches
|
||||
* @private
|
||||
*/
|
||||
const mmToIn = (mm) => {
|
||||
return mm / 25.4;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert length
|
||||
*
|
||||
* @param {Number} len mm
|
||||
* @param {String} units in or mm
|
||||
* @returns {Number} converted length
|
||||
*/
|
||||
export const convertLength = (len, units) => {
|
||||
if (!len && len !== 0) {
|
||||
console.log("missing input length!");
|
||||
return null;
|
||||
}
|
||||
if (units && units.toLowerCase() === "in") {
|
||||
return parseInt(mmToIn(len) * 100) / 100;
|
||||
} else if (units && units.toLowerCase() === "mm") {
|
||||
return len;
|
||||
} else {
|
||||
console.log("Missing / invalid target unit!", units);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Capitalizes the first letter of a string
|
||||
*
|
||||
* @param {String} string
|
||||
* @returns {String} String with first letter capitalized
|
||||
*/
|
||||
export const capitalizeFirstLetter = (string) => {
|
||||
if (string && typeof string === "string") {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
} else {
|
||||
return string;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Gets coordinates from `navigator.geolocation` (currently not supported on aspbian Chromium)
|
||||
*
|
||||
* @returns {Promise} coordinates
|
||||
*/
|
||||
export function getCoordsFromBrowser() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition((pos) => {
|
||||
if (!pos || (pos && !pos.coords)) {
|
||||
reject("Could not get current position");
|
||||
} else {
|
||||
resolve(pos.coords);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reject(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets coordinates from an external API
|
||||
*
|
||||
* @returns {Promise} coordinates
|
||||
*/
|
||||
export function getCoordsFromApi() {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.get("/geolocation")
|
||||
.then((res) => {
|
||||
const { latitude, longitude } = res.data;
|
||||
if (
|
||||
!latitude ||
|
||||
(!latitude && latitude !== 0) ||
|
||||
!longitude ||
|
||||
(!longitude && longitude !== 0)
|
||||
) {
|
||||
reject("Could not get lan/lon");
|
||||
} else {
|
||||
resolve({
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import axios from "axios";
|
||||
|
||||
let prevCoords = {};
|
||||
let prevResult = null;
|
||||
|
||||
/**
|
||||
* Reverse geocode lookup
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Number} params.lat latitude
|
||||
* @param {Number} params.lon longitude
|
||||
* @param {String} params.apiKey api key
|
||||
* @returns {Promise} API result
|
||||
*/
|
||||
function reverseGeocode({ lat, lon, apiKey }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (prevCoords.lat === lat && prevCoords.lon === lon) {
|
||||
return resolve(prevResult);
|
||||
}
|
||||
|
||||
axios
|
||||
.get(
|
||||
`https://us1.locationiq.com/v1/reverse.php?key=${apiKey}&lat=${lat}&lon=${lon}&format=json`
|
||||
)
|
||||
.then((res) => {
|
||||
prevCoords = { lat, lon };
|
||||
prevResult = res.data;
|
||||
resolve(res.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// return reject(); //tempoarily disabling
|
||||
// axios
|
||||
// .get(
|
||||
// `https://api.bigdatacloud.net/data/reverse-geocode?latitude=${lat}&longitude=${lon}&localityLanguage=en`
|
||||
// )
|
||||
// .then((res) => {
|
||||
// prevCoords = { lat, lon };
|
||||
// prevResult = res.data;
|
||||
// resolve(res.data);
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// reject(err);
|
||||
// });
|
||||
});
|
||||
}
|
||||
|
||||
export default reverseGeocode;
|
||||
@@ -0,0 +1,19 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Gets settings
|
||||
*
|
||||
* @returns {Promise} resolves settings
|
||||
*/
|
||||
export function getSettings() {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.get("/settings")
|
||||
.then((res) => {
|
||||
resolve(res.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import("!style-loader!css-loader!~/styles/main.css");
|
||||
@@ -0,0 +1,4 @@
|
||||
body {
|
||||
font-family: Rubik;
|
||||
margin: 0px;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const HtmlWebPackPlugin = require("html-webpack-plugin");
|
||||
|
||||
module.exports = (env) => {
|
||||
const PRODUCTION = !!(env && env.BUILD_PRODUCTION);
|
||||
process.env.NODE_ENV = PRODUCTION ? "production" : "development";
|
||||
|
||||
const definePlugin = new webpack.DefinePlugin({
|
||||
__PRODUCTION__: JSON.stringify(
|
||||
JSON.parse(env ? env.BUILD_PRODUCTION || "false" : "false")
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: "bundle.min.js",
|
||||
publicPath: "/",
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
enforce: "pre",
|
||||
test: /\.js$/,
|
||||
include: [path.resolve(__dirname, "src")],
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "eslint-loader"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
include: [path.resolve(__dirname, "src")],
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
use: [
|
||||
{
|
||||
loader: "html-loader",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
"style-loader",
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
sourceMap: !PRODUCTION,
|
||||
modules: {
|
||||
exportLocalsConvention: "camelCase",
|
||||
localIdentName: "[path][name]__[local]--[hash:base64:5]",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ loader: "postcss-loader", options: { sourceMap: !PRODUCTION } },
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(png|svg|jpg|gif)$/,
|
||||
use: [
|
||||
{
|
||||
loader: "url-loader",
|
||||
options: {
|
||||
limit: 8192,
|
||||
sourceMap: !PRODUCTION,
|
||||
name: PRODUCTION
|
||||
? "[contenthash].[ext]"
|
||||
: "[path][name].[ext]?[contenthash]"
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)$/,
|
||||
use: [
|
||||
{
|
||||
loader: "file-loader",
|
||||
options: {
|
||||
sourceMap: !PRODUCTION,
|
||||
name: PRODUCTION
|
||||
? "[contenthash].[ext]"
|
||||
: "[path][name].[ext]?[contenthash]"
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".js", ".scss"],
|
||||
alias: {
|
||||
["~"]: path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebPackPlugin({
|
||||
template: "./src/index.html",
|
||||
filename: "./index.html",
|
||||
}),
|
||||
definePlugin,
|
||||
],
|
||||
watchOptions: {
|
||||
ignored: /node_modules/
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user