Skip to content

Commit

Permalink
Optimize and restructure the backend (#118)
Browse files Browse the repository at this point in the history
* Re-organize the initialization routine

webr-init.html ->
CSS styles to qwebr-styling.css
JS initialization to qwebr-init.js

* Break away the compute engine from being embedded multiple times on the same page.

* Add tests for multiple cells and output of graphs

* Centralize the computation engine for webR

* Remove compute engine from output context

* Clean up globalthis usage in initialization

* Improve the multi-cell test

* Compartmentalize the Monaco Editor initialization routine.

The Monaco Editor routine is now encapsulated into a single function that is used by each interactive chunk. This cuts down on the number of times the same code appears multiple times in the document.

* Add JS routines for creating elements.

The original HTML has been shifted completely to the qwebr-cell-elements.js.

* Add noscript warning in the output area

* Add a release note describing the underlying system change.
  • Loading branch information
coatless authored Dec 13, 2023
1 parent c9f8640 commit 541f610
Show file tree
Hide file tree
Showing 14 changed files with 928 additions and 736 deletions.
2 changes: 1 addition & 1 deletion _extensions/webr/_extension.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: webr
title: Embedded webr code cells
author: James Joseph Balamuta
version: 0.4.0-dev.3
version: 0.4.0-dev.4
quarto-required: ">=1.2.198"
contributes:
filters:
Expand Down
115 changes: 115 additions & 0 deletions _extensions/webr/qwebr-cell-elements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Function that dispatches the creation request
globalThis.qwebrCreateHTMLElement = function (insertElement,
qwebrCounter,
evalType = EvalTypes.Interactive,
options = {}) {

// Figure out the routine to use to insert the element.
let qwebrElement;
switch ( evalType ) {
case EvalTypes.Interactive:
qwebrElement = qwebrCreateInteractiveElement(qwebrCounter);
case EvalTypes.Output:
qwebrElement = qwebrCreateNonInteractiveOutputElement(qwebrCounter);
case EvalTypes.Setup:
qwebrElement = qwebrCreateNonInteractiveSetupElement(qwebrCounter);
default:
qwebrElement = document.createElement('div');
qwebrElement.textContent = 'Error creating element';
}

// Insert the dynamically generated object at the document location.
insertElement.appendChild(qwebrElement);
};

// Function that setups the interactive element creation
globalThis.qwebrCreateInteractiveElement = function (qwebrCounter) {

// Create main div element
var mainDiv = document.createElement('div');
mainDiv.id = 'qwebr-interactive-area-' + qwebrCounter;
mainDiv.className = 'qwebr-interactive-area';

// Create button element
var button = document.createElement('button');
button.className = 'btn btn-default qwebr-button-run';
button.disabled = true;
button.type = 'button';
button.id = 'qwebr-button-run-' + qwebrCounter;
button.textContent = '🟡 Loading webR...';

// Create console area div
var consoleAreaDiv = document.createElement('div');
consoleAreaDiv.id = 'qwebr-console-area-' + qwebrCounter;
consoleAreaDiv.className = 'qwebr-console-area';

// Create editor div
var editorDiv = document.createElement('div');
editorDiv.id = 'qwebr-editor-' + qwebrCounter;
editorDiv.className = 'qwebr-editor';

// Create output code area div
var outputCodeAreaDiv = document.createElement('div');
outputCodeAreaDiv.id = 'qwebr-output-code-area-' + qwebrCounter;
outputCodeAreaDiv.className = 'qwebr-output-code-area';
outputCodeAreaDiv.setAttribute('aria-live', 'assertive');

// Create pre element inside output code area
var preElement = document.createElement('pre');
preElement.style.visibility = 'hidden';
outputCodeAreaDiv.appendChild(preElement);

// Create output graph area div
var outputGraphAreaDiv = document.createElement('div');
outputGraphAreaDiv.id = 'qwebr-output-graph-area-' + qwebrCounter;
outputGraphAreaDiv.className = 'qwebr-output-graph-area';

// Append all elements to the main div
mainDiv.appendChild(button);
consoleAreaDiv.appendChild(editorDiv);
consoleAreaDiv.appendChild(outputCodeAreaDiv);
mainDiv.appendChild(consoleAreaDiv);
mainDiv.appendChild(outputGraphAreaDiv);

return mainDiv;
}

// Function that adds output structure for non-interactive output
globalThis.qwebrCreateNonInteractiveOutputElement = function(qwebrCounter) {
// Create main div element
var mainDiv = document.createElement('div');
mainDiv.id = 'qwebr-noninteractive-area-' + qwebrCounter;
mainDiv.className = 'qwebr-noninteractive-area';

// Create output code area div
var outputCodeAreaDiv = document.createElement('div');
outputCodeAreaDiv.id = 'qwebr-output-code-area-' + qwebrCounter;
outputCodeAreaDiv.className = 'qwebr-output-code-area';
outputCodeAreaDiv.setAttribute('aria-live', 'assertive');

// Create pre element inside output code area
var preElement = document.createElement('pre');
preElement.style.visibility = 'hidden';
outputCodeAreaDiv.appendChild(preElement);

// Create output graph area div
var outputGraphAreaDiv = document.createElement('div');
outputGraphAreaDiv.id = 'qwebr-output-graph-area-' + qwebrCounter;
outputGraphAreaDiv.className = 'qwebr-output-graph-area';

// Append all elements to the main div
mainDiv.appendChild(outputCodeAreaDiv);
mainDiv.appendChild(outputGraphAreaDiv);

return mainDiv;
};

// Function that adds a stub in the page to indicate a setup cell was used.
globalThis.qwebrCreateNonInteractiveSetupElement = function(qwebrCounter) {
// Create main div element
var mainDiv = document.createElement('div');
mainDiv.id = 'qwebr-noninteractive-setup-area-' + qwebrCounter;
mainDiv.className = 'qwebr-noninteractive-setup-area';

return mainDiv;
}
210 changes: 210 additions & 0 deletions _extensions/webr/qwebr-compute-engine.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// Supported Evaluation Types for Context
globalThis.EvalTypes = Object.freeze({
Interactive: 'interactive',
Setup: 'setup',
Output: 'output',
});

// Function to verify a given JavaScript Object is empty
globalThis.qwebrIsObjectEmpty = function (arr) {
return Object.keys(arr).length === 0;
}

// Function to parse the pager results
globalThis.qwebrParseTypePager = async function (msg) {

// Split out the event data
const { path, title, deleteFile } = msg.data;

// Process the pager data by reading the information from disk
const paged_data = await webR.FS.readFile(path).then((data) => {
// Obtain the file content
let content = new TextDecoder().decode(data);

// Remove excessive backspace characters until none remain
while(content.match(/.[\b]/)){
content = content.replace(/.[\b]/g, '');
}

// Returned cleaned data
return content;
});

// Unlink file if needed
if (deleteFile) {
await webR.FS.unlink(path);
}

// Return extracted data with spaces
return paged_data;
}

// Function to run the code using webR and parse the output
globalThis.qwebrComputeEngine = async function(
codeToRun,
elements,
options) {

// Call into the R compute engine that persists within the document scope.
// To be prepared for all scenarios, the following happens:
// 1. We setup a canvas device to write to by making a namespace call into the {webr} package
// 2. We use values inside of the options array to set the figure size.
// 3. We capture the output stream information (STDOUT and STERR)
// 4. While parsing the results, we disable image creation.

// Create a canvas variable for graphics
let canvas = undefined;

// Create a pager variable for help/file contents
let pager = [];

// ----

// Initialize webR
await webR.init();

// Setup a webR canvas by making a namespace call into the {webr} package
await webR.evalRVoid(`webr::canvas(width=${options["fig-width"]}, height=${options["fig-height"]})`);

const result = await webRCodeShelter.captureR(codeToRun, {
withAutoprint: true,
captureStreams: true,
captureConditions: false//,
// env: webR.objs.emptyEnv, // maintain a global environment for webR v0.2.0
});

// -----

// Start attempting to parse the result data
try {

// Stop creating images
await webR.evalRVoid("dev.off()");

// Merge output streams of STDOUT and STDErr (messages and errors are combined.)
const out = result.output
.filter(evt => evt.type === "stdout" || evt.type === "stderr")
.map((evt, index) => {
const className = `qwebr-output-code-${evt.type}`;
return `<code id="${className}-editor-${elements.id}-result-${index + 1}" class="${className}">${qwebrEscapeHTMLCharacters(evt.data)}</code>`;
})
.join("\n");


// Clean the state
// We're now able to process both graphics and pager events.
// As a result, we cannot maintain a true 1-to-1 output order
// without individually feeding each line
const msgs = await webR.flush();

// Output each image event stored
msgs.forEach((msg) => {
// Determine if old canvas can be used or a new canvas is required.
if (msg.type === 'canvas'){
// Add image to the current canvas
if (msg.data.event === 'canvasImage') {
canvas.getContext('2d').drawImage(msg.data.image, 0, 0);
} else if (msg.data.event === 'canvasNewPage') {
// Generate a new canvas element
canvas = document.createElement("canvas");
canvas.setAttribute("width", 2 * options["fig-width"]);
canvas.setAttribute("height", 2 * options["fig-height"]);
canvas.style.width = "700px";
canvas.style.display = "block";
canvas.style.margin = "auto";
}
}
});

// Use `map` to process the filtered "pager" events asynchronously
const pager = await Promise.all(
msgs.filter(msg => msg.type === 'pager').map(
async (msg) => {
return await qwebrParseTypePager(msg);
}
)
);

// Nullify the output area of content
elements.outputCodeDiv.innerHTML = "";
elements.outputGraphDiv.innerHTML = "";

// Design an output object for messages
const pre = document.createElement("pre");
if (/\S/.test(out)) {
// Display results as HTML elements to retain output styling
const div = document.createElement("div");
div.innerHTML = out;
pre.appendChild(div);
} else {
// If nothing is present, hide the element.
pre.style.visibility = "hidden";
}

elements.outputCodeDiv.appendChild(pre);

// Place the graphics on the canvas
if (canvas) {
elements.outputGraphDiv.appendChild(canvas);
}

// Display the pager data
if (pager) {
// Use the `pre` element to preserve whitespace.
pager.forEach((paged_data, index) => {
let pre_pager = document.createElement("pre");
pre_pager.innerText = paged_data;
pre_pager.classList.add("qwebr-output-code-pager");
pre_pager.setAttribute("id", `qwebr-output-code-pager-editor-${elements.id}-result-${index + 1}`);
elements.outputCodeDiv.appendChild(pre_pager);
});
}
} finally {
// Clean up the remaining code
webRCodeShelter.purge();
}
}

// Function to execute the code (accepts code as an argument)
globalThis.qwebrExecuteCode = async function (
codeToRun,
id,
evalType = EvalTypes.Interactive,
options = {}) {

// If options are not passed, we fall back on the bare minimum to handle the computation
if (qwebrIsObjectEmpty(options)) {
options = { "fig-width": 504, "fig-height": 360 };
}

// Next, we access the compute areas values
const elements = {
runButton: document.getElementById(`qwebr-button-run-${id}`),
outputCodeDiv: document.getElementById(`qwebr-output-code-area-${id}`),
outputGraphDiv: document.getElementById(`qwebr-output-graph-area-${id}`),
id: id,
}

// Disallowing execution of other code cells
document.querySelectorAll(".qwebr-button-run").forEach((btn) => {
btn.disabled = true;
});

if (evalType == EvalTypes.Interactive) {
// Emphasize the active code cell
elements.runButton.innerHTML = '<i class="fa-solid fa-spinner fa-spin qwebr-icon-status-spinner"></i> <span>Run Code</span>';
}

// Evaluate the code and parse the output into the document
await qwebrComputeEngine(codeToRun, elements, options);

// Switch to allowing execution of code
document.querySelectorAll(".qwebr-button-run").forEach((btn) => {
btn.disabled = false;
});

if (evalType == EvalTypes.Interactive) {
// Revert to the initial code cell state
elements.runButton.innerHTML = '<i class="fa-solid fa-play qwebr-icon-run-code"></i> <span>Run Code</span>';
}
}
Loading

0 comments on commit 541f610

Please sign in to comment.