├── .eslintignore
├── dump.rdb
├── public
├── static
│ ├── img
│ │ ├── redis-u-small.png
│ │ ├── vue.svg
│ │ ├── nodejs.svg
│ │ ├── redis.svg
│ │ ├── jest.svg
│ │ └── lua.svg
│ ├── js
│ │ ├── manifest.2ae2e69a05c33dfc65f8.js
│ │ ├── manifest.2ae2e69a05c33dfc65f8.js.map
│ │ └── app.a81024c85819f0f2043f.js
│ └── css
│ │ ├── app.67b676c3ad3cca4844561b3c9355e213.css
│ │ └── app.67b676c3ad3cca4844561b3c9355e213.css.map
└── index.html
├── .gitignore
├── config.json
├── src
├── routes
│ ├── index.js
│ ├── metrics_routes.js
│ ├── capacity_routes.js
│ ├── meterreadings_routes.js
│ └── sites_routes.js
├── daos
│ ├── daoloader.js
│ ├── ratelimiter_dao.js
│ ├── sitestats_dao.js
│ ├── capacity_dao.js
│ ├── feed_dao.js
│ ├── impl
│ │ └── redis
│ │ │ ├── sliding_ratelimiter_dao_redis_impl.js
│ │ │ ├── redis_client.js
│ │ │ ├── scripts
│ │ │ ├── update_if_lowest_script.js
│ │ │ └── compare_and_update_script.js
│ │ │ ├── ratelimiter_dao_redis_impl.js
│ │ │ ├── capacity_dao_redis_impl.js
│ │ │ ├── metric_ts_dao_redis_impl.js
│ │ │ ├── sitestats_dao_redis_impl.js
│ │ │ ├── site_dao_redis_impl.js
│ │ │ ├── feed_dao_redis_impl.js
│ │ │ ├── metric_dao_redis_impl.js
│ │ │ ├── redis_key_generator.js
│ │ │ └── site_geo_dao_redis_impl.js
│ ├── metric_dao.js
│ └── site_dao.js
├── controllers
│ ├── capacity_controller.js
│ ├── metrics_controller.js
│ ├── meterreadings_controller.js
│ └── sites_controller.js
├── utils
│ ├── banner.js
│ ├── apierrorreporter.js
│ ├── logger.js
│ ├── time_utils.js
│ ├── data_loader.js
│ └── sample_data_generator.js
└── app.js
├── .eslintrc
├── package.json
└── README.md
/.eslintignore:
--------------------------------------------------------------------------------
1 | public/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/dump.rdb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/The-Anton/Redis-Monitoring/HEAD/dump.rdb
--------------------------------------------------------------------------------
/public/static/img/redis-u-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/The-Anton/Redis-Monitoring/HEAD/public/static/img/redis-u-small.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | package-lock.json
3 | yarn.lock
4 | .DS_Store
5 | .vscode/
6 | *.bak
7 | *.swp
8 | *.tmp
9 | *.log
10 |
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "application": {
3 | "port": 8081,
4 | "logLevel": "debug",
5 | "dataStore": "redis"
6 | },
7 | "dataStores": {
8 | "redis": {
9 | "host": "localhost",
10 | "port": 6379,
11 | "password": null,
12 | "keyPrefix": "ru102js"
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/routes/index.js:
--------------------------------------------------------------------------------
1 |
2 | const fs = require('fs');
3 |
4 | let routes = [];
5 |
6 | fs.readdirSync(__dirname).filter(file => file !== 'index.js').forEach((file) => {
7 | /* eslint-disable global-require, import/no-dynamic-require */
8 | routes = routes.concat(require(`./${file}`));
9 | /* eslint-enable */
10 | });
11 |
12 | module.exports = routes;
13 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "node": true,
4 | "es6": true
5 | },
6 | "extends": "airbnb",
7 | "parser": "babel-eslint",
8 | "parserOptions": {
9 | "ecmaVersion": 6
10 | },
11 | "rules": {
12 | "no-console": "off",
13 | "no-restricted-syntax": "off",
14 | "no-prototype-builtins": "off"
15 | }
16 | }
--------------------------------------------------------------------------------
/public/static/img/vue.svg:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/src/daos/daoloader.js:
--------------------------------------------------------------------------------
1 | const config = require('better-config');
2 |
3 | /* eslint-disable import/no-dynamic-require, global-require */
4 |
5 | /**
6 | * Load an implementation of a specified DAO.
7 | * @param {string} daoName - the name of the DAO to load
8 | * @returns {Object} - the DAO implemenation for the currently configured database.
9 | */
10 | const loadDao = (daoName) => {
11 | const currentDatabase = config.get('application.dataStore');
12 | return require(`./impl/${currentDatabase}/${daoName}_dao_${currentDatabase}_impl`);
13 | };
14 | /* eslint-enable */
15 |
16 | module.exports = {
17 | loadDao,
18 | };
19 |
--------------------------------------------------------------------------------
/src/controllers/capacity_controller.js:
--------------------------------------------------------------------------------
1 | const capacityDao = require('../daos/capacity_dao');
2 |
3 | /**
4 | * Retrieve the highest / lowest capacity report, containing up to
5 | * 'limit' entries in each.
6 | *
7 | * @param {number} limit - the maximum number of entries to return in each
8 | * part of the report.
9 | * @returns {Promise} - a Promise that resolves to an object containing two
10 | * keys, one for the sites with highest capacity and one for the sites with
11 | * lowest. Each array contains report entry objects.
12 | */
13 | const getCapacityReport = async limit => capacityDao.getReport(limit);
14 |
15 | module.exports = {
16 | getCapacityReport,
17 | };
18 |
--------------------------------------------------------------------------------
/src/utils/banner.js:
--------------------------------------------------------------------------------
1 | const logger = require('./logger');
2 |
3 | /**
4 | * Displays a RediSolar banner as an into level log message.
5 | */
6 | const showBanner = () => {
7 | logger.info(
8 | `
9 | ================================================================================
10 | ____ ___ _____ __
11 | / __ \\___ ____/ (_) ___/____ / /___ ______
12 | / /_/ / _ \\/ __ / /\\__ \\/ __ \\/ / __ \`/ ___/
13 | / _, _/ __/ /_/ / /___/ / /_/ / / /_/ / /
14 | /_/ |_|\\___/\\__,_/_//____/\\____/_/\\__,_/_/
15 | ================================================================================
16 | `,
17 | );
18 | };
19 |
20 | module.exports = showBanner;
21 |
--------------------------------------------------------------------------------
/src/utils/apierrorreporter.js:
--------------------------------------------------------------------------------
1 | const { validationResult } = require('express-validator');
2 |
3 | /**
4 | * Handles reporting of all validate.js validation errors.
5 | *
6 | * @param {Object} req - Express request object.
7 | * @param {Object} res - Express response object.
8 | * @param {Object} next - Express object for next middleware in the chain.
9 | * @returns {Object} - result of calling the next function in the
10 | * Express middleware chain.
11 | */
12 | module.exports = (req, res, next) => {
13 | const errors = validationResult(req);
14 | if (!errors.isEmpty()) {
15 | return res.status(400).json({ errors: errors.array() });
16 | }
17 |
18 | return next();
19 | };
20 |
--------------------------------------------------------------------------------
/src/daos/ratelimiter_dao.js:
--------------------------------------------------------------------------------
1 | const daoLoader = require('./daoloader');
2 |
3 | const impl = daoLoader.loadDao('ratelimiter');
4 |
5 | module.exports = {
6 | /**
7 | * Record a hit against a unique resource that is being
8 | * rate limited. Will return 0 when the resource has hit
9 | * the rate limit.
10 | * @param {string} name - the unique name of the resource.
11 | * @param {Object} opts - object containing interval and maxHits details:
12 | * {
13 | * interval: 1,
14 | * maxHits: 5
15 | * }
16 | * @returns {Promise} - Promise that resolves to number of hits remaining,
17 | * or 0 if the rate limit has been exceeded..
18 | */
19 | hit: async (name, opts) => impl.hit(name, opts),
20 | };
21 |
--------------------------------------------------------------------------------
/src/utils/logger.js:
--------------------------------------------------------------------------------
1 | const winston = require('winston');
2 | const config = require('better-config');
3 |
4 | // Create a logger based on the log level in config.json
5 | const logger = winston.createLogger({
6 | level: config.get('application.logLevel'),
7 | transports: [
8 | new winston.transports.Console({
9 | format: winston.format.combine(
10 | winston.format.colorize(),
11 | winston.format.simple(),
12 | ),
13 | }),
14 | ],
15 | });
16 |
17 | logger.stream = {
18 | // Write the text in 'message' to the log.
19 | write: (message) => {
20 | // Removes double newline issue with piping morgan server request
21 | // log through winston logger.
22 | logger.info(message.length > 0 ? message.substring(0, message.length - 1) : message);
23 | },
24 | };
25 |
26 | module.exports = logger;
27 |
--------------------------------------------------------------------------------
/public/static/js/manifest.2ae2e69a05c33dfc65f8.js:
--------------------------------------------------------------------------------
1 | !function(r){var n=window.webpackJsonp;window.webpackJsonp=function(e,u,c){for(var f,i,p,a=0,l=[];a impl.findById(siteId, timestamp),
15 |
16 | /**
17 | * Updates the site stats for a specific site with the meter
18 | * reading data provided.
19 | *
20 | * @param {Object} meterReading - a meter reading object.
21 | * @returns {Promise} - promise that resolves when the operation is complete.
22 | */
23 | update: async meterReading => impl.update(meterReading),
24 | };
25 |
--------------------------------------------------------------------------------
/src/daos/capacity_dao.js:
--------------------------------------------------------------------------------
1 | const daoLoader = require('./daoloader');
2 |
3 | const impl = daoLoader.loadDao('capacity');
4 |
5 | module.exports = {
6 | /**
7 | * Update capacity information with a new meter reading.
8 | * @param {Object} meterReading - A meter reading.
9 | * @returns {Promise} - Promise indicating the operation has completed.
10 | */
11 | update: async meterReading => impl.update(meterReading),
12 |
13 | /**
14 | * Get the capacity report for a given solar site.
15 | * @param {number} limit - Maximum number of entries to be returned.
16 | * @returns {Promise} - Promise containing capacity report.
17 | */
18 | getReport: async limit => impl.getReport(limit),
19 |
20 | /**
21 | * Get the capacity rank for a given solar site.
22 | * @param {number} siteId - A solar site ID.
23 | * @returns {Promise} - Promise containing rank for siteId as a number.
24 | */
25 | getRank: async siteId => impl.getRank(siteId),
26 | };
27 |
--------------------------------------------------------------------------------
/src/routes/metrics_routes.js:
--------------------------------------------------------------------------------
1 | const router = require('express').Router();
2 | const { param, query } = require('express-validator');
3 | const apiErrorReporter = require('../utils/apierrorreporter');
4 | const controller = require('../controllers/metrics_controller.js');
5 |
6 | // GET /metrics/999?n=50
7 | router.get(
8 | '/metrics/:siteId',
9 | [
10 | param('siteId').isInt().toInt(),
11 | query('n').optional().isInt({ min: 1 }).toInt(),
12 | apiErrorReporter,
13 | ],
14 | async (req, res, next) => {
15 | try {
16 | const limit = (req.query.n == null || Number.isNaN(req.query.n)
17 | || undefined === req.query.n) ? 120 : req.query.n;
18 |
19 | const siteMetricsReport = await controller.getMetricsForSite(req.params.siteId, limit);
20 | return res.status(200).json(siteMetricsReport);
21 | } catch (err) {
22 | if (err.name && err.name === 'TooManyMetricsError') {
23 | return res.status(400).send(err.message);
24 | }
25 |
26 | return next(err);
27 | }
28 | },
29 | );
30 |
31 | module.exports = router;
32 |
--------------------------------------------------------------------------------
/src/daos/feed_dao.js:
--------------------------------------------------------------------------------
1 | const daoLoader = require('./daoloader');
2 |
3 | const impl = daoLoader.loadDao('feed');
4 |
5 | module.exports = {
6 | /**
7 | * Insert a new meter reading into the system.
8 | * @param {Object} meterReading - a meter reading.
9 | * @returns {Promise} - Promise, resolves on completion.
10 | */
11 | insert: async meterReadings => impl.insert(meterReadings),
12 |
13 | /**
14 | * Get recent meter readings for all sites.
15 | * @param {number} limit - the maximum number of readings to return.
16 | * @returns {Promise} - Promise that resolves to an array of meter reading objects.
17 | */
18 | getRecentGlobal: async limit => impl.getRecentGlobal(limit),
19 |
20 | /**
21 | * Get recent meter readings for a specific solar sites.
22 | * @param {number} siteId - the ID of the solar site to get readings for.
23 | * @param {number} limit - the maximum number of readings to return.
24 | * @returns {Promise} - Promise that resolves to an array of meter reading objects.
25 | */
26 | getRecentForSite: async (siteId, limit) => impl.getRecentForSite(siteId, limit),
27 | };
28 |
--------------------------------------------------------------------------------
/src/controllers/metrics_controller.js:
--------------------------------------------------------------------------------
1 | const metricDao = require('../daos/metric_dao');
2 | const timeUtils = require('../utils/time_utils');
3 |
4 | /**
5 | * Retrieve metrics for a specified site ID.
6 | *
7 | * @param {number} siteId - the numeric site ID of a solar site.
8 | * @param {number} limit - the maximum number of metrics to return, if that
9 | * many are available.
10 | * @returns {Promise} - a promise that resolves to an array of two objects,
11 | * one for watt hours generated metrics, the other for watt hours used.
12 | */
13 | const getMetricsForSite = async (siteId, limit) => {
14 | const currentTimestamp = timeUtils.getCurrentTimestamp();
15 |
16 | const metrics = await Promise.all([
17 | metricDao.getRecent(siteId, 'whGenerated', currentTimestamp, limit),
18 | metricDao.getRecent(siteId, 'whUsed', currentTimestamp, limit),
19 | ]);
20 |
21 | return ([{
22 | measurements: metrics[0],
23 | name: 'Watt-Hours Generated',
24 | }, {
25 | measurements: metrics[1],
26 | name: 'Watt-Hours Used',
27 | }]);
28 | };
29 |
30 | module.exports = {
31 | getMetricsForSite,
32 | };
33 |
--------------------------------------------------------------------------------
/src/routes/capacity_routes.js:
--------------------------------------------------------------------------------
1 | const router = require('express').Router();
2 | const { query } = require('express-validator');
3 | const apiErrorReporter = require('../utils/apierrorreporter');
4 | const controller = require('../controllers/capacity_controller');
5 |
6 | /**
7 | * Returns the actual limit to be used, depending on whether or
8 | * not the optional value n is specified.
9 | *
10 | * @param {number} n - the desired limit
11 | * @returns {number} - the actual number used, n if a number was
12 | * passed in, otherwise 10 as a default.
13 | * @private
14 | */
15 | const getLimit = n => (Number.isNaN(n) || undefined === n ? 10 : n);
16 |
17 | // GET /capacity?limit=99
18 | router.get(
19 | '/capacity',
20 | [
21 | query('limit').optional().isInt({ min: 1 }).toInt(),
22 | apiErrorReporter,
23 | ],
24 | async (req, res, next) => {
25 | try {
26 | const capacityReport = await controller.getCapacityReport(getLimit(req.query.limit));
27 |
28 | return res.status(200).json(capacityReport);
29 | } catch (err) {
30 | return next(err);
31 | }
32 | },
33 | );
34 |
35 | module.exports = router;
36 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/sliding_ratelimiter_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const redis = require('./redis_client');
2 | /* eslint-disable no-unused-vars */
3 | const keyGenerator = require('./redis_key_generator');
4 | const timeUtils = require('../../../utils/time_utils');
5 | /* eslint-enable */
6 |
7 | /* eslint-disable no-unused-vars */
8 |
9 | // Challenge 7
10 | const hitSlidingWindow = async (name, opts) => {
11 | const client = redis.getClient();
12 |
13 | // START Challenge #7
14 | return -2;
15 | // END Challenge #7
16 | };
17 |
18 | /* eslint-enable */
19 |
20 | module.exports = {
21 | /**
22 | * Record a hit against a unique resource that is being
23 | * rate limited. Will return 0 when the resource has hit
24 | * the rate limit.
25 | * @param {string} name - the unique name of the resource.
26 | * @param {Object} opts - object containing interval and maxHits details:
27 | * {
28 | * interval: 1,
29 | * maxHits: 5
30 | * }
31 | * @returns {Promise} - Promise that resolves to number of hits remaining,
32 | * or 0 if the rate limit has been exceeded..
33 | */
34 | hit: hitSlidingWindow,
35 | };
36 |
--------------------------------------------------------------------------------
/src/daos/metric_dao.js:
--------------------------------------------------------------------------------
1 | const daoLoader = require('./daoloader');
2 |
3 | // Week 4, change this from 'metric' to 'metric_ts'.
4 | const impl = daoLoader.loadDao('metric');
5 |
6 | module.exports = {
7 | /**
8 | * Insert a new meter reading into the database.
9 | * @param {Object} meterReading - the meter reading to insert.
10 | * @returns {Promise} - Promise that resolves when the operation is completed.
11 | */
12 | insert: async meterReading => impl.insert(meterReading),
13 |
14 | /**
15 | * Get recent metrics for a specific solar site on a given date with
16 | * an optional limit.
17 | * @param {number} siteId - the ID of the solar site to get metrics for.
18 | * @param {string} metricUnit - the name of the metric to get.
19 | * @param {number} timestamp - UNIX timestamp for the date to get metrics for.
20 | * @param {number} limit - maximum number of metrics to be returned.
21 | * @returns {Promise} - Promise resolving to an array of measurement objects.
22 | */
23 | getRecent: async (siteId, metricUnit, timestamp, limit) => impl.getRecent(
24 | siteId,
25 | metricUnit,
26 | timestamp,
27 | limit,
28 | ),
29 | };
30 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/redis_client.js:
--------------------------------------------------------------------------------
1 | const redis = require('redis');
2 | const bluebird = require('bluebird');
3 | const config = require('better-config');
4 |
5 | // Add extra definitions for RedisTimeSeries commands.
6 | redis.addCommand('ts.add'); // redis.ts_addAsync
7 | redis.addCommand('ts.range'); // redis.ts_rangeAsync
8 |
9 | // Promisify all the functions exported by node_redis.
10 | bluebird.promisifyAll(redis);
11 |
12 | // Create a client and connect to Redis using configuration
13 | // from config.json.
14 | const clientConfig = {
15 | host: config.get('dataStores.redis.host'),
16 | port: config.get('dataStores.redis.port'),
17 | };
18 |
19 | if (config.get('dataStores.redis.password')) {
20 | clientConfig.password = config.get('dataStores.redis.password');
21 | }
22 |
23 | const client = redis.createClient(clientConfig);
24 |
25 | // This is a catch all basic error handler.
26 | client.on('error', error => console.log(error));
27 |
28 | module.exports = {
29 | /**
30 | * Get the application's connected Redis client instance.
31 | *
32 | * @returns {Object} - a connected node_redis client instance.
33 | */
34 | getClient: () => client,
35 | };
36 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | const config = require('better-config');
2 |
3 | // Load the configuration file.
4 | config.set('../config.json');
5 |
6 | const express = require('express');
7 | const morgan = require('morgan');
8 | const bodyParser = require('body-parser');
9 | const cors = require('cors');
10 | const path = require('path');
11 | const logger = require('./utils/logger');
12 | const routes = require('./routes');
13 | const banner = require('./utils/banner');
14 |
15 | const app = express();
16 |
17 | // Set up Express components.
18 | app.use(morgan('combined', { stream: logger.stream }));
19 | app.use(bodyParser.json());
20 | app.use(cors());
21 |
22 | // Serve the Vue files statically from the 'public' folder.
23 | app.use(express.static(path.join(__dirname, '../public')));
24 |
25 | // Serve dynamic API routes with '/api/' path prefix.
26 | app.use('/api', routes);
27 |
28 | const port = config.get('application.port');
29 |
30 | // Start the server.
31 | app.listen(port, () => {
32 | banner();
33 | logger.info(`RediSolar listening on port ${port}, using database: ${config.get('application.dataStore')}`);
34 | });
35 |
36 | // For test framework purposes...
37 | module.exports = {
38 | app,
39 | };
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redis-monitoring",
3 | "version": "1.0.0",
4 | "description": "Solar power data ingestion and a monitoring dashboard using Redis as a primary database.",
5 | "main": "./src/app.js",
6 | "directories": {
7 | "test": "test"
8 | },
9 | "private": true,
10 | "scripts": {
11 | "dev": "node ./node_modules/nodemon/bin/nodemon.js",
12 | "load": "node src/utils/data_loader.js --",
13 | "lint": "./node_modules/eslint/bin/eslint.js src tests",
14 | "start": "node ./src/app.js",
15 | "test": "jest --no-colors",
16 | "testdev": "jest --watch"
17 | },
18 | "author": "Redis Labs",
19 | "engines": {
20 | "node": ">=8.9.4"
21 | },
22 | "license": "MIT",
23 | "dependencies": {
24 | "better-config": "^1.2.3",
25 | "bluebird": "^3.5.5",
26 | "body-parser": "^1.19.0",
27 | "cors": "^2.8.5",
28 | "express": "^4.17.1",
29 | "express-validator": "^6.0.0",
30 | "moment": "^2.24.0",
31 | "morgan": "^1.9.1",
32 | "redis": "^2.8.0",
33 | "round-to": "^4.0.0",
34 | "shortid": "^2.2.14",
35 | "winston": "^3.2.1"
36 | },
37 | "devDependencies": {
38 | "babel-eslint": "^10.0.2",
39 | "eslint": "^5.16.0",
40 | "eslint-config-airbnb": "^17.1.0",
41 | "eslint-plugin-import": "^2.17.3",
42 | "eslint-plugin-jsx-a11y": "^6.2.1",
43 | "eslint-plugin-react": "^7.13.0",
44 | "jest": "^24.8.0",
45 | "nodemon": "^1.19.4"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/scripts/update_if_lowest_script.js:
--------------------------------------------------------------------------------
1 | const redis = require('../redis_client');
2 |
3 | let sha;
4 |
5 | /**
6 | * Get the Lua source code for the script.
7 | * @returns {string} - Lua source code for the script.
8 | * @private
9 | */
10 | const getSource = () => `
11 | local key = KEYS[1]
12 | local new = ARGV[1]
13 | local current = redis.call('GET', key)
14 |
15 | if (current == false) or (tonumber(new) < tonumber(current)) then
16 | redis.call('SET', key, new)
17 | return 1
18 | else
19 | return 0
20 | end;
21 | `;
22 |
23 | const load = async () => {
24 | const client = redis.getClient();
25 |
26 | // Load script on first use...
27 | if (!sha) {
28 | sha = await client.scriptAsync('load', getSource());
29 | }
30 |
31 | return sha;
32 | };
33 |
34 | const updateIfLowest = (key, value) => [
35 | sha, // Script SHA
36 | 1, // Number of Redis keys
37 | key,
38 | value,
39 | ];
40 |
41 | module.exports = {
42 | /**
43 | * Load the script into Redis and return its SHA.
44 | * @returns {string} - The SHA for this script.
45 | */
46 | load,
47 |
48 | /**
49 | * Build up an array of parameters that evalsha will use to run
50 | * an atomic compare and update if lower operation.
51 | *
52 | * @param {string} key - Redis key that the script will operate on.
53 | * @param {number} value - Value to set the key to if it passes the
54 | * comparison test.
55 | * @returns {number} - 1 if the update was performed, 0 otherwise.
56 | */
57 | updateIfLowest,
58 | };
59 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 | RU102JS: Redis for JavaScript Developers
--------------------------------------------------------------------------------
/src/daos/impl/redis/ratelimiter_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const redis = require('./redis_client');
2 | const keyGenerator = require('./redis_key_generator');
3 |
4 | /**
5 | * Record a hit against a unique resource that is being
6 | * rate limited. Will return -1 when the resource has hit
7 | * the rate limit.
8 | * @param {string} name - the unique name of the resource.
9 | * @param {Object} opts - object containing interval and maxHits details:
10 | * {
11 | * interval: 1,
12 | * maxHits: 5
13 | * }
14 | * @returns {Promise} - Promise that resolves to number of hits remaining,
15 | * or 0 if the rate limit has been exceeded..
16 | *
17 | * @private
18 | */
19 | const hitFixedWindow = async (name, opts) => {
20 | const client = redis.getClient();
21 | const key = keyGenerator.getRateLimiterKey(name, opts.interval, opts.maxHits);
22 |
23 | const pipeline = client.batch();
24 |
25 | pipeline.incr(key);
26 | pipeline.expire(key, opts.interval * 60);
27 |
28 | const response = await pipeline.execAsync();
29 | const hits = parseInt(response[0], 10);
30 |
31 | let hitsRemaining;
32 |
33 | if (hits > opts.maxHits) {
34 | // Too many hits.
35 | hitsRemaining = -1;
36 | } else {
37 | // Return number of hits remaining.
38 | hitsRemaining = opts.maxHits - hits;
39 | }
40 |
41 | return hitsRemaining;
42 | };
43 |
44 | module.exports = {
45 | /**
46 | * Record a hit against a unique resource that is being
47 | * rate limited. Will return 0 when the resource has hit
48 | * the rate limit.
49 | * @param {string} name - the unique name of the resource.
50 | * @param {Object} opts - object containing interval and maxHits details:
51 | * {
52 | * interval: 1,
53 | * maxHits: 5
54 | * }
55 | * @returns {Promise} - Promise that resolves to number of hits remaining,
56 | * or 0 if the rate limit has been exceeded..
57 | */
58 | hit: hitFixedWindow,
59 | };
60 |
--------------------------------------------------------------------------------
/src/controllers/meterreadings_controller.js:
--------------------------------------------------------------------------------
1 | const metricDao = require('../daos/metric_dao');
2 | const siteStatsDao = require('../daos/sitestats_dao');
3 | const capacityDao = require('../daos/capacity_dao');
4 | const feedDao = require('../daos/feed_dao');
5 |
6 | /**
7 | * Receives an array of meter reading objects and updates the
8 | * database for these. Writes to metrics, site stats, capacity
9 | * and feed DAOs.
10 | *
11 | * @param {Array} meterReadings - array of meterreading objects.
12 | * @returns {Promise} - a promise that resolves when the operation is complete.
13 | */
14 | const createMeterReadings = async (meterReadings) => {
15 | for (const meterReading of meterReadings) {
16 | /* eslint-disable no-await-in-loop */
17 | await metricDao.insert(meterReading);
18 | await siteStatsDao.update(meterReading);
19 | await capacityDao.update(meterReading);
20 | await feedDao.insert(meterReading);
21 | /* eslint-enable */
22 | }
23 | };
24 |
25 | /**
26 | * Retrieve entries from the global meter reading feed, up to the number
27 | * of entries specified by 'limit'.
28 | *
29 | * @param {number} limit - the maximum number of entries to retrieve from the feed.
30 | * @returns {Promise} - promise that resolves to an array of feed entries.
31 | */
32 | const getMeterReadings = async limit => feedDao.getRecentGlobal(limit);
33 |
34 | /**
35 | * Retrieve entries from an individual site's meter reading feed, up to the
36 | * number of entries specified by 'limit'.
37 | *
38 | * @param {number} siteId - the numeric ID of the site to retrieve entries for.
39 | * @param {number} limit - the maximum number of entries to retrieve from the feed.
40 | * @returns {Promise} - promise that resolves to an array of feed entries.
41 | */
42 | const getMeterReadingsForSite = async (siteId, limit) => feedDao.getRecentForSite(siteId, limit);
43 |
44 | module.exports = {
45 | createMeterReadings,
46 | getMeterReadings,
47 | getMeterReadingsForSite,
48 | };
49 |
--------------------------------------------------------------------------------
/src/controllers/sites_controller.js:
--------------------------------------------------------------------------------
1 | const siteDao = require('../daos/site_dao');
2 |
3 | /**
4 | * Creates a new site in the database.
5 | *
6 | * @param {Object} site - a site object.
7 | * @returns {Promise} - a Promise that resolves when the operation is complete.
8 | */
9 | const createSite = async site => siteDao.insert(site);
10 |
11 | /**
12 | * Retrieve all sites from the database.
13 | *
14 | * @returns {Promise} - a Promise that resolves to an array of site objects.
15 | */
16 | const getSites = async () => siteDao.findAll();
17 |
18 | /**
19 | * Retrieve an individual site from the database.
20 | *
21 | * @param {number} siteId - the numeric ID of the site to retrieve.
22 | * @returns {Promise} - a Promise that resolves to a site object.
23 | */
24 | const getSite = async siteId => siteDao.findById(siteId);
25 |
26 | /**
27 | * Retrieve sites that are within a specified distance of a coordinate,
28 | * optionally filtering so that only sites having excess capacity are
29 | * returned.
30 | *
31 | * @param {number} lat - the latitude of the center point to search from.
32 | * @param {number} lng - the longitude of the center point to search from.
33 | * @param {number} radius - the geo search radius.
34 | * @param {string} radiusUnit - the unit that radius is specified in ('MI', 'KM').
35 | * @param {boolean} onlyExcessCapacity - if true, only sites in range that have
36 | * excess capacity are returned. If false, all sites within range are returned.
37 | * @returns {Promise} - a Promise that resolves to an array of site objects.
38 | */
39 | const getSitesNearby = async (lat, lng, radius, radiusUnit, onlyExcessCapacity) => {
40 | const matchingSites = onlyExcessCapacity
41 | ? await siteDao.findByGeoWithExcessCapacity(
42 | lat,
43 | lng,
44 | radius,
45 | radiusUnit,
46 | )
47 | : await siteDao.findByGeo(
48 | lat,
49 | lng,
50 | radius,
51 | radiusUnit,
52 | );
53 |
54 | return matchingSites;
55 | };
56 |
57 | module.exports = {
58 | createSite,
59 | getSites,
60 | getSite,
61 | getSitesNearby,
62 | };
63 |
--------------------------------------------------------------------------------
/src/utils/time_utils.js:
--------------------------------------------------------------------------------
1 | const moment = require('moment');
2 |
3 | /**
4 | * Takes a UNIX timestamp in seconds and returns the minute of the day
5 | * represented by that timestamp, 00:01 = 1, 01:02 = 62 etc.
6 | *
7 | * @param {number} timestamp - a UNIX timestamp in seconds.
8 | * @returns {number} - the minute of the day that the supplied timestamp represents.
9 | */
10 | const getMinuteOfDay = (timestamp) => {
11 | const t = (timestamp === undefined ? Math.floor(new Date().getTime() / 1000) : timestamp);
12 | const ts = moment.unix(t).utc();
13 | const dayStart = moment.unix(t).utc().startOf('day');
14 |
15 | return ts.diff(dayStart, 'minutes');
16 | };
17 |
18 | /**
19 | * Given a timestamp representing a specific date, and a number representing
20 | * a minute on that date, return the timestamp for that minute on that date.
21 | *
22 | * @param {number} timestamp - UNIX timestamp in seconds for the date to work with.
23 | * @param {number} minute - the minute of the day to get a timestamp for.
24 | * @returns {number} - a UNIX timestamp in seconds representing the specified
25 | * minute of the day that timestamp falls on.
26 | */
27 | const getTimestampForMinuteOfDay = (timestamp, minute) => {
28 | const dayStart = moment.unix(timestamp).utc().startOf('day');
29 |
30 | return dayStart.add(minute, 'minutes').unix();
31 | };
32 |
33 | /**
34 | * Takes a UNIX timestamp in seconds and converts it to a YYYY-MM-DD string.
35 | * @param {number} timestamp - a UNIX timestamp in seconds.
36 | * @returns {string} - the YYYY-MM-DD string for the supplied timestamp.
37 | */
38 | const getDateString = timestamp => moment.unix(timestamp).utc().format('YYYY-MM-DD');
39 |
40 | /**
41 | * Returns the current UNIX timestamp in seconds.
42 | *
43 | * @returns {number} - the current UNIX timestamp in seconds.
44 | */
45 | const getCurrentTimestamp = () => moment().unix();
46 |
47 | /**
48 | * Returns the current UNIX timestamp in milliseconds.
49 | *
50 | * @returns {number} - the current UNIX timestamp in milliseconds.
51 | */
52 | const getCurrentTimestampMillis = () => moment().valueOf();
53 |
54 | module.exports = {
55 | getMinuteOfDay,
56 | getTimestampForMinuteOfDay,
57 | getDateString,
58 | getCurrentTimestamp,
59 | getCurrentTimestampMillis,
60 | };
61 |
--------------------------------------------------------------------------------
/src/daos/site_dao.js:
--------------------------------------------------------------------------------
1 | const daoLoader = require('./daoloader');
2 |
3 | // Week 3, change this from 'site' to 'site_geo'.
4 | const impl = daoLoader.loadDao('site');
5 |
6 | module.exports = {
7 | /**
8 | * Insert a new site.
9 | *
10 | * @param {Object} site - a site object.
11 | * @returns {Promise} - a Promise, resolving to the string value
12 | * for the ID of the site in the database.
13 | */
14 | insert: async site => impl.insert(site),
15 |
16 | /**
17 | * Get the site object for a given site ID.
18 | *
19 | * @param {number} id - a site ID.
20 | * @returns {Promise} - a Promise, resolving to a site object.
21 | */
22 | findById: async id => impl.findById(id),
23 |
24 | /**
25 | * Get an array of all site objects.
26 | *
27 | * @returns {Promise} - a Promise, resolving to an array of site objects.
28 | */
29 | findAll: async () => impl.findAll(),
30 |
31 | /**
32 | * Get an array of sites within a radius of a given coordinate.
33 | *
34 | * For week 3.
35 | *
36 | * @param {number} lat - Latitude of the coordinate to search from.
37 | * @param {number} lng - Longitude of the coordinate to search from.
38 | * @param {number} radius - Radius in which to search.
39 | * @param {'KM' | 'MI'} radiusUnit - The unit that the value of radius is in.
40 | * @returns {Promise} - a Promise, resolving to an array of site objects.
41 | */
42 | findByGeo: async (lat, lng, radius, radiusUnit) => impl.findByGeo(
43 | lat,
44 | lng,
45 | radius,
46 | radiusUnit,
47 | ),
48 |
49 | /**
50 | * Get an array of sites where capacity exceeds consumption within
51 | * a radius of a given coordinate.
52 | *
53 | * For week 3.
54 | *
55 | * @param {number} lat - Latitude of the coordinate to search from.
56 | * @param {number} lng - Longitude of the coordinate to search from.
57 | * @param {number} radius - Radius in which to search.
58 | * @param {'KM' | 'MI'} radiusUnit - The unit that the value of radius is in.
59 | * @returns {Promise} - a Promise, resolving to an array of site objects.
60 | */
61 | findByGeoWithExcessCapacity: async (lat, lng, radius, radiusUnit) => (
62 | impl.findByGeoWithExcessCapacity(
63 | lat,
64 | lng,
65 | radius,
66 | radiusUnit,
67 | )
68 | ),
69 | };
70 |
--------------------------------------------------------------------------------
/src/routes/meterreadings_routes.js:
--------------------------------------------------------------------------------
1 | const router = require('express').Router();
2 | const { body, param, query } = require('express-validator');
3 | const apiErrorReporter = require('../utils/apierrorreporter');
4 | const controller = require('../controllers/meterreadings_controller');
5 |
6 | /**
7 | * Get the numeric limit value, 100 if not specified, otherwise
8 | * use the number specified up to 1000 maximum.
9 | *
10 | * @param {number} n - the number of readings to get.
11 | * @returns {number} - the number of readings that the request will be capped at.
12 | * @private
13 | */
14 | const getLimit = (n) => {
15 | if (Number.isNaN(n) || undefined === n) {
16 | return 100;
17 | }
18 |
19 | return (n > 1000 ? 1000 : n);
20 | };
21 |
22 | // POST /meterreadings
23 | // Body is array of objects as described below.
24 | router.post(
25 | '/meterreadings',
26 | [
27 | body().isArray(),
28 | body('*.siteId').isInt(),
29 | body('*.dateTime').isInt({ min: 0 }),
30 | body('*.whUsed').isFloat({ min: 0 }),
31 | body('*.whGenerated').isFloat({ min: 0 }),
32 | body('*.tempC').isFloat(),
33 | apiErrorReporter,
34 | ],
35 | async (req, res, next) => {
36 | try {
37 | await controller.createMeterReadings(req.body);
38 | return res.status(201).send('OK');
39 | } catch (err) {
40 | return next(err);
41 | }
42 | },
43 | );
44 |
45 | // GET /meterreadings?n=99
46 | router.get(
47 | '/meterreadings',
48 | [
49 | query('n').optional().isInt({ min: 1 }).toInt(),
50 | apiErrorReporter,
51 | ],
52 | async (req, res, next) => {
53 | try {
54 | const readings = await controller.getMeterReadings(getLimit(req.query.n));
55 | return res.status(200).json(readings);
56 | } catch (err) {
57 | return next(err);
58 | }
59 | },
60 | );
61 |
62 | // GET /meterreadings/123?n=99
63 | router.get(
64 | '/meterreadings/:siteId',
65 | [
66 | param('siteId').isInt().toInt(),
67 | query('n').optional().isInt({ min: 1 }).toInt(),
68 | apiErrorReporter,
69 | ],
70 | async (req, res, next) => {
71 | try {
72 | const readings = await controller.getMeterReadingsForSite(
73 | req.params.siteId,
74 | getLimit(req.query.n),
75 | );
76 |
77 | return res.status(200).json(readings);
78 | } catch (err) {
79 | return next(err);
80 | }
81 | },
82 | );
83 |
84 | module.exports = router;
85 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/capacity_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const redis = require('./redis_client');
2 | const keyGenerator = require('./redis_key_generator');
3 |
4 | /**
5 | * Transform an array of siteId, capacity tuples to an array
6 | * of objects.
7 | * @param {array} arr - array of siteId, capacity tuples
8 | * @returns {Object[]} - array of Objects
9 | * @private
10 | */
11 | const remap = (arr) => {
12 | const remapped = [];
13 |
14 | for (let n = 0; n < arr.length; n += 2) {
15 | remapped.push({
16 | siteId: parseInt(arr[n], 10),
17 | capacity: parseFloat(arr[n + 1]),
18 | });
19 | }
20 |
21 | return remapped;
22 | };
23 |
24 | /**
25 | * Update capacity information with a new meter reading.
26 | * @param {Object} meterReading - A meter reading.
27 | * @returns {Promise} - Promise indicating the operation has completed.
28 | */
29 | const update = async (meterReading) => {
30 | const client = redis.getClient();
31 | const currentCapacity = meterReading.whGenerated - meterReading.whUsed;
32 |
33 | await client.zaddAsync(
34 | keyGenerator.getCapacityRankingKey(),
35 | currentCapacity,
36 | meterReading.siteId,
37 | );
38 | };
39 |
40 | /**
41 | * Get the capacity report for a given solar site.
42 | * @param {number} limit - Maximum number of entries to be returned.
43 | * @returns {Promise} - Promise containing capacity report.
44 | */
45 | const getReport = async (limit) => {
46 | const client = redis.getClient();
47 | const capacityRankingKey = keyGenerator.getCapacityRankingKey();
48 | const pipeline = client.batch();
49 |
50 | pipeline.zrange(capacityRankingKey, 0, limit - 1, 'WITHSCORES');
51 | pipeline.zrevrange(capacityRankingKey, 0, limit - 1, 'WITHSCORES');
52 |
53 | const results = await pipeline.execAsync();
54 |
55 | return {
56 | lowestCapacity: remap(results[0]),
57 | highestCapacity: remap(results[1]),
58 | };
59 | };
60 |
61 | /**
62 | * Get the capacity rank for a given solar site.
63 | * @param {number} siteId - A solar site ID.
64 | * @returns {Promise} - Promise containing rank for siteId as a number.
65 | */
66 | const getRank = async (siteId) => {
67 | // START Challenge #4
68 | const client = redis.getClient();
69 |
70 | const result = await client.zrankAsync(
71 | keyGenerator.getCapacityRankingKey(),
72 | `${siteId}`,
73 | );
74 |
75 | return result;
76 | // END Challenge #4
77 | };
78 |
79 | module.exports = {
80 | update,
81 | getReport,
82 | getRank,
83 | };
84 |
--------------------------------------------------------------------------------
/src/utils/data_loader.js:
--------------------------------------------------------------------------------
1 | const config = require('better-config');
2 |
3 | config.set('../../config.json');
4 |
5 | const path = require('path');
6 | const redis = require('../daos/impl/redis/redis_client');
7 |
8 | const client = redis.getClient();
9 | const sitesDao = require('../daos/impl/redis/site_dao_redis_impl');
10 | const sitesDaoWithGeo = require('../daos/impl/redis/site_geo_dao_redis_impl');
11 | const dataGenerator = require('./sample_data_generator');
12 |
13 | const dataDaysToGenerate = 1;
14 |
15 | /**
16 | * Flush the Redis database, deleting all data.
17 | *
18 | * @returns {Promise} - a promise that resolves when the operation is complete.
19 | */
20 | const flushDB = async () => client.flushdbAsync();
21 |
22 | /**
23 | *
24 | * @param {string} filename - the name of the file to load data from.
25 | * @param {boolean} flushDb - whether or not to delete all data from Redis before loading.
26 | * @returns {Promise} - a promise that resolves when the operation is complete.
27 | */
28 | const loadData = async (filename, flushDb) => {
29 | /* eslint-disable global-require, import/no-dynamic-require */
30 | const sampleData = require(filename);
31 | /* eslint-enable */
32 |
33 | if (sampleData && flushDb) {
34 | console.log('Flushing database before loading sample data.');
35 | await flushDB();
36 | }
37 |
38 | console.log('Loading data.');
39 |
40 | for (const site of sampleData) {
41 | /* eslint-disable no-await-in-loop */
42 | await sitesDao.insert(site);
43 | await sitesDaoWithGeo.insert(site);
44 | /* eslint-enable */
45 | }
46 |
47 | await dataGenerator.generateHistorical(sampleData, dataDaysToGenerate);
48 | };
49 |
50 | /**
51 | * Run the data loader. Will load the sample data into Redis.
52 | *
53 | * @param {Array} params - array of command line arguments.
54 | */
55 | const runDataLoader = async (params) => {
56 | if (params.length !== 4 && params.length !== 5) {
57 | console.error('Usage: npm run load [flushdb]');
58 | } else {
59 | const filename = params[3];
60 | let flushDb = false;
61 |
62 | if (params.length === 5 && params[4] === 'flushdb') {
63 | flushDb = true;
64 | }
65 |
66 | try {
67 | await loadData(path.resolve(__dirname, '../../', filename), flushDb);
68 | } catch (e) {
69 | console.error(`Error loading ${filename}:`);
70 | console.error(e);
71 | }
72 |
73 | client.quit();
74 | console.log('Data load completed.');
75 | }
76 | };
77 |
78 | runDataLoader(process.argv);
79 |
--------------------------------------------------------------------------------
/src/routes/sites_routes.js:
--------------------------------------------------------------------------------
1 | const router = require('express').Router();
2 | const { param, query } = require('express-validator');
3 | const apiErrorReporter = require('../utils/apierrorreporter');
4 | const controller = require('../controllers/sites_controller.js');
5 |
6 | /**
7 | * Custom validate.js validator. Validates a set of parameters to
8 | * make sure enough data was passed in to complete a geo search.
9 | *
10 | * @param {*} value - unused but required by validate.js.
11 | * @param {Object} param1 - object containins request parameters to check.
12 | * @returns {boolean} - true if the provided geo params are complete.
13 | * @private
14 | */
15 | const geoParamsValidator = (value, { req }) => {
16 | const {
17 | lat, lng, radius, radiusUnit,
18 | } = req.query;
19 |
20 | if (lat && lng && radius && radiusUnit) {
21 | return true;
22 | }
23 |
24 | throw new Error('When using geo lookup, params lat, lng, radius, radiusUnit are required.');
25 | };
26 |
27 | // GET /sites
28 | router.get(
29 | '/sites',
30 | [
31 | /* eslint-disable newline-per-chained-call */
32 | query('lat').optional().custom(geoParamsValidator).isFloat().toFloat(),
33 | query('lng').optional().custom(geoParamsValidator).isFloat().toFloat(),
34 | query('radius').optional().custom(geoParamsValidator).isFloat({ min: 0.1 }).toFloat(),
35 | query('radiusUnit').optional().custom(geoParamsValidator).isIn(['MI', 'KM']),
36 | query('onlyExcessCapacity').optional().isBoolean().toBoolean(),
37 | /* eslint-enable */
38 | apiErrorReporter,
39 | ],
40 | async (req, res, next) => {
41 | try {
42 | const {
43 | lat, lng, radius, radiusUnit, onlyExcessCapacity,
44 | } = req.query;
45 |
46 | const sites = (
47 | lat
48 | ? await controller.getSitesNearby(
49 | lat,
50 | lng,
51 | radius,
52 | radiusUnit,
53 | onlyExcessCapacity,
54 | )
55 | : await controller.getSites()
56 | );
57 |
58 | return res.status(200).json(sites);
59 | } catch (err) {
60 | return next(err);
61 | }
62 | },
63 | );
64 |
65 | // GET /sites/999
66 | router.get(
67 | '/sites/:siteId',
68 | [
69 | param('siteId').isInt().toInt(),
70 | apiErrorReporter,
71 | ],
72 | async (req, res, next) => {
73 | try {
74 | const site = await controller.getSite(req.params.siteId);
75 | return (site ? res.status(200).json(site) : res.sendStatus(404));
76 | } catch (err) {
77 | return next(err);
78 | }
79 | },
80 | );
81 |
82 | module.exports = router;
83 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/scripts/compare_and_update_script.js:
--------------------------------------------------------------------------------
1 | const redis = require('../redis_client');
2 |
3 | let sha;
4 |
5 | /**
6 | * Get the Lua source code for the script.
7 | * @returns {string} - Lua source code for the script.
8 | * @private
9 | */
10 | const getSource = () => `
11 | local key = KEYS[1]
12 | local field = ARGV[1]
13 | local value = ARGV[2]
14 | local op = ARGV[3]
15 | local current = redis.call('hget', key, field)
16 | if (current == false or current == nil) then
17 | redis.call('hset', key, field, value)
18 | elseif op == '>' then
19 | if tonumber(value) > tonumber(current) then
20 | redis.call('hset', key, field, value)
21 | end
22 | elseif op == '<' then
23 | if tonumber(value) < tonumber(current) then
24 | redis.call('hset', key, field, value)
25 | end
26 | end `;
27 |
28 | const load = async () => {
29 | const client = redis.getClient();
30 |
31 | // Load script on first use...
32 | if (!sha) {
33 | sha = await client.scriptAsync('load', getSource());
34 | }
35 |
36 | return sha;
37 | };
38 |
39 | /**
40 | * Build up an array of parameters that will be passed to
41 | * evalsha.
42 | *
43 | * @param {string} key - Redis key that the script will operate on.
44 | * @param {string} field - Field name in the hash to use.
45 | * @param {number} value - Value to set the field to if it passes the
46 | * comparison test.
47 | * @param {string} comparator - '<' or '>' depending on whether the value
48 | * should be updated if less or greater than the existing value.
49 | * @returns {Array} - array of parameters that evalsha can use to execute
50 | * the script.
51 | * @private
52 | */
53 | const buildEvalshaParams = (key, field, value, comparator) => [
54 | sha, // Script SHA
55 | 1, // Number of Redis keys
56 | key,
57 | field,
58 | value,
59 | comparator,
60 | ];
61 |
62 | const updateIfGreater = (key, field, value) => buildEvalshaParams(key, field, value, '>');
63 |
64 | const updateIfLess = (key, field, value) => buildEvalshaParams(key, field, value, '<');
65 |
66 | module.exports = {
67 | /**
68 | * Load the script into Redis and return its SHA.
69 | * @returns {string} - The SHA for this script.
70 | */
71 | load,
72 |
73 | /**
74 | * Build up an array of parameters that evalsha will use to run
75 | * a compare and set if greater operation.
76 | *
77 | * @param {string} key - Redis key that the script will operate on.
78 | * @param {string} field - Field name in the hash to use.
79 | * @param {number} value - Value to set the field to if it passes the
80 | * comparison test.
81 | * @returns {Array} - array of parameters that evalsha can use to execute
82 | * the script.
83 | */
84 | updateIfGreater,
85 |
86 | /**
87 | * Build up an array of parameters that evalsha will use to run
88 | * a compare and set if less operation.
89 | *
90 | * @param {string} key - Redis key that the script will operate on.
91 | * @param {string} field - Field name in the hash to use.
92 | * @param {number} value - Value to set the field to if it passes the
93 | * comparison test.
94 | * @returns {Array} - array of parameters that evalsha can use to execute
95 | * the script.
96 | */
97 | updateIfLess,
98 | };
99 |
--------------------------------------------------------------------------------
/public/static/img/nodejs.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/metric_ts_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const redis = require('./redis_client');
2 | const keyGenerator = require('./redis_key_generator');
3 |
4 | const metricIntervalSeconds = 60;
5 | const metricsPerDay = metricIntervalSeconds * 24;
6 | const maxMetricRetentionDays = 30;
7 | const daySeconds = 24 * 60 * 60;
8 | const timeSeriesMetricRetention = daySeconds * maxMetricRetentionDays * 1000;
9 |
10 | /**
11 | * Insert a metric into the database for a given solar site ID.
12 | * This function is used in week 4, and uses RedisTimeSeries to store
13 | * the metric.
14 | *
15 | * @param {number} siteId - a solar site ID.
16 | * @param {number} metricValue - the value of the metric to store.
17 | * @param {string} metricName - the name of the metric to store.
18 | * @param {number} timestamp - a UNIX timestamp.
19 | * @returns {Promise} - Promise that resolves when the operation is complete.
20 | * @private
21 | */
22 | const insertMetric = async (siteId, metricValue, metricName, timestamp) => {
23 | const client = redis.getClient();
24 |
25 | await client.ts_addAsync(
26 | keyGenerator.getTSKey(siteId, metricName),
27 | timestamp * 1000, // Use millseconds
28 | metricValue,
29 | 'RETENTION',
30 | timeSeriesMetricRetention,
31 | );
32 | };
33 |
34 | /**
35 | * Insert a new meter reading into the time series.
36 | * @param {Object} meterReading - the meter reading to insert.
37 | * @returns {Promise} - Promise that resolves when the operation is completed.
38 | */
39 | const insert = async (meterReading) => {
40 | await Promise.all([
41 | insertMetric(meterReading.siteId, meterReading.whGenerated, 'whGenerated', meterReading.dateTime),
42 | insertMetric(meterReading.siteId, meterReading.whUsed, 'whUsed', meterReading.dateTime),
43 | insertMetric(meterReading.siteId, meterReading.tempC, 'tempC', meterReading.dateTime),
44 | ]);
45 | };
46 |
47 | /**
48 | * Get recent metrics for a specific solar site on a given date with
49 | * an optional limit. This implementation uses RedisTimeSeries.
50 | * @param {number} siteId - the ID of the solar site to get metrics for.
51 | * @param {string} metricUnit - the name of the metric to get.
52 | * @param {number} timestamp - UNIX timestamp for the date to get metrics for.
53 | * @param {number} limit - maximum number of metrics to be returned.
54 | * @returns {Promise} - Promise resolving to an array of measurement objects.
55 | */
56 | const getRecent = async (siteId, metricUnit, timestamp, limit) => {
57 | if (limit > (metricsPerDay * maxMetricRetentionDays)) {
58 | const err = new Error(`Cannot request more than ${maxMetricRetentionDays} days of minute level data.`);
59 | err.name = 'TooManyMetricsError';
60 |
61 | throw err;
62 | }
63 |
64 | const client = redis.getClient();
65 |
66 | // End at the provided start point.
67 | const toMillis = timestamp * 1000;
68 |
69 | // Start as far back as we need to go where limit represents 1 min.
70 | const fromMillis = toMillis - (limit * 60) * 1000;
71 |
72 | // Get the samples from RedisTimeSeries.
73 | // We could also use client.send_commandAsync('ts.range')
74 | // rather than adding the RedisTimeSeries commands
75 | // to the redis module (see redis_client.js)
76 | const samples = await client.ts_rangeAsync(
77 | keyGenerator.getTSKey(siteId, metricUnit),
78 | fromMillis,
79 | toMillis,
80 | );
81 |
82 | // Truncate array if needed.
83 | if (samples.length > limit) {
84 | samples.length = limit;
85 | }
86 |
87 | const measurements = [];
88 |
89 | // Samples is an array of arrays [ timestamp in millis, 'value as string' ]
90 | for (const sample of samples) {
91 | measurements.push({
92 | siteId,
93 | dateTime: Math.floor(sample[0] / 1000),
94 | value: parseFloat(sample[1], 10),
95 | metricUnit,
96 | });
97 | }
98 |
99 | return measurements;
100 | };
101 |
102 | module.exports = {
103 | insert,
104 | getRecent,
105 | };
106 |
--------------------------------------------------------------------------------
/src/utils/sample_data_generator.js:
--------------------------------------------------------------------------------
1 | const moment = require('moment');
2 |
3 | const meterReadingsController = require('../controllers/meterreadings_controller');
4 |
5 | // Max value we allow temperatures to reach when generating sample data.
6 | const maxTempC = 30;
7 |
8 | /**
9 | * Calculates the max possible watt hours generated in a minute, for a
10 | * site with the specified capacity.
11 | *
12 | * @param {number} capacity - the site capacity.
13 | * @returns {number} - the max possible watt hours generated this site can
14 | * achieve in one minute.
15 | * @private
16 | */
17 | const getMaxMinuteWHGenerated = capacity => capacity * 1000 / 24 / 60;
18 |
19 | /**
20 | * Get the initial watt hours used figure for the first minute,
21 | * based on a site's given maximum capacity.
22 | *
23 | * @param {number} maxCapacity - the maximum capacity of the site.
24 | * @returns {number} - the initial minute watt hour used figure to use.
25 | * @private
26 | */
27 | const getInitialMinuteWHUsed = maxCapacity => (
28 | Math.random() > 0.5 ? maxCapacity + 0.1 : maxCapacity - 0.1
29 | );
30 |
31 | /**
32 | * Gets the next value in a series of values.
33 | *
34 | * @param {*} current - the current value.
35 | * @param {*} max - the maximum allowed value.
36 | * @returns {number} - the next value.
37 | * @private
38 | */
39 | const getNextValueInSeries = (current, max) => {
40 | const stepSize = 0.1 * max;
41 |
42 | if (Math.random() < 0.5) {
43 | return current + stepSize;
44 | }
45 |
46 | if (current - stepSize < 0) {
47 | return 0;
48 | }
49 |
50 | return current - stepSize;
51 | };
52 |
53 | /**
54 | * Gets the next value in a series of values.
55 | *
56 | * @param {number} max - the maximum allowed value.
57 | * @returns {number} - the next value.
58 | * @private
59 | */
60 | const getNextValue = max => getNextValueInSeries(max, max);
61 |
62 | /**
63 | * Generates historical sample data for each site in the 'sites' array.
64 | *
65 | * @param {Array} sites - array of site objects.
66 | * @param {number} days - how many days to generate data for (1-365 inclusive).
67 | * @returns {Promise} - a promise that resolves when the data has been generated.
68 | */
69 | const generateHistorical = async (sites, days) => {
70 | if (days < 1 || days > 365) {
71 | throw new Error(`Historical data generation requests must be for 1-365 days, not ${days}.`);
72 | }
73 |
74 | const generatedMeterReadings = {};
75 | const minuteDays = days * 3 * 60; // TODO wny not days * 60 * 24?
76 |
77 | for (const site of sites) {
78 | const maxCapacity = getMaxMinuteWHGenerated(site.capacity);
79 | let currentCapacity = getNextValue(maxCapacity);
80 | let currentTemperature = getNextValue(maxTempC);
81 | let currentUsage = getInitialMinuteWHUsed(maxCapacity);
82 | let currentTime = moment().subtract(minuteDays, 'minutes');
83 |
84 | generatedMeterReadings[site.id] = [];
85 |
86 | for (let n = 0; n < minuteDays; n += 1) {
87 | const meterReading = {
88 | siteId: site.id,
89 | dateTime: currentTime.unix(),
90 | whUsed: currentUsage,
91 | whGenerated: currentCapacity,
92 | tempC: currentTemperature,
93 | };
94 |
95 | generatedMeterReadings[site.id].push(meterReading);
96 |
97 | currentTime = currentTime.add(1, 'minutes');
98 | currentTemperature = getNextValue(currentTemperature);
99 | currentCapacity = getNextValue(currentCapacity, maxCapacity);
100 | currentUsage = getNextValue(currentUsage, maxCapacity);
101 | }
102 | }
103 |
104 | // Now feed these into the system one minute per site at a time.
105 | for (let n = 0; n < minuteDays; n += 1) {
106 | process.stdout.write('.');
107 |
108 | for (const site in generatedMeterReadings) {
109 | if (generatedMeterReadings.hasOwnProperty(site)) {
110 | /* eslint-disable no-await-in-loop */
111 | await meterReadingsController.createMeterReadings([generatedMeterReadings[site][n]]);
112 | /* eslint-enable */
113 | }
114 | }
115 | }
116 | };
117 |
118 | module.exports = {
119 | generateHistorical,
120 | };
121 |
--------------------------------------------------------------------------------
/public/static/js/manifest.2ae2e69a05c33dfc65f8.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"sources":["webpack:///webpack/bootstrap ee7f4645aa3f68a06f42"],"names":["parentJsonpFunction","window","chunkIds","moreModules","executeModules","moduleId","chunkId","result","i","resolves","length","installedChunks","push","Object","prototype","hasOwnProperty","call","modules","shift","__webpack_require__","s","installedModules","2","exports","module","l","m","c","d","name","getter","o","defineProperty","configurable","enumerable","get","n","__esModule","object","property","p","oe","err","console","error"],"mappings":"aACA,IAAAA,EAAAC,OAAA,aACAA,OAAA,sBAAAC,EAAAC,EAAAC,GAIA,IADA,IAAAC,EAAAC,EAAAC,EAAAC,EAAA,EAAAC,KACQD,EAAAN,EAAAQ,OAAoBF,IAC5BF,EAAAJ,EAAAM,GACAG,EAAAL,IACAG,EAAAG,KAAAD,EAAAL,GAAA,IAEAK,EAAAL,GAAA,EAEA,IAAAD,KAAAF,EACAU,OAAAC,UAAAC,eAAAC,KAAAb,EAAAE,KACAY,EAAAZ,GAAAF,EAAAE,IAIA,IADAL,KAAAE,EAAAC,EAAAC,GACAK,EAAAC,QACAD,EAAAS,OAAAT,GAEA,GAAAL,EACA,IAAAI,EAAA,EAAYA,EAAAJ,EAAAM,OAA2BF,IACvCD,EAAAY,IAAAC,EAAAhB,EAAAI,IAGA,OAAAD,GAIA,IAAAc,KAGAV,GACAW,EAAA,GAIA,SAAAH,EAAAd,GAGA,GAAAgB,EAAAhB,GACA,OAAAgB,EAAAhB,GAAAkB,QAGA,IAAAC,EAAAH,EAAAhB,IACAG,EAAAH,EACAoB,GAAA,EACAF,YAUA,OANAN,EAAAZ,GAAAW,KAAAQ,EAAAD,QAAAC,IAAAD,QAAAJ,GAGAK,EAAAC,GAAA,EAGAD,EAAAD,QAKAJ,EAAAO,EAAAT,EAGAE,EAAAQ,EAAAN,EAGAF,EAAAS,EAAA,SAAAL,EAAAM,EAAAC,GACAX,EAAAY,EAAAR,EAAAM,IACAhB,OAAAmB,eAAAT,EAAAM,GACAI,cAAA,EACAC,YAAA,EACAC,IAAAL,KAMAX,EAAAiB,EAAA,SAAAZ,GACA,IAAAM,EAAAN,KAAAa,WACA,WAA2B,OAAAb,EAAA,SAC3B,WAAiC,OAAAA,GAEjC,OADAL,EAAAS,EAAAE,EAAA,IAAAA,GACAA,GAIAX,EAAAY,EAAA,SAAAO,EAAAC,GAAsD,OAAA1B,OAAAC,UAAAC,eAAAC,KAAAsB,EAAAC,IAGtDpB,EAAAqB,EAAA,IAGArB,EAAAsB,GAAA,SAAAC,GAA8D,MAApBC,QAAAC,MAAAF,GAAoBA","file":"static/js/manifest.2ae2e69a05c33dfc65f8.js","sourcesContent":[" \t// install a JSONP callback for chunk loading\n \tvar parentJsonpFunction = window[\"webpackJsonp\"];\n \twindow[\"webpackJsonp\"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {\n \t\t// add \"moreModules\" to the modules object,\n \t\t// then flag all \"chunkIds\" as loaded and fire callback\n \t\tvar moduleId, chunkId, i = 0, resolves = [], result;\n \t\tfor(;i < chunkIds.length; i++) {\n \t\t\tchunkId = chunkIds[i];\n \t\t\tif(installedChunks[chunkId]) {\n \t\t\t\tresolves.push(installedChunks[chunkId][0]);\n \t\t\t}\n \t\t\tinstalledChunks[chunkId] = 0;\n \t\t}\n \t\tfor(moduleId in moreModules) {\n \t\t\tif(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {\n \t\t\t\tmodules[moduleId] = moreModules[moduleId];\n \t\t\t}\n \t\t}\n \t\tif(parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);\n \t\twhile(resolves.length) {\n \t\t\tresolves.shift()();\n \t\t}\n \t\tif(executeModules) {\n \t\t\tfor(i=0; i < executeModules.length; i++) {\n \t\t\t\tresult = __webpack_require__(__webpack_require__.s = executeModules[i]);\n \t\t\t}\n \t\t}\n \t\treturn result;\n \t};\n\n \t// The module cache\n \tvar installedModules = {};\n\n \t// objects to store loaded and loading chunks\n \tvar installedChunks = {\n \t\t2: 0\n \t};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, {\n \t\t\t\tconfigurable: false,\n \t\t\t\tenumerable: true,\n \t\t\t\tget: getter\n \t\t\t});\n \t\t}\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"/\";\n\n \t// on error function for async loading\n \t__webpack_require__.oe = function(err) { console.error(err); throw err; };\n\n\n\n// WEBPACK FOOTER //\n// webpack/bootstrap ee7f4645aa3f68a06f42"],"sourceRoot":""}
--------------------------------------------------------------------------------
/public/static/img/redis.svg:
--------------------------------------------------------------------------------
1 |
2 |
17 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/sitestats_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const redis = require('./redis_client');
2 | const compareAndUpdateScript = require('./scripts/compare_and_update_script');
3 | const keyGenerator = require('./redis_key_generator');
4 | const timeUtils = require('../../../utils/time_utils');
5 |
6 | const weekSeconds = 60 * 60 * 24 * 7;
7 |
8 | /**
9 | * Takes an object containing keys and values from a Redis hash, and
10 | * performs the required type conversions from string -> number on some
11 | * of the keysto transform it into a site stat domain object.
12 | * @param {Object} siteStatsHash - Object whose key/value pairs represent values from a Redis hash.
13 | * @private
14 | */
15 | const remap = (siteStatsHash) => {
16 | const remappedSiteStatsHash = { ...siteStatsHash };
17 |
18 | remappedSiteStatsHash.lastReportingTime = parseInt(siteStatsHash.lastReportingTime, 10);
19 | remappedSiteStatsHash.meterReadingCount = parseInt(siteStatsHash.meterReadingCount, 10);
20 | remappedSiteStatsHash.maxWhGenerated = parseFloat(siteStatsHash.maxWhGenerated);
21 | remappedSiteStatsHash.minWhGenerated = parseFloat(siteStatsHash.minWhGenerated);
22 | remappedSiteStatsHash.maxCapacity = parseFloat(siteStatsHash.maxCapacity);
23 |
24 | return remappedSiteStatsHash;
25 | };
26 |
27 | /**
28 | * Gets the site stats for the supplied site ID for the date specified
29 | * by the timestamp parameter.
30 | *
31 | * @param {number} siteId - the site ID to get site stats for.
32 | * @param {number} timestamp - timestamp for the day to get site stats for.
33 | * @returns {Promise} - promise that resolves to an object containing the
34 | * site stat details.
35 | */
36 | const findById = async (siteId, timestamp) => {
37 | const client = redis.getClient();
38 |
39 | const response = await client.hgetallAsync(
40 | keyGenerator.getSiteStatsKey(siteId, timestamp),
41 | );
42 |
43 | return (response ? remap(response) : response);
44 | };
45 |
46 | /* eslint-disable no-unused-vars */
47 | /**
48 | * Updates the site stats for a specific site with the meter
49 | * reading data provided.
50 | *
51 | * @param {Object} meterReading - a meter reading object.
52 | * @returns {Promise} - promise that resolves when the operation is complete.
53 | */
54 | const updateOptimized = async (meterReading) => {
55 | const client = redis.getClient();
56 | const key = keyGenerator.getSiteStatsKey(meterReading.siteId, meterReading.dateTime);
57 |
58 | // Load script if needed, uses cached SHA if already loaded.
59 | await compareAndUpdateScript.load();
60 |
61 | const transaction = await client.multi();
62 |
63 | transaction.hset(key, 'lastReportingTime', timeUtils.getCurrentTimestamp());
64 | transaction.hincrby(key, 'meterReadingCount', 1);
65 | transaction.expire(key, weekSeconds);
66 |
67 | transaction.evalsha(compareAndUpdateScript.updateIfGreater(key,'maxWhGenerated',meterReading.whGenerated));
68 | transaction.evalsha(compareAndUpdateScript.updateIfLess(key,'minWhGenerated', meterReading.whGenerated));
69 |
70 | const readingCapacity = meterReading.whGenerated - meterReading.whUsed;
71 | transaction.evalsha(compareAndUpdateScript.updateIfGreater(key, 'maxCapacity', readingCapacity));
72 |
73 | await transaction.execAsync();
74 | };
75 | /* eslint-enable */
76 |
77 | /* eslint-disable no-unused-vars */
78 | /**
79 | * Updates the site stats for a specific site with the meter
80 | * reading data provided.
81 | *
82 | * @param {Object} meterReading - a meter reading object.
83 | * @returns {Promise} - promise that resolves when the operation is complete.
84 | */
85 | const updateBasic = async (meterReading) => {
86 | const client = redis.getClient();
87 | const key = keyGenerator.getSiteStatsKey(
88 | meterReading.siteId,
89 | meterReading.dateTime,
90 | );
91 |
92 | await client.hsetAsync(
93 | key,
94 | 'lastReportingTime',
95 | timeUtils.getCurrentTimestamp(),
96 | );
97 | await client.hincrbyAsync(key, 'meterReadingCount', 1);
98 | await client.expireAsync(key, weekSeconds);
99 |
100 | const maxWh = await client.hgetAsync(key, 'maxWhGenerated');
101 | if (maxWh === null || meterReading.whGenerated > parseFloat(maxWh)) {
102 | await client.hsetAsync(key, 'maxWhGenerated', meterReading.whGenerated);
103 | }
104 |
105 | const minWh = await client.hgetAsync(key, 'minWhGenerated');
106 | if (minWh === null || meterReading.whGenerated < parseFloat(minWh)) {
107 | await client.hsetAsync(key, 'minWhGenerated', meterReading.whGenerated);
108 | }
109 |
110 | const maxCapacity = await client.hgetAsync(key, 'maxCapacity');
111 | const readingCapacity = meterReading.whGenerated - meterReading.whUsed;
112 | if (maxCapacity === null || readingCapacity > parseFloat(maxCapacity)) {
113 | await client.hsetAsync(key, 'maxCapacity', readingCapacity);
114 | }
115 | };
116 | /* eslint-enable */
117 |
118 | module.exports = {
119 | findById,
120 | update: updateOptimized
121 | };
122 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/site_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const redis = require('./redis_client');
2 | const keyGenerator = require('./redis_key_generator');
3 |
4 | /**
5 | * Takes a flat key/value pairs object representing a Redis hash, and
6 | * returns a new object whose structure matches that of the site domain
7 | * object. Also converts fields whose values are numbers back to
8 | * numbers as Redis stores all hash key values as strings.
9 | *
10 | * @param {Object} siteHash - object containing hash values from Redis
11 | * @returns {Object} - object containing the values from Redis remapped
12 | * to the shape of a site domain object.
13 | * @private
14 | */
15 | const remap = (siteHash) => {
16 | const remappedSiteHash = { ...siteHash };
17 |
18 | remappedSiteHash.id = parseInt(siteHash.id, 10);
19 | remappedSiteHash.panels = parseInt(siteHash.panels, 10);
20 | remappedSiteHash.capacity = parseFloat(siteHash.capacity, 10);
21 |
22 | // coordinate is optional.
23 | if (siteHash.hasOwnProperty('lat') && siteHash.hasOwnProperty('lng')) {
24 | remappedSiteHash.coordinate = {
25 | lat: parseFloat(siteHash.lat),
26 | lng: parseFloat(siteHash.lng),
27 | };
28 |
29 | // Remove original fields from resulting object.
30 | delete remappedSiteHash.lat;
31 | delete remappedSiteHash.lng;
32 | }
33 |
34 | return remappedSiteHash;
35 | };
36 |
37 | /**
38 | * Takes a site domain object and flattens its structure out into
39 | * a set of key/value pairs suitable for storage in a Redis hash.
40 | *
41 | * @param {Object} site - a site domain object.
42 | * @returns {Object} - a flattened version of 'site', with no nested
43 | * inner objects, suitable for storage in a Redis hash.
44 | * @private
45 | */
46 | const flatten = (site) => {
47 | const flattenedSite = { ...site };
48 |
49 | if (flattenedSite.hasOwnProperty('coordinate')) {
50 | flattenedSite.lat = flattenedSite.coordinate.lat;
51 | flattenedSite.lng = flattenedSite.coordinate.lng;
52 | delete flattenedSite.coordinate;
53 | }
54 |
55 | return flattenedSite;
56 | };
57 |
58 | /**
59 | * Insert a new site.
60 | *
61 | * @param {Object} site - a site object.
62 | * @returns {Promise} - a Promise, resolving to the string value
63 | * for the key of the site Redis.
64 | */
65 | const insert = async (site) => {
66 | const client = redis.getClient();
67 |
68 | const siteHashKey = keyGenerator.getSiteHashKey(site.id);
69 |
70 | await client.hmsetAsync(siteHashKey, flatten(site));
71 | await client.saddAsync(keyGenerator.getSiteIDsKey(), siteHashKey);
72 |
73 | return siteHashKey;
74 | };
75 |
76 | /**
77 | * Get the site object for a given site ID.
78 | *
79 | * @param {number} id - a site ID.
80 | * @returns {Promise} - a Promise, resolving to a site object.
81 | */
82 | const findById = async (id) => {
83 | const client = redis.getClient();
84 | const siteKey = keyGenerator.getSiteHashKey(id);
85 |
86 | const siteHash = await client.hgetallAsync(siteKey);
87 |
88 | return (siteHash === null ? siteHash : remap(siteHash));
89 | };
90 |
91 | /* eslint-disable arrow-body-style */
92 | /**
93 | * Get an array of all site objects.
94 | *
95 | * @returns {Promise} - a Promise, resolving to an array of site objects.
96 | */
97 | const findAll = async () => {
98 | const client = redis.getClient();
99 | const sitesIds = await client.smembersAsync(keyGenerator.getSiteIDsKey());
100 | const sites = [];
101 |
102 | for(const siteId of sitesIds){
103 | const siteHash = await client.hgetallAsync(siteId);
104 | if(siteHash){
105 | sites.push(remap(siteHash));
106 | }
107 | }
108 |
109 | return sites;
110 | };
111 | /* eslint-enable */
112 |
113 | /* eslint-disable no-unused-vars */
114 |
115 | /**
116 | * Get an array of sites within a radius of a given coordinate.
117 | *
118 | * This will be implemented in week 3.
119 | *
120 | * @param {number} lat - Latitude of the coordinate to search from.
121 | * @param {number} lng - Longitude of the coordinate to search from.
122 | * @param {number} radius - Radius in which to search.
123 | * @param {'KM' | 'MI'} radiusUnit - The unit that the value of radius is in.
124 | * @returns {Promise} - a Promise, resolving to an array of site objects.
125 | */
126 | const findByGeo = async (lat, lng, radius, radiusUnit) => [];
127 |
128 | /**
129 | * Get an array of sites where capacity exceeds consumption within
130 | * a radius of a given coordinate.
131 | *
132 | * This will be implemented in week 3.
133 | *
134 | * @param {number} lat - Latitude of the coordinate to search from.
135 | * @param {number} lng - Longitude of the coordinate to search from.
136 | * @param {number} radius - Radius in which to search.
137 | * @param {'KM' | 'MI'} radiusUnit - The unit that the value of radius is in.
138 | * @returns {Promise} - a Promise, resolving to an array of site objects.
139 | */
140 | const findByGeoWithExcessCapacity = async (lat, lng, radius, radiusUnit) => [];
141 |
142 | module.exports = {
143 | insert,
144 | findById,
145 | findAll,
146 | findByGeo,
147 | findByGeoWithExcessCapacity,
148 | };
149 |
150 | /* eslint-enable no-unused-vars */
151 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Redis-Monitoring
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | # Introduction
12 |
13 | The application is a solar power data ingestion
14 | and monitoring dashboard.
15 |
16 | We refer to each solar installation as a site, and each site is fitted with a network smart meter.
17 | The meter reports how much energy the site uses and how much it generates on a minute-by-minute basis.
18 |
19 | In application's front end, it displays a map showing all of the solar sites.
20 | The search bar allows us to find sites close to a given latitude/longitude coordinate.
21 | For each site, we can view recent energy data uploaded from the meter.
22 |
23 | We can also see which sites have the greatest and least capacity.
24 |
25 | The frontend is built using Vue.JS and Node.JS for backend. The project used Redis as a primary database using node_redis as redis client.
26 |
27 | The project also implements Lua scripting as a stored procedre for optimizing database logic and reducing network overheads.
28 |
29 |
30 | # Prerequisites
31 |
32 | In order to start and run this application, you will need:
33 |
34 | * [Node.js](https://nodejs.org/en/download/) (8.9.4 or newer, we recommend using the current Long Term Stable version)
35 | * npm (installed with Node.js)
36 | * Access to a local or remote installation of [Redis](https://redis.io/download) version 5 or newer (local preferred)
37 | * If you want to try the RedisTimeSeries exercises, you'll need to make sure that your Redis installation also has the [RedisTimeSeries Module](https://oss.redislabs.com/redistimeseries/) installed
38 |
39 | If you're using Windows, check out the following resources for help with running Redis:
40 |
41 | * [Redis Labs Blog - Running Redis on Windows 10](https://redislabs.com/blog/redis-on-windows-10/)
42 | * [Microsoft - Windows Subsystem for Linux Installation Guide for Windows 10](https://docs.microsoft.com/en-us/windows/wsl/install-win10)
43 |
44 | # Setup
45 |
46 | To get started:
47 |
48 | ```
49 | $ npm install
50 | ```
51 |
52 | # Configuration
53 |
54 | The application uses a configuration file, `config.json` to specify the port that it listens
55 | on plus some logging parameters and how it connects to a database.
56 |
57 | The supplied `config.json` file is already set up to use Redis on localhost port 6379. Change these values if your Redis instance is on another host or port, or requires a password to connect.
58 |
59 | ```
60 | {
61 | "application": {
62 | "port": 8081,
63 | "logLevel": "debug",
64 | "dataStore": "redis"
65 | },
66 | "dataStores": {
67 | "redis": {
68 | "host": "localhost",
69 | "port": 6379,
70 | "password": null,
71 | "keyPrefix": "ru102js"
72 | }
73 | }
74 | }
75 | ```
76 |
77 | The `keyPrefix` for Redis is used to namespace all the keys that the application generates or
78 | references. So for example a key `sites:999` would be `ru102js:sites:999` when written to Redis.
79 |
80 | # Load Sample Data
81 |
82 | To load sample site data and sample metrics, run:
83 |
84 | ```
85 | npm run load src/resources/data/sites.json flushdb
86 | ```
87 |
88 | `flushdb` is optional, and will erase ALL data from Redis before inserting the sample data.
89 |
90 | The application uses the key prefix `ru102js` by default, so you should be able to use the
91 | same Redis instance for this application and other data if necessary.
92 |
93 | # Development Workflow
94 |
95 | In order to speed up development, you can run the application using `nodemon`, so that any
96 | changes to source code files cause the server to reload and start using your changes.
97 |
98 | ```
99 | npm run dev
100 | ```
101 |
102 | Edit code, application will hot reload on save.
103 |
104 | If you want to run without `nodemon`, use:
105 |
106 | ```
107 | npm start
108 | ```
109 |
110 | But you will then need to stop the server and restart it when you change code.
111 |
112 | # Accessing the Front End Web Application
113 |
114 | You should be able to see the front end solar dashboard app at:
115 |
116 | ```
117 | http://localhost:8081/
118 | ```
119 |
120 | # Running Tests
121 |
122 | The project is setup to use [Jest](https://jestjs.io/en/) for testing. To run all tests:
123 |
124 | ```
125 | npm test
126 | ```
127 |
128 | To run a specific suite of tests (e.g. those in `tests/basic.test.js`):
129 |
130 | ```
131 | npm test -t basic
132 | ```
133 |
134 | To run Jest continuously in watch mode, which gives you access to menus allowing you to run
135 | subsets of tests and many more options:
136 |
137 | ```
138 | npm testdev
139 | ```
140 |
141 | # Linting
142 |
143 | This project uses [ESLint](https://eslint.org/) with a slightly modified version of the
144 | [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript).
145 |
146 | * The file `.eslintrc` contains a short list of rules that have been disabled for this project.
147 | * The file `.eslintignore` contains details of paths that the linter will not consider when
148 | linting the project.
149 |
150 | To run the linter:
151 |
152 | ```
153 | npm run lint
154 | ```
155 |
156 |
157 |
158 |
159 |
160 | ## If you find it interesting pls do ⭐ star this repo, it gives motivation. 🤩
161 |
162 | [](https://github.com/The-Anton/Redis-Solar/stargazers)
163 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/feed_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const redis = require('./redis_client');
2 | const keyGenerator = require('./redis_key_generator');
3 |
4 | /* eslint-disable no-unused-vars */
5 | const globalMaxFeedLength = 10000;
6 | const siteMaxFeedLength = 2440;
7 | /* eslint-enable */
8 |
9 | /**
10 | * Takes an object and returns an array whose elements are alternating
11 | * keys and values from that object. Example:
12 | *
13 | * { hello: 'world', shoeSize: 13 } -> [ 'hello', 'world', 'shoeSize', 13 ]
14 | *
15 | * Used as a helper function for XADD.
16 | *
17 | * @param {Object} obj - object to be converted to an array.
18 | * @returns {Array} - array containing alternating keys and values from 'obj'.
19 | * @private
20 | */
21 | const objectToArray = (obj) => {
22 | const arr = [];
23 |
24 | for (const k in obj) {
25 | if (obj.hasOwnProperty(k)) {
26 | arr.push(k);
27 | arr.push(obj[k]);
28 | }
29 | }
30 |
31 | return arr;
32 | };
33 |
34 | /**
35 | * Takes an array and returns an object whose keys and values are taken
36 | * from alternating elements in the array. Example:
37 | *
38 | * [ 'hello', 'world', 'shoeSize', 13 ] -> { hello: 'world', shoeSize: 13 }
39 | *
40 | * Used as a helper function for processing arrays returned by XRANGE / XREVRANGE.
41 | *
42 | * @param {Array} arr - array of field names and values to convert to an object.
43 | * @returns {Object} - object whose keys and values are the field names and values from arr.
44 | * @private
45 | */
46 | const arrayToObject = (arr) => {
47 | const obj = {};
48 |
49 | // arr contains an even number of entries, with alternating
50 | // field names and values. An empty set of field name/value
51 | // pairs is not permitted in Redis Streams.
52 | for (let n = 0; n < arr.length; n += 2) {
53 | const k = arr[n];
54 | const v = arr[n + 1];
55 |
56 | obj[k] = v;
57 | }
58 |
59 | return obj;
60 | };
61 |
62 | /**
63 | * Take an Object representing a meter reading that was
64 | * read from a stream, and transform the key values to
65 | * the appropriate types from strings.
66 | * @param {Object} streamEntry - An object that was read from a stream.
67 | * @returns {Object} - A meter reading object.
68 | * @private
69 | */
70 | const remap = (streamEntry) => {
71 | const remappedStreamEntry = { ...streamEntry };
72 |
73 | remappedStreamEntry.siteId = parseInt(streamEntry.siteId, 10);
74 | remappedStreamEntry.whUsed = parseFloat(streamEntry.whUsed);
75 | remappedStreamEntry.whGenerated = parseFloat(streamEntry.whGenerated);
76 | remappedStreamEntry.tempC = parseFloat(streamEntry.tempC);
77 | remappedStreamEntry.dateTime = parseInt(streamEntry.dateTime, 10);
78 |
79 | return remappedStreamEntry;
80 | };
81 |
82 | /**
83 | * Takes the array of arrays response from a Redis stream
84 | * XRANGE / XREVRANGE command and unpacks it into an array
85 | * of meter readings.
86 | * @param {Array} streamResponse - An array of arrays returned from a Redis stream command.
87 | * @returns {Array} - An array of meter reading objects.
88 | * @private
89 | */
90 | const unpackStreamEntries = (streamResponse) => {
91 | // Stream entries need to be unpacked as the Redis
92 | // client returns them as an array of arrays, rather
93 | // than an array of objects.
94 | let meterReadings = [];
95 |
96 | if (streamResponse && Array.isArray(streamResponse)) {
97 | meterReadings = streamResponse.map((entry) => {
98 | // entry[0] is the stream ID, we don't need that.
99 | const fieldValueArray = entry[1];
100 |
101 | // Convert the array of field/value pairs to an object.
102 | const obj = arrayToObject(fieldValueArray);
103 |
104 | // Adjust string values to be correct types before returning.
105 | return remap(obj);
106 | });
107 | }
108 |
109 | return meterReadings;
110 | };
111 |
112 | /**
113 | * Insert a new meter reading into the system.
114 | * @param {*} meterReading
115 | * @returns {Promise} - Promise, resolves on completion.
116 | */
117 | const insert = async (meterReading) => {
118 | // Unpack meterReading into array of alternating key
119 | // names and values for addition to the stream.
120 | /* eslint-disable no-unused-vars */
121 | const fields = objectToArray(meterReading);
122 | /* eslint-enable */
123 |
124 | const client = redis.getClient();
125 | const pipeline = client.batch();
126 |
127 | // START Challenge #6
128 | // END Challenge #6
129 |
130 | await pipeline.execAsync();
131 | };
132 |
133 | /**
134 | * Get recent meter reading data.
135 | * @param {string} key - Key name of Redis Stream to read data from.
136 | * @param {number} limit - the maximum number of readings to return.
137 | * @returns {Promise} - Promise that resolves to an array of meter reading objects.
138 | * @private
139 | */
140 | const getRecent = async (key, limit) => {
141 | const client = redis.getClient();
142 | const response = await client.xrevrangeAsync(key, '+', '-', 'COUNT', limit);
143 |
144 | return unpackStreamEntries(response);
145 | };
146 |
147 | /**
148 | * Get recent meter readings for all sites.
149 | * @param {number} limit - the maximum number of readings to return.
150 | * @returns {Promise} - Promise that resolves to an array of meter reading objects.
151 | */
152 | const getRecentGlobal = async limit => getRecent(
153 | keyGenerator.getGlobalFeedKey(),
154 | limit,
155 | );
156 |
157 | /**
158 | * Get recent meter readings for a specific solar sites.
159 | * @param {number} siteId - the ID of the solar site to get readings for.
160 | * @param {*} limit - the maximum number of readings to return.
161 | * @returns {Promise} - Promise that resolves to an array of meter reading objects.
162 | */
163 | const getRecentForSite = async (siteId, limit) => getRecent(
164 | keyGenerator.getFeedKey(siteId),
165 | limit,
166 | );
167 |
168 | module.exports = {
169 | insert,
170 | getRecentGlobal,
171 | getRecentForSite,
172 | };
173 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/metric_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const roundTo = require('round-to');
2 | const redis = require('./redis_client');
3 | const keyGenerator = require('./redis_key_generator');
4 | const timeUtils = require('../../../utils/time_utils');
5 |
6 | const metricIntervalSeconds = 60;
7 | const metricsPerDay = metricIntervalSeconds * 24;
8 | const maxMetricRetentionDays = 30;
9 | /* eslint-disable no-unused-vars */
10 | // Used in Challenge #2
11 | const metricExpirationSeconds = 60 * 60 * 24 * maxMetricRetentionDays + 1;
12 | /* eslint-enable */
13 | const maxDaysToReturn = 7;
14 | const daySeconds = 24 * 60 * 60;
15 |
16 | /* eslint-disable no-unused-vars */
17 | /**
18 | * Transforms measurement and minute values into the format used for
19 | * storage in a Redis sorted set. Will round measurement to 2 decimal
20 | * places. Used in Challenge 2.
21 | * @param {number} measurement - the measurement value to store.
22 | * @param {number} minuteOfDay - the minute of the day.
23 | * @returns {string} - String containing -.
24 | * @private
25 | */
26 | const formatMeasurementMinute = (measurement, minuteOfDay) => `${roundTo(measurement, 2)}:${minuteOfDay}`;
27 | /* eslint-enable */
28 |
29 | /**
30 | * Transforms a string containing : separated measurement value and
31 | * minute of day into an object having keys containing those values.
32 | * @param {string} measurementMinute - a string containing :
33 | * @returns {Object} - object containing measurement and minute values.
34 | * @private
35 | */
36 | const extractMeasurementMinute = (measurementMinute) => {
37 | const arr = measurementMinute.split(':');
38 | return {
39 | value: parseFloat(arr[0]),
40 | minute: parseInt(arr[1], 10),
41 | };
42 | };
43 |
44 | /* eslint-disable no-unused-vars */
45 | /**
46 | * Insert a metric into the database for a given solar site ID.
47 | * This function uses a sorted set to store the metric.
48 | * @param {number} siteId - a solar site ID.
49 | * @param {number} metricValue - the value of the metric to store.
50 | * @param {string} metricName - the name of the metric to store.
51 | * @param {number} timestamp - a UNIX timestamp.
52 | * @returns {Promise} - Promise that resolves when the operation is complete.
53 | * @private
54 | */
55 | const insertMetric = async (siteId, metricValue, metricName, timestamp) => {
56 | const client = redis.getClient();
57 |
58 | const metricKey = keyGenerator.getDayMetricKey(siteId, metricName, timestamp);
59 | const minuteOfDay = timeUtils.getMinuteOfDay(timestamp);
60 |
61 | const score = formatMeasurementMinute(metricValue,minuteOfDay);
62 | client.zaddAsync(metricKey,minuteOfDay,score);
63 | };
64 | /* eslint-enable */
65 |
66 | /**
67 | * Get a set of metrics for a specific solar site on a given day.
68 | * @param {number} siteId - the ID of a solar site.
69 | * @param {string} metricUnit - the name of the metric to get values for.
70 | * @param {number} timestamp - UNIX timestamp for the date to get values for.
71 | * @param {number} limit - the maximum number of metrics to return.
72 | * @returns {Promise} - Promise that resolves to an array of metric objects.
73 | * @private
74 | */
75 | const getMeasurementsForDate = async (siteId, metricUnit, timestamp, limit) => {
76 | const client = redis.getClient();
77 |
78 | // e.g. metrics:whGenerated:2020-01-01:1
79 | const key = keyGenerator.getDayMetricKey(siteId, metricUnit, timestamp);
80 |
81 | // Array of strings formatted :
82 | const metrics = await client.zrevrangeAsync(key, 0, limit - 1);
83 |
84 | const formattedMeasurements = [];
85 |
86 | for (let n = 0; n < metrics.length; n += 1) {
87 | const { value, minute } = extractMeasurementMinute(metrics[n]);
88 |
89 | // Create a measurement object
90 | const measurement = {
91 | siteId,
92 | dateTime: timeUtils.getTimestampForMinuteOfDay(timestamp, minute),
93 | value,
94 | metricUnit,
95 | };
96 |
97 | // Add in reverse order.
98 | formattedMeasurements.unshift(measurement);
99 | }
100 |
101 | return formattedMeasurements;
102 | };
103 |
104 | /**
105 | * Insert a new meter reading into the database.
106 | * @param {Object} meterReading - the meter reading to insert.
107 | * @returns {Promise} - Promise that resolves when the operation is completed.
108 | */
109 | const insert = async (meterReading) => {
110 | await Promise.all([
111 | insertMetric(meterReading.siteId, meterReading.whGenerated, 'whGenerated', meterReading.dateTime),
112 | insertMetric(meterReading.siteId, meterReading.whUsed, 'whUsed', meterReading.dateTime),
113 | insertMetric(meterReading.siteId, meterReading.tempC, 'tempC', meterReading.dateTime),
114 | ]);
115 | };
116 |
117 | /* eslint-disable no-unused-vars */
118 | /**
119 | * Get recent metrics for a specific solar site on a given date with
120 | * an optional limit. This implementation uses a Redis Sorted Set.
121 | * @param {number} siteId - the ID of the solar site to get metrics for.
122 | * @param {string} metricUnit - the name of the metric to get.
123 | * @param {number} timestamp - UNIX timestamp for the date to get metrics for.
124 | * @param {number} limit - maximum number of metrics to be returned.
125 | * @returns {Promise} - Promise resolving to an array of measurement objects.
126 | */
127 | const getRecent = async (siteId, metricUnit, timestamp, limit) => {
128 | if (limit > (metricsPerDay * maxMetricRetentionDays)) {
129 | const err = new Error(`Cannot request more than ${maxMetricRetentionDays} days of minute level data.`);
130 | err.name = 'TooManyMetricsError';
131 |
132 | throw err;
133 | }
134 |
135 | let currentTimestamp = timestamp;
136 | let count = limit;
137 | let iterations = 0;
138 | const measurements = [];
139 |
140 | do {
141 | /* eslint-disable no-await-in-loop */
142 | const dateMeasurements = await getMeasurementsForDate(
143 | siteId,
144 | metricUnit,
145 | currentTimestamp,
146 | count,
147 | );
148 | /* eslint-enable */
149 |
150 | measurements.unshift(...dateMeasurements);
151 | count -= dateMeasurements.length;
152 | iterations += 1;
153 | currentTimestamp -= daySeconds;
154 | } while (count > 0 && iterations < maxDaysToReturn);
155 |
156 | return measurements;
157 | };
158 | /* eslint-enable */
159 |
160 | module.exports = {
161 | insert,
162 | getRecent,
163 | };
164 |
--------------------------------------------------------------------------------
/public/static/img/jest.svg:
--------------------------------------------------------------------------------
1 |
2 |
8 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/redis_key_generator.js:
--------------------------------------------------------------------------------
1 | const config = require('better-config');
2 | const shortId = require('shortid');
3 | const timeUtils = require('../../../utils/time_utils');
4 |
5 | // Prefix that all keys will start with, taken from config.json
6 | let prefix = config.get('dataStores.redis.keyPrefix');
7 |
8 | /**
9 | * Takes a string containing a Redis key name and returns a
10 | * string containing that key with the application's configurable
11 | * prefix added to the front. Prefix is configured in config.json.
12 | *
13 | * @param {string} key - a Redis key
14 | * @returns {string} - a Redis key with the application prefix prepended to
15 | * the value of 'key'
16 | * @private
17 | */
18 | const getKey = key => `${prefix}:${key}`;
19 |
20 | /**
21 | * Generates a temporary unique key name using a the short string
22 | * generator module shortid.
23 | *
24 | * Used in week 3 geo for temporary set key names.
25 | *
26 | * @returns - a temporary key of the form tmp:PPBqWA9
27 | */
28 | const getTemporaryKey = () => getKey(`tmp:${shortId.generate()}`);
29 |
30 | /**
31 | * Takes a numeric site ID and returns the site information key
32 | * value for that ID.
33 | *
34 | * Key name: prefix:sites:info:[siteId]
35 | * Redis type stored at this key: hash
36 | *
37 | * @param {number} siteId - the numeric ID of a site.
38 | * @returns - the site information key for the provided site ID.
39 | */
40 | const getSiteHashKey = siteId => getKey(`sites:info:${siteId}`);
41 |
42 | /**
43 | * Returns the Redis key name used for the set storing all site IDs.
44 | *
45 | * Key name: prefix:sites:ids
46 | * Redis type stored at this key: set
47 | *
48 | * @returns - the Redis key name used for the set storing all site IDs.
49 | */
50 | const getSiteIDsKey = () => getKey('sites:ids');
51 |
52 | /**
53 | * Takes a numeric site ID and a UNIX timestamp, returns the Redis
54 | * key used to store site stats for that site for the day represented
55 | * by the timestamp.
56 | *
57 | * Key name: prefix:sites:stats:[year-month-day]:[siteId]
58 | * Redis type stored at this key: sorted set
59 | *
60 | * @param {number} siteId - the numeric ID of a site.
61 | * @param {number} timestamp - UNIX timestamp for the desired day.
62 | * @returns {string} - the Redis key used to store site stats for that site on the
63 | * day represented by the timestamp.
64 | */
65 | const getSiteStatsKey = (siteId, timestamp) => getKey(`sites:stats:${timeUtils.getDateString(timestamp)}:${siteId}`);
66 |
67 | /**
68 | * Takes a name, interval and maximum number of hits allowed in that interval,
69 | * returns the Redis key used to store the rate limiter data for those parameters.
70 | *
71 | * Key name: prefix:limiter:[name]:[interval]:[maxHits]
72 | * Redis type stored at this key: string (containing a number)
73 | *
74 | * @param {string} name - the unique name of the resource.
75 | * @param {number} interval - the time period that the rate limiter applies for (mins).
76 | * @param {number} maxHits - the maximum number of hits on the resource
77 | * allowed in the interval.
78 | * @returns {string} - the Redis key used to store the rate limiter data for the
79 | * given parameters.
80 | */
81 | const getRateLimiterKey = (name, interval, maxHits) => {
82 | const minuteOfDay = timeUtils.getMinuteOfDay();
83 | return getKey(`limiter:${name}:${Math.floor(minuteOfDay / interval)}:${maxHits}`);
84 | };
85 |
86 | /**
87 | * Returns the Redis key used to store geo information for sites.
88 | *
89 | * Key name: prefix:sites:geo
90 | * Redis type stored at this key: geo
91 | *
92 | * @returns {string} - the Redis key used to store site geo information.
93 | */
94 | const getSiteGeoKey = () => getKey('sites:geo');
95 |
96 | /**
97 | * Returns the Redis key used for storing site capacity ranking data.
98 | *
99 | * Key name: prefix:sites:capacity:ranking
100 | * Redis type stored at this key: sorted set
101 | *
102 | * @returns {string} - the Redis key used for storing site capacity ranking data.
103 | */
104 | const getCapacityRankingKey = () => getKey('sites:capacity:ranking');
105 |
106 | /**
107 | * Returns the Redis key used for storing RedisTimeSeries metrics
108 | * for the supplied site ID.
109 | *
110 | * Key name: prefix:sites:ts:[siteId]:[unit]
111 | * Redis type stored at this key: RedisTimeSeries
112 | *
113 | * @param {number} siteId - the numeric ID of a solar site
114 | * @param {string} unit - the metric unit name
115 | * @returns {string} - the Redis key used for storing RedisTimeSeries metrics
116 | * for the supplied site ID.
117 | */
118 | const getTSKey = (siteId, unit) => getKey(`sites:ts:${siteId}:${unit}`);
119 |
120 | /**
121 | * Returns the Redis key name used to store metrics for the site represented
122 | * by 'siteId', the metric type represented by 'unit' and the date represented
123 | * by 'timestamp'.
124 | *
125 | * Key name: prefix:metric:[unit]:[year-month-day]:[siteId]
126 | * Redis type stored at this key: sorted set
127 | *
128 | * @param {number} siteId - the numeric site ID of the site to get the key for.
129 | * @param {*} unit - the name of the measurement unit to get the key for.
130 | * @param {*} timestamp - UNIX timestamp representing the date to get the key for.
131 | * @returns {string} - the Redis key used to store metrics for the specified metric
132 | * on the specified day for the specified site ID.
133 | */
134 | const getDayMetricKey = (siteId, unit, timestamp) => getKey(
135 | `metric:${unit}:${timeUtils.getDateString(timestamp)}:${siteId}`,
136 | );
137 |
138 | /**
139 | * Returns the name of the Redis key used to store the global sites data feed.
140 | *
141 | * Key name: prefix:sites:feed
142 | * Redis type stored at this key: stream
143 | *
144 | * @returns {string} - the Redis key used to store the global site data feed.
145 | */
146 | const getGlobalFeedKey = () => getKey('sites:feed');
147 |
148 | /**
149 | * Returns the name of the Redis key used to store the data feed for the site
150 | * represented by 'siteId'.
151 | *
152 | * Key name: prefix:sites:feed:[siteId]
153 | * Redis type stored at this key: stream
154 | *
155 | * @param {number} siteId - numeric ID of a specific site.
156 | * @returns {string} - the Redis key used to store the data feed for the
157 | * site represented by 'siteId'.
158 | */
159 | const getFeedKey = siteId => getKey(`sites:feed:${siteId}`);
160 |
161 | /**
162 | * Set the global key prefix, overriding the one set in config.json.
163 | *
164 | * This is used by the test suites so that test keys do not overlap
165 | * with real application keys and can be safely deleted afterwards.
166 | *
167 | * @param {*} newPrefix - the new key prefix to use.
168 | */
169 | const setPrefix = (newPrefix) => {
170 | prefix = newPrefix;
171 | };
172 |
173 | module.exports = {
174 | getTemporaryKey,
175 | getSiteHashKey,
176 | getSiteIDsKey,
177 | getSiteStatsKey,
178 | getRateLimiterKey,
179 | getSiteGeoKey,
180 | getCapacityRankingKey,
181 | getTSKey,
182 | getDayMetricKey,
183 | getGlobalFeedKey,
184 | getFeedKey,
185 | setPrefix,
186 | getKey,
187 | };
188 |
--------------------------------------------------------------------------------
/src/daos/impl/redis/site_geo_dao_redis_impl.js:
--------------------------------------------------------------------------------
1 | const redis = require('./redis_client');
2 | const keyGenerator = require('./redis_key_generator');
3 |
4 | // Minimum amount of capacity that a site should have to be
5 | // considered as having 'excess capacity'.
6 | const capacityThreshold = 0.2;
7 |
8 | /**
9 | * Takes a flat key/value pairs object representing a Redis hash, and
10 | * returns a new object whose structure matches that of the site domain
11 | * object. Also converts fields whose values are numbers back to
12 | * numbers as Redis stores all hash key values as strings.
13 | *
14 | * @param {Object} siteHash - object containing hash values from Redis
15 | * @returns {Object} - object containing the values from Redis remapped
16 | * to the shape of a site domain object.
17 | * @private
18 | */
19 | const remap = (siteHash) => {
20 | const remappedSiteHash = { ...siteHash };
21 |
22 | remappedSiteHash.id = parseInt(siteHash.id, 10);
23 | remappedSiteHash.panels = parseInt(siteHash.panels, 10);
24 | remappedSiteHash.capacity = parseFloat(siteHash.capacity, 10);
25 |
26 | // coordinate is optional.
27 | if (siteHash.hasOwnProperty('lat') && siteHash.hasOwnProperty('lng')) {
28 | remappedSiteHash.coordinate = {
29 | lat: parseFloat(siteHash.lat),
30 | lng: parseFloat(siteHash.lng),
31 | };
32 |
33 | // Remove original fields from resulting object.
34 | delete remappedSiteHash.lat;
35 | delete remappedSiteHash.lng;
36 | }
37 |
38 | return remappedSiteHash;
39 | };
40 |
41 | /**
42 | * Takes a site domain object and flattens its structure out into
43 | * a set of key/value pairs suitable for storage in a Redis hash.
44 | *
45 | * @param {Object} site - a site domain object.
46 | * @returns {Object} - a flattened version of 'site', with no nested
47 | * inner objects, suitable for storage in a Redis hash.
48 | * @private
49 | */
50 | const flatten = (site) => {
51 | const flattenedSite = { ...site };
52 |
53 | if (flattenedSite.hasOwnProperty('coordinate')) {
54 | flattenedSite.lat = flattenedSite.coordinate.lat;
55 | flattenedSite.lng = flattenedSite.coordinate.lng;
56 | delete flattenedSite.coordinate;
57 | }
58 |
59 | return flattenedSite;
60 | };
61 |
62 | /**
63 | * Insert a new site.
64 | *
65 | * @param {Object} site - a site object.
66 | * @returns {Promise} - a Promise, resolving to the string value
67 | * for the key of the site Redis.
68 | */
69 | const insert = async (site) => {
70 | const client = redis.getClient();
71 |
72 | const siteHashKey = keyGenerator.getSiteHashKey(site.id);
73 |
74 | await client.hmsetAsync(siteHashKey, flatten(site));
75 |
76 | // Co-ordinates are required when using this version of the DAO.
77 | if (!site.hasOwnProperty('coordinate')) {
78 | throw new Error('Coordinate required for site geo insert!');
79 | }
80 |
81 | await client.geoaddAsync(
82 | keyGenerator.getSiteGeoKey(),
83 | site.coordinate.lng,
84 | site.coordinate.lat,
85 | site.id,
86 | );
87 |
88 | return siteHashKey;
89 | };
90 |
91 | /**
92 | * Get the site object for a given site ID.
93 | *
94 | * @param {number} id - a site ID.
95 | * @returns {Promise} - a Promise, resolving to a site object.
96 | */
97 | const findById = async (id) => {
98 | const client = redis.getClient();
99 | const siteKey = keyGenerator.getSiteHashKey(id);
100 |
101 | const siteHash = await client.hgetallAsync(siteKey);
102 |
103 | return (siteHash === null ? siteHash : remap(siteHash));
104 | };
105 |
106 | /**
107 | * Get an array of all site objects.
108 | *
109 | * @returns {Promise} - a Promise, resolving to an array of site objects.
110 | */
111 | const findAll = async () => {
112 | const client = redis.getClient();
113 |
114 | const siteIds = await client.zrangeAsync(keyGenerator.getSiteGeoKey(), 0, -1);
115 | const sites = [];
116 |
117 | for (const siteId of siteIds) {
118 | const siteKey = keyGenerator.getSiteHashKey(siteId);
119 |
120 | /* eslint-disable no-await-in-loop */
121 | const siteHash = await client.hgetallAsync(siteKey);
122 | /* eslint-enable */
123 |
124 | if (siteHash) {
125 | // Call remap to remap the flat key/value representation
126 | // from the Redis hash into the site domain object format.
127 | sites.push(remap(siteHash));
128 | }
129 | }
130 |
131 | return sites;
132 | };
133 |
134 | /**
135 | * Get an array of sites within a radius of a given coordinate.
136 | *
137 | * @param {number} lat - Latitude of the coordinate to search from.
138 | * @param {number} lng - Longitude of the coordinate to search from.
139 | * @param {number} radius - Radius in which to search.
140 | * @param {'KM' | 'MI'} radiusUnit - The unit that the value of radius is in.
141 | * @returns {Promise} - a Promise, resolving to an array of site objects.
142 | */
143 | const findByGeo = async (lat, lng, radius, radiusUnit) => {
144 | const client = redis.getClient();
145 |
146 | const siteIds = await client.georadiusAsync(
147 | keyGenerator.getSiteGeoKey(),
148 | lng,
149 | lat,
150 | radius,
151 | radiusUnit.toLowerCase(),
152 | );
153 |
154 | const sites = [];
155 |
156 | for (const siteId of siteIds) {
157 | /* eslint-disable no-await-in-loop */
158 | const siteKey = keyGenerator.getSiteHashKey(siteId);
159 | const siteHash = await client.hgetallAsync(siteKey);
160 | /* eslint-enable */
161 |
162 | if (siteHash) {
163 | sites.push(remap(siteHash));
164 | }
165 | }
166 |
167 | return sites;
168 | };
169 |
170 | /**
171 | * Get an array of sites where capacity exceeds consumption within
172 | * a radius of a given coordinate.
173 | *
174 | * @param {number} lat - Latitude of the coordinate to search from.
175 | * @param {number} lng - Longitude of the coordinate to search from.
176 | * @param {number} radius - Radius in which to search.
177 | * @param {'KM' | 'MI'} radiusUnit - The unit that the value of radius is in.
178 | * @returns {Promise} - a Promise, resolving to an array of site objects.
179 | */
180 | const findByGeoWithExcessCapacity = async (lat, lng, radius, radiusUnit) => {
181 | /* eslint-disable no-unreachable */
182 | // Challenge #5, remove the next line...
183 | return [];
184 |
185 | const client = redis.getClient();
186 |
187 | // Create a pipeline to send multiple commands in one round trip.
188 | const setOperationsPipeline = client.batch();
189 |
190 | // Get sites within the radius and store them in a temporary sorted set.
191 | const sitesInRadiusSortedSetKey = keyGenerator.getTemporaryKey();
192 |
193 | setOperationsPipeline.georadiusAsync(
194 | keyGenerator.getSiteGeoKey(),
195 | lng,
196 | lat,
197 | radius,
198 | radiusUnit.toLowerCase(),
199 | 'STORE',
200 | sitesInRadiusSortedSetKey,
201 | );
202 |
203 | // Create a key for a temporary sorted set containing sites that fell
204 | // within the radius and their current capacities.
205 | const sitesInRadiusCapacitySortedSetKey = keyGenerator.getTemporaryKey();
206 |
207 | // START Challenge #5
208 | // END Challenge #5
209 |
210 | // Expire the temporary sorted sets after 30 seconds, so that we
211 | // don't leave old keys on the server that we no longer need.
212 | setOperationsPipeline.expire(sitesInRadiusSortedSetKey, 30);
213 | setOperationsPipeline.expire(sitesInRadiusCapacitySortedSetKey, 30);
214 |
215 | // Execute the set operations commands, we do not need to
216 | // use the responses.
217 | await setOperationsPipeline.execAsync();
218 |
219 | // Get sites IDs with enough capacity from the temporary
220 | // sorted set and store them in siteIds.
221 | const siteIds = await client.zrangebyscoreAsync(
222 | sitesInRadiusCapacitySortedSetKey,
223 | capacityThreshold,
224 | '+inf',
225 | );
226 |
227 | // Populate array with site details, use pipeline for efficiency.
228 | const siteHashPipeline = client.batch();
229 |
230 | for (const siteId of siteIds) {
231 | const siteKey = keyGenerator.getSiteHashKey(siteId);
232 | siteHashPipeline.hgetall(siteKey);
233 | }
234 |
235 | const siteHashes = await siteHashPipeline.execAsync();
236 |
237 | const sitesWithCapacity = [];
238 |
239 | for (const siteHash of siteHashes) {
240 | // Ensure a result was found before processing it.
241 | if (siteHash) {
242 | // Call remap to remap the flat key/value representation
243 | // from the Redis hash into the site domain object format,
244 | // and convert any fields that a numerical from the Redis
245 | // string representations.
246 | sitesWithCapacity.push(remap(siteHash));
247 | }
248 | }
249 |
250 | return sitesWithCapacity;
251 | /* eslint-enable */
252 | };
253 |
254 | module.exports = {
255 | insert,
256 | findById,
257 | findAll,
258 | findByGeo,
259 | findByGeoWithExcessCapacity,
260 | };
261 |
--------------------------------------------------------------------------------
/public/static/img/lua.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/static/css/app.67b676c3ad3cca4844561b3c9355e213.css:
--------------------------------------------------------------------------------
1 | #app{font-family:Avenir,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-align:center;color:#2c3e50}.header{margin:.5em}#mapid{height:900px}li.router-link-active[data-v-2253b308],li.router-link-exact-active[data-v-2253b308]{background-color:#cd5c5c;cursor:pointer}body[data-v-18a907dd]{margin-bottom:30px}img[data-v-18a907dd],p[data-v-18a907dd]{padding-top:10px}.footer[data-v-18a907dd]{position:absolute;bottom:0;width:100%;height:50px;line-height:30px;background-color:#f5f5f5}.leaflet-image-layer,.leaflet-layer,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-pane,.leaflet-pane>canvas,.leaflet-pane>svg,.leaflet-tile,.leaflet-tile-container,.leaflet-zoom-box{position:absolute;left:0;top:0}.leaflet-container{overflow:hidden}.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-tile{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-user-drag:none}.leaflet-safari .leaflet-tile{image-rendering:-webkit-optimize-contrast}.leaflet-safari .leaflet-tile-container{width:1600px;height:1600px;-webkit-transform-origin:0 0}.leaflet-marker-icon,.leaflet-marker-shadow{display:block}.leaflet-container .leaflet-marker-pane img,.leaflet-container .leaflet-overlay-pane svg,.leaflet-container .leaflet-shadow-pane img,.leaflet-container .leaflet-tile,.leaflet-container .leaflet-tile-pane img,.leaflet-container img.leaflet-image-layer{max-width:none!important;max-height:none!important}.leaflet-container.leaflet-touch-zoom{-ms-touch-action:pan-x pan-y;touch-action:pan-x pan-y}.leaflet-container.leaflet-touch-drag{-ms-touch-action:pinch-zoom;touch-action:none;touch-action:pinch-zoom}.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom{-ms-touch-action:none;touch-action:none}.leaflet-container{-webkit-tap-highlight-color:transparent}.leaflet-container a{-webkit-tap-highlight-color:rgba(51,181,229,.4)}.leaflet-tile{filter:inherit;visibility:hidden}.leaflet-tile-loaded{visibility:inherit}.leaflet-zoom-box{width:0;height:0;box-sizing:border-box;z-index:800}.leaflet-overlay-pane svg{-moz-user-select:none}.leaflet-pane{z-index:400}.leaflet-tile-pane{z-index:200}.leaflet-overlay-pane{z-index:400}.leaflet-shadow-pane{z-index:500}.leaflet-marker-pane{z-index:600}.leaflet-tooltip-pane{z-index:650}.leaflet-popup-pane{z-index:700}.leaflet-map-pane canvas{z-index:100}.leaflet-map-pane svg{z-index:200}.leaflet-vml-shape{width:1px;height:1px}.lvml{behavior:url(#default#VML);display:inline-block;position:absolute}.leaflet-control{position:relative;z-index:800;pointer-events:visiblePainted;pointer-events:auto}.leaflet-bottom,.leaflet-top{position:absolute;z-index:1000;pointer-events:none}.leaflet-top{top:0}.leaflet-right{right:0}.leaflet-bottom{bottom:0}.leaflet-left{left:0}.leaflet-control{float:left;clear:both}.leaflet-right .leaflet-control{float:right}.leaflet-top .leaflet-control{margin-top:10px}.leaflet-bottom .leaflet-control{margin-bottom:10px}.leaflet-left .leaflet-control{margin-left:10px}.leaflet-right .leaflet-control{margin-right:10px}.leaflet-fade-anim .leaflet-tile{will-change:opacity}.leaflet-fade-anim .leaflet-popup{opacity:0;transition:opacity .2s linear}.leaflet-fade-anim .leaflet-map-pane .leaflet-popup{opacity:1}.leaflet-zoom-animated{transform-origin:0 0}.leaflet-zoom-anim .leaflet-zoom-animated{will-change:transform;transition:transform .25s cubic-bezier(0,0,.25,1)}.leaflet-pan-anim .leaflet-tile,.leaflet-zoom-anim .leaflet-tile{transition:none}.leaflet-zoom-anim .leaflet-zoom-hide{visibility:hidden}.leaflet-interactive{cursor:pointer}.leaflet-grab{cursor:grab}.leaflet-crosshair,.leaflet-crosshair .leaflet-interactive{cursor:crosshair}.leaflet-control,.leaflet-popup-pane{cursor:auto}.leaflet-dragging .leaflet-grab,.leaflet-dragging .leaflet-grab .leaflet-interactive,.leaflet-dragging .leaflet-marker-draggable{cursor:move;cursor:grabbing}.leaflet-image-layer,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-pane>svg path,.leaflet-tile-container{pointer-events:none}.leaflet-image-layer.leaflet-interactive,.leaflet-marker-icon.leaflet-interactive,.leaflet-pane>svg path.leaflet-interactive{pointer-events:visiblePainted;pointer-events:auto}.leaflet-container{background:#ddd;outline:0}.leaflet-container a{color:#0078a8}.leaflet-container a.leaflet-active{outline:2px solid orange}.leaflet-zoom-box{border:2px dotted #38f;background:hsla(0,0%,100%,.5)}.leaflet-container{font:12px/1.5 Helvetica Neue,Arial,Helvetica,sans-serif}.leaflet-bar{box-shadow:0 1px 5px rgba(0,0,0,.65);border-radius:4px}.leaflet-bar a,.leaflet-bar a:hover{background-color:#fff;border-bottom:1px solid #ccc;width:26px;height:26px;line-height:26px;display:block;text-align:center;text-decoration:none;color:#000}.leaflet-bar a,.leaflet-control-layers-toggle{background-position:50% 50%;background-repeat:no-repeat;display:block}.leaflet-bar a:hover{background-color:#f4f4f4}.leaflet-bar a:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.leaflet-bar a:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:4px;border-bottom:none}.leaflet-bar a.leaflet-disabled{cursor:default;background-color:#f4f4f4;color:#bbb}.leaflet-touch .leaflet-bar a{width:30px;height:30px;line-height:30px}.leaflet-touch .leaflet-bar a:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.leaflet-touch .leaflet-bar a:last-child{border-bottom-left-radius:2px;border-bottom-right-radius:2px}.leaflet-control-zoom-in,.leaflet-control-zoom-out{font:700 18px Lucida Console,Monaco,monospace;text-indent:1px}.leaflet-touch .leaflet-control-zoom-in,.leaflet-touch .leaflet-control-zoom-out{font-size:22px}.leaflet-control-layers{box-shadow:0 1px 5px rgba(0,0,0,.4);background:#fff;border-radius:5px}.leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC);width:36px;height:36px}.leaflet-retina .leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=);background-size:26px 26px}.leaflet-touch .leaflet-control-layers-toggle{width:44px;height:44px}.leaflet-control-layers-expanded .leaflet-control-layers-toggle,.leaflet-control-layers .leaflet-control-layers-list{display:none}.leaflet-control-layers-expanded .leaflet-control-layers-list{display:block;position:relative}.leaflet-control-layers-expanded{padding:6px 10px 6px 6px;color:#333;background:#fff}.leaflet-control-layers-scrollbar{overflow-y:scroll;overflow-x:hidden;padding-right:5px}.leaflet-control-layers-selector{margin-top:2px;position:relative;top:1px}.leaflet-control-layers label{display:block}.leaflet-control-layers-separator{height:0;border-top:1px solid #ddd;margin:5px -10px 5px -6px}.leaflet-default-icon-path{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=)}.leaflet-container .leaflet-control-attribution{background:#fff;background:hsla(0,0%,100%,.7);margin:0}.leaflet-control-attribution,.leaflet-control-scale-line{padding:0 5px;color:#333}.leaflet-control-attribution a{text-decoration:none}.leaflet-control-attribution a:hover{text-decoration:underline}.leaflet-container .leaflet-control-attribution,.leaflet-container .leaflet-control-scale{font-size:11px}.leaflet-left .leaflet-control-scale{margin-left:5px}.leaflet-bottom .leaflet-control-scale{margin-bottom:5px}.leaflet-control-scale-line{border:2px solid #777;border-top:none;line-height:1.1;padding:2px 5px 1px;font-size:11px;white-space:nowrap;overflow:hidden;box-sizing:border-box;background:#fff;background:hsla(0,0%,100%,.5)}.leaflet-control-scale-line:not(:first-child){border-top:2px solid #777;border-bottom:none;margin-top:-2px}.leaflet-control-scale-line:not(:first-child):not(:last-child){border-bottom:2px solid #777}.leaflet-touch .leaflet-bar,.leaflet-touch .leaflet-control-attribution,.leaflet-touch .leaflet-control-layers{box-shadow:none}.leaflet-touch .leaflet-bar,.leaflet-touch .leaflet-control-layers{border:2px solid rgba(0,0,0,.2);background-clip:padding-box}.leaflet-popup{position:absolute;text-align:center;margin-bottom:20px}.leaflet-popup-content-wrapper{padding:1px;text-align:left;border-radius:12px}.leaflet-popup-content{margin:13px 19px;line-height:1.4}.leaflet-popup-content p{margin:18px 0}.leaflet-popup-tip-container{width:40px;height:20px;position:absolute;left:50%;margin-left:-20px;overflow:hidden;pointer-events:none}.leaflet-popup-tip{width:17px;height:17px;padding:1px;margin:-10px auto 0;transform:rotate(45deg)}.leaflet-popup-content-wrapper,.leaflet-popup-tip{background:#fff;color:#333;box-shadow:0 3px 14px rgba(0,0,0,.4)}.leaflet-container a.leaflet-popup-close-button{position:absolute;top:0;right:0;padding:4px 4px 0 0;border:none;text-align:center;width:18px;height:14px;font:16px/14px Tahoma,Verdana,sans-serif;color:#c3c3c3;text-decoration:none;font-weight:700;background:transparent}.leaflet-container a.leaflet-popup-close-button:hover{color:#999}.leaflet-popup-scrolled{overflow:auto;border-bottom:1px solid #ddd;border-top:1px solid #ddd}.leaflet-oldie .leaflet-popup-content-wrapper{zoom:1}.leaflet-oldie .leaflet-popup-tip{width:24px;margin:0 auto;-ms-filter:"progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";filter:progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678,M12=0.70710678,M21=-0.70710678,M22=0.70710678)}.leaflet-oldie .leaflet-popup-tip-container{margin-top:-1px}.leaflet-oldie .leaflet-control-layers,.leaflet-oldie .leaflet-control-zoom,.leaflet-oldie .leaflet-popup-content-wrapper,.leaflet-oldie .leaflet-popup-tip{border:1px solid #999}.leaflet-div-icon{background:#fff;border:1px solid #666}.leaflet-tooltip{position:absolute;padding:6px;background-color:#fff;border:1px solid #fff;border-radius:3px;color:#222;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;pointer-events:none;box-shadow:0 1px 3px rgba(0,0,0,.4)}.leaflet-tooltip.leaflet-clickable{cursor:pointer;pointer-events:auto}.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before,.leaflet-tooltip-top:before{position:absolute;pointer-events:none;border:6px solid transparent;background:transparent;content:""}.leaflet-tooltip-bottom{margin-top:6px}.leaflet-tooltip-top{margin-top:-6px}.leaflet-tooltip-bottom:before,.leaflet-tooltip-top:before{left:50%;margin-left:-6px}.leaflet-tooltip-top:before{bottom:0;margin-bottom:-12px;border-top-color:#fff}.leaflet-tooltip-bottom:before{top:0;margin-top:-12px;margin-left:-6px;border-bottom-color:#fff}.leaflet-tooltip-left{margin-left:-6px}.leaflet-tooltip-right{margin-left:6px}.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{top:50%;margin-top:-6px}.leaflet-tooltip-left:before{right:0;margin-right:-12px;border-left-color:#fff}.leaflet-tooltip-right:before{left:0;margin-left:-12px;border-right-color:#fff}
2 | /*# sourceMappingURL=app.67b676c3ad3cca4844561b3c9355e213.css.map */
--------------------------------------------------------------------------------
/public/static/js/app.a81024c85819f0f2043f.js:
--------------------------------------------------------------------------------
1 | webpackJsonp([1],{"6VF5":function(t,a,e){"use strict";var s={render:function(){this.$createElement;this._self._c;return this._m(0)},staticRenderFns:[function(){var t=this.$createElement,a=this._self._c||t;return a("footer",{staticClass:"footer"},[a("div",{staticClass:"container"},[a("div",{staticClass:"row"},[a("div",{staticClass:"col"},[a("p",{staticClass:"text-muted"},[this._v("RU102J: Redis for Java Developers")])])])])])}]};a.a=s},DEtk:function(t,a){},EYnv:function(t,a){},NHnr:function(t,a,e){"use strict";Object.defineProperty(a,"__esModule",{value:!0});var s=e("7+uW"),r=e("MdIv"),n=e("mtWM"),i=e.n(n),c={name:"App",components:{LMap:r.LMap,LTileLayer:r.LTileLayer,LMarker:r.LMarker},mounted:function(){this.createMap(),this.getData()},methods:{submitForm:function(t){var a=this;this.markerLayers.clearLayers(),console.log(t.srcElement);var e={params:{lat:t.target.lat.value,lng:t.target.lng.value,radius:t.target.radius.value,radiusUnit:t.target.radiusUnit.value,onlyExcessCapacity:t.target.onlyExcessCapacity.checked}},s=[];i.a.get("/api/sites",e).then(function(t){t.data.forEach(function(t){a.addMarker(t),s.push([t.coordinate.lat,t.coordinate.lng])}),a.mymap.fitBounds(s)}).catch(function(t){console.log(t)})},getData:function(){var t=this;i.a.get("/api/sites").then(function(a){a.data.forEach(function(a){t.addMarker(a)})}).catch(function(t){console.log(t)})},addMarker:function(t){var a=t.coordinate;r.L.marker([a.lat,a.lng]).addTo(this.markerLayers).bindPopup(""+t.address+"
"+t.city+", "+t.state+" "+t.postalCode+"
("+t.coordinate.lat+", "+t.coordinate.lng+')
Stats')},createMap:function(){this.mymap=r.L.map("mapid").setView([37.715732,-122.027342],11),this.markerLayers=r.L.featureGroup().addTo(this.mymap),r.L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png?",{attribution:'Map and Image data © OpenStreetMap contributors. License.'}).addTo(this.mymap)}}},o={render:function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("div",{attrs:{id:"app"}},[t._m(0),t._v(" "),e("div",{staticClass:"container"},[e("form",{staticStyle:{margin:"0.5em"},attrs:{id:"search"},on:{submit:function(a){return a.preventDefault(),t.submitForm(a)}}},[t._m(1)])]),t._v(" "),e("div",{staticClass:"container",attrs:{id:"mapid"}})])},staticRenderFns:[function(){var t=this.$createElement,a=this._self._c||t;return a("div",{staticClass:"container"},[a("h1",{staticClass:"header"},[this._v("Solar Site Map")])])},function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("div",{staticClass:"form-row m-1"},[e("div",{staticClass:"col px-1"},[e("input",{staticClass:"form-control",attrs:{id:"lat",name:"latitude",type:"text",placeholder:"Latitude"}})]),t._v(" "),e("div",{staticClass:"col px-1"},[e("input",{staticClass:"form-control",attrs:{id:"lng",name:"longitude",type:"text",placeholder:"Longitude"}})]),t._v(" "),e("div",{staticClass:"col-sm-1 px-1"},[e("input",{staticClass:"form-control",attrs:{name:"radius",type:"text",placeholder:"Radius"}})]),t._v(" "),e("div",{staticClass:"col2 px-1"},[e("select",{staticClass:"custom-select",attrs:{name:"radiusUnit"}},[e("option",{attrs:{selected:"selected",value:"KM"}},[t._v("KM (Kilometers)")]),t._v(" "),e("option",{attrs:{value:"MI"}},[t._v("MI (Miles)")])])]),t._v(" "),e("div",{staticClass:"col2 px-1 form-check form-check-inline"},[e("input",{staticClass:"form-check-input",attrs:{type:"checkbox",name:"onlyExcessCapacity",id:"excessCapacityCheck"}}),t._v(" "),e("label",{staticClass:"form-check-label",attrs:{for:"excessCapacityCheck"}},[t._v("Excess Capacity")])]),t._v(" "),e("div",{staticClass:"col2 px-1"},[e("button",{staticClass:"btn btn-primary",attrs:{type:"submit"}},[t._v("Submit")])])])}]};var l=e("VU/8")(c,o,!1,function(t){e("k6kf")},null,null).exports,d=e("g2+m"),u=e("WaEV"),h=e.n(u),p={name:"Chart",data:function(){return{siteId:null}},mounted:function(){this.resetColor(),this.createChart(),this.getData(this)},watch:{$route:function(t,a){this.clearChartData(this),this.resetColor(),this.getData(this)}},methods:{resetColor:function(){this.currentColor={r:66,g:134,b:234}},cycleRGBColor:function(t){return t+20<=255?t+20:255-t+20},getBorderColor:function(){return this.currentColor.r=this.cycleRGBColor(this.currentColor.r),this.currentColor.g=this.cycleRGBColor(this.currentColor.g),this.currentColor.b=this.cycleRGBColor(this.currentColor.b),"rgb("+this.currentColor.r+", "+this.currentColor.g+", "+this.currentColor.b+")"},clearChartData:function(t){t.chart.data.datasets=[],t.chart.update()},getData:function(t){i.a.get("/api/metrics/"+t.$route.params.id).then(function(a){t.siteId=t.$route.params.id,a.data.forEach(function(a){t.chart.data.datasets.push({label:a.name,borderColor:t.getBorderColor(),data:a.measurements.map(function(t){return{x:new Date(1e3*t.dateTime),y:t.value.toPrecision(3)}})}),t.chart.update()})}).catch(function(t){console.log("Got error"),console.log(t)})},createChart:function(){var t=document.getElementById("myChart").getContext("2d");this.chart=new h.a(t,{type:"line",data:{datasets:[]},options:{title:{text:"Time Scale"},scales:{xAxes:[{type:"time",time:{format:"MM/DD/YYYY HH:mm",tooltipFormat:"ll HH:mm"},scaleLabel:{display:!0,labelString:"Date"}}],yAxes:[{scaleLabel:{display:!0,labelString:"Watt-Hours"}}]}}})}}},m={render:function(){var t=this.$createElement,a=this._self._c||t;return a("div",{attrs:{id:"app"}},[a("div",{staticClass:"container"},[a("h1",{staticClass:"header"},[this._v("Solar Site "+this._s(this.siteId))]),this._v(" "),a("canvas",{attrs:{id:"myChart"}})])])},staticRenderFns:[]};var g=e("VU/8")(p,m,!1,function(t){e("b7gL")},null,null).exports,f={name:"Chart",data:function(){return{capacityTable:[],minCapacityTable:[]}},mounted:function(){this.resetColor(),this.createChart(),this.getData(this)},watch:{$route:function(t,a){this.clearChartData(this),this.resetColor(),this.getData(this)}},methods:{resetColor:function(){this.currentColor={r:66,g:134,b:234}},cycleRGBColor:function(t){return t+20<=255?t+20:255-t+20},getBorderColor:function(){return this.currentColor.r=this.cycleRGBColor(this.currentColor.r),this.currentColor.g=this.cycleRGBColor(this.currentColor.g),this.currentColor.b=this.cycleRGBColor(this.currentColor.b),"rgb("+this.currentColor.r+", "+this.currentColor.g+", "+this.currentColor.b+")"},clearChartData:function(t){t.chart.data.datasets=[],t.chart.update()},getData:function(t){i.a.get("/api/capacity/").then(function(a){var e=[],s=[];a.data.highestCapacity.forEach(function(a){e.push({x:a.siteId,y:a.capacity}),s.push(""+a.siteId),t.capacityTable.push(a)}),t.maxChart.data.labels=s,t.maxChart.data.datasets.push({labels:s,backgroundColor:"#94c635",borderColor:"#709628",borderWidth:1,data:e}),t.maxChart.update();var r=[],n=[];a.data.lowestCapacity.forEach(function(a){n.push({x:a.siteId,y:a.capacity}),r.push(""+a.siteId),t.minCapacityTable.push(a)}),t.minChart.data.labels=r,t.minChart.data.datasets.push({labels:r,backgroundColor:"#A50104",borderColor:"#590004",borderWidth:1,data:n}),t.minChart.update()}).catch(function(t){console.log("Got error"),console.log(t)})},createChart:function(){var t=document.getElementById("maxChart").getContext("2d"),a=document.getElementById("minChart").getContext("2d");this.maxChart=new h.a(t,{type:"bar",data:{datasets:[]},options:{legend:{display:!1},title:{display:!0},scales:{xAxes:[{labelString:"Site ID",barPercentage:10,maxBarThickness:60,minBarLength:2,gridLines:{offsetGridLines:!0},scaleLabel:{labelString:"Site ID",display:!0}}],yAxes:[{scaleLabel:{labelString:"Watt-Hours",display:!0}}]}}}),this.minChart=new h.a(a,{type:"bar",data:{datasets:[]},options:{legend:{display:!1},title:{display:!0},scales:{xAxes:[{labelString:"Site ID",barPercentage:10,maxBarThickness:60,minBarLength:2,gridLines:{offsetGridLines:!0},scaleLabel:{labelString:"Site ID",display:!0}}],yAxes:[{scaleLabel:{labelString:"Watt-Hours",display:!0}}]}}})}}},v={render:function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("div",{attrs:{id:"app"}},[t._m(0),t._v(" "),e("div",{staticClass:"container"},[e("table",{staticClass:"table"},[t._m(1),t._v(" "),t._l(t.capacityTable,function(a){return e("tr",{key:a.siteId},[e("td",[e("router-link",{attrs:{to:{name:"stats",params:{id:a.siteId}}}},[t._v(t._s(a.siteId))])],1),t._v(" "),e("td",[t._v(t._s(a.capacity))])])})],2)]),t._v(" "),t._m(2),t._v(" "),e("div",{staticClass:"container"},[e("table",{staticClass:"table"},[t._m(3),t._v(" "),t._l(t.minCapacityTable,function(a){return e("tr",{key:a.siteId},[e("td",[e("router-link",{attrs:{to:{name:"stats",params:{id:a.siteId}}}},[t._v(t._s(a.siteId))])],1),t._v(" "),e("td",[t._v(t._s(a.capacity))])])})],2)])])},staticRenderFns:[function(){var t=this.$createElement,a=this._self._c||t;return a("div",{staticClass:"container"},[a("h1",{staticClass:"header"},[this._v("Site Capacity")]),this._v(" "),a("h3",{staticClass:"header"},[this._v("Top 10 Capacity")]),this._v(" "),a("canvas",{attrs:{id:"maxChart"}})])},function(){var t=this.$createElement,a=this._self._c||t;return a("thead",[a("tr",[a("td",{attrs:{scope:"col"}},[this._v("ID")]),this._v(" "),a("td",{attrs:{scope:"col"}},[this._v("Capacity")])])])},function(){var t=this.$createElement,a=this._self._c||t;return a("div",{staticClass:"container"},[a("h3",{staticClass:"header",staticStyle:{"margin-top":"1em"}},[this._v("Bottom 10 Capacity")]),this._v(" "),a("canvas",{attrs:{id:"minChart"}})])},function(){var t=this.$createElement,a=this._self._c||t;return a("thead",[a("tr",[a("td",{attrs:{scope:"col"}},[this._v("ID")]),this._v(" "),a("td",{attrs:{scope:"col"}},[this._v("Capacity")])])])}]};var C=e("VU/8")(f,v,!1,function(t){e("VkHU")},null,null).exports,b=e("nq5D"),j={name:"app",components:{Map:l,NavBar:d.default,Stats:g,Foot:b.default,Capacity:C}},k={render:function(){var t=this.$createElement,a=this._self._c||t;return a("div",{attrs:{id:"app"}},[a("nav-bar"),this._v(" "),a("router-view")],1)},staticRenderFns:[]};var y=e("VU/8")(j,k,!1,function(t){e("q9fh")},null,null).exports,x=e("/ocq"),A={name:"Recent",data:function(){return{meterReadings:[]}},mounted:function(){this.getData()},methods:{getData:function(){var t=this;i.a.get("/api/meterReadings/").then(function(a){t.meterReadings=a.data}).catch(function(t){console.log("Got error"),console.log(t)})}}},E={render:function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("div",{attrs:{id:"app"}},[t._m(0),t._v(" "),e("div",{staticClass:"container"},[e("table",{staticClass:"table"},[t._m(1),t._v(" "),t._l(t.meterReadings,function(a){return e("tr",{key:a.siteId},[e("td",[t._v(t._s(new Date(1e3*a.dateTime).toISOString()))]),t._v(" "),e("td",[e("router-link",{attrs:{to:{name:"stats",params:{id:a.siteId}}}},[t._v(t._s(a.siteId))])],1),t._v(" "),e("td",[t._v(t._s(Number.parseFloat(a.whGenerated).toPrecision(4)))]),t._v(" "),e("td",[t._v(t._s(Number.parseFloat(a.whUsed).toPrecision(4)))]),t._v(" "),e("td",[t._v(t._s(Number.parseFloat(a.tempC).toPrecision(4)))])])})],2)])])},staticRenderFns:[function(){var t=this.$createElement,a=this._self._c||t;return a("div",{staticClass:"container margin-md"},[a("h1",{staticClass:"header"},[this._v("Recent Meter Readings")])])},function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("thead",[e("tr",[e("td",{attrs:{scope:"col"}},[t._v("Timestamp")]),t._v(" "),e("td",{attrs:{scope:"col"}},[t._v("Site ID")]),t._v(" "),e("td",{attrs:{scope:"col"}},[t._v("Watt-hours Generated")]),t._v(" "),e("td",{attrs:{scope:"col"}},[t._v("Watt-hours Used")]),t._v(" "),e("td",{attrs:{scope:"col"}},[t._v("Temp (Celsius)")])])])}]};var w=e("VU/8")(A,E,!1,function(t){e("Niou"),e("lUrd")},null,null).exports;s.a.use(x.a);var z=new x.a({routes:[{path:"/",name:"Map",component:l},{path:"/map",name:"Map",component:l},{path:"/stats",name:"stats",component:g},{path:"/capacity",name:"capacity",component:C},{path:"/stats/:id",name:"stats",component:g},{path:"/recent",name:"Recent",component:w}]});e("EYnv");s.a.component("l-map",r.LMap),s.a.component("l-tile-layer",r.LTileLayer),s.a.component("l-marker",r.LMarker),delete r.L.Icon.Default.prototype._getIconUrl,r.L.Icon.Default.mergeOptions({iconRetinaUrl:e("qXhe"),iconUrl:e("TJ5S"),shadowUrl:e("wkq0")}),s.a.config.productionTip=!1,new s.a({el:"#app",router:z,components:{App:y},template:""})},Niou:function(t,a){},SdHj:function(t,a){},TJ5S:function(t,a){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII="},VkHU:function(t,a){},b7gL:function(t,a){},cMi8:function(t,a){},"g2+m":function(t,a,e){"use strict";var s=e("DEtk"),r=e.n(s),n=e("w8Q2");var i=function(t){e("cMi8")},c=e("VU/8")(r.a,n.a,!1,i,"data-v-2253b308",null);a.default=c.exports},k6kf:function(t,a){},lUrd:function(t,a){},nq5D:function(t,a,e){"use strict";var s=e("t2Vd"),r=e.n(s),n=e("6VF5");var i=function(t){e("SdHj")},c=e("VU/8")(r.a,n.a,!1,i,"data-v-18a907dd",null);a.default=c.exports},q9fh:function(t,a){},qXhe:function(t,a){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAABSCAMAAAAhFXfZAAAC91BMVEVMaXEzeak2f7I4g7g3g7cua5gzeKg8hJo3grY4g7c3grU0gLI2frE0daAubJc2gbQwd6QzeKk2gLMtd5sxdKIua5g1frA2f7IydaM0e6w2fq41fK01eqo3grgubJgta5cxdKI1f7AydaQydaMxc6EubJgvbJkwcZ4ubZkwcJwubZgubJcydqUydKIxapgubJctbJcubZcubJcvbJYubJcvbZkubJctbJctbZcubJg2f7AubJcrbZcubJcubJcua5g3grY0fq8ubJcubJdEkdEwhsw6i88vhswuhcsuhMtBjMgthMsrg8srgss6is8qgcs8i9A9iMYtg8spgcoogMo7hcMngMonf8olfso4gr8kfck5iM8jfMk4iM8he8k1fro7itAgesk2hs8eecgzfLcofssdeMg0hc4cd8g2hcsxeLQbdsgZdcgxeLImfcszhM0vda4xgckzhM4xg84wf8Yxgs4udKsvfcQucqhUndROmdM1fK0wcZ8vb5w0eqpQm9MzeKhXoNVcpdYydKNWn9VZotVKltJFjsIwcJ1Rms9OlslLmtH///8+kc9epdYzd6dbo9VHkMM2f7FHmNBClM8ydqVcpNY9hro3gLM9hLczealQmcw3fa46f7A8gLMxc6I3eagyc6FIldJMl9JSnNRSntNNl9JPnNJFi75UnM9ZodVKksg8kM45jc09e6ZHltFBk883gbRBh7pDk9EwcaBzn784g7dKkcY2i81Om9M7j85Llc81is09g7Q4grY/j9A0eqxKmdFFltBEjcXf6fFImdBCiLxJl9FGlNFBi78yiMxVndEvbpo6js74+vx+psPP3+o/ks5HkcpGmNCjwdZCkNDM3ehYoNJEls+lxNkxh8xHks0+jdC1zd5Lg6r+/v/H2ufz9/o3jM3t8/edvdM/k89Th61OiLBSjbZklbaTt9BfptdjmL1AicBHj8hGk9FAgK1dkLNTjLRekrdClc/k7fM0icy0y9tgp9c4jc2NtM9Dlc8zicxeXZn3AAAAQ3RSTlMAHDdTb4yPA+LtnEQmC4L2EmHqB7XA0d0sr478x4/Yd5i1zOfyPkf1sLVq4Nh3FvjxopQ2/STNuFzUwFIwxKaejILpIBEV9wAABhVJREFUeF6s1NdyFEcYBeBeoQIhRAkLlRDGrhIgY3BJL8CVeKzuyXFzzjkn5ZxzzuScg3PO8cKzu70JkO0LfxdTU//pM9vTu7Xgf6KqOVTb9X7toRrVEfBf1HTVjZccrT/2by1VV928Yty9ZbVuucdz90frG8DBjl9pVApbOstvmMuvVgaNXSfAAd6pGxpy6yxf5ph43pS/4f3uoaGm2rdu72S9xzOvMymkZFq/ptDrk90mhW7e4zl7HLzhxGWPR20xmSxJ/VqldG5m9XhaVOA1DadsNh3Pu5L2N6QtPO/32JpqQBVVk20oy/Pi2s23WEvyfHbe1thadVQttvm7Llf65gGmXK67XtupyoM7HQhmXdLS8oGWJNeOJ3C5fG5XCEJnkez3/oFdsvgJ4l2ANZwhrJKk/7OSXa+3Vw2WJMlKnGkobouYk6T0TyX30klOUnTD9HJ5qpckL3EW/w4XF3Xd0FGywXUrstrclVsqz5Pd/sXFYyDnPdrLcQODmGOK47IZb4CmibmMn+MYRzFZ5jg33ZL/EJrWcszHmANy3ARBK/IXtciJy8VsitPSdE3uuHxzougojcUdr8/32atnz/ev3f/K5wtpxUTpcaI45zusVDpYtZi+jg0oU9b3x74h7+n9ABvYEZeKaVq0sh0AtLKsFtqNBdeT0MrSzwwlq9+x6xAO4tgOtSzbCjrNQQiNvQUbUEubvzBUeGw26yDCsRHCoLkTHDa7IdOLIThs/gHvChszh2CimE8peRs47cxANI0lYNB5y1DljpOF0IhzBDPOZnDOqYYbeGKECbPzWnXludPphw5c2YBq5zlwXphIbO4VDCZ0gnPfUO1TwZoYwAs2ExPCedAu9DAjfQUjzITQb3jNj0KG2Sgt6BHaQUdYzWz+XmBktOHwanXjaSTcwwziBcuMOtwBmqPrTOxFQR/DRKKPqyur0aiW6cULYsx6tBm0jXpR/AUWR6HRq9WVW6MRhIq5jLyjbaCTDCijyYJNpCajdyobP/eTw0iexBAKkJ3gA5KcQb2zBXsIBckn+xVv8jkZSaEFHE+jFEleAEfayRU0MouNoBmB/L50Ai/HSLIHxcrpCvnhSQAuakKp2C/YbCylJjXRVy/z3+Kv/RrNcCo+WUzlVEhzKffnTQnxeN9fWF88fiNCUdSTsaufaChKWInHeysygfpIqagoakW+vV20J8uyl6TyNKEZWV4oRSPyCkWpgOLSbkCObT8o2r6tlG58HQquf6O0v50tB7JM7F4EORd2dx/K0w/KHsVkLPaoYrwgP/y7krr3SSMA4zj+OBgmjYkxcdIJQyQRKgg2viX9Hddi9UBb29LrKR7CVVEEEXWojUkXNyfTNDE14W9gbHJNuhjDettN3ZvbOvdOqCD3Jp/9l+/wJE+9PkYGjx/fqkys3S2rMozM/o2106rfMUINo6hVqz+eu/hd1c4xTg0TAfy5kV+4UG6+IthHTU9woWmxuKNbTfuCSfovBCxq7EtHqvYL4Sm6F8GVxsSXHMQ07TOi1DKtZxjWaaIyi4CXWjxPccUw8WVbMYY5wxC1mzEyXMJWkllpRloi+Kkoq69sxBTlElF6aAxYUbjXNlhlDZilDnM4U5SlN5biRsRHnbx3mbeWjEh4mEyiuJDl5XcWVmX5GvNkFgLWZM5qwsop4/AWfLhU1cR7k1VVvcYCWRkOI6Xy5gmnphCYIkvzuNYzHzosq2oNk2RtSs8khfUOfHIDgR6ysYBaMpl4uEgk2U/oJTs9AaTSwma7dT69geAE2ZpEjUsn2ieJNHeKfrI3EcAGJ2ZaNgVuC8EBctCLc57P5u5led6IOBkIYkuQMrmmjChs4VkfOerHqSBkPzZlhe06RslZ3zMjk2sscqKwY0RcjKK+LWbzd7KiHhkncs/siFJ+V5eXxD34B8nVuJEpGJNmxN2gH3vSvp7J70tF+D1Ej8qUJD1TkErAND2GZwTFg/LubvmgiBG3SOvdlsqFQrkEzJCL1rstlnVFROixZoDDSuXQFHESwVGlcuQcMb/b42NgjLowh5MTDFE3vNB5qStRIErdCQEh6pLPR92anSUb/wAIhldAaDMpGgAAAABJRU5ErkJggg=="},t2Vd:function(t,a){},uslO:function(t,a,e){var s={"./af":"3CJN","./af.js":"3CJN","./ar":"3MVc","./ar-dz":"tkWw","./ar-dz.js":"tkWw","./ar-kw":"j8cJ","./ar-kw.js":"j8cJ","./ar-ly":"wPpW","./ar-ly.js":"wPpW","./ar-ma":"dURR","./ar-ma.js":"dURR","./ar-sa":"7OnE","./ar-sa.js":"7OnE","./ar-tn":"BEem","./ar-tn.js":"BEem","./ar.js":"3MVc","./az":"eHwN","./az.js":"eHwN","./be":"3hfc","./be.js":"3hfc","./bg":"lOED","./bg.js":"lOED","./bm":"hng5","./bm.js":"hng5","./bn":"aM0x","./bn.js":"aM0x","./bo":"w2Hs","./bo.js":"w2Hs","./br":"OSsP","./br.js":"OSsP","./bs":"aqvp","./bs.js":"aqvp","./ca":"wIgY","./ca.js":"wIgY","./cs":"ssxj","./cs.js":"ssxj","./cv":"N3vo","./cv.js":"N3vo","./cy":"ZFGz","./cy.js":"ZFGz","./da":"YBA/","./da.js":"YBA/","./de":"DOkx","./de-at":"8v14","./de-at.js":"8v14","./de-ch":"Frex","./de-ch.js":"Frex","./de.js":"DOkx","./dv":"rIuo","./dv.js":"rIuo","./el":"CFqe","./el.js":"CFqe","./en-SG":"oYA3","./en-SG.js":"oYA3","./en-au":"Sjoy","./en-au.js":"Sjoy","./en-ca":"Tqun","./en-ca.js":"Tqun","./en-gb":"hPuz","./en-gb.js":"hPuz","./en-ie":"ALEw","./en-ie.js":"ALEw","./en-il":"QZk1","./en-il.js":"QZk1","./en-nz":"dyB6","./en-nz.js":"dyB6","./eo":"Nd3h","./eo.js":"Nd3h","./es":"LT9G","./es-do":"7MHZ","./es-do.js":"7MHZ","./es-us":"INcR","./es-us.js":"INcR","./es.js":"LT9G","./et":"XlWM","./et.js":"XlWM","./eu":"sqLM","./eu.js":"sqLM","./fa":"2pmY","./fa.js":"2pmY","./fi":"nS2h","./fi.js":"nS2h","./fo":"OVPi","./fo.js":"OVPi","./fr":"tzHd","./fr-ca":"bXQP","./fr-ca.js":"bXQP","./fr-ch":"VK9h","./fr-ch.js":"VK9h","./fr.js":"tzHd","./fy":"g7KF","./fy.js":"g7KF","./ga":"U5Iz","./ga.js":"U5Iz","./gd":"nLOz","./gd.js":"nLOz","./gl":"FuaP","./gl.js":"FuaP","./gom-latn":"+27R","./gom-latn.js":"+27R","./gu":"rtsW","./gu.js":"rtsW","./he":"Nzt2","./he.js":"Nzt2","./hi":"ETHv","./hi.js":"ETHv","./hr":"V4qH","./hr.js":"V4qH","./hu":"xne+","./hu.js":"xne+","./hy-am":"GrS7","./hy-am.js":"GrS7","./id":"yRTJ","./id.js":"yRTJ","./is":"upln","./is.js":"upln","./it":"FKXc","./it-ch":"/E8D","./it-ch.js":"/E8D","./it.js":"FKXc","./ja":"ORgI","./ja.js":"ORgI","./jv":"JwiF","./jv.js":"JwiF","./ka":"RnJI","./ka.js":"RnJI","./kk":"j+vx","./kk.js":"j+vx","./km":"5j66","./km.js":"5j66","./kn":"gEQe","./kn.js":"gEQe","./ko":"eBB/","./ko.js":"eBB/","./ku":"kI9l","./ku.js":"kI9l","./ky":"6cf8","./ky.js":"6cf8","./lb":"z3hR","./lb.js":"z3hR","./lo":"nE8X","./lo.js":"nE8X","./lt":"/6P1","./lt.js":"/6P1","./lv":"jxEH","./lv.js":"jxEH","./me":"svD2","./me.js":"svD2","./mi":"gEU3","./mi.js":"gEU3","./mk":"Ab7C","./mk.js":"Ab7C","./ml":"oo1B","./ml.js":"oo1B","./mn":"CqHt","./mn.js":"CqHt","./mr":"5vPg","./mr.js":"5vPg","./ms":"ooba","./ms-my":"G++c","./ms-my.js":"G++c","./ms.js":"ooba","./mt":"oCzW","./mt.js":"oCzW","./my":"F+2e","./my.js":"F+2e","./nb":"FlzV","./nb.js":"FlzV","./ne":"/mhn","./ne.js":"/mhn","./nl":"3K28","./nl-be":"Bp2f","./nl-be.js":"Bp2f","./nl.js":"3K28","./nn":"C7av","./nn.js":"C7av","./pa-in":"pfs9","./pa-in.js":"pfs9","./pl":"7LV+","./pl.js":"7LV+","./pt":"ZoSI","./pt-br":"AoDM","./pt-br.js":"AoDM","./pt.js":"ZoSI","./ro":"wT5f","./ro.js":"wT5f","./ru":"ulq9","./ru.js":"ulq9","./sd":"fW1y","./sd.js":"fW1y","./se":"5Omq","./se.js":"5Omq","./si":"Lgqo","./si.js":"Lgqo","./sk":"OUMt","./sk.js":"OUMt","./sl":"2s1U","./sl.js":"2s1U","./sq":"V0td","./sq.js":"V0td","./sr":"f4W3","./sr-cyrl":"c1x4","./sr-cyrl.js":"c1x4","./sr.js":"f4W3","./ss":"7Q8x","./ss.js":"7Q8x","./sv":"Fpqq","./sv.js":"Fpqq","./sw":"DSXN","./sw.js":"DSXN","./ta":"+7/x","./ta.js":"+7/x","./te":"Nlnz","./te.js":"Nlnz","./tet":"gUgh","./tet.js":"gUgh","./tg":"5SNd","./tg.js":"5SNd","./th":"XzD+","./th.js":"XzD+","./tl-ph":"3LKG","./tl-ph.js":"3LKG","./tlh":"m7yE","./tlh.js":"m7yE","./tr":"k+5o","./tr.js":"k+5o","./tzl":"iNtv","./tzl.js":"iNtv","./tzm":"FRPF","./tzm-latn":"krPU","./tzm-latn.js":"krPU","./tzm.js":"FRPF","./ug-cn":"To0v","./ug-cn.js":"To0v","./uk":"ntHu","./uk.js":"ntHu","./ur":"uSe8","./ur.js":"uSe8","./uz":"XU1s","./uz-latn":"/bsm","./uz-latn.js":"/bsm","./uz.js":"XU1s","./vi":"0X8Q","./vi.js":"0X8Q","./x-pseudo":"e/KL","./x-pseudo.js":"e/KL","./yo":"YXlc","./yo.js":"YXlc","./zh-cn":"Vz2w","./zh-cn.js":"Vz2w","./zh-hk":"ZUyn","./zh-hk.js":"ZUyn","./zh-tw":"BbgG","./zh-tw.js":"BbgG"};function r(t){return e(n(t))}function n(t){var a=s[t];if(!(a+1))throw new Error("Cannot find module '"+t+"'.");return a}r.keys=function(){return Object.keys(s)},r.resolve=n,t.exports=r,r.id="uslO"},w8Q2:function(t,a,e){"use strict";var s={render:function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("nav",{staticClass:"navbar navbar-expand-lg navbar-light bg-dark",attrs:{id:"main"}},[e("div",{staticClass:"container"},[e("img",{staticClass:"navbar-brand",attrs:{src:"/static/img/redis-u-small.png"}}),t._v(" "),e("a",{staticClass:"navbar-brand text-white",attrs:{href:"#"}},[t._v("Solar Power Dashboard")]),t._v(" "),t._m(0),t._v(" "),e("div",{staticClass:"collapse navbar-collapse",attrs:{id:"navbarSupportedContent"}},[e("ul",{staticClass:"navbar-nav mr-auto"},[e("li",{staticClass:"nav-item"},[e("router-link",{staticClass:"nav-link text-light",attrs:{to:"/map"}},[t._v("Map")])],1),t._v(" "),e("li",{staticClass:"nav-item"},[e("router-link",{staticClass:"nav-link text-light",attrs:{to:"/stats/1"}},[t._v("Stats")])],1),t._v(" "),e("li",{staticClass:"nav-item"},[e("router-link",{staticClass:"nav-link text-light",attrs:{to:"/capacity"}},[t._v("Capacity")])],1),t._v(" "),e("li",{staticClass:"nav-item"},[e("router-link",{staticClass:"nav-link text-light",attrs:{to:"/recent"}},[t._v("Recent")])],1)])])])])},staticRenderFns:[function(){var t=this.$createElement,a=this._self._c||t;return a("button",{staticClass:"navbar-toggler",attrs:{type:"button","data-toggle":"collapse","data-target":"#navbarSupportedContent","aria-controls":"navbarSupportedContent","aria-expanded":"false","aria-label":"Toggle navigation"}},[a("span",{staticClass:"navbar-toggler-icon"})])}]};a.a=s},wkq0:function(t,a){t.exports="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACkAAAApCAQAAAACach9AAACMUlEQVR4Ae3ShY7jQBAE0Aoz/f9/HTMzhg1zrdKUrJbdx+Kd2nD8VNudfsL/Th///dyQN2TH6f3y/BGpC379rV+S+qqetBOxImNQXL8JCAr2V4iMQXHGNJxeCfZXhSRBcQMfvkOWUdtfzlLgAENmZDcmo2TVmt8OSM2eXxBp3DjHSMFutqS7SbmemzBiR+xpKCNUIRkdkkYxhAkyGoBvyQFEJEefwSmmvBfJuJ6aKqKWnAkvGZOaZXTUgFqYULWNSHUckZuR1HIIimUExutRxwzOLROIG4vKmCKQt364mIlhSyzAf1m9lHZHJZrlAOMMztRRiKimp/rpdJDc9Awry5xTZCte7FHtuS8wJgeYGrex28xNTd086Dik7vUMscQOa8y4DoGtCCSkAKlNwpgNtphjrC6MIHUkR6YWxxs6Sc5xqn222mmCRFzIt8lEdKx+ikCtg91qS2WpwVfBelJCiQJwvzixfI9cxZQWgiSJelKnwBElKYtDOb2MFbhmUigbReQBV0Cg4+qMXSxXSyGUn4UbF8l+7qdSGnTC0XLCmahIgUHLhLOhpVCtw4CzYXvLQWQbJNmxoCsOKAxSgBJno75avolkRw8iIAFcsdc02e9iyCd8tHwmeSSoKTowIgvscSGZUOA7PuCN5b2BX9mQM7S0wYhMNU74zgsPBj3HU7wguAfnxxjFQGBE6pwN+GjME9zHY7zGp8wVxMShYX9NXvEWD3HbwJf4giO4CFIQxXScH1/TM+04kkBiAAAAAElFTkSuQmCC"}},["NHnr"]);
2 | //# sourceMappingURL=app.a81024c85819f0f2043f.js.map
--------------------------------------------------------------------------------
/public/static/css/app.67b676c3ad3cca4844561b3c9355e213.css.map:
--------------------------------------------------------------------------------
1 | {"version":3,"sources":["app.67b676c3ad3cca4844561b3c9355e213.css"],"names":[],"mappings":"AACA,KACI,8CAAoD,AACpD,mCAAoC,AACpC,kCAAmC,AACnC,kBAAmB,AACnB,aAAe,CAClB,AACD,QACI,WAAc,CACjB,AAED,OACI,YAAc,CACjB,AAGD,oFAEE,yBAA4B,AAC5B,cAAgB,CACjB,AAID,sBACE,kBAAoB,CACrB,AACD,wCACE,gBAAiB,CAClB,AACD,yBACE,kBAAmB,AACnB,SAAU,AACV,WAAY,AACZ,YAAa,AACb,iBAAkB,AAClB,wBAA0B,CAC3B,AAuCD,6LAUC,kBAAmB,AACnB,OAAQ,AACR,KAAO,CACN,AAEF,mBACC,eAAiB,CAChB,AAEF,0DAGC,yBAA0B,AACvB,sBAAuB,AAClB,qBAAsB,AAC1B,iBAAkB,AACpB,sBAAwB,CACzB,AAIF,8BACC,yCAA2C,CAC1C,AAIF,wCACC,aAAc,AACd,cAAe,AACf,4BAA8B,CAC7B,AAEF,4CAEC,aAAe,CACd,AAMF,2PAMC,yBAA2B,AAC3B,yBAA4B,CAC3B,AAEF,sCACC,6BAA8B,AAC9B,wBAA0B,CACzB,AAEF,sCACC,4BAA6B,AAE7B,kBAAmB,AACnB,uBAAyB,CACzB,AAED,yDACC,sBAAuB,AACvB,iBAAmB,CACnB,AAED,mBACC,uCAAyC,CACzC,AAED,qBACC,+CAAqD,CACrD,AAED,cAES,eAAgB,AACxB,iBAAmB,CAClB,AAEF,qBACC,kBAAoB,CACnB,AAEF,kBACC,QAAS,AACT,SAAU,AAEF,sBAAuB,AAC/B,WAAa,CACZ,AAIF,0BACC,qBAAuB,CACtB,AAEF,cAAwB,WAAa,CAAE,AAEvC,mBAAwB,WAAa,CAAE,AAEvC,sBAAwB,WAAa,CAAE,AAEvC,qBAAwB,WAAa,CAAE,AAEvC,qBAAwB,WAAa,CAAE,AAEvC,sBAA0B,WAAa,CAAE,AAEzC,oBAAwB,WAAa,CAAE,AAEvC,yBAA2B,WAAa,CAAE,AAE1C,sBAA2B,WAAa,CAAE,AAE1C,mBACC,UAAW,AACX,UAAY,CACX,AAEF,MACC,2BAA4B,AAC5B,qBAAsB,AACtB,iBAAmB,CAClB,AAIF,iBACC,kBAAmB,AACnB,YAAa,AACb,8BAA+B,AAC/B,mBAAqB,CACpB,AAEF,6BAEC,kBAAmB,AACnB,aAAc,AACd,mBAAqB,CACpB,AAEF,aACC,KAAO,CACN,AAEF,eACC,OAAS,CACR,AAEF,gBACC,QAAU,CACT,AAEF,cACC,MAAQ,CACP,AAEF,iBACC,WAAY,AACZ,UAAY,CACX,AAEF,gCACC,WAAa,CACZ,AAEF,8BACC,eAAiB,CAChB,AAEF,iCACC,kBAAoB,CACnB,AAEF,+BACC,gBAAkB,CACjB,AAEF,gCACC,iBAAmB,CAClB,AAIF,iCACC,mBAAqB,CACpB,AAEF,kCACC,UAAW,AAEH,6BAAgC,CACvC,AAEF,oDACC,SAAW,CACV,AAEF,uBAES,oBAAsB,CAC7B,AAEF,0CACC,sBAAuB,AAMf,iDAA6D,CALpE,AASF,iEAGS,eAAiB,CACxB,AAEF,sCACC,iBAAmB,CAClB,AAIF,qBACC,cAAgB,CACf,AAEF,cAEC,WAAqB,CACpB,AAEF,2DAEC,gBAAkB,CACjB,AAEF,qCAEC,WAAa,CACZ,AAEF,iIAGC,YAAa,AAEb,eAAyB,CACxB,AAIF,gHAKC,mBAAqB,CACpB,AAEF,6HAGC,8BAA+B,AAC/B,mBAAqB,CACpB,AAIF,mBACC,gBAAiB,AACjB,SAAW,CACV,AAEF,qBACC,aAAe,CACd,AAEF,oCACC,wBAA0B,CACzB,AAEF,kBACC,uBAAwB,AACxB,6BAAkC,CACjC,AAIF,mBACC,uDAA8D,CAC7D,AAIF,aAES,qCAAuC,AAC/C,iBAAmB,CAClB,AAEF,oCAEC,sBAAuB,AACvB,6BAA8B,AAC9B,WAAY,AACZ,YAAa,AACb,iBAAkB,AAClB,cAAe,AACf,kBAAmB,AACnB,qBAAsB,AACtB,UAAa,CACZ,AAEF,8CAEC,4BAA6B,AAC7B,4BAA6B,AAC7B,aAAe,CACd,AAEF,qBACC,wBAA0B,CACzB,AAEF,2BACC,2BAA4B,AAC5B,2BAA6B,CAC5B,AAEF,0BACC,8BAA+B,AAC/B,+BAAgC,AAChC,kBAAoB,CACnB,AAEF,gCACC,eAAgB,AAChB,yBAA0B,AAC1B,UAAY,CACX,AAEF,8BACC,WAAY,AACZ,YAAa,AACb,gBAAkB,CACjB,AAEF,0CACC,2BAA4B,AAC5B,2BAA6B,CAC5B,AAEF,yCACC,8BAA+B,AAC/B,8BAAgC,CAC/B,AAIF,mDAEC,8CAAoD,AACpD,eAAiB,CAChB,AAEF,iFACC,cAAgB,CACf,AAIF,wBAES,oCAAsC,AAC9C,gBAAiB,AACjB,iBAAmB,CAClB,AAEF,+BACC,68BAA88B,AAC98B,WAAY,AACZ,WAAa,CACZ,AAEF,+CACC,6rDAA8rD,AAC9rD,yBAA2B,CAC1B,AAEF,8CACC,WAAY,AACZ,WAAa,CACZ,AAEF,qHAEC,YAAc,CACb,AAEF,8DACC,cAAe,AACf,iBAAmB,CAClB,AAEF,iCACC,yBAA0B,AAC1B,WAAY,AACZ,eAAiB,CAChB,AAEF,kCACC,kBAAmB,AACnB,kBAAmB,AACnB,iBAAmB,CAClB,AAEF,iCACC,eAAgB,AAChB,kBAAmB,AACnB,OAAS,CACR,AAEF,8BACC,aAAe,CACd,AAEF,kCACC,SAAU,AACV,0BAA2B,AAC3B,yBAA2B,CAC1B,AAIF,2BACC,g9DAAk9D,CACj9D,AAIF,gDACC,gBAAiB,AACjB,8BAAqC,AACrC,QAAU,CACT,AAEF,yDAEC,cAAe,AACf,UAAY,CACX,AAEF,+BACC,oBAAsB,CACrB,AAEF,qCACC,yBAA2B,CAC1B,AAEF,0FAEC,cAAgB,CACf,AAEF,qCACC,eAAiB,CAChB,AAEF,uCACC,iBAAmB,CAClB,AAEF,4BACC,sBAAuB,AACvB,gBAAiB,AACjB,gBAAiB,AACjB,oBAAqB,AACrB,eAAgB,AAChB,mBAAoB,AACpB,gBAAiB,AAET,sBAAuB,AAE/B,gBAAiB,AACjB,6BAAqC,CACpC,AAEF,8CACC,0BAA2B,AAC3B,mBAAoB,AACpB,eAAiB,CAChB,AAEF,+DACC,4BAA8B,CAC7B,AAEF,+GAIS,eAAiB,CACxB,AAEF,mEAEC,gCAAkC,AAClC,2BAA6B,CAC5B,AAIF,eACC,kBAAmB,AACnB,kBAAmB,AACnB,kBAAoB,CACnB,AAEF,+BACC,YAAa,AACb,gBAAiB,AACjB,kBAAoB,CACnB,AAEF,uBACC,iBAAkB,AAClB,eAAiB,CAChB,AAEF,yBACC,aAAe,CACd,AAEF,6BACC,WAAY,AACZ,YAAa,AACb,kBAAmB,AACnB,SAAU,AACV,kBAAmB,AACnB,gBAAiB,AACjB,mBAAqB,CACpB,AAEF,mBACC,WAAY,AACZ,YAAa,AACb,YAAa,AAEb,oBAAqB,AAGb,uBAAyB,CAChC,AAEF,kDAEC,gBAAkB,AAClB,WAAY,AAEJ,oCAAuC,CAC9C,AAEF,gDACC,kBAAmB,AACnB,MAAO,AACP,QAAS,AACT,oBAAqB,AACrB,YAAa,AACb,kBAAmB,AACnB,WAAY,AACZ,YAAa,AACb,yCAA4C,AAC5C,cAAe,AACf,qBAAsB,AACtB,gBAAkB,AAClB,sBAAwB,CACvB,AAEF,sDACC,UAAY,CACX,AAEF,wBACC,cAAe,AACf,6BAA8B,AAC9B,yBAA2B,CAC1B,AAEF,8CACC,MAAQ,CACP,AAEF,kCACC,WAAY,AACZ,cAAe,AAEf,uHAAwH,AACxH,6GAAkH,CACjH,AAEF,4CACC,eAAiB,CAChB,AAEF,4JAIC,qBAAuB,CACtB,AAIF,kBACC,gBAAiB,AACjB,qBAAuB,CACtB,AAMF,iBACC,kBAAmB,AACnB,YAAa,AACb,sBAAuB,AACvB,sBAAuB,AACvB,kBAAmB,AACnB,WAAY,AACZ,mBAAoB,AACpB,yBAA0B,AAC1B,sBAAuB,AACvB,qBAAsB,AACtB,iBAAkB,AAClB,oBAAqB,AAEb,mCAAsC,CAC7C,AAEF,mCACC,eAAgB,AAChB,mBAAqB,CACpB,AAEF,sHAIC,kBAAmB,AACnB,oBAAqB,AACrB,6BAA8B,AAC9B,uBAAwB,AACxB,UAAY,CACX,AAIF,wBACC,cAAgB,CAChB,AAED,qBACC,eAAiB,CACjB,AAED,2DAEC,SAAU,AACV,gBAAkB,CACjB,AAEF,4BACC,SAAU,AACV,oBAAqB,AACrB,qBAAuB,CACtB,AAEF,+BACC,MAAO,AACP,iBAAkB,AAClB,iBAAkB,AAClB,wBAA0B,CACzB,AAEF,sBACC,gBAAkB,CAClB,AAED,uBACC,eAAiB,CACjB,AAED,2DAEC,QAAS,AACT,eAAiB,CAChB,AAEF,6BACC,QAAS,AACT,mBAAoB,AACpB,sBAAwB,CACvB,AAEF,8BACC,OAAQ,AACR,kBAAmB,AACnB,uBAAyB,CACxB","file":"app.67b676c3ad3cca4844561b3c9355e213.css","sourcesContent":["\n#app {\n font-family: 'Avenir', Helvetica, Arial, sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n text-align: center;\n color: #2c3e50;\n}\n.header {\n margin: 0.5em;\n}\n\n#mapid {\n height: 900px;\n}\n\n\nli.router-link-active[data-v-2253b308],\nli.router-link-exact-active[data-v-2253b308] {\n background-color: indianred;\n cursor: pointer;\n}\n\n\n\nbody[data-v-18a907dd] {\n margin-bottom: 30px; /* Margin bottom by footer height */\n}\nimg[data-v-18a907dd], p[data-v-18a907dd] {\n padding-top:10px;\n}\n.footer[data-v-18a907dd] {\n position: absolute;\n bottom: 0;\n width: 100%;\n height: 50px; /* Set the fixed height of the footer here */\n line-height: 30px; /* Vertically center the text there */\n background-color: #f5f5f5;\n}\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* required styles */\r\n\r\n.leaflet-pane,\r\n.leaflet-tile,\r\n.leaflet-marker-icon,\r\n.leaflet-marker-shadow,\r\n.leaflet-tile-container,\r\n.leaflet-pane > svg,\r\n.leaflet-pane > canvas,\r\n.leaflet-zoom-box,\r\n.leaflet-image-layer,\r\n.leaflet-layer {\r\n\tposition: absolute;\r\n\tleft: 0;\r\n\ttop: 0;\r\n\t}\r\n\r\n.leaflet-container {\r\n\toverflow: hidden;\r\n\t}\r\n\r\n.leaflet-tile,\r\n.leaflet-marker-icon,\r\n.leaflet-marker-shadow {\r\n\t-webkit-user-select: none;\r\n\t -moz-user-select: none;\r\n\t -ms-user-select: none;\r\n\t user-select: none;\r\n\t -webkit-user-drag: none;\r\n\t}\r\n\r\n/* Safari renders non-retina tile on retina better with this, but Chrome is worse */\r\n\r\n.leaflet-safari .leaflet-tile {\r\n\timage-rendering: -webkit-optimize-contrast;\r\n\t}\r\n\r\n/* hack that prevents hw layers \"stretching\" when loading new tiles */\r\n\r\n.leaflet-safari .leaflet-tile-container {\r\n\twidth: 1600px;\r\n\theight: 1600px;\r\n\t-webkit-transform-origin: 0 0;\r\n\t}\r\n\r\n.leaflet-marker-icon,\r\n.leaflet-marker-shadow {\r\n\tdisplay: block;\r\n\t}\r\n\r\n/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */\r\n\r\n/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */\r\n\r\n.leaflet-container .leaflet-overlay-pane svg,\r\n.leaflet-container .leaflet-marker-pane img,\r\n.leaflet-container .leaflet-shadow-pane img,\r\n.leaflet-container .leaflet-tile-pane img,\r\n.leaflet-container img.leaflet-image-layer,\r\n.leaflet-container .leaflet-tile {\r\n\tmax-width: none !important;\r\n\tmax-height: none !important;\r\n\t}\r\n\r\n.leaflet-container.leaflet-touch-zoom {\r\n\t-ms-touch-action: pan-x pan-y;\r\n\ttouch-action: pan-x pan-y;\r\n\t}\r\n\r\n.leaflet-container.leaflet-touch-drag {\r\n\t-ms-touch-action: pinch-zoom;\r\n\t/* Fallback for FF which doesn't support pinch-zoom */\r\n\ttouch-action: none;\r\n\ttouch-action: pinch-zoom;\r\n}\r\n\r\n.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {\r\n\t-ms-touch-action: none;\r\n\ttouch-action: none;\r\n}\r\n\r\n.leaflet-container {\r\n\t-webkit-tap-highlight-color: transparent;\r\n}\r\n\r\n.leaflet-container a {\r\n\t-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);\r\n}\r\n\r\n.leaflet-tile {\r\n\t-webkit-filter: inherit;\r\n\t filter: inherit;\r\n\tvisibility: hidden;\r\n\t}\r\n\r\n.leaflet-tile-loaded {\r\n\tvisibility: inherit;\r\n\t}\r\n\r\n.leaflet-zoom-box {\r\n\twidth: 0;\r\n\theight: 0;\r\n\t-webkit-box-sizing: border-box;\r\n\t box-sizing: border-box;\r\n\tz-index: 800;\r\n\t}\r\n\r\n/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */\r\n\r\n.leaflet-overlay-pane svg {\r\n\t-moz-user-select: none;\r\n\t}\r\n\r\n.leaflet-pane { z-index: 400; }\r\n\r\n.leaflet-tile-pane { z-index: 200; }\r\n\r\n.leaflet-overlay-pane { z-index: 400; }\r\n\r\n.leaflet-shadow-pane { z-index: 500; }\r\n\r\n.leaflet-marker-pane { z-index: 600; }\r\n\r\n.leaflet-tooltip-pane { z-index: 650; }\r\n\r\n.leaflet-popup-pane { z-index: 700; }\r\n\r\n.leaflet-map-pane canvas { z-index: 100; }\r\n\r\n.leaflet-map-pane svg { z-index: 200; }\r\n\r\n.leaflet-vml-shape {\r\n\twidth: 1px;\r\n\theight: 1px;\r\n\t}\r\n\r\n.lvml {\r\n\tbehavior: url(#default#VML);\r\n\tdisplay: inline-block;\r\n\tposition: absolute;\r\n\t}\r\n\r\n/* control positioning */\r\n\r\n.leaflet-control {\r\n\tposition: relative;\r\n\tz-index: 800;\r\n\tpointer-events: visiblePainted; /* IE 9-10 doesn't have auto */\r\n\tpointer-events: auto;\r\n\t}\r\n\r\n.leaflet-top,\r\n.leaflet-bottom {\r\n\tposition: absolute;\r\n\tz-index: 1000;\r\n\tpointer-events: none;\r\n\t}\r\n\r\n.leaflet-top {\r\n\ttop: 0;\r\n\t}\r\n\r\n.leaflet-right {\r\n\tright: 0;\r\n\t}\r\n\r\n.leaflet-bottom {\r\n\tbottom: 0;\r\n\t}\r\n\r\n.leaflet-left {\r\n\tleft: 0;\r\n\t}\r\n\r\n.leaflet-control {\r\n\tfloat: left;\r\n\tclear: both;\r\n\t}\r\n\r\n.leaflet-right .leaflet-control {\r\n\tfloat: right;\r\n\t}\r\n\r\n.leaflet-top .leaflet-control {\r\n\tmargin-top: 10px;\r\n\t}\r\n\r\n.leaflet-bottom .leaflet-control {\r\n\tmargin-bottom: 10px;\r\n\t}\r\n\r\n.leaflet-left .leaflet-control {\r\n\tmargin-left: 10px;\r\n\t}\r\n\r\n.leaflet-right .leaflet-control {\r\n\tmargin-right: 10px;\r\n\t}\r\n\r\n/* zoom and fade animations */\r\n\r\n.leaflet-fade-anim .leaflet-tile {\r\n\twill-change: opacity;\r\n\t}\r\n\r\n.leaflet-fade-anim .leaflet-popup {\r\n\topacity: 0;\r\n\t-webkit-transition: opacity 0.2s linear;\r\n\t transition: opacity 0.2s linear;\r\n\t}\r\n\r\n.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {\r\n\topacity: 1;\r\n\t}\r\n\r\n.leaflet-zoom-animated {\r\n\t-webkit-transform-origin: 0 0;\r\n\t transform-origin: 0 0;\r\n\t}\r\n\r\n.leaflet-zoom-anim .leaflet-zoom-animated {\r\n\twill-change: transform;\r\n\t}\r\n\r\n.leaflet-zoom-anim .leaflet-zoom-animated {\r\n\t-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);\r\n\t transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);\r\n\t transition: transform 0.25s cubic-bezier(0,0,0.25,1);\r\n\t transition: transform 0.25s cubic-bezier(0,0,0.25,1), -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);\r\n\t}\r\n\r\n.leaflet-zoom-anim .leaflet-tile,\r\n.leaflet-pan-anim .leaflet-tile {\r\n\t-webkit-transition: none;\r\n\t transition: none;\r\n\t}\r\n\r\n.leaflet-zoom-anim .leaflet-zoom-hide {\r\n\tvisibility: hidden;\r\n\t}\r\n\r\n/* cursors */\r\n\r\n.leaflet-interactive {\r\n\tcursor: pointer;\r\n\t}\r\n\r\n.leaflet-grab {\r\n\tcursor: -webkit-grab;\r\n\tcursor: grab;\r\n\t}\r\n\r\n.leaflet-crosshair,\r\n.leaflet-crosshair .leaflet-interactive {\r\n\tcursor: crosshair;\r\n\t}\r\n\r\n.leaflet-popup-pane,\r\n.leaflet-control {\r\n\tcursor: auto;\r\n\t}\r\n\r\n.leaflet-dragging .leaflet-grab,\r\n.leaflet-dragging .leaflet-grab .leaflet-interactive,\r\n.leaflet-dragging .leaflet-marker-draggable {\r\n\tcursor: move;\r\n\tcursor: -webkit-grabbing;\r\n\tcursor: grabbing;\r\n\t}\r\n\r\n/* marker & overlays interactivity */\r\n\r\n.leaflet-marker-icon,\r\n.leaflet-marker-shadow,\r\n.leaflet-image-layer,\r\n.leaflet-pane > svg path,\r\n.leaflet-tile-container {\r\n\tpointer-events: none;\r\n\t}\r\n\r\n.leaflet-marker-icon.leaflet-interactive,\r\n.leaflet-image-layer.leaflet-interactive,\r\n.leaflet-pane > svg path.leaflet-interactive {\r\n\tpointer-events: visiblePainted; /* IE 9-10 doesn't have auto */\r\n\tpointer-events: auto;\r\n\t}\r\n\r\n/* visual tweaks */\r\n\r\n.leaflet-container {\r\n\tbackground: #ddd;\r\n\toutline: 0;\r\n\t}\r\n\r\n.leaflet-container a {\r\n\tcolor: #0078A8;\r\n\t}\r\n\r\n.leaflet-container a.leaflet-active {\r\n\toutline: 2px solid orange;\r\n\t}\r\n\r\n.leaflet-zoom-box {\r\n\tborder: 2px dotted #38f;\r\n\tbackground: rgba(255,255,255,0.5);\r\n\t}\r\n\r\n/* general typography */\r\n\r\n.leaflet-container {\r\n\tfont: 12px/1.5 \"Helvetica Neue\", Arial, Helvetica, sans-serif;\r\n\t}\r\n\r\n/* general toolbar styles */\r\n\r\n.leaflet-bar {\r\n\t-webkit-box-shadow: 0 1px 5px rgba(0,0,0,0.65);\r\n\t box-shadow: 0 1px 5px rgba(0,0,0,0.65);\r\n\tborder-radius: 4px;\r\n\t}\r\n\r\n.leaflet-bar a,\r\n.leaflet-bar a:hover {\r\n\tbackground-color: #fff;\r\n\tborder-bottom: 1px solid #ccc;\r\n\twidth: 26px;\r\n\theight: 26px;\r\n\tline-height: 26px;\r\n\tdisplay: block;\r\n\ttext-align: center;\r\n\ttext-decoration: none;\r\n\tcolor: black;\r\n\t}\r\n\r\n.leaflet-bar a,\r\n.leaflet-control-layers-toggle {\r\n\tbackground-position: 50% 50%;\r\n\tbackground-repeat: no-repeat;\r\n\tdisplay: block;\r\n\t}\r\n\r\n.leaflet-bar a:hover {\r\n\tbackground-color: #f4f4f4;\r\n\t}\r\n\r\n.leaflet-bar a:first-child {\r\n\tborder-top-left-radius: 4px;\r\n\tborder-top-right-radius: 4px;\r\n\t}\r\n\r\n.leaflet-bar a:last-child {\r\n\tborder-bottom-left-radius: 4px;\r\n\tborder-bottom-right-radius: 4px;\r\n\tborder-bottom: none;\r\n\t}\r\n\r\n.leaflet-bar a.leaflet-disabled {\r\n\tcursor: default;\r\n\tbackground-color: #f4f4f4;\r\n\tcolor: #bbb;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-bar a {\r\n\twidth: 30px;\r\n\theight: 30px;\r\n\tline-height: 30px;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-bar a:first-child {\r\n\tborder-top-left-radius: 2px;\r\n\tborder-top-right-radius: 2px;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-bar a:last-child {\r\n\tborder-bottom-left-radius: 2px;\r\n\tborder-bottom-right-radius: 2px;\r\n\t}\r\n\r\n/* zoom control */\r\n\r\n.leaflet-control-zoom-in,\r\n.leaflet-control-zoom-out {\r\n\tfont: bold 18px 'Lucida Console', Monaco, monospace;\r\n\ttext-indent: 1px;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {\r\n\tfont-size: 22px;\r\n\t}\r\n\r\n/* layers control */\r\n\r\n.leaflet-control-layers {\r\n\t-webkit-box-shadow: 0 1px 5px rgba(0,0,0,0.4);\r\n\t box-shadow: 0 1px 5px rgba(0,0,0,0.4);\r\n\tbackground: #fff;\r\n\tborder-radius: 5px;\r\n\t}\r\n\r\n.leaflet-control-layers-toggle {\r\n\tbackground-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC);\r\n\twidth: 36px;\r\n\theight: 36px;\r\n\t}\r\n\r\n.leaflet-retina .leaflet-control-layers-toggle {\r\n\tbackground-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=);\r\n\tbackground-size: 26px 26px;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-control-layers-toggle {\r\n\twidth: 44px;\r\n\theight: 44px;\r\n\t}\r\n\r\n.leaflet-control-layers .leaflet-control-layers-list,\r\n.leaflet-control-layers-expanded .leaflet-control-layers-toggle {\r\n\tdisplay: none;\r\n\t}\r\n\r\n.leaflet-control-layers-expanded .leaflet-control-layers-list {\r\n\tdisplay: block;\r\n\tposition: relative;\r\n\t}\r\n\r\n.leaflet-control-layers-expanded {\r\n\tpadding: 6px 10px 6px 6px;\r\n\tcolor: #333;\r\n\tbackground: #fff;\r\n\t}\r\n\r\n.leaflet-control-layers-scrollbar {\r\n\toverflow-y: scroll;\r\n\toverflow-x: hidden;\r\n\tpadding-right: 5px;\r\n\t}\r\n\r\n.leaflet-control-layers-selector {\r\n\tmargin-top: 2px;\r\n\tposition: relative;\r\n\ttop: 1px;\r\n\t}\r\n\r\n.leaflet-control-layers label {\r\n\tdisplay: block;\r\n\t}\r\n\r\n.leaflet-control-layers-separator {\r\n\theight: 0;\r\n\tborder-top: 1px solid #ddd;\r\n\tmargin: 5px -10px 5px -6px;\r\n\t}\r\n\r\n/* Default icon URLs */\r\n\r\n.leaflet-default-icon-path {\r\n\tbackground-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=);\r\n\t}\r\n\r\n/* attribution and scale controls */\r\n\r\n.leaflet-container .leaflet-control-attribution {\r\n\tbackground: #fff;\r\n\tbackground: rgba(255, 255, 255, 0.7);\r\n\tmargin: 0;\r\n\t}\r\n\r\n.leaflet-control-attribution,\r\n.leaflet-control-scale-line {\r\n\tpadding: 0 5px;\r\n\tcolor: #333;\r\n\t}\r\n\r\n.leaflet-control-attribution a {\r\n\ttext-decoration: none;\r\n\t}\r\n\r\n.leaflet-control-attribution a:hover {\r\n\ttext-decoration: underline;\r\n\t}\r\n\r\n.leaflet-container .leaflet-control-attribution,\r\n.leaflet-container .leaflet-control-scale {\r\n\tfont-size: 11px;\r\n\t}\r\n\r\n.leaflet-left .leaflet-control-scale {\r\n\tmargin-left: 5px;\r\n\t}\r\n\r\n.leaflet-bottom .leaflet-control-scale {\r\n\tmargin-bottom: 5px;\r\n\t}\r\n\r\n.leaflet-control-scale-line {\r\n\tborder: 2px solid #777;\r\n\tborder-top: none;\r\n\tline-height: 1.1;\r\n\tpadding: 2px 5px 1px;\r\n\tfont-size: 11px;\r\n\twhite-space: nowrap;\r\n\toverflow: hidden;\r\n\t-webkit-box-sizing: border-box;\r\n\t box-sizing: border-box;\r\n\r\n\tbackground: #fff;\r\n\tbackground: rgba(255, 255, 255, 0.5);\r\n\t}\r\n\r\n.leaflet-control-scale-line:not(:first-child) {\r\n\tborder-top: 2px solid #777;\r\n\tborder-bottom: none;\r\n\tmargin-top: -2px;\r\n\t}\r\n\r\n.leaflet-control-scale-line:not(:first-child):not(:last-child) {\r\n\tborder-bottom: 2px solid #777;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-control-attribution,\r\n.leaflet-touch .leaflet-control-layers,\r\n.leaflet-touch .leaflet-bar {\r\n\t-webkit-box-shadow: none;\r\n\t box-shadow: none;\r\n\t}\r\n\r\n.leaflet-touch .leaflet-control-layers,\r\n.leaflet-touch .leaflet-bar {\r\n\tborder: 2px solid rgba(0,0,0,0.2);\r\n\tbackground-clip: padding-box;\r\n\t}\r\n\r\n/* popup */\r\n\r\n.leaflet-popup {\r\n\tposition: absolute;\r\n\ttext-align: center;\r\n\tmargin-bottom: 20px;\r\n\t}\r\n\r\n.leaflet-popup-content-wrapper {\r\n\tpadding: 1px;\r\n\ttext-align: left;\r\n\tborder-radius: 12px;\r\n\t}\r\n\r\n.leaflet-popup-content {\r\n\tmargin: 13px 19px;\r\n\tline-height: 1.4;\r\n\t}\r\n\r\n.leaflet-popup-content p {\r\n\tmargin: 18px 0;\r\n\t}\r\n\r\n.leaflet-popup-tip-container {\r\n\twidth: 40px;\r\n\theight: 20px;\r\n\tposition: absolute;\r\n\tleft: 50%;\r\n\tmargin-left: -20px;\r\n\toverflow: hidden;\r\n\tpointer-events: none;\r\n\t}\r\n\r\n.leaflet-popup-tip {\r\n\twidth: 17px;\r\n\theight: 17px;\r\n\tpadding: 1px;\r\n\r\n\tmargin: -10px auto 0;\r\n\r\n\t-webkit-transform: rotate(45deg);\r\n\t transform: rotate(45deg);\r\n\t}\r\n\r\n.leaflet-popup-content-wrapper,\r\n.leaflet-popup-tip {\r\n\tbackground: white;\r\n\tcolor: #333;\r\n\t-webkit-box-shadow: 0 3px 14px rgba(0,0,0,0.4);\r\n\t box-shadow: 0 3px 14px rgba(0,0,0,0.4);\r\n\t}\r\n\r\n.leaflet-container a.leaflet-popup-close-button {\r\n\tposition: absolute;\r\n\ttop: 0;\r\n\tright: 0;\r\n\tpadding: 4px 4px 0 0;\r\n\tborder: none;\r\n\ttext-align: center;\r\n\twidth: 18px;\r\n\theight: 14px;\r\n\tfont: 16px/14px Tahoma, Verdana, sans-serif;\r\n\tcolor: #c3c3c3;\r\n\ttext-decoration: none;\r\n\tfont-weight: bold;\r\n\tbackground: transparent;\r\n\t}\r\n\r\n.leaflet-container a.leaflet-popup-close-button:hover {\r\n\tcolor: #999;\r\n\t}\r\n\r\n.leaflet-popup-scrolled {\r\n\toverflow: auto;\r\n\tborder-bottom: 1px solid #ddd;\r\n\tborder-top: 1px solid #ddd;\r\n\t}\r\n\r\n.leaflet-oldie .leaflet-popup-content-wrapper {\r\n\tzoom: 1;\r\n\t}\r\n\r\n.leaflet-oldie .leaflet-popup-tip {\r\n\twidth: 24px;\r\n\tmargin: 0 auto;\r\n\r\n\t-ms-filter: \"progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)\";\r\n\tfilter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);\r\n\t}\r\n\r\n.leaflet-oldie .leaflet-popup-tip-container {\r\n\tmargin-top: -1px;\r\n\t}\r\n\r\n.leaflet-oldie .leaflet-control-zoom,\r\n.leaflet-oldie .leaflet-control-layers,\r\n.leaflet-oldie .leaflet-popup-content-wrapper,\r\n.leaflet-oldie .leaflet-popup-tip {\r\n\tborder: 1px solid #999;\r\n\t}\r\n\r\n/* div icon */\r\n\r\n.leaflet-div-icon {\r\n\tbackground: #fff;\r\n\tborder: 1px solid #666;\r\n\t}\r\n\r\n/* Tooltip */\r\n\r\n/* Base styles for the element that has a tooltip */\r\n\r\n.leaflet-tooltip {\r\n\tposition: absolute;\r\n\tpadding: 6px;\r\n\tbackground-color: #fff;\r\n\tborder: 1px solid #fff;\r\n\tborder-radius: 3px;\r\n\tcolor: #222;\r\n\twhite-space: nowrap;\r\n\t-webkit-user-select: none;\r\n\t-moz-user-select: none;\r\n\t-ms-user-select: none;\r\n\tuser-select: none;\r\n\tpointer-events: none;\r\n\t-webkit-box-shadow: 0 1px 3px rgba(0,0,0,0.4);\r\n\t box-shadow: 0 1px 3px rgba(0,0,0,0.4);\r\n\t}\r\n\r\n.leaflet-tooltip.leaflet-clickable {\r\n\tcursor: pointer;\r\n\tpointer-events: auto;\r\n\t}\r\n\r\n.leaflet-tooltip-top:before,\r\n.leaflet-tooltip-bottom:before,\r\n.leaflet-tooltip-left:before,\r\n.leaflet-tooltip-right:before {\r\n\tposition: absolute;\r\n\tpointer-events: none;\r\n\tborder: 6px solid transparent;\r\n\tbackground: transparent;\r\n\tcontent: \"\";\r\n\t}\r\n\r\n/* Directions */\r\n\r\n.leaflet-tooltip-bottom {\r\n\tmargin-top: 6px;\r\n}\r\n\r\n.leaflet-tooltip-top {\r\n\tmargin-top: -6px;\r\n}\r\n\r\n.leaflet-tooltip-bottom:before,\r\n.leaflet-tooltip-top:before {\r\n\tleft: 50%;\r\n\tmargin-left: -6px;\r\n\t}\r\n\r\n.leaflet-tooltip-top:before {\r\n\tbottom: 0;\r\n\tmargin-bottom: -12px;\r\n\tborder-top-color: #fff;\r\n\t}\r\n\r\n.leaflet-tooltip-bottom:before {\r\n\ttop: 0;\r\n\tmargin-top: -12px;\r\n\tmargin-left: -6px;\r\n\tborder-bottom-color: #fff;\r\n\t}\r\n\r\n.leaflet-tooltip-left {\r\n\tmargin-left: -6px;\r\n}\r\n\r\n.leaflet-tooltip-right {\r\n\tmargin-left: 6px;\r\n}\r\n\r\n.leaflet-tooltip-left:before,\r\n.leaflet-tooltip-right:before {\r\n\ttop: 50%;\r\n\tmargin-top: -6px;\r\n\t}\r\n\r\n.leaflet-tooltip-left:before {\r\n\tright: 0;\r\n\tmargin-right: -12px;\r\n\tborder-left-color: #fff;\r\n\t}\r\n\r\n.leaflet-tooltip-right:before {\r\n\tleft: 0;\r\n\tmargin-left: -12px;\r\n\tborder-right-color: #fff;\r\n\t}\r\n"]}
--------------------------------------------------------------------------------