Commit 0ef235eb by Braden MacDonald

Simplify and consolidate color coding

parent a89833ea
...@@ -13,26 +13,48 @@ And it will then look like this (after a student has submitted their answers): ...@@ -13,26 +13,48 @@ And it will then look like this (after a student has submitted their answers):
![Screen shot of a Dashboard XBlock](img/dashboard-example.png) ![Screen shot of a Dashboard XBlock](img/dashboard-example.png)
Color Coding Rules:
-------------------
Authors can add a list of rules (one per line) that apply colors to the various
possible student answer values.
Rules are entered into the dashboard configuration "Color Coding Rules", one
rule per line. The first rule to match a given value will be used to color
that value.
The simplest rule looks like "3: red". With this line, if the student's answer
is 3, it will be shown in red in the report. Colors can be specified using any
valid CSS color value, e.g. "red", "#f00", "#ff0000", "rgb(255,0,0)", etc.
For more advanced rules, you can use an expression in terms of x such as
"x > 3: blue" or "0 <= x < 5: green" (green if x is greater than or equal to
zero but less than five).
You can also just specify a color on a line by itself, which will always match
any value (usually this would be the last line, as a "default" color).
Visual Representation Visual Representation
--------------------- ---------------------
The Dashboard also supports an optional visual representation. This is a The Dashboard also supports an optional visual representation. This is a
powerful feature, but setting it up is quite involved. powerful feature, but setting it up is a bit involved.
The end result is shown below. You can see a diagram, in which a colored arrow The end result is shown below. You can see a diagram, in which a colored arrow
appears as the student works through the various "Steps". Each "Step" is one appears as the student works through the various "Steps". Each "Step" is one
mentoring block, which contains several multiple choice questions. Based on the mentoring block, which contains several multiple choice questions. Based on the
average value of the student's choices, the step is given a color. average value of the student's choices, the step is given a color.
In this example, steps that have not been attempted are transparent, steps that In this example, steps that have not been attempted are transparent, and steps
have been attempted but with an average less than two are grey, and steps with that have been attempted are colored in according to the color coding rules.
an average higher than two are green.
![Screen shot of visual representation](img/dashboard-visual.png) ![Screen shot of visual representation](img/dashboard-visual.png)
To achieve the result shown above requires a set of "stacked" image files (one To achieve the result shown above requires a set of "stacked" image files (one
for each step), as well as an overlay image (in this case, the overlay image for each step), as well as an overlay image (in this case, the overlay image
contains all the text). contains all the text). For coloring to work, the images must be white where
color is desired.
To build this example, the images used look like this: To build this example, the images used look like this:
![Images Used](img/dashboard-visual-instructions.png) ![Images Used](img/dashboard-visual-instructions.png)
...@@ -40,10 +62,6 @@ To build this example, the images used look like this: ...@@ -40,10 +62,6 @@ To build this example, the images used look like this:
The block was configured to use these images as follows: The block was configured to use these images as follows:
![Screen shot of visual representation rule configuration](img/dashboard-visual-config.png) ![Screen shot of visual representation rule configuration](img/dashboard-visual-config.png)
Notice that step 2 has turned green due to the default rule "hueRotate 280" for
any steps with an average above 2, and the first step has a blurred, grey
background due to the first rule for average values less than 2.
The **Visual Representation Settings** used to define the visual representation The **Visual Representation Settings** used to define the visual representation
must be in JSON format. The supported entries are: must be in JSON format. The supported entries are:
...@@ -59,33 +77,6 @@ must be in JSON format. The supported entries are: ...@@ -59,33 +77,6 @@ must be in JSON format. The supported entries are:
layered images, with no effects applied. layered images, with no effects applied.
* **`"background"`**: (Optional) The URL of an image to be drawn behind the * **`"background"`**: (Optional) The URL of an image to be drawn behind the
layered images, with no effects applied. layered images, with no effects applied.
* **`"colorRules"`**: (Optional) The rules used to determine how the student's * **`"width"`**: (Important) The width of the images, in pixels (all images
average choice values affect each layer's image. Each rule is evaluated in should be the same size).
order, and the first matching rule is used. Each rule is a JSON object * **`"height"`**: (Important) The height of the images, in pixels
(`{ ... }`) that may consist of any of the following optional entries.
* `if`: An optional expression involving `x` (the student's average choice
value). If this is true, then this rule will be used. If this is false, the
rule will not be used. If no `if` clause is set, the rule will always be
used, unless an earlier rule matched first.
Examples:
- `"if": "x > 3"`
- `"if": "0 < x < 1"`
- `"if": "(x > 3) and (x < 6 or x > 100)"`
* `hueRotate`: If this rule matches, adjust the color of all parts of the
image that have some saturation. In the above example, this is used to
change from a turquiose color to a light green. Valid values are between 0
(no change) and 360. Note that this effect will *not* affect any parts of
the image have no saturation (i.e. are white, black, or any shade of grey).
Examples:
- `"hueRotate": "180"`
- `"hueRotate": "x * 30"`
* `blur`: If this rule matches, apply a gaussian blur to blur this layer's
image. The standard deviation of the blur must be specified.
Examples:
- `"blur": "3"`
- `"blur": "x"`
* `saturate`: If this rule matches, adjust the saturation of the image. `1`
means no change, and `0` means fully desaturated. Valid values are between
zero and one.
Examples:
- `"saturate": "0.2"`
doc/img/dashboard-configuration.png

