CQS - Answer distribution charts scaling

Also did some refactoring and added a number of comments in the chart
generator to ease future maintenance.

Removed "grunt-notify" module because it's too annoying and hardly
useful.

Closes CNVS-16518

TEST PLAN
---- ----

  - create a quiz with two FIMB questions
    + the first question should have over 10 possible answers (could be
      right or wrong, doesn't matter)
    + the other question could have like 3-4, like normal ones
  - take the quiz by quizard or manually; we'd like to have a number of
    responses to give the chart some shape
  - turn on new stats FFlag
  - go to stats page
    + verify the chart scales for the first question as described by the
      ticket and its AC
    + verify that the chart for the second question looks almost like
      the one on master/the normal one .. e.g, the bars are 30 pixels
      wide
    + make sure you can trigger the tooltips for every bar out there

Change-Id: Ifb1ee3e2359f0c3d77d733bba6262a46df8ced0b
Reviewed-on: https://gerrit.instructure.com/43404
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Derek DeVries <ddevries@instructure.com>
QA-Review: Trevor deHaan <tdehaan@instructure.com>
Product-Review: Ahmad Amireh <ahmad@instructure.com>
This commit is contained in:
Ahmad Amireh 2014-10-28 13:00:40 +02:00
parent a038f68301
commit bacb70e3fc
10 changed files with 610 additions and 268 deletions

View File

@ -11,7 +11,6 @@ grunt.loadNpmTasks('grunt-connect-proxy');
grunt.loadNpmTasks('grunt-contrib-jasmine');
grunt.loadNpmTasks('grunt-jsduck');
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-notify');
grunt.loadNpmTasks('grunt-newer');
grunt.loadNpmTasks('grunt-sass');

View File

@ -1497,28 +1497,6 @@
}
}
},
"grunt-notify": {
"version": "0.3.1",
"from": "grunt-notify@0.3.1",
"resolved": "https://registry.npmjs.org/grunt-notify/-/grunt-notify-0.3.1.tgz",
"dependencies": {
"semver": {
"version": "3.0.1",
"from": "semver@>=3.0.1-0 <4.0.0-0",
"resolved": "https://registry.npmjs.org/semver/-/semver-3.0.1.tgz"
},
"stack-parser": {
"version": "0.0.1",
"from": "stack-parser@>=0.0.1-0 <0.1.0-0",
"resolved": "https://registry.npmjs.org/stack-parser/-/stack-parser-0.0.1.tgz"
},
"which": {
"version": "1.0.5",
"from": "which@>=1.0.5-0 <1.1.0-0",
"resolved": "https://registry.npmjs.org/which/-/which-1.0.5.tgz"
}
}
},
"grunt-react": {
"version": "0.9.0",
"from": "grunt-react@0.9.0",

View File

@ -35,7 +35,6 @@
"grunt-contrib-watch": "0.6.1",
"grunt-jsduck": "1.0.1",
"grunt-newer": "0.7.0",
"grunt-notify": "0.3.1",
"grunt-sass": "0.14.1",
"grunt-template-jasmine-requirejs": "git://github.com/amireh/grunt-template-jasmine-requirejs.git#0.2.0",
"jasmine_react": "1.1.0",

View File

@ -16,10 +16,6 @@ define(function(require) {
DEBUG.update = app.update;
});
DEBUG.expose('react', 'React');
DEBUG.expose('util/round', 'round');
DEBUG.expose('stores/statistics', 'statisticsStore');
Root.DEBUG = DEBUG;
Root.d = DEBUG;

View File

@ -9,239 +9,8 @@ define(function(require) {
var ScreenReaderContent = require('jsx!../../components/screen_reader_content');
var Text = require('jsx!../../components/text');
var round = require('../../util/round');
var mapBy = _.map;
var findWhere = _.findWhere;
var compact = _.compact;
var Chart = React.createClass({
mixins: [ ChartMixin.mixin, ChartInspectorMixin.mixin ],
tooltipOptions: {
position: {
my: 'center+15 bottom',
at: 'center top-8'
}
},
getDefaultProps: function() {
return {
answers: [],
/**
* @property {Number} [barWidth=30]
* Width of the bars in the chart in pixels.
*/
barWidth: 30,
/**
* @property {Number} [barMargin=1]
*
* Whitespace to offset the bars by, in pixels.
*/
barMargin: 1,
xOffset: 16,
yAxisLabel: '',
xAxisLabels: false,
linearScale: true,
width: 'auto',
height: 120
};
},
createChart: function(node, props) {
var otherAnswers;
var data = props.answers;
var container = this.getDOMNode();
var sz = data.reduce(function(sum, item) {
return sum + item.y;
}, 0);
var highest = d3.max(mapBy(data, 'y'));
var width, height;
var margin = { top: 0, right: 0, bottom: 0, left: 0 };
if (props.width === 'auto') {
width = container.offsetWidth;
}
else {
width = parseInt(props.width, 10);
}
width -= margin.left - margin.right;
height = props.height - margin.top - margin.bottom;
var barWidth = props.barWidth;
var barMargin = props.barMargin;
var xOffset = props.xOffset;
var x = d3.scale.ordinal()
.rangeRoundBands([0, barWidth * sz], 0.025);
var y = d3.scale.linear()
.range([height, 0]);
var visibilityThreshold = Math.max(5, y(highest) / 100.0);
var svg = d3.select(node)
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.attr('aria-hidden', true)
.attr('role', 'presentation')
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
var classifyChartBar = this.classifyChartBar;
x.domain(data.map(function(d, i) { return d.label || i; }));
y.domain([ 0, sz ]);
var bars = svg.selectAll('.bar')
.data(data)
.enter().append('rect')
.attr("class", function(d) {
return classifyChartBar(d);
})
.attr("x", function(d, i) {
return i * (barWidth + barMargin) + xOffset;
})
.attr("width", barWidth)
.attr("y", function(d) {
return y(d.y) - visibilityThreshold;
})
.attr("height", function(d) {
return height - y(d.y) + visibilityThreshold;
});
ChartInspectorMixin.makeInspectable(bars, this);
// If the special "No Answer" is present, we represent it as a diagonally-
// striped bar, but to do that we need to render the <svg:pattern> that
// generates the stripes and use that as a fill pattern, and we also need
// to create the <svg:rect> that will be filled with that pattern.
otherAnswers = compact([
findWhere(data, { id: 'other' }),
findWhere(data, { id: 'none' })
]);
if (otherAnswers.length) {
this.renderStripePattern(svg);
svg.selectAll('.bar.bar-striped')
.data(otherAnswers)
.enter().append('rect')
.attr('class', 'bar bar-striped')
// We need to inline the fill style because we are referencing an
// inline pattern (#diagonalStripes) which is unreachable from a CSS
// directive.
//
// See this link [StackOverflow] for more info: http://bit.ly/1uDTqyn
.attr('style', 'fill: url(#diagonalStripes);')
// remove 2 pixels from width and height, and offset it by {1,1} on
// both axes to "contain" it inside the margins of the bg rect
.attr('x', function(d) {
return data.indexOf(d) * (barWidth + barMargin) + xOffset + 1;
})
.attr('width', barWidth-2)
.attr('y', function(d) {
return y(d.y + visibilityThreshold) + 1;
})
.attr('height', function(d) {
return height - y(d.y + visibilityThreshold) - 2;
});
}
return svg;
},
renderStripePattern: function(svg) {
svg.append('pattern')
.attr('id', 'diagonalStripes')
.attr('width', 5)
.attr('height', 5)
.attr('patternTransform', 'rotate(45 0 0)')
.attr('patternUnits', 'userSpaceOnUse')
.append('g')
.append('path')
.attr('d', 'M0,0 L0,10');
},
classifyChartBar: function(answer) {
if (answer.correct) {
return 'bar bar-highlighted';
} else {
return 'bar';
}
},
updateChart: ChartMixin.mixin.updateChart,
removeChart: ChartMixin.mixin.removeChart,
render: function() {
return (
<div>
<div ref="inspector" />
<svg ref="chart" className="chart"></svg>
</div>
);
}
});
// A table for screen-readers that provides an alternative view of the data.
var Table = React.createClass({
getDefaultProps: function() {
return {
answers: []
};
},
render: function() {
return (
<table>
<caption>
<Text phrase="audible_description">
This table lists all the answers to the question, along with the
number of responses they have received.
</Text>
</caption>
<tbody>
{this.props.answers.map(this.renderEntry)}
</tbody>
</table>
);
},
renderEntry: function(answer, position) {
return (
<tr key={'answer-'+answer.id}>
<td scope="col">
{I18n.t('audible_answer_position', 'Answer %{position}:', { position: position+1 }) + ' '}
{answer.text + '. ' /* make sure there's a sentence delimiter */}
{answer.correct &&
<em>
{' '}
{I18n.t('audible_correct_answer_indicator', 'This is a correct answer.')}
</em>
}
</td>
<td>
{I18n.t('audible_answer_response_count', {
zero: 'No responses.',
one: 'One response.',
other: '%{count} responses.'
}, {
count: answer.responses
})}
</td>
</tr>
);
}
});
var Chart = require('jsx!./answer_bars/chart');
var Table = require('jsx!./answer_bars/table');
var AnswerBars = React.createClass({
propTypes: {

View File

@ -0,0 +1,231 @@
/** @jsx React.DOM */
define(function(require) {
var React = require('react');
var d3 = require('d3');
var _ = require('lodash');
var ChartMixin = require('../../../mixins/chart');
var ChartInspectorMixin = require('../../../mixins/components/chart_inspector');
var mapBy = _.map;
var findWhere = _.findWhere;
var compact = _.compact;
var Chart = React.createClass({
mixins: [ ChartMixin.mixin, ChartInspectorMixin.mixin ],
tooltipOptions: {
position: {
my: 'center+15 bottom',
at: 'center top-8'
}
},
getDefaultProps: function() {
return {
answers: [],
/**
* @property {Number} [barWidth=30]
*
* Prefered width of the bars in pixels. This value will be respected
* only if the chart's container is wide enough to contain all the
* bars with that specified value.
*/
barWidth: 30,
/**
* @property {Number} [padding=0.05]
*
* An amount of whitespace/padding to render between each pair of bars.
* Value is in the range of [0,1] and represents a percentage of the
* bar's *calculated* width.
*
* So, a padding of 0.5 means a number of pixels equal to half the bar's
* width will be rendered as whitespace.
*/
padding: 0.05,
/**
* @property {Number} [visibilityThreshold=5]
*
* An amount of pixels to use for a bar's height in the special case
* where an answer has received no responses (e.g, y=0).
*
* Setting this to a positive number would show the bar for such answers
* so that the tooltip can be triggered.
*/
visibilityThreshold: 5,
/**
* @property {String|Number} [width="auto"]
*
* Width of the chart in pixels. If set to "auto", the width will be
* equal to that of the element containing the chart.
*/
width: 'auto',
height: 120
};
},
createChart: function(node, props) {
var otherAnswers;
var width;
var data = props.answers;
var barCount = data.length;
// We need the container element to calculate the chart's width in case
// it is set to "auto".
var container = this.getDOMNode();
// The highest response count, for defining the y-axis range boundaries.
var highest = d3.max(mapBy(data, 'y'));
var visibilityThreshold;
// Uniform width of the bars that will represent answer sets. This amount
// will consider @props.barWidth only if the chart is large enough to
// accommodate all the bars with the requested width. Otherwise, we'll
// use the largest width possible that satisfies rendering *all* the
// answer sets.
//
// Also, a reasonable amount of padding will be rendered between each pair
// of bars that is equal to a 1/4 of a bar's calculated width.
var effectiveBarWidth;
// Scales for the x and y axes.
var x, y;
var xUpperBound;
var svg;
var bars;
if (props.width === 'auto') {
width = container.offsetWidth;
}
else {
width = parseInt(props.width, 10);
}
// Need to figure out the upper boundary of the range for the x axis scale;
// if the container is wide enough to display all the answer bars with the
// requested width, we'll clamp the viewport width to be narrow enough to
// just fit so that the bars are not widely spaced out. Otherwise, we use
// the entire viewport.
if (props.barWidth * barCount <= width) {
xUpperBound = props.barWidth * barCount;
}
else {
xUpperBound = width;
}
x = d3.scale.ordinal()
.domain(d3.range(barCount))
.rangeRoundBands([ 0, xUpperBound ], props.padding, 0 /* edge padding */);
effectiveBarWidth = d3.min([ x.rangeBand(), props.barWidth ]);
y = d3.scale.linear()
.domain([ 0, highest ])
.range([ props.height, 0 ]);
visibilityThreshold = Math.max(props.visibilityThreshold, y(highest) / 100.0);
svg = d3.select(node)
.attr('width', width)
.attr('height', props.height)
.attr('aria-hidden', true)
.attr('role', 'presentation')
.append('g');
bars = svg.selectAll('.bar')
.data(data)
.enter().append('rect')
.attr("class", this.classifyChartBar)
.attr("width", effectiveBarWidth)
.attr("x", function(d, i) {
return x(i);
})
.attr("y", function(d) {
return y(d.y) - visibilityThreshold;
})
.attr("height", function(d) {
return props.height - y(d.y) + visibilityThreshold;
});
ChartInspectorMixin.makeInspectable(bars, this);
// If the special "No Answer" is present, we represent it as a diagonally-
// striped bar, but to do that we need to render the <svg:pattern> that
// generates the stripes and use that as a fill pattern, and we also need
// to create the <svg:rect> that will be filled with that pattern.
otherAnswers = compact([
findWhere(data, { id: 'other' }),
findWhere(data, { id: 'none' })
]);
if (otherAnswers.length) {
this.renderStripePattern(svg);
svg.selectAll('.bar.bar-striped')
.data(otherAnswers)
.enter().append('rect')
.attr('class', 'bar bar-striped')
// We need to inline the fill style because we are referencing an
// inline pattern (#diagonalStripes) which is unreachable from a CSS
// directive.
//
// See this link [StackOverflow] for more info: http://bit.ly/1uDTqyn
.attr('style', 'fill: url(#diagonalStripes);')
// remove 2 pixels from width and height, and offset it by {1,1} on
// both axes to "contain" it inside the margins of the bg rect
.attr('x', function(d) {
var i = data.indexOf(d); // d is coming from otherAnswers
return x(i) + 1;
})
.attr('width', effectiveBarWidth-2)
.attr('y', function(d) {
return y(d.y + visibilityThreshold) + 1;
})
.attr('height', function(d) {
return props.height - y(d.y + visibilityThreshold) - 2;
});
}
return svg;
},
renderStripePattern: function(svg) {
svg.append('pattern')
.attr('id', 'diagonalStripes')
.attr('width', 5)
.attr('height', 5)
.attr('patternTransform', 'rotate(45 0 0)')
.attr('patternUnits', 'userSpaceOnUse')
.append('g')
.append('path')
.attr('d', 'M0,0 L0,10');
},
classifyChartBar: function(answer) {
if (answer.correct) {
return 'bar bar-highlighted';
} else {
return 'bar';
}
},
updateChart: ChartMixin.mixin.updateChart,
removeChart: ChartMixin.mixin.removeChart,
render: function() {
return (
<div>
<div ref="inspector" />
<svg ref="chart" className="chart"></svg>
</div>
);
}
});
return Chart;
});

View File

@ -0,0 +1,63 @@
/** @jsx React.DOM */
define(function(require) {
var React = require('react');
var I18n = require('i18n!quiz_statistics.answer_bars_chart');
var Text = require('jsx!../../../components/text');
// A table for screen-readers that provides an alternative view of the data.
var Table = React.createClass({
getDefaultProps: function() {
return {
answers: []
};
},
render: function() {
return (
<table>
<caption>
<Text phrase="audible_description">
This table lists all the answers to the question, along with the
number of responses they have received.
</Text>
</caption>
<tbody>
{this.props.answers.map(this.renderEntry)}
</tbody>
</table>
);
},
renderEntry: function(answer, position) {
return (
<tr key={'answer-'+answer.id}>
<td scope="col">
{I18n.t('audible_answer_position', 'Answer %{position}:', { position: position+1 }) + ' '}
{answer.text + '. ' /* make sure there's a sentence delimiter */}
{answer.correct &&
<em>
{' '}
{I18n.t('audible_correct_answer_indicator', 'This is a correct answer.')}
</em>
}
</td>
<td>
{I18n.t('audible_answer_response_count', {
zero: 'No responses.',
one: 'One response.',
other: '%{count} responses.'
}, {
count: answer.responses
})}
</td>
</tr>
);
}
});
return Table;
});

View File

@ -34,17 +34,20 @@ define(function(require) {
return answerSet.answers;
},
componentDidUpdate: function(prevProps, prevState) {
componentDidMount: function() {
// Make sure we always have an active answer set:
if (!this.state.answerSetId && this.props.answerSets) {
this.setState({ answerSetId: (this.props.answerSets[0] || {}).id });
}
this.ensureAnswerSetSelection(this.props);
},
componentWillReceiveProps: function(nextProps) {
this.ensureAnswerSetSelection(nextProps);
},
render: function() {
var crr = calculateResponseRatio(this.getAnswerPool(), this.props.participantCount, {
questionType: this.props.questionType
});
var answerPool = this.getAnswerPool();
return(
<Question expanded={this.props.expanded}>
@ -68,10 +71,10 @@ define(function(require) {
ratio: round(crr * 100.0, 0)
})} />
<AnswerBars answers={this.getAnswerPool()} />
<AnswerBars answers={answerPool} />
</div>
{this.props.expanded && <Answers answers={this.getAnswerPool()} />}
{this.props.expanded && <Answers answers={answerPool} />}
</Question>
);
},
@ -91,6 +94,12 @@ define(function(require) {
);
},
ensureAnswerSetSelection: function(props) {
if (!this.state.answerSetId && props.answerSets.length) {
this.setState({ answerSetId: props.answerSets[0].id });
}
},
switchAnswerSet: function(answerSetId, e) {
e.preventDefault();

View File

@ -13,7 +13,7 @@ module.exports = {
compiled_css: {
files: 'dist/*.css',
tasks: [ 'noop', 'notify:css' ],
tasks: [ 'noop' ],
options: {
livereload: {
port: 9224

View File

@ -0,0 +1,298 @@
{
"quiz_statistics": [
{
"id": "1",
"url": "http://localhost:3000/api/v1/courses/1/quizzes/32/statistics",
"html_url": "http://localhost:3000/courses/1/quizzes/32/statistics",
"generated_at": "2014-08-04T16:32:50Z",
"points_possible": 1,
"question_statistics": [
{
"id": "79",
"question_type": "fill_in_multiple_blanks_question",
"question_text": "<p>Hello. This should force a horizontal scroller cuz it's got MANY MANY MANY answers.</p>",
"position": 1,
"responses": 14,
"answered": 0,
"correct": 10,
"partially_correct": 0,
"incorrect": 4,
"answer_sets": [
{
"id": "bf0109538456c0c93d49334c032c4a0c",
"text": "Hair color",
"answers": [
{
"id": "1",
"text": "Red",
"correct": true,
"responses": 1
},
{
"id": "2",
"text": "Green",
"correct": true,
"responses": 1
},
{
"id": "3",
"text": "Blue",
"correct": false,
"responses": 0
},
{
"id": "4",
"text": "Teal",
"correct": true,
"responses": 5
},
{
"id": "5",
"text": "Purple",
"correct": false,
"responses": 0
},
{
"id": "6",
"text": "Yellow",
"correct": true,
"responses": 3
},
{
"id": "7",
"text": "Orange",
"correct": true,
"responses": 0
},
{
"id": "8",
"text": "Pink",
"correct": false,
"responses": 2
},
{
"id": "9",
"text": "Pink",
"correct": false,
"responses": 2
},
{
"id": "10",
"text": "A",
"correct": false,
"responses": 0
},
{
"id": "11",
"text": "B",
"correct": false,
"responses": 0
},
{
"id": "12",
"text": "C",
"correct": false,
"responses": 0
},
{
"id": "13",
"text": "D",
"correct": false,
"responses": 0
},
{
"id": "14",
"text": "E",
"correct": false,
"responses": 0
},
{
"id": "none",
"text": "No Answer",
"correct": false,
"responses": 2
}
]
},
{
"id": "bf0109538456c0c93d49334c032c4a0d",
"text": "Skin color",
"answers": [
{
"id": "1",
"text": "Red",
"correct": true,
"responses": 2
},
{
"id": "2",
"text": "Green",
"correct": true,
"responses": 0
},
{
"id": "3",
"text": "Blue",
"correct": false,
"responses": 2
},
{
"id": "4",
"text": "Teal",
"correct": true,
"responses": 3
},
{
"id": "5",
"text": "Purple",
"correct": false,
"responses": 0
},
{
"id": "6",
"text": "Yellow",
"correct": true,
"responses": 3
},
{
"id": "7",
"text": "Orange",
"correct": true,
"responses": 0
},
{
"id": "8",
"text": "Pink",
"correct": false,
"responses": 0
},
{
"id": "9",
"text": "Pink",
"correct": false,
"responses": 0
},
{
"id": "10",
"text": "A",
"correct": false,
"responses": 3
},
{
"id": "11",
"text": "B",
"correct": false,
"responses": 1
},
{
"id": "12",
"text": "C",
"correct": false,
"responses": 0
},
{
"id": "13",
"text": "D",
"correct": false,
"responses": 2
},
{
"id": "14",
"text": "E",
"correct": false,
"responses": 1
},
{
"id": "none",
"text": "No Answer",
"correct": false,
"responses": 0
}
]
}
]
},
{
"id": "11",
"question_type": "multiple_choice_question",
"question_text": "<p>Which?</p>",
"position": 1,
"responses": 156,
"answers": [
{
"id": "3866",
"text": "I am a very long description of an answer that should span multiple lines.",
"correct": true,
"responses": 129
},
{
"id": "2040",
"text": "b",
"correct": false,
"responses": 1
},
{
"id": "7387",
"text": "c",
"correct": false,
"responses": 26
},
{
"id": "4082",
"text": "d",
"correct": false,
"responses": 0
}
],
"answered_student_count": 156,
"top_student_count": 42,
"middle_student_count": 72,
"bottom_student_count": 42,
"correct_student_count": 129,
"incorrect_student_count": 27,
"correct_student_ratio": 0.8269230769230769,
"incorrect_student_ratio": 0.17307692307692307,
"correct_top_student_count": 42,
"correct_middle_student_count": 72,
"correct_bottom_student_count": 15,
"variance": 0.14312130177514806,
"stdev": 0.3783137610174233,
"difficulty_index": 0.8269230769230769,
"alpha": null,
"point_biserials": [
{
"answer_id": 3866,
"point_biserial": 0.7157094891780442,
"correct": true,
"distractor": false
},
{
"answer_id": 2040,
"point_biserial": -0.0608778335993478,
"correct": false,
"distractor": true
},
{
"answer_id": 7387,
"point_biserial": -0.7134960236217814,
"correct": false,
"distractor": true
},
{
"answer_id": 4082,
"point_biserial": null,
"correct": false,
"distractor": true
}
]
}
],
"submission_statistics": {
"unique_count": 14
},
"links": {
"quiz": "http://localhost:3000/api/v1/courses/1/quizzes/32"
}
}
]
}