diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..9b7d435 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "stage-0", "react"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a1f39d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +server/bin/ +client/build/ +shared/data/ diff --git a/client/api/energy_data.js b/client/api/energy_data.js new file mode 100644 index 0000000..2c0706c --- /dev/null +++ b/client/api/energy_data.js @@ -0,0 +1,19 @@ +import extend from 'extend'; + +const ENDPOINT = '/data/v1/energy'; + +class EnergyDataApi { + + static index(params){ + return jQuery.ajax({ + url: ENDPOINT + '?' + jQuery.param(params), + type: 'GET', + dataType: 'json' + }).then((res)=>{ + return res.data; + }); + } + +} + +export default EnergyDataApi; diff --git a/client/api/houses.js b/client/api/houses.js new file mode 100644 index 0000000..eb165af --- /dev/null +++ b/client/api/houses.js @@ -0,0 +1,19 @@ +import extend from 'extend'; + +const ENDPOINT = '/data/v1/houses'; + +class HousesApi { + + static index(params){ + return jQuery.ajax({ + url: ENDPOINT + '?' + jQuery.param(params), + type: 'GET', + dataType: 'json' + }).then((res)=>{ + return res.data; + }); + } + +} + +export default HousesApi; diff --git a/client/api/power_data.js b/client/api/power_data.js new file mode 100644 index 0000000..d8df20e --- /dev/null +++ b/client/api/power_data.js @@ -0,0 +1,20 @@ +const ENDPOINT = '/data/v1/power'; +import extend from 'extend'; + +// send all date parameters as unix timestamps; +class PowerDataApi { + + static index(params){ + return jQuery.ajax({ + url: ENDPOINT + '?' + jQuery.param(params), + type: 'GET', + dataType: 'json' + }).then((res)=>{ + return res.data; + }); + } + +} + +export default PowerDataApi; + diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..c938854 --- /dev/null +++ b/client/app.js @@ -0,0 +1,11 @@ +import 'babel-polyfill'; +import 'bootstrap/dist/js/bootstrap.min'; + +import React from 'react'; +import ReactDOM from 'react-dom'; +import Layout from './dashboard/layout/layout'; + +ReactDOM.render( + React.createElement(Layout), + document.getElementById('root') +); diff --git a/client/config/api.js b/client/config/api.js new file mode 100644 index 0000000..cc0bafa --- /dev/null +++ b/client/config/api.js @@ -0,0 +1 @@ +api.js diff --git a/client/config/db.js b/client/config/db.js new file mode 100644 index 0000000..c974c4b --- /dev/null +++ b/client/config/db.js @@ -0,0 +1,9 @@ +import Loki from 'lokijs'; + +var db = new Loki('spike'); + +db.addCollection('PowerData'); +db.addCollection('EnergyData'); +db.addCollection('Houses'); + +export default diff --git a/client/config/store.js b/client/config/store.js new file mode 100644 index 0000000..b1c4591 --- /dev/null +++ b/client/config/store.js @@ -0,0 +1 @@ +store.js diff --git a/client/d3/bar/base.js b/client/d3/bar/base.js new file mode 100644 index 0000000..1374492 --- /dev/null +++ b/client/d3/bar/base.js @@ -0,0 +1,48 @@ +import extend from 'extend'; + +// This class is inspired by // http://bl.ocks.org/mbostock/3885304. +class BarChart { + + get chart_options(){ + return { + series_opacity_gradient: true, + margin: {top: 0, left: 70, bottom: 50, right: 20} + }; + } + + serializeData(data){ + var bar_chart = this, + serialized_data = { + max: undefined, + series: [] + }; + + data.forEach(function(data_set){ + var series = extend({ + css_class: bar_chart.toClass ? bar_chart.toClass(data_set) : "", + title: bar_chart.titleize ? bar_chart.titleize(data_set) : "" + }, data_set); + series.total = 0; + series.values = []; + data_set.values.forEach(function(datum, j){ + var series_datum = extend({ + name: datum.name, + value: datum.value, + cummulative: series.total, + css_class: bar_chart.toClass ? bar_chart.toClass(data_set, datum) : "", + title: bar_chart.titleize ? bar_chart.titleize(data_set, datum) : "", + opacity: 1.0 - 0.5 * (j / data_set.values.length) + }, datum); + series_datum.series = series; + series.total += datum.value; + series.values.push(series_datum); + }); + serialized_data.series.push(series); + serialized_data.max = serialized_data.max === undefined ? + series.total : + Math.max(serialized_data.max, Math.abs(series.total)); + }); + return serialized_data; + }; + +} diff --git a/client/d3/bar/composite.js b/client/d3/bar/composite.js new file mode 100644 index 0000000..3b41cb9 --- /dev/null +++ b/client/d3/bar/composite.js @@ -0,0 +1,135 @@ +import Chart from './../base'; + +class BarLineChart extends Chart { + + get chart_options(){ + return Object.assign(Chart.DEFAULTS, { + }); + } + + afterAxes(){ + var chart = this; + line_chart.fnLine = d3.svg.line() + .interpolate(line_chart.chart_options.interpolation) + .x(function(d){ return line_chart.x_scale(d[line_chart.domain_attr]); }) + .y(function(d){ return line_chart.y_scale_right(d[line_chart.line_attr]); }); + } + + defineAxes(){ + var chart = this; + + // Axes Left + chart.y_scale_left = d3.scale.linear() + .range([chart.height, 0]); + chart.y_axis_left = d3.svg.axis() + .scale(chart.y_scale_left) + .orient("left") + .outerTickSize(0); + + // Axes Right + chart.y_scale_right = d3.scale.linear() + .range([chart.height, 0]); + chart.y_axis_left = d3.svg.axis() + .scale(chart.y_scale_right) + .orient("right") + .outerTickSize(0); + + chart.x_scale = d3.scale.ordinal() + .rangeRoundBands([chart.height, 0], 0.1); + + chart.x_axis = d3.svg.axis() + .scale(chart.x_scale) + .orient("bottom") + .outerTickSize(0) + //chart.x_axis.tickFormat(d3.time.format('%b %d at %H')) + //chart.x_axis.ticks(d3.time.hour, 12); + + // append axis groups. + chart.svg.append("g") + .attr("class", "d3-chart-range d3-chart-range-left d3-chart-axis"); + chart.svg.append("g") + .attr("class", "d3-chart-range d3-chart-range-right d3-chart-axis"); + chart.svg.append("g") + .attr("class", "d3-chart-domain d3-chart-axis") + .attr("transform", "translate(0, " + (chart.height) + ")"); + } + + defineDomain(domain){ + var chart = this; + + chart.domain = domain; + chart.x_scale.domain(domain); + chart.svg.select(".d3-chart-domain") + .call(chart.x_axis); + .selectAll("text") + .attr("transform", function(){ + var elem = this, + bbox = elem.getBBox(), + middleX = bbox.x + (bbox.width / 2), + middleY = bbox.y + (bbox.height / 2); + return "rotate(-30," + middleX + "," + middleY + ")"; + }); + } + + drawBarData(data){ + var chart = this; + data = chart.serializeBarData(data); + + chart.y_scale_left.domain(data.range_extent); + chart.svg.select(".d3-chart-range").call(chart.y_axis_left); + + data.series.forEach(function(series){ + var filtered_values = series.values.filter((value){ return chart.domain.indexOf(value[chart.domain_attr]) < 0; }) + bars = chart.svg.selectAll(".d3-chart-bar") + .data(series.values); + chart.applyData(series, bars.enter().append("rect")); + chart.applyData(series, bars.transition()); + bars.exit().remove(); + }); + } + + // helper method for drawData + applyBarData(series, elements){ + var chart = this, + series_class = "d3-chart-bar " + series.css_class; + elements + .attr("class", function(d){ return series_class + " " + d.css_class; }) + .attr("title", function(d){ return d.title; }) + .attr("width", chart.x_scale.rangeBand()) + .attr("x", chart.x_scale(series.title)) + .attr("height", return chart.y_scale(d[chart.bar_attr])) + .attr("y", function(d) { return chart.y_scale(d.cummulative); }) + .attr('fill', function(d){ return chart.fnColor(d.title); }); + } + + drawLineData(data){ + var chart = this, + nested_extent = chart.nestedExtent(data.series, 'values', chart.domain_attr, chart.line_attr); + + // calibrate axes + bar_chart.y_scale_right.domain([Math.min(0, nested_extent.range_min), nested_extent.range_max]); + bar_chart.svg.select(".d3-chart-range-right") + .call(bar_chart.y_axis_right); + + // draw lines + var line = g.selectAll(".d3-chart-line") + .data(data.series); + + [line.enter().append('g'), line.transition()].forEach((groups)=>{ + line_chart.applyLineData(groups, data.series); + }); + line.exit().remove(); + } + + applyLineData(groups){ + var chart = this; + groups + .attr('class', function(series){ return "d3-chart-line " + chart.cssClass(series); }) + .attr("title", function(series){ return series.title; }) + .append("path") + .attr("d", function(series){ return chart.fnLine(series.values.filter((value)=>{ return chart.domain.indexOf(value[chart.domain_attr]) < 0; })); }) + .style("stroke", function(series){ return line_chart.fnColor(series.title); }); + + } + +} diff --git a/client/d3/bar/horizontal.js b/client/d3/bar/horizontal.js new file mode 100644 index 0000000..4165473 --- /dev/null +++ b/client/d3/bar/horizontal.js @@ -0,0 +1,76 @@ +import BarChart from './bar.base'; + +class HorizontalBarChart extends BarChart { + + defineAxes(){ + var bar_chart = this; + bar_chart.y_scale = d3.scale.ordinal() + .rangeRoundBands([bar_chart.height, 0], 0.1); + + bar_chart.y_axis = d3.svg.axis() + .scale(bar_chart.y_scale) + .orient("left"); + + bar_chart.x_scale = d3.scale.linear() + .range([0, bar_chart.width]); + + bar_chart.x_axis = d3.svg.axis() + .scale(bar_chart.x_scale) + .orient("bottom") + .ticks(bar_chart.range_ticks) + .outerTickSize(0); + + // append axes + bar_chart.svg.append("g") + .attr("class", "d3-chart-domain d3-chart-axis"); + bar_chart.svg.append("g") + .attr("class", "d3-chart-range d3-chart-axis") + .attr("transform", "translate(0, " + (bar_chart.height - bar_chart.margin.top) + ")"); + } + + drawData(data){ + var bar_chart = this; + data = bar_chart.serializeData(data); + + // calibrate axes + bar_chart.y_scale.domain(data.series.reverse().map(function(d) { return d.name; })); + bar_chart.svg.select(".d3-chart-domain.d3-chart-axis") + .call(bar_chart.y_axis) + .selectAll("text") + .attr("transform", function(){ + var elem = this, + bbox = elem.getBBox(), + middleX = bbox.x + (bbox.width / 2), + middleY = bbox.y + (bbox.height / 2); + return "rotate(-30,"+middleX + "," + middleY+")"; + }); + + bar_chart.x_scale.domain([0, data.max]); + bar_chart.svg.select(".d3-chart-range.d3-chart-axis").call(bar_chart.x_axis); + + data.series.forEach(function(series){ + var bars = bar_chart.svg.selectAll("d3-chart-rect.d3-chart-bar." + series.css_class) + .data(series.values); + bar_chart.applyData(series, bars.enter().append("rect")); + bar_chart.applyData(series, bars.transition()); + bars.exit().remove(); + }); + } + + // helper method for drawData. + applyData(series, elements){ + var bar_chart = this, + series_class = "d3-chart-bar " + series.css_class; + elements + .attr("class", function(d){ return series_class + " " + d.css_class; }) + .attr("title", function(d){ return d.title; }) + .attr("y", function(d) { return bar_chart.y_scale(series.name); }) + .attr("height", bar_chart.y_scale.rangeBand()) + .attr("x", function(d) { return bar_chart.x_scale(d.cummulative); }) + .attr("width", function(d) { return bar_chart.x_scale(d.value); }) + .attr("opacity", function(d) { return d.opacity; }); + } + +} + +export default HorizontalBarChart; diff --git a/client/d3/bar/vertical.js b/client/d3/bar/vertical.js new file mode 100644 index 0000000..81a2d5f --- /dev/null +++ b/client/d3/bar/vertical.js @@ -0,0 +1,76 @@ +import BarChart from './bar.base'; + +class VerticalBarChart extends BarChart { + + defineAxes(){ + var bar_chart = this; + bar_chart.y_scale = d3.scale.ordinal() + .rangeRoundBands([bar_chart.height, 0], 0.1); + + bar_chart.y_axis = d3.svg.axis() + .scale(bar_chart.y_scale) + .ticks(bar_chart.range_ticks) + .orient("left") + .outerTickSize(0); + + bar_chart.x_scale = d3.scale.linear() + .range([0, bar_chart.width]); + + bar_chart.x_axis = d3.svg.axis() + .scale(bar_chart.x_scale) + .orient("bottom"); + + // append axes + bar_chart.svg.append("g") + .attr("class", "d3-chart-range d3-chart-axis"); + bar_chart.svg.append("g") + .attr("class", "d3-chart-domain d3-chart-axis") + .attr("transform", "translate(0, " + (bar_chart.height - bar_chart.margin.top) + ")"); + } + + drawData(data){ + var bar_chart = this; + data = bar_chart.serializeData(data); + + // calibrate axes + bar_chart.x_scale.domain(data.series.reverse().map((d)=>{ return d.name; })); + bar_chart.svg.select(".d3-chart-domain.d3-chart-axis") + .call(bar_chart.x_axis) + .selectAll("text") + .attr("transform", function(){ + var elem = this, + bbox = elem.getBBox(), + middleX = bbox.x + (bbox.width / 2), + middleY = bbox.y + (bbox.height / 2); + return "rotate(-30,"+middleX + "," + middleY+")"; + }); + + bar_chart.y_scale.domain([data.min, data.max]); + bar_chart.svg.select(".d3-chart-range.d3-chart-axis").call(bar_chart.y_axis); + + data.series.forEach(function(series){ + var bars = bar_chart.svg.selectAll(".d3-chart-rect.d3-chart-bar." + series.css_class) + .data(series.values); + bar_chart.applyData(series, bars.enter().append("rect")); + bar_chart.applyData(series, bars.transition()); + bars.exit().remove(); + }); + } + + // helper method for drawData + applyData(series, elements){ + var bar_chart = this, + series_class = "d3-chart-bar " + series.css_class; + elements + .attr("class", function(d){ return series_class + " " + d.css_class; }) + .attr("title", function(d){ return d.title; }) + .attr("width", function(d) { return bar_chart.x_scale.rangeBand(); }) + .attr("x", function(d) { return bar_chart.x_scale(series.name); }) + .attr("height", return bar_chart.y_scale(d.value)) + .attr("y", function(d) { return bar_chart.y_scale(d.cummulative); }) + .attr("opacity", function(d) { return d.opacity; }); + } + +} + +export default VerticalBarChart; diff --git a/client/d3/base.js b/client/d3/base.js new file mode 100644 index 0000000..e071906 --- /dev/null +++ b/client/d3/base.js @@ -0,0 +1,75 @@ +import extend from 'extend'; + +const DEFAULTS = { + outer_width: 500, + outer_height: 300, + margin: {top: 30, left: 70, bottom: 50, right: 20}, + domain_ticks: 10, + range_ticks: 8, + container: "container", + time_series: false, + range_label: undefined, + domain_attr: undefined, + range_attr: undefined, + toCssClass: function(series){ + return series ? series.title.toLowerCase().replace(/\s+/g, '-') : ""; + } +}; + + +class Chart { + + constructor(options){ + var chart = this; + chart = extend(chart, chart.chart_options, options); + + chart.height = chart.outer_height - chart.margin.top - chart.margin.bottom; + chart.width = chart.outer_width - chart.margin.left - chart.margin.right; + + chart.svg = d3.select(chart.container).append("svg") + .attr("width", chart.outer_width) + .attr("height", chart.outer_height) + .append("g") + .attr("transform", "translate(" + chart.margin.left + "," + chart.margin.top + ")"); + chart.defineAxes(); + if (chart.afterAxes) chart.afterAxes(); + } + + cssClass(series){ + var chart = this; + if (!chart.toCssClass) return ''; + return chart.toCssClass(series); + } + + nestedExtent(a, series_values, domain_attr, range_attr){ + var extent = { + min_domain: Infinity, + max_domain: -Infinity, + min_range: Infinity, + max_range: -Infinity + }; + a.forEach((series)=>{ + series[series_values].forEach((value)=>{ + extent.min_domain = Math.min(min_domain, value[domain_attr]); + extent.max_domain = Math.max(max_domain, value[domain_attr]); + extent.min_range = Math.min(min_range, value[range_attr]); + extent.max_range = Math.max(max_range, value[range_attr]); + }); + }); + return extent; + } + + titleize(s){ + var words = s.split(' '), + array = []; + for (var i=0; i{ return n+1; })) + .rangeRoundBands([0, grid_chart.width], grid_chart.grid_padding, 0); + + grid_chart.x_axis = d3.svg.axis() + .scale(grid_chart.x_scale) + .orient("top") + .outerTickSize(0); + + // append x axis + grid_chart.svg.append("g").attr("class", "d3-chart-domain d3-chart-axis"); + } + + afterAxes(){ + var grid_chart = this; + grid_chart.grid_unit_size = grid_chart.width / 31 - grid_chart.grid_padding * grid_chart.width / 30; + + if (grid_chart.display_date_format) grid_chart.displayDate = d3.time.format(grid_chart.display_date_format); + + if (!grid_chart.toDate && grid_chart.parse_date_format){ + grid_chart.parseDate = d3.time.format(grid_chart.parse_date_format); + grid_chart.toDate = (datum)=>{ + grid_chart.parseDate(datum[grid_chart.date_attr]); + } + } else if (!grid_chart.toDate){ + grid_chart.toDate = (datum)=>{ return datum[grid_chart.date_attr] }; + } + + + grid_chart.monthFormat = d3.time.format('%B %Y'); + grid_chart.toMonthString = (datum)=>{ + return grid_chart.monthFormat(grid_chart.toDate(datum)); + } + } + + serializeData(data){ + var grid_chart = this; + data.css_class = data.css_class || grid_chart.toClass ? grid_chart.toClass(data) : ""; + + grid_chart.rangeValue = grid_chart.range_attr ? function(d){ return d[grid_chart.range_attr]; } : grid_chart.rangeValue; + + data.months = []; + if (data.min_range !== undefined && data.max_range !== undefined){ + data.range = {min: data.min_range, max: data.max_range}; + data.values.forEach((value)=>{ + var date = grid_chart.toDate(value), + date_s = grid_chart.monthFormat(date); + if (data.months.indexOf(date_s) < 0) data.months.push(date_s); + }); + } else { + var min_range = Infinity, + max_range = -Infinity; + data.values.forEach((value)=>{ + var date = grid_chart.toDate(value), + date_s = grid_chart.monthFormat(date), + range_value =grid_chart.rangeValue(value); + min_range = Math.min(min_range, range_value); + max_range = Math.max(max_range, range_value); + if (data.months.indexOf(date_s) < 0) data.months.push(date_s); + }); + if (grid_chart.min_range_zero) min_range = Math.min(min_range, 0); + data.range = { min: min_range, max: max_range }; + } + data.range.diff = data.range.max - data.range.min; + + data.months = data.months.sort((date_s1, date_s2)=>{ + var date1 = grid_chart.monthFormat.parse(date_s1), + date2 = grid_chart.monthFormat.parse(date_s2); + return date1.getTime() - date2.getTime(); + }); + return data; + }; + + drawData(data){ + var grid_chart = this; + grid_chart.i = grid_chart.i || 1; + data = grid_chart.serializeData(data); + + // calibrate axes + var y_axis_height = grid_chart.grid_unit_size * (1 + grid_chart.grid_padding) * data.months.length; + grid_chart.y_scale.rangeRoundBands([0, y_axis_height], grid_chart.grid_padding, 0); + grid_chart.y_scale.domain(data.months); + grid_chart.y_axis.scale(grid_chart.y_scale); + + grid_chart.svg.select(".d3-chart-range") + .call(grid_chart.y_axis); + + grid_chart.svg.select(".d3-chart-domain").call(grid_chart.x_axis); + + var grid_units = grid_chart.svg.selectAll(".d3-chart-grid-unit") + .data(data.values); + grid_units.exit().remove(); + grid_chart.applyData(data, grid_units.enter().append("rect")); + grid_chart.applyData(data, grid_units); + } + + // helper method for drawData. + applyData(data, elements){ + var grid_chart = this, + series_class = "d3-chart-grid-unit " + data.css_class; + elements + .attr("class", series_class) + .attr("y", function(d) { + var bottom = grid_chart.y_scale(grid_chart.toMonthString(d)), + middle = grid_chart.y_scale.rangeBand() / 2 - grid_chart.grid_unit_size / 2; + return bottom + middle; + }) + .attr("height", grid_chart.grid_unit_size) + .attr("x", function(d) { + return grid_chart.x_scale(grid_chart.toDate(d).getDate()); + }) + .attr("width", function(d) { return grid_chart.grid_unit_size; }) + .attr('fill', grid_chart.color) + .attr("opacity", function(d) { return grid_chart.calculateOpacity(75, data.range); }); + } + + calculateOpacity(value, range){ + return Math.max(0, Math.min(1, 1 - (range.max - (value - range.min)) / range.diff)); + }; + +} + +export default CalendarGridChart; diff --git a/client/d3/line/line.js b/client/d3/line/line.js new file mode 100644 index 0000000..146241b --- /dev/null +++ b/client/d3/line/line.js @@ -0,0 +1,122 @@ +import extend from 'extend'; + +import Chart from './../base'; + + +// inspired by https://gist.github.com/mbostock/4b66c0d9be9a0d56484e +class LineChart extends Chart { + + get chart_options(){ + return { + interpolation: 'basis' + }; + } + + defineAxes(){ + var chart = this; + + chart.y_scale = d3.scale.linear() + .range([chart.height, 0]); + chart.y_axis = d3.svg.axis() + .scale(chart.y_scale) + .orient("left") + .outerTickSize(1); + + if (chart.time_series){ + chart.x_scale = d3.time.scale() + .range([0, chart.width]); + } else { + chart.x_scale = d3.scale.linear() + .range([0, chart.width]); + } + + chart.x_axis = d3.svg.axis() + .scale(chart.x_scale) + .orient("bottom") + .outerTickSize(0) + //chart.x_axis.tickFormat(d3.time.format('%b %d at %H')) + //chart.x_axis.ticks(d3.time.hour, 12); + + // append axes + chart.svg.append("g") + .attr("class", "d3-chart-range d3-chart-axis"); + chart.svg.append("g") + .attr("class", "d3-chart-domain d3-chart-axis") + .attr("transform", "translate(0, " + (chart.height) + ")"); + } + + afterAxes(){ + var line_chart = this; + // function that draws the lines. + line_chart.line = d3.svg.line() + .interpolate(line_chart.chart_options.interpolation) + .x(function(d){ return line_chart.x_scale(d[line_chart.domain_attr]); }) + .y(function(d){ return line_chart.y_scale(d[line_chart.range_attr]); }); + + // function that returns unique color based on series_title. + line_chart.color = d3.scale.category20(); + } + + serializeData(data){ + var line_chart = this, + serialized_data = { + series: [], + range_min: Infinity, + range_max: -Infinity, + domain_min: Infinity, + domain_max: -Infinity, + }; + + data.forEach(function(data_set){ + var series = extend({ + css_class: line_chart.toClass ? line_chart.toClass(data_set) : "", + title: line_chart.titleize ? line_chart.titleize(data_set) : "", + color: '' + }, data_set); + + series.values.forEach((value)=>{ + series_data.range_min = Math.min(series_data.range_min, value[line_chart.range_attr]); + series_data.range_max = Math.max(series_data.range_max, value[line_chart.range_attr]); + series_data.domain_min = Math.min(series_data.domain_min, value[line_chart.domain_attr]); + series_data.domain_max = Math.max(series_data.domain_max, value[line_chart.domain_attr]); + }); + serialized_data.series.push(series); + }); + return serialized_data; + }; + + drawData(data){ + var line_chart = this; + data = line_chart.serialize_data; + + // calibrate axes + bar_chart.y_scale.domain([Math.min(0, data.range_min), data.range_max]); + bar_chart.svg.select(".d3-chart-range.d3-chart-axis") + .call(bar_chart.y_axis); + + bar_chart.x_scale.domain([data.domain_max, Math.min(data.domain_min)]); + bar_chart.svg.select(".d3-chart-domain.d3-chart-axis").call(bar_chart.x_axis); + + // draw lines + var line = g.selectAll(".d3-chart-series") + .data(data.series); + + [line.enter().append('g'), line.transition()].forEach((groups)=>{ + line_chart.applyData(groups); + }); + line.exit().remove(); + } + + applyData(groups){ + var line_chart = this; + groups + .attr('class', function(series){ return "d3-chart-line " + series.css_class; }) + .attr("title", function(series){ return series.title; }) + .append("path") + .attr("d", function(series){ return line_chart.line(series.values); }) + .style("stroke", function(series){ return line_chart.color(series.title); }); + } + +} + +export default LineChart; diff --git a/client/d3/line/spline.js b/client/d3/line/spline.js new file mode 100644 index 0000000..e4f6bab --- /dev/null +++ b/client/d3/line/spline.js @@ -0,0 +1,13 @@ +import LineChart from './line'; + +const INTEPOLATION = 'cardinal'; + +class SplineChart extends LineChart { + + get chart_options(){ + return { + interpolation: INTEPOLATION + } + } + +} diff --git a/client/d3/line/spline_stack.js b/client/d3/line/spline_stack.js new file mode 100644 index 0000000..390c7c7 --- /dev/null +++ b/client/d3/line/spline_stack.js @@ -0,0 +1,108 @@ +import LineChart from './line'; + +const INTERPOLATION = 'cardinal'; + +// inspired by https://bl.ocks.org/mbostock/3885211 +class SplineStackChart extends LineChart { + + get chart_options(){ + return Object.assign(Object.assign({}, LineChart.DEFAULTS), { + interpolation: INTERPOLATION + }); + } + + afterAxes(){ + var spline_stack = this; + spline_stack.fnArea = d3.svg.area() + .x(function(d, i) { return spline_stack.x_scale(d.x); }) + .y0(function(d) { return spline_stack.y_scale(d.y0); }) + .y1(function(d) { return spline_stack.y_scale(d.y0 + d.y); }) + .interpolate(spline_stack.interpolation); + + spline_stack.fnStack = d3.layout.stack() + .values(function(d) { return d.values; }); + + // function that returns unique color based on series_title. + spline_stack.fnColor = d3.scale.category20(); + } + + serializeData(data){ + var spline_stack = this, + serialized_data = { + series: [] }; + data.series.forEach(function(series, i){ + series.css_class = series.css_class || spline_stack.toClass ? spline_stack.toClass(series) : "series-" + i; + if (spline_stack.domain_attr !== 'x' && spline_stack.range_attr !== 'y'){ + series.values = series.values.map((value)=>{ + return {x: value[spline_stack.domain_attr], y: value[spline_stack.range_attr], series: series}; + }); + } + serialized_data.series.push(series); + }); + + serialized_data.series = spline_stack.fnStack(serialized_data.series); + // assume all series have same domain, use first series to establish extent. + serialized_data.domain_extent = d3.extent(serialized_data.series[0].values.map((value)=>{ return value.x; })); + // final series will have the highest y values. + serialized_data.range_max = d3.max(serialized_data.series[serialized_data.series.length - 1].values.map((value)=>{ return value.y0 + value.y; })) + + return serialized_data; + }; + + drawData(data){ + var spline_stack = this; + data = spline_stack.serializeData(data); + + // calibrate axes. + spline_stack.y_scale.domain([0, data.range_max]); + spline_stack.svg.select(".d3-chart-range.d3-chart-axis") + .call(spline_stack.y_axis); + + spline_stack.x_scale.domain(data.domain_extent); + spline_stack.svg.select(".d3-chart-domain.d3-chart-axis").call(spline_stack.x_axis); + + var stack = spline_stack.svg.selectAll(".d3-chart-spline-stack") + .data(data.series); + [stack.enter().append("path"), stack].forEach((paths)=>{ + spline_stack.applyData(paths); + }); + stack.exit().remove(); + + if (spline_stack.include_dots){ + data.series.forEach((series)=>{ + var dots = spline_stack.svg.selectAll(".d3-chart-spline-dot." + series.css_class) + .data(series.values); + [dots.enter().append("circle"), dots].forEach((circles)=>{ + spline_stack.applyDots(series, circles); + }); + dots.exit().remove(); + }); + } + } + + applyData(paths){ + var spline_stack = this; + paths + .attr("class", function(series){ return "d3-chart-spline-stack " + series.css_class;}) + .attr("d", function(series){ return spline_stack.fnArea(series.values); }) + .style("fill", function(series){ return spline_stack.fnColor(series.title); }) + .attr('opacity', 1); + } + + applyDots(series, circles){ + var spline_stack = this; + circles + .attr('class', 'd3-chart-spline-dot ' + series.css_class) + .attr("r", 2) + .attr("cx", function(d, i){ return spline_stack.x_scale(d.x); }) + .attr("cy", function(d, i){ return spline_stack.y_scale(d.y + d.y0); }) + .attr("title", function(d, i){ return spline_stack.titleizeDatum(series, d); }) + .style("fill", spline_stack.fnColor(series.title)) + .attr('opacity', 1) + .attr('stroke-width', 0) + .attr('stroke', '#fff'); + } + +} + +export default SplineStackChart; diff --git a/client/d3/sliders/date_range.js b/client/d3/sliders/date_range.js new file mode 100644 index 0000000..6e38231 --- /dev/null +++ b/client/d3/sliders/date_range.js @@ -0,0 +1,133 @@ +import Chart from './../base'; + +class DateRange extends Chart { + + + get chart_options(){ + return Object.assign(Object.assign({}, Chart.DEFAULTS), { + outer_width: 600, + outer_height: 250, + margin: {top: 20, left: 10, bottom: 20, right: 10}, + }); + } + + defineAxes(){ + var date_range = this; + + date_range.x_scale = d3.time.scale() + .range([0, date_range.width]) + .clamp(true); + + date_range.x_axis = d3.svg.axis() + .scale(date_range.x_scale) + .orient("bottom") + .ticks(d3.time.weeks, 1) + //.tickFormat(function(d) { return d + "°"; }) + .tickSize(1) + .outerTickSize(1) + .tickPadding(12) + + date_range.svg.append("g") + .attr("class", "d3-chart-domain") + .attr("transform", "translate(0," + date_range.height / 2 + ")"); + } + + afterAxes(){ + var date_range = this; + + date_range.slider = date_range.svg.append("g") + .attr("class", "d3-chart-slider"); + + date_range.min_handle = date_range.slider.append("circle") + .attr("class", "d3-chart-min-handle") + .attr("transform", "translate(0," + date_range.height / 2 + ")") + .attr("r", 9); + + date_range.max_handle = date_range.slider.append("circle") + .attr("class", "d3-chart-max-handle") + .attr("transform", "translate(0," + date_range.height / 2 + ")") + .attr("r", 9); + + date_range.brush = d3.svg.brush() + .x(date_range.x_scale); + + date_range.slider + .call(date_range.brush) + //.select(".background") + // .attr("height", date_range.height); + + date_range.slider.call(date_range.brush) + .selectAll(".extent,.resize") + .remove(); + } + + drawData(data){ + var date_range = this; + date_range.x_scale.domain([data.abs_min, data.abs_max]); + + date_range.svg.select(".d3-chart-domain") + .call(date_range.x_axis); + + date_range.min_handle.attr('cx', date_range.x_scale(data.current_min)); + date_range.max_handle.attr('cx', date_range.x_scale(data.current_max)); + + date_range.brush.extent([data.current_min, data.current_min]) + .on("brush", ()=>{ + DateRange.handleBrush(date_range, eval('this')); + }); + + date_range.slider + .call(date_range.brush.event) + .transition() // gratuitous intro! + .duration(750) + .call(date_range.brush.extent([data.current_min, data.current_min])) + .call(date_range.brush.event); + + } + + static handleBrush(date_range, elem){ + var date = date_range.brush.extent()[0], + current_min = parseInt(date_range.min_handle.attr('cx')), + current_max = parseInt(date_range.max_handle.attr('cx')); + + if (!current_min && !current_max) return false + if (d3.event.sourceEvent) { // not a programmatic event + date = date_range.x_scale.invert(d3.mouse(elem)[0]); + date_range.brush.extent([date, date]); + } + + var value = date_range.x_scale(date); + + if (value < current_max && value > current_min){ + if (Math.abs(value - current_min) < Math.abs(value - current_max)){ + date_range.min_handle.attr('cx', value); + current_min = value; + } else { + date_range.max_handle.attr('cx', value); + current_max = value; + } + } else if (value >= current_max){ + date_range.max_handle.attr('cx', value); + current_max = value; + if (d3.event.sourceEvent && date_range.maxDelta){ + var new_current_min = date_range.maxDelta(date, date_range.x_scale.invert(current_min)); + if (new_current_min) date_range.min_handle.attr('cx', date_range.x_scale(new_current_min)); + } + } else { + date_range.min_handle.attr('cx', value); + current_min = value; + if (d3.event.sourceEvent && date_range.maxDelta){ + var new_current_max = date_range.maxDelta(date, date_range.x_scale.invert(current_max)); + if (new_current_max) date_range.max_handle.attr('cx', date_range.x_scale(new_current_max)); + } + } + + + if (d3.event.sourceEvent && date_range.onRangeUpdated) { + date_range.onRangeUpdated(date_range.x_scale.invert(current_min), date_range.x_scale.invert(current_max)); + } + } + +} + +export default DateRange; diff --git a/client/dashboard/energy/energy.js b/client/dashboard/energy/energy.js new file mode 100644 index 0000000..6d9de98 --- /dev/null +++ b/client/dashboard/energy/energy.js @@ -0,0 +1,134 @@ +import React from 'react'; +import energyRt from './energy.rt.js'; +import House from './../../models/house'; +import CalendarGridChart from './../../d3/grid/calendar_grid'; + +var Energy = React.createClass({ + + getInitialState: function(){ + var energy = this; + return { + graph_attr: 'production', + loading_data: true + }; + }, + + handleResize: function(e) { + this.setState({windowWidth: window.innerWidth}); + }, + + componentDidMount: function() { + // window.addEventListener('resize', this.handleResize); + var energy = this, + house = energy.props.house; + energy.graph_title = 'Daily Consumption'; + house.setEnergyData().then(()=>{ + energy.setState({loading_data: false}); + if (energy.props.view === 'graph') energy.initGraph(); + }); + }, + + componentWillUnmount: function(){ + var energy = this; + energy.destroyGraph(); + }, + + componentWillReceiveProps: function(new_props){ + var energy = this; + if (new_props.house !== energy.props.house){ + energy.setState({loading_data: true}); + new_props.house.setEnergyData().then(()=>{ + energy.setState({loading_data: false}); + if (energy.props.view === 'graph') energy.initGraph(); + }); + } + if (new_props.view !== 'graph' && energy.props.view === 'graph') energy.destroyGraph(); + }, + + componentDidUpdate: function(prev_props, _prev_state){ + var energy = this, + house = energy.props.house; + if (prev_props.view !== 'graph' && energy.props.view === 'graph') energy.initGraph(); + if (prev_props.year !== energy.props.year){ + energy.updateCurrentMonth(); + } + }, + + setGraphAttr: function(event){ + var energy = this, + graph_attr = event.target.dataset.value; + if (graph_attr !== energy.state.graph_attr){ + energy.graph_title = 'Daily ' + event.target.innerText; + energy.setState({ + graph_attr: graph_attr + }, function(){ + if (energy.props.view === 'graph') energy.updateGraph(); + }) + } + }, + + initGraph: function(){ + var energy = this; + if (energy.graph === undefined){ + energy.graph = new CalendarGridChart({ + container: '#energy_graph', + outer_width: 800, + outer_height: 300, + date_attr: 'day', + color: '#0404B4', + toDate: (energy_datum)=>{ return energy_datum.day_to_date; } + }); + jQuery('#energy_graph').tooltip({ + selector: '.d3-chart-grid-unit', + container: 'body', + title: function(){ + var energy_datum = this.__data__, + date_s = d3.time.format('%a %b %d, %Y')(energy_datum.day_to_date), + range_value = `${Math.round(energy_datum.data[energy.state.graph_attr])} kWh`; + return `${date_s}: ${range_value}`; + } + }); + } + energy.updateGraph(); + }, + + updateGraph: function(){ + var energy = this, + house = energy.props.house; + energy.graph.rangeValue = (datum)=>{ return datum.data[energy.state.graph_attr]; } + energy.graph.drawData({ + title: energy.graph_title, + css_class: '', + min_range: 0, + max_range: 150, + values: house.energy_data + }); + }, + + destroyGraph: function(){ + var energy = this; + document.getElementById('energy_graph').innerHTML = ''; + energy.graph = undefined; + }, + + updateCurrentMonth: function(){ + var energy = this, + house = energy.props.house; + house.setEnergyData() + .then(()=>{ + if (energy.props.view === 'graph'){ + // no update necessary since year already updated in layout.rt. + energy.updateGraph(); + } else { + // force update to render correct data in table. + energy.forceUpdate(); + } + }); + }, + + render: function() { + return energyRt.call(this); + } +}); + +export default Energy; diff --git a/client/dashboard/energy/energy.rt b/client/dashboard/energy/energy.rt new file mode 100644 index 0000000..4c94242 --- /dev/null +++ b/client/dashboard/energy/energy.rt @@ -0,0 +1,39 @@ +
+
+ Retrieving energy data for the {this.props.house.name} household... +
+
+

