├── .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 | 3 | 4 | 5 | 6 | 7 | 8 | 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 | 3 | 4 | 5 | 6 | 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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 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 | Vue.JS 5 | Node.JS 6 | Redis 7 | Lua 8 | Jest 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 | [![Stargazers repo roster for @The-Anton/Redis-Solar](https://reporoster.com/stars/The-Anton/Redis-Solar)](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 | 3 | 4 | 5 | 6 | 7 | 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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /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"]} --------------------------------------------------------------------------------