reports: Allow drilling down

This commit is contained in:
Aaron Lindsay 2017-02-17 10:01:31 -05:00
parent 4d642d1772
commit b443963375
16 changed files with 250 additions and 82 deletions

View File

@ -55,7 +55,6 @@ func luaGetAccounts(L *lua.LState) int {
return 1
}
// Registers my account type to given L.
func luaRegisterAccounts(L *lua.LState) {
mt := L.NewTypeMetatable(luaAccountTypeName)
L.SetGlobal("account", mt)

View File

@ -12,7 +12,6 @@ type Balance struct {
const luaBalanceTypeName = "balance"
// Registers my balance type to given L.
func luaRegisterBalances(L *lua.LState) {
mt := L.NewTypeMetatable(luaBalanceTypeName)
L.SetGlobal("balance", mt)

View File

@ -8,7 +8,6 @@ import (
const luaDateTypeName = "date"
const timeFormat = "2006-01-02"
// Registers my date type to given L.
func luaRegisterDates(L *lua.LState) {
mt := L.NewTypeMetatable(luaDateTypeName)
L.SetGlobal("date", mt)

View File

@ -21,6 +21,17 @@ function ajaxError(error) {
};
}
function clientError(error) {
var e = new Error();
e.ErrorId = 999;
e.ErrorString = "Client Error: " + error;
return {
type: ErrorConstants.ERROR_CLIENT,
error: e
};
}
function clearError() {
return {
type: ErrorConstants.CLEAR_ERROR,
@ -30,5 +41,6 @@ function clearError() {
module.exports = {
serverError: serverError,
ajaxError: ajaxError,
clientError: clientError,
clearError: clearError
};

View File

@ -6,9 +6,10 @@ var models = require('../models.js');
var Report = models.Report;
var Error = models.Error;
function fetchReport() {
function fetchReport(reportName) {
return {
type: ReportConstants.FETCH_REPORT
type: ReportConstants.FETCH_REPORT,
reportName: reportName
}
}
@ -19,9 +20,25 @@ function reportFetched(report) {
}
}
function selectReport(report, seriesTraversal) {
return {
type: ReportConstants.SELECT_REPORT,
report: report,
seriesTraversal: seriesTraversal
}
}
function reportSelected(flattenedReport, seriesTraversal) {
return {
type: ReportConstants.REPORT_SELECTED,
report: flattenedReport,
seriesTraversal: seriesTraversal
}
}
function fetch(report) {
return function (dispatch) {
dispatch(fetchReport());
dispatch(fetchReport(report));
$.ajax({
type: "GET",
@ -45,6 +62,48 @@ function fetch(report) {
};
}
function select(report, seriesTraversal) {
return function (dispatch) {
if (!seriesTraversal)
seriesTraversal = [];
dispatch(selectReport(report, seriesTraversal));
// Descend the tree to the right series to flatten
var series = report;
for (var i=0; i < seriesTraversal.length; i++) {
if (!series.Series.hasOwnProperty(seriesTraversal[i])) {
dispatch(ErrorActions.clientError("Invalid series"));
return;
}
series = series.Series[seriesTraversal[i]];
}
// Actually flatten the data
var flattenedSeries = series.mapReduceChildren(null,
function(accumulator, currentValue, currentIndex, array) {
return accumulator + currentValue;
}
);
// Add back in any values from the current level
if (series.hasOwnProperty('Values'))
flattenedSeries[report.topLevelAccountName] = series.Values;
var flattenedReport = new Report();
flattenedReport.ReportId = report.ReportId;
flattenedReport.Title = report.Title;
flattenedReport.Subtitle = report.Subtitle;
flattenedReport.XAxisLabel = report.XAxisLabel;
flattenedReport.YAxisLabel = report.YAxisLabel;
flattenedReport.Labels = report.Labels.slice();
flattenedReport.FlattenedSeries = flattenedSeries;
dispatch(reportSelected(flattenedReport, seriesTraversal));
};
}
module.exports = {
fetch: fetch
fetch: fetch,
select: select
};

View File

@ -1,5 +1,10 @@
var React = require('react');
var ReactBootstrap = require('react-bootstrap');
var Button = ReactBootstrap.Button;
var Panel = ReactBootstrap.Panel;
var StackedBarChart = require('../components/StackedBarChart');
module.exports = React.createClass({
@ -10,10 +15,69 @@ module.exports = React.createClass({
componentWillMount: function() {
this.props.onFetchReport("monthly_expenses");
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.reports['monthly_expenses'] && !nextProps.selectedReport.report) {
this.props.onSelectReport(nextProps.reports['monthly_expenses'], []);
}
},
onSelectSeries: function(series) {
if (series == this.props.selectedReport.report.topLevelAccountName)
return;
var seriesTraversal = this.props.selectedReport.seriesTraversal.slice();
seriesTraversal.push(series);
this.props.onSelectReport(this.props.reports[this.props.selectedReport.report.ReportId], seriesTraversal);
},
render: function() {
report = [];
if (this.props.reports['monthly_expenses'])
report = (<StackedBarChart data={this.props.reports['monthly_expenses']} />);
var report = [];
if (this.props.selectedReport.report) {
var titleTracks = [];
var seriesTraversal = [];
for (var i = 0; i < this.props.selectedReport.seriesTraversal.length; i++) {
var name = this.props.selectedReport.report.Title;
if (i > 0)
name = this.props.selectedReport.seriesTraversal[i-1];
// Make a closure for going up the food chain
var self = this;
var navOnClick = function() {
var onSelectReport = self.props.onSelectReport;
var report = self.props.reports[self.props.selectedReport.report.ReportId];
var mySeriesTraversal = seriesTraversal.slice();
return function() {
onSelectReport(report, mySeriesTraversal);
};
}();
titleTracks.push((
<Button bsStyle="link"
onClick={navOnClick}>
{name}
</Button>
));
titleTracks.push((<span>/</span>));
seriesTraversal.push(this.props.selectedReport.seriesTraversal[i]);
}
if (titleTracks.length == 0)
titleTracks.push((
<Button bsStyle="link">
{this.props.selectedReport.report.Title}
</Button>
));
else
titleTracks.push((
<Button bsStyle="link">
{this.props.selectedReport.seriesTraversal[this.props.selectedReport.seriesTraversal.length-1]}
</Button>
));
report = (<Panel header={titleTracks}>
<StackedBarChart
report={this.props.selectedReport.report}
onSelectSeries={this.onSelectSeries}
seriesTraversal={this.props.selectedReport.seriesTraversal} />
</Panel>
);
}
return (
<div>
{report}

View File

@ -1,15 +1,13 @@
var d3 = require('d3');
var React = require('react');
var Panel = require('react-bootstrap').Panel;
module.exports = React.createClass({
displayName: "StackedBarChart",
calcMinMax: function(data) {
calcMinMax: function(series) {
var children = [];
for (var child in data) {
if (data.hasOwnProperty(child))
children.push(data[child]);
for (var child in series) {
if (series.hasOwnProperty(child))
children.push(series[child]);
}
var positiveValues = [0];
@ -40,12 +38,6 @@ module.exports = React.createClass({
return Math.ceil(rangePerTick/roundTo)*roundTo;
},
render: function() {
var data = this.props.data.mapReduceChildren(null,
function(accumulator, currentValue, currentIndex, array) {
return accumulator + currentValue;
}
);
height = 400;
width = 600;
legendWidth = 100;
@ -54,7 +46,7 @@ module.exports = React.createClass({
height -= yMargin*2;
width -= xMargin*2;
var minMax = this.calcMinMax(data);
var minMax = this.calcMinMax(this.props.report.FlattenedSeries);
var y = d3.scaleLinear()
.range([0, height])
.domain(minMax);
@ -63,7 +55,7 @@ module.exports = React.createClass({
var x = d3.scaleLinear()
.range([0, width])
.domain([0, this.props.data.Labels.length + 0.5]);
.domain([0, this.props.report.Labels.length + 0.5]);
var bars = [];
var labels = [];
@ -76,13 +68,13 @@ module.exports = React.createClass({
// negativeSum arrays
var positiveSum = [];
var negativeSum = [];
for (var i=0; i < this.props.data.Labels.length; i++) {
for (var i=0; i < this.props.report.Labels.length; i++) {
positiveSum.push(0);
negativeSum.push(0);
var labelX = x(i) + barStart + barWidth/2;
var labelY = height + 15;
labels.push((
<text x={labelX} y={labelY} transform={"rotate(45 "+labelX+" "+labelY+")"}>{this.props.data.Labels[i]}</text>
<text x={labelX} y={labelY} transform={"rotate(45 "+labelX+" "+labelY+")"}>{this.props.report.Labels[i]}</text>
));
labels.push((
<line className="axis-tick" x1={labelX} y1={height-3} x2={labelX} y2={height+3} />
@ -103,14 +95,24 @@ module.exports = React.createClass({
for (var i=0-xAxisMarksEvery; i > minMax[0]; i -= xAxisMarksEvery)
makeXLabel(i);
//TODO handle Values from current series?
var legendMap = {};
for (var child in data) {
childId++;
var rectClasses = "chart-element chart-color" + (childId % 12);
if (data.hasOwnProperty(child)) {
for (var i=0; i < data[child].length; i++) {
var value = data[child][i];
for (var child in this.props.report.FlattenedSeries) {
if (this.props.report.FlattenedSeries.hasOwnProperty(child)) {
childId++;
var childData = this.props.report.FlattenedSeries[child];
var rectClasses = "chart-element chart-color" + (childId % 12);
var self = this;
var rectOnClick = function() {
var childName = child;
var onSelectSeries = self.props.onSelectSeries;
return function() {
onSelectSeries(childName);
};
}();
var seriesBars = [];
for (var i=0; i < childData.length; i++) {
var value = childData[i];
if (value == 0)
continue;
legendMap[child] = childId;
@ -124,10 +126,15 @@ module.exports = React.createClass({
negativeSum[i] += rectHeight;
}
bars.push((
<rect className={rectClasses} x={x(i) + barStart} y={rectY} width={barWidth} height={rectHeight} rx={1} ry={1}/>
seriesBars.push((
<rect onClick={rectOnClick} className={rectClasses} x={x(i) + barStart} y={rectY} width={barWidth} height={rectHeight} rx={1} ry={1}/>
));
}
bars.push((
<g className="chart-series">
{seriesBars}
</g>
));
}
}
@ -144,19 +151,17 @@ module.exports = React.createClass({
}
return (
<Panel header={this.props.data.Title}>
<svg height={height + 2*yMargin} width={width + 2*xMargin + legendWidth}>
<g className="stacked-bar-chart" transform={"translate("+xMargin+" "+yMargin+")"}>
{bars}
<line className="axis x-axis" x1={0} y1={height} x2={width} y2={height} />
<line className="axis y-axis" x1={0} y1={0} x2={0} y2={height} />
{labels}
</g>
<g className="chart-legend" transform={"translate("+(width + 2*xMargin)+" "+yMargin+")"}>
{legend}
</g>
</svg>
</Panel>
<svg height={height + 2*yMargin} width={width + 2*xMargin + legendWidth}>
<g className="stacked-bar-chart" transform={"translate("+xMargin+" "+yMargin+")"}>
{bars}
<line className="axis x-axis" x1={0} y1={height} x2={width} y2={height} />
<line className="axis y-axis" x1={0} y1={0} x2={0} y2={height} />
{labels}
</g>
<g className="chart-legend" transform={"translate("+(width + 2*xMargin)+" "+yMargin+")"}>
{legend}
</g>
</svg>
);
}
});

View File

@ -2,5 +2,7 @@ var keyMirror = require('keymirror');
module.exports = keyMirror({
FETCH_REPORT: null,
REPORT_FETCHED: null
REPORT_FETCHED: null,
SELECT_REPORT: null,
REPORT_SELECTED: null
});

View File

@ -5,13 +5,15 @@ var ReportsTab = require('../components/ReportsTab');
function mapStateToProps(state) {
return {
reports: state.reports
reports: state.reports,
selectedReport: state.selectedReport
}
}
function mapDispatchToProps(dispatch) {
return {
onFetchReport: function(reportname) {dispatch(ReportActions.fetch(reportname))}
onFetchReport: function(reportname) {dispatch(ReportActions.fetch(reportname))},
onSelectReport: function(report, seriesTraversal) {dispatch(ReportActions.select(report, seriesTraversal))}
}
}

View File

@ -399,16 +399,16 @@ Error.prototype.isError = function() {
function Series() {
this.Values = [];
this.Children = {};
this.Series = {};
}
Series.prototype.toJSONobj = function() {
var json_obj = {};
json_obj.Values = this.Values;
json_obj.Children = {};
for (var child in this.Children) {
if (this.Children.hasOwnProperty(child))
json_obj.Children[child] = this.Children[child].toJSONobj();
json_obj.Series = {};
for (var child in this.Series) {
if (this.Series.hasOwnProperty(child))
json_obj.Series[child] = this.Series[child].toJSONobj();
}
return json_obj;
}
@ -416,20 +416,20 @@ Series.prototype.toJSONobj = function() {
Series.prototype.fromJSONobj = function(json_obj) {
if (json_obj.hasOwnProperty("Values"))
this.Values = json_obj.Values;
if (json_obj.hasOwnProperty("Children")) {
for (var child in json_obj.Children) {
if (json_obj.Children.hasOwnProperty(child))
this.Children[child] = new Series();
this.Children[child].fromJSONobj(json_obj.Children[child]);
if (json_obj.hasOwnProperty("Series")) {
for (var child in json_obj.Series) {
if (json_obj.Series.hasOwnProperty(child))
this.Series[child] = new Series();
this.Series[child].fromJSONobj(json_obj.Series[child]);
}
}
}
Series.prototype.mapReduceChildren = function(mapFn, reduceFn) {
var children = {}
for (var child in this.Children) {
if (this.Children.hasOwnProperty(child))
children[child] = this.Children[child].mapReduce(mapFn, reduceFn);
for (var child in this.Series) {
if (this.Series.hasOwnProperty(child))
children[child] = this.Series[child].mapReduce(mapFn, reduceFn);
}
return children;
}
@ -441,9 +441,9 @@ Series.prototype.mapReduce = function(mapFn, reduceFn) {
else
childValues.push(this.Values.slice());
for (var child in this.Children) {
if (this.Children.hasOwnProperty(child))
childValues.push(this.Children[child].mapReduce(mapFn, reduceFn));
for (var child in this.Series) {
if (this.Series.hasOwnProperty(child))
childValues.push(this.Series[child].mapReduce(mapFn, reduceFn));
}
var reducedValues = [];
@ -466,8 +466,11 @@ function Report() {
this.YAxisLabel = "";
this.Labels = [];
this.Series = {};
this.FlattenedSeries = {};
}
Report.prototype.topLevelAccountName = "(top level)";
Report.prototype.toJSON = function() {
var json_obj = {};
json_obj.ReportId = this.ReportId;

View File

@ -8,6 +8,7 @@ var SecurityTemplateReducer = require('./SecurityTemplateReducer');
var SelectedAccountReducer = require('./SelectedAccountReducer');
var SelectedSecurityReducer = require('./SelectedSecurityReducer');
var ReportReducer = require('./ReportReducer');
var SelectedReportReducer = require('./SelectedReportReducer');
var ErrorReducer = require('./ErrorReducer');
module.exports = Redux.combineReducers({
@ -19,5 +20,6 @@ module.exports = Redux.combineReducers({
selectedAccount: SelectedAccountReducer,
selectedSecurity: SelectedSecurityReducer,
reports: ReportReducer,
selectedReport: SelectedReportReducer,
error: ErrorReducer
});

View File

@ -0,0 +1,23 @@
var assign = require('object-assign');
var ReportConstants = require('../constants/ReportConstants');
var UserConstants = require('../constants/UserConstants');
const initialState = {
report: null,
seriesTraversal: []
};
module.exports = function(state = initialState, action) {
switch (action.type) {
case ReportConstants.REPORT_SELECTED:
return {
report: action.report,
seriesTraversal: action.seriesTraversal
};
case UserConstants.USER_LOGGEDOUT:
return initialState;
default:
return state;
}
};

View File

@ -25,8 +25,8 @@ const (
const luaTimeoutSeconds time.Duration = 5 // maximum time a lua request can run for
type Series struct {
Values []float64
Children map[string]*Series
Values []float64
Series map[string]*Series
}
type Report struct {

View File

@ -97,8 +97,8 @@ func luaReportSeries(L *lua.LState) int {
ud.Value = s
} else {
report.Series[name] = &Series{
Children: make(map[string]*Series),
Values: make([]float64, cap(report.Labels)),
Series: make(map[string]*Series),
Values: make([]float64, cap(report.Labels)),
}
ud.Value = report.Series[name]
}
@ -157,8 +157,8 @@ func luaSeries__index(L *lua.LState) int {
switch field {
case "Value", "value":
L.Push(L.NewFunction(luaSeriesValue))
case "Series", "series", "Child", "child":
L.Push(L.NewFunction(luaSeriesChildren))
case "Series", "series":
L.Push(L.NewFunction(luaSeriesSeries))
default:
L.ArgError(2, "unexpected series attribute: "+field)
}
@ -179,20 +179,20 @@ func luaSeriesValue(L *lua.LState) int {
return 0
}
func luaSeriesChildren(L *lua.LState) int {
func luaSeriesSeries(L *lua.LState) int {
parent := luaCheckSeries(L, 1)
name := L.CheckString(2)
ud := L.NewUserData()
s, ok := parent.Children[name]
s, ok := parent.Series[name]
if ok {
ud.Value = s
} else {
parent.Children[name] = &Series{
Children: make(map[string]*Series),
Values: make([]float64, cap(parent.Values)),
parent.Series[name] = &Series{
Series: make(map[string]*Series),
Values: make([]float64, cap(parent.Values)),
}
ud.Value = parent.Children[name]
ud.Value = parent.Series[name]
}
L.SetMetatable(ud, L.GetTypeMetatable(luaSeriesTypeName))
L.Push(ud)

View File

@ -53,7 +53,6 @@ func luaGetSecurities(L *lua.LState) int {
return 1
}
// Registers my security type to given L.
func luaRegisterSecurities(L *lua.LState) {
mt := L.NewTypeMetatable(luaSecurityTypeName)
L.SetGlobal("security", mt)

View File

@ -28,7 +28,7 @@
}
.chart-color8 {
fill: #ff7f00;
fill: #df5f00;
stroke: #df5f00;
}
.chart-color9 {
fill: #cab2d6;
@ -50,7 +50,7 @@
.chart-element {
stroke-width: 0;
}
.chart-element:hover {
g.chart-series:hover .chart-element {
stroke-width: 2;
}
.chart-legend rect {