Select Data

+
+ + +
+
+ + + + + + + + + + + + + + + + + +
DayConsumption (kWh)Production (kWh)
{energy_datum.day_to_s}{energy_datum.consumption_to_s}{energy_datum.production_to_s}
+
+
diff --git a/client/dashboard/energy/energy.rt.js b/client/dashboard/energy/energy.rt.js new file mode 100644 index 0000000..a17cbd5 --- /dev/null +++ b/client/dashboard/energy/energy.rt.js @@ -0,0 +1,25 @@ +import React from 'react'; +import _ from 'lodash'; +function repeatEnergy_datum1(energy_datum, energy_datumIndex) { + return React.createElement('tr', { 'key': energy_datum.scoped_id }, React.createElement('td', {}), React.createElement('td', {}, energy_datum.day_to_s), React.createElement('td', {}, energy_datum.consumption_to_s), React.createElement('td', {}, energy_datum.production_to_s)); +} +export default function () { + return React.createElement('div', { 'id': 'energy_view' }, this.state.loading_data ? React.createElement('div', { 'className': 'alert alert-warning' }, '\n Retrieving energy data for the ', this.props.house.name, ' household...\n ') : null, this.props.view === 'graph' ? React.createElement('div', {}, React.createElement('h4', {}, 'Select Data'), React.createElement('div', { + 'className': 'btn-group', + 'role': 'group' + }, React.createElement('button', { + 'data-value': 'consumption', + 'className': _.keys(_.pick({ active: this.state.graph_attr === 'consumption' }, _.identity)).join(' ') + ' ' + 'btn btn-primary', + 'onClick': this.setGraphAttr, + 'type': 'button' + }, 'Consumption'), React.createElement('button', { + 'data-value': 'production', + 'className': _.keys(_.pick({ active: this.state.graph_attr === 'production' }, _.identity)).join(' ') + ' ' + 'btn btn-primary', + 'onClick': this.setGraphAttr, + 'type': 'button' + }, 'Production'))) : null, this.props.view === 'table' ? React.createElement('table', { 'className': 'table' }, React.createElement('thead', {}, React.createElement('tr', {}, React.createElement('th', {}), React.createElement('th', {}, 'Day'), React.createElement('th', {}, 'Consumption (kWh)'), React.createElement('th', {}, 'Production (kWh)'))), React.createElement.apply(this, [ + 'tbody', + {}, + _.map(this.props.house.energy_data, repeatEnergy_datum1.bind(this)) + ])) : null, this.props.view === 'graph' ? React.createElement('div', { 'id': 'energy_graph' }) : null); +}; \ No newline at end of file diff --git a/client/dashboard/energy/energy.scss b/client/dashboard/energy/energy.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/dashboard/layout/layout.js b/client/dashboard/layout/layout.js new file mode 100644 index 0000000..24c3de9 --- /dev/null +++ b/client/dashboard/layout/layout.js @@ -0,0 +1,88 @@ +import React from 'react'; +import layoutRt from './layout.rt.js'; +import House from './../../models/house'; +import PowerDatum from './../../models/power_datum'; + +var Layout = React.createClass({ + + getInitialState: function(){ + var layout = this; + return { + houses: null, + house: null, + view: 'graph', + dataset: 'power', + requesting_data: true + }; + }, + + handleResize: function(e) { + this.setState({windowWidth: window.innerWidth}); + }, + + componentDidMount: function() { + var layout = this; + // window.addEventListener('resize', this.handleResize); + House.ensureHouses().then((houses)=>{ + layout.setState({ + houses: houses, + house: houses[0], + requesting_data: false, + month: houses[0].current_month, + year: houses[0].current_year + }); + }); + }, + + setHouse: function(event){ + var layout = this, + house_id = event.target.value, + old_house = layout.state.house, + house = layout.state.houses.find((house)=>{ return house.data.id == house_id }); + layout.setState({house: house}, ()=>{ + old_house.closeDb(); + }); + }, + + setView: function(event){ + var layout = this, + view = event.target.dataset.value; + layout.view_name = event.target.innerText; + layout.setState({view: view}); + }, + + setDataset: function(event){ + var layout = this, + dataset = event.target.dataset.value; + layout.setState({dataset: dataset}); + }, + + setYear: function(event){ + var layout = this, + year = event.target.dataset.value, + house = layout.state.house; + if (year != house.current_year){ + house.setYear(year); + layout.setState({year: year}); + } + }, + + refreshData: function(){ + var layout = this, + houses = layout.state.houses, + all = []; + houses.forEach((house)=>{ + all.push(house.clearData()); + }); + Promise.all(all) + .then(()=>{ + window.location.reload(); + }); + }, + + render: function() { + return layoutRt.call(this); + } +}); + +export default Layout; diff --git a/client/dashboard/layout/layout.rt b/client/dashboard/layout/layout.rt new file mode 100644 index 0000000..94c7255 --- /dev/null +++ b/client/dashboard/layout/layout.rt @@ -0,0 +1,60 @@ + + +
+
Retrieving houses...
+ +

Select household:

+ + + +

Select dataset:

+
+ + +
+ +

View as:

+
+ + +
+ +

Select dates:

+
+ +

+ + + +
diff --git a/client/dashboard/layout/layout.rt.js b/client/dashboard/layout/layout.rt.js new file mode 100644 index 0000000..18ed882 --- /dev/null +++ b/client/dashboard/layout/layout.rt.js @@ -0,0 +1,69 @@ +import React from 'react'; +import _ from 'lodash'; +import Energy from './../energy/energy'; +import Power from './../power/power'; +function repeatHouse1(house, houseIndex) { + return React.createElement('option', { + 'value': house.data.id, + 'key': house.scoped_id + }, house.data.name); +} +function repeatYear2(year, yearIndex) { + return React.createElement('button', { + 'data-value': year, + 'key': 'data-year-' + year, + 'className': 'btn-info btn btn-sm' + ' ' + _.keys(_.pick({ active: year == this.state.house.current_year }, _.identity)).join(' '), + 'onClick': this.setYear + }, year); +} +export default function () { + return React.createElement('div', { 'id': 'layout' }, this.state.requesting_data ? React.createElement('div', { 'className': 'alert alert-warning' }, 'Retrieving houses...') : null, React.createElement('h4', {}, 'Select household:'), this.state.houses ? React.createElement.apply(this, [ + 'select', + { + 'className': 'form-control', + 'onChange': this.setHouse + }, + _.map(this.state.houses, repeatHouse1.bind(this)) + ]) : null, this.state.house ? React.createElement('button', { + 'onClick': this.refreshData, + 'className': 'btn btn-xs btn-default' + }, 'Refresh House Data') : null, React.createElement('h4', {}, 'Select dataset:'), React.createElement('div', { + 'className': 'btn-group', + 'role': 'group' + }, React.createElement('button', { + 'data-value': 'energy', + 'className': _.keys(_.pick({ active: this.state.dataset === 'energy' }, _.identity)).join(' ') + ' ' + 'btn btn-primary', + 'onClick': this.setDataset, + 'type': 'button' + }, 'Daily Energy Statistics'), React.createElement('button', { + 'data-value': 'power', + 'className': _.keys(_.pick({ active: this.state.dataset === 'power' }, _.identity)).join(' ') + ' ' + 'btn btn-primary', + 'onClick': this.setDataset, + 'type': 'button' + }, '15-minute Power Statistics')), React.createElement('h4', {}, 'View as:'), React.createElement('div', { + 'className': 'btn-group', + 'role': 'group' + }, React.createElement('button', { + 'data-value': 'graph', + 'className': _.keys(_.pick({ active: this.state.view === 'graph' }, _.identity)).join(' ') + ' ' + 'btn btn-primary', + 'onClick': this.setView, + 'type': 'button' + }, 'Graph'), React.createElement('button', { + 'data-value': 'table', + 'className': _.keys(_.pick({ active: this.state.view === 'table' }, _.identity)).join(' ') + ' ' + 'btn btn-primary', + 'onClick': this.setView, + 'type': 'button' + }, 'Table')), React.createElement('h4', {}, 'Select dates:'), React.createElement.apply(this, [ + 'div', + { 'className': 'btn-group' }, + this.state.house ? _.map(this.state.house.years, repeatYear2.bind(this)) : null + ]), React.createElement('br', {}), this.state.house && this.state.dataset === 'energy' ? React.createElement(Energy, { + 'house': this.state.house, + 'view': this.state.view, + 'year': this.state.year + }) : null, this.state.house && this.state.dataset === 'power' ? React.createElement(Power, { + 'house': this.state.house, + 'view': this.state.view, + 'year': this.state.year + }) : null); +}; \ No newline at end of file diff --git a/client/dashboard/layout/layout.scss b/client/dashboard/layout/layout.scss new file mode 100644 index 0000000..ec5b920 --- /dev/null +++ b/client/dashboard/layout/layout.scss @@ -0,0 +1,9 @@ +#layout { + h1 { + color: red; + } +} +// needless comment +#yada { + div { padding: 200px; } +} diff --git a/client/dashboard/power/power.js b/client/dashboard/power/power.js new file mode 100644 index 0000000..10141a9 --- /dev/null +++ b/client/dashboard/power/power.js @@ -0,0 +1,199 @@ +import React from 'react'; +import moment from 'moment-timezone'; +import _ from 'lodash'; + +import powerRt from './power.rt.js'; +import House from './../../models/house'; +import SplineStackChart from './../../d3/line/spline_stack'; +import DateRangeSlider from './../../d3/sliders/date_range'; + +var Power = React.createClass({ + + getInitialState: function(){ + var power = this; + return { + loading_data: true + }; + }, + + handleResize: function(e) { + this.setState({windowWidth: window.innerWidth}); + }, + + componentDidMount: function() { + // window.addEventListener('resize', this.handleResize); + var power = this, + house = power.props.house; + power.graph_title = ''; + power.initDateRange(); + house.setPowerData().then(()=>{ + power.setState({loading_data: false}); + if (power.props.view === 'graph'){ + power.initGraph(); + } + }); + }, + + componentWillUnmount: function(){ + var power = this; + power.destroyGraph(); + }, + + componentWillReceiveProps: function(new_props){ + var power = this; + if (new_props.house !== power.props.house){ + // house will change. + power.setState({loading_data: true}); + new_props.house.setPowerData().then(()=>{ + power.setState({loading_data: false}); + if (power.props.view === 'graph'){ + power.initGraph(); + } + }); + } + // view will change from graph to table. + if (new_props.view !== 'graph' && power.props.view === 'graph') power.destroyGraph(); + }, + + componentDidUpdate: function(prev_props, _prev_state){ + var power = this, + house = power.props.house; + // view has changed from graph to table. + if (prev_props.view !== 'graph' && power.props.view === 'graph'){ + power.initGraph(); + } + if (prev_props.house !== house) power.initDateRange(); + var need_update = false; + if (prev_props.year !== power.props.year){ + power.updateCurrentMonth(); + } + }, + + initGraph: function(){ + var power = this, + house = power.props.house; + if (power.graph === undefined){ + power.graph = new SplineStackChart({ + container: '#power_graph', + outer_width: 800, + outer_height: 200, + color: '#0404B4', + time_series: true, + domain_attr: 'x', + range_attr: 'y', + include_dots: true, + titleizeDatum: (series, d)=>{ + return series.title + '
' + Math.round(d.y) + ' W
' + house.formatDate(d.power_datum.data.time, 'MMM D [at] HH:mm'); + } + }); + jQuery('#power_graph').tooltip({ + selector: 'circle', + container: 'body', + html: true, + title: function(){ + return this.__data__.title; + } + }); + } + power.updateGraph(); + }, + + updateGraph: function(){ + var power = this, + house = power.props.house, + net_power = { + title: 'Net Power Consumption', + values: house.power_data.map((power_datum)=>{ + return { + power_datum: power_datum, + x: power_datum.time_to_date, + y: Math.max(0, power_datum.data.consumption - power_datum.data.production) } + }) + }, + savings = { + title: 'Power Production', + values: house.power_data.map((power_datum)=>{ + return { + power_datum: power_datum, + x: power_datum.time_to_date, + y: power_datum.data.production } + }) + }; + power.graph.drawData({ + title: power.graph_title, + css_class: '', + series: [net_power, savings] + }); + }, + + initDateRange: function(){ + var power = this, + house = power.props.house; + if (power.date_range_slider === undefined){ + power.date_range_slider = new DateRangeSlider({ + container: '#power_date_setter', + outer_height: 100, + maxDelta: function(changed_date, other_date){ + if (Math.abs(changed_date.getTime() - other_date.getTime()) > 3600 * 24 * 4 * 1000){ + if (changed_date > other_date){ + return new Date(changed_date.getTime() - 3600 * 24 * 4 * 1000); + } else { + return new Date(changed_date.getTime() + 3600 * 24 * 4 * 1000); + } + } + return false; + } + }); + } + power.date_range_slider.onRangeUpdated = (min, max)=>{ + if (power.date_range_update) clearTimeout(power.date_range_update) + power.date_range_update = setTimeout(()=>{ + house.power_date_range = [Math.round(min.getTime() / 1000), Math.round(max.getTime() / 1000)] + house.setPowerData() + .then(()=>{ + if (power.props.view === 'graph') power.updateGraph(); + else power.forceUpdate(); + }); + }, 500); + }; + power.date_range_slider.drawData({ + abs_min: house.current_month_moment.toDate(), + abs_max: house.end_of_current_data_moment.toDate(), + current_min: house.toDate(house.power_date_range[0]), + current_max: house.toDate(house.power_date_range[1]) + }); + }, + + destroyGraph: function(){ + var power = this; + document.getElementById('power_graph').innerHTML = ''; + power.graph = undefined; + }, + + setMonth: function(event){ + var power = this, + house = power.props.house, + month = event.target.dataset.value; + if (month !== house.current_month){ + var need_update = house.setMonth(month); + if (need_update) power.updateCurrentMonth(); + } + }, + + updateCurrentMonth: function(){ + var power = this, + house = power.props.house; + power.initDateRange(); + house.setPowerData() + .then(()=>{ + power.forceUpdate(); + if (power.props.view === 'graph') power.updateGraph(); + }); + }, + + render: function() { + return powerRt.call(this); + } +}); + +export default Power; diff --git a/client/dashboard/power/power.rt b/client/dashboard/power/power.rt new file mode 100644 index 0000000..f075273 --- /dev/null +++ b/client/dashboard/power/power.rt @@ -0,0 +1,35 @@ +
+
+ +
+
+ Retrieving power data for the {this.props.house.name} household... +
+
+ + + + + + + + + + + + + + + + + +
TimeConsumption (W)Production (W)
{power_datum.data.id}{power_datum.time_to_s}{power_datum.consumption_to_s}{power_datum.production_to_s}
+
+
diff --git a/client/dashboard/power/power.rt.js b/client/dashboard/power/power.rt.js new file mode 100644 index 0000000..7927c9a --- /dev/null +++ b/client/dashboard/power/power.rt.js @@ -0,0 +1,24 @@ +import React from 'react'; +import _ from 'lodash'; +function repeatMonth1(month, monthIndex) { + return React.createElement('button', { + 'data-value': month, + 'key': 'data-month-' + month, + 'className': 'btn-warning btn btn-sm' + ' ' + _.keys(_.pick({ active: month === this.props.house.current_month }, _.identity)).join(' '), + 'onClick': this.setMonth + }, month); +} +function repeatPower_datum2(power_datum, power_datumIndex) { + return React.createElement('tr', { 'key': power_datum.scoped_id }, React.createElement('td', {}, power_datum.data.id), React.createElement('td', {}, power_datum.time_to_s), React.createElement('td', {}, power_datum.consumption_to_s), React.createElement('td', {}, power_datum.production_to_s)); +} +export default function () { + return React.createElement('div', { 'id': 'power_view' }, React.createElement.apply(this, [ + 'div', + { 'className': 'btn-group' }, + this.props.house ? _.map(this.props.house.availableMonths(), repeatMonth1.bind(this)) : null + ]), this.state.loading_data ? React.createElement('div', { 'className': 'alert alert-warning' }, '\n Retrieving power data for the ', this.props.house.name, ' household...\n ') : null, React.createElement('div', { 'id': 'power_date_setter' }), this.props.view === 'table' ? React.createElement('table', { 'className': 'table' }, React.createElement('thead', {}, React.createElement('tr', {}, React.createElement('th', {}), React.createElement('th', {}, 'Time'), React.createElement('th', {}, 'Consumption (W)'), React.createElement('th', {}, 'Production (W)'))), React.createElement.apply(this, [ + 'tbody', + {}, + _.map(this.props.house.power_data, repeatPower_datum2.bind(this)) + ])) : null, this.props.view === 'graph' ? React.createElement('div', { 'id': 'power_graph' }) : null); +}; \ No newline at end of file diff --git a/client/lib/databasable.js b/client/lib/databasable.js new file mode 100644 index 0000000..abe6b67 --- /dev/null +++ b/client/lib/databasable.js @@ -0,0 +1,70 @@ +import Loki from 'lokijs/src/lokijs'; +import LokiIndexedAdapter from 'lokijs/src/loki-indexed-adapter'; + +const DEFAULTS = { + autosave: false +}; + +var databasable = { + + accessDb: function(db_name, opts){ + var databasable = this; + opts = Object.assign(Object.assign({ + adapter: new LokiIndexedAdapter(db_name) + }, DEFAULTS), opts || {}); + return new Promise((fnResolve, fnReject)=>{ + if (!databasable.db) { + databasable.db = new Loki(db_name, opts); + databasable.db.loadDatabase({}, ()=>{ + fnResolve(databasable.db); + }); + } else { fnResolve(databasable.db); } + + }); + }, + + closeDb: function(){ + var databasable = this; + if (databasable.db){ + databasable.db.save(); + databasable.db.close(); + databasable.db = undefined; + } + }, + + collection: function(db_name, collection_name, options){ + var databasable = this; + return databasable.accessDb(db_name) + .then((db)=>{ + var collection = db.getCollection(collection_name) + if (!collection){ + collection = db.addCollection(collection_name, options); + if (options && options.unique_indices){ + options.unique_indices.forEach((field)=>{ + collection.ensureUniqueIndex(field); + }); + } + } + return collection; + }); + }, + + rangeToLokiParams: function(attr, range){ + var date_params = {}; + if (range[0] !== undefined && range[0] !== undefined){ + var start_condition = {}, + end_condition = {}; + date_params['$and'] = [start_condition, end_condition]; + start_condition[attr] = {'$gte': range[0]}; + end_condition[attr] = {'$lte': range[1]}; + } else if (range[0] !== undefined) { + date_params[attr] = {'$gte': range[0]} + } else if (range[1] !== undefined) { + date_params[attr] = {'$lte': range[1]} + } + return date_params; + } + +}; + +export default databasable; diff --git a/client/lib/model.js b/client/lib/model.js new file mode 100644 index 0000000..0a61b40 --- /dev/null +++ b/client/lib/model.js @@ -0,0 +1,9 @@ +import Loki from 'lokijs'; + + +class Model { + + + + +} diff --git a/client/models/energy_datum.js b/client/models/energy_datum.js new file mode 100644 index 0000000..afc5df4 --- /dev/null +++ b/client/models/energy_datum.js @@ -0,0 +1,47 @@ +import extend from 'extend'; +import moment from 'moment-timezone'; + +const NAME = 'EnergyDatum'; +const COLLECTION_DEFAULTS = { + indices: ['day'] +}; + +class EnergyDatum { + constructor(data, house){ + var energy_datum = this; + energy_datum.house = house; + energy_datum.data = data; + } + + get react_key(){ + return `energy-datum-${this.data.id}`; + } + + // returns a datestamp that has the client timezone, but actually is house local time. + get day_to_date(){ + var energy_datum = this, + house = energy_datum.house; + return house.toDate(energy_datum.data.day); + } + + get day_to_s(){ + var energy_datum = this; + return moment.tz(energy_datum.data.day * 1000, energy_datum.house.data.timezone).format('YYYY-MM-DD'); + } + + get consumption_to_s(){ + var energy_datum = this; + return Math.round(energy_datum.data.consumption); + } + + get production_to_s(){ + var energy_datum = this; + return Math.round(energy_datum.data.production); + } + +} + +EnergyDatum.NAME = NAME; +EnergyDatum.COLLECTION_DEFAULTS = COLLECTION_DEFAULTS; + +export default EnergyDatum; diff --git a/client/models/house.js b/client/models/house.js new file mode 100644 index 0000000..abfa12a --- /dev/null +++ b/client/models/house.js @@ -0,0 +1,261 @@ +import extend from 'extend'; +import Loki from 'lokijs/src/lokijs'; +import moment from 'moment-timezone'; + +import PowerDatum from './power_datum'; +import EnergyDatum from './energy_datum'; +import PowerDataApi from './../api/power_data'; +import EnergyDataApi from './../api/energy_data'; +import HousesApi from './../api/houses'; +import ArrayUtil from './../../shared/utils/array'; +import MathUtil from './../../shared/utils/math'; +import DateRange from './../../shared/utils/date_range'; +import Databasable from './../lib/databasable'; + +const NAME = 'House'; + +class House { + + // must be initiated with a dataset already in Loki database (not directly JSON). + constructor(data){ + var house = this; + house.data = data; + Object.assign(house, Databasable); + + var n_years = house.data_until_moment.year() - house.data_from_moment.year() + 1; + house.years = []; + for (var year=house.data_from_moment.year(); year<=house.data_until_moment.year(); year+=1){ + house.years.push(year); + } + house.current_month = house.data_until_moment.format('MMM'); + house.current_year = house.data_until_moment.year(); + house.setCurrentMonthMoment(); + } + + get data_from_moment(){ + var house = this; + return moment.tz(house.data.data_from * 1000, house.data.timezone); + } + + get data_until_moment(){ + var house = this; + return moment.tz(house.data.data_until * 1000, house.data.timezone); + } + + get end_of_current_data_moment(){ + var house = this, + end_of_month = house.current_month_moment.clone().endOf('month'); + return end_of_month > house.data_until_moment ? house.data_until_moment : end_of_month; + } + + get scoped_id(){ + return `house-${this.data.id}`; + } + + availableMonths(){ + var house = this, + all_months = moment.monthsShort(), + year = house.current_year.toString(); + if ((year) === house.data_from_moment.format('YYYY')){ + return all_months.slice(house.data_from_moment.month(), 12); + } else if (year === house.data_until_moment.format('YYYY')){ + return all_months.slice(0, house.data_until_moment.month() + 1); + } else { + return all_months; + } + } + + setYear(year){ + var house = this; + house.current_year = year; + return house.setCurrentMonthMoment(); + } + + setMonth(month){ + var house = this; + house.current_month = month; + return house.setCurrentMonthMoment(); + } + + setCurrentMonthMoment(){ + var house = this, + month_i = moment.monthsShort().indexOf(house.current_month), + new_month_moment = moment.tz({year: house.current_year, month: month_i, day: 1}, house.data.timezone).startOf('month'); + if (!house.current_month_moment || new_month_moment.unix() !== house.current_month_moment.unix()){ + house.current_month_moment = new_month_moment; + house.power_date_range = [house.end_of_current_data_moment.clone().subtract(4, 'days').unix(), house.end_of_current_data_moment.unix()]; + house.energy_date_range = [house.end_of_current_data_moment.clone().startOf('year').unix(), house.end_of_current_data_moment.clone().endOf('year').unix()] + return true; + } + return false; + } + + offset_diff(unix){ + var house = this, + tz = moment.tz.zone(house.data.timezone); + return (new Date().getTimezoneOffset() - tz.offset(unix * 1000)) * 60; + } + + toDate(unix){ + var house = this; + return new Date((unix + house.offset_diff(unix)) * 1000); + } + + formatDate(unix, format){ + var house = this; + return moment.tz(unix * 1000, house.data.timezone).format(format) + } + + save(){ + var house = this; + return House.collection(House.NAME, House.NAME) + .then((house_collection)=>{ + house_collection.update(house.data); + return House.db.save(); + }); + } + + setPowerData(){ + var house = this; + return house.collection(house.scoped_id, PowerDatum.NAME, PowerDatum.COLLECTION_OPTIONS) + .then((power_collection)=>{ + return house.ensurePowerData() + .then(()=>{ + var params = house.rangeToLokiParams('time', house.power_date_range); + house.power_data = power_collection.find(params) + .sort((pd1, pd2)=>{ + if (pd1.time === pd2.time) return 0; + if (pd1.time > pd2.time) return 1; + if (pd1.time < pd2.time) return -1; + }) + .map((data)=>{ return new PowerDatum(data, house); }); + }); + }); + } + + ensurePowerData(){ + var house = this, + query_ranges; + + query_ranges = DateRange.addRange(house.power_date_range, house.data.power_datum_ranges || []); + if (query_ranges.gaps_filled.length > 0){ + var params = {dates: query_ranges.gaps_filled}; + return house.getPowerData(params) + .then(()=>{ + house.data.power_datum_ranges = query_ranges.new_ranges; + house.save(); + }); + } else { return Promise.resolve(); } + } + + getPowerData(params){ + var house = this; + params.house_id = house.data.id; + return house.collection(house.scoped_id, PowerDatum.NAME, PowerDatum.COLLECTION_OPTIONS) + .then((power_collection)=>{ + return PowerDataApi.index(params) + .then((power_data)=>{ + power_collection.insert(power_data); + house.db.save(); + }); + }) + } + + setEnergyData(){ + var house = this; + return house.collection(house.scoped_id, EnergyDatum.NAME, EnergyDatum.COLLECTION_OPTIONS) + .then((energy_collection)=>{ + return house.ensureEnergyData() + .then(()=>{ + var params = house.rangeToLokiParams('day', house.energy_date_range); + house.energy_data = energy_collection.find(params) + .sort((pd1, pd2)=>{ + if (pd1.day === pd2.day) return 0; + if (pd1.day > pd2.day) return 1; + if (pd1.day < pd2.day) return -1; + }) + .map((data)=>{ return new EnergyDatum(data, house); }); + }); + }); + } + + ensureEnergyData(){ + var house = this, + query_ranges = DateRange.addRange(house.energy_date_range, house.data.energy_datum_ranges || []); + if (query_ranges.gaps_filled.length > 0){ + return house.getEnergyData({dates: query_ranges.gaps_filled}) + .then(()=>{ + house.data.energy_datum_ranges = query_ranges.new_ranges; + house.save(); + }); + } else { return Promise.resolve(); } + } + + getEnergyData(params){ + var house = this; + params.house_id = house.data.id; + return house.collection(house.scoped_id, EnergyDatum.NAME, EnergyDatum.COLLECTION_OPTIONS) + .then((energy_collection)=>{ + return EnergyDataApi.index(params) + .then((energy_data)=>{ + energy_collection.insert(energy_data); + house.db.save(); + }); + }) + } + + // removes all energy and power data from LokiJs (memory and persisted) database. + clearData(){ + var house = this, + all = [ + new Promise((fnResolve, fnReject)=>{ + house.collection(house.scoped_id, PowerDatum.NAME) + .then((power_collection)=>{ + power_collection.removeWhere({}); + house.db.save(fnResolve); + }); + }), + new Promise((fnResolve, fnReject)=>{ + house.collection(house.scoped_id, EnergyDatum.NAME) + .then((energy_collection)=>{ + energy_collection.removeWhere({}); + house.db.save(fnResolve); + }); + }), + new Promise((fnResolve, fnReject)=>{ + House.collection(House.NAME, House.NAME) + .then((house_collection)=>{ + house_collection.remove(house.data); + House.db.save(fnResolve); + }); + }) + ] + return Promise.all(all) + } + + static ensureHouses(ids){ + return House.collection(House.NAME, House.NAME) + .then((house_collection)=>{ + var houses_data = ids ? house_collection.find({id: {$in: ids}}) : house_collection.find(); + if (!ids && houses_data.length === 0 || ids && houses_data.length !== ids.length){ + var required_ids = ids ? ArrayUtil.diff(ids, houses_data.map((data)=>{ return data.id; })) : undefined; + return HousesApi.index({id: ids}) + .then((required_houses)=>{ + required_houses.forEach((house_data)=>{ + house_collection.insert(house_data); + }); + House.db.save(); + return houses_data.concat(required_houses); + }); + } else { return Promise.resolve(houses_data); } + }).then((houses_data)=>{ + return houses_data.map((house_data)=>{ return new House(house_data); }) + }); + } + +} + +House.NAME = NAME; + +Object.assign(House, Databasable); +export default House; diff --git a/client/models/power_datum.js b/client/models/power_datum.js new file mode 100644 index 0000000..c53a7ac --- /dev/null +++ b/client/models/power_datum.js @@ -0,0 +1,46 @@ +import extend from 'extend'; +import moment from 'moment-timezone'; + +const NAME = 'PowerDatum'; +const COLLECTION_DEFAULTS = { + indices: ['time'], + unique_indices: ['time'] +}; + +class PowerDatum { + constructor(data, house){ + var power_datum = this; + power_datum.house = house; + power_datum.data = data; + } + + get react_key(){ + return `power-datum-${this.data.id}`; + } + + get time_to_date(){ + var power_datum = this, + house = power_datum.house; + return house.toDate(power_datum.data.time); + } + + get time_to_s(){ + var power_datum = this, + moment_tz = moment.tz(power_datum.data.time * 1000, power_datum.house.data.timezone); + return moment_tz.format('YYYY-MM-DD HH:mm'); + } + get consumption_to_s(){ + var power_datum = this; + return Math.round(power_datum.data.consumption); + } + get production_to_s(){ + var power_datum = this; + return Math.round(power_datum.data.production); + } + +} + +PowerDatum.NAME = NAME; +PowerDatum.COLLECTION_DEFAULTS = COLLECTION_DEFAULTS; + +export default PowerDatum; diff --git a/client/style.js b/client/style.js new file mode 100644 index 0000000..1903da4 --- /dev/null +++ b/client/style.js @@ -0,0 +1,9 @@ +// Vendor Stylesheets +require('bootstrap/dist/css/bootstrap.min.css'); +require('font-awesome/css/font-awesome.min.css'); + + +// Component Stylesheets +require(__dirname + '/style.scss'); +require(__dirname + '/dashboard/layout/layout.scss'); +require(__dirname + '/d3/chart.scss'); diff --git a/client/style.scss b/client/style.scss new file mode 100644 index 0000000..df9f399 --- /dev/null +++ b/client/style.scss @@ -0,0 +1,16 @@ +html, body { + height:100%; +} +#spike_container { + min-height: 100%; + position:relative; + padding-bottom:100px; +} +#spike_footer { + width:100%; + padding:15px; + position:absolute; + bottom:0px; + border-top:2px solid darkgrey; + background-color:#F8F8F8; +} diff --git a/gulpfile.babel.js b/gulpfile.babel.js new file mode 100644 index 0000000..857be1c --- /dev/null +++ b/gulpfile.babel.js @@ -0,0 +1,68 @@ +import gulp from 'gulp'; +import yargs from 'yargs'; +import webpack from 'webpack'; +import gutil from 'gulp-util'; + +import DB from './server/config/database'; +import {PowerDataSeed, HouseSeed} from './server/lib/tasks/seed_data'; +import rtCompile from './server/lib/tasks/react_template_compile'; +import rt_config from './server/config/react_templates'; + +gulp.task('generate_power_csv', function(done){ + DB.sync().then(()=>{ + PowerDataSeed.generateCsv(yargs.argv, done); + }); +}); + +gulp.task('save_power_csv', function(done){ + DB.sync().then(()=>{ + PowerDataSeed.saveCsv(yargs.argv, done); + }); +}); + +gulp.task('save_house_csv', function(done){ + DB.sync().then(()=>{ + HouseSeed.saveCsv(yargs.argv, done); + }); +}); + +gulp.task('compile_react_templates', function() { + gulp.src('./client/dashboard/**/*.rt') + .pipe(rtCompile(rt_config)) + .pipe(gulp.dest('./client/dashboard')); +}); + + +gulp.task('build', function(done) { + var config, env; + + if (yargs.argv.production){ + env = 'production'; + } else if (yargs.argv.design){ + env = 'design'; + } else if (yargs.argv.test){ + env = 'test'; + } else { + throw new gutil.PluginError("webpack", "Must include '--production' or '--design' option."); + } + config = require(`${__dirname}/server/config/webpack/${env}`); + // run webpack + webpack(config, function(err, stats) { + if(err) throw new gutil.PluginError("webpack", err); + gutil.log("[webpack]", stats.toString({ + // output options + })); + done(); + }); +}); + +gulp.task('test', function(done) { + var Jasmine = require('jasmine'); + var jasmine = new Jasmine(); + + jasmine.loadConfigFile('test/jasmine.json'); + jasmine.configureDefaultReporter({ + showColors: true + }); + jasmine.execute(); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f1996bf --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "spike_proto", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "babel-node ./server/app.express.js" + }, + "dependencies": { + "body-parser": "~1.12.0", + "debug": "~2.1.1", + "jade": "~1.9.2", + "morgan": "~1.5.1", + "serve-favicon": "~2.2.0", + "node-forge": "~0.6.26", + "sequelize": "~3.15.1", + "pg": "~4.4.3", + "pg-hstore": "~2.3.2", + "fast-csv": "0.6.0", + "babel-polyfill": "6.3.14", + "babel-preset-es2015": "6.3.13", + "babel-preset-react": "6.3.13", + "babel-preset-stage-0": "6.3.13", + "babel-core": "6.3.21", + "babel-loader": "6.2.0", + "express": "4.13.3", + "react": "0.14.3", + "react-dom": "0.14.3", + "webpack": "1.12.9", + "webpack-dev-server": "1.14.0", + "extract-text-webpack-plugin": "1.0.1", + "jquery": "2.2.0", + "bootstrap": "3.3.6", + "d3": "3.5.12", + "font-awesome": "4.5.0", + "raw-loader": "0.5.1", + "sass-loader": "3.1.2", + "style-loader": "^0.12.3", + "json-loader": "0.5.4", + "node-sass": "3.4.2", + "moment-timezone": "0.5.0", + "yargs": "3.32.0", + "extend": "3.0.0", + "through2": "2.0.1", + "lokijs": "1.3.11", + "babel-cli": "^6.3.17", + "babel-standalone": "6.4.4", + "gulp": "^3.9.0", + "gulp-util": "3.0.7", + "jasmine-core": "2.4.1", + "karma": "^0.13.19", + "karma-babel-preprocessor": "^6.0.1", + "karma-chrome-launcher": "^0.2.2", + "karma-jasmine": "^0.3.6", + "react-templates": "0.4.1", + "requirejs": "~2.1", + "jasmine-es6": "0.1.4" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..cddb9d8 --- /dev/null +++ b/readme.md @@ -0,0 +1 @@ +# Spike diff --git a/server/app.express.js b/server/app.express.js new file mode 100644 index 0000000..ff46718 --- /dev/null +++ b/server/app.express.js @@ -0,0 +1,127 @@ +/* + * Serve GraphQL Backend + */ + +import express from 'express'; +import path from 'path'; +import webpack from 'webpack'; +import WebpackDevServer from 'webpack-dev-server'; +import bodyParser from 'body-parser'; + + +import DB from './config/database'; +import routes from './routes'; + +const API_PORT = 8080; +const APP_PORT = 3000; + +var api = express(); + +/* + * Serve API App + */ + +DB.sync().then(()=>{ + + routes(api); + + api.use(bodyParser.json()); + api.use(bodyParser.urlencoded({ extended: false })); + + + api.listen(API_PORT, () => { + console.log(`API is now running on http://localhost:${API_PORT}`); + }); + +}); + + +/* + * Development Server + */ + +var config = require('./config/webpack/development'), + dev_server = new WebpackDevServer(webpack(config), { + contentBase: __dirname + '/../client/build/development', + publicPath: "/assets/", + proxy: { + '/data*': `http://localhost:${API_PORT}`, + }, + stats: {colors: true} + }), + app = dev_server.app; + +/* + * Serve Vendor Scripts, CSS, and Templates + */ + +import favicon from 'serve-favicon'; +import logger from 'morgan'; + +// uncomment after placing your favicon in /public +app.use(favicon(__dirname + '/public/favicon.ico')); +app.use(logger('dev')); + +// serve fonts in /assets/fonts +import assets from "connect-assets"; + +// TODO: These routes need to match references in the bootstrap and font awesome files. +app.use("/assets/fonts", express.static("bootstrap/dist/fonts")); +app.use("/assets/fonts", express.static("font-awesome/fonts")); +// serve compiled vendor assets and application.css. +app.use(assets({ + paths: ["./../node_modules"], + build: true, + buildDir: false, + //compile: false, + compress: true +})); +// serve public static files. +dev_server.app.use('/', express.static(path.resolve(__dirname, 'public'))); + +// view engine set up +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'jade'); +app.get("/", (req, res, next)=>{ + res.render("index"); +}); + + +/* + * Handle Errors + */ + +// catch 404 and forward to error handler +app.use(function(req, res, next) { + var err = new Error('Not Found'); + err.status = 404; + next(err); +}); + +// development error handler +// will print stacktrace +if (app.get('env') === 'development') { + app.use(function(err, req, res, next) { + res.status(err.status || 500); + res.render('error', { + message: err.message, + error: err + }); + }); +} + +// production error handler +// no stacktraces leaked to user +app.use(function(err, req, res, next) { + res.status(err.status || 500); + res.render('error', { + message: err.message, + error: {} + }); +}); + +dev_server.listen(APP_PORT, () => { + console.log(`App is now running on http://localhost:${APP_PORT}`); +}); + +module.exports = app; diff --git a/server/config/controllers.js b/server/config/controllers.js new file mode 100644 index 0000000..d76d60b --- /dev/null +++ b/server/config/controllers.js @@ -0,0 +1,17 @@ +import fs from 'fs'; + +const CONTROLLER_DIR = __dirname + '/../controllers'; + +class Controllers { + + static sync(){ + fs.readdirSync(CONTROLLER_DIR).forEach(function(file) { + var controller = require(CONTROLLER_DIR + '/' + file); + Controllers[controller.NAME] = controller; + }); + return true; + } + +} + +export default Controllers; diff --git a/server/config/database.js b/server/config/database.js new file mode 100644 index 0000000..d13d5d8 --- /dev/null +++ b/server/config/database.js @@ -0,0 +1,37 @@ +"use strict"; + +import fs from "fs"; +import Sequelize from 'sequelize'; + +var sequelize = new Sequelize("postgres://spikeuser:123456@localhost:5432/spike2", { + pool: { + max: 5, + min: 0, + idle: 10000 + } +}); +const MODEL_DIR = __dirname + '/../models' + +class Database { + + static sync(){ + console.log("syncing db") + fs.readdirSync(MODEL_DIR).forEach(function(file) { + var model = require(MODEL_DIR + '/' + file); + Database[model.NAME] = model; + Database.models.push(model); + }); + + // add associations + for (var model of Database.models){ + model.set(); + } + + return sequelize.sync().then(()=>{ console.log("done syncing db") }); + } +} +Database.sequelize = sequelize; +Database.Sequelize = Sequelize; +Database.models = []; + +export default Database; diff --git a/server/config/react_templates.js b/server/config/react_templates.js new file mode 100644 index 0000000..3809455 --- /dev/null +++ b/server/config/react_templates.js @@ -0,0 +1,7 @@ +var config = { + modules: 'es6', + targetVersion: '0.14.0', + suffix: '.rt' +}; + +export default config; diff --git a/server/config/webpack/design.js b/server/config/webpack/design.js new file mode 100644 index 0000000..a9af8e5 --- /dev/null +++ b/server/config/webpack/design.js @@ -0,0 +1 @@ +design.js diff --git a/server/config/webpack/development.js b/server/config/webpack/development.js new file mode 100644 index 0000000..897de9e --- /dev/null +++ b/server/config/webpack/development.js @@ -0,0 +1,48 @@ +import webpack from 'webpack'; + +const ROOT = __dirname + '/../../../'; + +module.exports = { + entry: { + app: ROOT + 'client/app', + style: ROOT + 'client/style' + }, + output: { + filename: '[name].js', + path: ROOT + 'client/build/development' + }, + module: { + loaders: [ + { + test: /\.scss$/, + loaders: ['style', 'raw', 'sass'] + }, { + test: /\.css$/, + loaders: ['style', 'raw'] + }, { + test: /\.js$/, + loader: 'babel' + }, { + test: /\.json$/, + loader: 'json-loader' + } + ] + }, + sassLoader: { + includePaths: [ROOT + 'client', ROOT + 'node_modules'] + }, + plugins: [ + new webpack.ProvidePlugin({ + $: "jquery", + jQuery: "jquery", + "window.jQuery": "jquery" + }), + new webpack.ProvidePlugin({ + d3: "d3", + "window.d3": "d3" + }) + ], + node: { + fs: "empty" + } +} diff --git a/server/config/webpack/production.js b/server/config/webpack/production.js new file mode 100644 index 0000000..dae28da --- /dev/null +++ b/server/config/webpack/production.js @@ -0,0 +1,58 @@ +import ExtractTextPlugin from 'extract-text-webpack-plugin'; +import webpack from 'webpack'; + +const ROOT = __dirname + '/../../../'; + +module.exports = { + entry: { + app: ROOT + 'client/app', + style: ROOT + 'client/style' + }, + devtool: 'source-map', + output: { + filename: '[name].min.js', + path: ROOT + 'client/build/production' + }, + externals: { + jquery: "$", + d3: "d3" + }, + module: { + loaders: [ + { + test: /\.scss$/, + loader: ExtractTextPlugin.extract("style-loader", "raw-loader!sass-loader") + }, { + test: /\.css$/, + loader: ExtractTextPlugin.extract("style-loader", "raw-loader") + }, { + test: /\.js$/, + loader: 'babel' + }, { + test: /\.woff(\?v=[0-9]\.[0-9]\.[0-9])?$/, + loader: "url-loader?limit=10000&minetype=application/font-woff" + }, { + test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, + loader: "file-loader" + } + ] + }, + sassLoader: { + includePaths: [ROOT + 'client', ROOT + 'node_modules'] + }, + // Use the plugin to specify the resulting filename (and add needed behavior to the compiler) + plugins: [ + new ExtractTextPlugin("style.min.css", { + allChunks: true + }), + new webpack.optimize.UglifyJsPlugin({minimize: true}), + new webpack.ProvidePlugin({ + $: "jquery", + jQuery: "jquery", + "window.jQuery": "jquery" + }) + ], + node: { + fs: "empty" + } +}; diff --git a/server/controllers/energy_controller.js b/server/controllers/energy_controller.js new file mode 100644 index 0000000..9646461 --- /dev/null +++ b/server/controllers/energy_controller.js @@ -0,0 +1,16 @@ +import DB from './../config/database.js'; + +const NAME = 'EnergyController'; + +class EnergyController{ + + static index(req, res){ + DB.EnergyDatum.exposeForHouseAtDates(req.query.house_id, req.query.dates).then((energy_data)=>{ + res.json({data: energy_data}); + }); + } + +} + +EnergyController.NAME = NAME; +module.exports = EnergyController; diff --git a/server/controllers/houses_controller.js b/server/controllers/houses_controller.js new file mode 100644 index 0000000..3fa4d8e --- /dev/null +++ b/server/controllers/houses_controller.js @@ -0,0 +1,18 @@ +import DB from './../config/database.js'; + +const NAME = 'HousesController'; + +class HousesController { + + static index(req, res){ + var params = {}; + if (req.query.ids) query.id = ids; + DB.House.findAll({where: params}).then((houses)=>{ + res.json({data: houses.map((house)=>{ return house.dataValues; })}); + }); + } + +} + +HousesController.NAME = NAME; +module.exports = HousesController; diff --git a/server/controllers/power_controller.js b/server/controllers/power_controller.js new file mode 100644 index 0000000..8c4654b --- /dev/null +++ b/server/controllers/power_controller.js @@ -0,0 +1,16 @@ +import DB from './../config/database.js'; + +const NAME = 'PowerController'; + +class PowerController{ + + static index(req, res){ + DB.PowerDatum.exposeForHouseAtDates(req.query.house_id, req.query.dates).then((power_data)=>{ + res.json({data: power_data}); + }); + } + +} + +PowerController.NAME = NAME; +module.exports = PowerController; diff --git a/server/helpers/api_helper.js b/server/helpers/api_helper.js new file mode 100644 index 0000000..964188e --- /dev/null +++ b/server/helpers/api_helper.js @@ -0,0 +1,29 @@ +class ApiHelper { + + // assume all dates from api coming as UNIX timestamps. + static datesParamToSequelize(dates, field_name){ + if (!dates) return {}; + var params = {}; + + if (dates.length > 1){ + params['$or'] = []; + dates.forEach((min_max)=>{ + var condition_n = {}; + condition_n[field_name] = {}; + if (min_max[0]) condition_n[field_name]['$gte'] = min_max[0]; + if (min_max[1]) condition_n[field_name]['$lte'] = min_max[1]; + if (Object.keys(condition_n).length) params['$or'].push(condition_n); + }); + } else { + var min_max = dates[0], + condition = {} + if (min_max[0]) condition['$gte'] = min_max[0]; + if (min_max[1]) condition['$lte'] = min_max[1]; + if (Object.keys(condition).length) params[field_name] = condition; + } + return params; + } + +} + +export default ApiHelper; diff --git a/server/lib/tasks/react_template_compile.js b/server/lib/tasks/react_template_compile.js new file mode 100644 index 0000000..9db7800 --- /dev/null +++ b/server/lib/tasks/react_template_compile.js @@ -0,0 +1,57 @@ +'use strict'; +// through2 is a thin wrapper around node transform streams +import through from 'through2'; +import gutil from 'gulp-util'; +import rt from 'react-templates'; +import path from 'path'; +import extend from 'extend'; + +// Consts +const PLUGIN_NAME = 'gulp-react-templates'; +var PluginError = gutil.PluginError; + +function normalizeName(name) { + return name.replace(/-/g, '_'); +} + +export default function (opt) { + function replaceExtension(filePath) { + return filePath + '.js'; + } + + function transform(file, enc, cb) { + if (file.isNull()) { + return cb(null, file); + } + if (file.isStream()) { + return cb(new PluginError(PLUGIN_NAME, 'Streaming not supported')); + } + + var filePath = file.path, + str = file.contents.toString('utf8'), + data; + + var options = extend({ + filename: file.path, + sourceFiles: [file.relative], + generatedFile: replaceExtension(file.relative) + }, opt); + + if (options.suffix && !options.name) { + options.name = normalizeName(path.basename(filePath, path.extname(filePath))) + options.suffix; + } + + try { + data = rt.convertTemplateToReact(str, options); + } catch (err) { + return cb(new PluginError(PLUGIN_NAME, err)); + } + + file.contents = new Buffer(data); + + file.path = replaceExtension(file.path); + cb(null, file); + } + + return through.obj(transform); +}; diff --git a/server/lib/tasks/seed_data.js b/server/lib/tasks/seed_data.js new file mode 100644 index 0000000..1732dd0 --- /dev/null +++ b/server/lib/tasks/seed_data.js @@ -0,0 +1,103 @@ +import extend from "extend"; +import moment from "moment"; +import csv from "fast-csv"; +import fs from 'fs'; +import MathUtils from "./../../../shared/utils/math" +import DB from './../../config/database'; + +const DATA_PATH = __dirname + '/../../../shared/data/' + +export class PowerDataSeed { + + static saveCsv(opts, done){ + opts = extend({ + path: DATA_PATH + "power_data.csv" + }, opts || {}); + var stream = fs.createReadStream(opts.path), + csvStream = csv.fromStream(stream, {headers: ['house_id', 'time', 'consumption', 'production']}), + rows = []; + + csvStream.on("data", function(data){ + data.time = data.time; + rows.push(data); + if (rows.length % 100 === 0){ + DB.PowerDatum.bulkCreate(rows, {validate: true}).catch((error)=>{ + console.error(JSON.stringify(error)); + console.error(JSON.stringify(rows)); + }); + rows = []; + } + }); + csvStream.on("end", function(){ + console.log("all rows parsed") + DB.PowerDatum.bulkCreate(rows, {validate: true}).then(()=>{ + return DB.House.findAll().then((houses)=>{ + var promises = []; + for (var house of houses){ + var p = house.aggregatePowerToEnergyData(); + promises.push(p); + } + return Promise.all(promises); + }); + }).then(()=>{ + console.log("DONE!") + }); + }); + } + + static generateCsv(opts, done){ + opts = extend({ + start_date: moment().subtract(120, "months").unix(), + end_date: moment().unix(), + interval: 900, // every 15 minutes (in s) + average: 1400, // Wh + path: DATA_PATH + "power_data.csv" + }, opts || {}); +console.log(opts.start_date, opts.end_date) + var row_date = opts.start_date, + csvStream = csv.format({headers: true}), + writableStream = fs.createWriteStream(opts.path), + house_ids = opts.house_ids.split(",") + + DB.House.findAll({where: {id: house_ids}}).then((houses)=>{ + + csvStream.pipe(writableStream); + writableStream.on("finish", ()=>{ + console.log("DONE!") + done(); + }); + + while (row_date <= opts.end_date){ + for (var house of houses){ + var consumption = MathUtils.normal(opts.average), + production = MathUtils.normal(opts.average) * house.productionMultiplier(row_date * 1000); + csvStream.write([house.id, row_date, consumption, production]); + } + row_date += opts.interval; + } + csvStream.end(); + }); + } +} + +export class HouseSeed { + static saveCsv(opts, done){ + opts = extend({ + path: DATA_PATH + "houses.csv" + }, opts || {}); + var stream = fs.createReadStream(opts.path), + csvStream = csv.fromStream(stream, {headers: ['id', 'name', 'timezone']}), + rows = []; + + csvStream.on("data", function(data){ + rows.push(data); + }); + csvStream.on("end", function(){ + console.log(rows); + DB.House.bulkCreate(rows, {validate: true}).then(()=>{ + console.log("DONE!") + done(); + }); + }); + } +} diff --git a/server/models/energy_datum.js b/server/models/energy_datum.js new file mode 100644 index 0000000..1521c1a --- /dev/null +++ b/server/models/energy_datum.js @@ -0,0 +1,52 @@ +import DB from "./../config/database"; +import extend from 'extend'; +import ApiHelper from './../helpers/api_helper'; + +const NAME = 'EnergyDatum'; + +/** + * Define your own types here + */ + +var EnergyDatum = DB.sequelize.define(NAME, { + id: { + type: DB.Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true // Automatically gets converted to SERIAL for postgres + }, + day: { + type: DB.Sequelize.INTEGER, + }, + production: DB.Sequelize.FLOAT, + consumption: DB.Sequelize.FLOAT +}, { + paranoid: true, + underscored: true, + tableName: "energy_data", + instanceMethods: {}, + classMethods: { + set: ()=>{ + EnergyDatum.associate(); + }, + associate: ()=>{ + EnergyDatum.belongsTo(DB.House); + }, + exposeForHouseAtDates: (house_id, dates)=>{ + var params = {house_id: house_id}; + extend(params, ApiHelper.datesParamToSequelize(dates, 'day')); + console.log('EnergyDatum#exposeForHouseAtDates') + console.log(params, dates) + return EnergyDatum.findAll({ + where: params, + attributes: ['id', 'production', 'consumption', 'day'] + }).then((energy_data)=>{ + return energy_data.map((energy_datum)=>{ + return energy_datum.dataValues; + }); + }); + } + } +}); + +EnergyDatum.NAME = NAME; +module.exports = EnergyDatum; diff --git a/server/models/house.js b/server/models/house.js new file mode 100644 index 0000000..2700180 --- /dev/null +++ b/server/models/house.js @@ -0,0 +1,93 @@ +import moment from 'moment-timezone'; +import DB from "./../config/database"; + +const NAME = 'House'; + +/** + * Sequelize Definition + */ + +var House = DB.sequelize.define(NAME, { + id: { + type: DB.Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true // Automatically gets converted to SERIAL for postgres + }, + timezone: DB.Sequelize.STRING, + name: DB.Sequelize.STRING, + data_until: { + type: DB.Sequelize.INTEGER, + }, + data_from: { + type: DB.Sequelize.INTEGER, + } +}, { + paranoid: true, + underscored: true, + tableName: "houses", + instanceMethods: { + productionMultiplier: function(timestamp){ + var house = this, + minute = moment.tz(timestamp, house.timezone).hour() * 60 + moment.tz(timestamp, house.timezone).minute(), + multiplier = 0; + if (minute > 420 && minute < 1140){ + multiplier = 1 - Math.abs(780 - minute) / 360; + } + return multiplier; + }, + unixToLocalDay: function(unix){ + var house = this; + return moment.tz(unix * 1000, house.timezone).startOf('day').unix(); + }, + aggregatePowerToEnergyData: function(){ + var house = this; + return DB.EnergyDatum.destroy({where: {house_id: house.id}}) + .then(()=>{ + return DB.PowerDatum.count({where: {house_id: house.id}}) + }) + .then((count)=>{ + var limit = 0, + energy_data = new Map(), + promises = []; + while (limit < count){ + let complete = DB.PowerDatum.findAll({where: {house_id: house.id}, limit: 1000, offset: limit, order: 'id ASC'}) + .then((power_data)=>{ + power_data.forEach((power_datum)=>{ + var day = house.unixToLocalDay(power_datum.time), + energy_datum = energy_data.get(day) || {production: 0, consumption: 0, day: day, house_id: house.id}; + energy_datum.production += power_datum.production / 1000; // convert Wh to kWh + energy_datum.consumption += power_datum.consumption / 1000; // convert Wh to kWh + energy_data.set(day, energy_datum); + }); + }); + promises.push(complete); + limit += 1000; + } + return Promise.all(promises).then(()=>{ + return DB.EnergyDatum.bulkCreate(Array.from(energy_data.values()), {validate: true}); + }); + }) + .then(()=>{ + return house.getPowerData({order: 'time DESC', limit: 1}); + }) + .then((max_data)=>{ + house.data_until = max_data[0].time; + return house.getPowerData({order: 'time ASC', limit: 1}); + }).then((min_data)=>{ + house.data_from = min_data[0].time; + return house.save(); + }); + } + }, + classMethods: { + set: ()=>{ + House.associate(); + }, + associate: ()=>{ + House.hasMany(DB.PowerDatum, {as: 'PowerData'}); + } + } +}); + +House.NAME = NAME; +module.exports = House; diff --git a/server/models/power_datum.js b/server/models/power_datum.js new file mode 100644 index 0000000..642a073 --- /dev/null +++ b/server/models/power_datum.js @@ -0,0 +1,59 @@ +import DB from "./../config/database"; +import extend from 'extend'; +import ApiHelper from './../helpers/api_helper'; + +const NAME = 'PowerDatum'; + +/** + * Define your own types here + */ + +var PowerDatum = DB.sequelize.define(NAME, { + id: { + type: DB.Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true // Automatically gets converted to SERIAL for postgres + }, + time: { + type: DB.Sequelize.INTEGER, + }, + consumption: DB.Sequelize.FLOAT, + production: DB.Sequelize.FLOAT +}, { + paranoid: true, + underscored: true, + tableName: "power_data", + instanceMethods: { + exposeToApi: function(){ + var power_datum = this, + data = power_datum.dataValues; + data.consumption = data.consumption * 4; // convert Wh / 15 minutes, to W + data.production = data.production * 4; // convert Wh / 15 minutes, to W + return data; + } + }, + classMethods: { + exposeForHouseAtDates: (house_id, dates)=>{ + var params = {house_id: house_id}; + params = extend(params, ApiHelper.datesParamToSequelize(dates, 'time')); + console.log(params); + return PowerDatum.findAll({ + where: params, + attributes: ['id', 'production', 'consumption', 'time'] + }).then((power_data)=>{ + return power_data.map((power_datum)=>{ + return power_datum.exposeToApi(); + }); + }); + }, + set: ()=>{ + PowerDatum.associate(); + }, + associate: ()=>{ + PowerDatum.belongsTo(DB.House); + } + } +}); + +PowerDatum.NAME = NAME; +module.exports = PowerDatum; diff --git a/server/public/favicon.ico b/server/public/favicon.ico new file mode 100644 index 0000000..127c0d9 Binary files /dev/null and b/server/public/favicon.ico differ diff --git a/server/public/index2.html b/server/public/index2.html new file mode 100644 index 0000000..5a28fda --- /dev/null +++ b/server/public/index2.html @@ -0,0 +1,41 @@ + + + + + + + + + Spike Prototype + + +
+
+ +
+
+ +
+ + + + + + diff --git a/server/routes.js b/server/routes.js new file mode 100644 index 0000000..3502aa6 --- /dev/null +++ b/server/routes.js @@ -0,0 +1,11 @@ +import Controllers from './config/controllers' + +export default function(app){ + + Controllers.sync(); + + app.use('/data/v1/power', Controllers.PowerController.index); + app.use('/data/v1/energy', Controllers.EnergyController.index); + app.use('/data/v1/houses', Controllers.HousesController.index); + +}; diff --git a/server/views/error.jade b/server/views/error.jade new file mode 100644 index 0000000..51ec12c --- /dev/null +++ b/server/views/error.jade @@ -0,0 +1,6 @@ +extends layout + +block content + h1= message + h2= error.status + pre #{error.stack} diff --git a/server/views/index.jade b/server/views/index.jade new file mode 100644 index 0000000..a3f3ff4 --- /dev/null +++ b/server/views/index.jade @@ -0,0 +1,3 @@ +extends layout +block content + div(id="root") diff --git a/server/views/layout.jade b/server/views/layout.jade new file mode 100644 index 0000000..3dc3b6b --- /dev/null +++ b/server/views/layout.jade @@ -0,0 +1,34 @@ +doctype html +html + head + meta(charset='utf-8') + meta(http-equiv='content-type', content='text/html; charset=UTF-8') + meta(name='viewport', content='width=device-width, initial-scale=1') + title Spike Prototype + body + #spike_container + #spike_content + nav.navbar.navbar-default(style='margin-bottom:0px;') + .container + .navbar-header + button.navbar-toggle.collapsed(type='button', data-toggle='collapse', data-target='#navbar', aria-expanded='false', aria-controls='navbar') + span.sr-only Toggle navigation + span.icon-bar + span.icon-bar + span.icon-bar + a.navbar-brand(href='/') Spike + #navbar.collapse.navbar-collapse + ul.nav.navbar-nav.navbar-right + li + a(href='/') Spike + .container + block content + #spike_footer + .container Footer + script(type='text/javascript'). + // Force `fetch` polyfill to workaround Chrome not displaying request body + // in developer tools for the native `fetch`. + self.fetch = null; + script(src='http://localhost:3000/webpack-dev-server.js') + script(src='/assets/style.js') + script(src='/assets/app.js') diff --git a/shared/models/house.js b/shared/models/house.js new file mode 100644 index 0000000..8910627 --- /dev/null +++ b/shared/models/house.js @@ -0,0 +1,10 @@ +import moment from 'moment'; + +class House { + + timeToDateString(timestamp){ + var house = this; + return moment.tz(timestamp, house.timezone).format("YYYY-MM-DD"); + } + +} diff --git a/shared/utils/array.js b/shared/utils/array.js new file mode 100644 index 0000000..bc333bf --- /dev/null +++ b/shared/utils/array.js @@ -0,0 +1,28 @@ +class ArrayUtil { + + static diff(a1, a2){ + return a1.filter((a1n)=>{ return a2.indexOf(a1n) < 0; }); + } + + static selectMap(a, fnSelect, fnMap){ + var map = []; + for (var elem of a){ + if (fnSelect(elem)) map.push(fnMap(elem)); + } + return map; + } + + static all(a, fnCondition){ + var all = true; + for (var elem of a){ + if (!fnCondition(elem)){ + all = false; + break; + } + } + return all; + } + +} + +export default ArrayUtil; diff --git a/shared/utils/date_range.js b/shared/utils/date_range.js new file mode 100644 index 0000000..04be8e1 --- /dev/null +++ b/shared/utils/date_range.js @@ -0,0 +1,68 @@ +class DateRange { + + static addRange(new_range, ranges){ + var gaps_filled = [], new_ranges = [], + start = new_range[0], end = new_range[1]; + if (start === undefined && end === undefined && ranges.length === 0){ + gaps_filled = [undefined, undefined]; + new_ranges = [[undefined, undefined]]; + } else { + var covered = false, + last_start = start, + last_end = start; + + ranges.forEach((range, i)=>{ + if (covered){ new_ranges.push(range); return true; } + if (DateRange.lte(start, range[0])){ + if (end && !DateRange.eq(end, range[0]) && DateRange.lte(end, range[0])){ + new_ranges.push([last_start, end]); + new_ranges.push(range); + gaps_filled.push([last_end, end]); + covered = true; + } else if (end && !DateRange.gte(end, range[1])) { + new_ranges.push([last_start, range[1]]); + if (range[0] && !DateRange.eq(last_end, range[0])){ gaps_filled.push([last_end, range[0]]); } + covered = true + } else { + if (range[0] && !DateRange.eq(last_end, range[0])) gaps_filled.push([last_end, range[0]]); + last_end = range[1] + } + } else if (start && DateRange.gte(range[1], start)){ + if (!DateRange.eq(end, range[1]) && DateRange.gte(end, range[1])){ + last_start = range[0]; + last_end = range[1]; + } else { + new_ranges.push(range); + covered = true; + } + } else { new_ranges.push(range); } + }); + if (!covered) { + new_ranges.push([last_start, end]); + if (!DateRange.eq(last_end, end)) gaps_filled.push([last_end, end]); + } + } + + return { gaps_filled: gaps_filled, new_ranges: new_ranges } + } + + static gte(date1, date2){ + return (date1 === undefined || (date2 !== undefined && date1 >= date2)); + } + + static lte(date1, date2){ + return (date1 === undefined || (date2 !== undefined && date1 <= date2)); + } + + static eq(date1, date2){ + if (date1 && date1.constructor === Date) date1 = date1.getTime(); + if (date2 && date2.constructor === Date) date2 = date2.getTime(); + return (date1 !== undefined && date2 !== undefined && date1 === date2) || date1 === undefined && date2 === undefined + } + + static add(date, s){ + return new Date(date.getTime() + s); + } + +} +export default DateRange; diff --git a/shared/utils/math.js b/shared/utils/math.js new file mode 100644 index 0000000..b626ed1 --- /dev/null +++ b/shared/utils/math.js @@ -0,0 +1,38 @@ +export default class { + + static normal(average){ + return average + this.n6() * average; + } + + static n6(){ + return ((Math.random() + Math.random() + Math.random() + Math.random() + Math.random() + Math.random()) - 3) / 3; + } + + // min_max1 and min_max2 arrays of length two representing mins and maxes of their ranges; + // returns array of array length two, representing mins and maxes not within min_max2. + static minusRange(min_max1, min_max2){ + var minus = []; + + // return undefined if min_max1 not provided + if (!min_max1 || (!min_max1[0] && !min_max2[1])) return undefined; + + if (min_max1[0] >= min_max2[0]){ + if (min_max1[1] > min_max2[1]) minus.push([min_max2[1], min_max1[1]]); + } else if (min_max1[1] <= min_max2[1]){ + if (min_max1[0] < min_max2[0]) minus.push([min_max1[0], min_max2[0]]); + } else if (min_max1[0] < min_max2[0] && min_max1[1] > min_max2[1]){ + minus.push([min_max1[0], min_max2[0]]); + minus.push([min_max2[1], min_max1[1]]); + } else { + minus.push([min_max1[0], min_max1[1]]); + } + return minus; + } + + static inRange(n, min_max){ + var min = min_max[0], + max = min_max[1]; + return ((n >= min) && (n <= max)); + } + +} diff --git a/spec/shared/utils/date_range.test.js b/spec/shared/utils/date_range.test.js new file mode 100644 index 0000000..cbe3dea --- /dev/null +++ b/spec/shared/utils/date_range.test.js @@ -0,0 +1,424 @@ +"use strict"; + +import DateRange from './../../../shared/utils/date_range.js'; + +describe('DateRange.gte', ()=>{ + + it('considers undefined as a large date', ()=>{ + var date1 = new Date(), + date2 = new Date(date1.getTime() + 1000); + expect(DateRange.gte(undefined, date1)).toEqual(true); + expect(DateRange.gte(undefined, undefined)).toEqual(true); + expect(DateRange.gte(date1, undefined)).toEqual(false); + expect(DateRange.gte(date1, date2)).toEqual(false); + expect(DateRange.gte(date2, date1)).toEqual(true); + }); +}); + + +describe('DateRange.lte', ()=>{ + it('considers undefined as a small date', ()=>{ + var date1 = new Date(), + date2 = new Date(date1.getTime() + 1000); + expect(DateRange.lte(undefined, date1)).toEqual(true); + expect(DateRange.lte(undefined, undefined)).toEqual(true); + expect(DateRange.lte(date1, undefined)).toEqual(false); + expect(DateRange.lte(date1, date2)).toEqual(true); + expect(DateRange.lte(date2, date1)).toEqual(false); + }); +}); + +describe('DateRange.addRange', ()=>{ + var date1 = new Date(), + date01 = DateRange.add(date1, -1000), + date11 = DateRange.add(date1, 1000), + date2 = DateRange.add(date1, 2000), + date21 = DateRange.add(date2, 1000), + date3 = DateRange.add(date2, 2000), + date31 = DateRange.add(date3, 1000), + date4 = DateRange.add(date3, 2000), + date41 = DateRange.add(date4, 1000), + date5 = DateRange.add(date4, 2000), + date51 = DateRange.add(date5, 1000), + date6 = DateRange.add(date5, 2000), + date61 = DateRange.add(date6, 1000), + date7 = DateRange.add(date6, 2000), + date71 = DateRange.add(date7, 1000); + + describe('no ranges exist', ()=>{ + it('returns the new ranges', ()=>{ + var result = DateRange.addRange([date1, date2], []); + expect(result.gaps_filled).toEqual([[date1, date2]]); + expect(result.new_ranges).toEqual([[date1, date2]]); + }); + }); + + describe('infinite range exists', ()=>{ + it('returns the infinite range, no gaps filled', ()=>{ + var result = DateRange.addRange([date1, date2], [[undefined, undefined]]); + expect(result.gaps_filled).toEqual([]); + expect(result.new_ranges).toEqual([[undefined, undefined]]); + }); + }); + + describe('-Infinity to definite date exists', ()=>{ + describe('with gaps', ()=>{ + var ranges = [[undefined, date1], [date2, date3], [date4, date5]]; + + describe('new range low low', ()=>{ + var new_range = [undefined, date01]; + it('no gaps filled', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]) + expect(result.new_ranges).toEqual(ranges); + }); + }); + + describe('new range low mid', ()=>{ + var new_range = [undefined, date31]; + it('fills mid gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date1, date2], [date3, date31]]); + expect(result.new_ranges).toEqual([[undefined, date31], [date4, date5]]); + }); + }); + + describe('new range low high', ()=>{ + var new_range = [undefined, date61]; + it('fills mid and high gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date1, date2], [date3, date4], [date5, date61]]); + expect(result.new_ranges).toEqual([[undefined, date61]]); + }); + }); + + + describe('new range mid mid', ()=>{ + var new_range = [date11, date41]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date11, date2], [date3, date4]]) + expect(result.new_ranges).toEqual([[undefined, date1], [date11, date5]]); + }); + }); + + describe('new range mid high', ()=>{ + var new_range = [date11, date61]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date11, date2], [date3, date4], [date5, date61]]); + expect(result.new_ranges).toEqual([[undefined, date1], [date11, date61]]); + }); + }); + + describe('new range high high', ()=>{ + var new_range = [date5, date61]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date5, date61]]); + expect(result.new_ranges).toEqual([[undefined, date1], [date2, date3], [date4, date61]]); + }); + }); + + }); + + describe('no gaps', ()=>{ + var ranges = [[undefined, date1]]; + + describe('new range low low', ()=>{ + var new_range = [undefined, date01]; + it('no gaps filled', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]) + expect(result.new_ranges).toEqual([[undefined, date1]]); + }); + }); + + describe('new range low mid', ()=>{ + var new_range = [undefined, date1]; + it('fills mid gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]); + expect(result.new_ranges).toEqual([[undefined, date1]]); + }); + }); + + describe('new range low high', ()=>{ + var new_range = [undefined, date61]; + it('fills mid and high gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date1, date61]]); + expect(result.new_ranges).toEqual([[undefined, date61]]); + }); + }); + + + describe('new range mid mid', ()=>{ + var new_range = [date01, date1]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]) + expect(result.new_ranges).toEqual([[undefined, date1]]); + }); + }); + + describe('new range mid high', ()=>{ + var new_range = [date1, date61]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date1, date61]]); + expect(result.new_ranges).toEqual([[undefined, date61]]); + }); + }); + + describe('new range high high', ()=>{ + var new_range = [date5, date61]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date5, date61]]); + expect(result.new_ranges).toEqual([[undefined, date1], [date5, date61]]); + }); + }); + }); + }); + + describe('definite to Infinity range exists', ()=>{ + describe('with gaps', ()=>{ + var ranges = [[date1, date2], [date3, date4], [date5, undefined]]; + + describe('new range low low', ()=>{ + var new_range = [undefined, date01]; + it('no gaps filled', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date01]]); + expect(result.new_ranges).toEqual([[undefined, date01], [date1, date2], [date3, date4], [date5, undefined]]); + }); + }); + + describe('new range low mid', ()=>{ + var new_range = [undefined, date3]; + it('fills mid gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date1], [date2, date3]]); + expect(result.new_ranges).toEqual([[undefined, date4], [date5, undefined]]); + }); + }); + + describe('new range low high', ()=>{ + var new_range = [undefined, date61]; + it('fills mid and high gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date1], [date2, date3], [date4, date5]]); + expect(result.new_ranges).toEqual([[undefined, undefined]]); + }); + }); + + describe('new range mid mid', ()=>{ + var new_range = [date1, date41]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date2, date3], [date4, date41]]); + expect(result.new_ranges).toEqual([[date1, date41], [date5, undefined]]); + }); + }); + + describe('new range mid high', ()=>{ + var new_range = [date31, date61]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date4, date5]]); + expect(result.new_ranges).toEqual([[date1, date2], [date3, undefined]]); + }); + }); + + describe('new range high high', ()=>{ + var new_range = [date5, date61]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]); + expect(result.new_ranges).toEqual([[date1, date2], [date3, date4], [date5, undefined]]); + }); + }); + }); + + describe('no gaps', ()=>{ + var ranges = [[date1, undefined]]; + + describe('new range low low', ()=>{ + var new_range = [undefined, date01]; + it('no gaps filled', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date01]]); + expect(result.new_ranges).toEqual([[undefined, date01], [date1, undefined]]); + }); + }); + + describe('new range low mid', ()=>{ + var new_range = [undefined, date1]; + it('fills mid gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date1]]); + expect(result.new_ranges).toEqual([[undefined, undefined]]); + }); + }); + + describe('new range low high', ()=>{ + var new_range = [undefined, date61]; + it('fills mid and high gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date1]]); + expect(result.new_ranges).toEqual([[undefined, undefined]]); + }); + }); + + describe('new range mid mid', ()=>{ + var new_range = [date1, date1]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]); + expect(result.new_ranges).toEqual([[date1, undefined]]); + }); + }); + + describe('new range mid high', ()=>{ + var new_range = [date1, undefined]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]); + expect(result.new_ranges).toEqual([[date1, undefined]]); + }); + }); + + describe('new range high high', ()=>{ + var new_range = [date5, undefined]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]); + expect(result.new_ranges).toEqual([[date1, undefined]]); + }); + }); + + }); + }); + + describe('definite range exists', ()=>{ + describe('with gaps', ()=>{ + var ranges = [[date1, date2], [date3, date4], [date5, date6]]; + + describe('new range low low', ()=>{ + var new_range = [undefined, date01]; + it('no gaps filled', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date01]]); + expect(result.new_ranges).toEqual([[undefined, date01], [date1, date2], [date3, date4], [date5, date6]]); + }); + }); + + describe('new range low mid', ()=>{ + var new_range = [undefined, date41]; + it('fills mid gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date1], [date2, date3], [date4, date41]]); + expect(result.new_ranges).toEqual([[undefined, date41], [date5, date6]]); + }); + }); + + describe('new range low high', ()=>{ + var new_range = [undefined, date61]; + it('fills mid and high gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date1], [date2, date3], [date4, date5], [date6, date61]]); + expect(result.new_ranges).toEqual([[undefined, date61]]); + }); + }); + + describe('new range mid mid', ()=>{ + var new_range = [date11, date41]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date2, date3], [date4, date41]]); + expect(result.new_ranges).toEqual([[date1, date41], [date5, date6]]); + }); + }); + + describe('new range mid high', ()=>{ + var new_range = [date31, undefined]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date4, date5], [date6, undefined]]); + expect(result.new_ranges).toEqual([[date1, date2], [date3, undefined]]); + }); + }); + + describe('new range high high', ()=>{ + var new_range = [date61, undefined]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date61, undefined]]); + expect(result.new_ranges).toEqual([[date1, date2], [date3, date4], [date5, date6], [date61, undefined]]); + }); + }); + + }); + + describe('no gaps', ()=>{ + var ranges = [[date1, date2]]; + + describe('new range low low', ()=>{ + var new_range = [undefined, date01]; + it('no gaps filled', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date01]]); + expect(result.new_ranges).toEqual([[undefined, date01], [date1, date2]]); + }); + }); + + describe('new range low mid', ()=>{ + var new_range = [undefined, date11]; + it('fills mid gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date1]]); + expect(result.new_ranges).toEqual([[undefined, date2]]); + }); + }); + + describe('new range low high', ()=>{ + var new_range = [undefined, date61]; + it('fills mid and high gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[undefined, date1], [date2, date61]]); + expect(result.new_ranges).toEqual([[undefined, date61]]); + }); + }); + + describe('new range mid mid', ()=>{ + var new_range = [date11, date2]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([]); + expect(result.new_ranges).toEqual([[date1, date2]]); + }); + }); + + describe('new range mid high', ()=>{ + var new_range = [date11, undefined]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date2, undefined]]); + expect(result.new_ranges).toEqual([[date1, undefined]]); + }); + }); + + describe('new range high high', ()=>{ + var new_range = [date61, undefined]; + it('includes gaps', ()=>{ + var result = DateRange.addRange(new_range, ranges); + expect(result.gaps_filled).toEqual([[date61, undefined]]); + expect(result.new_ranges).toEqual([[date1, date2], [date61, undefined]]); + }); + }); + + }); + }); + +}); diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json new file mode 100644 index 0000000..bcaf67b --- /dev/null +++ b/spec/support/jasmine.json @@ -0,0 +1,12 @@ +{ + "spec_dir": "spec", + "spec_files": [ + "**/*.test.js" + ], + "helpers": [ + "../node_modules/babel-core/register.js", + "helpers/**/*.js" + ], + "stopSpecOnExpectationFailure": false, + "random": false +}