Ember Quiz Stats - FIMB & MDropdowns - "No Answer"

Add support to display the "No Answer" and "Other Answers" (FIMB-only)
in the answer bar chart for those question types, and make the bars
visible for answers with 0% responses.

Totally unrelated change: now using the icons Blake made for us.

Closes CNVS-13033, CNVS-13071, CNVS-12961

TEST PLAN
---- ----

  - create a quiz with FIMB and MDropdowns questions
  - take the quiz by a number of students and type in different answers
  - make an API request to stats:
    GET /api/v1/courses/:course_id/quizzes/:quiz_id/statistics
    - verify you get the documented metrics and they have the proper
      values
  - visit the ember quizzes page and verify that:
    - the charts are still working
    - the bar chart now displays a bar for "No Answer" responses and
      another bar for "Other answers" if your students provided any
      (only applies to FIMB questions)
  - new bar/tooltip improvement: answers that have 0% now take up at
    least 5 pixels of the chart so that you can hover over them and get
    the tooltip to show, earlier it was really difficult because they
    were too tiny

Change-Id: Ie9a7ea6bb539fa9d120679997d4a20737f8ad03c
Reviewed-on: https://gerrit.instructure.com/34953
Tested-by: Jenkins <jenkins@instructure.com>
QA-Review: Caleb Guanzon <cguanzon@instructure.com>
Reviewed-by: Derek DeVries <ddevries@instructure.com>
Product-Review: Ahmad Amireh <ahmad@instructure.com>
This commit is contained in:
Ahmad Amireh 2014-05-18 08:56:24 +03:00
parent 32801bd45d
commit a9b4ab3090
6 changed files with 75 additions and 75 deletions

View File

@ -1,6 +1,4 @@
define [
'ember'
'../multiple_choice/answer_bars_controller'
], (Em, BaseController) ->
BaseController.extend
answers: Em.computed.alias('ratioCalculator.answerPool')
], (BaseController) ->
BaseController

View File

@ -1,5 +1,10 @@
define [ '../questions_controller' ], (Base) ->
define [
'../questions_controller'
'i18n!quiz_statistics'
], (Base, I18n) ->
Base.extend
answers: Em.computed.alias('ratioCalculator.answerPool')
# @property [Object] activeAnswer
#
# The answer set that's currently being inspected. This would point to an
@ -16,9 +21,17 @@ define [ '../questions_controller' ], (Base) ->
# Tell our ratio calculator to use the newly-activated answer set's
# "answer_matches" as the pool of answers to calculate ratio from
updateCalculatorAnswerPool: (->
@set('ratioCalculator.answerPool', @get('activeAnswer.answer_matches'))
@set('ratioCalculator.answerPool', @get('activeAnswer.answers'))
).observes('activeAnswer')
correctResponseRatioLabel: (->
I18n.t('correct_multiple_response_ratio',
'%{ratio}% of your students responded correctly.',
{
ratio: Math.round(@get('correctResponseRatio') * 100)
})
).property('correctResponseRatio')
actions:
activateAnswer: (blankId) ->
@get('answerSets').forEach (answerSet) ->

View File

@ -1,8 +1,26 @@
define [ 'ember', 'ember-data' ], (Em, DS) ->
respondentCount = 0
define [ 'ember', 'ember-data', 'i18n!quiz_statistics' ], (Em, DS, I18n) ->
participantCount = 0
calculateResponseRatio = (answer) ->
Em.Util.round(answer.responses / respondentCount * 100)
Em.Util.round(answer.responses / participantCount * 100)
decorate = (quiz_statistics) ->
participantCount = quiz_statistics.submission_statistics.unique_count
quiz_statistics.question_statistics.forEach (question_statistics) ->
question_statistics.id = "#{question_statistics.id}"
# assign a FK between question and quiz statistics
question_statistics.quiz_statistics_id = quiz_statistics.id
decorateAnswers(question_statistics.answers)
if question_statistics.answer_sets
question_statistics.answer_sets.forEach(decorateAnswerSet)
# set of FKs between quiz and question stats
quiz_statistics.question_statistic_ids =
Em.A(quiz_statistics.question_statistics).mapBy('id')
decorateAnswers = (answers) ->
(answers || []).forEach (answer) ->
@ -17,42 +35,21 @@ define [ 'ember', 'ember-data' ], (Em, DS) ->
delete answer.weight
decorateAnswerSet = (answerSet) ->
# TODO: remove once the API output is consistent
if answerSet.answers
answerSet.answer_matches = answerSet.answers
delete answerSet.answers
(answerSet.answer_matches || []).forEach (answer, i) ->
answer.id = "#{answerSet.id}_#{i}"
(answerSet.answers || []).forEach (answer, i) ->
answer.ratio = calculateResponseRatio(answer)
if answer.id == 'none'
answer.text = I18n.t('no_answer', 'No Answer')
else if answer.id == 'other'
answer.text = I18n.t('unknown_answer', 'Something Else')
DS.ActiveModelSerializer.extend
extractArray: (store, type, payload, id, requestType) ->
decorate(payload.quiz_statistics[0])
data = {
quizStatistics: payload.quiz_statistics
questionStatistics: payload.quiz_statistics[0].question_statistics
}
data.questionStatistics.forEach (questionStatistics) ->
questionStatistics.id = "#{questionStatistics.id}"
# assign a FK between question and quiz statistics
questionStatistics.quiz_statistics_id = data.quizStatistics[0].id
@decorate(payload.quiz_statistics[0])
# set of FKs between quiz and question stats
data.quizStatistics[0].question_statistic_ids = Em.A(data.questionStatistics).mapBy('id')
@_super(store, type, data, id, requestType)
# TODO: remove once deprecated (the new stats API will expose these items)
decorate: (quiz_statistics) ->
participantCount = quiz_statistics.submission_statistics.unique_count
quiz_statistics.question_statistics.forEach (questionStatistics) ->
respondentCount = questionStatistics.responses || participantCount
decorateAnswers(questionStatistics.answers)
if questionStatistics.answer_sets
questionStatistics.answer_sets.forEach(decorateAnswerSet)
@_super(store, type, data, id, requestType)

View File

@ -35,15 +35,6 @@
</div>
</div>
<ol class="answer-drilldown detail-section">
{{#if activeAnswer}}
{{#each answer in activeAnswer.answer_matches}}
<li {{bind-attr class="answer.correct"}}>
<span class="answer-response-ratio">{{answer.ratio}}<sup>%</sup></span>
<div class="answer-text">
{{{answer.text}}}
</div>
</li>
{{/each}}
{{/if}}
</ol>
{{#if activeAnswer}}
{{partial "quiz/statistics/questions/multiple_choice/answers"}}
{{/if}}

View File

@ -13,23 +13,23 @@
<thead>
<tr>
<th>
<i class="icon-mark-as-read"></i>
<i class="icon-quiz-stats-avg"></i>
{{t 'stats_mean' 'Avg Score'}}
</th>
<th>
<i class="icon-mark-as-read"></i>
<i class="icon-quiz-stats-high"></i>
{{t 'stats_high' 'High Score'}}
</th>
<th>
<i class="icon-mark-as-read"></i>
<i class="icon-quiz-stats-low"></i>
{{t 'stats_low' 'Low Score'}}
</th>
<th>
<i class="icon-mark-as-read"></i>
<i class="icon-quiz-stats-deviation"></i>
{{t 'stats_stdev' 'Std. Deviation'}}
</th>
<th>
<i class="icon-clock"></i>
<i class="icon-quiz-stats-time"></i>
{{t 'stats_avg_time' 'Avg Time'}}
</th>
</tr>

View File

@ -1,16 +1,15 @@
define [
'ember'
'underscore'
'vendor/d3.v3'
'../../../../../mixins/views/chart_inspector'
'compiled/behaviors/tooltip'
], ({View}, _, d3, ChartInspectorMixin) ->
], (Em, d3, ChartInspectorMixin) ->
# This view renders a bar chart that plots each question answer versus the
# amount of responses each answer has received.
#
# Also, each bar that represents an answer provides a tooltip on hover that
# displays more information.
View.extend ChartInspectorMixin,
Em.View.extend ChartInspectorMixin,
# @config [Integer] [barWidth=30]
# Width of the bars in the chart in pixels.
barWidth: 30
@ -30,12 +29,11 @@ define [
at: 'center top-8'
renderChart: (->
data = @get('controller.chartData')
$container = @$().parent()
data = Em.A(@get('controller.chartData'))
sz = _.reduce data, ((sum, item) -> sum + item.y), 0
sz = data.reduce(((sum, item) -> sum + item.y), 0)
highest = Math.max.apply(Math, _.pluck(data, 'y'))
highest = Math.max.apply(Math, data.mapBy('y'))
margin = { top: 0, right: 0, bottom: 0, left: 0 }
width = @get('w') - margin.left - margin.right
@ -43,13 +41,14 @@ define [
barWidth = @get('barWidth')
barMargin = @get('barMargin')
xOffset = @get('xOffset')
visibilityThreshold = Math.min(1.0, highest / 100.0)
x = d3.scale.ordinal()
.rangeRoundBands([0, @get('barWidth') * sz], .025)
y = d3.scale.linear()
.range([height + visibilityThreshold, 0])
.range([height, 0])
visibilityThreshold = Math.max(5, y(highest) / 100.0)
svg = d3.select(@$('svg')[0])
.attr("width", width + margin.left + margin.right)
@ -57,8 +56,7 @@ define [
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
x.domain _.map data, (d, i) ->
d.label || i
x.domain data.map (d, i) -> d.label || i
y.domain [ 0, sz ]
svg.selectAll('.bar')
@ -67,19 +65,23 @@ define [
.attr("class", (d) => @classifyChartBar(d))
.attr("x", (d, i) -> i*(barWidth + barMargin) + xOffset)
.attr("width", barWidth)
.attr("y", (d) -> y(d.y + visibilityThreshold))
.attr("height", (d) -> height - y(d.y + visibilityThreshold))
.attr("y", (d) -> y(d.y) - visibilityThreshold)
.attr("height", (d) -> height - y(d.y) + visibilityThreshold)
.inspectable(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.
if noAnswer = _.where(data, { id: 'none' })[0]
otherAnswers = Em.A([
data.findBy('id', 'other')
data.findBy('id', 'none')
]).compact()
if otherAnswers.length
@renderStripePattern(svg)
index = data.indexOf(noAnswer)
svg.selectAll('.bar.bar-striped')
.data([ noAnswer ])
.data(otherAnswers)
.enter().append('rect')
.attr('class', 'bar bar-striped')
# We need to inline the fill style because we are referencing an
@ -90,7 +92,7 @@ define [
.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', (d) -> index*(barWidth + barMargin) + xOffset + 1)
.attr('x', (d) -> data.indexOf(d) * (barWidth + barMargin) + xOffset + 1)
.attr('width', barWidth-2)
.attr('y', (d) -> y(d.y + visibilityThreshold) + 1)
.attr('height', (d) -> height - y(d.y + visibilityThreshold) - 2)
@ -99,8 +101,7 @@ define [
).on('didInsertElement')
removeChart: (->
if @svg
@svg.remove()
@svg.remove() if @svg
).on('willDestroyElement')
renderStripePattern: (svg) ->