Mforns has uploaded a new change for review. https://gerrit.wikimedia.org/r/214036
Change subject: Add stacked bars component to compare layout ...................................................................... Add stacked bars component to compare layout * Add visualizer that draws a stacked bar chart * Add component that compares two stacked bar charts * Add data converter that takes data for the bar chart Bug: T91123 Change-Id: Iba4cc5c4145e1dfa1d7d5d47c09bc4247c7a383b --- A src/app/data-converters/matrix-data.js M src/app/data-converters/timeseries-data.js M src/app/require.config.js M src/app/startup.js A src/components/a-b-compare/compare-stacked-bars.html A src/components/a-b-compare/compare-stacked-bars.js M src/components/compare-layout/compare-layout.js A src/components/visualizers/stacked-bars/bindings.js A src/components/visualizers/stacked-bars/stacked-bars.html A src/components/visualizers/stacked-bars/stacked-bars.js A src/css/070_stacked_bars.css M src/layouts/compare/index.js M src/lib/utils.js 13 files changed, 498 insertions(+), 5 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/analytics/dashiki refs/changes/36/214036/1 diff --git a/src/app/data-converters/matrix-data.js b/src/app/data-converters/matrix-data.js new file mode 100644 index 0000000..6999823 --- /dev/null +++ b/src/app/data-converters/matrix-data.js @@ -0,0 +1,64 @@ +/** + * This module returns a method that knows how to parse data of the form: + * date type col1 col2 col3 + * 2015-01-01 row1 1 2 3 + * 2015-01-01 row2 4 5 6 + * 2015-01-02 row1 7 8 9 + * And aggregate it by date, returning a TimeseriesData object + * with the following data format: + * date row1@col1 row1@col2 row1@col3 row2@col1 row2@col2 row2@col3 + * 2015-01-01 1 2 3 4 5 6 + * 2015-01-02 7 8 9 0 0 0 + */ +define(function (require) { + + var TimeseriesData = require('converters.timeseries-data'), + utils = require('utils'), + _ = require('lodash'); + + return function (data) { + var colNames = data.header.slice(2, data.header.length), + headerMap = {}, + rowsByDate = {}; + + // First pass: aggregate by date and get all crossNames + data.rows.forEach(function (row) { + var dateStr = utils.formatDate(row[0]), + rowName = row[1], + values = row.slice(2, row.length).map(function (value) { + return +value || 0; + }); + + if (!_.has(rowsByDate, dateStr)) { + rowsByDate[dateStr] = {}; + } + var rowByDate = rowsByDate[dateStr]; + + for (var i = 0; i < values.length; i++) { + var colName = colNames[i], + value = values[i], + crossName = rowName + '@' + colName; + + headerMap[crossName] = true; + rowByDate[crossName] = value; + } + }); + + // Get header sorted alphabetically + var header = _.chain(headerMap).keys().sort().value(); + + // Second pass: sort values and fill undefineds + _.keys(rowsByDate).forEach(function (dateStr) { + var row = []; + + header.forEach(function (crossName) { + var value = _.get(rowsByDate[dateStr], crossName, 0); + row.push(value); + }); + + rowsByDate[dateStr] = row; + }); + + return new TimeseriesData(header, rowsByDate); + }; +}); diff --git a/src/app/data-converters/timeseries-data.js b/src/app/data-converters/timeseries-data.js index 65f6aa8..d6039d4 100644 --- a/src/app/data-converters/timeseries-data.js +++ b/src/app/data-converters/timeseries-data.js @@ -76,13 +76,11 @@ }; _.reduce(from, function (dest, src) { - console.log('processing: ', src); _.map(src.rowsByDate, function (value, key) { if (!_.has(dest.rowsByDate, key)) { // fill arrays that are nonexistent up to this point dest.rowsByDate[key] = _.fill(Array(dest.header.length), null); } - console.log(dest.rowsByDate); dest.rowsByDate[key] = dest.rowsByDate[key].concat(value); }); diff --git a/src/app/require.config.js b/src/app/require.config.js index 36d6a0e..53359c8 100644 --- a/src/app/require.config.js +++ b/src/app/require.config.js @@ -58,6 +58,8 @@ 'converters.wikimetrics-timeseries' : 'app/data-converters/wikimetrics-timeseries', 'converters.funnel-data' : 'app/data-converters/funnel-data', 'converters.timeseries' : 'app/data-converters/timeseries-data', + 'converters.matrix-data' : 'app/data-converters/matrix-data', + 'converters.timeseries-data' : 'app/data-converters/timeseries-data', // *** lib 'lib.polyfills' : 'lib/polyfills', diff --git a/src/app/startup.js b/src/app/startup.js index 73e274f..99dd92d 100644 --- a/src/app/startup.js +++ b/src/app/startup.js @@ -32,12 +32,13 @@ ko.components.register('rickshaw-timeseries', { require: 'components/visualizers/rickshaw-timeseries/rickshaw-timeseries' }); ko.components.register('nvd3-timeseries', { require: 'components/visualizers/nvd3-timeseries/nvd3-timeseries' }); ko.components.register('dygraphs-timeseries', { require: 'components/visualizers/dygraphs-timeseries/dygraphs-timeseries' }); - //ko.components.register('stacked-bar', { require: 'components/visualizers/stacked-bar' }); + ko.components.register('stacked-bars', { require: 'components/visualizers/stacked-bars/stacked-bars' }); // comparison components ko.components.register('a-b-compare', { require: 'components/a-b-compare/a-b-compare' }); ko.components.register('compare-sunburst', { require: 'components/a-b-compare/compare-sunburst' }); ko.components.register('compare-timeseries', { require: 'components/a-b-compare/compare-timeseries' }); + ko.components.register('compare-stacked-bars', { require: 'components/a-b-compare/compare-stacked-bars' }); // *********** END Funnel Layout Components ************ // diff --git a/src/components/a-b-compare/compare-stacked-bars.html b/src/components/a-b-compare/compare-stacked-bars.html new file mode 100644 index 0000000..6af4735 --- /dev/null +++ b/src/components/a-b-compare/compare-stacked-bars.html @@ -0,0 +1,9 @@ +<div class="ui grid height540"> + <div data-bind="if: data.showAB().a" class="eight wide full height column"> + <stacked-bars params="data: data.a.data(), colors: $data.colors, side: 'left'"/> + </div> + <div data-bind="if: data.showAB().b" class="eight wide full height column"> + <stacked-bars params="data: data.b.data(), colors: $data.colors, side: 'right'"/> + </div> + <div class="stacked-bars-legend"></div> +</div> diff --git a/src/components/a-b-compare/compare-stacked-bars.js b/src/components/a-b-compare/compare-stacked-bars.js new file mode 100644 index 0000000..34f0f88 --- /dev/null +++ b/src/components/a-b-compare/compare-stacked-bars.js @@ -0,0 +1,11 @@ +define(function (require) { + 'use strict'; + + var templateMarkup = require('text!./compare-stacked-bars.html'), + CopyParams = require('viewmodels.copy-params'); + + return { + viewModel: CopyParams, + template: templateMarkup + }; +}); diff --git a/src/components/compare-layout/compare-layout.js b/src/components/compare-layout/compare-layout.js index 3958c88..34e4d47 100644 --- a/src/components/compare-layout/compare-layout.js +++ b/src/components/compare-layout/compare-layout.js @@ -88,6 +88,16 @@ wikiPromise.done(function (){ configApi.getDefaultDashboard(function (config) { + /////////////////////////////////////// + // ADDED THIS JUST FOR TESTING + config.comparisons.push({ + "title": "Failure Types by User Type", + "type": "stacked-bars", + "metric": "failure_types_by_user_type", + "desc": "***" + }); + /////////////////////////////////////// + this.config = config; // dates @@ -115,7 +125,7 @@ if (c.type === 'timeseries') { c.data = asyncMergedDataForAB(api, c.metric, config.a, config.b); } - else if (c.type === 'sunburst') { + else if (c.type === 'sunburst' || c.type === 'stacked-bars') { c.data.a = { label: config.a, data: asyncData(api, c.metric, config.a) diff --git a/src/components/visualizers/stacked-bars/bindings.js b/src/components/visualizers/stacked-bars/bindings.js new file mode 100644 index 0000000..58b2f63 --- /dev/null +++ b/src/components/visualizers/stacked-bars/bindings.js @@ -0,0 +1,347 @@ +define(function(require) { + + var ko = require('knockout'), + d3 = require('d3') + _ = require('lodash'); + + function unfoldData (timeseriesData) { + // Transforms the standard timeseriesData format + // into the format needed for the stacked bars. + var dataMap = {}; + + for (colIndex = 0; colIndex < timeseriesData.header.length; colIndex++) { + var header = timeseriesData.header[colIndex], + names = header.split('@'), + rowName = names[0], + colName = names[1]; + + if (!_.has(dataMap, rowName)) { + dataMap[rowName] = {}; + dataMap[rowName]['type'] = rowName; + } + + var totalValue = 0; + _.values(timeseriesData.rowsByDate).forEach(function (rowByDate) { + totalValue += rowByDate[colIndex]; + }); + + dataMap[rowName][colName] = totalValue; + }; + + return _.values(dataMap); + } + + ko.bindingHandlers.stackedBars = { + update: function (element, valueAccessor) { + var timeseriesData = ko.unwrap(valueAccessor().timeseriesData), + side = ko.unwrap(valueAccessor().side), + data = unfoldData(timeseriesData); + + // This code is taken from: + // http://stackoverflow.com/questions/5560248/programmatically-lighten-or-darken-a-hex-color-or-rgb-and-blend-colors + // Thanks Pimp Trizkit + function highlightColor(color, percent){ + var num = parseInt(color.slice(1), 16), + amt = Math.round(2.55 * percent), + R = (num >> 16) + amt, + G = (num >> 8 & 0x00FF) + amt, + B = (num & 0x0000FF) + amt; + + return ("#" + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + ( B < 255 ? B < 1 ? 0 : B : 255) + ).toString(16).slice(1)); + } + + if (!_.isEmpty(data)) { + + /////////////////////////////////////////////////// + // This code is based on this example: + // http://bl.ocks.org/yuuniverse4444/8325617 + // Thanks yuuniverse4444 + + var margin = {top: 20, right: 20, bottom: 30, left: 60}, + width = 500 - margin.left - margin.right, + height = 400 - margin.top - margin.bottom; + + var x = d3.scale.ordinal().rangeRoundBands([0, width], .1), + yAbsolute = d3.scale.linear().rangeRound([height, 0]), + yRelative = d3.scale.linear().rangeRound([height, 0]); + + + var xAxis = d3.svg.axis().scale(x).orient("bottom"); + + var yAxisRelative = d3.svg.axis() + .scale(yRelative) + .orient("left") + .tickFormat(d3.format(".0%")); + + var yAxisAbsolute = d3.svg.axis() + .scale(yAbsolute) + .orient("left") + .tickFormat(d3.format("2s")); + + var svg = d3.select(element).append("svg") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + var color = d3.scale.ordinal() + .range(["#a5c5d5", "#88a1b3", "#967784", "#b06474", + "#b27476", "#b98574", "#cd8c65", "#cdad55", "#b4ac54"]); + + color.domain(d3.keys(data[0]).filter(function(key) { return key !== "type"; })); + + data.forEach(function(d) { + var mystate = d.type; + var y0 = 0; + d.columns = color.domain().map(function(name) { + return {mystate:mystate, name: name, y0: y0, y1: y0 += +d[name]}; + }); + + d.total = d.columns[d.columns.length - 1].y1; // the last row + d.pct = []; + + for (var i=0;i <d.columns.length;i ++ ){ + + var y_coordinate = +d.columns[i].y1/d.total; + var y_height1 = (d.columns[i].y1)/d.total; + var y_height0 = (d.columns[i].y0)/d.total; + var y_pct = y_height1 - y_height0; + d.pct.push({ + y_coordinate: y_coordinate, + y_height1: y_height1, + y_height0: y_height0, + name: d.columns[i].name, + mystate: d.type, + y_pct: y_pct + }); + } + }); + + x.domain(data.map(function(d) { return d.type; })); + yAbsolute.domain([0, d3.max(data, function(d) { return d.total; })]); // Absolute View scale + yRelative.domain([0,1]); // Relative View domain + + var absoluteView = false; // define a boolean variable, true is absolute view, + // false is relative view. Initial view is absolute + + svg.append("g") + .attr("class", "x axis") + .attr("transform", "translate(0," + height + ")") + .style("font-size", "12px") + .call(xAxis); + + //Define the rect of Relative + + var stateRelative = svg.selectAll(".relative") + .data(data) + .enter().append("g") + .attr("class", "relative") + .attr("transform", function(d) { + return "translate(" + "0 "+ ",0)"; + }); + + stateRelative.selectAll("rect") + .data(function(d) { + return d.pct; + }) + .enter().append("rect") + .attr("width", x.rangeBand()) + .attr("y", function(d) { + return yRelative(d.y_coordinate); + }) + .attr("x",function(d) {return x(d.mystate)}) + .attr("height", function(d) { + return yRelative(d.y_height0) - yRelative(d.y_height1); //distance + }) + .attr("fill", function(d){return color(d.name)}) + .attr("stroke","pink") + .attr("stroke-width",0.2) + .attr("id",function(d) {return d.mystate}) + .attr("class","relative") + .attr("id",function(d) {return d.mystate}) + .attr("name", function(d) { return d.name; }) + .style("pointer-events","visible"); + + stateRelative.selectAll("rect") + .on("mouseover", function(d){ + var xPos = parseFloat(d3.select(this).attr("x")); + var yPos = parseFloat(d3.select(this).attr("y")); + var height = parseFloat(d3.select(this).attr("height")) + var width = parseFloat(d3.select(this).attr("width")) + + var name = d3.select(this).attr("name"); + + d.previousFill = d3.select(this).attr("fill"); + var newFill = highlightColor(d.previousFill, 10); + d3.select(this).attr("fill", newFill); + + var tooltip = svg.append("text") + .attr("x",xPos + width/2) + .attr("y",yPos + height/2) + .attr("id","tooltip") + .style("pointer-events","none") + .style("font-size", "12px") + .style("text-anchor","middle") + .style("alignment-baseline","middle") + .text(name + ": " + Math.floor(d.y_pct.toFixed(2)*100) + "%"); + + var tooltipBox = tooltip[0][0].getBBox(), + tooltipWidth = tooltipBox.width + 16, + tooltipHeight = tooltipBox.height + 16; + + var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", xPos + width/2 - tooltipWidth/2); + rect.setAttribute("y", yPos + height/2 - tooltipHeight/2); + rect.setAttribute("rx", 4); + rect.setAttribute("ry", 4); + rect.setAttribute("id", "tooltipBox"); + rect.setAttribute("width", tooltipWidth); + rect.setAttribute("height", tooltipHeight); + rect.setAttribute("fill", "white"); + rect.setAttribute("fill-opacity", 0.7); + rect.setAttribute("stroke", "black"); + rect.setAttribute("stroke-width", 0.2); + rect.setAttribute("style", "pointer-events:none;text-anchor:middle;alignment-baseline:midele"); + svg[0][0].insertBefore(rect, tooltip[0][0]); + }) + .on("mouseout",function(d){ + svg.select("#tooltip").remove(); + svg.select("#tooltipBox").remove(); + d3.select(this).attr("fill",d.previousFill); + }); + + // End of define rect of relative + + // define rect for absolute + + var stateAbsolute= svg.selectAll(".absolute") + .data(data) + .enter().append("g") + .attr("class", "absolute") + .attr("transform", function(d) { return "translate(" + "0" + ",0)"; }); + + stateAbsolute.selectAll("rect") + .data(function(d) { return d.columns}) + .enter().append("rect") + .attr("width", x.rangeBand()) + .attr("y", function(d) { + + return yAbsolute(d.y1); + }) + .attr("x",function(d) { + return x(d.mystate) + }) + .attr("height", function(d) { + return yAbsolute(d.y0) - yAbsolute(d.y1); + }) + .attr("fill", function(d){ + return color(d.name) + }) + .attr("id",function(d) { + return d.mystate + }) + .attr("class","absolute") + .style("pointer-events","visible") + .attr("opacity",0) // initially it is invisible, i.e. start with Absolute View + .on("mouseover", function(d){ + var xPos = parseFloat(d3.select(this).attr("x")); + var yPos = parseFloat(d3.select(this).attr("y")); + var height = parseFloat(d3.select(this).attr("height")) + var width = parseFloat(d3.select(this).attr("width")) + + d.previousFill = d3.select(this).attr("fill"); + var newFill = highlightColor(d.previousFill, 10); + d3.select(this).attr("fill", newFill); + + svg.append("text") + .attr("x",xPos + width/2) + .attr("y",yPos + height/2) + .attr("class","tooltip") + .style("pointer-events","none") + .text(Math.floor((d.y1-d.y0).toFixed(2))); + }) + .on("mouseout",function(d){ + svg.select(".tooltip").remove(); + d3.select(this).attr("fill",d.previousFill); + }); + + //define two different scales, but one of them will always be hidden. + svg.append("g") + .attr("class", "y axis absolute") + .style("font-size", "12px") + .call(yAxisAbsolute) + .append("text") + .attr("transform", "rotate(-90)") + .attr("y", 6) + .attr("dy", ".71em") + .style("text-anchor", "end"); + + svg.append("g") + .attr("class", "y axis relative") + .style("font-size", "12px") + .call(yAxisRelative) + .append("text") + .attr("transform", "rotate(-90)") + .attr("y", 6) + .attr("dy", ".71em") + .style("text-anchor", "end"); + + svg.select(".y.axis.absolute").style("opacity",0); + + // end of define absolute + + var clickButton = svg.selectAll(".clickButton") + .data([30,30]) + .enter().append("g") + .attr("class","clickButton") + .style("cursor", "pointer"); + + clickButton.append("text") + .attr("x", width - 8) + .attr("y", -10) + .attr("dy", ".35em") + .style("text-anchor", "end") + .text("Switch View") + .style("font-size", "12px") + .attr("fill","blue") + .attr("id","clickChangeView" + side); + + // start with relative view + Transition2Relative(); + + // Switch view on click the clickButton + d3.selectAll("#"+ "clickChangeView" + side) + .on("click",function(){ + if(absoluteView){ // absolute, otherwise relative + Transition2Relative(); + } else { + Transition2Absolute(); + } + absoluteView = !absoluteView // change the current view status + }); + + function Transition2Absolute(){ + // Currently it is Relative + stateRelative.selectAll("rect").transition().style("opacity",0).style("visibility","hidden"); + stateAbsolute.selectAll("rect").transition().style("opacity",1).style("visibility","visible"); + svg.select(".y.axis.relative").transition().style("opacity",0); + svg.select(".y.axis.absolute").transition().style("opacity",1); + } + + function Transition2Relative(){ + // Currently it is absolute + stateAbsolute.selectAll("rect").transition().style("opacity",0).style("visibility","hidden"); + stateRelative.selectAll("rect").transition().style("opacity",1).style("visibility","visible"); + svg.select(".y.axis.absolute").transition().style("opacity",0); + svg.select(".y.axis.relative").transition().style("opacity",1); + } + + } + + /////////////////////////////////////////////////// + } + }; + +}); diff --git a/src/components/visualizers/stacked-bars/stacked-bars.html b/src/components/visualizers/stacked-bars/stacked-bars.html new file mode 100644 index 0000000..9ee7ee7 --- /dev/null +++ b/src/components/visualizers/stacked-bars/stacked-bars.html @@ -0,0 +1 @@ +<div data-bind="stackedBars: {timeseriesData: timeseriesData, side: side}"></div> diff --git a/src/components/visualizers/stacked-bars/stacked-bars.js b/src/components/visualizers/stacked-bars/stacked-bars.js new file mode 100644 index 0000000..3478467 --- /dev/null +++ b/src/components/visualizers/stacked-bars/stacked-bars.js @@ -0,0 +1,19 @@ +define(function(require) { + + var ko = require('knockout'), + templateMarkup = require('text!./stacked-bars.html'), + buildMatrix = require('converters.matrix-data'); + + require('./bindings'); + + function StackedBars(params) { + this.rawData = params.data; + this.timeseriesData = ko.computed(function() { + var rd = ko.unwrap(this.rawData); + return buildMatrix(rd); + }, this); + this.side = ko.unwrap(params.side); + } + + return { viewModel: StackedBars, template: templateMarkup }; +}); diff --git a/src/css/070_stacked_bars.css b/src/css/070_stacked_bars.css new file mode 100644 index 0000000..e2c4a8d --- /dev/null +++ b/src/css/070_stacked_bars.css @@ -0,0 +1,29 @@ +/*.axis { + font: 13px sans-serif; + font-weight: bold; + fill: #444; +} + +.axis path { + fill: none; + stroke: #444; + shape-rendering: crispEdges; +} + +.clickButton { + cursor: pointer; + text-decoration: normal; + font-weight: normal; +} +.clickButton text:hover { + text-decoration: underline; +} + +.x.axis path { + display: none; +} + +.tooltip{ + text-anchor: middle; +} +*/ \ No newline at end of file diff --git a/src/layouts/compare/index.js b/src/layouts/compare/index.js index 694fbd5..b78c1fc 100644 --- a/src/layouts/compare/index.js +++ b/src/layouts/compare/index.js @@ -12,11 +12,13 @@ 'components/a-b-compare/a-b-compare', 'components/a-b-compare/compare-sunburst', 'components/a-b-compare/compare-timeseries', + 'components/a-b-compare/compare-stacked-bars', ], bundles: { // If you want parts of the site to load on demand, remove them from the 'include' list // above, and group them into bundles here. 'sunburst': ['components/visualizers/sunburst/sunburst'], + 'stacked-bars': ['components/visualizers/stacked-bars/stacked-bars'], 'dygraphs-timeseries': ['components/visualizers/dygraphs-timeseries/dygraphs-timeseries'], } } diff --git a/src/lib/utils.js b/src/lib/utils.js index f657055..9be63e3 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -48,7 +48,7 @@ }, formatDate: function (d) { - return d ? d.format('YYYY-MM-DD') : '(invalid)'; + return d ? moment(d).format('YYYY-MM-DD') : '(invalid)'; }, /** -- To view, visit https://gerrit.wikimedia.org/r/214036 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: Iba4cc5c4145e1dfa1d7d5d47c09bc4247c7a383b Gerrit-PatchSet: 1 Gerrit-Project: analytics/dashiki Gerrit-Branch: master Gerrit-Owner: Mforns <mfo...@wikimedia.org> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits