├── .eslintrc.js ├── .firebaserc ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── LICENSE.md ├── README.md ├── _config.yml ├── config ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── dev-server ├── mock │ ├── covid-19.json │ ├── location.json │ ├── ny-weather-si.json │ ├── ny-weather-us.json │ ├── weather-si-by-date.json │ ├── weather-si.json │ ├── weather-us-by-date.json │ └── weather-us.json ├── package-lock.json ├── package.json └── server.js ├── firebase.json ├── functions ├── apikey.js ├── index.js ├── package-lock.json └── package.json ├── package-lock.json ├── package.json ├── src ├── api.ts ├── assets │ ├── covid_page.jpeg │ ├── favicon.ico │ ├── main_page.jpeg │ ├── mobile_page.jpeg │ └── weather-icons │ │ ├── css │ │ ├── weather-icons-wind.css │ │ └── weather-icons.min.css │ │ └── font │ │ ├── weathericons-regular-webfont.eot │ │ ├── weathericons-regular-webfont.svg │ │ ├── weathericons-regular-webfont.ttf │ │ ├── weathericons-regular-webfont.woff │ │ └── weathericons-regular-webfont.woff2 ├── components │ ├── chart-config.ts │ ├── current-weather.tsx │ ├── daily-forecast.tsx │ ├── hourly-forecast.tsx │ ├── icon │ │ ├── moon-icon.tsx │ │ ├── weather-icon.tsx │ │ └── wind-icon.tsx │ ├── nav-bar.tsx │ └── weather-search.tsx ├── constants │ ├── api-key.ts │ ├── coordinates.ts │ ├── message.ts │ ├── types.ts │ └── weather-condition.ts ├── covid-19 │ ├── chart-config.ts │ └── covid-19.tsx ├── css │ └── index.css ├── d3-demo │ ├── d3-demo-app.tsx │ ├── d3-demo-network.tsx │ ├── d3-force.css │ ├── gauge.ts │ ├── mock │ │ ├── app-traffic.json │ │ └── network-traffic.json │ ├── tool-tip.tsx │ └── traffic.ts ├── index.html ├── index.tsx ├── store │ ├── actions.ts │ ├── index.ts │ └── reducers.ts ├── typings.d.ts ├── utils.ts └── views │ ├── about.tsx │ ├── app.tsx │ ├── weather-main.tsx │ └── weather-map.tsx └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaFeatures: { 5 | jsx: true, 6 | }, 7 | ecmaVersion: 'latest', 8 | sourceType: 'module', 9 | }, 10 | plugins: ['react', '@typescript-eslint', 'prettier'], 11 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:react/recommended', 'prettier'], 12 | settings: { 13 | react: { 14 | version: 'detect', 15 | }, 16 | }, 17 | rules: { 18 | 'prettier/prettier': [ 19 | 'error', 20 | { 21 | endOfLine: 'auto', 22 | }, 23 | ], 24 | '@typescript-eslint/explicit-function-return-type': 'off', 25 | '@typescript-eslint/explicit-member-accessibility': 'off', 26 | '@typescript-eslint/explicit-module-boundary-types': 'off', 27 | '@typescript-eslint/no-explicit-any': 'off', 28 | '@typescript-eslint/no-var-requires': 'off', 29 | 'react/prop-types': 'off', 30 | 'prefer-spread': 'off', 31 | 'no-undef': 'off', 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "reactjs-weather" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | *.iml 3 | .firebase/* 4 | node_modules/* 5 | */node_modules/* 6 | dist/* 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Laurence Ho 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ☀️🌤⛈❄️ A weather web application using React, Redux, TypeScript, Webpack4, Ant Design, ECharts and firebase. 2 | 3 | ## Table of Contents 4 | - [Introduction](#introduction) 5 | - [Prerequisites](#prerequisites) 6 | - [Local development](#local-development) 7 | - [Write Your Own Google Cloud Functions](#write-your-own-google-cloud-functions) 8 | - [Deploy to Firebase](#deploy-to-firebase) 9 | - [Webpack, Reactjs and TypeScript](#webpack-reactjs-and-typescript) 10 | - [TypeScript, Eslint and Prettier](#typescript-eslint-and-prettier) 11 | - [Ant Design](#ant-design) 12 | - [ECharts](#echarts) 13 | - [Windy API](#windy-api) 14 | - [Mapbox](#mapbox) 15 | 16 | ## Introduction 17 | This project demonstrates how to use ReactJS, Redux, TypeScript, Webpack4, [Ant Design](https://ant.design/docs/react/introduce), 18 | D3v5, [ECharts](https://echarts.apache.org/index.html) and [Mapbox](https://www.mapbox.com). 19 | It is also including two kinds of D3 force simulation demonstrations along with gauge, which is based on 20 | my personal interest and previous project. 21 | 22 | Furthermore, this project also demonstrates how to deploy the web app to Google firebase, and use Google 23 | cloud function serverless platform with React frontend app. 24 | 25 | ## Prerequisites 26 | 1. The latest version of Nodejs and npm need to be installed 27 | 2. Google Geocoding API Key 28 | 3. Google Firebase project 29 | 4. Dark Sky weather API key 30 | 5. Windy API key 31 | 6. Mapbox API key 32 | 33 | [NOTE] Since I already placed protection to all keys, you cannot use my own key. You have to apply for your own API key. 34 | 35 | ## Local development 36 | * Clone the repo: `git clone https://github.com/LaurenceHo/react-weather-app.git` 37 | * Install npm package: `npm i` 38 | * If you want to start client using webpack dev server: `npm run start`, and visit in your browser: `http://localhost:8080`. 39 | * Because we don't want to use Google Cloud Function when we do local development, we write simple NodeJs Express server for 40 | returning mock JSON response. Move to [dev-server](dev-server) folder `cd dev-server`, and run `npm i` to install the npm modules. 41 | After that, run `npm start` to start NodeJs Express Server, and we can move forward to frontend development. 42 | * Put your [Windy API key](https://api.windy.com/) and Mapbox API key into [`./src/constants/api-key.ts`](src/constants/api-key.ts) 43 | * For bundling frontend code run `npm run build` 44 | 45 | [Back to the top↑](#table-of-contents) 46 | 47 | ## Write Your Own Google Cloud Functions: 48 | Please visit: [Google Cloud Functions](https://firebase.google.com/docs/functions) for more detail 49 | 50 | ## Deploy to Firebase 51 | * Put your Google Geocoding API Key and [dark sky API key](https://darksky.net/dev) into [`./functions/apiKey.js`](./functions/apikey.js). 52 | * Change the Google Cloud Function URL `CLOUD_FUNCTION_URL` in [api.ts](./src/api.ts) to your own Google Cloud Function URL. 53 | * Visit `https://console.firebase.google.com` to create a new project 54 | * Check [here](https://firebase.google.com/docs/hosting/quickstart) for further detail about how to deploy your app to Firebase 55 | * If you want to deploy the whole project, run `npm run firebase-deploy` 56 | * If you want to deploy the cloud functions only, run `npm run deploy-functions` 57 | 58 | [Back to the top↑](#table-of-contents) 59 | 60 | ## Webpack, Reactjs and TypeScript 61 | Although there is `create-react-app` toolkit to create ReactJS project very easily and quickly, I personally love creating 62 | the ReactJS project by using webpack from the beginning. Also configure the project a bit by bit manually. It helps me to 63 | understand how these things work together. 64 | 65 | When using webpack, we need a bunch of loaders to parse the specific file types. For example, `ts-loader` for Typescript, 66 | `css-loader` for css files, `file-loader` for pictures...etc. 67 | 68 | Before starting using webpack with TypeScript, we at least need to install the following plugins: 69 | `npm i -D css-loader file-loader html-webpack-plugin source-map-loader style-loader ts-loader typescript webpack webpack-cli` 70 | 71 | In the [webpack.common.js](config/webpack.common.js) file, setup the entry point at first: 72 | ``` 73 | module.exports = { 74 | entry: ['./src/index.tsx'], 75 | output: { 76 | path: path.resolve(__dirname, '../dist'), 77 | filename: '[name].bundle.js', 78 | }, 79 | resolve: { 80 | modules: [path.join(__dirname, '../dist'), 'node_modules'], 81 | extensions: ['.ts', '.tsx', '.js', '.json'], 82 | }, 83 | } 84 | ``` 85 | 86 | Then setup the loaders: 87 | ``` 88 | { 89 | module: { 90 | rules: [ 91 | { 92 | test: /\.tsx?$/, 93 | loader: 'ts-loader' 94 | }, 95 | { 96 | enforce: 'pre', 97 | test: /\.js$/, 98 | exclude: /(node_modules)/, 99 | loader: 'source-map-loader' 100 | }, 101 | { 102 | test: /\.css$/, 103 | use: [ 'style-loader', 'css-loader' ] 104 | }, 105 | { 106 | test: /\.(jpe?g|png|gif|ico)$/i, 107 | use: [ 'file-loader' ] 108 | }, 109 | { 110 | test: /\.(ttf|eot|svg|woff|woff2)(\?.+)?$/, 111 | loader: 'file-loader?name=[hash:12].[ext]', 112 | }, 113 | ] 114 | } 115 | } 116 | ``` 117 | 118 | If we want to extract CSS into separate files, we need to install `mini-css-extract-plugin`, and replace style loader: 119 | ``` 120 | { 121 | test: /\.css$/, 122 | use: [ 123 | { 124 | loader: MiniCssExtractPlugin.loader, 125 | options: { 126 | hmr: process.env.NODE_ENV === 'development', 127 | }, 128 | }, 129 | 'css-loader', 130 | ], 131 | }, 132 | ``` 133 | 134 | Then setup the plugins: 135 | ``` 136 | { 137 | plugins: [ 138 | new HtmlWebpackPlugin({ 139 | template: 'src/index.html' 140 | }), 141 | new CopyWebpackPlugin([ 142 | { 143 | from: 'src/assets', 144 | to: 'assets' 145 | } 146 | ]) 147 | ] 148 | } 149 | ``` 150 | 151 | [Back to the top↑](#table-of-contents) 152 | 153 | ### Webpack Dev Server and Hot Module Replacement 154 | When we do frontend development, we want the browser reloading the content automatically when we make changes. To achieve this, 155 | we need `WebpackDevServer`. So let's install something: `npm i -D webpack-dev-server webpack-merge`. 156 | In the [webpack.dev.js](config/webpack.dev.js), since we want to merge the common setting, we need `webpack-merge` library along 157 | with `WebpackDevServer` for browser reloading: 158 | ``` 159 | const merge = require('webpack-merge'); 160 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 161 | const DefinePlugin = require('webpack/lib/DefinePlugin'); 162 | const common = require('./webpack.common.js'); 163 | 164 | module.exports = merge(common, { 165 | mode: 'development', 166 | devtool: 'inline-source-map', 167 | devServer: { 168 | contentBase: '../dist', 169 | historyApiFallback: true, 170 | hot: true, 171 | inline: true, 172 | }, 173 | plugins: [ 174 | new DefinePlugin({ 175 | 'process.env': { 176 | NODE_ENV: JSON.stringify('development'), 177 | }, 178 | }), 179 | new MiniCssExtractPlugin({ 180 | filename: '[name].css', 181 | chunkFilename: '[id].css', 182 | }), 183 | ], 184 | }); 185 | ``` 186 | 187 | And place `start` script in the package.json for starting the webpack dev server: 188 | ``` 189 | "scripts": { 190 | "start": "webpack-dev-server --config ./config/webpack.dev.js --progress --profile --watch --open" 191 | } 192 | ``` 193 | 194 | [Back to the top↑](#table-of-contents) 195 | 196 | ### Optimising Application Bundle Size 197 | Finally, let's look into bundling code for production deployment. Since we want to reduce the bundle file size for production 198 | as much as possible, we need to install some plugins for helping us: `npm i -D terser-webpack-plugin`. We also need 199 | `CleanWebpackPlugin` to clean the build folder (dist) before building code, as well as `MiniCssExtractPlugin` for extracting 200 | CSS files. Therefore, in the [webpack.prod.js](config/webpack.prod.js), we use above plugins to bundle code: 201 | ``` 202 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 203 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 204 | const TerserPlugin = require('terser-webpack-plugin'); 205 | const merge = require('webpack-merge'); 206 | const DefinePlugin = require('webpack/lib/DefinePlugin'); 207 | const common = require('./webpack.common.js'); 208 | 209 | module.exports = merge(common, { 210 | mode: 'production', 211 | plugins: [ 212 | new DefinePlugin({ 213 | 'process.env': { 214 | NODE_ENV: JSON.stringify('production'), 215 | }, 216 | }), 217 | new MiniCssExtractPlugin({ 218 | filename: '[name].[hash].css', 219 | chunkFilename: '[id].[hash].css', 220 | }), 221 | new CleanWebpackPlugin(), 222 | ], 223 | optimization: { 224 | splitChunks: { 225 | cacheGroups: { 226 | commons: { 227 | test: /[\\/]node_modules[\\/]/, 228 | name: 'vendors', 229 | chunks: 'all', 230 | }, 231 | styles: { 232 | name: 'styles', 233 | test: /\.css$/, 234 | chunks: 'all', 235 | enforce: true, 236 | }, 237 | }, 238 | }, 239 | minimize: true, 240 | minimizer: [ 241 | new TerserPlugin({ 242 | cache: true, 243 | parallel: true, 244 | terserOptions: { 245 | output: { 246 | comments: false, 247 | }, 248 | }, 249 | }), 250 | ], 251 | }, 252 | }); 253 | ``` 254 | 255 | [Back to the top↑](#table-of-contents) 256 | 257 | ## TypeScript, Eslint and Prettier 258 | I use [eslint](https://eslint.org/) + [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint) + 259 | [eslint-plugin-react](https://github.com/yannickcr/eslint-plugin-react) + [prettier](https://prettier.io/) for linting project. 260 | Run `npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-react prettier` 261 | 262 | ### TypeScript ESLint usage 263 | Add `@typescript-eslint/parser` to the `parser` field and `@typescript-eslint` to the `plugins` section of [.eslintrc.json](.eslintrc.json) configuration file: 264 | ``` 265 | { 266 | "parser": "@typescript-eslint/parser", 267 | "plugins": [ 268 | "@typescript-eslint" 269 | ], 270 | } 271 | ``` 272 | 273 | Because we use ReactJS, we also need to set the `parserOptions` property: 274 | ``` 275 | { 276 | "parserOptions": { 277 | "ecmaFeatures": { 278 | "jsx": true 279 | }, 280 | "ecmaVersion": "latest", 281 | "sourceType": "module", 282 | } 283 | } 284 | ``` 285 | 286 | [Back to the top↑](#table-of-contents) 287 | 288 | ### eslint-plugin-react usage 289 | Append `react` to the `plugins` section: 290 | ``` 291 | { 292 | "parser": "@typescript-eslint/parser", 293 | "plugins": [ 294 | "react", 295 | "@typescript-eslint" 296 | ], 297 | } 298 | ``` 299 | 300 | Indicate the ReactJS version, add `settings` property: 301 | ``` 302 | { 303 | "settings": { 304 | "react": { 305 | "version": "detect" 306 | } 307 | } 308 | } 309 | ``` 310 | 311 | [Back to the top↑](#table-of-contents) 312 | 313 | ### Prettier Integrate with Eslint Using `eslint-plugin-prettier` 314 | Append `prettier` into `plugins` section: 315 | ``` 316 | { 317 | "parser": "@typescript-eslint/parser", 318 | "plugins": [ 319 | "react", 320 | "@typescript-eslint", 321 | "prettier" 322 | ] 323 | } 324 | ``` 325 | 326 | Turn off the eslint formatting rule: 327 | ``` 328 | { 329 | "extends": [ 330 | "plugin:@typescript-eslint/recommended", 331 | "plugin:react/recommended", 332 | "prettier" 333 | ], 334 | "rules": { 335 | "prettier/prettier": "error" 336 | } 337 | } 338 | ``` 339 | 340 | Append the prettier configuration in the package.json 341 | ``` 342 | "prettier": { 343 | "jsxSingleQuote": true, 344 | "jsxBracketSameLine": true, 345 | "printWidth": 120, 346 | "singleQuote": true, 347 | "trailingComma": "es5", 348 | "useTabs": false 349 | } 350 | ``` 351 | 352 | [Back to the top↑](#table-of-contents) 353 | 354 | ## Ant Design 355 | ### Getting Started 356 | Ant Design React is dedicated to providing a good development experience for programmers. Make sure that you have installed 357 | Node.js(> 8.0.0) correctly. Then run `npm i antd` 358 | 359 | ### Usage 360 | Ant design provides abundant UI components, which means the library size is quite large. I usually only import the 361 | component I needed rather than import everything. 362 | Import CSS files in the `index.tsx`: 363 | ``` 364 | import 'antd/es/col/style/css'; 365 | import 'antd/es/row/style/css'; 366 | ``` 367 | 368 | Import necessary packages e.g in the `current-weather.tsx`: 369 | ``` 370 | import Col from 'antd/es/col'; 371 | import Row from 'antd/es/row'; 372 | 373 | export class CurrentWeather extends React.Component { 374 | render() { 375 | const { weather, location, timezone, filter } = this.props; 376 | 377 | return ( 378 |
379 | 380 | 381 | ...... 382 | 383 | 384 |
385 | ); 386 | } 387 | } 388 | ``` 389 | 390 | [Back to the top↑](#table-of-contents) 391 | 392 | ### Customise theme 393 | If we want customise ant design theme, make sure we install `less-loader` and `style-loader` at first. In the [webpack.common.js](config/webpack.common.js), 394 | add `less-loader` for parsing *.less files along with other loaders: 395 | ``` 396 | module.exports = { 397 | rules: [ 398 | { 399 | test: /\.less$/, 400 | use: [ 401 | { 402 | loader: 'style-loader', 403 | }, 404 | { 405 | loader: 'css-loader', // translates CSS into CommonJS 406 | }, 407 | { 408 | loader: 'less-loader', // compiles Less to CSS 409 | options: { 410 | modifyVars: { 411 | 'primary-color': '#1DA57A', 412 | 'link-color': '#1DA57A', 413 | 'border-radius-base': '2px', 414 | // or 415 | 'ant-theme-file': "~'your-less-file-path.less'", // Override with less file 416 | }, 417 | javascriptEnabled: true, 418 | }, 419 | } 420 | ], 421 | }, 422 | // ...other rules 423 | ], 424 | // ...other config 425 | } 426 | ``` 427 | We can look at [here](https://ant.design/docs/react/customize-theme) for getting the further detail. 428 | 429 | [Back to the top↑](#table-of-contents) 430 | 431 | ### TypeScript 432 | * Don't use `@types/antd`, as antd provides a built-in ts definition already. 433 | 434 | ## ECharts 435 | ### Getting Started 436 | `npm i echarts -S` and `npm i -D @types/echarts` 437 | 438 | ### Usage 439 | Keep in mind, we only import the packages on demand. So in our TypeScript files, we import ECharts components as below: 440 | ``` 441 | // Import the main module of echarts. 442 | import * as echarts from 'echarts/lib/echarts'; 443 | // Import line chart. 444 | import 'echarts/lib/chart/line'; 445 | // Import components of tooltip, title and toolbox. 446 | import 'echarts/lib/component/tooltip'; 447 | import 'echarts/lib/component/title'; 448 | import 'echarts/lib/component/toolbox'; 449 | ``` 450 | 451 | [Back to the top↑](#table-of-contents) 452 | 453 | ## Windy API 454 | Since I put the protection for my Windy API, only the allowed domain name can use this API key. Windy API is free, 455 | please feel free to apply for a new one for yourself. 456 | 457 | ### Usage 458 | There is no npm package for installing Windy API, so we have to import source in [index.html](./src/index.html) 459 | ``` 460 | 461 | 462 | 463 | 464 | ``` 465 | Windy API v4 was based on Leaflet 1.4, so import leaflet by this way is very important. 466 | How to make these 2 JavaScript 3rd party libraries working in TypeScript? We need to declare the definition in [TypeScript 467 | Declaration File](./src/typings.d.ts). 468 | ``` 469 | declare const windyInit: any; 470 | declare const L: any; 471 | ``` 472 | After that, we can use `windyInit` and `L` these 2 parameters directly without importing module into TypeScript file. 473 | In [`weather-map.tsx`](src/views/weather-map.tsx), when we init Windy API, the basic usage it's very simple: 474 | ``` 475 | export const WeatherMap: React.FC = () => { 476 | const renderMap = () => { 477 | const options = { 478 | // Required: API key 479 | key: 'PsLAtXpsPTZexBwUkO7Mx5I', 480 | // Put additional console output 481 | verbose: true, 482 | // Optional: Initial state of the map 483 | lat: 50.4, 484 | lon: 14.3, 485 | zoom: 5, 486 | } 487 | windyInit(options, (windyAPI: any) => { 488 | const { map } = windyAPI; 489 | L.popup() 490 | .setLatLng([50.4, 14.3]) 491 | .setContent("Hello World") 492 | .openOn( map ); 493 | }); 494 | } 495 | 496 | useEffect(() => { 497 | renderMap(); 498 | }, []); 499 | 500 | render() { 501 | return (
); 502 | } 503 | } 504 | ``` 505 | [Back to the top↑](#table-of-contents) 506 | 507 | ## Mapbox 508 | Before starting using Mapbox, get an API for your project. Please go to 509 | [Mapbox](https://www.mapbox.com/maps/) for further detail. For JavaScript bundler installation, you can go to [here](https://docs.mapbox.com/mapbox-gl-js/guides/install/). 510 | ### Usage 511 | If you're using a CSS loader like webpack css-loader, you can import the CSS directly in your JavaScript: 512 | ``` 513 | import 'mapbox-gl/dist/mapbox-gl.css'; 514 | ``` 515 | We can now start using mapbox: 516 | ``` 517 | import mapboxgl from 'mapbox-gl'; // or "const mapboxgl = require('mapbox-gl');" 518 | 519 | mapboxgl.accessToken = 'YOUR_MAPBOX_API_KEY'; 520 | const map = new mapboxgl.Map({ 521 | container: 'map', // container id 522 | style: 'mapbox://styles/mapbox/streets-v11', // stylesheet location 523 | center: [-74.5, 40], // starting position [lng, lat] 524 | zoom: 9 // starting zoom 525 | }); 526 | ``` 527 | 528 | Then we can start using Mapbox very easily: 529 | ``` 530 | export const Mapbox: React.FC = () => { 531 | useEffect(() => { 532 | mapboxgl.accessToken = 'YOUR_MAPBOX_API_KEY'; 533 | const map = new mapboxgl.Map({ 534 | container: 'map', // container id 535 | style: 'mapbox://styles/mapbox/streets-v11', // stylesheet location 536 | center: [-74.5, 40], // starting position [lng, lat] 537 | zoom: 9 // starting zoom 538 | }); 539 | }, []); 540 | 541 | render() { 542 | return (
); 543 | } 544 | } 545 | ``` 546 | You can find more examples from [here](https://docs.mapbox.com/mapbox-gl-js/examples/) 547 | 548 | [Back to the top↑](#table-of-contents) 549 | 550 | ## License 551 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 552 | 553 | ## Screenshot 554 | ![main](./src/assets/main_page.jpeg) 555 | 556 | ![main](./src/assets/mobile_page.jpeg) 557 | 558 | ![covid](./src/assets/covid_page.jpeg) 559 | 560 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /config/webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const IgnorePlugin = require('webpack/lib/IgnorePlugin'); 6 | 7 | module.exports = { 8 | entry: ['./src/index.tsx'], 9 | output: { 10 | path: path.resolve(__dirname, '../dist'), 11 | filename: '[name].bundle.js', 12 | }, 13 | resolve: { 14 | modules: [path.join(__dirname, '../dist'), 'node_modules'], 15 | extensions: ['.ts', '.tsx', '.js'], 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | loader: 'ts-loader', 22 | }, 23 | { 24 | enforce: 'pre', 25 | test: /\.js$/, 26 | exclude: /(node_modules)/, 27 | loader: 'source-map-loader', 28 | }, 29 | { 30 | test: /\.css$/, 31 | use: [ 32 | { 33 | loader: MiniCssExtractPlugin.loader, 34 | }, 35 | 'css-loader', 36 | ], 37 | }, 38 | { 39 | test: /\.(jpe?g|png|gif|ico)$/i, 40 | use: [ 41 | { 42 | loader: 'file-loader', 43 | }, 44 | ], 45 | }, 46 | { 47 | test: /\.(ttf|eot|svg|woff|woff2)(\?.+)?$/, 48 | loader: 'file-loader', 49 | options: { 50 | name: '[sha512:hash:base64:7].[ext]', 51 | }, 52 | }, 53 | ], 54 | }, 55 | plugins: [ 56 | new IgnorePlugin({ 57 | resourceRegExp: /^\.\/locale$/, 58 | contextRegExp: /moment$/, 59 | }), 60 | /* 61 | * Plugin: HtmlWebpackPlugin 62 | * Description: Simplifies creation of HTML files to serve your webpack bundles. 63 | * This is especially useful for webpack bundles that include a hash in the filename 64 | * which changes every compilation. 65 | * 66 | * See: https://github.com/ampedandwired/html-webpack-plugin 67 | */ 68 | new HtmlWebpackPlugin({ 69 | template: 'src/index.html', 70 | }), 71 | /* 72 | * Plugin: CopyWebpackPlugin 73 | * Description: Copy files and directories in webpack. 74 | * Copies project static assets. 75 | * See: https://www.npmjs.com/package/copy-webpack-plugin 76 | */ 77 | new CopyWebpackPlugin({ 78 | patterns: [{ from: 'src/assets', to: 'assets' }], 79 | }), 80 | ], 81 | }; 82 | -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const DefinePlugin = require('webpack/lib/DefinePlugin'); 4 | const common = require('./webpack.common.js'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'development', 8 | devtool: 'inline-source-map', 9 | devServer: { 10 | allowedHosts: 'auto', 11 | static: ['../src/assets'], 12 | historyApiFallback: true, 13 | hot: true, 14 | }, 15 | plugins: [ 16 | new DefinePlugin({ 17 | 'process.env': { 18 | NODE_ENV: JSON.stringify('development'), 19 | }, 20 | }), 21 | /* 22 | * Plugin: MiniCssExtractPlugin 23 | * Description: This plugin extracts CSS into separate files. 24 | * It creates a CSS file per JS file which contains CSS. 25 | * It supports On-Demand-Loading of CSS and SourceMaps. 26 | * See: https://github.com/webpack-contrib/mini-css-extract-plugin 27 | */ 28 | new MiniCssExtractPlugin({ 29 | filename: '[name].css', 30 | chunkFilename: '[id].css', 31 | }), 32 | ], 33 | }); 34 | -------------------------------------------------------------------------------- /config/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const TerserPlugin = require('terser-webpack-plugin'); 4 | const { merge } = require('webpack-merge'); 5 | const DefinePlugin = require('webpack/lib/DefinePlugin'); 6 | const common = require('./webpack.common.js'); 7 | 8 | module.exports = merge(common, { 9 | mode: 'production', 10 | plugins: [ 11 | new DefinePlugin({ 12 | 'process.env': { 13 | NODE_ENV: JSON.stringify('production'), 14 | }, 15 | }), 16 | new MiniCssExtractPlugin({ 17 | filename: '[name].[fullhash].css', 18 | chunkFilename: '[id].[fullhash].css', 19 | }), 20 | /* 21 | * Plugin: CleanWebpackPlugin 22 | * Description: A webpack plugin to remove/clean your build folder(s). 23 | * See: https://github.com/johnagan/clean-webpack-plugin 24 | */ 25 | new CleanWebpackPlugin(), 26 | ], 27 | optimization: { 28 | splitChunks: { 29 | cacheGroups: { 30 | commons: { 31 | test: /[\\/]node_modules[\\/]/, 32 | name: 'vendors', 33 | chunks: 'all', 34 | }, 35 | styles: { 36 | name: 'styles', 37 | test: /\.css$/, 38 | chunks: 'all', 39 | enforce: true, 40 | }, 41 | }, 42 | }, 43 | minimize: true, 44 | minimizer: [ 45 | /* 46 | * Plugin: TerserWebpackPlugin 47 | * Description: This plugin uses terser to minify your JavaScript. 48 | * See: https://webpack.js.org/plugins/terser-webpack-plugin 49 | */ 50 | new TerserPlugin({ 51 | parallel: true, 52 | }), 53 | ], 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /dev-server/mock/location.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "OK", 3 | "address": "New York, NY, USA", 4 | "latitude": 40.7127753, 5 | "longitude": -74.0059728 6 | } 7 | -------------------------------------------------------------------------------- /dev-server/mock/weather-si-by-date.json: -------------------------------------------------------------------------------- 1 | { 2 | "latitude": -36.8051115, 3 | "longitude": 174.6943808, 4 | "timezone": "Pacific/Auckland", 5 | "currently": { 6 | "time": 1567857600, 7 | "summary": "Mostly Cloudy", 8 | "icon": "partly-cloudy-night", 9 | "precipIntensity": 0, 10 | "precipProbability": 0, 11 | "temperature": 8.16, 12 | "apparentTemperature": 6.58, 13 | "dewPoint": 5.67, 14 | "humidity": 0.84, 15 | "pressure": 1007.74, 16 | "windSpeed": 2.58, 17 | "windGust": 5.17, 18 | "windBearing": 1, 19 | "cloudCover": 0.75, 20 | "uvIndex": 0, 21 | "visibility": 10.003, 22 | "ozone": 362.2 23 | }, 24 | "hourly": { 25 | "summary": "Light rain overnight and in the morning.", 26 | "icon": "rain", 27 | "data": [ 28 | { 29 | "time": 1567857600, 30 | "summary": "Mostly Cloudy", 31 | "icon": "partly-cloudy-night", 32 | "precipIntensity": 0, 33 | "precipProbability": 0, 34 | "temperature": 8.16, 35 | "apparentTemperature": 6.58, 36 | "dewPoint": 5.67, 37 | "humidity": 0.84, 38 | "pressure": 1007.74, 39 | "windSpeed": 2.58, 40 | "windGust": 5.17, 41 | "windBearing": 1, 42 | "cloudCover": 0.75, 43 | "uvIndex": 0, 44 | "visibility": 10.003, 45 | "ozone": 362.2 46 | }, 47 | { 48 | "time": 1567861200, 49 | "summary": "Partly Cloudy", 50 | "icon": "partly-cloudy-night", 51 | "precipIntensity": 0.1382, 52 | "precipProbability": 0.14, 53 | "precipType": "rain", 54 | "temperature": 9.67, 55 | "apparentTemperature": 8.74, 56 | "dewPoint": 7.31, 57 | "humidity": 0.85, 58 | "pressure": 1008.03, 59 | "windSpeed": 2.09, 60 | "windGust": 6.12, 61 | "windBearing": 47, 62 | "cloudCover": 0.19, 63 | "uvIndex": 0, 64 | "visibility": 9.951, 65 | "ozone": 361.8 66 | }, 67 | { 68 | "time": 1567864800, 69 | "summary": "Possible Light Rain", 70 | "icon": "rain", 71 | "precipIntensity": 0.7659, 72 | "precipProbability": 0.38, 73 | "precipType": "rain", 74 | "temperature": 10.07, 75 | "apparentTemperature": 10.07, 76 | "dewPoint": 7.57, 77 | "humidity": 0.84, 78 | "pressure": 1007.02, 79 | "windSpeed": 2.21, 80 | "windGust": 7.53, 81 | "windBearing": 20, 82 | "cloudCover": 0.44, 83 | "uvIndex": 0, 84 | "visibility": 9.881, 85 | "ozone": 361.8 86 | }, 87 | { 88 | "time": 1567868400, 89 | "summary": "Possible Light Rain", 90 | "icon": "rain", 91 | "precipIntensity": 1.4796, 92 | "precipProbability": 0.51, 93 | "precipType": "rain", 94 | "temperature": 11.07, 95 | "apparentTemperature": 11.07, 96 | "dewPoint": 8.65, 97 | "humidity": 0.85, 98 | "pressure": 1006.01, 99 | "windSpeed": 3.41, 100 | "windGust": 8.8, 101 | "windBearing": 26, 102 | "cloudCover": 0.75, 103 | "uvIndex": 0, 104 | "visibility": 9.857, 105 | "ozone": 362.7 106 | }, 107 | { 108 | "time": 1567872000, 109 | "summary": "Possible Light Rain", 110 | "icon": "rain", 111 | "precipIntensity": 1.739, 112 | "precipProbability": 0.63, 113 | "precipType": "rain", 114 | "temperature": 11.2, 115 | "apparentTemperature": 11.2, 116 | "dewPoint": 10.62, 117 | "humidity": 0.96, 118 | "pressure": 1004.98, 119 | "windSpeed": 6.5, 120 | "windGust": 9.74, 121 | "windBearing": 9, 122 | "cloudCover": 0.75, 123 | "uvIndex": 0, 124 | "visibility": 7.908, 125 | "ozone": 364.9 126 | }, 127 | { 128 | "time": 1567875600, 129 | "summary": "Possible Light Rain", 130 | "icon": "rain", 131 | "precipIntensity": 2.0517, 132 | "precipProbability": 0.61, 133 | "precipType": "rain", 134 | "temperature": 11.26, 135 | "apparentTemperature": 11.26, 136 | "dewPoint": 10.55, 137 | "humidity": 0.95, 138 | "pressure": 1003.98, 139 | "windSpeed": 9.29, 140 | "windGust": 10.54, 141 | "windBearing": 18, 142 | "cloudCover": 1, 143 | "uvIndex": 0, 144 | "visibility": 10.005, 145 | "ozone": 367.9 146 | }, 147 | { 148 | "time": 1567879200, 149 | "summary": "Possible Light Rain", 150 | "icon": "rain", 151 | "precipIntensity": 1.4325, 152 | "precipProbability": 0.51, 153 | "precipType": "rain", 154 | "temperature": 12.23, 155 | "apparentTemperature": 12.23, 156 | "dewPoint": 12.23, 157 | "humidity": 1, 158 | "pressure": 1003.21, 159 | "windSpeed": 6.32, 160 | "windGust": 11.81, 161 | "windBearing": 329, 162 | "cloudCover": 0.75, 163 | "uvIndex": 0, 164 | "visibility": 10.64, 165 | "ozone": 371.4 166 | }, 167 | { 168 | "time": 1567882800, 169 | "summary": "Possible Light Rain", 170 | "icon": "rain", 171 | "precipIntensity": 0.8206, 172 | "precipProbability": 0.48, 173 | "precipType": "rain", 174 | "temperature": 13.07, 175 | "apparentTemperature": 13.07, 176 | "dewPoint": 12.5, 177 | "humidity": 0.96, 178 | "pressure": 1003.31, 179 | "windSpeed": 7.59, 180 | "windGust": 12.05, 181 | "windBearing": 318, 182 | "cloudCover": 1, 183 | "uvIndex": 0, 184 | "visibility": 13.792, 185 | "ozone": 372.4 186 | }, 187 | { 188 | "time": 1567886400, 189 | "summary": "Possible Drizzle", 190 | "icon": "rain", 191 | "precipIntensity": 0.2366, 192 | "precipProbability": 0.39, 193 | "precipType": "rain", 194 | "temperature": 12.98, 195 | "apparentTemperature": 12.98, 196 | "dewPoint": 12.43, 197 | "humidity": 0.97, 198 | "pressure": 1003.83, 199 | "windSpeed": 5.71, 200 | "windGust": 11.94, 201 | "windBearing": 319, 202 | "cloudCover": 1, 203 | "uvIndex": 0, 204 | "visibility": 13.447, 205 | "ozone": 372.5 206 | }, 207 | { 208 | "time": 1567890000, 209 | "summary": "Mostly Cloudy", 210 | "icon": "partly-cloudy-day", 211 | "precipIntensity": 0.0553, 212 | "precipProbability": 0.23, 213 | "precipType": "rain", 214 | "temperature": 15.91, 215 | "apparentTemperature": 15.91, 216 | "dewPoint": 12.22, 217 | "humidity": 0.79, 218 | "pressure": 1004.21, 219 | "windSpeed": 8.26, 220 | "windGust": 11.75, 221 | "windBearing": 298, 222 | "cloudCover": 0.75, 223 | "uvIndex": 1, 224 | "visibility": 12.908, 225 | "ozone": 372.2 226 | }, 227 | { 228 | "time": 1567893600, 229 | "summary": "Mostly Cloudy", 230 | "icon": "partly-cloudy-day", 231 | "precipIntensity": 0.034, 232 | "precipProbability": 0.18, 233 | "precipType": "rain", 234 | "temperature": 15.96, 235 | "apparentTemperature": 15.96, 236 | "dewPoint": 12.18, 237 | "humidity": 0.78, 238 | "pressure": 1004.07, 239 | "windSpeed": 9.25, 240 | "windGust": 14.22, 241 | "windBearing": 282, 242 | "cloudCover": 0.75, 243 | "uvIndex": 2, 244 | "visibility": 12.213, 245 | "ozone": 371.6 246 | }, 247 | { 248 | "time": 1567897200, 249 | "summary": "Mostly Cloudy", 250 | "icon": "partly-cloudy-day", 251 | "precipIntensity": 0.0314, 252 | "precipProbability": 0.15, 253 | "precipType": "rain", 254 | "temperature": 16.87, 255 | "apparentTemperature": 16.87, 256 | "dewPoint": 12.11, 257 | "humidity": 0.74, 258 | "pressure": 1003.92, 259 | "windSpeed": 9.83, 260 | "windGust": 11.27, 261 | "windBearing": 271, 262 | "cloudCover": 0.75, 263 | "uvIndex": 3, 264 | "visibility": 11.342, 265 | "ozone": 370.6 266 | }, 267 | { 268 | "time": 1567900800, 269 | "summary": "Windy and Mostly Cloudy", 270 | "icon": "partly-cloudy-day", 271 | "precipIntensity": 0.0381, 272 | "precipProbability": 0.16, 273 | "precipType": "rain", 274 | "temperature": 16.82, 275 | "apparentTemperature": 16.82, 276 | "dewPoint": 10.92, 277 | "humidity": 0.68, 278 | "pressure": 1003.71, 279 | "windSpeed": 11.89, 280 | "windGust": 11.89, 281 | "windBearing": 271, 282 | "cloudCover": 0.75, 283 | "uvIndex": 3, 284 | "visibility": 10.705, 285 | "ozone": 369.8 286 | }, 287 | { 288 | "time": 1567904400, 289 | "summary": "Windy and Partly Cloudy", 290 | "icon": "partly-cloudy-day", 291 | "precipIntensity": 0.0149, 292 | "precipProbability": 0.08, 293 | "precipType": "rain", 294 | "temperature": 17.12, 295 | "apparentTemperature": 17.12, 296 | "dewPoint": 11.16, 297 | "humidity": 0.68, 298 | "pressure": 1002.73, 299 | "windSpeed": 11.7, 300 | "windGust": 11.7, 301 | "windBearing": 273, 302 | "cloudCover": 0.44, 303 | "uvIndex": 4, 304 | "visibility": 11.049, 305 | "ozone": 364.4 306 | }, 307 | { 308 | "time": 1567908000, 309 | "summary": "Partly Cloudy", 310 | "icon": "partly-cloudy-day", 311 | "precipIntensity": 0.0447, 312 | "precipProbability": 0.12, 313 | "precipType": "rain", 314 | "temperature": 16.87, 315 | "apparentTemperature": 16.87, 316 | "dewPoint": 11.11, 317 | "humidity": 0.69, 318 | "pressure": 1002.65, 319 | "windSpeed": 10.15, 320 | "windGust": 10.51, 321 | "windBearing": 263, 322 | "cloudCover": 0.19, 323 | "uvIndex": 3, 324 | "visibility": 12.769, 325 | "ozone": 365.2 326 | }, 327 | { 328 | "time": 1567911600, 329 | "summary": "Mostly Cloudy", 330 | "icon": "partly-cloudy-day", 331 | "precipIntensity": 0.0874, 332 | "precipProbability": 0.17, 333 | "precipType": "rain", 334 | "temperature": 16.03, 335 | "apparentTemperature": 16.03, 336 | "dewPoint": 11.31, 337 | "humidity": 0.74, 338 | "pressure": 1002.57, 339 | "windSpeed": 8.5, 340 | "windGust": 10.5, 341 | "windBearing": 273, 342 | "cloudCover": 0.75, 343 | "uvIndex": 2, 344 | "visibility": 13.814, 345 | "ozone": 366.4 346 | }, 347 | { 348 | "time": 1567915200, 349 | "summary": "Mostly Cloudy", 350 | "icon": "partly-cloudy-day", 351 | "precipIntensity": 0.1143, 352 | "precipProbability": 0.19, 353 | "precipType": "rain", 354 | "temperature": 15.97, 355 | "apparentTemperature": 15.97, 356 | "dewPoint": 10.27, 357 | "humidity": 0.69, 358 | "pressure": 1002.63, 359 | "windSpeed": 8.52, 360 | "windGust": 10.7, 361 | "windBearing": 283, 362 | "cloudCover": 0.75, 363 | "uvIndex": 1, 364 | "visibility": 13.333, 365 | "ozone": 367.9 366 | }, 367 | { 368 | "time": 1567918800, 369 | "summary": "Mostly Cloudy", 370 | "icon": "partly-cloudy-day", 371 | "precipIntensity": 0.1364, 372 | "precipProbability": 0.21, 373 | "precipType": "rain", 374 | "temperature": 14.87, 375 | "apparentTemperature": 14.87, 376 | "dewPoint": 12.23, 377 | "humidity": 0.84, 378 | "pressure": 1002.78, 379 | "windSpeed": 7.55, 380 | "windGust": 10.97, 381 | "windBearing": 302, 382 | "cloudCover": 0.75, 383 | "uvIndex": 0, 384 | "visibility": 12.148, 385 | "ozone": 369.8 386 | }, 387 | { 388 | "time": 1567922400, 389 | "summary": "Partly Cloudy", 390 | "icon": "partly-cloudy-day", 391 | "precipIntensity": 0.0331, 392 | "precipProbability": 0.06, 393 | "precipType": "rain", 394 | "temperature": 13.66, 395 | "apparentTemperature": 13.66, 396 | "dewPoint": 11.22, 397 | "humidity": 0.85, 398 | "pressure": 1002.66, 399 | "windSpeed": 6.82, 400 | "windGust": 9.02, 401 | "windBearing": 312, 402 | "cloudCover": 0.44, 403 | "uvIndex": 0, 404 | "visibility": 16.093, 405 | "ozone": 374.1 406 | }, 407 | { 408 | "time": 1567926000, 409 | "summary": "Overcast", 410 | "icon": "cloudy", 411 | "precipIntensity": 0.0352, 412 | "precipProbability": 0.07, 413 | "precipType": "rain", 414 | "temperature": 13.51, 415 | "apparentTemperature": 13.51, 416 | "dewPoint": 11.43, 417 | "humidity": 0.87, 418 | "pressure": 1002.8, 419 | "windSpeed": 2.5, 420 | "windGust": 8.37, 421 | "windBearing": 354, 422 | "cloudCover": 1, 423 | "uvIndex": 0, 424 | "visibility": 16.093, 425 | "ozone": 377.3 426 | }, 427 | { 428 | "time": 1567929600, 429 | "summary": "Mostly Cloudy", 430 | "icon": "partly-cloudy-night", 431 | "precipIntensity": 0.0436, 432 | "precipProbability": 0.1, 433 | "precipType": "rain", 434 | "temperature": 13.33, 435 | "apparentTemperature": 13.33, 436 | "dewPoint": 12.31, 437 | "humidity": 0.94, 438 | "pressure": 1003.06, 439 | "windSpeed": 1.67, 440 | "windGust": 7.71, 441 | "windBearing": 340, 442 | "cloudCover": 0.75, 443 | "uvIndex": 0, 444 | "visibility": 15.065, 445 | "ozone": 380.5 446 | }, 447 | { 448 | "time": 1567933200, 449 | "summary": "Mostly Cloudy", 450 | "icon": "partly-cloudy-night", 451 | "precipIntensity": 0.0635, 452 | "precipProbability": 0.13, 453 | "precipType": "rain", 454 | "temperature": 13.27, 455 | "apparentTemperature": 13.27, 456 | "dewPoint": 13.2, 457 | "humidity": 1, 458 | "pressure": 1003.07, 459 | "windSpeed": 2.13, 460 | "windGust": 7.45, 461 | "windBearing": 309, 462 | "cloudCover": 0.75, 463 | "uvIndex": 0, 464 | "visibility": 13.921, 465 | "ozone": 382.3 466 | }, 467 | { 468 | "time": 1567936800, 469 | "summary": "Mostly Cloudy", 470 | "icon": "partly-cloudy-night", 471 | "precipIntensity": 0.1239, 472 | "precipProbability": 0.26, 473 | "precipType": "rain", 474 | "temperature": 13.38, 475 | "apparentTemperature": 13.38, 476 | "dewPoint": 13.04, 477 | "humidity": 0.98, 478 | "pressure": 1002.89, 479 | "windSpeed": 2.24, 480 | "windGust": 7.98, 481 | "windBearing": 318, 482 | "cloudCover": 0.75, 483 | "uvIndex": 0, 484 | "visibility": 12.825, 485 | "ozone": 381.3 486 | }, 487 | { 488 | "time": 1567940400, 489 | "summary": "Possible Drizzle", 490 | "icon": "rain", 491 | "precipIntensity": 0.2919, 492 | "precipProbability": 0.47, 493 | "precipType": "rain", 494 | "temperature": 12.57, 495 | "apparentTemperature": 12.57, 496 | "dewPoint": 11.9, 497 | "humidity": 0.96, 498 | "pressure": 1002.45, 499 | "windSpeed": 2.97, 500 | "windGust": 8.9, 501 | "windBearing": 301, 502 | "cloudCover": 0.75, 503 | "uvIndex": 0, 504 | "visibility": 10.677, 505 | "ozone": 379.1 506 | } 507 | ] 508 | }, 509 | "daily": { 510 | "data": [ 511 | { 512 | "time": 1567857600, 513 | "summary": "Light rain in the morning and overnight.", 514 | "icon": "rain", 515 | "sunriseTime": 1567881247, 516 | "sunsetTime": 1567922829, 517 | "moonPhase": 0.32, 518 | "precipIntensity": 0.4088, 519 | "precipIntensityMax": 2.0517, 520 | "precipIntensityMaxTime": 1567875600, 521 | "precipProbability": 0.87, 522 | "precipType": "rain", 523 | "temperatureHigh": 17.12, 524 | "temperatureHighTime": 1567904400, 525 | "temperatureLow": 9.68, 526 | "temperatureLowTime": 1567965600, 527 | "apparentTemperatureHigh": 17.12, 528 | "apparentTemperatureHighTime": 1567904400, 529 | "apparentTemperatureLow": 9.44, 530 | "apparentTemperatureLowTime": 1567965600, 531 | "dewPoint": 11.01, 532 | "humidity": 0.85, 533 | "pressure": 1003.93, 534 | "windSpeed": 6.24, 535 | "windGust": 14.22, 536 | "windGustTime": 1567893600, 537 | "windBearing": 303, 538 | "cloudCover": 0.71, 539 | "uvIndex": 4, 540 | "uvIndexTime": 1567904400, 541 | "visibility": 12.102, 542 | "ozone": 370.4, 543 | "temperatureMin": 8.16, 544 | "temperatureMinTime": 1567857600, 545 | "temperatureMax": 17.12, 546 | "temperatureMaxTime": 1567904400, 547 | "apparentTemperatureMin": 6.58, 548 | "apparentTemperatureMinTime": 1567857600, 549 | "apparentTemperatureMax": 17.12, 550 | "apparentTemperatureMaxTime": 1567904400 551 | } 552 | ] 553 | }, 554 | "offset": 12 555 | } 556 | -------------------------------------------------------------------------------- /dev-server/mock/weather-us-by-date.json: -------------------------------------------------------------------------------- 1 | { 2 | "latitude": -36.8051115, 3 | "longitude": 174.6943808, 4 | "timezone": "Pacific/Auckland", 5 | "currently": { 6 | "time": 1567857600, 7 | "summary": "Mostly Cloudy", 8 | "icon": "partly-cloudy-night", 9 | "precipIntensity": 0, 10 | "precipProbability": 0, 11 | "temperature": 46.69, 12 | "apparentTemperature": 43.85, 13 | "dewPoint": 42.21, 14 | "humidity": 0.84, 15 | "pressure": 1007.74, 16 | "windSpeed": 5.77, 17 | "windGust": 11.57, 18 | "windBearing": 1, 19 | "cloudCover": 0.75, 20 | "uvIndex": 0, 21 | "visibility": 6.216, 22 | "ozone": 362.2 23 | }, 24 | "hourly": { 25 | "summary": "Light rain overnight and in the morning.", 26 | "icon": "rain", 27 | "data": [ 28 | { 29 | "time": 1567857600, 30 | "summary": "Mostly Cloudy", 31 | "icon": "partly-cloudy-night", 32 | "precipIntensity": 0, 33 | "precipProbability": 0, 34 | "temperature": 46.69, 35 | "apparentTemperature": 43.85, 36 | "dewPoint": 42.21, 37 | "humidity": 0.84, 38 | "pressure": 1007.74, 39 | "windSpeed": 5.77, 40 | "windGust": 11.57, 41 | "windBearing": 1, 42 | "cloudCover": 0.75, 43 | "uvIndex": 0, 44 | "visibility": 6.216, 45 | "ozone": 362.2 46 | }, 47 | { 48 | "time": 1567861200, 49 | "summary": "Partly Cloudy", 50 | "icon": "partly-cloudy-night", 51 | "precipIntensity": 0.0054, 52 | "precipProbability": 0.14, 53 | "precipType": "rain", 54 | "temperature": 49.41, 55 | "apparentTemperature": 47.74, 56 | "dewPoint": 45.16, 57 | "humidity": 0.85, 58 | "pressure": 1008.03, 59 | "windSpeed": 4.67, 60 | "windGust": 13.69, 61 | "windBearing": 47, 62 | "cloudCover": 0.19, 63 | "uvIndex": 0, 64 | "visibility": 6.183, 65 | "ozone": 361.8 66 | }, 67 | { 68 | "time": 1567864800, 69 | "summary": "Possible Light Rain", 70 | "icon": "rain", 71 | "precipIntensity": 0.0302, 72 | "precipProbability": 0.38, 73 | "precipType": "rain", 74 | "temperature": 50.12, 75 | "apparentTemperature": 50.12, 76 | "dewPoint": 45.63, 77 | "humidity": 0.84, 78 | "pressure": 1007.02, 79 | "windSpeed": 4.94, 80 | "windGust": 16.85, 81 | "windBearing": 20, 82 | "cloudCover": 0.44, 83 | "uvIndex": 0, 84 | "visibility": 6.14, 85 | "ozone": 361.8 86 | }, 87 | { 88 | "time": 1567868400, 89 | "summary": "Possible Light Rain", 90 | "icon": "rain", 91 | "precipIntensity": 0.0583, 92 | "precipProbability": 0.51, 93 | "precipType": "rain", 94 | "temperature": 51.92, 95 | "apparentTemperature": 51.92, 96 | "dewPoint": 47.57, 97 | "humidity": 0.85, 98 | "pressure": 1006.01, 99 | "windSpeed": 7.62, 100 | "windGust": 19.69, 101 | "windBearing": 26, 102 | "cloudCover": 0.75, 103 | "uvIndex": 0, 104 | "visibility": 6.125, 105 | "ozone": 362.7 106 | }, 107 | { 108 | "time": 1567872000, 109 | "summary": "Possible Light Rain", 110 | "icon": "rain", 111 | "precipIntensity": 0.0685, 112 | "precipProbability": 0.63, 113 | "precipType": "rain", 114 | "temperature": 52.15, 115 | "apparentTemperature": 52.15, 116 | "dewPoint": 51.11, 117 | "humidity": 0.96, 118 | "pressure": 1004.98, 119 | "windSpeed": 14.53, 120 | "windGust": 21.8, 121 | "windBearing": 9, 122 | "cloudCover": 0.75, 123 | "uvIndex": 0, 124 | "visibility": 4.914, 125 | "ozone": 364.9 126 | }, 127 | { 128 | "time": 1567875600, 129 | "summary": "Possible Light Rain", 130 | "icon": "rain", 131 | "precipIntensity": 0.0808, 132 | "precipProbability": 0.61, 133 | "precipType": "rain", 134 | "temperature": 52.27, 135 | "apparentTemperature": 52.27, 136 | "dewPoint": 50.98, 137 | "humidity": 0.95, 138 | "pressure": 1003.98, 139 | "windSpeed": 20.78, 140 | "windGust": 23.59, 141 | "windBearing": 18, 142 | "cloudCover": 1, 143 | "uvIndex": 0, 144 | "visibility": 6.217, 145 | "ozone": 367.9 146 | }, 147 | { 148 | "time": 1567879200, 149 | "summary": "Possible Light Rain", 150 | "icon": "rain", 151 | "precipIntensity": 0.0564, 152 | "precipProbability": 0.51, 153 | "precipType": "rain", 154 | "temperature": 54.01, 155 | "apparentTemperature": 54.01, 156 | "dewPoint": 54.01, 157 | "humidity": 1, 158 | "pressure": 1003.21, 159 | "windSpeed": 14.13, 160 | "windGust": 26.42, 161 | "windBearing": 329, 162 | "cloudCover": 0.75, 163 | "uvIndex": 0, 164 | "visibility": 6.611, 165 | "ozone": 371.4 166 | }, 167 | { 168 | "time": 1567882800, 169 | "summary": "Possible Light Rain", 170 | "icon": "rain", 171 | "precipIntensity": 0.0323, 172 | "precipProbability": 0.48, 173 | "precipType": "rain", 174 | "temperature": 55.53, 175 | "apparentTemperature": 55.53, 176 | "dewPoint": 54.5, 177 | "humidity": 0.96, 178 | "pressure": 1003.31, 179 | "windSpeed": 16.97, 180 | "windGust": 26.96, 181 | "windBearing": 318, 182 | "cloudCover": 1, 183 | "uvIndex": 0, 184 | "visibility": 8.57, 185 | "ozone": 372.4 186 | }, 187 | { 188 | "time": 1567886400, 189 | "summary": "Possible Drizzle", 190 | "icon": "rain", 191 | "precipIntensity": 0.0093, 192 | "precipProbability": 0.39, 193 | "precipType": "rain", 194 | "temperature": 55.36, 195 | "apparentTemperature": 55.36, 196 | "dewPoint": 54.38, 197 | "humidity": 0.97, 198 | "pressure": 1003.83, 199 | "windSpeed": 12.78, 200 | "windGust": 26.71, 201 | "windBearing": 319, 202 | "cloudCover": 1, 203 | "uvIndex": 0, 204 | "visibility": 8.356, 205 | "ozone": 372.5 206 | }, 207 | { 208 | "time": 1567890000, 209 | "summary": "Mostly Cloudy", 210 | "icon": "partly-cloudy-day", 211 | "precipIntensity": 0.0022, 212 | "precipProbability": 0.23, 213 | "precipType": "rain", 214 | "temperature": 60.65, 215 | "apparentTemperature": 60.65, 216 | "dewPoint": 54, 217 | "humidity": 0.79, 218 | "pressure": 1004.21, 219 | "windSpeed": 18.49, 220 | "windGust": 26.28, 221 | "windBearing": 298, 222 | "cloudCover": 0.75, 223 | "uvIndex": 1, 224 | "visibility": 8.021, 225 | "ozone": 372.2 226 | }, 227 | { 228 | "time": 1567893600, 229 | "summary": "Mostly Cloudy", 230 | "icon": "partly-cloudy-day", 231 | "precipIntensity": 0.0013, 232 | "precipProbability": 0.18, 233 | "precipType": "rain", 234 | "temperature": 60.73, 235 | "apparentTemperature": 60.73, 236 | "dewPoint": 53.92, 237 | "humidity": 0.78, 238 | "pressure": 1004.07, 239 | "windSpeed": 20.7, 240 | "windGust": 31.82, 241 | "windBearing": 282, 242 | "cloudCover": 0.75, 243 | "uvIndex": 2, 244 | "visibility": 7.589, 245 | "ozone": 371.6 246 | }, 247 | { 248 | "time": 1567897200, 249 | "summary": "Mostly Cloudy", 250 | "icon": "partly-cloudy-day", 251 | "precipIntensity": 0.0012, 252 | "precipProbability": 0.15, 253 | "precipType": "rain", 254 | "temperature": 62.36, 255 | "apparentTemperature": 62.36, 256 | "dewPoint": 53.8, 257 | "humidity": 0.74, 258 | "pressure": 1003.92, 259 | "windSpeed": 21.99, 260 | "windGust": 25.21, 261 | "windBearing": 271, 262 | "cloudCover": 0.75, 263 | "uvIndex": 3, 264 | "visibility": 7.047, 265 | "ozone": 370.6 266 | }, 267 | { 268 | "time": 1567900800, 269 | "summary": "Windy and Mostly Cloudy", 270 | "icon": "partly-cloudy-day", 271 | "precipIntensity": 0.0015, 272 | "precipProbability": 0.16, 273 | "precipType": "rain", 274 | "temperature": 62.28, 275 | "apparentTemperature": 62.28, 276 | "dewPoint": 51.66, 277 | "humidity": 0.68, 278 | "pressure": 1003.71, 279 | "windSpeed": 26.59, 280 | "windGust": 26.59, 281 | "windBearing": 271, 282 | "cloudCover": 0.75, 283 | "uvIndex": 3, 284 | "visibility": 6.652, 285 | "ozone": 369.8 286 | }, 287 | { 288 | "time": 1567904400, 289 | "summary": "Windy and Partly Cloudy", 290 | "icon": "partly-cloudy-day", 291 | "precipIntensity": 0.0006, 292 | "precipProbability": 0.08, 293 | "precipType": "rain", 294 | "temperature": 62.81, 295 | "apparentTemperature": 62.81, 296 | "dewPoint": 52.09, 297 | "humidity": 0.68, 298 | "pressure": 1002.73, 299 | "windSpeed": 26.18, 300 | "windGust": 26.18, 301 | "windBearing": 273, 302 | "cloudCover": 0.44, 303 | "uvIndex": 4, 304 | "visibility": 6.866, 305 | "ozone": 364.4 306 | }, 307 | { 308 | "time": 1567908000, 309 | "summary": "Partly Cloudy", 310 | "icon": "partly-cloudy-day", 311 | "precipIntensity": 0.0018, 312 | "precipProbability": 0.12, 313 | "precipType": "rain", 314 | "temperature": 62.37, 315 | "apparentTemperature": 62.37, 316 | "dewPoint": 52, 317 | "humidity": 0.69, 318 | "pressure": 1002.65, 319 | "windSpeed": 22.69, 320 | "windGust": 23.52, 321 | "windBearing": 263, 322 | "cloudCover": 0.19, 323 | "uvIndex": 3, 324 | "visibility": 7.934, 325 | "ozone": 365.2 326 | }, 327 | { 328 | "time": 1567911600, 329 | "summary": "Mostly Cloudy", 330 | "icon": "partly-cloudy-day", 331 | "precipIntensity": 0.0034, 332 | "precipProbability": 0.17, 333 | "precipType": "rain", 334 | "temperature": 60.85, 335 | "apparentTemperature": 60.85, 336 | "dewPoint": 52.36, 337 | "humidity": 0.74, 338 | "pressure": 1002.57, 339 | "windSpeed": 19.01, 340 | "windGust": 23.48, 341 | "windBearing": 273, 342 | "cloudCover": 0.75, 343 | "uvIndex": 2, 344 | "visibility": 8.584, 345 | "ozone": 366.4 346 | }, 347 | { 348 | "time": 1567915200, 349 | "summary": "Mostly Cloudy", 350 | "icon": "partly-cloudy-day", 351 | "precipIntensity": 0.0045, 352 | "precipProbability": 0.19, 353 | "precipType": "rain", 354 | "temperature": 60.75, 355 | "apparentTemperature": 60.75, 356 | "dewPoint": 50.48, 357 | "humidity": 0.69, 358 | "pressure": 1002.63, 359 | "windSpeed": 19.05, 360 | "windGust": 23.93, 361 | "windBearing": 283, 362 | "cloudCover": 0.75, 363 | "uvIndex": 1, 364 | "visibility": 8.285, 365 | "ozone": 367.9 366 | }, 367 | { 368 | "time": 1567918800, 369 | "summary": "Mostly Cloudy", 370 | "icon": "partly-cloudy-day", 371 | "precipIntensity": 0.0054, 372 | "precipProbability": 0.21, 373 | "precipType": "rain", 374 | "temperature": 58.77, 375 | "apparentTemperature": 58.77, 376 | "dewPoint": 54.02, 377 | "humidity": 0.84, 378 | "pressure": 1002.78, 379 | "windSpeed": 16.88, 380 | "windGust": 24.55, 381 | "windBearing": 302, 382 | "cloudCover": 0.75, 383 | "uvIndex": 0, 384 | "visibility": 7.549, 385 | "ozone": 369.8 386 | }, 387 | { 388 | "time": 1567922400, 389 | "summary": "Partly Cloudy", 390 | "icon": "partly-cloudy-day", 391 | "precipIntensity": 0.0013, 392 | "precipProbability": 0.06, 393 | "precipType": "rain", 394 | "temperature": 56.59, 395 | "apparentTemperature": 56.59, 396 | "dewPoint": 52.19, 397 | "humidity": 0.85, 398 | "pressure": 1002.66, 399 | "windSpeed": 15.26, 400 | "windGust": 20.17, 401 | "windBearing": 312, 402 | "cloudCover": 0.44, 403 | "uvIndex": 0, 404 | "visibility": 10, 405 | "ozone": 374.1 406 | }, 407 | { 408 | "time": 1567926000, 409 | "summary": "Overcast", 410 | "icon": "cloudy", 411 | "precipIntensity": 0.0014, 412 | "precipProbability": 0.07, 413 | "precipType": "rain", 414 | "temperature": 56.31, 415 | "apparentTemperature": 56.31, 416 | "dewPoint": 52.58, 417 | "humidity": 0.87, 418 | "pressure": 1002.8, 419 | "windSpeed": 5.6, 420 | "windGust": 18.72, 421 | "windBearing": 354, 422 | "cloudCover": 1, 423 | "uvIndex": 0, 424 | "visibility": 10, 425 | "ozone": 377.3 426 | }, 427 | { 428 | "time": 1567929600, 429 | "summary": "Mostly Cloudy", 430 | "icon": "partly-cloudy-night", 431 | "precipIntensity": 0.0017, 432 | "precipProbability": 0.1, 433 | "precipType": "rain", 434 | "temperature": 56, 435 | "apparentTemperature": 56, 436 | "dewPoint": 54.16, 437 | "humidity": 0.94, 438 | "pressure": 1003.06, 439 | "windSpeed": 3.73, 440 | "windGust": 17.26, 441 | "windBearing": 340, 442 | "cloudCover": 0.75, 443 | "uvIndex": 0, 444 | "visibility": 9.361, 445 | "ozone": 380.5 446 | }, 447 | { 448 | "time": 1567933200, 449 | "summary": "Mostly Cloudy", 450 | "icon": "partly-cloudy-night", 451 | "precipIntensity": 0.0025, 452 | "precipProbability": 0.13, 453 | "precipType": "rain", 454 | "temperature": 55.88, 455 | "apparentTemperature": 55.88, 456 | "dewPoint": 55.76, 457 | "humidity": 1, 458 | "pressure": 1003.07, 459 | "windSpeed": 4.76, 460 | "windGust": 16.67, 461 | "windBearing": 309, 462 | "cloudCover": 0.75, 463 | "uvIndex": 0, 464 | "visibility": 8.65, 465 | "ozone": 382.3 466 | }, 467 | { 468 | "time": 1567936800, 469 | "summary": "Mostly Cloudy", 470 | "icon": "partly-cloudy-night", 471 | "precipIntensity": 0.0049, 472 | "precipProbability": 0.26, 473 | "precipType": "rain", 474 | "temperature": 56.08, 475 | "apparentTemperature": 56.08, 476 | "dewPoint": 55.48, 477 | "humidity": 0.98, 478 | "pressure": 1002.89, 479 | "windSpeed": 5.02, 480 | "windGust": 17.85, 481 | "windBearing": 318, 482 | "cloudCover": 0.75, 483 | "uvIndex": 0, 484 | "visibility": 7.969, 485 | "ozone": 381.3 486 | }, 487 | { 488 | "time": 1567940400, 489 | "summary": "Possible Drizzle", 490 | "icon": "rain", 491 | "precipIntensity": 0.0115, 492 | "precipProbability": 0.47, 493 | "precipType": "rain", 494 | "temperature": 54.62, 495 | "apparentTemperature": 54.62, 496 | "dewPoint": 53.42, 497 | "humidity": 0.96, 498 | "pressure": 1002.45, 499 | "windSpeed": 6.64, 500 | "windGust": 19.91, 501 | "windBearing": 301, 502 | "cloudCover": 0.75, 503 | "uvIndex": 0, 504 | "visibility": 6.634, 505 | "ozone": 379.1 506 | } 507 | ] 508 | }, 509 | "daily": { 510 | "data": [ 511 | { 512 | "time": 1567857600, 513 | "summary": "Light rain in the morning and overnight.", 514 | "icon": "rain", 515 | "sunriseTime": 1567881247, 516 | "sunsetTime": 1567922829, 517 | "moonPhase": 0.32, 518 | "precipIntensity": 0.0161, 519 | "precipIntensityMax": 0.0808, 520 | "precipIntensityMaxTime": 1567875600, 521 | "precipProbability": 0.87, 522 | "precipType": "rain", 523 | "temperatureHigh": 62.81, 524 | "temperatureHighTime": 1567904400, 525 | "temperatureLow": 49.43, 526 | "temperatureLowTime": 1567965600, 527 | "apparentTemperatureHigh": 62.81, 528 | "apparentTemperatureHighTime": 1567904400, 529 | "apparentTemperatureLow": 49, 530 | "apparentTemperatureLowTime": 1567965600, 531 | "dewPoint": 51.81, 532 | "humidity": 0.85, 533 | "pressure": 1003.93, 534 | "windSpeed": 13.95, 535 | "windGust": 31.82, 536 | "windGustTime": 1567893600, 537 | "windBearing": 303, 538 | "cloudCover": 0.71, 539 | "uvIndex": 4, 540 | "uvIndexTime": 1567904400, 541 | "visibility": 7.52, 542 | "ozone": 370.4, 543 | "temperatureMin": 46.69, 544 | "temperatureMinTime": 1567857600, 545 | "temperatureMax": 62.81, 546 | "temperatureMaxTime": 1567904400, 547 | "apparentTemperatureMin": 43.85, 548 | "apparentTemperatureMinTime": 1567857600, 549 | "apparentTemperatureMax": 62.81, 550 | "apparentTemperatureMaxTime": 1567904400 551 | } 552 | ] 553 | }, 554 | "offset": 12 555 | } 556 | -------------------------------------------------------------------------------- /dev-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dev-server", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "node server.js" 6 | }, 7 | "dependencies": { 8 | "express": "^4.17.1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /dev-server/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const port = 3000; 4 | const location = require('./mock/location'); 5 | const weatherSi = require('./mock/weather-si'); 6 | const weatherUs = require('./mock/weather-us'); 7 | const weatherSiByDate = require('./mock/weather-si-by-date'); 8 | const weatherUsByDate = require('./mock/weather-us-by-date'); 9 | const nyWeatherSi = require('./mock/ny-weather-si'); 10 | const nyWeatherUs = require('./mock/ny-weather-us'); 11 | const covidData = require('./mock/covid-19'); 12 | 13 | const corsHeader = (req, res, next) => { 14 | res.setHeader('Access-Control-Allow-Origin', 'http://localhost:8080'); 15 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 16 | res.setHeader('Access-Control-Allow-Headers', 'Origin, Content-Type, Accept, Authorization, X-Requested-With'); 17 | res.setHeader('Access-Control-Allow-Credentials', 'true'); 18 | 19 | if ('OPTIONS' === req.method) { 20 | res.sendStatus(200); 21 | } else { 22 | next(); 23 | } 24 | }; 25 | app.use(corsHeader); 26 | 27 | app.get('/getGeocode', (req, res) => setTimeout(() => res.send(location), 1000)); 28 | // app.get('/getGeocode', (req, res) => 29 | // setTimeout( 30 | // () => 31 | // res.status(404).json({ 32 | // status: 'ERROR', 33 | // }), 34 | // 1000 35 | // ) 36 | // ); 37 | app.get('/getWeather', (req, res) => { 38 | // res.status(403).send({ code: 403, error: 'daily usage limit exceeded' }); 39 | if (req.query.lat === '40.7127753' && req.query.lon === '-74.0059728') { 40 | if (req.query.units.toLocaleLowerCase() === 'us') { 41 | setTimeout(() => res.send(nyWeatherUs), 1000); 42 | } else { 43 | setTimeout(() => res.send(nyWeatherSi), 1000); 44 | } 45 | } else { 46 | if (req.query.time && req.query.time !== '0') { 47 | if (req.query.units.toLocaleLowerCase() === 'us') { 48 | setTimeout(() => res.send(weatherUsByDate), 1000); 49 | } else { 50 | setTimeout(() => res.send(weatherSiByDate), 1000); 51 | } 52 | } else { 53 | if (req.query.units.toLocaleLowerCase() === 'us') { 54 | setTimeout(() => res.send(weatherUs), 1000); 55 | } else { 56 | setTimeout(() => res.send(weatherSi), 1000); 57 | } 58 | } 59 | } 60 | }); 61 | 62 | app.get('/covidData', (req, res) => { 63 | setTimeout(() => res.send(covidData), 1000); 64 | }); 65 | 66 | app.listen(port, () => console.log(`Mock server listening on port ${port}!`)); 67 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /functions/apikey.js: -------------------------------------------------------------------------------- 1 | const apiKey = { 2 | googleGeocoding: 'GOOGLE_GEOCODING_API_KEY', 3 | darkSky: 'DARK_SKY_API_KEY', 4 | }; 5 | 6 | module.exports = apiKey; 7 | -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const functions = require('firebase-functions'); 3 | const request = require('request'); 4 | const apiKey = require('./apikey'); 5 | 6 | const GOOGLE_MAPS_API_URL = 'https://maps.googleapis.com/maps/api/'; 7 | const GEOCODE_API_URL = GOOGLE_MAPS_API_URL + 'geocode/json?'; 8 | const DARK_SKY_API_URL = 'https://api.darksky.net/forecast/' + apiKey.darkSky; 9 | const whitelist = ['https://reactjs-weather.firebaseapp.com', 'https://reactjs-weather.web.app']; 10 | 11 | const corsOptions = { 12 | origin: (origin, callback) => { 13 | if (whitelist.indexOf(origin) !== -1) { 14 | callback(null, true); 15 | } else { 16 | callback(new Error('Not allowed by CORS')); 17 | } 18 | }, 19 | optionsSuccessStatus: 200, 20 | }; 21 | const cors = require('cors')(corsOptions); 22 | 23 | exports.getGeocode = functions.https.onRequest((req, res) => { 24 | let params = ''; 25 | if (req.query.lat !== 'null' && req.query.lon !== 'null') { 26 | params = `latlng=${req.query.lat},${req.query.lon}`; 27 | } else { 28 | params = `address=${req.query.address}`; 29 | } 30 | let requestUrl = `${GEOCODE_API_URL}${params}&key=${apiKey.googleGeocoding}`; 31 | requestUrl = encodeURI(requestUrl); 32 | console.log('requestUrl:', requestUrl); 33 | cors(req, res, () => { 34 | return request.get(requestUrl, (error, response, body) => { 35 | if (error) { 36 | return res.send(error); 37 | } 38 | console.log('response:', body); 39 | const geocode = JSON.parse(body); 40 | if (geocode.status === 'OK') { 41 | const results = geocode.results; 42 | 43 | let geocodeResponse = { 44 | status: 'OK', 45 | address: results[0].formatted_address, 46 | latitude: results[0].geometry.location.lat, 47 | longitude: results[0].geometry.location.lng, 48 | }; 49 | return res.status(200).send(geocodeResponse); 50 | } else if (geocode.status === 'ZERO_RESULTS') { 51 | return res.status(404).send({ error: 'ERROR' }); 52 | } else { 53 | return res.status(500).send({ error: 'ERROR' }); 54 | } 55 | }); 56 | }); 57 | }); 58 | 59 | exports.getWeather = functions.https.onRequest((req, res) => { 60 | let params = `${req.query.lat},${req.query.lon}`; 61 | if (req.query.time && req.query.time > 0) { 62 | params = `${params},${req.query.time}`; 63 | } 64 | let requestUrl = `${DARK_SKY_API_URL}/${params}`; 65 | 66 | if (req.query.exclude) { 67 | requestUrl = `${requestUrl}?exclude=${req.query.exclude}`; 68 | } 69 | if (req.query.units) { 70 | requestUrl = `${requestUrl}&units=${req.query.units}`; 71 | } 72 | console.log('requestUrl:', requestUrl); 73 | cors(req, res, () => { 74 | return request.get(requestUrl, (error, response, body) => { 75 | console.log('response:', body); 76 | if (error) { 77 | console.log('error:', error); 78 | return res.status(response.statusCode).send(JSON.parse(body)); 79 | } 80 | return res.status(response.statusCode).send(JSON.parse(body)); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "cors": "^2.8.5", 13 | "lodash": "^4.17.20", 14 | "request": "^2.88.2" 15 | }, 16 | "engines": { 17 | "node": "14" 18 | }, 19 | "devDependencies": { 20 | "firebase-admin": "^11.0.0", 21 | "firebase-functions": "^3.16.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-weather-app", 3 | "version": "3.6.2", 4 | "description": "React Weather App", 5 | "scripts": { 6 | "serve": "webpack serve --config ./config/webpack.dev.js --progress --profile", 7 | "build": "webpack --config ./config/webpack.prod.js", 8 | "firebase-deploy": "npm run build && firebase deploy", 9 | "deploy-functions": "firebase deploy --only functions", 10 | "lint": "eslint --ext .ts,.tsx src --fix" 11 | }, 12 | "author": "Laurence Ho", 13 | "license": "MIT", 14 | "keywords": [ 15 | "React", 16 | "Redux", 17 | "Webpack", 18 | "Typescript", 19 | "D3", 20 | "antd", 21 | "ECharts" 22 | ], 23 | "lint-staged": { 24 | "*.{ts,tsx}": [ 25 | "eslint --fix", 26 | "prettier --write", 27 | "git add" 28 | ] 29 | }, 30 | "dependencies": { 31 | "@ant-design/icons": "^4.7.0", 32 | "antd": "^4.18.4", 33 | "clsx": "^1.1.1", 34 | "d3": "^7.6.1", 35 | "echarts": "^5.2.2", 36 | "lodash": "^4.17.21", 37 | "mapbox-gl": "^2.6.1", 38 | "moment": "^2.29.2", 39 | "react": "^18.1.0", 40 | "react-dom": "^18.1.0", 41 | "react-redux": "^7.2.8", 42 | "react-router-dom": "^6.2.1", 43 | "redux": "^4.1.2", 44 | "redux-thunk": "^2.4.1" 45 | }, 46 | "devDependencies": { 47 | "@types/d3": "^5.16.4", 48 | "@types/echarts": "^4.9.13", 49 | "@types/lodash": "^4.14.178", 50 | "@types/mapbox-gl": "^2.6.0", 51 | "@types/react": "^18.0.12", 52 | "@types/react-dom": "^18.0.5", 53 | "@typescript-eslint/eslint-plugin": "^5.10.0", 54 | "@typescript-eslint/parser": "^5.10.0", 55 | "clean-webpack-plugin": "^4.0.0", 56 | "copy-webpack-plugin": "^11.0.0", 57 | "css-loader": "^6.5.1", 58 | "eslint": "^8.7.0", 59 | "eslint-config-prettier": "^8.3.0", 60 | "eslint-plugin-prettier": "^4.0.0", 61 | "eslint-plugin-react": "^7.28.0", 62 | "file-loader": "^6.2.0", 63 | "firebase": "^9.6.4", 64 | "firebase-tools": "^11.0.1", 65 | "html-webpack-plugin": "^5.5.0", 66 | "husky": "^8.0.1", 67 | "lint-staged": "^13.0.1", 68 | "mini-css-extract-plugin": "^2.2.2", 69 | "prettier": "^2.5.1", 70 | "source-map-loader": "^4.0.0", 71 | "terser-webpack-plugin": "^5.3.0", 72 | "ts-loader": "^9.2.6", 73 | "typescript": "^4.5.5", 74 | "webpack": "^5.67.0", 75 | "webpack-cli": "^4.9.1", 76 | "webpack-dev-server": "^4.7.3", 77 | "webpack-merge": "^5.8.0" 78 | }, 79 | "prettier": { 80 | "jsxSingleQuote": true, 81 | "jsxBracketSameLine": true, 82 | "printWidth": 120, 83 | "singleQuote": true, 84 | "trailingComma": "es5", 85 | "useTabs": false 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { Forecast, GeoCode } from './constants/types'; 2 | 3 | declare let process: { 4 | env: { 5 | NODE_ENV: string; 6 | }; 7 | }; 8 | 9 | const CLOUD_FUNCTION_URL = 10 | process.env.NODE_ENV === 'development' 11 | ? 'http://localhost:3000/' 12 | : 'https://us-central1-reactjs-weather.cloudfunctions.net/'; 13 | 14 | const checkStatus = async (response: Response) => { 15 | if (response.status >= 200 && response.status < 300) { 16 | return response; 17 | } else { 18 | let errorJson: any = null; 19 | try { 20 | errorJson = await response.json(); 21 | } catch (error) { 22 | throw new Error(response.statusText); 23 | } 24 | if (errorJson.error) { 25 | throw new Error(errorJson.error); 26 | } else { 27 | throw new Error(response.statusText); 28 | } 29 | } 30 | }; 31 | 32 | const parseJSON = (response: Response) => { 33 | return response 34 | .json() 35 | .then((data) => data) 36 | .catch(() => response); 37 | }; 38 | 39 | export const getGeocode = (latitude: number, longitude: number, address: string): Promise => { 40 | const requestUrl = 41 | `${CLOUD_FUNCTION_URL}getGeocode?lat=${latitude}&lon=${longitude}&address=` + encodeURIComponent(address); 42 | return fetch(requestUrl).then(checkStatus).then(parseJSON); 43 | }; 44 | 45 | export const getWeatherByTime = ( 46 | latitude: number, 47 | longitude: number, 48 | time: number, 49 | exclude: string, 50 | units: string 51 | ): Promise => { 52 | const requestUrl = 53 | `${CLOUD_FUNCTION_URL}getWeather?lat=${latitude}&lon=${longitude}&time=${time}` + 54 | `&exclude=${encodeURIComponent(exclude)}&units=${encodeURIComponent(units)}`; 55 | return fetch(requestUrl).then(checkStatus).then(parseJSON); 56 | }; 57 | -------------------------------------------------------------------------------- /src/assets/covid_page.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceHo/react-weather-app/afe83ef5b56aa968cadae950fc989d50a222310c/src/assets/covid_page.jpeg -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceHo/react-weather-app/afe83ef5b56aa968cadae950fc989d50a222310c/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/assets/main_page.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceHo/react-weather-app/afe83ef5b56aa968cadae950fc989d50a222310c/src/assets/main_page.jpeg -------------------------------------------------------------------------------- /src/assets/mobile_page.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceHo/react-weather-app/afe83ef5b56aa968cadae950fc989d50a222310c/src/assets/mobile_page.jpeg -------------------------------------------------------------------------------- /src/assets/weather-icons/font/weathericons-regular-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceHo/react-weather-app/afe83ef5b56aa968cadae950fc989d50a222310c/src/assets/weather-icons/font/weathericons-regular-webfont.eot -------------------------------------------------------------------------------- /src/assets/weather-icons/font/weathericons-regular-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceHo/react-weather-app/afe83ef5b56aa968cadae950fc989d50a222310c/src/assets/weather-icons/font/weathericons-regular-webfont.ttf -------------------------------------------------------------------------------- /src/assets/weather-icons/font/weathericons-regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceHo/react-weather-app/afe83ef5b56aa968cadae950fc989d50a222310c/src/assets/weather-icons/font/weathericons-regular-webfont.woff -------------------------------------------------------------------------------- /src/assets/weather-icons/font/weathericons-regular-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceHo/react-weather-app/afe83ef5b56aa968cadae950fc989d50a222310c/src/assets/weather-icons/font/weathericons-regular-webfont.woff2 -------------------------------------------------------------------------------- /src/components/chart-config.ts: -------------------------------------------------------------------------------- 1 | import 'echarts/lib/chart/bar'; 2 | import 'echarts/lib/chart/line'; 3 | import 'echarts/lib/component/legend'; 4 | import 'echarts/lib/component/tooltip'; 5 | import { map } from 'lodash'; 6 | import { Timezone } from '../constants/types'; 7 | import { Utils } from '../utils'; 8 | 9 | export const chartConfig: any = (units: string, timezone: Timezone, hourly: any) => { 10 | const fontSize = Utils.isMobile() ? 10 : 14; 11 | 12 | const formatterXAxisLabel = (value: number, index: number) => { 13 | if (index === 0) { 14 | return 'Now'; 15 | } 16 | return Utils.getLocalTime(value, timezone.offset, 'HH:mm'); 17 | }; 18 | 19 | const formatterTooltip = (params: any) => { 20 | const temperature = params[0]; 21 | const rain = params[1]; 22 | const time = Utils.getLocalTime(temperature.name, timezone.offset, 'YYYY-MM-DD HH:mm'); 23 | 24 | return ` 25 |
${time}
26 |
27 |
28 | Temperature:${Utils.getTemperature(temperature.value, units)} 29 |
30 |
31 |
32 | Rain: ${rain.value} ${units === 'us' ? 'in' : 'mm'} 33 |
34 | `; 35 | }; 36 | 37 | const roundTemperature = map(hourly.data, (n) => Math.round(n.temperature)).slice(0, 23); 38 | 39 | const roundIntensity = map(hourly.data, (n) => 40 | units === 'us' ? n.precipIntensity.toFixed(3) : n.precipIntensity.toFixed(2) 41 | ).slice(0, 23); 42 | 43 | const temperatureMax = Math.round(Math.max.apply(null, roundTemperature) * 1.3); 44 | 45 | const rainMax = (Math.max.apply(null, roundIntensity) * 1.3).toFixed(1); 46 | 47 | return { 48 | legend: { 49 | data: ['Temperature', 'Rain'], 50 | right: '10%', 51 | textStyle: { 52 | fontSize, 53 | }, 54 | }, 55 | xAxis: [ 56 | { 57 | type: 'category', 58 | data: map(hourly.data, 'time').slice(0, 23), 59 | axisLabel: { 60 | formatter: formatterXAxisLabel, 61 | fontSize, 62 | }, 63 | }, 64 | ], 65 | yAxis: [ 66 | { 67 | type: 'value', 68 | max: temperatureMax, 69 | axisLabel: { 70 | formatter: units === 'us' ? '{value} ℉' : '{value} ℃', 71 | fontSize, 72 | }, 73 | splitLine: { 74 | show: false, 75 | }, 76 | splitArea: { 77 | show: true, 78 | areaStyle: { 79 | color: ['rgba(255,255,255,0.3)', 'rgba(200,200,200,0.1)'], 80 | }, 81 | }, 82 | }, 83 | { 84 | type: 'value', 85 | min: 0, 86 | max: rainMax, 87 | axisLabel: { 88 | formatter: units === 'us' ? '{value} in' : '{value} mm', 89 | fontSize, 90 | }, 91 | }, 92 | ], 93 | tooltip: { 94 | trigger: 'axis', 95 | backgroundColor: '#FFF', 96 | borderWidth: 1, 97 | borderColor: '#ccc', 98 | padding: [8, 17], 99 | extraCssText: 'box-shadow: 0 2px 4px 0 #CDCDCD;', 100 | formatter: formatterTooltip, 101 | axisPointer: { 102 | lineStyle: { 103 | color: '#666666', 104 | type: 'dashed', 105 | }, 106 | }, 107 | }, 108 | series: [ 109 | { 110 | name: 'Temperature', 111 | data: roundTemperature, 112 | type: 'line', 113 | smooth: true, 114 | lineStyle: { 115 | color: '#1869b7', 116 | width: 2, 117 | }, 118 | itemStyle: { 119 | color: '#1869b7', 120 | }, 121 | }, 122 | { 123 | name: 'Rain', 124 | type: 'bar', 125 | data: roundIntensity, 126 | yAxisIndex: 1, 127 | itemStyle: { 128 | color: '#A4A4A4', 129 | }, 130 | }, 131 | ], 132 | }; 133 | }; 134 | -------------------------------------------------------------------------------- /src/components/current-weather.tsx: -------------------------------------------------------------------------------- 1 | import Col from 'antd/es/col'; 2 | import Row from 'antd/es/row'; 3 | import * as React from 'react'; 4 | import { Filter, Timezone, Weather } from '../constants/types'; 5 | import { Utils } from '../utils'; 6 | import { WeatherIcon } from './icon/weather-icon'; 7 | import { WindIcon } from './icon/wind-icon'; 8 | 9 | interface CurrentWeatherProps { 10 | filter: Filter; 11 | location: string; 12 | currentWeather: Weather; 13 | timezone: Timezone; 14 | } 15 | 16 | export const CurrentWeather: React.FC = ({ 17 | filter, 18 | location, 19 | timezone, 20 | currentWeather, 21 | }: CurrentWeatherProps) => { 22 | return ( 23 |
24 | 25 | 26 |
27 | Rain: {Utils.getRain(currentWeather.precipIntensity, currentWeather.precipProbability, filter.units)} 28 |
29 | 30 | 31 |
32 | Wind: {Utils.getWindSpeed(currentWeather.windSpeed, filter.units)}{' '} 33 | 34 |
35 | 36 | 37 |
38 | Humidity: {Math.round(currentWeather.humidity * 100)} 39 |
40 | 41 | 42 |
43 | Pressure: {Utils.getPressure(currentWeather.pressure, filter.units)} 44 |
45 | 46 | 47 |
48 | Dew Point: {Utils.getTemperature(currentWeather.dewPoint, filter.units)} 49 |
50 | 51 | 52 |
UV Index: {currentWeather.uvIndex}
53 | 54 | 55 |
56 | Visibility: {Utils.getDistance(currentWeather.visibility, filter.units)} 57 |
58 | 59 |
60 | 61 | {location} 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 |
{Utils.getLocalTime(currentWeather.time, timezone.offset, 'YYYY-MM-DD HH:mm')}
70 |
71 | {currentWeather.summary} {Utils.getTemperature(currentWeather.temperature, filter.units)} 72 |
73 |
Feels like {Utils.getTemperature(currentWeather.apparentTemperature, filter.units)}
74 |
75 | 76 |
77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/daily-forecast.tsx: -------------------------------------------------------------------------------- 1 | import Col from 'antd/es/col'; 2 | import Row from 'antd/es/row'; 3 | import Table from 'antd/es/table'; 4 | import Column from 'antd/es/table/Column'; 5 | import * as React from 'react'; 6 | import { Filter, Timezone, Weather } from '../constants/types'; 7 | import { Utils } from '../utils'; 8 | import { MoonIcon } from './icon/moon-icon'; 9 | import { WeatherIcon } from './icon/weather-icon'; 10 | import { WindIcon } from './icon/wind-icon'; 11 | 12 | interface DailyForecastProps { 13 | filter: Filter; 14 | timezone: Timezone; 15 | dailyForecast: { 16 | summary: string; 17 | icon: string; 18 | data: Weather[]; 19 | }; 20 | } 21 | 22 | export const DailyForecast: React.FC = ({ 23 | filter, 24 | timezone, 25 | dailyForecast, 26 | }: DailyForecastProps) => { 27 | const isMobile = Utils.isMobile(); 28 | 29 | const expandedRowRender = (data: Weather) => ( 30 |
31 | 32 |
33 |
{data.summary}
34 |
35 |
36 | 37 | 38 |
Sunrise
39 |
40 | 41 |
@{Utils.getLocalTime(data.sunriseTime, timezone.offset, 'HH:mm')}
42 |
43 | 44 | 45 |
Sunset
46 |
47 | 48 |
@{Utils.getLocalTime(data.sunsetTime, timezone.offset, 'HH:mm')}
49 |
50 | 51 | 52 |
Moon
53 | 54 | 55 | {!isMobile ? ( 56 | 57 |
Rain
58 |
59 | {Utils.getRain(data.precipIntensity, data.precipProbability, filter.units)} 60 |
61 | 62 | ) : null} 63 | {!isMobile ? ( 64 | 65 |
Humidity
66 |
67 | {Math.round(data.humidity * 100)} 68 |
69 | 70 | ) : null} 71 |
72 | {isMobile ? ( 73 | 74 | 75 |
Rain
76 |
77 | {Utils.getRain(data.precipIntensity, data.precipProbability, filter.units)} 78 |
79 | 80 | 81 |
Humidity
82 |
83 | {Math.round(data.humidity * 100)} 84 |
85 | 86 |
87 | ) : null} 88 |
89 | ); 90 | 91 | const renderDailyForecastTable = () => ( 92 | String(data.time)} 96 | expandedRowRender={expandedRowRender}> 97 | ( 103 |
104 | 105 |
106 | )} 107 | /> 108 | ( 115 |
116 | {index === 0 ? 'Today' : Utils.getLocalTime(time, timezone.offset, 'ddd')} 117 |
118 | )} 119 | /> 120 | ( 126 |
127 | {Utils.getTemperature(data.temperatureLow, filter.units)} 128 |
@{Utils.getLocalTime(data.temperatureLowTime, timezone.offset, 'ha')}
129 |
130 | )} 131 | /> 132 | ( 138 |
139 | {Utils.getTemperature(data.temperatureHigh, filter.units)} 140 |
@{Utils.getLocalTime(data.temperatureHighTime, timezone.offset, 'ha')}
141 |
142 | )} 143 | /> 144 | ( 150 |
151 | {Utils.getWindSpeed(data.windSpeed, filter.units)} 152 |
153 | )} 154 | /> 155 |
156 | ); 157 | 158 | return ( 159 |
160 | 161 | {dailyForecast.data.length} days forecast 162 | 163 | 164 | {dailyForecast.summary} 165 | 166 | 167 | 168 | {renderDailyForecastTable()} 169 | 170 | 171 |
172 | ); 173 | }; 174 | -------------------------------------------------------------------------------- /src/components/hourly-forecast.tsx: -------------------------------------------------------------------------------- 1 | import Row from 'antd/es/row'; 2 | import * as echarts from 'echarts/lib/echarts'; 3 | import * as React from 'react'; 4 | import { useEffect } from 'react'; 5 | import { Filter, Timezone, Weather } from '../constants/types'; 6 | import { chartConfig } from './chart-config'; 7 | 8 | interface HourlyForecastProps { 9 | filter: Filter; 10 | timezone: Timezone; 11 | hourlyForecast: { 12 | summary: string; 13 | icon: string; 14 | data: Weather[]; 15 | }; 16 | } 17 | 18 | export const HourlyForecast: React.FC = ({ 19 | filter, 20 | timezone, 21 | hourlyForecast, 22 | }: HourlyForecastProps) => { 23 | useEffect(() => { 24 | const renderChart = () => { 25 | try { 26 | const weatherChart = document.getElementById('weather-chart'); 27 | weatherChart.parentNode.removeChild(weatherChart); 28 | } catch (err) {} 29 | 30 | // Generate div element dynamically for ECharts 31 | const divElement: HTMLDivElement = document.createElement('div'); 32 | divElement.setAttribute('id', 'weather-chart'); 33 | divElement.setAttribute('class', 'weather-chart'); 34 | document.getElementById('weather-chart-wrapper').appendChild(divElement); 35 | 36 | let chart = echarts.getInstanceByDom(divElement); 37 | if (!chart) { 38 | chart = echarts.init(divElement); 39 | chart.setOption(chartConfig(filter.units, timezone, hourlyForecast)); 40 | } 41 | }; 42 | renderChart(); 43 | }); 44 | 45 | return ( 46 |
47 | 48 | {hourlyForecast.summary} 49 | 50 | 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/icon/moon-icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface MoonIconProps { 4 | moonPhase: number; 5 | latitude: number; 6 | size: string; 7 | } 8 | 9 | export const MoonIcon: React.FC = ({ moonPhase, latitude, size }: MoonIconProps) => { 10 | const moonPhaseCalculated = Math.round((moonPhase * 100) / 3.57); 11 | 12 | const renderMoonIcon = () => { 13 | if (latitude > 0) { 14 | if (Math.floor(moonPhaseCalculated / 7) === 0) { 15 | if (moonPhaseCalculated === 0) { 16 | return ; 17 | } else { 18 | const className = `wi wi-moon-alt-waxing-crescent-${moonPhaseCalculated % 7}`; 19 | return ; 20 | } 21 | } else if (Math.floor(moonPhaseCalculated / 7) === 1) { 22 | if (moonPhaseCalculated === 7) { 23 | return ; 24 | } else { 25 | const className = `wi wi-moon-alt-waxing-gibbous-${moonPhaseCalculated % 7}`; 26 | return ; 27 | } 28 | } else if (Math.floor(moonPhaseCalculated / 7) === 2) { 29 | if (moonPhaseCalculated === 14) { 30 | return ; 31 | } else { 32 | const className = `wi wi-moon-alt-waning-gibbous-${moonPhaseCalculated % 7}`; 33 | return ; 34 | } 35 | } else if (Math.floor(moonPhaseCalculated / 7) === 3) { 36 | if (moonPhaseCalculated === 21) { 37 | return ; 38 | } else { 39 | const className = `wi wi-moon-alt-waning-crescent-${moonPhaseCalculated % 7}`; 40 | return ; 41 | } 42 | } else if (moonPhaseCalculated / 7 === 4) { 43 | return ; 44 | } 45 | } else { 46 | if (Math.floor(moonPhaseCalculated / 7) === 0) { 47 | if (moonPhaseCalculated === 0) { 48 | return ; 49 | } else { 50 | const className = `wi wi-moon-alt-waning-crescent-${7 - (moonPhaseCalculated % 7)}`; 51 | return ; 52 | } 53 | } else if (Math.floor(moonPhaseCalculated / 7) === 1) { 54 | if (moonPhaseCalculated === 7) { 55 | return ; 56 | } else { 57 | const className = `wi wi-moon-alt-waning-gibbous-${7 - (moonPhaseCalculated % 7)}`; 58 | return ; 59 | } 60 | } else if (Math.floor(moonPhaseCalculated / 7) === 2) { 61 | if (moonPhaseCalculated === 14) { 62 | return ; 63 | } else { 64 | const className = `wi wi-moon-alt-waxing-gibbous-${7 - (moonPhaseCalculated % 7)}`; 65 | return ; 66 | } 67 | } else if (Math.floor(moonPhaseCalculated / 7) === 3) { 68 | if (moonPhaseCalculated === 21) { 69 | return ; 70 | } else { 71 | const className = `wi wi-moon-alt-waxing-crescent-${7 - (moonPhaseCalculated % 7)}`; 72 | return ; 73 | } 74 | } else if (moonPhaseCalculated / 7 === 4) { 75 | return ; 76 | } 77 | } 78 | }; 79 | 80 | return
{renderMoonIcon()}
; 81 | }; 82 | -------------------------------------------------------------------------------- /src/components/icon/weather-icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Condition from '../../constants/weather-condition'; 3 | 4 | interface WeatherIconProps { 5 | icon: string; 6 | size?: string; 7 | } 8 | 9 | export const WeatherIcon: React.FC = ({ icon, size }: WeatherIconProps) => { 10 | const defaultSize = !size ? '1rem' : size; 11 | 12 | const renderIcon = () => { 13 | if (icon === Condition.CLEAR_DAY) { 14 | return ; 15 | } else if (icon === Condition.CLEAR_NIGHT) { 16 | return ; 17 | } else if (icon === Condition.RAIN) { 18 | return ; 19 | } else if (icon === Condition.SNOW) { 20 | return ; 21 | } else if (icon === Condition.SLEET) { 22 | return ; 23 | } else if (icon === Condition.WIND) { 24 | return ; 25 | } else if (icon === Condition.FOG) { 26 | return ; 27 | } else if (icon === Condition.CLOUDY) { 28 | return ; 29 | } else if (icon === Condition.PARTLY_CLOUDY_DAY) { 30 | return ; 31 | } else if (icon === Condition.PARTLY_CLOUDY_NIGHT) { 32 | return ; 33 | } else { 34 | return null; 35 | } 36 | }; 37 | 38 | return {renderIcon()}; 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/icon/wind-icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Condition from '../../constants/weather-condition'; 3 | 4 | interface WindIconProps { 5 | degree: number; 6 | size?: string; 7 | } 8 | 9 | export const WindIcon: React.FC = ({ degree, size }: WindIconProps) => { 10 | const defaultSize = !size ? '1rem' : size; 11 | 12 | const windCode = Math.round(degree / 22.5); 13 | 14 | const windIcon = () => { 15 | if (windCode === Condition.WIND_N) { 16 | return ; 17 | } else if (windCode === Condition.WIND_NNE) { 18 | return ; 19 | } else if (windCode === Condition.WIND_NE) { 20 | return ; 21 | } else if (windCode === Condition.WIND_ENE) { 22 | return ; 23 | } else if (windCode === Condition.WIND_E) { 24 | return ; 25 | } else if (windCode === Condition.WIND_ESE) { 26 | return ; 27 | } else if (windCode === Condition.WIND_SE) { 28 | return ; 29 | } else if (windCode === Condition.WIND_SSE) { 30 | return ; 31 | } else if (windCode === Condition.WIND_S) { 32 | return ; 33 | } else if (windCode === Condition.WIND_SSW) { 34 | return ; 35 | } else if (windCode === Condition.WIND_SW) { 36 | return ; 37 | } else if (windCode === Condition.WIND_WSW) { 38 | return ; 39 | } else if (windCode === Condition.WIND_W) { 40 | return ; 41 | } else if (windCode === Condition.WIND_WNW) { 42 | return ; 43 | } else if (windCode === Condition.WIND_NW) { 44 | return ; 45 | } else if (windCode === Condition.WIND_NNW) { 46 | return ; 47 | } else { 48 | return null; 49 | } 50 | }; 51 | 52 | return {windIcon()}; 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/nav-bar.tsx: -------------------------------------------------------------------------------- 1 | import { GithubOutlined, MenuOutlined } from '@ant-design/icons'; 2 | import Button from 'antd/es/button'; 3 | import Col from 'antd/es/col'; 4 | import DatePicker from 'antd/es/date-picker'; 5 | import Layout from 'antd/es/layout'; 6 | import Menu from 'antd/es/menu'; 7 | import Popover from 'antd/es/popover'; 8 | import Row from 'antd/es/row'; 9 | import Select from 'antd/es/select'; 10 | import * as moment from 'moment'; 11 | import * as React from 'react'; 12 | import { useDispatch, useSelector } from 'react-redux'; 13 | import { Link, useLocation, useNavigate } from 'react-router-dom'; 14 | import { NavBarState, RootState } from '../constants/types'; 15 | import { setFilter } from '../store/actions'; 16 | import { Utils } from '../utils'; 17 | import { WeatherSearch } from './weather-search'; 18 | 19 | const Option = Select.Option; 20 | const { Header } = Layout; 21 | 22 | export const NavBar: React.FC = () => { 23 | const dispatch = useDispatch(); 24 | const [navBarState, setNavBarState] = React.useState({ location: '', timestamp: 0 }); 25 | 26 | const isLoading = useSelector((state: RootState) => state.weather.isLoading); 27 | const filter = useSelector((state: RootState) => state.weather.filter); 28 | 29 | const datePickerOnChange = (date: moment.Moment, dateString: string) => { 30 | let timestamp = Number(moment(dateString, 'YYYY-MM-DD').format('X')); 31 | if (navBarState.timestamp !== timestamp) { 32 | const today = moment().format('YYYY-MM-DD'); 33 | timestamp = dateString === today ? 0 : timestamp; 34 | 35 | setNavBarState({ ...navBarState, timestamp }); 36 | dispatch(setFilter({ ...filter, timestamp })); 37 | } 38 | }; 39 | 40 | const handleSearch = (location: string) => { 41 | if (location && navBarState.location.toLowerCase() !== location.toLowerCase()) { 42 | setNavBarState({ ...navBarState, location }); 43 | dispatch(setFilter({ ...filter, searchedLocation: location })); 44 | } 45 | }; 46 | 47 | const handleUnitsChange = (units: any) => { 48 | dispatch(setFilter({ ...filter, units })); 49 | }; 50 | 51 | const WeatherLink = () => { 52 | const navigate = useNavigate(); 53 | return ( 54 | navigate('/')}> 55 | Weather 56 | 57 | ); 58 | }; 59 | 60 | const AboutLink = () => { 61 | const navigate = useNavigate(); 62 | return ( 63 | navigate('/about')}> 64 | About 65 | 66 | ); 67 | }; 68 | 69 | const WeatherMapLink = () => { 70 | const navigate = useNavigate(); 71 | return ( 72 | navigate('/map')}> 73 | Map 74 | 75 | ); 76 | }; 77 | 78 | const MyDatePicker = () => { 79 | const pathname = useLocation().pathname; 80 | const urlPath = pathname.substring(1) === '' ? 'weather' : pathname.substring(1); 81 | 82 | return ( 83 | 89 | ); 90 | }; 91 | 92 | const Search = () => { 93 | const pathname = useLocation().pathname; 94 | const urlPath = pathname.substring(1) === '' ? 'weather' : pathname.substring(1); 95 | 96 | return ( 97 | 98 | ); 99 | }; 100 | 101 | const UnitOptions = () => { 102 | const pathname = useLocation().pathname; 103 | const urlPath = pathname.substring(1) === '' ? 'weather' : pathname.substring(1); 104 | 105 | return ( 106 | 114 | ); 115 | }; 116 | 117 | const NavBar = () => { 118 | const navigate = useNavigate(); 119 | const pathname = useLocation().pathname; 120 | const urlPath = pathname.substring(1) === '' ? 'weather' : pathname.substring(1); 121 | 122 | return ( 123 |
124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | navigate('/d3_demo_app')}> 138 | D3 Demo 139 | 140 | 141 | 142 | navigate('/covid-19')}> 143 | Covid-19 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 |
156 | 157 |
158 | 159 | 160 | 161 | 162 | 163 |
173 | ); 174 | }; 175 | 176 | const MenuContent = () => { 177 | const pathname = useLocation().pathname; 178 | const urlPath = pathname.substring(1) === '' ? 'weather' : pathname.substring(1); 179 | 180 | return ( 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | ); 202 | }; 203 | 204 | const NavBarMobile = ( 205 |
206 | 207 | 208 | 209 | 210 | 211 | } trigger='click'> 212 |
217 | ); 218 | return
{Utils.isMobile() ? NavBarMobile : }
; 219 | }; 220 | -------------------------------------------------------------------------------- /src/components/weather-search.tsx: -------------------------------------------------------------------------------- 1 | import Input from 'antd/es/input'; 2 | import * as React from 'react'; 3 | import { ChangeEvent } from 'react'; 4 | 5 | const Search = Input.Search; 6 | 7 | interface WeatherSearchProps { 8 | onSearch: any; 9 | isDisabled: boolean; 10 | } 11 | 12 | export const WeatherSearch: React.FC = (props: WeatherSearchProps) => { 13 | const [location, setLocation] = React.useState(''); 14 | 15 | const handleChange = (event: ChangeEvent) => { 16 | const value = event.target.value; 17 | setLocation(value); 18 | }; 19 | 20 | const handleSubmit = () => { 21 | props.onSearch(location); 22 | }; 23 | 24 | return ( 25 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/constants/api-key.ts: -------------------------------------------------------------------------------- 1 | export const ApiKey = { 2 | mapbox: 'pk.eyJ1IjoiYmx1ZWdyYXkiLCJhIjoiY2s4Z2NsNmdtMDA3NTNwcnk1NTdmNHZudCJ9.UyzNvdQsuXmueW0gKWe-WA', // PROD 3 | firebase: 'AIzaSyCH2qBgaPuAfb5wfbtx0u3F5LtTvFgFDG0', 4 | windy: 'bRpzkzPp38FdrEGhYHWBzBf8lT3mIPSw', 5 | }; 6 | -------------------------------------------------------------------------------- /src/constants/coordinates.ts: -------------------------------------------------------------------------------- 1 | export const coordinates: { region: string; coordinates: number[] }[] = [ 2 | // North Island 3 | { region: 'Auckland', coordinates: [174.762301, -36.848779] }, 4 | { region: 'Bay of Plenty', coordinates: [176.731423, -38.044809] }, 5 | { region: 'Gisborne', coordinates: [177.916522, -38.544078] }, 6 | { region: "Hawke's Bay", coordinates: [176.7416374, -39.1089867] }, 7 | { region: 'Manawatu-Whanganui', coordinates: [175.4375574, -39.7273356] }, 8 | { region: 'Northland', coordinates: [173.7624053, -35.5795461] }, 9 | { region: 'Taranaki', coordinates: [174.4382721, -39.3538149] }, 10 | { region: 'Waikato', coordinates: [175.250159, -37.777292] }, 11 | { region: 'Wellington', coordinates: [175.377054, -41.193314] }, 12 | // South Island 13 | { region: 'Canterbury', coordinates: [171.1637245, -43.7542275] }, 14 | { region: 'Nelson Marlborough', coordinates: [173.4216613, -41.57269] }, 15 | { region: 'Otago Southland', coordinates: [169.177806, -45.362409] }, 16 | { region: 'West Coast', coordinates: [171.3399414, -42.6919232] }, 17 | ]; 18 | -------------------------------------------------------------------------------- /src/constants/message.ts: -------------------------------------------------------------------------------- 1 | export const USE_DEFAULT_LOCATION = 'Use default location: Auckland, New Zealand'; 2 | -------------------------------------------------------------------------------- /src/constants/types.ts: -------------------------------------------------------------------------------- 1 | export interface GeoCode { 2 | status: string; 3 | address: string; 4 | latitude: number; 5 | longitude: number; 6 | } 7 | 8 | export interface Filter { 9 | units: 'si' | 'us'; 10 | searchedLocation: string; 11 | timestamp: number; 12 | } 13 | 14 | export interface Timezone { 15 | timezone: string; 16 | offset: number; 17 | latitude: number; 18 | longitude: number; 19 | } 20 | 21 | export interface Weather { 22 | time: number; 23 | summary: string; 24 | icon: string; 25 | sunriseTime: number; 26 | sunsetTime: number; 27 | moonPhase: number; 28 | nearestStormDistance: number; 29 | precipIntensity: number; 30 | precipIntensityMax: number; 31 | precipIntensityMaxTime: number; 32 | precipProbability: number; 33 | precipType: string; 34 | temperature: number; 35 | apparentTemperature: number; 36 | temperatureHigh: number; 37 | temperatureHighTime: number; 38 | temperatureLow: number; 39 | temperatureLowTime: number; 40 | apparentTemperatureHigh: number; 41 | apparentTemperatureHighTime: number; 42 | apparentTemperatureLow: number; 43 | apparentTemperatureLowTime: number; 44 | apparentTemperatureMin: number; 45 | apparentTemperatureMinTime: number; 46 | apparentTemperatureMax: number; 47 | apparentTemperatureMaxTime: number; 48 | dewPoint: number; 49 | humidity: number; 50 | pressure: number; 51 | windSpeed: number; 52 | windGust: number; 53 | windBearing: number; 54 | cloudCover: number; 55 | uvIndex: number; 56 | visibility: number; 57 | } 58 | 59 | export interface Forecast { 60 | latitude: number; 61 | longitude: number; 62 | timezone: string; 63 | currently: Weather; 64 | minutely: { 65 | summary: string; 66 | icon: string; 67 | data: Weather[]; 68 | }; 69 | hourly: { 70 | summary: string; 71 | icon: string; 72 | data: Weather[]; 73 | }; 74 | daily: { 75 | summary: string; 76 | icon: string; 77 | data: Weather[]; 78 | }; 79 | flags: any; 80 | offset: number; 81 | } 82 | 83 | export interface NavBarState { 84 | location: string; 85 | timestamp: number; 86 | } 87 | 88 | export interface WeatherMapState { 89 | latitude: number; 90 | longitude: number; 91 | location: string; 92 | error: string; 93 | isLoading: boolean; 94 | } 95 | 96 | export interface ForecastState { 97 | isLoading: boolean; 98 | filter: Filter; 99 | location: string; 100 | timezone: Timezone; 101 | currentWeather: Weather; 102 | hourlyForecast: { 103 | summary: string; 104 | icon: string; 105 | data: Weather[]; 106 | }; 107 | dailyForecast: { 108 | summary: string; 109 | icon: string; 110 | data: Weather[]; 111 | }; 112 | error: string; 113 | } 114 | 115 | export interface RootState { 116 | weather: ForecastState; 117 | } 118 | 119 | export interface ToolTipType { 120 | display: boolean; 121 | data: { 122 | key: string; 123 | group: string; 124 | description?: string; 125 | }; 126 | type: 'network' | 'app'; 127 | pos?: { 128 | x: number; 129 | y: number; 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /src/constants/weather-condition.ts: -------------------------------------------------------------------------------- 1 | export const CLEAR_DAY = 'clear-day'; 2 | export const CLEAR_NIGHT = 'clear-night'; 3 | export const RAIN = 'rain'; 4 | export const SNOW = 'snow'; 5 | export const SLEET = 'sleet'; 6 | export const WIND = 'wind'; 7 | export const FOG = 'fog'; 8 | export const CLOUDY = 'cloudy'; 9 | export const PARTLY_CLOUDY_DAY = 'partly-cloudy-day'; 10 | export const PARTLY_CLOUDY_NIGHT = 'partly-cloudy-night'; 11 | 12 | // Wind coming from 13 | export const WIND_N = 0; 14 | export const WIND_NNE = 1; 15 | export const WIND_NE = 2; 16 | export const WIND_ENE = 3; 17 | export const WIND_E = 4; 18 | export const WIND_ESE = 5; 19 | export const WIND_SE = 6; 20 | export const WIND_SSE = 7; 21 | export const WIND_S = 8; 22 | export const WIND_SSW = 9; 23 | export const WIND_SW = 10; 24 | export const WIND_WSW = 11; 25 | export const WIND_W = 12; 26 | export const WIND_WNW = 13; 27 | export const WIND_NW = 14; 28 | export const WIND_NNW = 15; 29 | -------------------------------------------------------------------------------- /src/covid-19/chart-config.ts: -------------------------------------------------------------------------------- 1 | import 'echarts/lib/chart/bar'; 2 | import 'echarts/lib/chart/line'; 3 | import 'echarts/lib/chart/pie'; 4 | import 'echarts/lib/component/grid'; 5 | import 'echarts/lib/component/legend'; 6 | import 'echarts/lib/component/title'; 7 | import 'echarts/lib/component/toolbox'; 8 | import 'echarts/lib/component/tooltip'; 9 | import { get, map } from 'lodash'; 10 | 11 | export const dailyChartConfig: any = (covid19Data: any) => { 12 | return { 13 | title: [ 14 | { 15 | text: 'Probable and confirmed cases in New Zealand', 16 | left: 'center', 17 | top: -5, 18 | textStyle: { 19 | color: 'rgba(0, 0, 0, 0.65)', 20 | }, 21 | }, 22 | ], 23 | toolbox: { 24 | feature: { 25 | dataView: { show: true, readOnly: false, title: 'Data view' }, 26 | restore: { show: true, title: 'Restore' }, 27 | saveAsImage: { show: true, title: 'Save as image' }, 28 | }, 29 | }, 30 | legend: { 31 | data: [ 32 | 'Total cases', 33 | 'Total confirmed cases', 34 | 'Total recovered cases', 35 | 'New confirmed cases', 36 | 'New probable cases', 37 | ], 38 | bottom: 5, 39 | }, 40 | tooltip: { 41 | trigger: 'axis', 42 | axisPointer: { 43 | lineStyle: { 44 | color: '#666666', 45 | type: 'dashed', 46 | }, 47 | }, 48 | }, 49 | xAxis: [ 50 | { 51 | type: 'category', 52 | data: map(covid19Data, 'date'), 53 | }, 54 | ], 55 | yAxis: [ 56 | { 57 | type: 'value', 58 | name: 'Total cases', 59 | position: 'right', 60 | }, 61 | { 62 | type: 'value', 63 | name: 'New cases', 64 | position: 'left', 65 | }, 66 | ], 67 | series: [ 68 | { 69 | name: 'Total cases', 70 | type: 'line', 71 | data: map(covid19Data, (c) => c.totalConfirmed + c.totalProbable), 72 | smooth: true, 73 | }, 74 | { 75 | name: 'Total confirmed cases', 76 | type: 'line', 77 | data: map(covid19Data, 'totalConfirmed'), 78 | smooth: true, 79 | }, 80 | { 81 | name: 'Total recovered cases', 82 | type: 'line', 83 | data: map(covid19Data, 'totalRecovered'), 84 | smooth: true, 85 | }, 86 | { 87 | name: 'New confirmed cases', 88 | type: 'bar', 89 | stack: 'New cases', 90 | data: map(covid19Data, 'newConfirmed'), 91 | yAxisIndex: 1, 92 | }, 93 | { 94 | name: 'New probable cases', 95 | type: 'bar', 96 | stack: 'New cases', 97 | data: map(covid19Data, 'newProbable'), 98 | yAxisIndex: 1, 99 | }, 100 | ], 101 | }; 102 | }; 103 | 104 | export const pieChartConfig: any = (agesGroup: any, ethnicityGroup: any) => { 105 | let femaleNumber = 0; 106 | Object.keys(agesGroup).forEach((key) => { 107 | const female = get(agesGroup[key], 'female', 0); 108 | femaleNumber += female; 109 | }); 110 | 111 | let maleNumber = 0; 112 | Object.keys(agesGroup).forEach((key) => { 113 | const male = get(agesGroup[key], 'male', 0); 114 | maleNumber += male; 115 | }); 116 | 117 | let unknownNumber = 0; 118 | Object.keys(agesGroup).forEach((key) => { 119 | const unknown = get(agesGroup[key], 'unknown', 0); 120 | unknownNumber += unknown; 121 | }); 122 | 123 | const ethnicity: { name: string; value: number }[] = []; 124 | Object.keys(ethnicityGroup).forEach((key) => { 125 | ethnicity.push({ 126 | name: key, 127 | value: ethnicityGroup[key], 128 | }); 129 | }); 130 | 131 | return { 132 | title: [ 133 | { 134 | text: 'Age, Gender and Ethnicity Groups', 135 | left: 'center', 136 | top: 50, 137 | textStyle: { 138 | color: 'rgba(0, 0, 0, 0.65)', 139 | }, 140 | }, 141 | ], 142 | toolbox: { 143 | top: 50, 144 | right: '15%', 145 | feature: { 146 | dataView: { show: true, readOnly: false, title: 'Data view' }, 147 | restore: { show: true, title: 'Restore' }, 148 | saveAsImage: { show: true, title: 'Save as image' }, 149 | }, 150 | }, 151 | tooltip: { 152 | trigger: 'item', 153 | formatter: '{b} : {c} ({d}%)', 154 | }, 155 | series: [ 156 | { 157 | name: 'Gender Groups', 158 | type: 'pie', 159 | selectedMode: 'single', 160 | radius: [0, '40%'], 161 | center: ['32%', '55%'], 162 | label: { 163 | position: 'inner', 164 | }, 165 | labelLine: { 166 | show: false, 167 | }, 168 | data: [ 169 | { name: 'Female', value: femaleNumber }, 170 | { name: 'Male', value: maleNumber }, 171 | { name: 'Unknown', value: unknownNumber }, 172 | ], 173 | }, 174 | { 175 | name: 'Age Groups', 176 | type: 'pie', 177 | radius: ['45%', '60%'], 178 | center: ['32%', '55%'], 179 | label: { 180 | formatter: '{a|{a}}{abg|}\n{hr|}\n {b|{b}:}{c} {per|{d}%} ', 181 | backgroundColor: '#eee', 182 | borderColor: '#aaa', 183 | borderWidth: 1, 184 | borderRadius: 4, 185 | rich: { 186 | a: { 187 | color: '#999', 188 | lineHeight: 22, 189 | align: 'center', 190 | }, 191 | hr: { 192 | borderColor: '#aaa', 193 | width: '100%', 194 | borderWidth: 0.5, 195 | height: 0, 196 | }, 197 | b: { 198 | fontSize: 16, 199 | lineHeight: 33, 200 | }, 201 | per: { 202 | color: '#eee', 203 | backgroundColor: '#334455', 204 | padding: [2, 4], 205 | borderRadius: 2, 206 | }, 207 | }, 208 | }, 209 | data: Object.keys(agesGroup).map((key) => { 210 | const female = get(agesGroup[key], 'female', 0); 211 | const male = get(agesGroup[key], 'male', 0); 212 | const unknown = get(agesGroup[key], 'unknown', 0); 213 | return { 214 | name: key, 215 | value: female + male + unknown, 216 | }; 217 | }), 218 | }, 219 | { 220 | name: 'Ethnicity Groups', 221 | type: 'pie', 222 | radius: '60%', 223 | center: ['75%', '55%'], 224 | data: ethnicity, 225 | }, 226 | ], 227 | }; 228 | }; 229 | 230 | export const testsChartConfig: any = (covid19Data: any) => { 231 | return { 232 | title: [ 233 | { 234 | text: 'Tests v.s Cases', 235 | left: 'center', 236 | top: -2, 237 | textStyle: { 238 | color: 'rgba(0, 0, 0, 0.65)', 239 | }, 240 | }, 241 | ], 242 | toolbox: { 243 | feature: { 244 | dataView: { show: true, readOnly: false, title: 'Data view' }, 245 | restore: { show: true, title: 'Restore' }, 246 | saveAsImage: { show: true, title: 'Save as image' }, 247 | }, 248 | }, 249 | legend: { 250 | data: ['Total cases', 'Total tests', 'Daily tests', 'Daily cases'], 251 | bottom: 5, 252 | }, 253 | tooltip: { 254 | trigger: 'axis', 255 | axisPointer: { 256 | lineStyle: { 257 | color: '#666666', 258 | type: 'dashed', 259 | }, 260 | }, 261 | }, 262 | xAxis: [ 263 | { 264 | type: 'category', 265 | data: map(covid19Data, 'date'), 266 | }, 267 | { 268 | type: 'category', 269 | data: map(covid19Data, 'date'), 270 | gridIndex: 1, 271 | }, 272 | ], 273 | yAxis: [ 274 | { 275 | type: 'value', 276 | name: 'Total cases', 277 | position: 'right', 278 | }, 279 | { 280 | type: 'value', 281 | name: 'Total tests', 282 | position: 'left', 283 | }, 284 | { 285 | type: 'value', 286 | name: 'Daily cases', 287 | position: 'right', 288 | gridIndex: 1, 289 | }, 290 | { 291 | type: 'value', 292 | name: 'Daily tests', 293 | position: 'left', 294 | gridIndex: 1, 295 | }, 296 | ], 297 | grid: [ 298 | { left: '5%', width: '40%' }, 299 | { left: '55%', width: '40%' }, 300 | ], 301 | series: [ 302 | { 303 | name: 'Total cases', 304 | type: 'line', 305 | data: map(covid19Data, (c) => c.totalConfirmed + c.totalProbable), 306 | smooth: true, 307 | }, 308 | { 309 | name: 'Total tests', 310 | type: 'bar', 311 | stack: 'Tests', 312 | yAxisIndex: 1, 313 | data: map(covid19Data, 'totalTests'), 314 | }, 315 | { 316 | name: 'Daily tests', 317 | type: 'bar', 318 | data: map(covid19Data, 'newTests'), 319 | xAxisIndex: 1, 320 | yAxisIndex: 3, 321 | gridIndex: 1, 322 | }, 323 | { 324 | name: 'Daily cases', 325 | type: 'line', 326 | data: map(covid19Data, (c) => c.newConfirmed + c.newProbable), 327 | smooth: true, 328 | xAxisIndex: 1, 329 | yAxisIndex: 2, 330 | gridIndex: 1, 331 | }, 332 | ], 333 | }; 334 | }; 335 | -------------------------------------------------------------------------------- /src/covid-19/covid-19.tsx: -------------------------------------------------------------------------------- 1 | import Alert from 'antd/es/alert'; 2 | import BackTop from 'antd/es/back-top'; 3 | import Card from 'antd/es/card'; 4 | import Col from 'antd/es/col'; 5 | import Row from 'antd/es/row'; 6 | import Spin from 'antd/es/spin'; 7 | import clsx from 'clsx'; 8 | import * as echarts from 'echarts/lib/echarts'; 9 | import { find, get, isEmpty, last, map } from 'lodash'; 10 | import mapboxgl from 'mapbox-gl'; 11 | import * as React from 'react'; 12 | import { useEffect } from 'react'; 13 | import { ApiKey } from '../constants/api-key'; 14 | import { coordinates } from '../constants/coordinates'; 15 | import { dailyChartConfig, pieChartConfig, testsChartConfig } from './chart-config'; 16 | 17 | declare let process: { 18 | env: { 19 | NODE_ENV: string; 20 | }; 21 | }; 22 | 23 | const CLOUD_URL = 24 | process.env.NODE_ENV === 'development' 25 | ? 'http://localhost:3000/covidData' 26 | : 'https://covid19nz.s3.amazonaws.com/covid-19.json'; 27 | 28 | export const Covid19: React.FC = () => { 29 | const [isLoadingState, setIsloadingState] = React.useState(true); 30 | const [errorState, setErrorState] = React.useState(null); 31 | const [covidState, setCovidState] = React.useState(null); 32 | const [largestState, setLargestState] = React.useState(0); 33 | const [totalCasesState, setTotalCasesState] = React.useState(0); 34 | 35 | const renderChart = () => { 36 | try { 37 | const covidChart = document.getElementById('covid-daily-chart'); 38 | covidChart.parentNode.removeChild(covidChart); 39 | 40 | const pieChart = document.getElementById('covid-pie-chart'); 41 | pieChart.parentNode.removeChild(pieChart); 42 | 43 | const testsChart = document.getElementById('covid-tests-chart'); 44 | testsChart.parentNode.removeChild(testsChart); 45 | } catch (err) {} 46 | 47 | // Generate div element dynamically for ECharts 48 | const covidDailyDivElement: HTMLDivElement = document.createElement('div'); 49 | covidDailyDivElement.setAttribute('id', 'covid-daily-chart'); 50 | covidDailyDivElement.setAttribute('class', 'covid-daily-chart'); 51 | document.getElementById('covid-chart-wrapper').appendChild(covidDailyDivElement); 52 | 53 | const covidPieDivElement: HTMLDivElement = document.createElement('div'); 54 | covidPieDivElement.setAttribute('id', 'covid-pie-chart'); 55 | covidPieDivElement.setAttribute('class', 'covid-pie-chart'); 56 | document.getElementById('covid-pie-wrapper').appendChild(covidPieDivElement); 57 | 58 | const covidTestsDivElement: HTMLDivElement = document.createElement('div'); 59 | covidTestsDivElement.setAttribute('id', 'covid-tests-chart'); 60 | covidTestsDivElement.setAttribute('class', 'covid-tests-chart'); 61 | document.getElementById('covid-tests-wrapper').appendChild(covidTestsDivElement); 62 | 63 | let dailyChart = echarts.getInstanceByDom(covidDailyDivElement); 64 | let pieChart = echarts.getInstanceByDom(covidPieDivElement); 65 | let testsChart = echarts.getInstanceByDom(covidTestsDivElement); 66 | if (!dailyChart && !pieChart && !testsChart) { 67 | dailyChart = echarts.init(covidDailyDivElement); 68 | dailyChart.setOption(dailyChartConfig(covidState.daily)); 69 | 70 | pieChart = echarts.init(covidPieDivElement); 71 | pieChart.setOption(pieChartConfig(covidState.ages, covidState.ethnicity)); 72 | 73 | testsChart = echarts.init(covidTestsDivElement); 74 | testsChart.setOption(testsChartConfig(covidState.daily)); 75 | } 76 | }; 77 | 78 | const renderMapbox = () => { 79 | const features: { 80 | type: 'Feature'; 81 | geometry: { 82 | type: 'Point'; 83 | coordinates: number[]; 84 | }; 85 | properties: { 86 | region: string; 87 | totalCases: number; 88 | femaleCases: number; 89 | maleCases: number; 90 | unknownCases: number; 91 | percentage: number; 92 | }; 93 | }[] = []; 94 | 95 | const caseByRegion = { 96 | Auckland: { 97 | total: 0, 98 | female: 0, 99 | male: 0, 100 | unknown: 0, 101 | }, 102 | Wellington: { 103 | total: 0, 104 | female: 0, 105 | male: 0, 106 | unknown: 0, 107 | }, 108 | Waikato: { 109 | total: 0, 110 | female: 0, 111 | male: 0, 112 | unknown: 0, 113 | }, 114 | 'Manawatu-Whanganui': { 115 | total: 0, 116 | female: 0, 117 | male: 0, 118 | unknown: 0, 119 | }, 120 | Canterbury: { 121 | total: 0, 122 | female: 0, 123 | male: 0, 124 | unknown: 0, 125 | }, 126 | }; 127 | 128 | // Calculate the totalCases 129 | const totalCases = last(covidState.daily)['totalConfirmed'] + last(covidState.daily)['totalProbable']; 130 | setTotalCasesState(totalCases); 131 | 132 | const regionTotalCases = (key: string) => { 133 | return ( 134 | get(covidState.location[key], 'female', 0) + 135 | get(covidState.location[key], 'male', 0) + 136 | get(covidState.location[key], 'unknown', 0) 137 | ); 138 | }; 139 | 140 | const getCaseDataByRegion = (caseByRegion: any, key: string, region: string) => { 141 | caseByRegion[region].total += regionTotalCases(key); 142 | caseByRegion[region].female += get(covidState.location[key], 'female', 0); 143 | caseByRegion[region].male += get(covidState.location[key], 'male', 0); 144 | caseByRegion[region].unknown += get(covidState.location[key], 'unknown', 0); 145 | }; 146 | 147 | // Prepare GeoJson data 148 | Object.keys(covidState.location).forEach((key) => { 149 | if (key === 'Auckland' || key === 'Counties Manukau' || key === 'Waitemata') { 150 | // Auckland region 151 | getCaseDataByRegion(caseByRegion, key, 'Auckland'); 152 | } else if (key === 'Capital and Coast' || key === 'Hutt Valley' || key === 'Wairarapa') { 153 | // Wellington region 154 | getCaseDataByRegion(caseByRegion, key, 'Wellington'); 155 | } else if (key === 'Waikato' || key === 'Lakes') { 156 | // Waikato region 157 | getCaseDataByRegion(caseByRegion, key, 'Waikato'); 158 | } else if (key === 'Whanganui' || key === 'MidCentral') { 159 | // Manawatu-Whanganui region 160 | getCaseDataByRegion(caseByRegion, key, 'Manawatu-Whanganui'); 161 | } else if (key === 'Canterbury' || key === 'South Canterbury') { 162 | // Canterbury region 163 | getCaseDataByRegion(caseByRegion, key, 'Canterbury'); 164 | } else { 165 | features.push({ 166 | type: 'Feature', 167 | geometry: { 168 | type: 'Point', 169 | coordinates: find(coordinates, (coordinate) => 170 | key === 'Tairāwhiti' 171 | ? coordinate.region === 'Gisborne' 172 | : key === 'Southern' 173 | ? coordinate.region === 'Otago Southland' 174 | : coordinate.region === key 175 | ).coordinates, 176 | }, 177 | properties: { 178 | region: key === 'Tairāwhiti' ? 'Gisborne' : key === 'Southern' ? 'Otago Southland' : key, 179 | totalCases: regionTotalCases(key), 180 | femaleCases: get(covidState.location[key], 'female', 0), 181 | maleCases: get(covidState.location[key], 'male', 0), 182 | unknownCases: get(covidState.location[key], 'unknown', 0), 183 | percentage: Math.ceil((regionTotalCases(key) / totalCases) * 100), 184 | }, 185 | }); 186 | } 187 | }); 188 | 189 | Object.keys(caseByRegion).forEach((region) => { 190 | features.push({ 191 | type: 'Feature', 192 | geometry: { 193 | type: 'Point', 194 | coordinates: find(coordinates, (coordinate) => coordinate.region === region).coordinates, 195 | }, 196 | properties: { 197 | region: region, 198 | totalCases: caseByRegion[region].total, 199 | femaleCases: caseByRegion[region].female, 200 | maleCases: caseByRegion[region].male, 201 | unknownCases: caseByRegion[region].unknown, 202 | percentage: Math.ceil((caseByRegion[region].total / totalCases) * 100), 203 | }, 204 | }); 205 | }); 206 | 207 | // Find the largest number in the regions 208 | const largest = Math.max.apply(Math, map(features, 'properties.totalCases')); 209 | setLargestState(largest); 210 | 211 | // Initial Mapbox 212 | mapboxgl.accessToken = ApiKey.mapbox; 213 | const mapBox = new mapboxgl.Map({ 214 | container: 'new-zealand-map', 215 | style: 'mapbox://styles/mapbox/light-v10', 216 | center: [173.295319, -41.288483], // [lng, lat], Nelson 217 | zoom: 5, 218 | }); 219 | // disable map rotation using right click + drag 220 | mapBox.dragRotate.disable(); 221 | // disable map rotation using touch rotation gesture 222 | mapBox.touchZoomRotate.disableRotation(); 223 | 224 | mapBox.on('load', () => { 225 | mapBox.addSource('covidCases', { 226 | type: 'geojson', 227 | data: { 228 | type: 'FeatureCollection', 229 | features, 230 | }, 231 | }); 232 | 233 | // Add a layer showing the places. 234 | mapBox.addLayer({ 235 | id: 'regions', 236 | interactive: true, 237 | type: 'circle', 238 | source: 'covidCases', 239 | paint: { 240 | 'circle-color': ['interpolate', ['linear'], ['get', 'totalCases'], 1, '#FCA107', largest, '#7F3121'], 241 | 'circle-opacity': 0.65, 242 | 'circle-radius': ['interpolate', ['linear'], ['get', 'totalCases'], 1, 10, largest, Math.ceil(largest / 7)], 243 | }, 244 | }); 245 | 246 | // Add a layer showing the number of cases. 247 | mapBox.addLayer({ 248 | id: 'cases-count', 249 | type: 'symbol', 250 | source: 'covidCases', 251 | layout: { 252 | 'text-field': '{totalCases}', 253 | 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 254 | 'text-size': 12, 255 | }, 256 | paint: { 257 | 'text-color': 'rgba(0,0,0,0.45)', 258 | }, 259 | }); 260 | 261 | // Create a popup, but don't add it to the map yet. 262 | const popup = new mapboxgl.Popup({ 263 | closeButton: false, 264 | closeOnClick: false, 265 | }); 266 | 267 | mapBox.on('mouseenter', 'regions', (event: any) => { 268 | // Change the cursor style as a UI indicator. 269 | mapBox.getCanvas().style.cursor = 'pointer'; 270 | 271 | const coordinates = event.features[0].geometry.coordinates.slice(); 272 | const description = 273 | `${event.features[0].properties.region}
` + 274 | `${event.features[0].properties.totalCases} cases, ${event.features[0].properties.percentage}%
` + 275 | `female: ${event.features[0].properties.femaleCases}, male: ${event.features[0].properties.maleCases}, unknown: ${event.features[0].properties.unknownCases}`; 276 | // Ensure that if the map is zoomed out such that multiple 277 | // copies of the feature are visible, the popup appears 278 | // over the copy being pointed to. 279 | while (Math.abs(event.lngLat.lng - coordinates[0]) > 180) { 280 | coordinates[0] += event.lngLat.lng > coordinates[0] ? 360 : -360; 281 | } 282 | 283 | // Populate the popup and set its coordinates 284 | // based on the feature found. 285 | popup.setLngLat(coordinates).setHTML(description).addTo(mapBox); 286 | }); 287 | 288 | mapBox.on('mouseleave', 'regions', () => { 289 | mapBox.getCanvas().style.cursor = ''; 290 | popup.remove(); 291 | }); 292 | }); 293 | }; 294 | 295 | useEffect(() => { 296 | fetch(CLOUD_URL) 297 | .then((response: any): any => response.json()) 298 | .then((data: any) => { 299 | setCovidState(data); 300 | setIsloadingState(false); 301 | }) 302 | .catch((error) => { 303 | setIsloadingState(false); 304 | setErrorState(error); 305 | }); 306 | }, []); 307 | 308 | useEffect(() => { 309 | if (!isLoadingState && !isEmpty(covidState)) { 310 | renderChart(); 311 | renderMapbox(); 312 | } 313 | }); 314 | 315 | return ( 316 |
317 | 318 | Covid-19 in New Zealand 319 | 320 | {isLoadingState ? ( 321 | 322 | 323 |

Loading...

324 |
325 | ) : !isEmpty(errorState) ? ( 326 |
327 | 328 | 329 | 330 | 331 | 332 |
333 | ) : ( 334 | <> 335 | {/* Grid */} 336 | 337 | 338 | 339 |
{last(covidState.daily)['totalConfirmed']}
340 |
341 | 342 | 343 | 344 |
{totalCasesState}
345 |
346 | 347 | 348 | 349 |
350 | {last(covidState.daily)['totalRecovered']} 351 |
352 |
353 | 354 | 355 | 356 |
357 | {last(covidState.daily)['totalDeath']} 358 |
359 |
360 | 361 |
362 | 363 | [Age, Gender and Ethnicity Groups] [Map] [ 364 | Tests v.s Cases] 365 | 366 | 367 |
1st case on 28-02-2020
368 |
369 | {/* Chart */} 370 | 371 | 372 | 373 | {/* Map */} 374 | 375 |
Total Cases by Regions
376 |
377 | 378 |
379 |
380 |
381 |
382 |
383 |
384 |
1
385 |
{largestState}
386 |
387 |
388 |
Cases
389 |
390 |
391 |
392 |
393 | 394 | {/* Last updated */} 395 | 396 | 397 | Last updated: {covidState.lastUpdated} 398 | 399 | 400 | 401 | )} 402 | 403 |
404 | ); 405 | }; 406 | -------------------------------------------------------------------------------- /src/css/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto:400,500,700'); 2 | 3 | body { 4 | font-size: 1rem; 5 | font-family: 'Roboto', sans-serif; 6 | height: 100%; 7 | } 8 | 9 | .content { 10 | min-height: 100%; 11 | width: 100%; 12 | } 13 | 14 | .footer { 15 | text-align: center; 16 | background: #fff; 17 | font-size: 1rem; 18 | } 19 | 20 | .not-found-content { 21 | display:flex; 22 | justify-content: center; 23 | padding-top: 24px; 24 | } 25 | 26 | .about-content { 27 | padding-top: 2rem; 28 | } 29 | 30 | /* Navigation bar for desktop version start */ 31 | .nav-bar { 32 | padding: 0 1rem; 33 | z-index: 1; 34 | width: 100%; 35 | } 36 | 37 | .nav-bar-menu { 38 | line-height: 4rem; 39 | } 40 | 41 | .nav-bar-icon { 42 | text-align: right; 43 | } 44 | 45 | .weather-search-outer { 46 | padding: 0 0.5rem; 47 | } 48 | /* Navigation bar for desktop version end */ 49 | 50 | /* Navigation bar for mobile device start */ 51 | .nav-bar-mobile { 52 | padding: 0 1.5rem; 53 | } 54 | 55 | .ant-popover-inner-content { 56 | padding: 0; 57 | } 58 | /* Navigation bar for mobile device end */ 59 | 60 | .error { 61 | padding: 2rem 2rem; 62 | } 63 | 64 | .fetching-weather-content { 65 | padding: 4rem 2rem; 66 | } 67 | 68 | .fetching-weather-spinner { 69 | padding: 0 0.5rem; 70 | } 71 | 72 | .loading-text { 73 | color: rgba(0, 0, 0, 0.65); 74 | } 75 | 76 | /* Current weather section start */ 77 | .current-weather-top { 78 | background: #fafafa; 79 | padding: 0.5rem 2rem; 80 | } 81 | 82 | .current-weather-top-item { 83 | text-align: center; 84 | font-size: 0.8rem; 85 | } 86 | 87 | .current-weather-location { 88 | padding: 0.5rem 1rem; 89 | font-size: 2rem; 90 | font-weight: 700; 91 | } 92 | 93 | .current-weather-summary { 94 | padding-top: 1rem; 95 | font-size: 1rem; 96 | } 97 | 98 | .current-weather-icon { 99 | margin: 0.7rem 1rem 0 0; 100 | } 101 | /* Current weather section end */ 102 | 103 | .forecast-summary { 104 | padding: 0.5rem 1rem; 105 | font-size: 1.2rem; 106 | font-weight: 700; 107 | } 108 | 109 | .forecast-title { 110 | font-size: 1.8rem; 111 | font-weight: 500; 112 | } 113 | 114 | .daily-forecast-table-outer { 115 | padding: 1rem 0; 116 | } 117 | 118 | .daily-forecast-item { 119 | font-size: 0.8rem; 120 | } 121 | 122 | .daily-forecast-sub-item-wrapper { 123 | text-align: center; 124 | } 125 | 126 | .daily-forecast-sub-item-summary { 127 | font-size: 0.8rem; 128 | font-weight: 700; 129 | } 130 | 131 | /* Weather chart section start */ 132 | #weather-chart-wrapper { 133 | padding: 0 2rem; 134 | } 135 | 136 | .weather-chart { 137 | height: 20rem; 138 | width: 50rem; 139 | } 140 | 141 | .weather-chart-tooltip-time { 142 | font-size: 0.9rem; 143 | color: #949494; 144 | line-height: 1.1rem; 145 | } 146 | 147 | .weather-chart-tooltip-item { 148 | color: #2e2e2e; 149 | font-size: 0.8rem; 150 | font-weight: 500; 151 | line-height: 0.5rem; 152 | } 153 | /* Weather chart section end */ 154 | 155 | /* Covid-19 section start */ 156 | #covid-chart-wrapper { 157 | padding: 0.5rem 2rem; 158 | } 159 | 160 | #covid-pie-wrapper { 161 | padding: 0.5rem 2rem; 162 | } 163 | 164 | #new-zealand-map { 165 | height: 50rem; 166 | width: 40rem; 167 | } 168 | 169 | .covid-daily-chart { 170 | height: 30rem; 171 | width: 55rem; 172 | } 173 | 174 | .covid-pie-chart { 175 | height: 40rem; 176 | width: 70rem; 177 | } 178 | 179 | .covid-tests-chart { 180 | padding-top: 2rem; 181 | height: 30rem; 182 | width: 70rem; 183 | } 184 | 185 | .covid-title { 186 | padding: 2rem 1rem 1rem 1rem; 187 | font-size: 1.8rem; 188 | font-weight: 700; 189 | } 190 | 191 | .covid-cases-card { 192 | text-align: center; 193 | } 194 | 195 | .covid-cases-card-content { 196 | font-size: 1.8rem; 197 | font-weight: 700; 198 | } 199 | 200 | .covid-recovered-cases-content { 201 | color: #8aca2b; 202 | } 203 | 204 | .covid-death-cases-content { 205 | color: red; 206 | } 207 | 208 | .map-title { 209 | padding-top: 2rem; 210 | padding-bottom: 0.5rem; 211 | font-size: 1.2rem; 212 | font-weight: 700; 213 | } 214 | 215 | .map-legend-overlay { 216 | position: absolute; 217 | width: 35%; 218 | top: 0; 219 | left: 0; 220 | padding: 10px; 221 | } 222 | 223 | .map-legend-wrapper { 224 | background-color: #fff; 225 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); 226 | border-radius: 3px; 227 | padding: 10px; 228 | margin-bottom: 10px; 229 | } 230 | 231 | .map-legend { 232 | height: 10px; 233 | width: 100%; 234 | background: linear-gradient(to right, #fca107, #7f3121); 235 | } 236 | 237 | .last-updated { 238 | font-size: 0.8rem; 239 | } 240 | /* Covid-19 section end */ 241 | 242 | #windy { 243 | width: 100%; 244 | min-height: 100%; 245 | height: 85vh; 246 | } 247 | 248 | ::-webkit-scrollbar { 249 | height: 0.8rem; 250 | width: 0.8rem; 251 | background-color: #fafafa; 252 | } 253 | 254 | ::-webkit-scrollbar-thumb { 255 | border-radius: 1rem; 256 | background-color: #a4a4a4; 257 | } 258 | 259 | @media screen and (min-width: 993px) and (max-width: 1200px) { 260 | } 261 | 262 | @media screen and (min-width: 769px) and (max-width: 992px) { 263 | } 264 | 265 | @media screen and (min-device-width: 577px) and (max-device-width: 768px) { 266 | } 267 | 268 | @media screen and (min-device-width: 320px) and (max-device-width: 576px) { 269 | .current-weather-top { 270 | background: #fafafa; 271 | padding: 0.5rem 1rem; 272 | } 273 | 274 | .current-weather-top-item { 275 | text-align: center; 276 | font-size: 0.8rem; 277 | } 278 | 279 | .current-weather-location { 280 | padding: 0.5rem 1rem; 281 | font-size: 1.4rem; 282 | font-weight: 700; 283 | } 284 | 285 | .current-weather-icon { 286 | margin: 0.5rem 1rem 0 0; 287 | } 288 | 289 | .forecast-title { 290 | font-size: 1.4rem; 291 | font-weight: 500; 292 | } 293 | 294 | .forecast-summary { 295 | padding: 0.5rem 1rem; 296 | font-size: 1rem; 297 | font-weight: 700; 298 | } 299 | 300 | #weather-chart-wrapper { 301 | padding: 0 0.5rem; 302 | } 303 | 304 | .weather-chart { 305 | height: 18rem; 306 | width: 25rem; 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/d3-demo/d3-demo-app.tsx: -------------------------------------------------------------------------------- 1 | import { event } from 'd3'; 2 | import { drag } from 'd3-drag'; 3 | import { forceCenter, forceLink, forceManyBody, forceSimulation, forceX, forceY } from 'd3-force'; 4 | import { select } from 'd3-selection'; 5 | import * as React from 'react'; 6 | import { Link } from 'react-router-dom'; 7 | import './d3-force.css'; 8 | import Gauge from './gauge'; 9 | import appTraffic from './mock/app-traffic.json'; 10 | import { TrafficService } from './traffic'; 11 | 12 | export class D3DemoApp extends React.Component { 13 | nodes: any[] = []; 14 | links: any[] = []; 15 | hits: any[] = []; 16 | simulation: any = {}; 17 | width: number = window.innerWidth; 18 | height: number = window.innerHeight; 19 | svg: any = {}; 20 | g: any = {}; 21 | link: any = {}; 22 | node: any = {}; 23 | trafficService: any = {}; 24 | requests: any[] = []; 25 | isActive = true; 26 | intervalId = 0; 27 | powerGauge: Gauge = null; 28 | 29 | constructor(props: any) { 30 | super(props); 31 | // Create force simulation 32 | this.simulation = forceSimulation() 33 | .force('x', forceX(this.width / 2).strength(0.185)) 34 | .force('y', forceY(this.height / 2).strength(0.185)) 35 | .force( 36 | 'link', 37 | forceLink() 38 | .id((d: any) => { 39 | return d.id; 40 | }) 41 | .distance((d: any) => { 42 | const numlinks = this.links.filter((link: any) => { 43 | return ( 44 | link.source.name === d.source.name || 45 | link.source.name === d.target.name || 46 | link.target.name === d.target.name || 47 | link.target.name === d.source.name 48 | ); 49 | }); 50 | return numlinks.length * 0.6 * (this.height / 130) + this.width / 300; 51 | }) 52 | .strength(0.1) 53 | ) 54 | .force( 55 | 'charge', 56 | forceManyBody().strength((d: any) => { 57 | const numlinks = this.links.filter((link: any) => { 58 | return ( 59 | link.source.name === d.name || 60 | link.source.name === d.name || 61 | link.target.name === d.name || 62 | link.target.name === d.name 63 | ); 64 | }); 65 | return numlinks.length * -50 - 1000; 66 | }) 67 | ) 68 | .force('center', forceCenter(this.width / 2, this.height / 2)); 69 | } 70 | 71 | getNode(name: string) { 72 | return this.nodes.find((node) => { 73 | return name === node.name; 74 | }); 75 | } 76 | 77 | componentDidMount() { 78 | this.svg = select('svg.svg-content-responsive'); 79 | this.g = this.svg.append('g'); 80 | this.link = this.g.append('g').selectAll('.link'); 81 | this.node = this.g.append('g').selectAll('.node'); 82 | this.trafficService = new TrafficService(this.svg, this.width); 83 | 84 | // Initial gauge 85 | this.powerGauge = new Gauge(this.svg, { 86 | size: 150, 87 | clipWidth: 300, 88 | clipHeight: 300, 89 | ringWidth: 60, 90 | maxValue: 1000, 91 | transitionMs: 5000, 92 | x: 250, 93 | y: 10, 94 | title: 'Logs per second', 95 | titleDx: 36, 96 | titleDy: 90, 97 | }); 98 | this.powerGauge.render(undefined); 99 | 100 | const ticked = () => { 101 | this.link 102 | .attr('x1', (d: any) => { 103 | return d.source.x; 104 | }) 105 | .attr('y1', (d: any) => { 106 | return d.source.y; 107 | }) 108 | .attr('x2', (d: any) => { 109 | return d.target.x; 110 | }) 111 | .attr('y2', (d: any) => { 112 | return d.target.y; 113 | }); 114 | this.node.attr('transform', (d: any) => { 115 | return 'translate(' + d.x + ',' + d.y + ')'; 116 | }); 117 | }; 118 | 119 | const dragstarted = () => { 120 | if (!event.active) { 121 | this.simulation.alphaTarget(0.3).restart(); 122 | } 123 | event.subject.fx = event.subject.x; 124 | event.subject.fy = event.subject.y; 125 | }; 126 | 127 | const dragged = () => { 128 | event.subject.fx = event.x; 129 | event.subject.fy = event.y; 130 | }; 131 | 132 | const dragended = () => { 133 | if (!event.active) { 134 | this.simulation.alphaTarget(0); 135 | } 136 | event.subject.fx = null; 137 | event.subject.fy = null; 138 | }; 139 | 140 | const drawGraph = () => { 141 | // Apply the general update pattern to the nodes. 142 | this.node = this.node.data(this.nodes, (d: any) => { 143 | return d.name; 144 | }); 145 | this.node.exit().remove(); 146 | 147 | const nodeEnter = this.node.enter().append('g').attr('class', 'node'); 148 | nodeEnter 149 | .append('circle') 150 | .attr('class', (d: any) => { 151 | return d.name + ' ' + d.priority; 152 | }) 153 | .attr('r', this.width / 200) 154 | .call(drag().on('start', dragstarted).on('drag', dragged).on('end', dragended)); 155 | nodeEnter 156 | .append('text') 157 | .attr('dx', this.width / 130 + 3) 158 | .attr('dy', '.25em') 159 | .text((d: any) => { 160 | return d.shortName; 161 | }); 162 | this.node = nodeEnter.merge(this.node); 163 | 164 | // Apply the general update pattern to the links. 165 | this.link = this.link.data(this.links, (d: any) => { 166 | return d.source.name + '-' + d.target.name; 167 | }); 168 | this.link.exit().remove(); 169 | this.link = this.link 170 | .enter() 171 | .insert('line', '.node') 172 | .attr('class', (d: any) => { 173 | return 'link ' + d.source.name + '-' + d.target.name; 174 | }) 175 | .merge(this.link); 176 | 177 | this.simulation.nodes(this.nodes).on('tick', ticked); 178 | this.simulation.force('link').links(this.links); 179 | this.simulation.alpha(0.1).restart(); 180 | }; 181 | 182 | const processData = () => { 183 | this.powerGauge.update(Math.random() * 1000, undefined); 184 | 185 | // process nodes data 186 | let addedSomething = false; 187 | for (let i = 0; i < appTraffic.nodes.length; i++) { 188 | const nodeIndex = this.nodes.findIndex((node: any) => { 189 | return node.name === appTraffic.nodes[i].name; 190 | }); 191 | if (nodeIndex < 0) { 192 | this.nodes.push(appTraffic.nodes[i]); 193 | addedSomething = true; 194 | } 195 | } 196 | // process links data 197 | for (let i = 0; i < appTraffic.links.length; i++) { 198 | let found = false; 199 | for (let k = 0; k < this.links.length; k++) { 200 | if ( 201 | appTraffic.nodes[appTraffic.links[i].source].name === this.links[k].source.name && 202 | appTraffic.nodes[appTraffic.links[i].target].name === this.links[k].target.name 203 | ) { 204 | found = true; 205 | break; 206 | } 207 | } 208 | 209 | if (!found) { 210 | this.links.push({ 211 | source: this.getNode(appTraffic.nodes[appTraffic.links[i].source].name), 212 | target: this.getNode(appTraffic.nodes[appTraffic.links[i].target].name), 213 | }); 214 | addedSomething = true; 215 | } 216 | } 217 | 218 | if (addedSomething) { 219 | drawGraph(); 220 | } 221 | 222 | this.requests = this.trafficService.viewHits(null, appTraffic.hits, this.isActive); 223 | this.trafficService.drawLegend(this.requests); 224 | this.trafficService.drawResponseTimes(); 225 | this.trafficService.updateResponseTimes(); 226 | }; 227 | 228 | processData(); 229 | 230 | this.intervalId = window.setInterval(() => processData(), 5000); 231 | } 232 | 233 | componentWillUnmount() { 234 | clearInterval(this.intervalId); 235 | } 236 | 237 | render() { 238 | const nodeLegendItems = ['DEBUG', 'INFO', 'WARN', 'ERROR', 'UNKNOWN']; 239 | 240 | const renderNodeLegend = nodeLegendItems.map((nodeLegendItem: any, index: number) => ( 241 | 242 | 243 | 244 | {nodeLegendItem} 245 | 246 | 247 | )); 248 | 249 | return ( 250 |
251 | Application Traffic 252 |  | Network Traffic 253 |
254 |
255 | 260 | {renderNodeLegend} 261 | 262 |
263 |
264 |
265 | ); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/d3-demo/d3-demo-network.tsx: -------------------------------------------------------------------------------- 1 | import { event, rgb, schemeCategory10 } from 'd3'; 2 | import { drag } from 'd3-drag'; 3 | import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from 'd3-force'; 4 | import { scaleOrdinal } from 'd3-scale'; 5 | import { select } from 'd3-selection'; 6 | import { find } from 'lodash'; 7 | import * as React from 'react'; 8 | import { Link } from 'react-router-dom'; 9 | import { ToolTipType } from '../constants/types'; 10 | import './d3-force.css'; 11 | import Gauge from './gauge'; 12 | import networkTraffic from './mock/network-traffic.json'; 13 | import { ToolTip } from './tool-tip'; 14 | import { TrafficService } from './traffic'; 15 | 16 | interface D3DemoNetworkState { 17 | tooltip: ToolTipType; 18 | } 19 | 20 | export class D3DemoNetwork extends React.Component { 21 | nodes: any[] = []; 22 | links: any[] = []; 23 | hits: any[] = []; 24 | simulation: any = {}; 25 | width: number = window.innerWidth; 26 | height: number = window.innerHeight; 27 | svg: any = {}; 28 | g: any = {}; 29 | link: any = {}; 30 | node: any = {}; 31 | trafficService: any = {}; 32 | requests: any[] = []; 33 | isActive = true; 34 | intervalId = 0; 35 | c10 = scaleOrdinal(schemeCategory10); 36 | powerGauge: Gauge = null; 37 | 38 | tooltip: ToolTipType = { 39 | display: false, 40 | data: { 41 | key: '', 42 | group: '', 43 | }, 44 | type: 'network', 45 | }; 46 | 47 | state = { 48 | tooltip: this.tooltip, 49 | }; 50 | 51 | constructor(props: any) { 52 | super(props); 53 | // Create force simulation 54 | this.simulation = forceSimulation() 55 | // apply collision with padding 56 | .force( 57 | 'collide', 58 | forceCollide((d: any) => { 59 | if (d.type === 'az') { 60 | return this.scaleFactor() / 5; 61 | } 62 | }) 63 | ) 64 | .force('x', forceX(this.width / 2).strength(0.185)) 65 | .force('y', forceY(this.height / 2).strength(0.185)) 66 | .force( 67 | 'link', 68 | forceLink() 69 | .id((d: any) => { 70 | return d.id; 71 | }) 72 | .strength((d: any) => { 73 | if (d.linkType === 'nn') { 74 | return 0.1; 75 | } else if (d.linkType === 'azn') { 76 | return 3; 77 | } else { 78 | return 1; 79 | } 80 | }) 81 | ) 82 | .force( 83 | 'charge', 84 | forceManyBody().strength((d: any) => { 85 | if (d.type === 'az') { 86 | return -12000; 87 | } else if (d.type === 'node') { 88 | return -40; 89 | } 90 | }) 91 | ) 92 | .force('center', forceCenter(this.width / 2, this.height / 2)); 93 | } 94 | 95 | showToolTip = (e: any) => { 96 | this.setState({ 97 | tooltip: { 98 | display: true, 99 | data: { 100 | key: e.name, 101 | group: e.group, 102 | }, 103 | pos: { 104 | x: e.x, 105 | y: e.y, 106 | }, 107 | type: 'network', 108 | }, 109 | }); 110 | }; 111 | 112 | hideToolTip = () => { 113 | this.setState({ 114 | tooltip: { 115 | display: false, 116 | data: { 117 | key: '', 118 | group: '', 119 | }, 120 | type: 'network', 121 | }, 122 | }); 123 | }; 124 | 125 | scaleFactor(): any { 126 | if (this.width > this.height) { 127 | return this.height; 128 | } 129 | return this.width; 130 | } 131 | 132 | componentDidMount() { 133 | this.svg = select('svg.svg-content-responsive'); 134 | this.g = this.svg.append('g'); 135 | this.link = this.g.append('g').selectAll('.link'); 136 | this.node = this.g.append('g').selectAll('.node'); 137 | this.trafficService = new TrafficService(this.svg, this.width); 138 | 139 | // Initial gauge 140 | this.powerGauge = new Gauge(this.svg, { 141 | size: 150, 142 | clipWidth: 300, 143 | clipHeight: 300, 144 | ringWidth: 60, 145 | maxValue: 1000, 146 | transitionMs: 5000, 147 | x: 250, 148 | y: 10, 149 | title: 'Logs per second', 150 | titleDx: 36, 151 | titleDy: 90, 152 | }); 153 | this.powerGauge.render(undefined); 154 | 155 | const ticked = () => { 156 | this.link 157 | .attr('x1', (d: any) => { 158 | return d.source.x; 159 | }) 160 | .attr('y1', (d: any) => { 161 | return d.source.y; 162 | }) 163 | .attr('x2', (d: any) => { 164 | return d.target.x; 165 | }) 166 | .attr('y2', (d: any) => { 167 | return d.target.y; 168 | }); 169 | this.node.attr('transform', (d: any) => { 170 | return 'translate(' + d.x + ',' + d.y + ')'; 171 | }); 172 | }; 173 | 174 | const dragstarted = () => { 175 | if (!event.active) { 176 | this.simulation.alphaTarget(0.3).restart(); 177 | } 178 | event.subject.fx = event.subject.x; 179 | event.subject.fy = event.subject.y; 180 | }; 181 | 182 | const dragged = () => { 183 | event.subject.fx = event.x; 184 | event.subject.fy = event.y; 185 | }; 186 | 187 | const dragended = () => { 188 | if (!event.active) { 189 | this.simulation.alphaTarget(0); 190 | } 191 | event.subject.fx = null; 192 | event.subject.fy = null; 193 | }; 194 | 195 | const drawGraph = () => { 196 | this.node = this.node.data(this.nodes, (d: any) => { 197 | return d.name; 198 | }); 199 | this.node.exit().remove(); 200 | 201 | // Create g tag for node 202 | const nodeEnter = this.node 203 | .enter() 204 | .append('g') 205 | .attr('class', (d: any) => { 206 | return 'node node' + d.index; 207 | }); 208 | 209 | // append centre circle 210 | nodeEnter 211 | .filter((d: any) => { 212 | return d.type === 'az'; 213 | }) 214 | .append('circle') 215 | .attr('class', (d: any) => { 216 | return 'az-center node' + d.index; 217 | }) 218 | .attr('r', this.scaleFactor() / 200); 219 | 220 | // append az zone circle 221 | nodeEnter 222 | .filter((d: any) => { 223 | return d.type === 'az'; 224 | }) 225 | .append('circle') 226 | .attr('class', 'az') 227 | .attr('r', this.scaleFactor() / 5.5); 228 | 229 | // append node circle 230 | nodeEnter 231 | .filter((d: any) => { 232 | return d.type === 'node'; 233 | }) 234 | .append('circle') 235 | .attr('class', (d: any) => { 236 | return 'node' + d.index; 237 | }) 238 | .attr('r', this.scaleFactor() / 130) 239 | .style('stroke', (d: any) => { 240 | return rgb(this.c10(d.group)); 241 | }) 242 | // for tooltip 243 | .on('mouseover', this.showToolTip) 244 | .on('mouseout', this.hideToolTip); 245 | 246 | // for interaction 247 | nodeEnter.call(drag().on('start', dragstarted).on('drag', dragged).on('end', dragended)); 248 | 249 | // append text to g 250 | nodeEnter 251 | .append('text') 252 | .attr('dx', this.scaleFactor() / 130 + 3) 253 | .attr('dy', '.25em') 254 | .attr('class', (d: any) => { 255 | if (d.type === 'az') { 256 | return 'label az'; 257 | } 258 | return 'label'; 259 | }) 260 | .text((d: any) => { 261 | return d.name; 262 | }); 263 | this.node = nodeEnter.merge(this.node); 264 | 265 | this.link = this.link.data(this.links, (d: any) => { 266 | return 'node' + d.source.index + '-node' + d.target.index; 267 | }); 268 | this.link.exit().remove(); 269 | this.link = this.link 270 | .enter() 271 | .insert('line', '.node') 272 | .attr('class', (d: any) => { 273 | if (d.linkType === 'az' || d.linkType === 'azn') { 274 | return 'link light node' + d.source.index + '-node' + d.target.index; 275 | } 276 | return 'link node' + d.source.index + '-node' + d.target.index; 277 | }) 278 | .merge(this.link); 279 | 280 | this.simulation.nodes(this.nodes).on('tick', ticked); 281 | this.simulation.force('link').links(this.links); 282 | this.simulation.alpha(0.1).restart(); 283 | }; 284 | 285 | const processData = () => { 286 | this.powerGauge.update(Math.random() * 1000, undefined); 287 | 288 | // process nodes data 289 | let addedSomething = false; 290 | // process nodes data 291 | for (let i = 0; i < networkTraffic.nodes.length; i++) { 292 | const found = find(this.nodes, (node: any) => { 293 | return node.name === networkTraffic.nodes[i].name; 294 | }); 295 | 296 | if (!found) { 297 | const node = networkTraffic.nodes[i]; 298 | node.index = i; 299 | 300 | this.nodes.push(networkTraffic.nodes[i]); 301 | addedSomething = true; 302 | } 303 | } 304 | 305 | // process links data 306 | for (let i = 0; i < networkTraffic.links.length; i++) { 307 | const found = find(this.links, (link: any) => { 308 | return ( 309 | networkTraffic.links[i].source === link.source.name && networkTraffic.links[i].target === link.target.name 310 | ); 311 | }); 312 | 313 | if (!found) { 314 | this.links.push({ 315 | linkType: networkTraffic.links[i].linkType, 316 | source: find(this.nodes, (n: any) => { 317 | return n.name === networkTraffic.links[i].source; 318 | }), 319 | target: find(this.nodes, (n: any) => { 320 | return n.name === networkTraffic.links[i].target; 321 | }), 322 | }); 323 | addedSomething = true; 324 | } 325 | } 326 | 327 | if (addedSomething) { 328 | drawGraph(); 329 | } 330 | 331 | this.requests = this.trafficService.viewHits(this.nodes, networkTraffic.hits, this.isActive); 332 | this.trafficService.drawLegend(this.requests); 333 | this.trafficService.drawResponseTimes(); 334 | this.trafficService.updateResponseTimes(); 335 | }; 336 | 337 | processData(); 338 | 339 | this.intervalId = window.setInterval(() => processData(), 5000); 340 | } 341 | 342 | componentWillUnmount() { 343 | clearInterval(this.intervalId); 344 | } 345 | 346 | render() { 347 | return ( 348 |
349 | 350 | Application Traffic 351 | 352 |  |  Network Traffic 353 |
354 |
355 | 360 | 361 | 362 |
363 |
364 |
365 | ); 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /src/d3-demo/d3-force.css: -------------------------------------------------------------------------------- 1 | .d3-force-content { 2 | padding-top: 1rem; 3 | } 4 | 5 | .nav-link { 6 | padding-left: 2rem; 7 | padding-top: 2rem; 8 | } 9 | 10 | .link { 11 | stroke: #555; 12 | } 13 | 14 | .node text { 15 | fill: #000; 16 | cursor: pointer; 17 | font-size: 0.8rem; 18 | } 19 | 20 | text { 21 | fill: #000; 22 | cursor: pointer; 23 | font-size: 0.7rem; 24 | } 25 | 26 | text.axis-t { 27 | fill: #7dc7f4; 28 | } 29 | 30 | text.axis-p { 31 | fill: #a4a4a4; 32 | } 33 | 34 | text.legendHeading, 35 | .responseHeading { 36 | font-size: 1.1rem; 37 | } 38 | 39 | rect.responseTimesChart, 40 | tspan.averageLabel { 41 | fill: #d75107; 42 | } 43 | 44 | rect.responseTimesChartMax, 45 | tspan.maxLabel { 46 | fill: rgba(215, 81, 7, 0.38); 47 | } 48 | 49 | text.legendRequestId, 50 | text.responseTimesText tspan { 51 | font-size: 0.7rem; 52 | } 53 | 54 | .node circle, 55 | .nodeLegend circle { 56 | stroke-width: 0.25rem; 57 | fill: #fff; 58 | stroke: #d16107; 59 | } 60 | 61 | .node circle.DEBUG, 62 | .nodeLegend circle.DEBUG { 63 | stroke: #62679a; 64 | } 65 | 66 | .node circle.INFO, 67 | .nodeLegend circle.INFO { 68 | stroke: #008376; 69 | } 70 | 71 | .node circle.WARN, 72 | .nodeLegend circle.WARN { 73 | stroke: #d1b948; 74 | } 75 | 76 | .node circle.ERROR, 77 | .nodeLegend circle.ERROR { 78 | stroke: #c10901; 79 | } 80 | 81 | circle.az-center { 82 | opacity: 0; 83 | } 84 | 85 | circle.az { 86 | stroke-width: 0.4rem; 87 | opacity: 0.2; 88 | fill: #666; 89 | stroke: #666; 90 | } 91 | 92 | .link.light { 93 | opacity: 0; 94 | } 95 | 96 | text.label.az { 97 | font-size: 1.1rem; 98 | } 99 | -------------------------------------------------------------------------------- /src/d3-demo/gauge.ts: -------------------------------------------------------------------------------- 1 | import { curveLinear, easeElastic, rgb } from 'd3'; 2 | import { range } from 'd3-array'; 3 | import { format } from 'd3-format'; 4 | import { interpolateHsl } from 'd3-interpolate'; 5 | import { scaleLinear } from 'd3-scale'; 6 | import { arc, line } from 'd3-shape'; 7 | 8 | export interface Config { 9 | [key: string]: any; 10 | } 11 | 12 | export default class Gauge { 13 | config: Config = { 14 | size: 200, 15 | clipWidth: 200, 16 | clipHeight: 110, 17 | ringInset: 20, 18 | ringWidth: 20, 19 | 20 | pointerWidth: 10, 21 | pointerTailLength: 5, 22 | pointerHeadLengthPercent: 0.9, 23 | 24 | minValue: 0, 25 | maxValue: 10, 26 | 27 | minAngle: -90, 28 | maxAngle: 90, 29 | 30 | transitionMs: 750, 31 | 32 | majorTicks: 5, 33 | labelFormat: format('d'), 34 | labelInset: 10, 35 | 36 | arcColorFn: interpolateHsl(rgb('#e8e2ca'), rgb('#3e6c0a')), 37 | }; 38 | 39 | configuration: any = null; 40 | range: any; 41 | r: any; 42 | pointerHeadLength: any; 43 | svg: any; 44 | arc: any; 45 | scale: any; 46 | ticks: any; 47 | tickData: any; 48 | pointer: any; 49 | 50 | constructor(container: any, configuration: any) { 51 | this.svg = container; 52 | this.configuration = configuration; 53 | this.configure(this.configuration); 54 | } 55 | 56 | deg2rad(deg: number) { 57 | return (deg * Math.PI) / 180; 58 | } 59 | 60 | configure(configuration: any) { 61 | for (const prop in configuration) { 62 | if (configuration.hasOwnProperty(prop)) { 63 | this.config[prop] = configuration[prop]; 64 | } 65 | } 66 | 67 | this.range = this.config.maxAngle - this.config.minAngle; 68 | this.r = this.config.size / 2; 69 | this.pointerHeadLength = Math.round(this.r * this.config.pointerHeadLengthPercent); 70 | 71 | // a linear scale that maps domain values to a percent from 0..1 72 | this.scale = scaleLinear().range([0, 1]).domain([this.config.minValue, this.config.maxValue]); 73 | 74 | this.ticks = this.scale.ticks(this.config.majorTicks); 75 | this.tickData = range(this.config.majorTicks).map(() => { 76 | return 1 / this.config.majorTicks; 77 | }); 78 | 79 | this.arc = arc() 80 | .innerRadius(this.r - this.config.ringWidth - this.config.ringInset) 81 | .outerRadius(this.r - this.config.ringInset) 82 | .startAngle((d: any, i: number) => { 83 | const ratio = d * i; 84 | return this.deg2rad(this.config.minAngle + ratio * this.range); 85 | }) 86 | .endAngle((d: any, i: number) => { 87 | const ratio = d * (i + 1); 88 | return this.deg2rad(this.config.minAngle + ratio * this.range); 89 | }); 90 | } 91 | 92 | centerTranslation() { 93 | return 'translate(' + this.r + ',' + this.r + ')'; 94 | } 95 | 96 | render(newValue: any) { 97 | const gauge = this.svg 98 | .append('g') 99 | .attr('class', 'gauge') 100 | .attr('width', this.config.clipWidth) 101 | .attr('height', this.config.clipHeight) 102 | .attr('transform', 'translate(' + this.config.x + ',' + this.config.y + ')'); 103 | 104 | gauge 105 | .append('text') 106 | .text(this.config.title) 107 | .attr('dx', this.config.titleDx) 108 | .attr('dy', this.config.titleDy) 109 | .attr('class', this.config.class); 110 | 111 | const centerTx = this.centerTranslation(); 112 | 113 | const arcs = gauge.append('g').attr('class', 'arc').attr('transform', centerTx); 114 | 115 | arcs 116 | .selectAll('path') 117 | .data(this.tickData) 118 | .enter() 119 | .append('path') 120 | .attr('fill', (d: any, i: number) => { 121 | return this.config.arcColorFn(d * i); 122 | }) 123 | .attr('d', this.arc); 124 | 125 | const lg = gauge.append('g').attr('class', 'label').attr('transform', centerTx); 126 | lg.selectAll('text') 127 | .data(this.ticks) 128 | .enter() 129 | .append('text') 130 | .attr('transform', (d: any) => { 131 | const ratio = this.scale(d); 132 | const newAngle = this.config.minAngle + ratio * this.range; 133 | return 'rotate(' + newAngle + ') translate(0,' + (this.config.labelInset - this.r) + ')'; 134 | }) 135 | .text(this.config.labelFormat); 136 | 137 | const lineData = [ 138 | [this.config.pointerWidth / 2, 0], 139 | [0, -this.pointerHeadLength], 140 | [-(this.config.pointerWidth / 2), 0], 141 | [0, this.config.pointerTailLength], 142 | [this.config.pointerWidth / 2, 0], 143 | ]; 144 | const pointerLine = line().curve(curveLinear); 145 | const pg = gauge.append('g').data([lineData]).attr('class', 'pointer').attr('transform', centerTx); 146 | 147 | this.pointer = pg 148 | .append('path') 149 | .attr('d', pointerLine) 150 | .attr('transform', 'rotate(' + this.config.minAngle + ')'); 151 | 152 | this.update(newValue === undefined ? 0 : newValue, undefined); 153 | } 154 | 155 | update(newValue: any, newConfiguration: any) { 156 | if (newConfiguration !== undefined) { 157 | this.configure(newConfiguration); 158 | } 159 | const ratio = this.scale(newValue); 160 | const newAngle = this.config.minAngle + ratio * this.range; 161 | this.pointer 162 | .transition() 163 | .duration(this.config.transitionMs) 164 | .ease(easeElastic) 165 | .attr('transform', 'rotate(' + newAngle + ')'); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/d3-demo/tool-tip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ToolTipType } from '../constants/types'; 3 | 4 | interface ToolTipPropTypes { 5 | tooltip: ToolTipType; 6 | } 7 | 8 | export const ToolTip: React.FC = (props: ToolTipPropTypes) => { 9 | let visibility = 'hidden'; 10 | let transform = ''; 11 | let x = 0; 12 | let y = 0; 13 | let width = 150; 14 | let height = 70; 15 | const transformText = 'translate(' + width / 2 + ',' + (height / 2 - 14) + ')'; 16 | let transformArrow = ''; 17 | 18 | if (props.tooltip.type === 'network') { 19 | width = 160; 20 | height = 50; 21 | } 22 | 23 | if (props.tooltip.display === true) { 24 | const position = props.tooltip.pos; 25 | 26 | x = position.x; 27 | y = position.y; 28 | visibility = 'visible'; 29 | 30 | if (y > height) { 31 | transform = 'translate(' + (x - width / 2 + 30) + ',' + (y - height - 20) + ')'; 32 | transformArrow = 'translate(' + (width / 2 - 20) + ',' + (height - 2) + ')'; 33 | } else if (y < height) { 34 | transform = 'translate(' + (x - width / 2 + 30) + ',' + (Math.round(y) + 20) + ')'; 35 | transformArrow = 'translate(' + (width / 2 - 20) + ',' + 0 + ') rotate(180,20,0)'; 36 | } 37 | } else { 38 | visibility = 'hidden'; 39 | } 40 | 41 | return ( 42 | 43 | 53 | 61 | 62 | 63 | {props.tooltip.data.key} 64 | 65 | 66 | {props.tooltip.type === 'network' ? props.tooltip.data.group : props.tooltip.data.description} 67 | 68 | 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/d3-demo/traffic.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import { find } from 'lodash'; 3 | 4 | export class TrafficService { 5 | c10 = d3.scaleOrdinal(d3.schemeCategory10); 6 | slowest = 0; 7 | slowestMax = 0; 8 | scaleMax = d3.scaleLinear().domain([0, 0]).range([2, 175]); 9 | statusCodes: any[] = []; 10 | responseTimes: any[] = []; 11 | requests: any[] = []; 12 | svg: any = {}; 13 | width = 0; 14 | lastUpdate = ''; 15 | 16 | constructor(svg: any, width: number) { 17 | this.svg = svg; 18 | this.width = width; 19 | } 20 | 21 | static overlay() { 22 | const el = document.getElementById('overlay'); 23 | el.style.visibility = el.style.visibility === 'visible' ? 'hidden' : 'visible'; 24 | } 25 | 26 | static syntaxHighlight(json: any) { 27 | if (typeof json !== 'string') { 28 | json = JSON.stringify(json, undefined, 2); 29 | } 30 | json = json.replace(/&/g, '&').replace(//g, '>'); 31 | return json.replace( 32 | /('(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\'])*'(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, 33 | (match: any) => { 34 | let cls = 'number'; 35 | if (/^'/.test(match)) { 36 | cls = /:$/.test(match) ? 'key' : 'string'; 37 | } else if (/true|false/.test(match)) { 38 | cls = 'boolean'; 39 | } else if (/null/.test(match)) { 40 | cls = 'null'; 41 | } 42 | return '' + match + ''; 43 | } 44 | ); 45 | } 46 | 47 | viewHits(nodes: any[], hits: any[], isActive: boolean) { 48 | if (!hits || hits.length === 0) { 49 | return; 50 | } 51 | 52 | let lastRunLogId = 0; 53 | let start = hits[0].timestamp; 54 | let delay = 0; 55 | const requests: any[] = []; 56 | const startTime = new Date().getTime(); 57 | 58 | for (let i = 0; i < hits.length; i++) { 59 | if (hits[i].id === lastRunLogId) { 60 | break; 61 | } 62 | 63 | // process up till the last processed from previous run 64 | delay = delay + hits[i].timestamp - start + 15; 65 | start = hits[i].timestamp; 66 | 67 | // process requests data for the legend 68 | if (requests.indexOf(hits[i].requestId) < 0) { 69 | requests.unshift(hits[i].requestId); 70 | if (requests.length > 20) { 71 | requests.pop(); 72 | } 73 | } 74 | 75 | // count non 200 status codes 76 | const statusCodeIndex = this.statusCodes.findIndex((item: any) => { 77 | return item.code === hits[i].statusCode; 78 | }); 79 | 80 | if (hits[i].statusCode && statusCodeIndex < 0 && (hits[i].statusCode < '200' || hits[i].statusCode > '299')) { 81 | this.statusCodes.push({ code: hits[i].statusCode, count: 1 }); 82 | } else if (hits[i].statusCode && (hits[i].statusCode < '200' || hits[i].statusCode > '299')) { 83 | this.statusCodes[statusCodeIndex].count++; 84 | } 85 | 86 | // collect response times 87 | const responseTimesIndex = this.responseTimes.findIndex((item: any) => { 88 | return item.service === hits[i].target; 89 | }); 90 | 91 | if (hits[i].processingTimeMs && responseTimesIndex < 0) { 92 | hits[i].processingTimeMs = parseInt(hits[i].processingTimeMs, null); 93 | this.responseTimes.push({ 94 | service: hits[i].target, 95 | count: 1, 96 | average: hits[i].processingTimeMs, 97 | rpm: 1, 98 | max: hits[i].processingTimeMs, 99 | }); 100 | } else if (hits[i].processingTimeMs) { 101 | hits[i].processingTimeMs = parseInt(hits[i].processingTimeMs, null); 102 | this.responseTimes[responseTimesIndex].average = 103 | Math.round( 104 | ((this.responseTimes[responseTimesIndex].average * this.responseTimes[responseTimesIndex].count + 105 | hits[i].processingTimeMs) / 106 | (this.responseTimes[responseTimesIndex].count + 1)) * 107 | 10 108 | ) / 10; 109 | if (hits[i].processingTimeMs > this.responseTimes[responseTimesIndex].max) { 110 | this.responseTimes[responseTimesIndex].max = hits[i].processingTimeMs; 111 | this.responseTimes[responseTimesIndex].maxHit = hits[i]; 112 | } 113 | if (this.responseTimes[responseTimesIndex].average > this.slowest) { 114 | this.slowest = this.responseTimes[responseTimesIndex].average; 115 | } 116 | if (this.responseTimes[responseTimesIndex].max > this.slowestMax) { 117 | this.slowestMax = this.responseTimes[responseTimesIndex].max; 118 | this.scaleMax = d3.scaleLinear().domain([0, this.slowestMax]).range([2, 175]); 119 | this.updateResponseTimes(); 120 | } 121 | this.responseTimes[responseTimesIndex].count++; 122 | const now = new Date().getTime(); 123 | this.responseTimes[responseTimesIndex].rpm = Math.round( 124 | (this.responseTimes[responseTimesIndex].count / ((now - startTime) / 1000)) * 60 125 | ); 126 | } else { 127 | hits[i].processingTimeMs = 0; 128 | } 129 | 130 | let totalDelay = delay; 131 | if (hits[i].processingTimeMs) { 132 | totalDelay += hits[i].processingTimeMs; 133 | } 134 | 135 | if (isActive) { 136 | setTimeout(() => { 137 | if (nodes) { 138 | const sourceNode = find(nodes, (node: any) => { 139 | return node.name === hits[i].source; 140 | }); 141 | const targetNode = find(nodes, (node: any) => { 142 | return node.name === hits[i].target; 143 | }); 144 | this.drawCircle( 145 | this.statusCodes, 146 | 'node' + sourceNode.index, 147 | 'node' + targetNode.index, 148 | hits[i].requestId, 149 | hits[i].statusCode, 150 | hits[i].processingTimeMs 151 | ); 152 | } else { 153 | this.drawCircle( 154 | this.statusCodes, 155 | hits[i].source, 156 | hits[i].target, 157 | hits[i].requestId, 158 | hits[i].statusCode, 159 | hits[i].processingTimeMs 160 | ); 161 | } 162 | }, totalDelay); 163 | } 164 | lastRunLogId = hits[i].id; 165 | this.lastUpdate = hits[i].timestamp; 166 | } 167 | this.requests = requests; 168 | if (hits && hits.length >= 1000) { 169 | this.lastUpdate = 'init'; 170 | } 171 | this.updateLegend(this.requests); 172 | 173 | return this.requests; 174 | } 175 | 176 | drawCircle( 177 | statusCodes: any[], 178 | source: string, 179 | target: string, 180 | requestId: string, 181 | statusCode: any, 182 | processingTime: number 183 | ) { 184 | if (statusCode === 'undefined') { 185 | statusCode = null; 186 | } 187 | const tempLink: any = d3.select('line.' + source + '-' + target); 188 | const link: any = tempLink._groups[0][0]; 189 | 190 | if (link) { 191 | const circle = this.svg 192 | .append('circle') 193 | .attr('r', this.width / 350) 194 | .attr('cx', link.getAttribute('x1')) 195 | .attr('cy', link.getAttribute('y1')) 196 | .attr('class', 'hit'); 197 | if (requestId !== 'no-request-id') { 198 | circle.attr('style', () => { 199 | return 'fill:' + this.c10(requestId); 200 | }); 201 | } 202 | 203 | circle.transition().on('end', () => { 204 | this.moveIt(circle, link.getAttribute('x2'), link.getAttribute('y2'), statusCode, false, processingTime); 205 | }); 206 | } 207 | } 208 | 209 | moveIt(item: any, x2: number, y2: number, statusCode: any, error: boolean, processingTime: number) { 210 | if (item) { 211 | item 212 | .transition() 213 | .duration(1000 + processingTime) 214 | .attr('cx', x2) 215 | .attr('cy', y2) 216 | .on('end', () => item.remove()); 217 | } 218 | } 219 | 220 | drawLegend(requests: any[]) { 221 | if (!requests || requests.length === 0) { 222 | return; 223 | } 224 | 225 | if ( 226 | this.svg.selectAll('.legendHeading')._groups[0] && 227 | this.svg.selectAll('.legendHeading')._groups[0].length === 0 228 | ) { 229 | this.svg 230 | .append('text') 231 | .attr('dx', this.width - 280) 232 | .attr('dy', 20) 233 | .text('Unique Request id (last 20)') 234 | .attr('class', 'legendHeading'); 235 | } 236 | 237 | let legend = this.svg.selectAll('.legend'); 238 | legend = legend.data(requests, (d: any) => { 239 | return d; 240 | }); 241 | 242 | const g = legend 243 | .enter() 244 | .append('g') 245 | .attr('class', (d: any) => { 246 | return 'legend ' + d; 247 | }); 248 | 249 | const circle = g.append('circle'); 250 | circle 251 | .attr('r', 6) 252 | .attr('class', 'hit') 253 | .attr('cx', this.width - 272) 254 | .attr('cy', (d: any, i: any) => { 255 | return i * 20 + 30; 256 | }) 257 | .attr('style', (d: any) => { 258 | if (d !== 'no-request-id') { 259 | return 'fill:' + this.c10(d); 260 | } 261 | return ''; 262 | }); 263 | 264 | g.append('text') 265 | .attr('class', 'legendRequestId') 266 | .attr('dx', this.width - 262) 267 | .attr('dy', (d: any, i: any) => { 268 | return i * 20 + 34; 269 | }) 270 | .text((d: any) => { 271 | return d; 272 | }); 273 | legend.exit().remove(); 274 | } 275 | 276 | updateLegend(requests: any[]) { 277 | if (!requests || requests.length === 0) { 278 | return; 279 | } 280 | 281 | const items = this.svg.selectAll('.legend').data(requests, (d: any) => { 282 | return d; 283 | }); 284 | items 285 | .select('circle') 286 | .transition() 287 | .attr('cx', this.width - 270) 288 | .attr('cy', (d: any, i: any) => { 289 | return i * 20 + 30; 290 | }); 291 | items 292 | .select('text') 293 | .transition() 294 | .attr('dx', this.width - 260) 295 | .attr('dy', (d: any, i: any) => { 296 | return i * 20 + 34; 297 | }); 298 | } 299 | 300 | drawResponseTimes() { 301 | if (this.svg.selectAll('.responseHeading')._groups[0].length === 0) { 302 | this.svg 303 | .append('text') 304 | .attr('dx', 20) 305 | .attr('dy', 25) 306 | .text('Response times (ms)') 307 | .attr('class', 'responseHeading'); 308 | this.svg 309 | .append('rect') 310 | .attr('x', 20) 311 | .attr('y', 30) 312 | .attr('width', 8) 313 | .attr('height', 4) 314 | .attr('class', 'responseTimesChart'); 315 | this.svg.append('text').text('average').attr('dx', 30).attr('dy', 36).attr('class', 'heading'); 316 | this.svg 317 | .append('rect') 318 | .attr('x', 120) 319 | .attr('y', 30) 320 | .attr('width', 8) 321 | .attr('height', 4) 322 | .attr('class', 'responseTimesChartMax'); 323 | this.svg.append('text').text('maximum').attr('dx', 130).attr('dy', 36).attr('class', 'heading'); 324 | } 325 | 326 | let responseItem = this.svg.selectAll('.responseTime'); 327 | responseItem = responseItem.data(this.responseTimes, (d: any) => { 328 | return d.service; 329 | }); 330 | 331 | const g = responseItem 332 | .enter() 333 | .append('g') 334 | .attr('class', (d: any) => { 335 | return 'responseTime ' + d.service; 336 | }); 337 | g.append('rect') 338 | .attr('height', 4) 339 | .attr('width', (d: any) => { 340 | let w = this.scaleMax(d.max); 341 | if (isNaN(w) || w === 0) { 342 | w = 2; 343 | } 344 | if (w > 200) { 345 | w = 200; 346 | } 347 | return w; 348 | }) 349 | .attr('x', 20) 350 | .attr('y', (d: any, i: number) => { 351 | return i * 22 + 56; 352 | }) 353 | .attr('class', 'responseTimesChartMax'); 354 | g.append('rect') 355 | .attr('height', 4) 356 | .attr('width', (d: any) => { 357 | let w = this.scaleMax(d.average); 358 | if (isNaN(w) || w === 0) { 359 | w = 2; 360 | } 361 | return w; 362 | }) 363 | .attr('x', 20) 364 | .attr('y', (d: any, i: number) => { 365 | return i * 22 + 56; 366 | }) 367 | .attr('class', 'responseTimesChart'); 368 | 369 | const label = g.append('text'); 370 | label 371 | .attr('class', 'responseTimesText') 372 | .attr('dx', 20) 373 | .attr('dy', (d: any, i: number) => { 374 | return i * 22 + 53; 375 | }); 376 | 377 | label.append('tspan').text((d: any) => { 378 | return d.service; 379 | }); 380 | label 381 | .append('tspan') 382 | .text((d: any) => { 383 | return d.average; 384 | }) 385 | .attr('class', 'averageLabel') 386 | .attr('dx', 5); 387 | label 388 | .append('tspan') 389 | .text((d: any) => { 390 | return d.max; 391 | }) 392 | .attr('class', 'maxLabel') 393 | .attr('dx', 8); 394 | label 395 | .append('tspan') 396 | .text((d: any) => { 397 | return d.rpm + ' rpm'; 398 | }) 399 | .attr('class', 'rpmLabel') 400 | .attr('dx', 8) 401 | .on('click', (d: any) => { 402 | const content = d3.select('#overlayContent'); 403 | content.selectAll('*').remove(); 404 | content.append('b').text('Details maximum log entry: ').append('br'); 405 | content.append('pre').html(TrafficService.syntaxHighlight(d.maxHit)).append('br').append('br'); 406 | 407 | TrafficService.overlay(); 408 | }); 409 | 410 | responseItem.exit().remove(); 411 | } 412 | 413 | updateResponseTimes() { 414 | let responseItem = this.svg.selectAll('.responseTime'); 415 | responseItem = responseItem.data(this.responseTimes, (d: any) => { 416 | return d.service; 417 | }); 418 | responseItem 419 | .select('rect.responseTimesChart') 420 | .transition() 421 | .attr('width', (d: any) => { 422 | let w = this.scaleMax(d.average); 423 | if (isNaN(w) || w === 0) { 424 | w = 2; 425 | } 426 | if (w > 200) { 427 | w = 200; 428 | } 429 | return w; 430 | }); 431 | responseItem 432 | .select('rect.responseTimesChartMax') 433 | .transition() 434 | .attr('width', (d: any) => { 435 | let w = this.scaleMax(d.max); 436 | if (isNaN(w) || w === 0) { 437 | w = 2; 438 | } 439 | if (w > 200) { 440 | w = 200; 441 | } 442 | return w; 443 | }); 444 | const label = responseItem.select('text'); 445 | label 446 | .select('tspan.averageLabel') 447 | .text((d: any) => { 448 | return d.average; 449 | }) 450 | .attr('dx', 5); 451 | label 452 | .select('tspan.maxLabel') 453 | .text((d: any) => { 454 | return d.max; 455 | }) 456 | .attr('dx', 8); 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Weather App 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'antd/es/alert/style/css'; 2 | import 'antd/es/back-top/style/css'; 3 | import 'antd/es/button/style/css'; 4 | import 'antd/es/card/style/css'; 5 | import 'antd/es/col/style/css'; 6 | import 'antd/es/date-picker/style/css'; 7 | import 'antd/es/icon/style/css'; 8 | import 'antd/es/input/style/css'; 9 | import 'antd/es/layout/style/css'; 10 | import 'antd/es/menu/style/css'; 11 | import 'antd/es/popover/style/css'; 12 | import 'antd/es/row/style/css'; 13 | import 'antd/es/select/style/css'; 14 | import 'antd/es/spin/style/css'; 15 | import 'antd/es/table/style/css'; 16 | import 'mapbox-gl/dist/mapbox-gl.css'; 17 | import { initializeApp } from 'firebase/app'; 18 | import * as React from 'react'; 19 | import * as ReactDOM from 'react-dom'; 20 | import { Provider } from 'react-redux'; 21 | import { ApiKey } from './constants/api-key'; 22 | import './css/index.css'; 23 | import store from './store'; 24 | import { App } from './views/app'; 25 | 26 | // Initialise Firebase 27 | initializeApp({ 28 | apiKey: ApiKey.firebase, 29 | authDomain: 'reactjs-weather.firebaseapp.com', 30 | projectId: 'reactjs-weather', 31 | storageBucket: 'reactjs-weather.appspot.com', 32 | messagingSenderId: '120664202212', 33 | appId: '1:120664202212:web:b733e66714cd0fde', 34 | }); 35 | 36 | ReactDOM.render( 37 | 38 | 39 | , 40 | document.getElementById('app') 41 | ); 42 | -------------------------------------------------------------------------------- /src/store/actions.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction } from 'redux'; 2 | import { ThunkDispatch } from 'redux-thunk'; 3 | import { getGeocode, getWeatherByTime } from '../api'; 4 | import { Filter, Forecast, GeoCode, RootState, Timezone, Weather } from '../constants/types'; 5 | 6 | export const FETCHING_DATA = 'FETCHING_DATA'; 7 | export const FETCHING_DATA_SUCCESS = 'FETCHING_DATA_SUCCESS'; 8 | export const FETCHING_DATA_FAILURE = 'FETCHING_DATA_FAILURE'; 9 | 10 | export const SET_FILTER = 'SET_FILTER'; 11 | export const SET_LOCATION = 'SET_LOCATION'; 12 | export const SET_TIMEZONE = 'SET_TIMEZONE'; 13 | 14 | export const SET_CURRENT_WEATHER = 'SET_CURRENT_WEATHER'; 15 | export const SET_HOURLY_FORECAST = 'SET_HOURLY_FORECAST'; 16 | export const SET_DAILY_FORECAST = 'SET_DAILY_FORECAST'; 17 | 18 | export const setFilter = (filter: Filter) => { 19 | return { 20 | type: SET_FILTER, 21 | filter, 22 | }; 23 | }; 24 | 25 | const setLocation = (location: string) => { 26 | return { 27 | type: SET_LOCATION, 28 | location, 29 | }; 30 | }; 31 | 32 | const setTimezone = (timezone: Timezone) => { 33 | return { 34 | type: SET_TIMEZONE, 35 | timezone, 36 | }; 37 | }; 38 | 39 | const setCurrentWeather = (currentWeather: Weather) => { 40 | return { 41 | type: SET_CURRENT_WEATHER, 42 | currentWeather, 43 | }; 44 | }; 45 | 46 | const setHourlyForecast = (hourlyForecast: { summary: string; icon: string; data: Weather[] }) => { 47 | return { 48 | type: SET_HOURLY_FORECAST, 49 | hourlyForecast, 50 | }; 51 | }; 52 | 53 | const setDailyForecast = (dailyForecast: { summary: string; icon: string; data: Weather[] }) => { 54 | return { 55 | type: SET_DAILY_FORECAST, 56 | dailyForecast, 57 | }; 58 | }; 59 | 60 | export const fetchingData = () => { 61 | return { 62 | type: FETCHING_DATA, 63 | }; 64 | }; 65 | 66 | const fetchingDataSuccess = () => { 67 | return { 68 | type: FETCHING_DATA_SUCCESS, 69 | }; 70 | }; 71 | 72 | export const fetchingDataFailure = (error: string) => { 73 | return { 74 | type: FETCHING_DATA_FAILURE, 75 | error, 76 | }; 77 | }; 78 | 79 | const EXCLUDE = 'flags,minutely'; 80 | 81 | /** 82 | * If you set lat along with lon, you must set city name as well, otherwise set (0, 0, city) 83 | * @param {number} lat 84 | * @param {number} lon 85 | * @param {string} city 86 | */ 87 | export const getWeatherData = (lat: number, lon: number, city: string) => { 88 | return async (dispatch: ThunkDispatch, getState: any) => { 89 | dispatch(fetchingData()); 90 | try { 91 | if (lat !== 0 && lon !== 0) { 92 | const results: Forecast = await getWeatherByTime( 93 | lat, 94 | lon, 95 | getState().weather.filter.timestamp, 96 | EXCLUDE, 97 | getState().weather.filter.units 98 | ); 99 | const timezone: Timezone = { 100 | timezone: results.timezone, 101 | offset: results.offset, 102 | latitude: results.latitude, 103 | longitude: results.longitude, 104 | }; 105 | dispatch(setLocation(city)); 106 | dispatch(setTimezone(timezone)); 107 | dispatch(setCurrentWeather(results.currently)); 108 | dispatch(setHourlyForecast(results.hourly)); 109 | dispatch(setDailyForecast(results.daily)); 110 | dispatch(fetchingDataSuccess()); 111 | } else { 112 | // Get coordinates by city at first, after that get the weather and forecast info by coordinates 113 | const geocode: GeoCode = await getGeocode(null, null, city); 114 | if (geocode.status === 'OK') { 115 | await dispatch(getWeatherData(geocode.latitude, geocode.longitude, geocode.address)); 116 | } else { 117 | dispatch(fetchingDataFailure('ERROR!')); 118 | } 119 | } 120 | } catch (error) { 121 | dispatch(fetchingDataFailure(error.message)); 122 | } 123 | }; 124 | }; 125 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, combineReducers, compose, createStore } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { reducers } from './reducers'; 4 | 5 | const rootReducer = () => 6 | combineReducers({ 7 | weather: reducers, 8 | }); 9 | 10 | const composeEnhancer: typeof compose = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 11 | const store = createStore(rootReducer(), composeEnhancer(applyMiddleware(thunk))); 12 | export default store; 13 | -------------------------------------------------------------------------------- /src/store/reducers.ts: -------------------------------------------------------------------------------- 1 | import { ForecastState } from '../constants/types'; 2 | import * as ACTION from './actions'; 3 | 4 | const initialState: ForecastState = { 5 | isLoading: false, 6 | filter: { 7 | units: 'si', 8 | searchedLocation: '', 9 | timestamp: 0, 10 | }, 11 | location: '', 12 | timezone: null, 13 | currentWeather: null, 14 | hourlyForecast: null, 15 | dailyForecast: null, 16 | error: '', 17 | }; 18 | 19 | export const reducers = (state: any = initialState, action: any) => { 20 | switch (action.type) { 21 | case ACTION.FETCHING_DATA: 22 | return { 23 | ...state, 24 | isLoading: true, 25 | error: '', 26 | }; 27 | 28 | case ACTION.FETCHING_DATA_SUCCESS: 29 | return { 30 | ...state, 31 | isLoading: false, 32 | }; 33 | 34 | case ACTION.FETCHING_DATA_FAILURE: 35 | return { 36 | ...state, 37 | isLoading: false, 38 | error: action.error, 39 | }; 40 | 41 | case ACTION.SET_FILTER: 42 | return { 43 | ...state, 44 | filter: action.filter, 45 | }; 46 | 47 | case ACTION.SET_LOCATION: 48 | return { 49 | ...state, 50 | location: action.location, 51 | }; 52 | 53 | case ACTION.SET_TIMEZONE: 54 | return { 55 | ...state, 56 | timezone: action.timezone, 57 | }; 58 | 59 | case ACTION.SET_CURRENT_WEATHER: 60 | return { 61 | ...state, 62 | currentWeather: action.currentWeather, 63 | }; 64 | 65 | case ACTION.SET_HOURLY_FORECAST: 66 | return { 67 | ...state, 68 | hourlyForecast: action.hourlyForecast, 69 | }; 70 | 71 | case ACTION.SET_DAILY_FORECAST: 72 | return { 73 | ...state, 74 | dailyForecast: action.dailyForecast, 75 | }; 76 | 77 | default: 78 | return state; 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.json' { 2 | const value: any; 3 | export default value; 4 | } 5 | 6 | declare const windyInit: any; 7 | declare const L: any; 8 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment'; 2 | 3 | export class Utils { 4 | /** 5 | * @param {number} timestamp 6 | * @param {number} offset 7 | * @param {string} format 8 | * @returns {string} 9 | */ 10 | static getLocalTime = (timestamp: number, offset: number, format: string): string => { 11 | return `${moment.unix(timestamp).utcOffset(offset).format(format)}`; 12 | }; 13 | 14 | /** 15 | * @param {number} value 16 | * @param {string} units 17 | * @returns {string} 18 | */ 19 | static getPressure = (value: number, units: string): string => { 20 | if (units === 'us') { 21 | return `${Math.round(value)} mb`; 22 | } else if (units === 'si') { 23 | return `${Math.round(value)} hPa`; 24 | } 25 | }; 26 | 27 | /** 28 | * @param {number} value 29 | * @param {string} units 30 | * @returns {string} 31 | */ 32 | static getTemperature = (value: number, units: string): string => { 33 | if (units === 'us') { 34 | return `${Math.round(value)} ℉`; 35 | } else if (units === 'si') { 36 | return `${Math.round(value)} ℃`; 37 | } 38 | }; 39 | 40 | /** 41 | * @param {number} value 42 | * @param {string} units 43 | * @returns {string} 44 | */ 45 | static getWindSpeed = (value: number, units: string): string => { 46 | if (units === 'us') { 47 | return `${Math.round(value)} mph`; 48 | } else if (units === 'si') { 49 | return `${Math.round(value * 3.6)} kph`; 50 | } 51 | }; 52 | 53 | /** 54 | * @param {number} intensity 55 | * @param {number} chance 56 | * @param {string} units 57 | * @returns {string} 58 | */ 59 | static getRain = (intensity: number, chance: number, units: string): string => { 60 | if (units === 'us') { 61 | return `${intensity.toFixed(3)} in / ${Math.round(chance * 100)} %`; 62 | } else if (units === 'si') { 63 | return `${intensity.toFixed(2)} mm / ${Math.round(chance * 100)} %`; 64 | } 65 | }; 66 | 67 | static getDistance = (value: number, units: string): string => { 68 | if (units === 'us') { 69 | return `${Math.round(value)} mi`; 70 | } else if (units === 'si') { 71 | return `${Math.round(value)} km`; 72 | } 73 | }; 74 | 75 | static isMobile = (): boolean => { 76 | return /Mobi|Android/i.test(navigator.userAgent); 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/views/about.tsx: -------------------------------------------------------------------------------- 1 | import Col from 'antd/es/col'; 2 | import Row from 'antd/es/row'; 3 | import * as React from 'react'; 4 | 5 | export const About: React.FC = () => ( 6 | 7 | 8 |

About

9 |

10 | This is an open source weather web application using React v17, Redux, React Router v6, Typescript, Webpack v5, 11 | Ant Design, D3 v5, ECharts and Mapbox. 12 |

13 |

14 | Source code: 15 | 16 | GitHub 17 | 18 |

19 |

Here are most important libraries (dependencies) I used:

20 |
    21 |
  • 22 | 23 | React 24 | 25 | - A JavaScript library for building user interfaces. 26 |
  • 27 |
  • 28 | 29 | React Router 30 | 31 | - React Router is a fully-featured client and server-side routing library for React, a JavaScript library for 32 | building user interfaces. React Router runs anywhere React runs; on the web, on the server with node.js, and 33 | on React Native. 34 |
  • 35 |
  • 36 | 37 | Redux 38 | 39 | - Redux is a predictable state container for JavaScript apps. 40 |
  • 41 |
  • 42 | 43 | Webpack 44 | 45 | - Webpack is a module bundler. 46 |
  • 47 |
  • 48 | 49 | Ant Design of React 50 | 51 | - A design system with values of Nature and Determinacy for better user experience of enterprise applications. 52 |
  • 53 |
  • 54 | 55 | D3 56 | 57 | - D3.js is a JavaScript library for manipulating documents based on data. 58 |
  • 59 |
  • 60 | 61 | ECharts 62 | 63 | - ECharts is a free, powerful charting and visualization Javascript library offering an easy way of adding 64 | intuitive, interactive, and highly customizable charts to your products. 65 |
  • 66 |
  • 67 | 68 | Mapbox 69 | 70 | - Mapbox is the location data platform for mobile and web applications. 71 |
  • 72 |
  • 73 | 74 | Weather Icon 75 | 76 | - Weather Icons is the only icon font and CSS with 222 weather themed icons, ready to be dropped right into 77 | Bootstrap, or any project that needs high quality weather, maritime, and meteorological based icons. 78 |
  • 79 |
80 |

API:

81 | 103 | 104 |
105 | ); 106 | -------------------------------------------------------------------------------- /src/views/app.tsx: -------------------------------------------------------------------------------- 1 | import Layout from 'antd/es/layout'; 2 | import * as React from 'react'; 3 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 4 | import { NavBar } from '../components/nav-bar'; 5 | import { Covid19 } from '../covid-19/covid-19'; 6 | import { D3DemoApp } from '../d3-demo/d3-demo-app'; 7 | import { D3DemoNetwork } from '../d3-demo/d3-demo-network'; 8 | import { About } from './about'; 9 | import { WeatherMain } from './weather-main'; 10 | import { WeatherMap } from './weather-map'; 11 | 12 | const { Footer, Content } = Layout; 13 | 14 | export const App: React.FC = () => { 15 | return ( 16 | 17 |
18 | 19 | 20 | 21 | } /> 22 | } /> 23 | } /> 24 | } /> 25 | } /> 26 | } /> 27 | 31 |

Whoops...Page not found!

32 |
33 | } 34 | /> 35 | 36 |
©2022 Developed by Laurence Ho, v3.6.3
37 | 38 |
39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/views/weather-main.tsx: -------------------------------------------------------------------------------- 1 | import Alert from 'antd/es/alert'; 2 | import Col from 'antd/es/col'; 3 | import Row from 'antd/es/row'; 4 | import Spin from 'antd/es/spin'; 5 | import { isEmpty } from 'lodash'; 6 | import * as React from 'react'; 7 | import { useEffect } from 'react'; 8 | import { useDispatch, useSelector } from 'react-redux'; 9 | import { getGeocode } from '../api'; 10 | import { CurrentWeather } from '../components/current-weather'; 11 | import { DailyForecast } from '../components/daily-forecast'; 12 | import { HourlyForecast } from '../components/hourly-forecast'; 13 | import { USE_DEFAULT_LOCATION } from '../constants/message'; 14 | import { Filter, GeoCode, RootState } from '../constants/types'; 15 | import { fetchingData, fetchingDataFailure, getWeatherData } from '../store/actions'; 16 | 17 | export const WeatherMain: React.FC = () => { 18 | const dispatch = useDispatch(); 19 | 20 | const isLoading = useSelector((state: RootState) => state.weather.isLoading); 21 | const filter = useSelector((state: RootState) => state.weather.filter); 22 | const location = useSelector((state: RootState) => state.weather.location); 23 | const timezone = useSelector((state: RootState) => state.weather.timezone); 24 | const currentWeather = useSelector((state: RootState) => state.weather.currentWeather); 25 | const hourlyForecast = useSelector((state: RootState) => state.weather.hourlyForecast); 26 | const dailyForecast = useSelector((state: RootState) => state.weather.dailyForecast); 27 | const error = useSelector((state: RootState) => state.weather.error); 28 | 29 | const [filterState, setFilterState] = React.useState(filter); 30 | 31 | const searchByDefaultLocation = (message: string) => { 32 | dispatch(fetchingDataFailure(message)); 33 | setTimeout(() => { 34 | dispatch(getWeatherData(-36.8484597, 174.7633315, 'Auckland')); 35 | }, 5000); 36 | }; 37 | 38 | useEffect(() => { 39 | if (isEmpty(location) && isEmpty(currentWeather) && isEmpty(hourlyForecast) && isEmpty(dailyForecast)) { 40 | dispatch(fetchingData()); 41 | // Get user's coordinates when user access the web app, it will ask user's location permission 42 | const options = { 43 | enableHighAccuracy: true, 44 | timeout: 5000, 45 | maximumAge: 0, 46 | }; 47 | 48 | const handleLocation = async (location: any) => { 49 | try { 50 | const geocode: GeoCode = await getGeocode(location.coords.latitude, location.coords.longitude, ''); 51 | if (geocode.status === 'OK') { 52 | dispatch(getWeatherData(geocode.latitude, geocode.longitude, geocode.address)); 53 | } 54 | } catch (error) { 55 | searchByDefaultLocation(`${error.message}.${USE_DEFAULT_LOCATION}`); 56 | } 57 | }; 58 | 59 | const handleError = (error: any) => searchByDefaultLocation(`${error.message}.${USE_DEFAULT_LOCATION}`); 60 | if (process.env.NODE_ENV === 'development') { 61 | searchByDefaultLocation(USE_DEFAULT_LOCATION); 62 | } else { 63 | navigator.geolocation.getCurrentPosition(handleLocation, handleError, options); 64 | } 65 | } 66 | }, []); 67 | 68 | useEffect(() => { 69 | // When user search weather by city name 70 | if (filter.searchedLocation !== filterState.searchedLocation) { 71 | dispatch(getWeatherData(0, 0, filter.searchedLocation)); 72 | setFilterState({ ...filterState, searchedLocation: filter.searchedLocation }); 73 | } 74 | // When user change units 75 | if (filter.units !== filterState.units) { 76 | if (timezone.latitude && timezone.longitude) { 77 | dispatch(getWeatherData(timezone.latitude, timezone.longitude, location)); 78 | } else { 79 | dispatch(getWeatherData(0, 0, location)); 80 | } 81 | setFilterState({ ...filterState, units: filter.units }); 82 | } 83 | 84 | // When user search weather by particular time 85 | if (filter.timestamp !== filterState.timestamp) { 86 | dispatch(getWeatherData(timezone.latitude, timezone.longitude, location)); 87 | setFilterState({ ...filterState, timestamp: filter.timestamp }); 88 | } 89 | }); 90 | 91 | const renderWeatherAndForecast = () => { 92 | if (error) { 93 | return ( 94 |
95 | 96 | 97 | 98 | 99 | 100 |
101 | ); 102 | } else if (currentWeather && location) { 103 | return ( 104 |
105 | 106 | 107 | 108 |
109 | ); 110 | } 111 | }; 112 | 113 | return ( 114 |
115 | {isLoading ? ( 116 | 117 | 118 |

Loading...

119 |
120 | ) : ( 121 | renderWeatherAndForecast() 122 | )} 123 |
124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/views/weather-map.tsx: -------------------------------------------------------------------------------- 1 | import Alert from 'antd/es/alert'; 2 | import Col from 'antd/es/col'; 3 | import Row from 'antd/es/row'; 4 | import Spin from 'antd/es/spin'; 5 | import { isEmpty, isUndefined } from 'lodash'; 6 | import * as React from 'react'; 7 | import { useEffect, useRef } from 'react'; 8 | import { useDispatch, useSelector } from 'react-redux'; 9 | import { getGeocode } from '../api'; 10 | import { ApiKey } from '../constants/api-key'; 11 | import { USE_DEFAULT_LOCATION } from '../constants/message'; 12 | import { GeoCode, RootState, WeatherMapState } from '../constants/types'; 13 | import { getWeatherData } from '../store/actions'; 14 | 15 | const usePrevious = (value: any) => { 16 | const ref = useRef(); 17 | useEffect(() => { 18 | ref.current = value; 19 | }); 20 | return ref.current; 21 | }; 22 | 23 | export const WeatherMap: React.FC = () => { 24 | const dispatch = useDispatch(); 25 | 26 | const filter = useSelector((state: RootState) => state.weather.filter); 27 | const location = useSelector((state: RootState) => state.weather.location); 28 | const timezone = useSelector((state: RootState) => state.weather.timezone); 29 | 30 | const [searchedLocation, setSearchedLocation] = React.useState(filter.searchedLocation); 31 | const [weatherMapState, setWeatherMapState] = React.useState({ 32 | latitude: 0, 33 | longitude: 0, 34 | location: '', 35 | isLoading: false, 36 | error: '', 37 | }); 38 | const prevState = usePrevious(weatherMapState); 39 | 40 | const renderMap = () => { 41 | try { 42 | const weatherMap = document.getElementById('windy'); 43 | weatherMap.parentNode.removeChild(weatherMap); 44 | } catch (err) { 45 | console.log('map does not exist'); 46 | } 47 | 48 | const divElement: HTMLDivElement = document.createElement('div'); 49 | divElement.setAttribute('id', 'windy'); 50 | divElement.setAttribute('class', 'windy'); 51 | document.getElementById('weather-map-wrapper').appendChild(divElement); 52 | const options = { 53 | key: ApiKey.windy, 54 | lat: weatherMapState.latitude, 55 | lon: weatherMapState.longitude, 56 | }; 57 | 58 | windyInit(options, (windyAPI: any) => { 59 | const { map } = windyAPI; 60 | map.options.minZoom = 4; 61 | map.options.maxZoom = 17; 62 | 63 | const topLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 64 | attribution: '© OpenStreetMap contributors', 65 | minZoom: 12, 66 | maxZoom: 17, 67 | }).addTo(map); 68 | topLayer.setOpacity('0'); 69 | 70 | map.on('zoomend', () => { 71 | if (map.getZoom() >= 12) { 72 | topLayer.setOpacity('1'); 73 | } else { 74 | topLayer.setOpacity('0'); 75 | } 76 | }); 77 | map.setZoom(10); 78 | 79 | L.popup() 80 | .setLatLng([weatherMapState.latitude, weatherMapState.longitude]) 81 | .setContent(weatherMapState.location) 82 | .openOn(map); 83 | }); 84 | }; 85 | 86 | const fetchLatitudeAndLongitude = async (lat: number, lon: number, city: string) => { 87 | if (lat !== 0 && lon !== 0) { 88 | setWeatherMapState({ 89 | latitude: lat, 90 | longitude: lon, 91 | location: city, 92 | isLoading: false, 93 | error: '', 94 | }); 95 | } else { 96 | try { 97 | const geocode: GeoCode = await getGeocode(null, null, city); 98 | if (geocode.status === 'OK') { 99 | setWeatherMapState({ 100 | latitude: geocode.latitude, 101 | longitude: geocode.longitude, 102 | location: geocode.address, 103 | isLoading: false, 104 | error: '', 105 | }); 106 | dispatch(getWeatherData(geocode.latitude, geocode.longitude, geocode.address)); 107 | } 108 | } catch (error) { 109 | setWeatherMapState({ ...weatherMapState, error: error.message }); 110 | } 111 | } 112 | }; 113 | 114 | /** 115 | * Only be called when error occurs 116 | * @param {string} message 117 | */ 118 | const searchByDefaultLocation = (message: string) => { 119 | setWeatherMapState({ ...weatherMapState, error: message }); 120 | setTimeout(async () => { 121 | await fetchLatitudeAndLongitude(-36.8484597, 174.7633315, 'Auckland'); 122 | }, 5000); 123 | }; 124 | 125 | // Do initialise data, get user's location at first 126 | useEffect(() => { 127 | const { latitude, longitude } = timezone || {}; 128 | if (isUndefined(latitude) || isUndefined(longitude)) { 129 | setWeatherMapState({ ...weatherMapState, isLoading: true }); 130 | // Get user's coordinates when user access the web app, it will ask user's location permission 131 | const options = { 132 | enableHighAccuracy: true, 133 | timeout: 5000, 134 | maximumAge: 0, 135 | }; 136 | 137 | const handleLocation = async (location: any) => { 138 | try { 139 | const geocode: GeoCode = await getGeocode(location.coords.latitude, location.coords.longitude, ''); 140 | if (geocode.status === 'OK') { 141 | setWeatherMapState({ 142 | latitude: geocode.latitude, 143 | longitude: geocode.longitude, 144 | location: geocode.address, 145 | isLoading: false, 146 | error: '', 147 | }); 148 | renderMap(); 149 | dispatch(getWeatherData(geocode.latitude, geocode.longitude, geocode.address)); 150 | } 151 | } catch (error) { 152 | searchByDefaultLocation(`${error.message}.${USE_DEFAULT_LOCATION}`); 153 | } 154 | }; 155 | 156 | const handleError = (error: any) => searchByDefaultLocation(`${error.message}.${USE_DEFAULT_LOCATION}`); 157 | 158 | if (process.env.NODE_ENV === 'development') { 159 | searchByDefaultLocation(USE_DEFAULT_LOCATION); 160 | } else { 161 | navigator.geolocation.getCurrentPosition(handleLocation, handleError, options); 162 | } 163 | } else { 164 | setWeatherMapState({ ...weatherMapState, latitude, longitude, location }); 165 | } 166 | }, []); 167 | 168 | useEffect(() => { 169 | if ( 170 | weatherMapState.latitude !== 0 && 171 | weatherMapState.longitude !== 0 && 172 | (weatherMapState.latitude !== prevState.latitude || weatherMapState.longitude !== prevState.longitude) 173 | ) { 174 | renderMap(); 175 | } 176 | 177 | if (filter.searchedLocation !== searchedLocation) { 178 | setWeatherMapState({ ...weatherMapState, isLoading: true }); 179 | fetchLatitudeAndLongitude(0, 0, filter.searchedLocation); 180 | setSearchedLocation(filter.searchedLocation); 181 | } 182 | }); 183 | 184 | return ( 185 |
186 | {weatherMapState.isLoading ? ( 187 | 188 | 189 |

Fetching location...

190 |
191 | ) : !isEmpty(weatherMapState.error) ? ( 192 |
193 | 194 | 195 | 196 | 197 | 198 |
199 | ) : ( 200 |
201 | )} 202 |
203 | ); 204 | }; 205 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "jsx": "react", 5 | "lib": ["dom", "es2017"], 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "outDir": "./dist/", 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "suppressImplicitAnyIndexErrors": true, 13 | "target": "es5", 14 | "allowSyntheticDefaultImports": true 15 | }, 16 | "compileOnSave": true, 17 | "include": ["./src/**/*"] 18 | } 19 | --------------------------------------------------------------------------------