Skip to content

Commit

Permalink
First pass at data grid viewer in webR app
Browse files Browse the repository at this point in the history
  • Loading branch information
georgestagg committed Jun 20, 2024
1 parent 629676f commit 87d30c9
Show file tree
Hide file tree
Showing 13 changed files with 168 additions and 13 deletions.
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

* Added resizable panels to the webR application (#396). The `canvas()` graphics device is now resized dynamically to fit to the plotting pane.

* The R `View()` command now invokes a simple data grid viewer in the webR application.

## Breaking changes

* The `ServiceWorker` communication channel has been deprecated. Users should use the `SharedArrayBuffer` channel where cross-origin isolation is possible, or otherwise use the `PostMessage` channel. For the moment the `ServiceWorker` channel can still be used, but emits a warning at start up. The channel will be removed entirely in a future version of webR.
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# cd src; prefetch-npm-deps package-lock.json
srcNpmDeps = pkgs.fetchNpmDeps {
src = "${self}/src";
hash = "sha256-EP9uT3JT/i0A/a0UI2KkOi7V8KDxuujUOzHEB2D5+l4=";
hash = "sha256-Qb3A25+y6X2Ky0qznfJHPjubkziYcESntXsF7wyDw1w=";
};

inherit system;
Expand Down
40 changes: 40 additions & 0 deletions patches/R-4.4.0/emscripten-dataviewer.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Index: R-4.4.0/src/unix/system.c
===================================================================
--- R-4.4.0.orig/src/unix/system.c
+++ R-4.4.0/src/unix/system.c
@@ -115,7 +115,24 @@ void R_FlushConsole(void) {
}
#endif

+/* Dataviewer under Emscripten */
+#ifdef __EMSCRIPTEN__
+#include <emscripten.h>
+static SEXP emscripten_do_dataviewer(SEXP call, SEXP op, SEXP args, SEXP rho) {
+ SEXP data = CAR(args);
+ SEXP title = CADR(args);
+ if (!isString(title) || LENGTH(title) < 1)
+ errorcall(call, _("invalid '%s' argument"), "title");

+ EM_ASM({
+ const title = UTF8ToString($1);
+ globalThis.Module.webr.dataViewer($0, title);
+ }, data, R_CHAR(STRING_ELT(title, 0)));
+
+ return R_NilValue;
+}
+#endif
+
void R_setupHistory(void)
{
int value, ierr;
@@ -326,6 +343,10 @@ int Rf_initialize_R(int ac, char **av)
ptr_R_EditFile = NULL; /* for future expansion */
R_timeout_handler = NULL;
R_timeout_val = 0;
+
+ #ifdef __EMSCRIPTEN__
+ ptr_do_dataviewer = emscripten_do_dataviewer;
+ #endif

R_GlobalContext = NULL; /* Make R_Suicide less messy... */

1 change: 1 addition & 0 deletions patches/R-4.4.0/series
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ emscripten-r-home-stderr.diff
flang-avoid-maxloc.diff
fontconfig-typeof.diff
fix-sink-release.diff
emscripten-dataviewer.diff
21 changes: 21 additions & 0 deletions src/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"prop-types": "^15.7.2",
"react": "^18.2.0",
"react-accessible-treeview": "^2.6.1",
"react-data-grid": "^7.0.0-beta.44",
"react-dom": "^18.2.0",
"react-icons": "^4.10.1",
"react-resizable-panels": "^2.0.19",
Expand Down
13 changes: 12 additions & 1 deletion src/repl/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import Plot from './components/Plot';
import Files from './components/Files';
import { Readline } from 'xterm-readline';
import { WebR } from '../webR/webr-main';
import { CanvasMessage, PagerMessage } from '../webR/webr-chan';
import { CanvasMessage, PagerMessage, ViewMessage } from '../webR/webr-chan';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import './App.css';
import { NamedObject, WebRDataJsAtomic } from '../webR/robj';

const webR = new WebR({
RArgs: [],
Expand All @@ -30,6 +31,7 @@ export interface TerminalInterface {
export interface FilesInterface {
refreshFilesystem: () => Promise<void>;
openFileInEditor: (name: string, path: string, readOnly: boolean) => Promise<void>;
openDataInEditor: (title: string, data: NamedObject<WebRDataJsAtomic<string>> ) => void;
}

export interface PlotInterface {
Expand All @@ -47,6 +49,7 @@ const terminalInterface: TerminalInterface = {
const filesInterface: FilesInterface = {
refreshFilesystem: () => Promise.resolve(),
openFileInEditor: () => { throw new Error('Unable to open file, editor not initialised.'); },
openDataInEditor: () => { throw new Error('Unable to view data, editor not initialised.'); },
};

const plotInterface: PlotInterface = {
Expand All @@ -73,6 +76,11 @@ async function handlePagerMessage(msg: PagerMessage) {
}
}

function handleViewMessage(msg: ViewMessage) {
const { title, data } = msg.data;
filesInterface.openDataInEditor(title, data);
}

const onPanelResize = (size: number) => {
plotInterface.resize("width", size * window.innerWidth / 100);
};
Expand Down Expand Up @@ -156,6 +164,9 @@ void (async () => {
case 'pager':
await handlePagerMessage(output as PagerMessage);
break;
case 'view':
handleViewMessage(output as ViewMessage);
break;
case 'closed':
throw new Error('The webR communication channel has been closed');
default:
Expand Down
2 changes: 1 addition & 1 deletion src/repl/components/Editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
padding: 5px 5px 0 5px;
}

.editor-container {
.editor-container, .data-container {
flex: 1;
overflow: auto;
}
Expand Down
73 changes: 66 additions & 7 deletions src/repl/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,38 @@ import { indentWithTab } from '@codemirror/commands';
import { Panel } from 'react-resizable-panels';
import { FilesInterface, TerminalInterface } from '../App';
import { r } from 'codemirror-lang-r';
import './Editor.css';
import { WebRDataJsAtomic } from '../../webR/robj';
import { NamedObject, WebRDataJsAtomic } from '../../webR/robj';
import DataGrid from 'react-data-grid';
import * as utils from './utils';
import 'react-data-grid/lib/styles.css';
import './Editor.css';

const language = new Compartment();
const tabSize = new Compartment();

export type EditorFile = {
name: string;
path: string;
type: "script" | "text" | "data",
readOnly: boolean,
ref: {
editorState: EditorState;
scrollTop?: number;
scrollLeft?: number;
data?: {
columns: {
key: string;
name: string;
}[];
rows: {
[key: string]: string;
}[];
}
}
};

const emptyState = EditorState.create();

export function FileTabs({
files,
activeFileIdx,
Expand Down Expand Up @@ -67,7 +82,7 @@ export function FileTabs({
className="editor-close"
aria-label={`Close ${f.name}`}
onClick={(e) => {
if (!f.ref.editorState.readOnly && !confirm('Close ' + f.name + '?')) {
if (!f.readOnly && !confirm('Close ' + f.name + '?')) {
e.stopPropagation();
return;
}
Expand Down Expand Up @@ -100,8 +115,9 @@ export function Editor({
});

const activeFile = files[activeFileIdx];
const isRFile = activeFile && activeFile.name.endsWith('.R');
const isReadOnly = activeFile && activeFile.ref.editorState.readOnly;
const isScript = activeFile && activeFile.type === "script";
const isData = activeFile && activeFile.type === "data";
const isReadOnly = activeFile && activeFile.readOnly;

const completionMethods = React.useRef<null | {
assignLineBuffer: RFunction;
Expand Down Expand Up @@ -265,6 +281,8 @@ export function Editor({
setFiles([{
name: 'Untitled1.R',
path: '/home/web_user/Untitled1.R',
type: 'script',
readOnly: false,
ref: {
editorState: state,
}
Expand All @@ -280,6 +298,33 @@ export function Editor({
* opening files they are displayed in this codemirror instance.
*/
React.useEffect(() => {
filesInterface.openDataInEditor = (title: string, data: NamedObject<WebRDataJsAtomic<string>>) => {
syncActiveFileState();

const columns = Object.keys(data).map((key) => {
return {key, name: key === "row.names" ? "" : key};
});

const rows = Object.entries(data).reduce((a, entry) => {
entry[1].values.forEach((v, j) => a[j] = Object.assign(a[j] || {}, { [entry[0]!]: v }));
return a;
}, []);

const updatedFiles = [...files];
const index = updatedFiles.push({
name: title,
path: `/tmp/${title}.tmp`,
type: "data",
readOnly: true,
ref: {
editorState: emptyState,
data: { columns, rows }
},
});
setFiles(updatedFiles);
setActiveFileIdx(index - 1);
};

filesInterface.openFileInEditor = (name: string, path: string, readOnly: boolean) => {
// Don't reopen the file if it's already open, switch to that tab instead
const existsIndex = files.findIndex((f) => f.path === path);
Expand All @@ -304,6 +349,8 @@ export function Editor({
const index = updatedFiles.push({
name,
path,
type: name.endsWith('.R') ? "script" : "text",
readOnly,
ref: {
editorState: EditorState.create({
doc: content,
Expand Down Expand Up @@ -362,7 +409,7 @@ export function Editor({
<div
aria-label="Editor"
aria-describedby="editor-desc"
className="editor-container"
className={`editor-container ${isData ? "d-none" : ""}`}
ref={editorRef}
>
</div>
Expand All @@ -372,12 +419,24 @@ export function Editor({
To move focus away from the editor, press the Escape key, and then press the Tab key directly after it.
Escape and then Shift-Tab can also be used to move focus backwards.
</p>
{(isData && activeFile.ref.data) &&
<DataGrid
aria-label="Data viewer"
columns={activeFile.ref.data.columns}
rows={activeFile.ref.data.rows}
className="data-container"
defaultColumnOptions={{
sortable: true,
resizable: true
}}
/>
}
<div
role="toolbar"
aria-label="Editor Toolbar"
className="editor-actions"
>
{isRFile && <button onClick={runFile}>
{isScript && <button onClick={runFile}>
<FaPlay aria-hidden="true" className="icon" /> Run
</button>}
{!isReadOnly && <button onClick={saveFile}>
Expand Down
1 change: 1 addition & 0 deletions src/webR/emscripten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export interface Module extends EmscriptenModule {
readConsole: () => number;
resolveInit: () => void;
handleEvents: () => void;
dataViewer: (data: RPtr, title: string) => void;
evalJs: (code: RPtr) => unknown;
evalR: (expr: string | RObject, options?: EvalROptions) => RObject;
captureR: (expr: string | RObject, options: EvalROptions) => {
Expand Down
4 changes: 4 additions & 0 deletions src/webR/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IN_NODE } from './compat';
import { WebRError } from './error';
import { RObjectBase } from './robj-worker';

export type ResolveFn = (_value?: unknown) => void;
export type RejectFn = (_reason?: any) => void;
Expand Down Expand Up @@ -48,6 +49,9 @@ export function replaceInObject<T>(
replaceInObject(v, test, replacer, ...replacerArgs)
) as T[];
}
if (obj instanceof RObjectBase) {
return obj;
}
if (typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, replaceInObject(v, test, replacer, ...replacerArgs)])
Expand Down
12 changes: 11 additions & 1 deletion src/webR/webr-chan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Message } from './chan/message';
import { UUID as ShelterID } from './chan/task-common';
import { EmPtr } from './emscripten';
import { WebRPayloadWorker, WebRPayloadPtr } from './payload';
import { RType, RCtor, WebRData } from './robj';
import { RType, RCtor, WebRData, WebRDataJsAtomic } from './robj';
import type { FSType, FSMountOptions } from './webr-main';

export { isUUID as isShelterID, UUID as ShelterID } from './chan/task-common';
Expand Down Expand Up @@ -235,3 +235,13 @@ export interface PagerMessage extends Message {
deleteFile: boolean;
};
}

export interface ViewMessage extends Message {
type: 'view';
data: {
data: {
[key: string]: WebRDataJsAtomic<string>;
};
title: string;
};
}
9 changes: 7 additions & 2 deletions src/webR/webr-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ function dispatch(msg: Message): void {
const msg = reqMsg as InstallPackagesMessage;
let pkgs = msg.data.name;
let repos = msg.data.options.repos ? msg.data.options.repos : _config.repoUrl;
if (typeof pkgs === "string") pkgs = [ pkgs ];
if (typeof repos === "string") repos = [ repos ];
if (typeof pkgs === "string") pkgs = [pkgs];
if (typeof repos === "string") repos = [repos];
evalR(`webr::install(
c(${pkgs.map((r) => '"' + r + '"').join(',')}),
repos = c(${repos.map((r) => '"' + r + '"').join(',')}),
Expand Down Expand Up @@ -911,6 +911,11 @@ function init(config: Required<WebROptions>) {
chan?.handleInterrupt();
},

dataViewer: (ptr: RPtr, title: string) => {
const data = RList.wrap(ptr).toObject({ depth: 0 });
chan?.write({ type: 'view', data: { data, title } });
},

evalJs: (code: RPtr): unknown => {
try {
return (0, eval)(Module.UTF8ToString(code));
Expand Down

0 comments on commit 87d30c9

Please sign in to comment.