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:
parent
32801bd45d
commit
a9b4ab3090
|
@ -1,6 +1,4 @@
|
|||
define [
|
||||
'ember'
|
||||
'../multiple_choice/answer_bars_controller'
|
||||
], (Em, BaseController) ->
|
||||
BaseController.extend
|
||||
answers: Em.computed.alias('ratioCalculator.answerPool')
|
||||
], (BaseController) ->
|
||||
BaseController
|
|
@ -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) ->
|
||||
|
|
|
@ -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)
|
|
@ -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}}
|
|
@ -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>
|
||||
|
|
|
@ -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) ->
|
||||
|
|
Loading…
Reference in New Issue