Editableinteractive notebooks from the comfort of your code editor

editable-cli is a command line tool piggybacking on observable internals which provides file-based interactive notebooks.

User has access to new top-level function: def. This function is used to define constants and functions which are live-reloaded, with persisted state (unless the body of the function has changed):

def("a", 10);
def("b", 20);
def("sum", (a, b) => a + b); // sum is "tied" to defs "a" and "b" here

This environment is ideal for quick sketches and explorations. It provides all that observable exposes, including DOM access and more.

File-based notebooks have few additional nice properties:

  • they work offline, even if the online serivce goes out of business
  • they compose well with standard javascript tools: prettier, eslint, etc.

editable-cli is open sourced: szymonkaliski/editable-cli, and can be installed via npm: npm install -g editable-cli.

def("chart", (DOM, data, margin, d3, yAxis, xAxis, y, x, width, height) => {
  const svg = d3.select(DOM.svg(width, height));

    .attr("fill", "#444")
    .attr("x", x(0))
    .attr("y", (d) => y(d.name))
    .attr("width", (d) => x(d.value) - x(0))
    .attr("height", y.bandwidth());

    .attr("fill", "white")
    .attr("text-anchor", "end")
    .style("font", "12px sans-serif")
    .attr("x", (d) => x(d.value) - 4)
    .attr("y", (d) => y(d.name) + y.bandwidth() / 2)
    .attr("dy", "0.35em")
    .text((d) => d3.format(".3f")(d.value));


  return svg.node();

def("margin", { top: 30, right: 0, left: 30, bottom: 10 });
def("height", (data, margin) => data.length * 18 + margin.top + margin.bottom);
def("width", 600);

def("alphabet", (require) => require("@observablehq/alphabet"));
def("d3", (require) => require("d3"));

def("yAxis", (d3, margin, y) => (g) => {
  return g
    .attr("transform", `translate(${margin.left},0)`)

def("xAxis", (d3, margin, x, width) => (g) => {
  return g
    .attr("transform", `translate(0,${margin.top})`)
    .call(d3.axisTop(x).ticks(width / 80))
    .call((g) => g.select(".domain").remove());

def("y", (d3, margin, data, height) => {
  return d3
    .domain(data.map((d) => d.name))
    .range([margin.top, height - margin.bottom])

def("x", (d3, margin, data, width) => {
  return d3
    .domain([0, d3.max(data, (d) => d.value)])
    .range([margin.left, width - margin.right]);

def("data", (alphabet) => {
  return (alphabet || [])
    .sort((a, b) => b.frequency - a.frequency)
    .map(({ letter, frequency }) => ({ name: letter, value: frequency }));

Unfortunately, in practice, I don't think having the full history is that useful. Yes, it's sometimes good to know how you ended up somewhere, but I think what's most valuable about "research" is the synthesis part grabbing parts of larger wholes, rearranging, recombining, thinking with the material. A small step in this direction could be persisting scroll position or maybe selection, and making the history editable allowing users to remove dead ends, add notes, etc.

  • this sometimes feels close to trying to have multiple git branches visible and editable at the same time