// lrs-lite-sim.js — compact xAPI for MicroSims. // // Design reference: docs/lrs-lite/index.md §6 (producer-side summarization). Statement // construction stays in lrs-xapi.js, the one module that owns the producer contract; this // file decides WHEN to emit and WHAT a session summary contains. // // WHAT "COMPACT" MEANS // -------------------- // A MicroSim in full mode emits one statement per meaningful interaction: every slider // step past its deadband, every Start/Pause run, every node a student studies. In compact // mode the same interactions are folded into an in-memory SESSION, and the session is // emitted as ONE `experienced` statement when the sim loses focus. The summary says how // many full-mode statements it stands for (`statements_represented`), so the compression // stays observable — the producer-side version of spec C-6. // // CONTROLLED BY CONFIG: lrs-config.js (the book) and metadata.json (the sim) // ------------------------------------------------------------------------- // The sim's own metadata.json (fetched relative to main.html) may carry: // // "xapi": { "compact": true, "teaching": false, // "idleMs": 90000, "offscreenMs": 10000, "blurMs": 30000 } // // compact true -> fold into a session, emit one summary on focus loss // false -> the sim emits its full per-interaction stream, unchanged // teaching true -> the sim shows its teaching UI (statement log, Full/Compact switch, // Simulate Done, View Formatted JSON) — only for sims that teach xAPI // idleMs no input for this long (and the sim is not running) ends the session // offscreenMs the sim mostly (< 25%) out of view for this long ends the session // blurMs the frame losing keyboard focus for this long ends the session // // Any key missing from the sim's block comes from the book's lrs-config.js `xapi` block, // then from DEFAULTS. With neither, the sim is COMPACT and silent, per §6.4: a sim with no // policy is in summary mode — not silent about the data, and not verbose. // // Above all of them sits the URL switch (urlPolicy below): `?xapi=teaching` on the sim's // page — or on the lesson or chapter page that embeds it — shows the teaching panel for // that visit, so any instrumented sim can become a teaching aid without editing a file. // // A teaching sim may override the policy at runtime with session.setCompact(bool), so a // reader can flip between the two streams. Leaving compact flushes the open session // (end_reason 'mode-switch') so nothing already folded is lost. // // "LOSES FOCUS" (§6.1) // -------------------- // No single browser event catches every way a student leaves a sim, so any of these ends // the session: tab hidden (visibilitychange), page left (pagehide), scrolled mostly out of // view (IntersectionObserver — inside an iframe the implicit root is the top-level // viewport, so this sees the PARENT page scrolling), idle, or blurred. Re-engaging starts // a new session. // // LOADING // ------- // // // // // A sim's JS must still run without this file — teachers paste sim JS into the p5.js // editor — so every call site is guarded: `if (xapi && xapi.compact) { ... }`. // // NOTHING HERE POSTS. Statements go to the sim's own publish() (its log panel) and to // LRSLite.statements. When the LRS-Lite store exists (§5), record() is where it attaches. (function (global) { 'use strict'; var DEFAULTS = { compact: true, teaching: false, idleMs: 90000, offscreenMs: 10000, blurMs: 30000 }; // Not configurable yet: a tick counts as active if the student gave input this recently. var ACTIVE_WINDOW_MS = 30000; // The frame counts as "in view" while at least this fraction of it is visible. var VISIBLE_FRACTION = 0.25; var TICK_MS = 1000; // Every statement published on this page, full or compact, in order. Tests read this. var statements = []; function record(st) { statements.push(st); try { global.dispatchEvent(new CustomEvent('lrs-lite:statement', { detail: st })); } catch (e) { /* CustomEvent unavailable: the array is still authoritative */ } } // ── The URL switch: a viewer's explicit request wins over every config layer ───── // // ?xapi=teaching show the teaching panel; starts on Full, like every teaching sim // ?xapi=teaching,compact the same, starting on Compact // ?xapi=full | compact choose the stream without the panel (read LRSLite.statements) // ?xapi=production hide the panel on a teaching sim, to see what production shows // // Read from the sim's own URL AND, inside an iframe, from the embedding page's URL. That is // what makes it useful: readers never see main.html's URL, so `?xapi=teaching` on a lesson // or chapter page turns every instrumented sim on that page into a teaching aid, with no // file edited. Tokens combine with `,` or `+`; a later token wins over an earlier one, and // the embedding page's tokens are read after the sim's own. function urlPolicy() { var tokens = []; function read(loc) { try { var v = new URLSearchParams(loc.search).get('xapi'); if (v) tokens = tokens.concat(v.split(/[\s,+]+/)); } catch (e) { /* no URLSearchParams, or a location we may not read */ } } read(global.location); try { if (global.parent && global.parent !== global) read(global.parent.location); } catch (e) { /* cross-origin embed: only the sim's own URL counts */ } var out = {}; tokens.forEach(function (t) { t = t.toLowerCase(); if (t === 'teaching') out.teaching = true; else if (t === 'production') out.teaching = false; else if (t === 'full') out.compact = false; else if (t === 'compact') out.compact = true; else if (t && global.console) { global.console.warn('[lrs-lite-sim] unknown ?xapi= token "' + t + '" — use teaching, production, full or compact'); } }); // Teaching sims start on Full (Dan, 2026-09-26): each statement is what they teach. if (out.teaching === true && out.compact === undefined) out.compact = false; return out; } // Config -> policy. Resolves, never rejects: an unreadable metadata.json is "no policy". // opts.metadata === false a page with no metadata.json of its own (a chapter quiz): skip it // opts.policy keys the page sets itself, over the book's and under the sim's function loadPolicy(opts) { opts = opts || {}; var fetched = (typeof fetch === 'function' && opts.metadata !== false) ? fetch('metadata.json', { cache: 'no-cache' }) .then(function (r) { return r.ok ? r.json() : null; }) .catch(function () { return null; }) : Promise.resolve(null); return fetched.then(function (meta) { // Precedence, lowest first: DEFAULTS < this book's lrs-config.js `xapi` < the page's own // `policy` option < this sim's metadata.json `xapi` < the URL switch. An agent changes a // whole textbook in one file, or one sim in its own; a viewer changes one visit in the URL. var cfg = global.LRS_CONFIG; var book = (cfg && typeof cfg.xapi === 'object' && cfg.xapi) || {}; var page = opts.policy || {}; var block = (meta && typeof meta.xapi === 'object' && meta.xapi) || {}; var url = urlPolicy(); var policy = {}; for (var k in DEFAULTS) { policy[k] = url[k] !== undefined ? url[k] : block[k] !== undefined ? block[k] : page[k] !== undefined ? page[k] : book[k] !== undefined ? book[k] : DEFAULTS[k]; } // Only an explicit `false` turns compaction off; only an explicit `true` shows teaching UI. policy.compact = policy.compact !== false; policy.teaching = policy.teaching === true; // True when ONLY the URL turned the panel on. The sim's iframe was then sized without // the panel, so lrs-sim.js grows the frame to fit it (teaching sims were sized for it). var configured = block.teaching !== undefined ? block.teaching : page.teaching !== undefined ? page.teaching : book.teaching; policy.teachingFromUrl = url.teaching === true && configured !== true; policy.source = Object.keys(url).length ? '?xapi= URL switch' : meta && meta.xapi ? 'metadata.json' : cfg && cfg.xapi ? 'lrs-config.js' : 'default'; return policy; }); } // ── Session ─────────────────────────────────────────────────────────────── // // opts = { // name: 'Bouncing Ball Simulation', // object.definition.name // concept: 'motion', // context concept_id (contract §6) // publish: function (statement, summaryText) { ... }, // the sim's log panel // beforeEnd: function (reason) { ... }, // optional: close open intervals first // metadata, policy // optional: see loadPolicy() // } function Session(opts) { var self = this; this.opts = opts || {}; this.compact = false; // false until the policy loads — nothing is folded early this.policy = null; this.busy = false; // e.g. a Start/Pause sim that is running this.lastInputAt = Date.now(); this.frameVisible = true; this.ending = false; this.ticker = null; this.offscreenTimer = null; this.blurTimer = null; this._reset(); this.listening = false; this.pendingCompact = null; // a setCompact() before the policy loaded wins over it this.ready = loadPolicy(this.opts).then(function (policy) { self.policy = policy; self._applyMode(self.pendingCompact !== null ? self.pendingCompact : policy.compact); return self; }); } Session.prototype._applyMode = function (compact) { this.compact = compact; document.documentElement.setAttribute('data-xapi-mode', compact ? 'compact' : 'full'); if (compact && !this.listening) this._listen(); }; // Switch modes at runtime — for teaching sims that let the reader compare the two // streams side by side. Leaving compact mode ends the open session first, so folded // work is emitted as a summary rather than silently dropped. The focus-loss listeners // stay attached once added; end() is a no-op in full mode. Session.prototype.setCompact = function (compact) { compact = !!compact; if (!this.policy) { this.pendingCompact = compact; return; } // timers need the policy if (compact === this.compact) return; if (!compact) this.end('mode-switch'); this._applyMode(compact); }; Session.prototype._reset = function () { this.open = false; this.startedAt = null; this.lastTickAt = null; this.activeMs = 0; this.interactions = 0; this.represented = 0; // full-mode statements this session stands for this.answers = 0; // answers passed through (never folded; they only open the session) this.controls = {}; this.runs = { count: 0, ms: 0 }; }; Session.prototype._open = function () { if (this.open) return; var self = this; this.open = true; this.startedAt = Date.now(); this.lastTickAt = this.startedAt; this.ticker = setInterval(function () { self._tick(); }, TICK_MS); }; Session.prototype._visible = function () { return document.visibilityState !== 'hidden' && this.frameVisible; }; // Credit the time since the last tick if the student was plausibly engaged, then check // for idleness. A running sim (busy) is engaged without input and never goes idle. Session.prototype._tick = function () { if (!this.open) return; var now = Date.now(); var dt = Math.min(now - this.lastTickAt, 2 * TICK_MS); this.lastTickAt = now; if (this._visible() && (this.busy || now - this.lastInputAt < ACTIVE_WINDOW_MS)) { this.activeMs += dt; } if (!this.busy && now - this.lastInputAt >= this.policy.idleMs) this.end('idle'); }; Session.prototype._listen = function () { var self = this; this.listening = true; function onInput() { self.lastInputAt = Date.now(); clearTimeout(self.blurTimer); } ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'input', 'change'] .forEach(function (type) { global.addEventListener(type, onInput, { capture: true, passive: true }); }); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') self.end('tab-hidden'); }); global.addEventListener('pagehide', function () { self.end('page-left'); }); global.addEventListener('blur', function () { clearTimeout(self.blurTimer); self.blurTimer = setTimeout(function () { self.end('blurred'); }, self.policy.blurMs); }); global.addEventListener('focus', function () { clearTimeout(self.blurTimer); }); if (typeof IntersectionObserver === 'function') { var thresholds = []; for (var i = 0; i <= 20; i++) thresholds.push(i / 20); new IntersectionObserver(function (entries) { var e = entries[entries.length - 1]; // Fraction of the FRAME that is visible, not of the document: a sim taller than // its iframe is still "fully in view" when the whole frame is on screen. var frame = Math.min(global.innerHeight, e.boundingClientRect.height) || 1; var fraction = e.isIntersecting ? e.intersectionRect.height / frame : 0; self._setFrameVisible(fraction >= VISIBLE_FRACTION); }, { threshold: thresholds }).observe(document.documentElement); } }; Session.prototype._setFrameVisible = function (visible) { var self = this; if (visible === this.frameVisible) return; this.frameVisible = visible; clearTimeout(this.offscreenTimer); if (!visible) { this.offscreenTimer = setTimeout(function () { if (!self.frameVisible) self.end('scrolled-away'); }, this.policy.offscreenMs); } }; // ── What a sim calls ────────────────────────────────────────────────────── // A control interaction that full mode would have emitted as one statement. // value — a numeric control's value (min/max/last are kept), or undefined // extra — { ms: dwell, mode: 'hover'|'pinned'|..., concept: 'concept-id', // reversals: direction changes since the last touch } // Reversals are carried because a summary loses the ORDER of values, and order is the // only thing a direction change could otherwise be recovered from. Session.prototype.touch = function (control, value, extra) { this._open(); extra = extra || {}; this.interactions++; this.represented++; var c = this.controls[control] || (this.controls[control] = { n: 0 }); c.n++; if (typeof value === 'number' && isFinite(value)) { c.min = (c.min === undefined) ? value : Math.min(c.min, value); c.max = (c.max === undefined) ? value : Math.max(c.max, value); c.last = value; } if (extra.ms) c.ms = (c.ms || 0) + Math.round(extra.ms); if (extra.mode) { c.modes = c.modes || {}; c.modes[extra.mode] = (c.modes[extra.mode] || 0) + 1; } if (extra.concept) c.concept = extra.concept; if (extra.reversals) c.reversals = (c.reversals || 0) + extra.reversals; }; // A Start/Pause run interval (contract §7) that full mode would have emitted. Session.prototype.run = function (ms) { this._open(); this.runs.count++; this.runs.ms += Math.round(ms); this.represented++; }; // There is deliberately NO way to fold an answer, prediction, or goal into a session. // Checked answers pass through in both modes as their own `answered` statements (Dan, // 2026-09-26; lrs-sim.js `question().answer()`): BKT reads the ORDER of attempts, each // answer keeps its question IRI, and the per-question rollups stay replayable (C-2). // A summary carries exposure evidence only. // // But an answer IS engagement with the sim, so it opens the session. Without that, a visit // whose only evidence was answers (a student who went straight through a drilldown's three // questions) ended in no summary at all, and its time on the sim was lost (found in // eight-hour-entrepreneur's symptom-root-cause-drilldown, 2026-09-26). The answer adds // nothing to statements_represented, interaction_count or controls: the summary of such a // visit says "0 statements folded", truthfully, and carries only the time. Session.prototype.answered = function () { this._open(); this.answers++; }; Session.prototype.setBusy = function (busy) { this.busy = !!busy; if (this.busy) this.lastInputAt = Date.now(); }; // End the session and emit its summary. Safe to call any time, in any mode. Session.prototype.end = function (reason) { if (!this.compact || !this.open || this.ending) return; this.ending = true; try { // e.g. close a run that is still going, so it lands in THIS summary if (this.opts.beforeEnd) this.opts.beforeEnd(reason); } catch (e) { if (global.console) global.console.warn('[lrs-lite-sim] beforeEnd failed', e); } this._tick(); var st = this._summary(reason); clearInterval(this.ticker); clearTimeout(this.offscreenTimer); this._reset(); this.ending = false; if (!st) return; var text = 'experienced SUMMARY ' + st.result.duration + ' (' + reason + ') — folds ' + st.context.extensions[global.LRS.EXT + 'statements_represented'] + ' statements'; if (typeof this.opts.publish === 'function') this.opts.publish(st, text); else record(st); }; Session.prototype._summary = function (reason) { // No evidence at all — nothing worth a statement. Answers count as evidence of engagement // here, though they were emitted on their own and are not folded into the summary. if (!this.represented && !this.answers) return null; if (!global.LRS) { if (global.console) global.console.warn('[lrs-lite-sim] lrs-xapi.js not loaded — summary dropped'); return null; } var ext = { xapi_mode: 'compact', end_reason: reason, active_ms: this.activeMs, session_ms: Date.now() - this.startedAt, interaction_count: this.interactions, controls: this.controls }; if (this.runs.count) ext.runs = this.runs; return global.LRS.build({ verb: 'experienced', object: { iri: global.LRS.pageIri(), name: this.opts.name || document.title, type: 'MicroSim' }, // `experienced` requires result.duration (contract §3). Engaged time, or the summed // run intervals if those are longer — a run is dwell by definition (§7). result: { durationMs: Math.max(this.activeMs, this.runs.ms), extensions: ext }, concept: this.opts.concept, contextExtensions: { statements_represented: this.represented } }); }; global.LRSLite = { DEFAULTS: DEFAULTS, statements: statements, record: record, loadPolicy: loadPolicy, sim: function (opts) { return new Session(opts); } }; })(typeof window !== 'undefined' ? window : this);