// lrs-sim.js — the one xAPI API a MicroSim talks to. // // A sim reports WHAT happened, as evidence classes (TODO.md, "add-xapi-events-to-microsim"): // // const x = LRSSim.create({ name: 'Sine Wave', concept: 'sine-wave', mount: 'main' }); // const amp = x.slider('amplitude-slider', { name: 'Amplitude Slider', concept: 'amplitude', // min: 0, max: 1, initial: 0.5, round: 2 }); // amplitudeSlider.input(() => amp.input(amplitudeSlider.value())); // // x.slider(key, o) a continuous parameter .input(v) .settle(v) // x.item(key, o) something inspected .study(mode, ms) mode: 'hover' | 'pinned' | … // x.button(key, o) a discrete press .press(action) e.g. 'start' | 'pause' // x.runner(o) a Start/Pause run interval .start() .stop(reason) // x.question(key,o) a checked answer .answer({success, response, …}) // pageDwell: true time on a sim with no Run control, as one `experienced` per visit // // Answers are never folded: in BOTH modes each one is its own `answered` statement, emitted // as it happens (Dan, 2026-09-26). Compact mode folds exposure evidence only. // // This file decides whether each becomes a Full-mode statement now or a fold into the // Compact session (lrs-lite-sim.js); builds every statement with LRS.build (lrs-xapi.js), so // no sim constructs xAPI by hand; and closes open intervals on focus loss and mode switches. // // MODE AND TEACHING UI COME FROM CONFIG, NEVER FROM THE SIM. The book's lrs-config.js `xapi` // block sets the defaults; a sim's metadata.json `xapi` block overrides them: // compact true -> LRS-Lite, one summary per session; false -> the full per-interaction stream // teaching true -> show the statement log, the Full/Compact switch, Simulate Done, and View // Formatted JSON. Only for sims that TEACH xAPI; production sims are silent. // A viewer can override both for one visit with the URL switch `?xapi=teaching` (on the sim's // page or the page that embeds it; lrs-lite-sim.js urlPolicy). A production sim switched on // that way grows its iframe to fit the panel (_fitFrame). // // The teaching controls live in this panel, in HTML, for every library — p5 included. They // operate on the xAPI stream, not the simulation, so they belong beside the log they affect, // and a production sim (teaching off) keeps its original layout with no empty control row. // // Loads after lrs-config.js, lrs-xapi.js, lrs-lite-sim.js (and xapi-json-viewer.js for the // formatted view). A sim must still run without it — pasted into the p5.js editor — so guard // every call site: if (window.LRSSim) { ... } (function (global) { 'use strict'; var HOVER_MS = 600; // a shorter hover is a mouse crossing the sim, not attention var MISCLICK_MS = 250; // a shorter run is a mis-click (contract §7) var GLANCE_MS = 1000; // shorter page dwell is a glance, not engagement var MAX_LOG_LINES = 150; var MAX_KEPT = 400; // statements kept in memory to fill a panel built after they were emitted var instances = 0; function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } function roundTo(v, places) { if (places === undefined || typeof v !== 'number') return v; var f = Math.pow(10, places); return Math.round(v * f) / f; } // ── The sim ─────────────────────────────────────────────────────────────── // // opts = { // name, concept the MicroSim activity and its page-level concept (summary, run, dwell) // pageDwell true for sims with no Run control: Full mode reports time on the page // source how the formatted-JSON tab names this sim ('the Sine Wave MicroSim') // metadata false for a page with no metadata.json of its own (a chapter quiz) // policy config keys the page sets itself, e.g. { teaching: true } // Teaching UI — used only when the config says `teaching: true`: // mount element or selector the panel is appended to (default ) // title log header (default 'xAPI statements emitted:') // modeText(compact, sim) header explanation, called on every update // foldNotes false to skip the "· … folded into the session summary" log lines // modeControls false where the Full/Compact switch would change nothing — a page // whose every statement is an answer, which passes through in both modes // } function Sim(opts) { var self = this; this.opts = opts = opts || {}; this.id = ++instances; this.name = opts.name || document.title; this.concept = opts.concept; this.iri = LRS.pageIri(); this.count = 0; // statements emitted, full or compact this.interactions = 0; // evidence events reported, whether emitted or folded this.kept = []; // {st, text}, to fill a teaching panel built after the fact this.latest = null; this.runners = []; this.pageShownAt = Date.now(); this.pageClosed = false; this.panelEl = this.headerEl = this.logEl = null; this.session = LRSLite.sim({ name: this.name, concept: this.concept, metadata: opts.metadata, policy: opts.policy, publish: function (st, text) { self._publish(st, text); }, // A compact session is ending: close a run that is still going, so it lands in THIS summary. beforeEnd: function (reason) { self._stopRunners(reason); } }); // Registered AFTER the session's own listener, so in compact mode the session has already // ended (and folded any open run) by the time this runs; then this only handles Full mode. document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden' && !self.compact) { self._stopRunners('tab-hidden'); self._closePage('tab-hidden'); } }); this.ready = this.session.ready.then(function () { if (self.teaching) self._buildPanel(); return self; }); } Object.defineProperty(Sim.prototype, 'compact', { get: function () { return this.session.compact; } }); Object.defineProperty(Sim.prototype, 'teaching', { get: function () { return !!(this.session.policy && this.session.policy.teaching); } }); // ── Evidence classes ────────────────────────────────────────────────────── Sim.prototype.slider = function (key, o) { return new Slider(this, key, o || {}); }; Sim.prototype.item = function (key, o) { return new Item(this, key, o || {}); }; Sim.prototype.button = function (key, o) { return new Button(this, key, o || {}); }; Sim.prototype.question = function (key, o) { return new Question(this, key, o || {}); }; Sim.prototype.runner = function (o) { var r = new Runner(this, o || {}); this.runners.push(r); return r; }; // Any other contract statement, built and published the same way. Sim.prototype.emit = function (spec, text) { this._publish(LRS.build(spec), text); }; // ── Mode switch and Simulate Done (teaching) ───────────────────────────── // Each mode keeps its own record of engagement: Full mode its open run and page interval, // Compact mode its session. Switching closes the record being LEFT, so nothing is lost and // nothing is counted twice. Sim.prototype.setCompact = function (compact) { compact = !!compact; if (!this.session.policy) { this.session.setCompact(compact); return; } // not loaded yet if (compact === this.compact) return; if (compact) { this._stopRunners('mode-switch'); this._closePage('mode-switch'); this.session.setCompact(true); } else { this.session.setCompact(false); // ends the session: a 'mode-switch' summary this._restartPage(); } this.note('switched to ' + (compact ? 'COMPACT' : 'FULL') + ' mode'); this._syncControls(); this._update(); }; // What the host page does when the student moves on: it takes focus from the sim. Sim.prototype.done = function () { var before = this.count; if (this.compact) { this.session.end('simulated-done'); if (this.count === before) this.note('simulated done — nothing folded yet, so no summary'); else if (global.XapiJsonViewer) { this.note('summary emitted — press View Formatted JSON ↗ (or click the line) to read it'); } return; } if (this._stopRunners('simulated-done')) return; if (this.opts.pageDwell) { this.note(this._closePage('simulated-done') ? 'page dwell emitted — full mode had already sent every other interaction' : 'simulated done — under 1s on the page, so no dwell to emit'); this._restartPage(); return; } this.note('simulated done — full mode already emitted every interaction; nothing to flush'); }; Sim.prototype.view = function (st) { if (st && global.XapiJsonViewer) { global.XapiJsonViewer.open(st, { source: this.opts.source || this.name }); } }; Sim.prototype.viewLatest = function () { this.view(this.latest); }; // A log line that is NOT a statement: why something was deliberately not emitted. Sim.prototype.note = function (msg) { if (!this.logEl) return; var d = document.createElement('div'); d.className = 'xapi-log-line xapi-log-note'; d.textContent = '· ' + msg; this._append(d); }; // ── Internals ───────────────────────────────────────────────────────────── Sim.prototype._publish = function (st, text) { this.count++; this.latest = st; this.kept.push({ st: st, text: text }); if (this.kept.length > MAX_KEPT) this.kept.shift(); LRSLite.record(st); if (this.logEl) this._render(st, text); this._update(); }; Sim.prototype._fold = function (what) { if (this.opts.foldNotes !== false) this.note(what + ' folded into the session summary'); this._update(); }; // Returns true if a running interval was closed. Sim.prototype._stopRunners = function (reason) { var any = false; for (var i = 0; i < this.runners.length; i++) { if (this.runners[i].running) { this.runners[i].stop(reason); any = true; } } return any; }; // Full mode's page interval, for sims with no Run control. Returns true if it emitted. Sim.prototype._closePage = function (reason) { if (!this.opts.pageDwell || this.compact || this.pageClosed) return false; this.pageClosed = true; var ms = Date.now() - this.pageShownAt; if (ms < GLANCE_MS) return false; this.interactions++; this.emit({ verb: 'experienced', object: { iri: this.iri, name: this.name, type: 'MicroSim' }, // page IRI: one PageEngagement concept: this.concept, result: { durationMs: ms, extensions: { 'run-ended-by': reason } } }, 'experienced page ' + LRS.isoDuration(ms) + ' (' + reason + ')'); return true; }; Sim.prototype._restartPage = function () { this.pageShownAt = Date.now(); this.pageClosed = false; }; Sim.prototype._buildPanel = function () { var self = this, o = this.opts; var host = typeof o.mount === 'string' ? document.querySelector(o.mount) : o.mount; var wrap = document.createElement('div'); wrap.className = 'xapi-panel'; var group = 'xapi-mode-' + this.id; wrap.innerHTML = (o.modeControls === false ? '' : '
xAPI events:' + '' + '' + '
') + '
' + esc(o.title || 'xAPI statements emitted:') + ' 0' + ' — ' + 'Nothing is sent to a server.
'; (host || document.body).appendChild(wrap); this.panelEl = wrap; this.headerEl = wrap.querySelector('.xapi-panel-header'); this.logEl = wrap.querySelector('.xapi-log'); if (global.XapiJsonViewer) { var vb = document.createElement('button'); vb.type = 'button'; vb.className = 'xapi-view-btn'; vb.textContent = 'View Formatted JSON ↗'; vb.title = 'Open the most recent statement, formatted, in a new tab'; vb.disabled = true; vb.addEventListener('click', function () { self.viewLatest(); }); this.headerEl.appendChild(vb); this.viewBtn = vb; } Array.prototype.forEach.call(wrap.querySelectorAll('.xapi-controls input'), function (r) { r.addEventListener('change', function () { self.setCompact(r.value === 'compact'); }); }); var doneBtn = wrap.querySelector('.xapi-done'); if (doneBtn) doneBtn.addEventListener('click', function () { self.done(); }); this.kept.forEach(function (k) { self._render(k.st, k.text); }); this._syncControls(); this._update(); if (this.session.policy && this.session.policy.teachingFromUrl) this._fitFrame(); }; // `?xapi=teaching` on a PRODUCTION sim: its iframe was sized without this panel (and often // has scrolling="no"), so the panel would be clipped. Grow the embedding iframe to fit it, // and keep following it: the log fills, and content ABOVE the panel can grow too (the FDM // chart reveals an info box on click). Same-origin only (frameElement is null otherwise), // and grow-only. Teaching sims never get here: their iframes are sized for the panel (the // add-xapi-events-to-microsim skill, step 8). // // One layout cannot be fitted: one sized by its frame (100vh, or html/body at 100%). Every // time the frame grows, the content above the panel grows by the same amount and pushes // the panel back out. That is detectable exactly: the panel moved down by as much as the // viewport grew. Stop there and say so, rather than chasing the frame forever. Sim.prototype._fitFrame = function () { var frame = null; try { frame = global.frameElement; } catch (e) { return; } if (!frame || !this.panelEl) return; var panel = this.panelEl; var base = frame.clientHeight; var last = null; var stopped = false; function fit() { if (stopped) return; var box = panel.getBoundingClientRect(); var top = box.top + (global.scrollY || 0); var vh = global.innerHeight; if (last && vh > last.vh && top > last.top && Math.abs((top - last.top) - (vh - last.vh)) <= 2) { stopped = true; if (global.console) { global.console.warn('[lrs-sim] this sim is laid out to fill its frame (100vh or 100%), so ' + 'the ?xapi=teaching panel cannot be fitted by growing the iframe. Pin its height while ' + 'the panel is present: body:has(> .xapi-panel) { height: }'); } return; } last = { top: top, vh: vh }; var need = Math.ceil(top + box.height + (parseFloat(global.getComputedStyle(panel).marginBottom) || 0) + 16); if (need > base && need > frame.clientHeight) frame.style.height = need + 'px'; } fit(); if (typeof ResizeObserver === 'function') { var ro = new ResizeObserver(fit); ro.observe(panel); // the log filling ro.observe(document.body); // content above the panel growing (or the frame itself) } }; Sim.prototype._render = function (st, text) { var self = this; function line(cls, content) { var d = document.createElement('div'); d.className = cls; d.textContent = content; if (global.XapiJsonViewer) { d.classList.add('xapi-log-clickable'); d.title = 'Click to view this statement formatted, in a new tab'; d.addEventListener('click', function () { self.view(st); }); } self._append(d); } if (text) line('xapi-log-line', '▸ ' + text); line('xapi-log-line xapi-log-raw', JSON.stringify(st)); if (this.viewBtn) this.viewBtn.disabled = false; }; Sim.prototype._append = function (el) { this.logEl.appendChild(el); while (this.logEl.childElementCount > MAX_LOG_LINES) this.logEl.removeChild(this.logEl.firstChild); this.logEl.scrollTop = this.logEl.scrollHeight; }; Sim.prototype._syncControls = function () { if (!this.panelEl) return; var want = this.compact ? 'compact' : 'full'; Array.prototype.forEach.call(this.panelEl.querySelectorAll('.xapi-controls input'), function (r) { r.checked = r.value === want; }); }; Sim.prototype._update = function () { if (!this.panelEl) return; this.panelEl.querySelector('.xapi-count').textContent = String(this.count); this.panelEl.querySelector('.xapi-mode-text').textContent = this.opts.modeText ? this.opts.modeText(this.compact, this) : this.compact ? 'COMPACT (LRS-Lite): interactions are folded into ONE summary statement, emitted ' + 'when the sim loses focus. Press Simulate Done to see it.' : 'FULL (full LRS): every interaction is its own statement, sent as it happens.'; }; // Page IRI + a stable local name (contract §2), typed Control (§5) so it lands in the // concept rollup and never becomes its own PageEngagement row. function control(sim, key, name) { return { iri: sim.iri + '#' + key, name: name || key, type: 'Control' }; } // ── 1. Continuous parameter ─────────────────────────────────────────────── // // o = { name, concept, min, max, initial, round, deadband } // deadband the least change worth a statement; default (max - min) / 60, about 60 // statements per full sweep, whatever the slider's numeric range // initial the control's starting value: the first move's previous-value, and where // direction tracking starts // round decimal places for the reported value — report what the student SEES function Slider(sim, key, o) { this.sim = sim; this.key = key; this.o = o; this.deadband = o.deadband !== undefined ? o.deadband : o.max !== undefined && o.min !== undefined ? (o.max - o.min) / 60 : 0; this.lastEmitted = o.initial !== undefined ? o.initial : null; this.lastRaw = this.lastEmitted; this.lastDir = 0; this.pendingReversals = 0; this.count = 0; } // Every raw input event. Returns true if this one was reported (emitted or folded). Slider.prototype.input = function (v) { // Direction changes are counted on EVERY raw value, not just reported ones: a compact // summary has no order to recover them from later, so they must be carried explicitly. if (this.lastRaw !== null && v !== this.lastRaw) { var dir = v > this.lastRaw ? 1 : -1; if (this.lastDir && dir !== this.lastDir) this.pendingReversals++; this.lastDir = dir; } this.lastRaw = v; if (this.lastEmitted !== null && (v === this.lastEmitted || Math.abs(v - this.lastEmitted) < this.deadband)) return false; this._report(v); return true; }; // The value the student let go at (a `change` event): always captured, even inside the deadband. Slider.prototype.settle = function (v) { if (v === this.lastEmitted) return false; this._report(v); return true; }; Slider.prototype._report = function (v) { var sim = this.sim, o = this.o; var prev = this.lastEmitted; this.lastEmitted = v; this.count++; sim.interactions++; var value = roundTo(v, o.round); var previous = prev === null ? null : roundTo(prev, o.round); var reversals = this.pendingReversals; this.pendingReversals = 0; if (sim.compact) { sim.session.touch(this.key, value, { concept: o.concept, reversals: reversals }); sim._fold(this.key + '=' + value); return; } sim.emit({ verb: 'interacted', object: control(sim, this.key, o.name), parent: sim.iri, concept: o.concept, result: { extensions: { value: value, 'previous-value': previous } } }, 'interacted ' + this.key + '=' + value + (previous === null ? '' : ' (was ' + previous + ')')); }; // ── 2. Discrete inspection ──────────────────────────────────────────────── // // o = { name, concept }. study(mode, ms): mode says how strong the evidence is — 'hover' // (attention above HOVER_MS) or 'pinned' (a deliberate click). The same object either way. function Item(sim, key, o) { this.sim = sim; this.key = key; this.o = o; } Item.prototype.study = function (mode, ms) { var sim = this.sim, o = this.o; sim.interactions++; if (sim.compact) { sim.session.touch(this.key, undefined, { ms: ms, mode: mode, concept: o.concept }); sim._fold(this.key + ' [' + mode + ']'); return; } sim.emit({ verb: 'interacted', object: control(sim, this.key, o.name), parent: sim.iri, concept: o.concept, // Duration on a Control reaches no rollup today (contract §12 item 9), but the log is // the system of record: "which step did they labour over?" is answerable from it. result: { durationMs: ms, extensions: { 'engagement-mode': mode } } }, 'interacted ' + this.key + ' ' + (ms === undefined ? '' : LRS.isoDuration(ms) + ' ') + '[' + mode + ']' + (o.concept ? ' → ' + o.concept : '')); }; // ── 3a. A discrete press ────────────────────────────────────────────────── // // o = { name, concept }. press(action) — contract §7.1: additive evidence the control was // touched. It carries no duration and is never emitted for a flush (tab hidden, idle, …). function Button(sim, key, o) { this.sim = sim; this.key = key; this.o = o; } Button.prototype.press = function (action) { var sim = this.sim, o = this.o; sim.interactions++; if (sim.compact) { sim.session.touch(this.key, undefined, { mode: action, concept: o.concept }); sim._fold(this.key + ' [' + action + ']'); return; } sim.emit({ verb: 'interacted', object: control(sim, this.key, o.name), parent: sim.iri, concept: o.concept, result: { extensions: { action: action } } }, 'interacted ' + this.key + ' [' + action + ']'); }; // ── 5. A checked answer ─────────────────────────────────────────────────── // // o = { name, concept }. key names the QUESTION, not the thing it asks about: `q3` for a // fixed-order quiz (contract §2, one-based), `q-nucleus` when the order is shuffled. // answer(r), r = { success, response, score, durationMs, extensions }: // success REQUIRED (contract §3) — without it the concept rollup counts no attempt // score 0..1; defaults to 1 for success, 0 otherwise // response what the student actually chose — a wrong answer's most useful part // // PASS-THROUGH IN BOTH MODES (Dan, 2026-09-26): an answer is never folded into a compact // summary. BKT reads the order of attempts (wrong, wrong, right is not right, wrong, wrong), // each answer keeps its question IRI, and the per-question rollups stay replayable (C-2). // Emit EVERY attempt, wrong ones included: a student who brute-forces six options must not // look like one who knew the answer. function Question(sim, key, o) { this.sim = sim; this.key = key; this.o = o; } Question.prototype.answer = function (r) { var sim = this.sim, o = this.o; r = r || {}; sim.interactions++; sim.emit({ verb: 'answered', object: { iri: sim.iri + '#' + this.key, name: o.name || this.key, type: 'Question' }, parent: sim.iri, // §4 — required for `answered` concept: o.concept, result: { success: r.success, score: r.score !== undefined ? r.score : (r.success ? 1 : 0), response: r.response, durationMs: r.durationMs, extensions: r.extensions } }, 'answered ' + this.key + ' ' + (r.response !== undefined ? r.response + ' ' : '') + (r.success ? '✓' : '✗') + (o.concept ? ' → ' + o.concept : '')); // In Compact, the answer also opens the sim's session (without folding into it), so a // visit made only of answers still ends in a summary carrying its time on the sim. Not // on a page with no metadata.json of its own, i.e. a chapter quiz. That page is not a // MicroSim, and a summary typed MicroSim would misname it (contract §5). if (sim.compact && sim.opts.metadata !== false) sim.session.answered(); }; // ── 3b. A Start/Pause run interval ──────────────────────────────────────── // // Contract §7: one `experienced` per run, emitted when it closes, carrying the whole run // as result.duration; nothing on start. o.onStop(reason) runs whenever the run closes — // Pause, or a flush the sim did not ask for — so the sim can halt its animation. function Runner(sim, o) { this.sim = sim; this.o = o; this.running = false; this.startedAt = null; } Runner.prototype.start = function () { if (this.running) return; this.running = true; this.startedAt = Date.now(); this.sim.session.setBusy(true); // a running sim is engaged even without input }; // Returns the elapsed ms, or null if nothing was running. Runner.prototype.stop = function (reason) { if (!this.running) return null; var sim = this.sim; var ms = Date.now() - this.startedAt; this.running = false; this.startedAt = null; sim.session.setBusy(false); if (this.o.onStop) this.o.onStop(reason); if (ms < MISCLICK_MS) { sim.note('run under 250ms — treated as a mis-click, no statement emitted'); return ms; } sim.interactions++; if (sim.compact) { sim.session.run(ms); sim._fold('run ' + LRS.isoDuration(ms)); return ms; } sim.emit({ verb: 'experienced', // The PAGE, not the button: the run is engagement with the simulation (contract §7). object: { iri: sim.iri, name: sim.name, type: 'MicroSim' }, concept: sim.concept, result: { durationMs: ms, extensions: { 'run-ended-by': reason } } }, 'experienced ' + LRS.isoDuration(ms) + ' (' + reason + ')'); return ms; }; global.LRSSim = { HOVER_MS: HOVER_MS, MISCLICK_MS: MISCLICK_MS, GLANCE_MS: GLANCE_MS, create: function (opts) { return new Sim(opts); } }; })(typeof window !== 'undefined' ? window : this);