92.4 KB | W: | H:

doc/img/dashboard-configuration.png

87.7 KB | W: | H:

doc/img/dashboard-configuration.png
doc/img/dashboard-configuration.png
doc/img/dashboard-configuration.png
doc/img/dashboard-configuration.png
  • 2-up
  • Swipe
  • Onion skin
doc/img/dashboard-example.png

74 KB | W: | H:

doc/img/dashboard-example.png

64.4 KB | W: | H:

doc/img/dashboard-example.png
doc/img/dashboard-example.png
doc/img/dashboard-example.png
doc/img/dashboard-example.png
  • 2-up
  • Swipe
  • Onion skin
doc/img/dashboard-visual-config.png

88.3 KB | W: | H:

doc/img/dashboard-visual-config.png

96.2 KB | W: | H:

doc/img/dashboard-visual-config.png
doc/img/dashboard-visual-config.png
doc/img/dashboard-visual-config.png
doc/img/dashboard-visual-config.png
  • 2-up
  • Swipe
  • Onion skin
doc/img/dashboard-visual.png

75.8 KB | W: | H:

doc/img/dashboard-visual.png

94.4 KB | W: | H:

doc/img/dashboard-visual.png
doc/img/dashboard-visual.png
doc/img/dashboard-visual.png
doc/img/dashboard-visual.png
  • 2-up
  • Swipe
  • Onion skin
...@@ -27,15 +27,13 @@ block. ...@@ -27,15 +27,13 @@ block.
For example, each layer can have its color turn green if the student's average value on MCQs in For example, each layer can have its color turn green if the student's average value on MCQs in
a specific Problem Builder block was at least 3. a specific Problem Builder block was at least 3.
""" """
import ast
import operator as op
class DashboardVisualData(object): class DashboardVisualData(object):
""" """
Data about the visual representation of a dashboard. Data about the visual representation of a dashboard.
""" """
def __init__(self, blocks, rules): def __init__(self, blocks, rules, color_for_value):
""" """
Construct the data required for the optional visual representation of the dashboard. Construct the data required for the optional visual representation of the dashboard.
...@@ -59,6 +57,8 @@ class DashboardVisualData(object): ...@@ -59,6 +57,8 @@ class DashboardVisualData(object):
"width": "500", "width": "500",
"height": "500" "height": "500"
} }
color_for_value is a method that, given a value, returns a color string or None
""" """
# Images is a list of images, one per PB block, in the same order as 'blocks' # Images is a list of images, one per PB block, in the same order as 'blocks'
# All images are rendered layered on top of each other, and can be hidden, # All images are rendered layered on top of each other, and can be hidden,
...@@ -68,11 +68,6 @@ class DashboardVisualData(object): ...@@ -68,11 +68,6 @@ class DashboardVisualData(object):
overlay = rules.get("overlay") overlay = rules.get("overlay")
# Background is an optional images drawn on the bottom, with no effects applied # Background is an optional images drawn on the bottom, with no effects applied
background = rules.get("background") background = rules.get("background")
# Color rules specify how the average value of the PB block affects that block's image.
# Each rule is evaluated in order, and the first matching rule is used.
# Each rule can have an "if" expression that is evaluated and if true, that rule will
# be used.
colorRules = rules.get("colorRules", [])
# Width and height of the image: # Width and height of the image:
self.width = int(rules.get("width", 400)) self.width = int(rules.get("width", 400))
self.height = int(rules.get("height", 400)) self.height = int(rules.get("height", 400))
...@@ -90,74 +85,8 @@ class DashboardVisualData(object): ...@@ -90,74 +85,8 @@ class DashboardVisualData(object):
break break
# Check if a color rule applies: # Check if a color rule applies:
x = block["average"] layer_data["color"] = color_for_value(block["average"])
for rule in colorRules:
condition = rule.get("if")
if condition and not self._safe_eval_expression(condition, x):
continue # This rule does not apply
# Rule does apply:
layer_data["has_filter"] = True
for key in ("hueRotate", "blur", "saturate"):
if key in rule:
layer_data[key] = self._safe_eval_expression(rule[key], x)
break
self.layers.append(layer_data) self.layers.append(layer_data)
if overlay: if overlay:
self.layers.append({"url": overlay}) self.layers.append({"url": overlay})
@staticmethod
def _safe_eval_expression(expr, x=0):
"""
Safely evaluate a mathematical or boolean expression involving the value x
>>> _safe_eval_expression('2*x', x=3)
6
>>> _safe_eval_expression('x >= 0 and x < 2', x=3)
False
We use python syntax for the expressions, so the parsing and evaluation is mostly done
using Python's built-in asbstract syntax trees and operator implementations.
The expression can only contain: numbers, mathematical operators, boolean operators,
comparisons, and a placeholder variable called "x"
"""
# supported operators:
operators = {
# Allow +, -, *, /, %, negative:
ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv, ast.Mod: op.mod, ast.USub: op.neg,
# Allow comparison:
ast.Eq: op.eq, ast.NotEq: op.ne, ast.Lt: op.lt, ast.LtE: op.le, ast.Gt: op.gt, ast.GtE: op.ge,
}
def eval_(node):
""" Recursive evaluation of syntax tree node 'node' """
if isinstance(node, ast.Num): # <number>
return node.n
elif isinstance(node, ast.BinOp): # <left> <operator> <right>
return operators[type(node.op)](eval_(node.left), eval_(node.right))
elif isinstance(node, ast.UnaryOp): # <operator> <operand> e.g., -1
return operators[type(node.op)](eval_(node.operand))
elif isinstance(node, ast.Name) and node.id == "x":
return x
elif isinstance(node, ast.BoolOp): # Boolean operator: either "and" or "or" with two or more values
if type(node.op) == ast.And:
return all(eval_(val) for val in node.values)
else: # Or:
for val in node.values:
result = eval_(val)
if result:
return result
return result # or returns the final value even if it's falsy
elif isinstance(node, ast.Compare): # A comparison expression, e.g. "3 > 2" or "5 < x < 10"
left = eval_(node.left)
for comparison_op, right_expr in zip(node.ops, node.comparators):
right = eval_(right_expr)
if not operators[type(comparison_op)](left, right):
return False
left = right
return True
else:
raise TypeError(node)
return eval_(ast.parse(expr, mode='eval').body)
...@@ -16,11 +16,6 @@ ...@@ -16,11 +16,6 @@
.pb-dashboard table td:last-child { .pb-dashboard table td:last-child {
min-width: 3em; min-width: 3em;
text-align: right; text-align: right;
}
.pb-dashboard table td:last-child span {
position: relative;
background: transparent;
color: black; color: black;
/* Background color could be anything, so add a white outline to preserve readability */ /* Background color could be anything, so add a white outline to preserve readability */
text-shadow: text-shadow:
......
...@@ -7,17 +7,23 @@ ...@@ -7,17 +7,23 @@
<svg width="{{visual_repr.width}}" height="{{visual_repr.height}}"> <svg width="{{visual_repr.width}}" height="{{visual_repr.height}}">
<!-- Filter definitions --> <!-- Filter definitions -->
{% for layer in visual_repr.layers %} {% for layer in visual_repr.layers %}
{% if layer.has_filter %} {% if layer.color %}
<filter id="{{layer.id}}"> <filter id="{{layer.id}}">
<feColorMatrix in="SourceGraphic" type="hueRotate" values="{% if layer.hueRotate %}{{layer.hueRotate}}{% else %}0{% endif %}" result="hued" /> <feFlood flood-color="{{layer.color}}" result="flood" />
<feColorMatrix in="hued" type="saturate" values="{% if 'saturate' in layer %}{{layer.saturate}}{% else %}1{% endif %}" result="sat" /> <feBlend in="flood" in2="SourceGraphic" mode="multiply" />
<feGaussianBlur stdDeviation="{% if layer.blur %}{{layer.blur}}{% else %}0{% endif %}" in="sat" />
</filter> </filter>
<mask id="{{layer.id}}-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
<image xlink:href="{{layer.url}}" x="0" y="0" height="100%" width="100%" />
</mask>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<!-- Layer images --> <!-- Layer images -->
{% for layer in visual_repr.layers %} {% for layer in visual_repr.layers %}
<image xlink:href="{{layer.url}}" x="0" y="0" height="100%" width="100%" {% if layer.has_filter %}style="filter: url(#{{layer.id}});"{% endif %} /> {% if layer.color %}
<rect x="0" y="0" height="100%" width="100%" fill="{{layer.color}}" mask="url(#{{layer.id}}-mask)" />
{% else %}
<image xlink:href="{{layer.url}}" x="0" y="0" height="100%" width="100%" />
{% endif %}
{% endfor %} {% endfor %}
</svg> </svg>
</div> </div>
...@@ -34,14 +40,14 @@ ...@@ -34,14 +40,14 @@
<tr> <tr>
<td>{{ mcq.display_name }}</td> <td>{{ mcq.display_name }}</td>
<td {% if mcq.color %}style="background-color: {{mcq.color}};"{% endif %}> <td {% if mcq.color %}style="background-color: {{mcq.color}};"{% endif %}>
<span>{% if mcq.value %}{{ mcq.value }}{% endif %}</span> {% if mcq.value %}{{ mcq.value }}{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if block.has_average %} {% if block.has_average %}
<tr class="avg-row"> <tr class="avg-row">
<td>{% trans "Average" %}</td> <td>{% trans "Average" %}</td>
<td>{{ block.average|floatformat }}</td> <td {% if block.average_color %}style="background-color: {{block.average_color}};"{% endif %}>{{ block.average|floatformat }}</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
......
...@@ -118,7 +118,7 @@ class TestDashboardBlock(SeleniumXBlockTest): ...@@ -118,7 +118,7 @@ class TestDashboardBlock(SeleniumXBlockTest):
""") """)
# Apply a whole bunch of patches that are needed in lieu of the LMS/CMS runtime and edx-submissions: # Apply a whole bunch of patches that are needed in lieu of the LMS/CMS runtime and edx-submissions:
def get_mentoring_blocks(dashboard_block): def get_mentoring_blocks(dashboard_block, mentoring_ids, ignore_errors=True):
return [dashboard_block.runtime.get_block(key) for key in dashboard_block.get_parent().children[:-1]] return [dashboard_block.runtime.get_block(key) for key in dashboard_block.get_parent().children[:-1]]
mock_submisisons_api = MockSubmissionsAPI() mock_submisisons_api = MockSubmissionsAPI()
patches = ( patches = (
......
...@@ -43,32 +43,26 @@ class TestDashboardVisualData(unittest.TestCase): ...@@ -43,32 +43,26 @@ class TestDashboardVisualData(unittest.TestCase):
], ],
"background": "background.png", "background": "background.png",
"overlay": "overlay.png", "overlay": "overlay.png",
"colorRules": [
{"if": "x < 1", "hueRotate": "20"},
{"if": "x < 2", "hueRotate": "80", "blur": "1"},
{"blur": "x / 2", "saturate": "0.4"}
],
"width": "500", "width": "500",
"height": "500" "height": "500"
} }
data = DashboardVisualData(blocks, rules)
def color_for_value(value):
""" Mock color_for_value """
return "red" if value > 1 else None
data = DashboardVisualData(blocks, rules, color_for_value)
self.assertEqual(len(data.layers), 5) self.assertEqual(len(data.layers), 5)
self.assertEqual(data.layers[0]["url"], "background.png") self.assertEqual(data.layers[0]["url"], "background.png")
self.assertEqual(data.layers[4]["url"], "overlay.png") self.assertEqual(data.layers[4]["url"], "overlay.png")
self.assertEqual(data.width, 500) self.assertEqual(data.width, 500)
self.assertEqual(data.height, 500) self.assertEqual(data.height, 500)
# Check the three middle layers built from the average values: # Check the three middle layers built from the average values:
# Step 1, average is 0 - first colorRule should match
self.assertEqual(data.layers[1]["url"], "step1.png") self.assertEqual(data.layers[1]["url"], "step1.png")
self.assertEqual(data.layers[1]["has_filter"], True) self.assertEqual(data.layers[1].get("color"), None)
self.assertEqual(data.layers[1]["hueRotate"], 20)
# Step 2, average is 1.3 - second colorRule should match
self.assertEqual(data.layers[2]["url"], "step2.png") self.assertEqual(data.layers[2]["url"], "step2.png")
self.assertEqual(data.layers[2]["has_filter"], True) self.assertEqual(data.layers[2]["color"], "red")
self.assertEqual(data.layers[2]["hueRotate"], 80)
self.assertEqual(data.layers[2]["blur"], 1)
# Step 3, average is 30.8 - final colorRule should match
self.assertEqual(data.layers[3]["url"], "step3.png") self.assertEqual(data.layers[3]["url"], "step3.png")
self.assertEqual(data.layers[3]["has_filter"], True) self.assertEqual(data.layers[3]["color"], "red")
self.assertEqual(data.layers[3]["blur"], 30.8/2)
self.assertEqual(data.layers[3]["saturate"], 0.4)
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