๐งช Open Educational Resources • Chemistry • HTML5 Simulations
From Simulation to Learning Tool: Improving a WebEJS Chemistry HTML5 App
A developer's diary of pedagogical upgrades, encoding traps, hidden buttons, and star ratings that refused to appear
The Starting Point
The Dot and Cross Diagram simulation is a free, open-access HTML5 interactive built with WebEJS (Easy JavaScript Simulations). It lets Singapore O-Level chemistry students drag-and-drop electrons to build covalent bond diagrams for 15 molecules — from simple H₂ all the way to the sulfite ion SO₃²⁻.
The simulation already worked. Students could place electrons, click Check, and get a Correct or Incorrect result. But it lacked depth: no real-world context, no molecular geometry, no hint about why chemistry matters beyond the exam, and no way for students to track their own progress.
This post documents the engineering journey of turning a functional simulation into a genuinely pedagogical tool — and every frustrating bug that stood in the way.
“The simulation worked. The question was whether it taught.”— The design challenge
What We Wanted to Add
Critical constraint: All changes must be made in _source.json, the WebEJS source file, so the teacher can re-upload it to the WebEJS online editor and regenerate index.html. We cannot just edit the compiled output.
Understanding the WebEJS Architecture
WebEJS compiles a UTF-16 LE encoded JSON file (_source.json) into a self-contained index.html. The JSON has five top-level sections:
{
"information": { "HTMLHead": "..." }, // <head> content
"description": { "pages": [...] }, // documentation tabs
"model": {
"variables": { "pages": [...] }, // model state variables
"custom": { "pages": [...] }, // JavaScript functions
"initialization": { "pages": [...] }
},
"view": { "Tree": [...] }, // UI element tree
"metadata": { ... }
}We wrote a Python script — modify_source.py — that reads from a clean backup (_source.json.bak), applies all patches programmatically, and writes the modified _source.json. This is idempotent: run it ten times, get the same result. It is also safe: the backup is never touched.
The Engineering Journey: A Timeline of Struggles
1. Adding static content — the easy part
We added new String and int variables to the Var Table (whythismatters, moleculeshape, bondangle, shapedescription, moleculepolarity, streakcount) and patched each of the 15 option1()–option15() functions to set these values when a molecule is selected. We also added three description pages (How to Use, Background Theory, Molecule Guide) to the description.pages array.
2. Template literals don’t interpolate in Panel HTML
${bondangle} directly in the Panel's HTML property, expecting WebEJS to interpolate model variables like a template literal. Instead, the browser rendered the literal string ${bondangle} on screen.<script> block injected into information.HTMLHead calls window._model._userSerialize() every 500 ms and writes HTML into named <div> placeholder elements.function _dcUpdate() {
var s = window._model._userSerialize();
var mi = document.getElementById('dc-molinfo');
if (mi) {
mi.innerHTML =
'<b>Shape:</b> ' + s.moleculeshape +
' | <b>Angle:</b> ' + s.bondangle;
}
}3. UTF-16 + emoji = encoding crash
json.dump(..., ensure_ascii=False) caused a UnicodeEncodeError when writing emoji characters (⭐, ๐) to a UTF-16 LE file. Python's codec cannot represent code points above U+FFFF as surrogate pairs in this mode.ensure_ascii=True in json.dump. This serialises all non-ASCII characters as \uXXXX escape sequences, which the browser's JavaScript engine decodes correctly at runtime.# Wrong
json.dump(data, f, ensure_ascii=False, indent=4)
# Correct
json.dump(data, f, ensure_ascii=True, indent=4)4. HtmlArea renders as <iframe> — and appears four times
HtmlArea view elements caused two problems: (a) each rendered as a fixed-height <iframe> with scrollbars, and (b) WebEJS renders view children once per description page, so four copies appeared on screen.HtmlArea additions. The existing html element (type Panel) renders as an inline <div> in the main document. Dynamic dc-molinfo and dc-progress divs were placed as placeholders inside it. The polling script updates them directly.5. Array variables are not returned by _userSerialize()
starsDisplay[16] (String array) and starsearned[16] (double array) stored in a separate variable page called “Stars & Progress”. These never appeared in _userSerialize()’s return object. Every polling cycle saw s.starsDisplay === undefined. All stars showed ○ forever.starsCSV (initial value: "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0") in the main Var Table. _userSerialize() reliably returns plain scalar strings. The polling script splits on comma and converts each value to a star emoji display.// computeStars() stores ratings as CSV
var parts = starsCSV.split(',');
parts[q] = String(newStars); // "3", "2", or "1"
starsCSV = parts.join(',');
// Polling script reads it back
var _csv = s.starsCSV.split(',');
var _n = parseInt(_csv[i]) || 0;
var _star = _n >= 3 ? '⭐⭐⭐' :
_n === 2 ? '⭐⭐' :
_n === 1 ? '⭐' : '○';6. The visible “Check” button was not the one we patched
This was the most instructive bug of the project.
computeStars() was wired inside showCorrectWithMCQ(), which was called from the twoStateButtoncheck element's OffClick. It turned out that button had Display: none — it was invisible. The actual “Check ๐ค” button users click is a separate, regular button element named check, and its OnClick never called computeStars() at all.check button's OnClick at the score-recalculation block at the end: if scoreIndividual[question] === 1 (correct), call computeStars(); otherwise do attemptq++ to count the failed attempt. Single patch point, handles all 15 questions.// Added at end of check button OnClick, before _update()
if (scoreIndividual[question] === 1) {
computeStars(); // award stars based on attemptq
} else {
attemptq++; // count wrong attempt for next try
}7. Streak counter doubling on every correct answer
computeStars(), stars finally appeared — but the streak counter showed “2 in a row” after completing just 1 molecule. computeStars() was being triggered twice per click (once from the check button, and once from the hidden twoStateButtoncheck path which could still fire under some conditions), and each call incremented streakcount.streakcount only changes inside the if (current === 0) branch — i.e., only on the first completion of a question. Repeat calls for an already-completed question (where current > 0) become no-ops for the streak.function computeStars() {
var current = parseInt(parts[q]) || 0;
if (current === 0) {
// First completion: update stars AND streak
parts[q] = String(newStars);
starsCSV = parts.join(',');
streakcount = (attemptq === 0) ? streakcount + 1 : 0;
} else if (newStars > current) {
// Re-done better: upgrade stars only, streak unchanged
parts[q] = String(newStars);
starsCSV = parts.join(',');
}
// Already done at same/lower level: no-op
}The Final Architecture
After all seven rounds of iteration, the system works as follows:
_source.json.bak (clean backup, never modified)
|
v
modify_source.py (Python script, run once to apply all patches)
|
v
_source.json (upload this to WebEJS online editor)
|
v
index.html (WebEJS compiles and generates this)
Runtime flow in the browser:
Student clicks "Check"
|-- check button OnClick runs
| |-- validates electron placement
| |-- sets scoreIndividual[question] = 1 (if correct)
| |-- recalculates score
| |-- calls computeStars() [OUR PATCH]
| |-- parses starsCSV
| |-- updates CSV + streakcount (first time only)
|
Polling script (every 500ms)
|-- calls window._model._userSerialize()
|-- reads starsCSV, streakcount, question, moleculeshape, ...
|-- updates #dc-molinfo innerHTML (molecule info panel)
|-- updates #dc-progress innerHTML (star progress table)Key Lessons Learned
1. Read the compiled output, not just the source
The hidden-button bug was only found by grepping the compiled index.html and reading the view element definitions. The source JSON alone would not have revealed that twoStateButtoncheck had Display: none and that a different element named check was the real visible button.
2. Prefer scalar variables over arrays for cross-context data
WebEJS's _userSerialize() is designed to return simple model state for serialisation/embedding. It reliably serialises scalar strings and numbers. Array variables stored in secondary variable pages may be omitted. When bridging the model and a custom polling script, always use scalars — CSV strings, space-separated tokens, or JSON strings.
3. Make side-effect functions idempotent
Any function that updates counters or scores may be called more than once due to event handler overlap in a complex compiled simulation. Build guard conditions into the function itself: “only do this if the state was previously zero / not yet completed”. This is far more robust than trying to audit every call site.
4. UTF-16 and emoji: always use ensure_ascii=True
When writing JSON to a UTF-16 LE file in Python, use ensure_ascii=True. Emoji and other high-code-point characters become \uXXXX sequences in the JSON, which JavaScript decodes natively. Attempting ensure_ascii=False with surrogate-heavy characters will crash the encoder.
5. Polling is a pragmatic solution for read-only DOM bridging
An ideal architecture would use WebEJS events or reactive bindings to update the DOM. In practice, the window._model._userSerialize() polling approach — 500 ms interval, with a first call at 800 ms after load — is simple, debuggable, and robust. The student never notices the delay; the simulation does not feel sluggish.
What the Students Now See
After uploading the patched _source.json and recompiling with the WebEJS editor, a student working through the simulation experiences:
- A collapsible Learning Objectives panel that reminds them of the five things they are working toward
- A Valence Electron Quick Reference table (collapsible) listing every atom they will encounter
- After each correct diagram, a Molecule Info panel appears instantly showing VSEPR shape, bond angle, and polarity
- A real-world “Why This Matters” paragraph — for example, water’s bent polar shape creating hydrogen bonds, or nitrogen’s triple bond explaining why 78% of the air does not react
- A live progress table showing all 15 molecules with ○ / ⭐ / ⭐⭐ / ⭐⭐⭐ ratings
- A streak counter (“๐ฅ 3 in a row!”) for consecutive first-attempt successes
- Three new description pages: How to Use, Background Theory, and a Molecule Guide reference table covering all 15 structures with difficulty tiers
“N₂’s triple bond is one of the strongest in chemistry. That is why 78% of the air you breathe does not react, keeping atmospheric conditions stable for life.”— Example “Why This Matters” text for Question 4
Closing Thoughts
The total change required about 500 lines of Python in modify_source.py and zero manual editing of any JSON or HTML file. The simulation is open-source under CC-BY-SA-NC, hosted at sg.iwant2study.org, and freely usable by any teacher or student.
The biggest takeaway is not technical — it is pedagogical. A simulation that tells a student “Correct!” teaches very little. A simulation that responds with the molecular shape, the bond angle, the reason this molecule exists in your body or your food or your atmosphere, and a visible record of what you have accomplished — that is a tool that teaches.
Try the simulation: iwant2study.org — Dot and Cross Diagram
More chemistry interactives: sg.iwant2study.org/chemistry
No comments:
Post a Comment