CQS - ANIMATED percentile chart & brushing
This patch enhances the big chart in the Summary section (percentile distribution) in a few ways: 1. better positioning / anti-clipping when browser is resized 2. bars are animated on the Y axis 3. support for selecting a range of percentiles to interactively see how many students have scored within that range. This is refered to as "brushing" in the test plan 4. code is optimized and no longer fully re-renders on updates Closes CNVS-16595 TEST PLAN ---- ---- - create a quiz with a large number of submissions so we get some healthy data for the chart - go to new quiz stats Testing the animation: - just keep an eye on the chart as the page loads - verify that you see the animation of every bar growing up on Y axis Testing positioning / scaling: - try resizing the browser a bit by bit (make it narrower) and make sure that to a certain extent, the chart stays visible and shrinks in size (x-axis ticks may get clipped, not much we can do about that) Testing the brush: - verify that when you hover over the chart, you see a tip telling you you could use ur cursor to make a selection - try making a selection: + you should be able to "brush" the chart, and as you do, you read a sentence below with some information about the number of students who scored within the range of percentiles you selected + grab the selection around, and verify that the sentence updates with the correct information - single-click on any percentile: + you should see a similar, but some-what reworded, version of the sentence highlighting students who scored *exactly* that + also, the brush should automatically "clip" to cover the entire bar (e.g, it gets shaded without you having to manually make the selection) + try clicking the 0 and 100 percentile bars, make sure they're OK Change-Id: Id0b5cfdc60e42214845c191f90ca42edd7d4b859 Reviewed-on: https://gerrit.instructure.com/43993 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Ryan Taylor <rtaylor@instructure.com> QA-Review: Trevor deHaan <tdehaan@instructure.com> Product-Review: Derek DeVries <ddevries@instructure.com>
This commit is contained in:
parent
e00396cd2c
commit
fbe72e558c
|
@ -50,6 +50,26 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.x.brush {
|
||||
fill: rgba($contrastColor, 0.1);
|
||||
}
|
||||
|
||||
text.brush-stats {
|
||||
fill: $primaryFg;
|
||||
transition: opacity 0.25s ease 0;
|
||||
|
||||
&.unused {
|
||||
opacity: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text.brush-stats.unused {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.report-generator.busy {
|
||||
|
|
|
@ -30,6 +30,8 @@ $whitespace: 12px;
|
|||
|
||||
$brandColor: $canvas-primary;
|
||||
|
||||
$primaryFg: #333;
|
||||
|
||||
// $active and $inactive variables should be used for coloring interactive sets
|
||||
// in which elements can be "activated" and the rest would become deactivated,
|
||||
// like navigation pills or tabs.
|
||||
|
|
|
@ -26,11 +26,11 @@ define(function(require) {
|
|||
},
|
||||
|
||||
addTitle: function(svg, title) {
|
||||
svg.append('title').text(title);
|
||||
return svg.append('title').text(title);
|
||||
},
|
||||
|
||||
addDescription: function(svg, description) {
|
||||
svg.append('text')
|
||||
return svg.append('text')
|
||||
.attr('fill', 'transparent')
|
||||
.attr('font-size', '0px')
|
||||
.text(description);
|
||||
|
|
|
@ -3,58 +3,61 @@ define(function(require) {
|
|||
var React = require('react');
|
||||
var ChartMixin = require('../../mixins/chart');
|
||||
var d3 = require('d3');
|
||||
var _ = require('lodash');
|
||||
var I18n = require('i18n!quiz_statistics.summary');
|
||||
var max = d3.max;
|
||||
var sum = d3.sum;
|
||||
var throttle = _.throttle;
|
||||
|
||||
var MARGIN_T = 0;
|
||||
var MARGIN_R = 0;
|
||||
var MARGIN_B = 40;
|
||||
var MARGIN_L = -40;
|
||||
var WIDTH = 960;
|
||||
var HEIGHT = 220;
|
||||
var BAR_WIDTH = 10;
|
||||
var BAR_MARGIN = 0.25;
|
||||
var MARGIN_R = 18;
|
||||
var MARGIN_B = 60;
|
||||
var MARGIN_L = 34;
|
||||
var CHART_BRUSHING_TIP_LABEL = I18n.t(
|
||||
'chart_brushing_tip',
|
||||
'Tip: you can focus a specific segment of the chart by making a ' +
|
||||
'selection using your cursor.'
|
||||
);
|
||||
|
||||
var ScorePercentileChart = React.createClass({
|
||||
mixins: [ ChartMixin.mixin ],
|
||||
|
||||
propTypes: {
|
||||
scores: React.PropTypes.object.isRequired,
|
||||
scoreAverage: React.PropTypes.number.isRequired,
|
||||
pointsPossible: React.PropTypes.number.isRequired,
|
||||
scores: React.PropTypes.object,
|
||||
scoreAverage: React.PropTypes.number,
|
||||
pointsPossible: React.PropTypes.number,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
scores: {}
|
||||
scores: {},
|
||||
animeDelay: 500,
|
||||
animeDuration: 500,
|
||||
width: 960,
|
||||
height: 220,
|
||||
barPadding: 0.25,
|
||||
minBarHeight: 1
|
||||
};
|
||||
},
|
||||
|
||||
createChart: function(node, props) {
|
||||
var svg, width, height, x, y, xAxis;
|
||||
var highest;
|
||||
var visibilityThreshold;
|
||||
var data = this.chartData(props);
|
||||
var avgScore = props.scoreAverage / props.pointsPossible * 100.0;
|
||||
var labelOptions = this.calculateStudentStatistics(avgScore, data);
|
||||
var svg, width, height, x, xAxis;
|
||||
var brush, brushCaption, onBrushed;
|
||||
var barContainer;
|
||||
|
||||
width = WIDTH - MARGIN_L - MARGIN_R;
|
||||
height = HEIGHT - MARGIN_T - MARGIN_B;
|
||||
highest = max(data);
|
||||
width = props.width - MARGIN_L - MARGIN_R;
|
||||
height = props.height - MARGIN_T - MARGIN_B;
|
||||
|
||||
x = d3.scale.ordinal().rangeRoundBands([0, BAR_WIDTH * data.length], BAR_MARGIN);
|
||||
y = d3.scale.linear().range([0, highest]).rangeRound([height, 0]);
|
||||
// the x scale is static since it will always represent the 100
|
||||
// percentiles, so we can avoid recalculating it on every update:
|
||||
x = d3.scale.ordinal().rangeRoundBands([0, width], props.barPadding, 0);
|
||||
x.domain(d3.range(0, 101, 1));
|
||||
|
||||
x.domain(data.map(function(d, i) {
|
||||
return i;
|
||||
}));
|
||||
|
||||
y.domain([0, highest]);
|
||||
|
||||
xAxis = d3.svg.axis().scale(x).orient("bottom").tickValues(d3.range(0, 101, 10)).tickFormat(function(d) {
|
||||
return d + '%';
|
||||
});
|
||||
xAxis = d3.svg.axis()
|
||||
.scale(x)
|
||||
.orient("bottom")
|
||||
.tickValues(d3.range(0, 101, 10))
|
||||
.tickFormat(function(d) { return d + '%'; });
|
||||
|
||||
svg = d3.select(node)
|
||||
.attr('role', 'document')
|
||||
|
@ -62,48 +65,128 @@ define(function(require) {
|
|||
.attr('width', width + MARGIN_L + MARGIN_R)
|
||||
.attr('height', height + MARGIN_T + MARGIN_B)
|
||||
.attr('viewBox', "0 0 " + (width + MARGIN_L + MARGIN_R) + " " + (height + MARGIN_T + MARGIN_B))
|
||||
.attr('preserveAspectRatio', 'xMinYMax')
|
||||
.append('g')
|
||||
.attr('transform', "translate(" + MARGIN_L + "," + MARGIN_T + ")")
|
||||
.attr('preserveAspectRatio', 'xMidYMax')
|
||||
.append('g');
|
||||
|
||||
ChartMixin.addTitle(svg, I18n.t('chart_title', 'Score percentiles chart'));
|
||||
ChartMixin.addDescription(svg, I18n.t('audible_chart_description',
|
||||
this.title = ChartMixin.addTitle(svg, '');
|
||||
this.description = ChartMixin.addDescription(svg, '');
|
||||
|
||||
svg.append('g')
|
||||
.attr('class', 'x axis')
|
||||
.attr('aria-hidden', true)
|
||||
.attr('transform', "translate(5," + height + ")")
|
||||
.call(xAxis);
|
||||
|
||||
barContainer = svg.append('g');
|
||||
|
||||
brushCaption = svg
|
||||
.append('text')
|
||||
.attr('class', 'brush-stats unused')
|
||||
.attr('text-anchor', 'left')
|
||||
.attr('aria-hidden', true)
|
||||
.attr('dy', '.35em')
|
||||
.attr('y', height + 40)
|
||||
.attr('x', 0)
|
||||
.text(CHART_BRUSHING_TIP_LABEL);
|
||||
|
||||
onBrushed = throttle(this.onBrushed, 100, {
|
||||
leading: false,
|
||||
trailing: true
|
||||
});
|
||||
|
||||
brush = d3.svg.brush()
|
||||
.x(x)
|
||||
.clamp(true)
|
||||
.on("brush", onBrushed);
|
||||
|
||||
this.brushContainer = svg.append("g")
|
||||
.attr("class", "x brush")
|
||||
.call(brush);
|
||||
|
||||
this.brushContainer.selectAll("rect")
|
||||
.attr("y", props.minBarHeight)
|
||||
.attr("height", height + props.minBarHeight);
|
||||
|
||||
this.x = x;
|
||||
this.height = height;
|
||||
this.barContainer = barContainer;
|
||||
this.brushCaption = brushCaption;
|
||||
this.brush = brush;
|
||||
|
||||
this.updateChart(svg, props);
|
||||
|
||||
return svg;
|
||||
},
|
||||
|
||||
updateChart: function(svg, props) {
|
||||
var labelOptions;
|
||||
var data = this.chartData = this.calculateChartData(props);
|
||||
var avgScore = props.scoreAverage / props.pointsPossible * 100.0;
|
||||
labelOptions = this.calculateStudentStatistics(avgScore, data);
|
||||
|
||||
this.title.text(I18n.t('chart_title', 'Score percentiles chart'));
|
||||
this.description.text(I18n.t('audible_chart_description',
|
||||
'%{above_average} students scored above or at the average, and %{below_average} below.', {
|
||||
above_average: labelOptions.aboveAverage,
|
||||
below_average: labelOptions.belowAverage
|
||||
}));
|
||||
|
||||
svg.append('g')
|
||||
.attr('class', 'x axis')
|
||||
.attr('aria-hidden', true)
|
||||
.attr('transform', "translate(0," + height + ")")
|
||||
.call(xAxis);
|
||||
this.renderBars(this.barContainer, props);
|
||||
|
||||
visibilityThreshold = Math.min(highest / 100, 0.5);
|
||||
if (!this.brush.empty()) {
|
||||
this.onBrushed();
|
||||
}
|
||||
},
|
||||
|
||||
svg.selectAll('rect.bar')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.attr("class", 'bar')
|
||||
.attr('x', function(d, i) {
|
||||
return x(i);
|
||||
}).attr('width', x.rangeBand).attr('y', function(d) {
|
||||
return y(d + visibilityThreshold);
|
||||
}).attr('height', function(d) {
|
||||
return height - y(d + visibilityThreshold);
|
||||
});
|
||||
renderBars: function(svg, props) {
|
||||
var height, x, y, bars;
|
||||
var highest;
|
||||
var visibilityThreshold;
|
||||
var data = this.chartData;
|
||||
|
||||
return svg;
|
||||
height = this.height;
|
||||
highest = max(data);
|
||||
|
||||
x = this.x;
|
||||
|
||||
y = d3.scale.linear()
|
||||
.range([0, highest])
|
||||
.rangeRound([height, 0]);
|
||||
|
||||
y.domain([0, highest]);
|
||||
|
||||
visibilityThreshold = Math.max(highest / 100, props.minBarHeight);
|
||||
|
||||
bars = svg.selectAll('rect.bar').data(data);
|
||||
|
||||
bars.enter()
|
||||
.append('rect')
|
||||
.attr("class", 'bar')
|
||||
.attr('x', function(d, i) { return x(i); })
|
||||
.attr('y', height)
|
||||
.attr('width', x.rangeBand)
|
||||
.attr('height', 0);
|
||||
|
||||
bars.transition()
|
||||
.delay(props.animeDelay)
|
||||
.duration(props.animeDuration)
|
||||
.attr('y', function(d) { return y(d) + visibilityThreshold; })
|
||||
.attr('height', function(d) {
|
||||
return height - y(d) + visibilityThreshold;
|
||||
});
|
||||
|
||||
bars.exit().remove();
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* Calculate the number of students who scored above, or at, the average
|
||||
* and those who did lower.
|
||||
*
|
||||
* @param {Number} _avgScore
|
||||
* @param {Number[]} scores
|
||||
* The flattened score percentile data-set (see #chartData()).
|
||||
* The flattened score percentile data-set (see #calculateChartData()).
|
||||
*
|
||||
* @return {Object} out
|
||||
* @return {Number} out.aboveAverage
|
||||
|
@ -127,7 +210,10 @@ define(function(require) {
|
|||
};
|
||||
},
|
||||
|
||||
chartData: function(props) {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
calculateChartData: function(props) {
|
||||
var percentile, upperBound;
|
||||
var set = [];
|
||||
var scores = props.scores || {};
|
||||
|
@ -147,6 +233,88 @@ define(function(require) {
|
|||
return set;
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* Update the focus caption with the number of students who are allocated to
|
||||
* the "brushed" percentile range (which could be just a single point as
|
||||
* well).
|
||||
*/
|
||||
onBrushed: function() {
|
||||
var i, a, b;
|
||||
var studentCount;
|
||||
var message;
|
||||
|
||||
var brush = this.brush;
|
||||
var brushCaption = this.brushCaption;
|
||||
var data = this.chartData;
|
||||
|
||||
var range = this.x.range();
|
||||
var extent = brush.extent();
|
||||
|
||||
// invert the brush extent against the X scale and locate the percentiles
|
||||
// we're focusing, a and b:
|
||||
for (i = 0; i < range.length; ++i) {
|
||||
if (a === undefined && range[i] > extent[0]) {
|
||||
a = Math.max(i-1, 0);
|
||||
}
|
||||
|
||||
if (b === undefined && range[i] > extent[1]) {
|
||||
b = Math.max(i-1, 0);
|
||||
}
|
||||
|
||||
if (a !== undefined && b !== undefined) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// if at this point we still didn't find the end percentile, it means
|
||||
// the brush extends beyond the last percentile (100%) so just choose that
|
||||
if (b === undefined) {
|
||||
b = range.length - 1;
|
||||
|
||||
if (a === undefined) { // single-point brush
|
||||
a = b;
|
||||
}
|
||||
}
|
||||
|
||||
if (a - b === 0) { // single percentile
|
||||
studentCount = data[a] || 0;
|
||||
|
||||
message = I18n.t('students_who_got_a_certain_score', {
|
||||
zero: 'No students have received a score of %{score}%.',
|
||||
one: 'One student has received a score of %{score}%.',
|
||||
other: '%{count} students have received a score of %{score}%.',
|
||||
}, {
|
||||
count: studentCount,
|
||||
score: a
|
||||
});
|
||||
|
||||
// redraw the brush to cover the percentile bar:
|
||||
this.brush.extent([ range[a], range[b] + this.x.rangeBand() ]);
|
||||
this.brush(this.brushContainer);
|
||||
}
|
||||
else { // percentile range
|
||||
studentCount = 0;
|
||||
|
||||
for (i = a; i <= b; ++i) {
|
||||
studentCount += data[i] || 0;
|
||||
}
|
||||
|
||||
message = I18n.t('students_who_scored_in_a_range', {
|
||||
zero: 'No students have scored between %{start} and %{end}%.',
|
||||
one: 'One student has scored between %{start} and %{end}%.',
|
||||
other: '%{count} students have scored between %{start} and %{end}%.',
|
||||
}, {
|
||||
count: studentCount,
|
||||
start: a,
|
||||
end: b
|
||||
});
|
||||
}
|
||||
|
||||
brushCaption.text(message).attr('class', 'brush-stats');
|
||||
},
|
||||
|
||||
render: ChartMixin.defaults.render
|
||||
});
|
||||
|
||||
|
|
|
@ -1,26 +1,46 @@
|
|||
define(function(require) {
|
||||
var Subject = require('jsx!views/summary/score_percentile_chart');
|
||||
var testRects = function(bars) {
|
||||
bars.forEach(function(bar) {
|
||||
var index = bar.i;
|
||||
rect = find('rect.bar:nth-of-type(' + (index+1) + ')');
|
||||
expect(rect.x.baseVal.value).toEqual(bar.x, 'rect[' + index + '][x]');
|
||||
expect(rect.y.baseVal.value).toEqual(bar.y, 'rect[' + index + '][y]');
|
||||
expect(rect.height.baseVal.value).toEqual(bar.h, 'rect['+index+'][h]');
|
||||
});
|
||||
var tick;
|
||||
|
||||
var testRects = function(bars, done) {
|
||||
setTimeout(function() {
|
||||
bars.forEach(function(bar) {
|
||||
var index = bar.i;
|
||||
rect = find('rect.bar:nth-of-type(' + (index+1) + ')');
|
||||
expect(rect.x.baseVal.value).toEqual(bar.x, 'rect[' + index + '][x]');
|
||||
expect(rect.y.baseVal.value).toEqual(bar.y, 'rect[' + index + '][y]');
|
||||
expect(rect.height.baseVal.value).toEqual(bar.h, 'rect['+index+'][h]');
|
||||
});
|
||||
|
||||
if (done) done();
|
||||
}, (tick += 5));
|
||||
};
|
||||
|
||||
describe('ScorePercentileChart', function() {
|
||||
this.reactSuite({
|
||||
type: Subject
|
||||
type: Subject,
|
||||
initialProps: {
|
||||
animeDelay: 0,
|
||||
animeDuration: 0,
|
||||
width: 960,
|
||||
height: 240,
|
||||
minBarHeight: 2
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(function() {
|
||||
tick = 10;
|
||||
});
|
||||
|
||||
it('should render', function() {
|
||||
expect(subject.isMounted()).toEqual(true);
|
||||
});
|
||||
|
||||
it('should render', function() {});
|
||||
it('should render a bar for each percentile', function() {
|
||||
expect(findAll('rect.bar').length).toEqual(101);
|
||||
});
|
||||
|
||||
it('bar height should be based on score frequency', function() {
|
||||
it('bar height should be based on score frequency', function(done) {
|
||||
setProps({
|
||||
scores: {
|
||||
15: 1,
|
||||
|
@ -32,14 +52,14 @@ define(function(require) {
|
|||
});
|
||||
|
||||
testRects([
|
||||
{ i: 15, x: 187, y: 88, h: 92 },
|
||||
{ i: 25, x: 277, y: 88, h: 92 },
|
||||
{ i: 44, x: 448, y: 88, h: 92 },
|
||||
{ i: 59, x: 583, y: -2, h: 182 },
|
||||
]);
|
||||
{ i: 15, x: 136, y: 92, h: 92 },
|
||||
{ i: 25, x: 226, y: 92, h: 92 },
|
||||
{ i: 44, x: 397, y: 92, h: 92 },
|
||||
{ i: 59, x: 532, y: 2, h: 182 },
|
||||
], done);
|
||||
});
|
||||
|
||||
it('should update', function() {
|
||||
it('should update', function(done) {
|
||||
var rect;
|
||||
|
||||
setProps({
|
||||
|
@ -49,24 +69,24 @@ define(function(require) {
|
|||
});
|
||||
|
||||
testRects([
|
||||
{ i: 15, x: 187, y: -2, h: 182 },
|
||||
{ i: 25, x: 277, y: 178, h: 2 },
|
||||
]);
|
||||
{ i: 15, x: 136, y: 2, h: 182 },
|
||||
{ i: 25, x: 226, y: 182, h: 2 },
|
||||
], function updatePropsAnotherTime() {
|
||||
setProps({
|
||||
scores: {
|
||||
15: 1,
|
||||
25: 1,
|
||||
}
|
||||
});
|
||||
|
||||
setProps({
|
||||
scores: {
|
||||
15: 1,
|
||||
25: 1,
|
||||
}
|
||||
testRects([
|
||||
{ i: 15, x: 136, y: 2, h: 182 },
|
||||
{ i: 25, x: 226, y: 2, h: 182 },
|
||||
], done);
|
||||
});
|
||||
|
||||
testRects([
|
||||
{ i: 15, x: 187, y: -2, h: 182 },
|
||||
{ i: 25, x: 277, y: -2, h: 182 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render scores for the 0th and 100th percentiles', function() {
|
||||
it('should render scores for the 0th and 100th percentiles', function(done) {
|
||||
setProps({
|
||||
scores: {
|
||||
0: 1,
|
||||
|
@ -75,9 +95,9 @@ define(function(require) {
|
|||
});
|
||||
|
||||
testRects([
|
||||
{ i: 0, x: 52, y: 142, h: 38 },
|
||||
{ i: 100, x: 952, y: -2, h: 182 },
|
||||
]);
|
||||
{ i: 0, x: 1, y: 146, h: 38 },
|
||||
{ i: 100, x: 901, y: 2, h: 182 },
|
||||
], done);
|
||||
});
|
||||
|
||||
describe('#calculateStudentStatistics', function() {
|
||||
|
|
Loading…
Reference in New Issue