Commit fe2b7258 by Tim Krones

Implement plot block.

parent 8d8a8f96
......@@ -185,7 +185,7 @@ class RatingBlock(MCQBlock):
list_values_provider=QuestionnaireAbstractBlock.choice_values_provider,
list_style='set', # Underered, unique items. Affects the UI editor.
)
editable_fields = MCQBlock.editable_fields + ('low', 'high')
editable_fields = MCQBlock.editable_fields + ('low', 'high', 'name')
@property
def all_choice_values(self):
......
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
import json
import logging
from opaque_keys.edx.keys import CourseKey
from xblock.core import XBlock
from xblock.fields import String, Scope
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import (
StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin
)
from .sub_api import sub_api
loader = ResourceLoader(__name__)
log = logging.getLogger(__name__)
# Make '_' a no-op so we can scrape strings
def _(text):
return text
@XBlock.needs('i18n')
class PlotBlock(StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin, XBlock):
"""
XBlock that displays plot that summarizes answers to scale questions.
"""
CATEGORY = 'sb-plot'
STUDIO_LABEL = _(u"Plot")
# Settings
display_name = String(
display_name=_("Plot title"),
default="Plot",
scope=Scope.content
)
plot_label = String(
display_name=_("Plot label"),
help=_("Label for default overlay that shows student's answers to scale questions"),
default="yours",
scope=Scope.content
)
point_color_default = String(
display_name=_("Point color (default overlay)"),
help=_("Point color to use for default overlay"),
default="green",
scope=Scope.content
)
point_color_average = String(
display_name=_("Point color (average overlay)"),
help=_("Point color to use for average overlay"),
default="blue",
scope=Scope.content
)
q1_label = String(
display_name=_("Quadrant I"),
help=_(
"Label for the first quadrant. "
"Plot uses counter-clockwise numbering starting in the top right quadrant."
),
default="Q1",
scope=Scope.content
)
q2_label = String(
display_name=_("Quadrant II"),
help=_(
"Label for the second quadrant. "
"Plot uses counter-clockwise numbering starting in the top right quadrant."
),
default="Q2",
scope=Scope.content
)
q3_label = String(
display_name=_("Quadrant III"),
help=_(
"Label for the third quadrant. "
"Plot uses counter-clockwise numbering starting in the top right quadrant."
),
default="Q3",
scope=Scope.content
)
q4_label = String(
display_name=_("Quadrant IV"),
help=_(
"Label for the fourth quadrant. "
"Plot uses counter-clockwise numbering starting in the top right quadrant."
),
default="Q4",
scope=Scope.content
)
claims = String(
display_name=_("Claims and associated questions"),
help=_(
'Claims and questions that should be included in the plot. '
'Each line defines a triple of the form "claim, q1, q2", '
'where "claim" is arbitrary text that represents a claim (must be quoted using double-quotes), '
'and "q1" and "q2" are IDs of scale questions. '
),
default="",
multiline_editor=True,
resettable_editor=False
)
editable_fields = (
'display_name', 'plot_label', 'point_color_default', 'point_color_average',
'q1_label', 'q2_label', 'q3_label', 'q4_label', 'claims'
)
@property
def default_claims(self):
if not self.claims:
return json.dumps([])
mentoring_block = self.get_parent().get_parent()
claims = []
for line in self.claims.split('\n'):
claim, q1, q2 = line.split(', ')
r1, r2 = None, None
for step in mentoring_block.steps:
for student_result in step.student_results:
child_name, child_result = student_result
if child_name == q1:
r1 = child_result['submission']
if child_name == q2: # Don't use "elif" here (would break cases in which q1 == q2)
r2 = child_result['submission']
if r1 is not None and r2 is not None:
break
if r1 is not None and r2 is not None:
break
claims.append([claim, r1, r2])
return json.dumps(claims)
@property
def average_claims(self):
if not self.claims:
return json.dumps([])
course_id = unicode(getattr(self.runtime, 'course_id', 'course_id'))
course_key = CourseKey.from_string(course_id)
course_key_str = unicode(course_key)
mentoring_block = self.get_parent().get_parent()
question_ids, questions = mentoring_block.question_ids, mentoring_block.questions
claims = []
for line in self.claims.split('\n'):
claim, q1, q2 = line.split(', ')
r1, r2 = None, None
for question_id, question in zip(question_ids, questions):
if question.name == q1:
r1 = self._average_response(course_key_str, question, question_id)
if question.name == q2:
r2 = self._average_response(course_key_str, question, question_id)
if r1 is not None and r2 is not None:
break
claims.append([claim, r1, r2])
return json.dumps(claims)
def _average_response(self, course_key_str, question, question_id):
from .tasks import _get_answer # Import here to avoid circular dependency
# 1. Obtain block_type for question
question_type = question.scope_ids.block_type
# 2. Obtain submissions for question using course_key_str, block_id, block_type
submissions = sub_api.get_all_submissions(course_key_str, question_id, question_type)
# 3. Extract responses from submissions for question and sum them up
answer_cache = {}
response_total = 0
num_submissions = 0 # Can't use len(submissions) because submissions is a generator
for submission in submissions:
answer = _get_answer(question, submission, answer_cache)
response_total += int(answer)
num_submissions += 1
# 4. Calculate average response for question
if num_submissions:
return response_total / float(num_submissions)
def clean_studio_edits(self, data):
# FIXME: Use this to clean data.claims (remove leading/trailing whitespace, etc.)
pass
def validate_field_data(self, validation, data):
# FIXME: Use this to validate data.claims:
# - Each line should be of the form "claim, q1, q2" (no quotes)
# - Entries for "claim", "q1", "q2" must point to existing blocks
pass
def author_preview_view(self, context):
return Fragment(
u"<p>{}</p>".format(
_(u"This block displays a plot that summarizes answers to scale questions.")
)
)
def mentoring_view(self, context):
return self.student_view(context)
def student_view(self, context=None):
""" Student View """
context = context.copy() if context else {}
context['hide_header'] = True
context['self'] = self
fragment = Fragment()
fragment.add_content(loader.render_template('templates/html/plot.html', context))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/plot.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/d3.min.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/plot.js'))
fragment.initialize_js('PlotBlock')
return fragment
.quadrants label {
font-weight: bold;
}
.overlays {
float: right;
}
.quadrants input, .overlays input {
background-color: rgb(204, 204, 204);
}
function PlotBlock(runtime, element) {
// Define margins
var margins = {top: 20, right: 20, bottom: 20, left: 20};
// Define width and height of SVG viewport
var width = 440;
var height = 440;
// Define dimensions of plot area
var plotWidth = width - margins.left - margins.right;
var plotHeight = height - margins.top - margins.bottom;
// Preselect target DOM element for plot.
// This is necessary because when using a CSS selector,
// d3.select will select the *first* element that matches the selector (in document traversal order),
// which leads to unintended consequences when multiple plot blocks are present.
var plotTarget = $(element).find('.sb-plot').get(0);
// Create SVG viewport with nested group for plot area
var svgContainer = d3.select(plotTarget)
.append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", "translate(" + margins.left + ", " + margins.right + ")");
// Create scales to use for axes and data
var xScale = d3.scale.linear()
.domain([0, 100])
.range([0, plotWidth]);
var yScale = d3.scale.linear()
.domain([100, 0])
.range([0, plotHeight]);
// Create axes
var xAxis = d3.svg.axis()
.scale(xScale)
.orient("bottom");
var yAxis = d3.svg.axis()
.scale(yScale)
.orient("left");
// Create SVG group elements for axes and call the xAxis and yAxis functions
var xAxisGroup = svgContainer.append("g")
.attr("transform", "translate(0, " + plotHeight / 2 + ")")
.call(xAxis);
var yAxisGroup = svgContainer.append("g")
.attr("transform", "translate(" + plotWidth / 2 + ", 0)")
.call(yAxis);
// Claims
var defaultClaims = $('.plot-default', element).data('claims');
var averageClaims = $('.plot-average', element).data('claims');
// Colors
var defaultColor = $('.plot-default', element).data('point-color');
var averageColor = $('.plot-average', element).data('point-color');
// Quadrant labels
var q1Label = $('.plot-quadrants', element).data('q1-label');
var q2Label = $('.plot-quadrants', element).data('q2-label');
var q3Label = $('.plot-quadrants', element).data('q3-label');
var q4Label = $('.plot-quadrants', element).data('q4-label');
// Event handlers
var defaultButton = $('.plot-default', element);
var averageButton = $('.plot-average', element);
function toggleOverlay(claims, color, klass) {
var selector = "." + klass;
var selection = svgContainer.selectAll(selector);
if (selection.empty()) {
svgContainer.selectAll(selector)
.data(claims)
.enter()
.append("circle")
.attr("class", klass)
.attr("title", function(d) {
return d[0] + ": " + d[1] + ", " + d[2];
})
.attr("cx", function(d) {
return xScale(d[1]);
})
.attr("cy", function(d) {
return yScale(d[2]);
})
.attr("r", 5)
.style("fill", color);
} else {
selection.remove();
}
}
function toggleBorderColor(button, color) {
var $button = $(button);
var overlayOn = $button.data("overlay-on");
if (overlayOn) {
$button.css("border-color", "rgb(237, 237, 237)"); // Default color: grey
} else {
$button.css("border-color", color);
}
$button.data("overlay-on", !overlayOn);
}
defaultButton.on('click', function() {
toggleOverlay(defaultClaims, defaultColor, 'claim-default');
toggleBorderColor(this, defaultColor);
});
averageButton.on('click', function() {
toggleOverlay(averageClaims, averageColor, 'claim-average');
toggleBorderColor(this, averageColor);
});
var quadrantsButton = $('.plot-quadrants', element);
function toggleQuadrantLabels() {
var selection = svgContainer.selectAll(".quadrant-label");
var quadrantLabelsOn = quadrantsButton.val() === 'On';
if (quadrantLabelsOn) {
selection.remove();
quadrantsButton.val("Off");
quadrantsButton.css("border-color", "red");
} else {
var labels = [
[0.75 * plotWidth, 0, q1Label],
[0.25 * plotWidth, 0, q2Label],
[0.25 * plotWidth, plotHeight, q3Label],
[0.75 * plotWidth, plotHeight, q4Label]
];
selection.data(labels)
.enter()
.append("text")
.attr("class", 'quadrant-label')
.attr("x", function(d) {
return d[0];
})
.attr("y", function(d) {
return d[1];
})
.text(function(d) {
return d[2];
})
.attr("text-anchor", "middle")
.attr("font-family", "sans-serif")
.attr("font-size", "16px")
.attr("fill", "black");
quadrantsButton.val("On");
quadrantsButton.css("border-color", "green");
}
}
quadrantsButton.on('click', function() {
toggleQuadrantLabels();
});
// Show default overlay initially
defaultButton.trigger('click');
// Quadrant labels are off initially; color of button for toggling them should reflect this
quadrantsButton.css("border-color", "red");
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -37,6 +37,7 @@ from .message import (
)
from problem_builder.mixins import EnumerableChildMixin, MessageParentMixin, StepParentMixin
from problem_builder.mrq import MRQBlock
from problem_builder.plot import PlotBlock
from problem_builder.table import MentoringTableBlock
......@@ -146,7 +147,7 @@ class MentoringStepBlock(
return [
NestedXBlockSpec(AnswerBlock, boilerplate='studio_default'),
MCQBlock, RatingBlock, MRQBlock, HtmlBlockShim,
AnswerRecapBlock, MentoringTableBlock,
AnswerRecapBlock, MentoringTableBlock, PlotBlock
] + additional_blocks
@property
......
<div class="sb-plot">
<div class="quadrants">
<label>
Quadrant labels
<input type="button"
class="plot-quadrants"
data-q1-label="{{ self.q1_label }}"
data-q2-label="{{ self.q2_label }}"
data-q3-label="{{ self.q3_label }}"
data-q4-label="{{ self.q4_label }}"
value="Off">
</label>
</div>
<div class="overlays">
<h3>Compare your plot to others!</h3>
<input type="button"
class="plot-default"
data-claims="{{ self.default_claims }}"
data-point-color="{{ self.point_color_default }}"
data-overlay-on="false"
value="{{ self.plot_label }}"
/>
<input type="button"
class="plot-average"
data-claims="{{ self.average_claims }}"
data-point-color="{{ self.point_color_average }}"
data-overlay-on="false"
value="Average"
/>
</div>
</div>
......@@ -45,6 +45,8 @@ BLOCKS = [
'sb-step = problem_builder.step:MentoringStepBlock',
'sb-review-step = problem_builder.step:ReviewStepBlock',
'sb-plot = problem_builder.plot:PlotBlock',
'pb-table = problem_builder.table:MentoringTableBlock',
'pb-column = problem_builder.table:MentoringTableColumn',
'pb-answer = problem_builder.answer:AnswerBlock',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment