I’ve been spending some time optimizing a reusable Riemann sum graph component in DoenetML, since this is such a common visualization in calculus activities. The current version is currently showing noticeably better performance than several previous implementations I’ve written (and others I’ve seen), so I thought it might be useful to share some observations for anyone building similar interactive graphs.
Goal
The main objective was to test different DoenetML component structures to try and determine what was most expensive in rendering Riemann Sum illustrations.
Optimization #1: Eliminate Repeated Function Evaluations
One of the first improvements was removing a separate list of precomputed rectangle heights.
Rather than creating something like:
<numberList name="tr">
$$f($bl[1]) $$f($bl[2])
</numberList>
and then referencing those values later, the rectangle heights are evaluated directly:
<rectangle
filled
vertices="($bl[17],0) ($bl[18],$$f($bl[18]))"
/>
This removes an entire dependency layer.
Optimization #2: Maintain One Shared Sample Point List
The implementation stores a single list of partition endpoints, as these would be recomputed numerous times in the set of rectangles
<numberList name="bl">
$a $a+$del $a+2*$del ...
</numberList>
While there are certainly other ways to generate coordinates, maintaining a single shared list keeps the dependency structure relatively simple and avoids repeated coordinate computations throughout the graph definition.
Optimization #3: Use Discrete Sample Sizes
Rather than allowing arbitrary values of n with a slider or <textInput>, the activity uses:
<choiceInput>
<choice><number>1</number></choice>
<choice><number>5</number></choice>
<choice><number>10</number></choice>
<choice><number>25</number></choice>
<choice><number>50</number></choice>
</choiceInput>
These values correspond to meaningfully different visual scales for a classroom discussion while preventing excessive growth in the number of rectangles. I think this activity would still run reasonable efficiently at 100 rectangles.
In practice, students rarely gain additional insight by moving from 47 to 48 rectangles, whereas 5 → 10 → 25 → 50 produces obvious visual changes. That said, this still runs reasonably efficiently with a <slider> for n instead of the <choiceInput>, however, that brings us to another related performance gain.
Optimization #4: Group Hides Instead of Individual Hides
This turned out to be an easy performance improvement.
A naive implementation might assign a hide condition to every rectangle (as my original testing with a slider did):
<rectangle hide="$n<17" />
<rectangle hide="$n<18" />
<rectangle hide="$n<19" />
...
In my testing, this was noticeably slower than grouping rectangles together, likely due to the number of boolean checks occuring:
<group hide="$n < 1">
<group hide="$n < 5">
<group hide="$n < 10">
<group hide="$n < 25">
<group hide="$n < 50">
</group>
The current implementation uses visibility thresholds corresponding to the available sample size, so entire blocks of rectangles become visible or hidden together.
This produced a clear performance improvement over individual visibility conditions on every rectangle.
## Optimization #5: Nested Groups
The final version also nests the visibility groups:
```xml
<group hide="$n < 5">
...
<group hide="$n < 10">
...
<group hide="$n < 25">
...
<group hide="$n < 50">
...
</group>
</group>
</group>
</group>
I was not able to determine conclusively whether nesting itself improves performance, although this might be a point where the back-end processing could be improved.
However, grouping definitely helped, and nesting at least keeps the structure aligned with how the sample-size choices expand.
If anyone has insight into how Doenet’s renderer processes nested hidden groups, I’d be interested to hear whether short-circuiting occurs internally so that the outer-most group is processed first reducing the number of re-renders of the JSXGraph.
Most Interesting Finding: The Function Plot Appears to Be the Real Bottleneck
The most surprising result came from testing the graph with the function hidden:
<function domain="\[-10,10\]" name="f" hide="$hide_f">
1/15\*x^2
</function>
<booleanInput name="hide_f">
<label>Hide Function?</label>
</booleanInput>
When the function is hidden and only the rectangles remain visible, graph updates become significantly faster.
The effect is especially apparent when moving from:
50 rectangles → 1 rectangle
With the function visible, there is a much more noticeable, generally consistent lag. With only the rectangles visible, that lag is dramatically reduced.
This suggests that the expensive operation is not necessarily the number of rectangles, the sampling computations, or even the hiding/reappearing of many rectangles, but rather that visibility changes may be triggering substantial re-rendering with re-sampling of the plotted function.
I also tested with a simple function definition:
<function>x</function>
visible instead of the quadratic, and the same lag-times in update were observed.
The slowdown remained much more tied to the presence of the function plot than to the complexity of the function itself, as this was also tested using a piecewise-function with three branches to test.
Takeaway
For anyone building Riemann sum visualizations in DoenetML, the biggest wins I found were:
- Evaluate rectangle heights directly rather than storing a separate height list.
- Reuse a single partition-point list.
- Restrict sample sizes to a handful of meaningful scales.
- Use grouped visibility controls instead of individual hide conditions on every rectangle.
- Benchmark with the function hidden and visible.
The last point is particularly important. I would suggest optimizing with the function removed from the graph, and then adding it back as a current hard-limit of DoenetML in the background causing performance degradation at that point. In my testing, the plotted function appears to contribute substantially more overhead than expected, especially when many graphical components are changing visibility simultaneously.
I’d be very interested if anyone else has profiled graph-heavy Doenet activities and can confirm whether function rendering is being recomputed more aggressively than necessary during visibility changes. The behavior is noticeable enough that it seems worth investigating further.