1
0

Initial commit

This commit is contained in:
Eric Lewin
2020-08-23 21:48:06 -05:00
commit 162ce47bc6
57 changed files with 12096 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
+81
View File
@@ -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"
}
}
}
}
+1
View File
@@ -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}}]);
+318
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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>
+7073
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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"
}
}
+11
View File
@@ -0,0 +1,11 @@
const postcssPresetEnv = require("postcss-preset-env");
const autoprefixer = require("autoprefixer");
const config = {
plugins: [
autoprefixer,
postcssPresetEnv()
]
};
module.exports = config;
+587
View File
@@ -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,
};
+56
View File
@@ -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;
+12
View File
@@ -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;
}
+47
View File
@@ -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;
}
+39
View File
@@ -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;
+16
View File
@@ -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;
}
+33
View File
@@ -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%);
}
+425
View File
@@ -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,
};
+144
View File
@@ -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;
}
+58
View File
@@ -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;
+46
View File
@@ -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);
}
}
+43
View File
@@ -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;
}
+185
View File
@@ -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;
}
+226
View File
@@ -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='&amp;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;
}
+23
View File
@@ -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>
+12
View File
@@ -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")
);
+99
View File
@@ -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;
}
};
+13
View File
@@ -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;
}
};
+53
View File
@@ -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);
});
});
}
+50
View File
@@ -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;
+19
View File
@@ -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);
});
});
}
+1
View File
@@ -0,0 +1 @@
import("!style-loader!css-loader!~/styles/main.css");
+4
View File
@@ -0,0 +1,4 @@
body {
font-family: Rubik;
margin: 0px;
}
+113
View File
@@ -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/
}
};
};