concept-collection / jupyterlite-numbl-kernel
jupyterlite-numbl-kernel: MATLAB-syntax kernel for JupyterLite
A JupyterLite kernel that executes MATLAB-syntax code fully client-side via numbl browser sessions, plus a mime renderer that displays figures with numbl's React renderer, a three-notebook demo site, and GitHub Actions to build the wheel and deploy the demo to Pages. Kernel: extends BaseKernel from @jupyterlite/services; cells run through session.execute (persistent workspace), output streams to the cell, and plot instructions are published as application/vnd.numbl.figure+json. Requires numbl >= 0.4.14 (unreleased at commit time).
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 41c072c63bdc Browse files
22 changed files+1312−0
.github/workflows/build.ymladded+41−0View file
@@ -0,0 +1,41 @@
1+name: Build
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+ branches: ['*']
8+
9+jobs:
10+ build:
11+ runs-on: ubuntu-latest
12+ steps:
13+ - name: Checkout
14+ uses: actions/checkout@v4
15+
16+ - name: Setup Node
17+ uses: actions/setup-node@v4
18+ with:
19+ node-version: '22'
20+
21+ - name: Setup Python
22+ uses: actions/setup-python@v5
23+ with:
24+ python-version: '3.12'
25+
26+ - name: Install build requirements
27+ run: python -m pip install "jupyterlab~=4.6.0" build
28+
29+ - name: Lint
30+ run: |
31+ jlpm install
32+ jlpm lint:check
33+
34+ - name: Build the extension wheel
35+ run: python -m build
36+
37+ - name: Upload wheel
38+ uses: actions/upload-artifact@v4
39+ with:
40+ name: extension-wheel
41+ path: dist/*.whl
.github/workflows/deploy.ymladded+51−0View file
@@ -0,0 +1,51 @@
1+name: Build and Deploy Demo Site
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+jobs:
9+ build:
10+ runs-on: ubuntu-latest
11+ steps:
12+ - name: Checkout
13+ uses: actions/checkout@v4
14+
15+ - name: Setup Node
16+ uses: actions/setup-node@v4
17+ with:
18+ node-version: '22'
19+
20+ - name: Setup Python
21+ uses: actions/setup-python@v5
22+ with:
23+ python-version: '3.12'
24+
25+ - name: Install site requirements and the kernel extension
26+ run: |
27+ python -m pip install -r demo/requirements.txt
28+ python -m pip install .
29+
30+ - name: Build the JupyterLite site
31+ run: jupyter lite build --lite-dir demo --contents content --output-dir dist
32+
33+ - name: Upload artifact
34+ uses: actions/upload-pages-artifact@v3
35+ with:
36+ path: ./dist
37+
38+ deploy:
39+ needs: build
40+ if: github.ref == 'refs/heads/main'
41+ permissions:
42+ pages: write
43+ id-token: write
44+ environment:
45+ name: github-pages
46+ url: ${{ steps.deployment.outputs.page_url }}
47+ runs-on: ubuntu-latest
48+ steps:
49+ - name: Deploy to GitHub Pages
50+ id: deployment
51+ uses: actions/deploy-pages@v4
.gitignoreadded+27−0View file
@@ -0,0 +1,27 @@
1+node_modules/
2+lib/
3+tsconfig.tsbuildinfo
4+dist/
5+*.tgz
6+
7+# Prebuilt labextension (built from src/ by hatch/jlpm)
8+jupyterlite_numbl_kernel/labextension
9+jupyterlite_numbl_kernel/_version.py
10+
11+# Python
12+__pycache__/
13+*.egg-info/
14+.venv/
15+build/
16+
17+# Yarn (jlpm)
18+.yarn/
19+# The lockfile is intentionally not committed yet: it would pin the numbl
20+# dependency, which is released in lockstep with this extension. Remove this
21+# line and commit yarn.lock once the referenced numbl version is on npm.
22+yarn.lock
23+
24+# JupyterLite build artifacts
25+.jupyterlite.doit.db
26+demo/.jupyterlite.doit.db
27+demo/_output
.prettierignoreadded+10−0View file
@@ -0,0 +1,10 @@
1+node_modules
2+**/node_modules
3+lib
4+dist
5+_output
6+.venv
7+.yarn
8+yarn.lock
9+jupyterlite_numbl_kernel/labextension
10+demo/content/*.ipynb
.yarnrc.ymladded+3−0View file
@@ -0,0 +1,3 @@
1+enableTelemetry: false
2+
3+nodeLinker: node-modules
LICENSEadded+191−0View file
@@ -0,0 +1,191 @@
1+
2+ Apache License
3+ Version 2.0, January 2004
4+ http://www.apache.org/licenses/
5+
6+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7+
8+ 1. Definitions.
9+
10+ "License" shall mean the terms and conditions for use, reproduction,
11+ and distribution as defined by Sections 1 through 9 of this document.
12+
13+ "Licensor" shall mean the copyright owner or entity authorized by
14+ the copyright owner that is granting the License.
15+
16+ "Legal Entity" shall mean the union of the acting entity and all
17+ other entities that control, are controlled by, or are under common
18+ control with that entity. For the purposes of this definition,
19+ "control" means (i) the power, direct or indirect, to cause the
20+ direction or management of such entity, whether by contract or
21+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22+ outstanding shares, or (iii) beneficial ownership of such entity.
23+
24+ "You" (or "Your") shall mean an individual or Legal Entity
25+ exercising permissions granted by this License.
26+
27+ "Source" form shall mean the preferred form for making modifications,
28+ including but not limited to software source code, documentation
29+ source, and configuration files.
30+
31+ "Object" form shall mean any form resulting from mechanical
32+ transformation or translation of a Source form, including but
33+ not limited to compiled object code, generated documentation,
34+ and conversions to other media types.
35+
36+ "Work" shall mean the work of authorship, whether in Source or
37+ Object form, made available under the License, as indicated by a
38+ copyright notice that is included in or attached to the work
39+ (an example is provided in the Appendix below).
40+
41+ "Derivative Works" shall mean any work, whether in Source or Object
42+ form, that is based on (or derived from) the Work and for which the
43+ editorial revisions, annotations, elaborations, or other modifications
44+ represent, as a whole, an original work of authorship. For the purposes
45+ of this License, Derivative Works shall not include works that remain
46+ separable from, or merely link (or bind by name) to the interfaces of,
47+ the Work and Derivative Works thereof.
48+
49+ "Contribution" shall mean any work of authorship, including
50+ the original version of the Work and any modifications or additions
51+ to that Work or Derivative Works thereof, that is intentionally
52+ submitted to the Licensor for inclusion in the Work by the copyright owner
53+ or by an individual or Legal Entity authorized to submit on behalf of
54+ the copyright owner. For the purposes of this definition, "submitted"
55+ means any form of electronic, verbal, or written communication sent
56+ to the Licensor or its representatives, including but not limited to
57+ communication on electronic mailing lists, source code control systems,
58+ and issue tracking systems that are managed by, or on behalf of, the
59+ Licensor for the purpose of discussing and improving the Work, but
60+ excluding communication that is conspicuously marked or otherwise
61+ designated in writing by the copyright owner as "Not a Contribution."
62+
63+ "Contributor" shall mean Licensor and any individual or Legal Entity
64+ on behalf of whom a Contribution has been received by the Licensor and
65+ subsequently incorporated within the Work.
66+
67+ 2. Grant of Copyright License. Subject to the terms and conditions of
68+ this License, each Contributor hereby grants to You a perpetual,
69+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70+ copyright license to reproduce, prepare Derivative Works of,
71+ publicly display, publicly perform, sublicense, and distribute the
72+ Work and such Derivative Works in Source or Object form.
73+
74+ 3. Grant of Patent License. Subject to the terms and conditions of
75+ this License, each Contributor hereby grants to You a perpetual,
76+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77+ (except as stated in this section) patent license to make, have made,
78+ use, offer to sell, sell, import, and otherwise transfer the Work,
79+ where such license applies only to those patent claims licensable
80+ by such Contributor that are necessarily infringed by their
81+ Contribution(s) alone or by combination of their Contribution(s)
82+ with the Work to which such Contribution(s) was submitted. If You
83+ institute patent litigation against any entity (including a
84+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85+ or a Contribution incorporated within the Work constitutes direct
86+ or contributory patent infringement, then any patent licenses
87+ granted to You under this License for that Work shall terminate
88+ as of the date such litigation is filed.
89+
90+ 4. Redistribution. You may reproduce and distribute copies of the
91+ Work or Derivative Works thereof in any medium, with or without
92+ modifications, and in Source or Object form, provided that You
93+ meet the following conditions:
94+
95+ (a) You must give any other recipients of the Work or
96+ Derivative Works a copy of this License; and
97+
98+ (b) You must cause any modified files to carry prominent notices
99+ stating that You changed the files; and
100+
101+ (c) You must retain, in the Source form of any Derivative Works
102+ that You distribute, all copyright, patent, trademark, and
103+ attribution notices from the Source form of the Work,
104+ excluding those notices that do not pertain to any part of
105+ the Derivative Works; and
106+
107+ (d) If the Work includes a "NOTICE" text file as part of its
108+ distribution, then any Derivative Works that You distribute must
109+ include a readable copy of the attribution notices contained
110+ within such NOTICE file, excluding any notices that do not
111+ pertain to any part of the Derivative Works, in at least one
112+ of the following places: within a NOTICE text file distributed
113+ as part of the Derivative Works; within the Source form or
114+ documentation, if provided along with the Derivative Works; or,
115+ within a display generated by the Derivative Works, if and
116+ wherever such third-party notices normally appear. The contents
117+ of the NOTICE file are for informational purposes only and
118+ do not modify the License. You may add Your own attribution
119+ notices within Derivative Works that You distribute, alongside
120+ or as an addendum to the NOTICE text from the Work, provided
121+ that such additional attribution notices cannot be construed
122+ as modifying the License.
123+
124+ You may add Your own copyright statement to Your modifications and
125+ may provide additional or different license terms and conditions
126+ for use, reproduction, or distribution of Your modifications, or
127+ for any such Derivative Works as a whole, provided Your use,
128+ reproduction, and distribution of the Work otherwise complies with
129+ the conditions stated in this License.
130+
131+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132+ any Contribution intentionally submitted for inclusion in the Work
133+ by You to the Licensor shall be under the terms and conditions of
134+ this License, without any additional terms or conditions.
135+ Notwithstanding the above, nothing herein shall supersede or modify
136+ the terms of any separate license agreement you may have executed
137+ with Licensor regarding such Contributions.
138+
139+ 6. Trademarks. This License does not grant permission to use the trade
140+ names, trademarks, service marks, or product names of the Licensor,
141+ except as required for reasonable and customary use in describing the
142+ origin of the Work and reproducing the content of the NOTICE file.
143+
144+ 7. Disclaimer of Warranty. Unless required by applicable law or
145+ agreed to in writing, Licensor provides the Work (and each
146+ Contributor provides its Contributions) on an "AS IS" BASIS,
147+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148+ implied, including, without limitation, any warranties or conditions
149+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150+ PARTICULAR PURPOSE. You are solely responsible for determining the
151+ appropriateness of using or redistributing the Work and assume any
152+ risks associated with Your exercise of permissions under this License.
153+
154+ 8. Limitation of Liability. In no event and under no legal theory,
155+ whether in tort (including negligence), contract, or otherwise,
156+ unless required by applicable law (such as deliberate and grossly
157+ negligent acts) or agreed to in writing, shall any Contributor be
158+ liable to You for damages, including any direct, indirect, special,
159+ incidental, or consequential damages of any character arising as a
160+ result of this License or out of the use or inability to use the
161+ Work (including but not limited to damages for loss of goodwill,
162+ work stoppage, computer failure or malfunction, or any and all
163+ other commercial damages or losses), even if such Contributor
164+ has been advised of the possibility of such damages.
165+
166+ 9. Accepting Warranty or Additional Liability. While redistributing
167+ the Work or Derivative Works thereof, You may choose to offer,
168+ and charge a fee for, acceptance of support, warranty, indemnity,
169+ or other liability obligations and/or rights consistent with this
170+ License. However, in accepting such obligations, You may act only
171+ on Your own behalf and on Your sole responsibility, not on behalf
172+ of any other Contributor, and only if You agree to indemnify,
173+ defend, and hold each Contributor harmless for any liability
174+ incurred by, or claims asserted against, such Contributor by reason
175+ of your accepting any such warranty or additional liability.
176+
177+ END OF TERMS AND CONDITIONS
178+
179+ Copyright 2025 Flatiron Institute
180+
181+ Licensed under the Apache License, Version 2.0 (the "License");
182+ you may not use this file except in compliance with the License.
183+ You may obtain a copy of the License at
184+
185+ http://www.apache.org/licenses/LICENSE-2.0
186+
187+ Unless required by applicable law or agreed to in writing, software
188+ distributed under the License is distributed on an "AS IS" BASIS,
189+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
190+ See the License for the specific language governing permissions and
191+ limitations under the License.
README.mdadded+104−0View file
@@ -0,0 +1,104 @@
1+# jupyterlite-numbl-kernel
2+
3+A MATLAB-syntax kernel for [JupyterLite](https://jupyterlite.readthedocs.io/) —
4+notebooks that run **entirely in the browser**, with no server, no kernel
5+process, and nothing for the reader to install.
6+
7+The language engine is [numbl](https://github.com/flatironinstitute/numbl), an
8+open-source MATLAB-syntax implementation in TypeScript. Each kernel runs a
9+numbl session in a Web Worker in the page: variables persist across cells,
10+console output streams into the running cell, MATLAB plotting commands render
11+as figures in cell outputs (including interactive 3-D), and the `mip` package
12+manager can install MATLAB-syntax packages from GitHub — all client-side.
13+
14+**Demo site:**
15+<https://concept-collection.github.io/jupyterlite-numbl-kernel/> (deployed
16+from this repo via GitHub Pages — see `.github/workflows/deploy.yml`)
17+
18+## Why
19+
20+Existing MATLAB/Octave Jupyter kernels require the real product installed
21+behind a server. This kernel is a proof of concept that a MATLAB-syntax
22+notebook can be a static web page: hostable on GitHub Pages, shareable as a
23+link, and executable by anyone with a browser.
24+
25+## How it works
26+
27+Three small pieces, all in this repo:
28+
29+- **Kernel** (`src/kernel.ts`) — implements JupyterLite's `BaseKernel` from
30+ `@jupyterlite/services`. `execute_request` forwards the cell source to a
31+ numbl session (`createNumblSession` / `session.execute` from
32+ `numbl/browser`, a Web Worker that numbl manages). Output streams back as
33+ `stream` messages; the run's plot instructions are published as
34+ `display_data` with the mime type `application/vnd.numbl.figure+json`.
35+- **Figure renderer** (`src/mime.tsx`) — a JupyterLab mime renderer for that
36+ mime type: it replays the instructions through numbl's figures reducer and
37+ mounts numbl's React `FigureView` (from `numbl/graphics`). Outputs are
38+ plain JSON, so saved notebooks re-render wherever the extension is
39+ installed.
40+- **Kernel registration** (`src/index.ts`) — registers the kernelspec with
41+ JupyterLite's `IKernelSpecs`.
42+
43+## Build a site with it
44+
45+```bash
46+pip install jupyterlite-core jupyterlite-numbl-kernel
47+jupyter lite build --contents my-notebooks --output-dir dist
48+# dist/ is a static site — serve it anywhere
49+```
50+
51+The `demo/` directory in this repo contains the demo site sources
52+(notebooks + requirements); `.github/workflows/deploy.yml` builds and
53+deploys it to GitHub Pages.
54+
55+## Limitations (proof of concept)
56+
57+- **No interrupt**: a runaway cell can only be stopped by restarting the
58+ kernel (restart works and gives a fresh workspace). Cooperative
59+ cancellation exists in numbl but needs `SharedArrayBuffer`, i.e.
60+ cross-origin isolation headers, which plain GitHub Pages doesn't set.
61+- **No `input()`** (stdin), for the same reason.
62+- **Figures are per-cell** (like inline matplotlib): each cell renders the
63+ figures its own commands produce; `hold on` does not span cells.
64+- **Named function definitions are not supported inside cells** (a numbl
65+ REPL limitation) — anonymous functions work; named functions belong in
66+ `.m` files.
67+- **uihtml** components render display-only; the MATLAB↔HTML event bridge
68+ is not wired into outputs yet.
69+- Notebook files from the JupyterLite contents (e.g. sibling `.m` files)
70+ are not yet synced into the numbl session's virtual filesystem.
71+- numbl itself is not MATLAB: it covers a large, tested subset of the
72+ language and toolbox surface. See the
73+ [numbl repo](https://github.com/flatironinstitute/numbl) for scope.
74+
75+## Development
76+
77+Requires Python ≥ 3.9 and NodeJS ≥ 20, and `numbl >= 0.4.14` on npm (the
78+first release with the incremental `session.execute` browser API). To
79+develop against an unreleased numbl checkout, run `npm pack` there and
80+point the `numbl` dependency at the tarball.
81+
82+```bash
83+python -m venv .venv && source .venv/bin/activate
84+pip install "jupyterlab~=4.6.0" "jupyterlite-core==0.8.1"
85+
86+jlpm install
87+jlpm build # tsc + labextension (dev)
88+pip install -e . # editable install, registers the labextension
89+
90+# Build and serve the demo site locally
91+pip install -r demo/requirements.txt
92+jupyter lite build --lite-dir demo --contents content --output-dir demo/_output
93+python -m http.server -d demo/_output 8000
94+```
95+
96+`jlpm watch` rebuilds on change during development.
97+
98+## License
99+
100+Apache-2.0. Built on [numbl](https://github.com/flatironinstitute/numbl) and
101+the [JupyterLite](https://github.com/jupyterlite/jupyterlite) kernel API;
102+scaffolding follows the
103+[jupyterlite/echo-kernel](https://github.com/jupyterlite/echo-kernel)
104+template.
demo/content/01-intro.ipynbadded+107−0View file
@@ -0,0 +1,107 @@
1+{
2+ "cells": [
3+ {
4+ "cell_type": "markdown",
5+ "id": "intro-title",
6+ "metadata": {},
7+ "source": [
8+ "# MATLAB syntax, entirely in your browser\n",
9+ "\n",
10+ "This notebook runs on the **numbl kernel for JupyterLite**. Every cell executes\n",
11+ "in a Web Worker in *your* browser tab — there is no server and no kernel\n",
12+ "process behind this page, and nothing to install.\n",
13+ "\n",
14+ "The engine is [numbl](https://github.com/flatironinstitute/numbl), an\n",
15+ "open-source MATLAB-syntax language implementation in TypeScript.\n",
16+ "\n",
17+ "Run the cells below with **Shift+Enter**."
18+ ]
19+ },
20+ {
21+ "cell_type": "code",
22+ "execution_count": null,
23+ "id": "intro-matrix",
24+ "metadata": {},
25+ "outputs": [],
26+ "source": [
27+ "A = [4 2 1; 2 5 3; 1 3 6]"
28+ ]
29+ },
30+ {
31+ "cell_type": "code",
32+ "execution_count": null,
33+ "id": "intro-solve",
34+ "metadata": {},
35+ "outputs": [],
36+ "source": [
37+ "% Variables persist across cells; solve a linear system with backslash\n",
38+ "b = [1; 2; 3];\n",
39+ "x = A \\ b\n",
40+ "residual = norm(A*x - b)"
41+ ]
42+ },
43+ {
44+ "cell_type": "code",
45+ "execution_count": null,
46+ "id": "intro-indexing",
47+ "metadata": {},
48+ "outputs": [],
49+ "source": [
50+ "% MATLAB indexing: rows, and logical masks\n",
51+ "A(2, :)\n",
52+ "A(A > 3)'"
53+ ]
54+ },
55+ {
56+ "cell_type": "code",
57+ "execution_count": null,
58+ "id": "intro-loop",
59+ "metadata": {},
60+ "outputs": [],
61+ "source": [
62+ "total = 0;\n",
63+ "for k = 1:10\n",
64+ " total = total + k^2;\n",
65+ "end\n",
66+ "fprintf('sum of squares 1..10 = %d\\n', total);"
67+ ]
68+ },
69+ {
70+ "cell_type": "code",
71+ "execution_count": null,
72+ "id": "intro-anon",
73+ "metadata": {},
74+ "outputs": [],
75+ "source": [
76+ "% Anonymous functions work in cells (named functions belong in .m files)\n",
77+ "f = @(t) exp(-t) .* cos(2*pi*t);\n",
78+ "vals = arrayfun(f, 0:0.5:2)"
79+ ]
80+ },
81+ {
82+ "cell_type": "markdown",
83+ "id": "intro-next",
84+ "metadata": {},
85+ "source": [
86+ "Next: [plotting](./02-plotting.ipynb) and\n",
87+ "[installing packages](./03-packages.ipynb) — both also fully client-side."
88+ ]
89+ }
90+ ],
91+ "metadata": {
92+ "kernelspec": {
93+ "display_name": "MATLAB (numbl)",
94+ "language": "matlab",
95+ "name": "numbl"
96+ },
97+ "language_info": {
98+ "codemirror_mode": "octave",
99+ "file_extension": ".m",
100+ "mimetype": "text/x-octave",
101+ "name": "matlab",
102+ "pygments_lexer": "matlab"
103+ }
104+ },
105+ "nbformat": 4,
106+ "nbformat_minor": 5
107+}
demo/content/02-plotting.ipynbadded+87−0View file
@@ -0,0 +1,87 @@
1+{
2+ "cells": [
3+ {
4+ "cell_type": "markdown",
5+ "id": "plot-title",
6+ "metadata": {},
7+ "source": [
8+ "# Plotting\n",
9+ "\n",
10+ "MATLAB plotting commands render through numbl's figure renderer, straight\n",
11+ "into the cell output — no display server involved. 3-D figures are\n",
12+ "interactive (drag to rotate).\n",
13+ "\n",
14+ "Figures are per-cell, like inline matplotlib: each cell renders the figures\n",
15+ "its own commands produce."
16+ ]
17+ },
18+ {
19+ "cell_type": "code",
20+ "execution_count": null,
21+ "id": "plot-lines",
22+ "metadata": {},
23+ "outputs": [],
24+ "source": [
25+ "x = linspace(0, 2*pi, 200);\n",
26+ "plot(x, sin(x), 'b-');\n",
27+ "hold on;\n",
28+ "plot(x, cos(x), 'r--');\n",
29+ "hold off;\n",
30+ "title('sin and cos');\n",
31+ "xlabel('x');\n",
32+ "legend('sin', 'cos');"
33+ ]
34+ },
35+ {
36+ "cell_type": "code",
37+ "execution_count": null,
38+ "id": "plot-subplot",
39+ "metadata": {},
40+ "outputs": [],
41+ "source": [
42+ "subplot(1, 2, 1); plot(x, sin(x)); title('sin(x)');\n",
43+ "subplot(1, 2, 2); plot(x, sin(2*x)); title('sin(2x)');"
44+ ]
45+ },
46+ {
47+ "cell_type": "code",
48+ "execution_count": null,
49+ "id": "plot-imagesc",
50+ "metadata": {},
51+ "outputs": [],
52+ "source": [
53+ "Z = peaks(80);\n",
54+ "imagesc(Z);\n",
55+ "colorbar;\n",
56+ "title('peaks');"
57+ ]
58+ },
59+ {
60+ "cell_type": "code",
61+ "execution_count": null,
62+ "id": "plot-surf",
63+ "metadata": {},
64+ "outputs": [],
65+ "source": [
66+ "surf(peaks(40));\n",
67+ "title('peaks surface — drag to rotate');"
68+ ]
69+ }
70+ ],
71+ "metadata": {
72+ "kernelspec": {
73+ "display_name": "MATLAB (numbl)",
74+ "language": "matlab",
75+ "name": "numbl"
76+ },
77+ "language_info": {
78+ "codemirror_mode": "octave",
79+ "file_extension": ".m",
80+ "mimetype": "text/x-octave",
81+ "name": "matlab",
82+ "pygments_lexer": "matlab"
83+ }
84+ },
85+ "nbformat": 4,
86+ "nbformat_minor": 5
87+}
demo/content/03-packages.ipynbadded+67−0View file
@@ -0,0 +1,67 @@
1+{
2+ "cells": [
3+ {
4+ "cell_type": "markdown",
5+ "id": "pkg-title",
6+ "metadata": {},
7+ "source": [
8+ "# Installing packages — still no server\n",
9+ "\n",
10+ "numbl ships with `mip`, a package manager for MATLAB-syntax libraries.\n",
11+ "Packages are fetched from GitHub releases and cached in your browser's\n",
12+ "IndexedDB, so the download happens once.\n",
13+ "\n",
14+ "The first cell you run in a session also bootstraps the engine, so expect a\n",
15+ "short delay (progress is printed)."
16+ ]
17+ },
18+ {
19+ "cell_type": "code",
20+ "execution_count": null,
21+ "id": "pkg-install",
22+ "metadata": {},
23+ "outputs": [],
24+ "source": [
25+ "mip load --install chebfun"
26+ ]
27+ },
28+ {
29+ "cell_type": "code",
30+ "execution_count": null,
31+ "id": "pkg-roots",
32+ "metadata": {},
33+ "outputs": [],
34+ "source": [
35+ "% Chebfun: numerical computing with functions\n",
36+ "x = chebfun('x', [0 4]);\n",
37+ "f = sin(x.^2);\n",
38+ "r = roots(f);\n",
39+ "fprintf('sin(x^2) has %d roots in [0, 4]\\n', numel(r));"
40+ ]
41+ },
42+ {
43+ "cell_type": "code",
44+ "execution_count": null,
45+ "id": "pkg-plot",
46+ "metadata": {},
47+ "outputs": [],
48+ "source": "% Evaluate the chebfun on a grid and mark its roots\nxx = linspace(0, 4, 400);\nplot(xx, f(xx));\nhold on;\nplot(r, f(r), 'ro');\nhold off;\ntitle('sin(x^2) and its roots');"
49+ }
50+ ],
51+ "metadata": {
52+ "kernelspec": {
53+ "display_name": "MATLAB (numbl)",
54+ "language": "matlab",
55+ "name": "numbl"
56+ },
57+ "language_info": {
58+ "codemirror_mode": "octave",
59+ "file_extension": ".m",
60+ "mimetype": "text/x-octave",
61+ "name": "matlab",
62+ "pygments_lexer": "matlab"
63+ }
64+ },
65+ "nbformat": 4,
66+ "nbformat_minor": 5
67+}
\ No newline at end of file
demo/jupyter-lite.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "jupyter-lite-schema-version": 0,
3+ "jupyter-config-data": {
4+ "appName": "numbl JupyterLite demo",
5+ "defaultKernelName": "numbl"
6+ }
7+}
demo/requirements.txtadded+5−0View file
@@ -0,0 +1,5 @@
1+# JupyterLite site build requirements. The kernel itself is installed
2+# separately from the repo root: pip install .
3+jupyterlite-core==0.8.1
4+jupyterlab~=4.6.0
5+notebook~=7.6.0
install.jsonadded+5−0View file
@@ -0,0 +1,5 @@
1+{
2+ "packageManager": "python",
3+ "packageName": "jupyterlite-numbl-kernel",
4+ "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlite-numbl-kernel"
5+}
jupyterlite_numbl_kernel/__init__.pyadded+12−0View file
@@ -0,0 +1,12 @@
1+try:
2+ from ._version import __version__
3+except ImportError:
4+ # Fallback when using the package in dev mode without installing
5+ import warnings
6+
7+ warnings.warn("Importing 'jupyterlite_numbl_kernel' outside a proper installation.")
8+ __version__ = "dev"
9+
10+
11+def _jupyter_labextension_paths():
12+ return [{"src": "labextension", "dest": "jupyterlite-numbl-kernel"}]
package.jsonadded+153−0View file
@@ -0,0 +1,153 @@
1+{
2+ "name": "jupyterlite-numbl-kernel",
3+ "version": "0.1.0",
4+ "description": "MATLAB-syntax (numbl) kernel for JupyterLite — notebooks run entirely in the browser",
5+ "keywords": [
6+ "jupyter",
7+ "jupyterlab",
8+ "jupyterlab-extension",
9+ "jupyterlite",
10+ "jupyterlite-kernel",
11+ "matlab",
12+ "numbl"
13+ ],
14+ "homepage": "https://github.com/concept-collection/jupyterlite-numbl-kernel",
15+ "bugs": {
16+ "url": "https://github.com/concept-collection/jupyterlite-numbl-kernel/issues"
17+ },
18+ "license": "Apache-2.0",
19+ "author": "Jeremy Magland",
20+ "files": [
21+ "lib/**/*.{d.ts,js,js.map,json}",
22+ "style/**/*.{css,js}",
23+ "src/**/*.{ts,tsx}"
24+ ],
25+ "main": "lib/index.js",
26+ "types": "lib/index.d.ts",
27+ "style": "style/index.css",
28+ "repository": {
29+ "type": "git",
30+ "url": "https://github.com/concept-collection/jupyterlite-numbl-kernel.git"
31+ },
32+ "scripts": {
33+ "build": "jlpm build:lib && jlpm build:labextension:dev",
34+ "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
35+ "build:labextension": "jupyter labextension build .",
36+ "build:labextension:dev": "jupyter labextension build --development True .",
37+ "build:lib": "tsc --sourceMap",
38+ "build:lib:prod": "tsc",
39+ "clean": "rimraf lib tsconfig.tsbuildinfo",
40+ "clean:labextension": "rimraf jupyterlite_numbl_kernel/labextension jupyterlite_numbl_kernel/_version.py",
41+ "clean:all": "jlpm clean && jlpm clean:labextension",
42+ "eslint": "eslint . --ext .ts,.tsx --fix",
43+ "eslint:check": "eslint . --ext .ts,.tsx",
44+ "install:extension": "jlpm build",
45+ "lint": "jlpm prettier && jlpm eslint",
46+ "lint:check": "jlpm prettier:check && jlpm eslint:check",
47+ "prettier": "prettier --write \"**/*{.ts,.tsx,.js,.css,.json,.md}\"",
48+ "prettier:check": "prettier --check \"**/*{.ts,.tsx,.js,.css,.json,.md}\"",
49+ "watch": "run-p watch:src watch:labextension",
50+ "watch:src": "tsc -w --sourceMap",
51+ "watch:labextension": "jupyter labextension watch ."
52+ },
53+ "dependencies": {
54+ "@jupyterlab/application": "^4.5.0",
55+ "@jupyterlab/rendermime-interfaces": "^3.9.0",
56+ "@jupyterlite/services": "^0.8.1",
57+ "@lumino/widgets": "^2.3.0",
58+ "numbl": "^0.4.14",
59+ "react": "^18.2.0",
60+ "react-dom": "^18.2.0"
61+ },
62+ "devDependencies": {
63+ "@jupyterlab/builder": "^4.5.0",
64+ "@types/react": "^18.2.0",
65+ "@types/react-dom": "^18.2.0",
66+ "@typescript-eslint/eslint-plugin": "^6.21.0",
67+ "@typescript-eslint/parser": "^6.21.0",
68+ "eslint": "^8.57.0",
69+ "eslint-config-prettier": "^8.10.0",
70+ "eslint-plugin-prettier": "^5.0.0",
71+ "npm-run-all2": "^7.0.1",
72+ "prettier": "^3.0.0",
73+ "rimraf": "^5.0.1",
74+ "typescript": "~5.9.3"
75+ },
76+ "sideEffects": [
77+ "style/*.css",
78+ "style/index.js"
79+ ],
80+ "styleModule": "style/index.js",
81+ "publishConfig": {
82+ "access": "public"
83+ },
84+ "jupyterlab": {
85+ "extension": "lib/index.js",
86+ "mimeExtension": "lib/mime.js",
87+ "outputDir": "jupyterlite_numbl_kernel/labextension",
88+ "sharedPackages": {
89+ "@jupyterlite/services": {
90+ "bundled": false,
91+ "singleton": true
92+ }
93+ }
94+ },
95+ "eslintIgnore": [
96+ "node_modules",
97+ "dist",
98+ "lib",
99+ "**/*.d.ts",
100+ "jupyterlite_numbl_kernel"
101+ ],
102+ "eslintConfig": {
103+ "extends": [
104+ "eslint:recommended",
105+ "plugin:@typescript-eslint/eslint-recommended",
106+ "plugin:@typescript-eslint/recommended",
107+ "plugin:prettier/recommended"
108+ ],
109+ "parser": "@typescript-eslint/parser",
110+ "parserOptions": {
111+ "project": "tsconfig.json",
112+ "sourceType": "module"
113+ },
114+ "plugins": [
115+ "@typescript-eslint"
116+ ],
117+ "rules": {
118+ "@typescript-eslint/naming-convention": [
119+ "error",
120+ {
121+ "selector": "interface",
122+ "format": [
123+ "PascalCase"
124+ ],
125+ "custom": {
126+ "regex": "^I[A-Z]",
127+ "match": true
128+ }
129+ }
130+ ],
131+ "@typescript-eslint/no-unused-vars": [
132+ "warn",
133+ {
134+ "args": "none"
135+ }
136+ ],
137+ "@typescript-eslint/no-explicit-any": "off",
138+ "@typescript-eslint/no-namespace": "off",
139+ "curly": [
140+ "error",
141+ "all"
142+ ],
143+ "eqeqeq": "error",
144+ "prefer-arrow-callback": "error"
145+ }
146+ },
147+ "prettier": {
148+ "singleQuote": true,
149+ "trailingComma": "none",
150+ "arrowParens": "avoid",
151+ "endOfLine": "auto"
152+ }
153+}
pyproject.tomladded+60−0View file
@@ -0,0 +1,60 @@
1+[build-system]
2+requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"]
3+build-backend = "hatchling.build"
4+
5+[project]
6+name = "jupyterlite-numbl-kernel"
7+readme = "README.md"
8+license = { file = "LICENSE" }
9+requires-python = ">=3.9"
10+classifiers = [
11+ "Framework :: Jupyter",
12+ "Framework :: Jupyter :: JupyterLab",
13+ "Framework :: Jupyter :: JupyterLab :: 4",
14+ "Framework :: Jupyter :: JupyterLab :: Extensions",
15+ "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt",
16+ "License :: OSI Approved :: Apache Software License",
17+ "Programming Language :: Python",
18+ "Programming Language :: Python :: 3",
19+]
20+dependencies = []
21+dynamic = ["version", "description", "authors", "urls", "keywords"]
22+
23+[tool.hatch.version]
24+source = "nodejs"
25+
26+[tool.hatch.metadata.hooks.nodejs]
27+fields = ["description", "authors", "urls", "keywords"]
28+
29+[tool.hatch.build.targets.sdist]
30+artifacts = ["jupyterlite_numbl_kernel/labextension"]
31+exclude = [".github", "demo"]
32+
33+[tool.hatch.build.targets.wheel.shared-data]
34+"jupyterlite_numbl_kernel/labextension" = "share/jupyter/labextensions/jupyterlite-numbl-kernel"
35+"install.json" = "share/jupyter/labextensions/jupyterlite-numbl-kernel/install.json"
36+
37+[tool.hatch.build.hooks.version]
38+path = "jupyterlite_numbl_kernel/_version.py"
39+
40+[tool.hatch.build.hooks.jupyter-builder]
41+dependencies = ["hatch-jupyter-builder>=0.5"]
42+build-function = "hatch_jupyter_builder.npm_builder"
43+ensured-targets = [
44+ "jupyterlite_numbl_kernel/labextension/static/style.js",
45+ "jupyterlite_numbl_kernel/labextension/package.json",
46+]
47+skip-if-exists = ["jupyterlite_numbl_kernel/labextension/static/style.js"]
48+
49+[tool.hatch.build.hooks.jupyter-builder.build-kwargs]
50+build_cmd = "build:prod"
51+npm = ["jlpm"]
52+
53+[tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs]
54+build_cmd = "install:extension"
55+npm = ["jlpm"]
56+source_dir = "src"
57+build_dir = "jupyterlite_numbl_kernel/labextension"
58+
59+[tool.check-wheel-contents]
60+ignore = ["W002"]
src/index.tsadded+43−0View file
@@ -0,0 +1,43 @@
1+import {
2+ JupyterFrontEnd,
3+ JupyterFrontEndPlugin
4+} from '@jupyterlab/application';
5+
6+import { IKernelSpecs } from '@jupyterlite/services';
7+import type { IKernel } from '@jupyterlite/services';
8+
9+import { NumblKernel } from './kernel';
10+
11+/** numbl's matrix logo, inlined so the spec needs no served resources. */
12+const NUMBL_LOGO =
13+ 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8IS0tIEJhY2tncm91bmQgLS0+CiAgPHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiBmaWxsPSIjNjQ3NDhiIiByeD0iNCIvPgoKICA8IS0tIE1hdHJpeCBicmFja2V0cyBhbmQgZG90cyAod2hpdGUgb24gYmx1ZSkgLS0+CiAgPGcgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBmaWxsPSJub25lIiBzdHJva2UtbGluZWNhcD0icm91bmQiPgogICAgPCEtLSBMZWZ0IGJyYWNrZXQgLS0+CiAgICA8cGF0aCBkPSJNIDggOSBMIDYgOSBMIDYgMjMgTCA4IDIzIi8+CiAgICA8IS0tIFJpZ2h0IGJyYWNrZXQgLS0+CiAgICA8cGF0aCBkPSJNIDI0IDkgTCAyNiA5IEwgMjYgMjMgTCAyNCAyMyIvPgogIDwvZz4KCiAgPCEtLSBNYXRyaXggZG90cyAtLT4KICA8ZyBmaWxsPSJ3aGl0ZSI+CiAgICA8Y2lyY2xlIGN4PSIxMiIgY3k9IjEzIiByPSIxLjUiLz4KICAgIDxjaXJjbGUgY3g9IjE2IiBjeT0iMTMiIHI9IjEuNSIvPgogICAgPGNpcmNsZSBjeD0iMjAiIGN5PSIxMyIgcj0iMS41Ii8+CgogICAgPGNpcmNsZSBjeD0iMTIiIGN5PSIxOSIgcj0iMS41Ii8+CiAgICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE5IiByPSIxLjUiLz4KICAgIDxjaXJjbGUgY3g9IjIwIiBjeT0iMTkiIHI9IjEuNSIvPgogIDwvZz4KPC9zdmc+Cg==';
14+
15+/**
16+ * A plugin to register the numbl (MATLAB-syntax) kernel.
17+ */
18+const kernel: JupyterFrontEndPlugin<void> = {
19+ id: 'jupyterlite-numbl-kernel:kernel',
20+ autoStart: true,
21+ requires: [IKernelSpecs],
22+ activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => {
23+ kernelspecs.register({
24+ spec: {
25+ name: 'numbl',
26+ display_name: 'MATLAB (numbl)',
27+ language: 'matlab',
28+ argv: [],
29+ resources: {
30+ 'logo-32x32': NUMBL_LOGO,
31+ 'logo-64x64': NUMBL_LOGO
32+ }
33+ },
34+ create: async (options: IKernel.IOptions): Promise<IKernel> => {
35+ return new NumblKernel(options);
36+ }
37+ });
38+ }
39+};
40+
41+const plugins: JupyterFrontEndPlugin<unknown>[] = [kernel];
42+
43+export default plugins;
src/kernel.tsadded+206−0View file
@@ -0,0 +1,206 @@
1+import type { KernelMessage } from '@jupyterlab/services';
2+
3+import { BaseKernel } from '@jupyterlite/services';
4+
5+import { createNumblSession } from 'numbl/browser';
6+import type { NumblSession } from 'numbl/browser';
7+
8+/** Mime type carrying a cell's plot instructions (see src/mime.tsx). */
9+export const FIGURE_MIME = 'application/vnd.numbl.figure+json';
10+
11+/**
12+ * A JupyterLite kernel that executes MATLAB-syntax code with numbl,
13+ * entirely in the browser.
14+ *
15+ * Each kernel owns one numbl session (a Web Worker managed by numbl):
16+ * variables persist across cells, console output streams to the running
17+ * cell, and figures are published as display_data with the numbl figure
18+ * mime type. Restarting the kernel disposes the session, so the next
19+ * execution boots a fresh workspace.
20+ */
21+export class NumblKernel extends BaseKernel {
22+ /**
23+ * Handle a kernel_info_request message.
24+ */
25+ async kernelInfoRequest(): Promise<KernelMessage.IInfoReplyMsg['content']> {
26+ const content: KernelMessage.IInfoReply = {
27+ implementation: 'numbl',
28+ implementation_version: '0.1.0',
29+ language_info: {
30+ codemirror_mode: 'octave',
31+ file_extension: '.m',
32+ mimetype: 'text/x-octave',
33+ name: 'matlab',
34+ nbconvert_exporter: 'script',
35+ pygments_lexer: 'matlab',
36+ version: 'numbl'
37+ },
38+ protocol_version: '5.3',
39+ status: 'ok',
40+ banner:
41+ 'numbl: MATLAB-syntax numerical computing, running in the browser',
42+ help_links: [
43+ {
44+ text: 'numbl',
45+ url: 'https://github.com/flatironinstitute/numbl'
46+ }
47+ ]
48+ };
49+ return content;
50+ }
51+
52+ /**
53+ * Handle an `execute_request` message: run the cell against the numbl
54+ * session's persistent workspace.
55+ */
56+ async executeRequest(
57+ content: KernelMessage.IExecuteRequestMsg['content']
58+ ): Promise<KernelMessage.IExecuteReplyMsg['content']> {
59+ let session: NumblSession;
60+ try {
61+ session = await this._sessionPromise();
62+ } catch (err) {
63+ // Boot failure (e.g. the mip download was unreachable). Reset so a
64+ // later cell can retry, and report the failure on this cell.
65+ this._session = null;
66+ const message = err instanceof Error ? err.message : String(err);
67+ return this._errorReply(
68+ 'SessionError',
69+ `Failed to start numbl: ${message}`
70+ );
71+ }
72+
73+ const result = await session.execute(content.code);
74+
75+ if (!result.ok) {
76+ return this._errorReply('NumblError', result.error ?? 'Unknown error');
77+ }
78+
79+ if (result.plotInstructions.length > 0) {
80+ // Round-trip through JSON so the live render path sees exactly what a
81+ // reloaded notebook sees (structured-clone NaNs become nulls; the
82+ // renderer restores them).
83+ const instructions = JSON.parse(JSON.stringify(result.plotInstructions));
84+ this.displayData({
85+ data: {
86+ [FIGURE_MIME]: { version: 1, plotInstructions: instructions },
87+ 'text/plain':
88+ '<numbl figure — install jupyterlite-numbl-kernel to render>'
89+ },
90+ metadata: {}
91+ });
92+ }
93+
94+ return {
95+ status: 'ok',
96+ execution_count: this.executionCount,
97+ user_expressions: {}
98+ };
99+ }
100+
101+ /**
102+ * Handle a `complete_request` message. Completion is not implemented.
103+ */
104+ async completeRequest(
105+ content: KernelMessage.ICompleteRequestMsg['content']
106+ ): Promise<KernelMessage.ICompleteReplyMsg['content']> {
107+ return {
108+ status: 'ok',
109+ matches: [],
110+ cursor_start: content.cursor_pos,
111+ cursor_end: content.cursor_pos,
112+ metadata: {}
113+ };
114+ }
115+
116+ /**
117+ * Handle an `inspect_request` message. Inspection is not implemented.
118+ */
119+ async inspectRequest(
120+ content: KernelMessage.IInspectRequestMsg['content']
121+ ): Promise<KernelMessage.IInspectReplyMsg['content']> {
122+ return { status: 'ok', found: false, data: {}, metadata: {} };
123+ }
124+
125+ /**
126+ * Handle an `is_complete_request` message: treat every submission as a
127+ * complete MATLAB statement (the console runs on Enter).
128+ */
129+ async isCompleteRequest(
130+ content: KernelMessage.IIsCompleteRequestMsg['content']
131+ ): Promise<KernelMessage.IIsCompleteReplyMsg['content']> {
132+ return { status: 'complete' };
133+ }
134+
135+ /**
136+ * Handle a `comm_info_request` message. Comms are not implemented.
137+ */
138+ async commInfoRequest(
139+ content: KernelMessage.ICommInfoRequestMsg['content']
140+ ): Promise<KernelMessage.ICommInfoReplyMsg['content']> {
141+ return { status: 'ok', comms: {} };
142+ }
143+
144+ /**
145+ * Send an `input_reply` message. stdin is not supported.
146+ */
147+ inputReply(content: KernelMessage.IInputReplyMsg['content']): void {
148+ // no-op
149+ }
150+
151+ async commOpen(msg: KernelMessage.ICommOpenMsg): Promise<void> {
152+ // no-op
153+ }
154+
155+ async commMsg(msg: KernelMessage.ICommMsgMsg): Promise<void> {
156+ // no-op
157+ }
158+
159+ async commClose(msg: KernelMessage.ICommCloseMsg): Promise<void> {
160+ // no-op
161+ }
162+
163+ /**
164+ * Dispose the kernel and its numbl session (worker).
165+ */
166+ dispose(): void {
167+ if (this.isDisposed) {
168+ return;
169+ }
170+ void this._session?.then(s => s.dispose()).catch(() => undefined);
171+ this._session = null;
172+ super.dispose();
173+ }
174+
175+ /**
176+ * Boot the numbl session lazily on first use, so creating the kernel is
177+ * instant and boot progress (mip download, cached-package restore) streams
178+ * into the first executed cell.
179+ */
180+ private _sessionPromise(): Promise<NumblSession> {
181+ this._session ??= createNumblSession({
182+ onOutput: text => this.stream({ name: 'stdout', text }),
183+ onProgress: message =>
184+ this.stream({ name: 'stdout', text: `[numbl] ${message}\n` })
185+ });
186+ return this._session;
187+ }
188+
189+ private _errorReply(
190+ ename: string,
191+ formatted: string
192+ ): KernelMessage.IExecuteReplyMsg['content'] {
193+ const traceback = formatted.split('\n');
194+ const evalue = traceback[0] ?? '';
195+ this.publishExecuteError({ ename, evalue, traceback });
196+ return {
197+ status: 'error',
198+ execution_count: this.executionCount,
199+ ename,
200+ evalue,
201+ traceback
202+ };
203+ }
204+
205+ private _session: Promise<NumblSession> | null = null;
206+}
src/mime.tsxadded+97−0View file
@@ -0,0 +1,97 @@
1+import type { IRenderMime } from '@jupyterlab/rendermime-interfaces';
2+
3+import { Widget } from '@lumino/widgets';
4+
5+import { createRoot } from 'react-dom/client';
6+import type { Root } from 'react-dom/client';
7+
8+import {
9+ FigureView,
10+ figuresReducer,
11+ initialFiguresState,
12+ restoreNaNs
13+} from 'numbl/graphics';
14+import type { FigureState, PlotInstruction } from 'numbl/graphics';
15+
16+/** Mime type carrying a cell's plot instructions (emitted by the kernel). */
17+export const FIGURE_MIME = 'application/vnd.numbl.figure+json';
18+
19+/**
20+ * Render one cell's plot instructions: replay them through numbl's figures
21+ * reducer (from an empty state — figures are per-cell, like inline
22+ * matplotlib) and mount numbl's React figure renderer for each resulting
23+ * figure. Outputs are plain JSON, so saved notebooks re-render on reload
24+ * wherever this extension is installed.
25+ */
26+class NumblFigureRenderer extends Widget implements IRenderMime.IRenderer {
27+ constructor() {
28+ super();
29+ this.addClass('numbl-figure-output');
30+ }
31+
32+ async renderModel(model: IRenderMime.IMimeModel): Promise<void> {
33+ const payload = model.data[FIGURE_MIME] as
34+ { plotInstructions?: PlotInstruction[] } | undefined;
35+ // Clone before mutating: mime model data is shared, and restoreNaNs
36+ // rewrites in place the nulls that JSON made of NaNs.
37+ const instructions: PlotInstruction[] = JSON.parse(
38+ JSON.stringify(payload?.plotInstructions ?? [])
39+ );
40+
41+ let state = initialFiguresState;
42+ for (const instruction of instructions) {
43+ restoreNaNs(instruction);
44+ state = figuresReducer(state, instruction);
45+ }
46+ const figures: FigureState[] = Object.keys(state.figs)
47+ .map(Number)
48+ .sort((a, b) => a - b)
49+ .map(handle => state.figs[handle]);
50+
51+ this._root ??= createRoot(this.node);
52+ this._root.render(
53+ <>
54+ {figures.map((figure, i) => (
55+ <div className="numbl-figure" key={i}>
56+ <FigureView figure={figure} />
57+ </div>
58+ ))}
59+ </>
60+ );
61+ }
62+
63+ dispose(): void {
64+ if (this.isDisposed) {
65+ return;
66+ }
67+ // Unmount asynchronously: dispose can be called from within a React
68+ // lifecycle, where a synchronous unmount is not allowed.
69+ const root = this._root;
70+ this._root = null;
71+ if (root) {
72+ setTimeout(() => root.unmount(), 0);
73+ }
74+ super.dispose();
75+ }
76+
77+ private _root: Root | null = null;
78+}
79+
80+/**
81+ * The numbl figure mime renderer factory. `safe: false` because uihtml
82+ * figures embed author-provided HTML (rendered only in trusted notebooks).
83+ */
84+export const rendererFactory: IRenderMime.IRendererFactory = {
85+ safe: false,
86+ mimeTypes: [FIGURE_MIME],
87+ createRenderer: () => new NumblFigureRenderer()
88+};
89+
90+const extension: IRenderMime.IExtension = {
91+ id: 'jupyterlite-numbl-kernel:figure-renderer',
92+ rendererFactory,
93+ rank: 0,
94+ dataType: 'json'
95+};
96+
97+export default extension;
style/index.cssadded+10−0View file
@@ -0,0 +1,10 @@
1+/* Each figure gets a fixed-height box; numbl's FigureView fills its parent
2+ and the plot canvas measures the container. */
3+.numbl-figure {
4+ position: relative;
5+ width: 100%;
6+ max-width: 680px;
7+ height: 420px;
8+ margin: 4px 0;
9+ background: #ffffff;
10+}
style/index.jsadded+1−0View file
@@ -0,0 +1 @@
1+import './index.css';
tsconfig.jsonadded+25−0View file
@@ -0,0 +1,25 @@
1+{
2+ "compilerOptions": {
3+ "allowSyntheticDefaultImports": true,
4+ "composite": true,
5+ "declaration": true,
6+ "esModuleInterop": true,
7+ "incremental": true,
8+ "jsx": "react-jsx",
9+ "lib": ["DOM", "ES2022"],
10+ "module": "esnext",
11+ "moduleResolution": "bundler",
12+ "noEmitOnError": true,
13+ "noImplicitAny": true,
14+ "noUnusedLocals": true,
15+ "outDir": "lib",
16+ "preserveWatchOutput": true,
17+ "resolveJsonModule": true,
18+ "rootDir": "src",
19+ "skipLibCheck": true,
20+ "strict": true,
21+ "strictNullChecks": true,
22+ "target": "ES2020"
23+ },
24+ "include": ["src/*"]
25+}