In this method, we copy/paste the cells to functions in your code, and refactor Observable specific features.
How does it compares to notebook embedding using Observable runtime?
- all the code is available at build time, not at runtime, making it easier to understand, version and test (compare to Observable cells-based code)
- more freedom to mold the code into your favorite framework
- you will need to manage the state, instead of letting the Observable runtime do it for you
- related: you must recode the useful Observable feature, like
viewof
orwidth
for example - migration is complex and manual
Understand the general data flow in your notebook (read How Observable Runs for background). Draw its dependency graph using the Notebook Visualizer:
Each node corresponds to a notebook cell, and arrows represent a dependency between cells. They are colored by category:
- Green
cells correspond to external code imported into the notebook:
- library imported with
require
(e.g.d3 = require("d3@5")
): you typically will install it in your project withnpm install
, and then import it as an ES module - imported notebook (e.g.
import { radio } from "@jashkenas/inputs"
): you will have to repeat the same process in this notebook, examining its own dependency graph.
- library imported with
- Gray cells are anonymous (non-named) cells and will generally not be migrated. They often contain explanation texts, and no other cell can depend on them, so they shouldn't break the code if removed. But, be careful: if your main chart cell is not named, you will still want to copy its code.
- Black cells are the actual notebook code written by the user, and you will want to copy it to your project.
- Purple
cells are the toughest ones. They correspond to features of Observable that
will typically be used a lot by a notebook writer (see the
Standard Library), and their
migration to a standalone application can be the hardest part of the rewrite
from scratch, particularly
mutable
andviewof
cells, since they manage an internal state.
Install the build and deploy environment (see the "Bundle" method for more details). First install node.js and npm and create a new npm project:
mkdir joyplot
cd joyplot
npm init
Install dev dependencies
npm install -save-dev rollup@1 rollup-plugin-node-resolve@5 @babel/core@7 \
rollup-plugin-babel@4 rollup-plugin-terser@5 now@16
Create rollup configuration in rollup.config.js:
import * as meta from './package.json';
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
import {terser} from 'rollup-plugin-terser';
export default {
input: 'src/main.js',
onwarn: function(warning, warn) {
if (warning.code === 'CIRCULAR_DEPENDENCY') {
return;
}
warn(warning);
},
output: {
file: `public/main.min.js`,
name: '${meta.name}',
format: 'iife',
indent: false,
extend: true,
banner: `// ${meta.homepage} v${
meta.version
} Copyright ${new Date().getFullYear()} ${meta.author.name}`,
},
plugins: [resolve(), babel(), terser()],
};
Add npm scripts, in package.json:
"scripts": {
"build": "rollup -c",
"deploy": "now",
"predeploy": "npm run build"
}
Create the HTML and JS files:
mkdir -p public src
touch public/index.html
touch src/main.js
Edit public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Minimal HTML head elements -->
<meta charset="utf-8" />
<title>PSR B1919+21</title>
</head>
<body>
<!-- Title of the page -->
<h1>PSR B1919+21</h1>
<!-- Empty placeholders -->
<div id="joyplot"></div>
<!-- JavaScript code to fill the empty placeholders -->
<script src="./main.min.js"></script>
</body>
</html>
Edit src/main.js:
document.querySelector('#joyplot').innerHTML =
'Placeholder for the joyplot chart.';
Test your configuration is working:
npm run build
npm run deploy
See src/main.1.js.
Install the external libraries (green cells):
npm install --save-dev d3@5
Import them in src/main.js:
import * as d3 from 'd3';
Anonymous cells generally contain markdown text, and can be ignored. But if you need to migrate an anonymous cell, just consider it as a black cell (see below).
Copy paste the black cells definitions to src/main.js (in the order you want, the dependency order will be managed later).
To migrate a cell, put its content inside an async function that takes the dependencies (incoming arrows in the graph) as arguments. This async function will return the cell value. For example the cell:
data = d3
.text(
'https://gist.githubusercontent.com/borgar/31c1e476b8e92a11d7e9/raw/0fae97dab6830ecee185a63c1cee0008f6778ff6/pulsar.csv'
)
.then(data => d3.csvParseRows(data, row => row.map(Number)));
becomes
async function _data(d3) {
return d3
.text(
'https://gist.githubusercontent.com/borgar/31c1e476b8e92a11d7e9/raw/0fae97dab6830ecee185a63c1cee0008f6778ff6/pulsar.csv'
)
.then(data => d3.csvParseRows(data, row => row.map(Number)));
}
and the cell value will then be available as:
const data = await _data(d3);
But don't apply this method blindly. Don't use async
if the cell code is
synchronous:
function _x(d3, data, margin, width) {
return d3
.scaleLinear()
.domain([0, data[0].length - 1])
.range([margin.left, width - margin.right]);
}
Ensure to follow the functional programming paradigm: pass all the dependencies as arguments without relying on global variables.
See src/main.2.js.
In your main code, instantiate the variables using the cell definitions functions, and following the dependency graph order: first the cells without dependencies, until the most dependent ones:
// Data flow
async function main(d3, DOM, width) {
const height = _height();
const margin = _margin();
const overlap = _overlap();
const data = await _data(d3);
const x = _x(d3, data, margin, width);
const y = _y(d3, data, margin, height);
const z = _z(d3, data, overlap, y);
const xAxis = _xAxis(height, margin, d3, x, width);
const area = _area(d3, x, z);
const line = _line(area);
const chart = _chart(d3, DOM, width, height, data, y, area, line, xAxis);
}
See src/main.3.js.
In the code above, we still have to provide two variables: DOM
and width
,
that correspond to purple cells (Observable-specific code). You will have to
refactor your code to get the expected behavior.
Note: maybe a generic solution involving @observable/stdlib could be applied. For now, just refactor.
For example, to replace width
:
const width = 960; // in the notebook, width came from stdlib. We fix its value
and to replace DOM
in const svg = d3.select(DOM.svg(width, height));
:
const svg = d3
.select('svg#joyplot')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0,0,${width},${height}`);
See src/main.4.js for this basic solution. Look at src/main.js for a better (and more complex) solution that manages window resize as in the original notebook.
The code can be found in joyplot/.
Build with
npm run build
Run locally with
python3 -m http.server --directory public/
Deploy on now.sh (see https://joyplot-p9qmx1pf3.now.sh/):
npm run deploy