ObservableJS in Quarto

Demonstration of widgets, upload and download

Author

Roy Francis

Published

26-Feb-2025

See the official OJS documentation.

Note

Note that OJS does not work when opening a static local HTML file in the browser. It requires a live server. For local viewing, use quarto preview, python -m http.server etc. It will just work when hosted on a server/hosting service such as Github pages etc.

1 Define data

Data is defined as JSON arrays.

Code
data_hdi = [
  {
    name: "Switzerland",
    continent: "Europe",
    hdi: "0.962"
  },
  {
    name: "Norway",
    continent: "Europe",
    hdi: "0.961"
  },
  {
    name: "Iceland",
    continent: "Europe",
    hdi: "0.959"
  },
  {
    name: "Hong Kong",
    continent: "Asia",
    hdi: "0.952"
  },
  {
    name: "Australia",
    continent: "Oceania",
    hdi: "0.951"
  },
  {
    name: "Denmark",
    continent: "Europe",
    hdi: "0.948"
  },
  {
    name: "Sweden",
    continent: "Europe",
    hdi: "0.947"
  },
  {
    name: "Ireland",
    continent: "Europe",
    hdi: "0.945"
  },
  {
    name: "Germany",
    continent: "Europe",
    hdi: "0.942"
  },
  {
    name: "Netherlands",
    continent: "Europe",
    hdi: "0.941"
  },
  {
    name: "Finland",
    continent: "Europe",
    hdi: "0.940"
  },
  {
    name: "Singapore",
    continent: "Asia",
    hdi: "0.939"
  },
  {
    name: "Belgium",
    continent: "Europe",
    hdi: "0.937"
  },
  {
    name: "New Zealand",
    continent: "Oceania",
    hdi: "0.937"
  },
  {
    name: "Canada",
    continent: "North America",
    hdi: "0.936"
  }
]
data_hdi = Array(15) [Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object]

2 Widgets

2.1 Table

Code
viewof table_a = Inputs.table(data_hdi)
namecontinenthdi
SwitzerlandEurope0.962
NorwayEurope0.961
IcelandEurope0.959
Hong KongAsia0.952
AustraliaOceania0.951
DenmarkEurope0.948
SwedenEurope0.947
IrelandEurope0.945
GermanyEurope0.942
NetherlandsEurope0.941
FinlandEurope0.940
SingaporeAsia0.939
BelgiumEurope0.937
New ZealandOceania0.937
CanadaNorth America0.936
table_a = Array(15) [Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, columns: Array(3)]

2.2 Text

Code
viewof name = Inputs.text({label: "Name", placeholder: "What’s your name?"})
name = ""

Your input is

2.3 Text area input

Code
viewof bio = Inputs.textarea({label: "Biography", placeholder: "What’s your story?"})
bio = ""

Your input is

2.4 Range (Numeric)

Code
viewof hdi = Inputs.range([0.90, 1.00], {value: 0.98, step: 0.01, label: "HDI"})
hdi = 0.98

Below are countries with HDI less than 0.980

Code
data_numeric = data_hdi.filter(d => d.hdi < hdi)
viewof table_numeric = Inputs.table(data_numeric)
data_numeric = Array(15) [Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object]
namecontinenthdi
SwitzerlandEurope0.962
NorwayEurope0.961
IcelandEurope0.959
Hong KongAsia0.952
AustraliaOceania0.951
DenmarkEurope0.948
SwedenEurope0.947
IrelandEurope0.945
GermanyEurope0.942
NetherlandsEurope0.941
FinlandEurope0.940
SingaporeAsia0.939
BelgiumEurope0.937
New ZealandOceania0.937
CanadaNorth America0.936
table_numeric = Array(15) [Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, columns: Array(3)]

2.5 Button

Code
viewof clicks = Inputs.button("Click me")
clicks = 0

Number of clicks: 0

2.6 Toggle button

Code
viewof mute = Inputs.toggle({label: "Mute"})
mute = false

Current state: false

2.7 Radio button

Code
viewof cont = Inputs.radio(["Asia", "Europe", "North America", "Oceania"], {label: "Select continent:", value: "Europe"})
cont = "Europe"

The table below is filtered to keep Europe.

Code
data_radio = data_hdi.filter(d => d.continent == cont)
viewof table_radio = Inputs.table(data_radio)
data_radio = Array(10) [Object, Object, Object, Object, Object, Object, Object, Object, Object, Object]
namecontinenthdi
SwitzerlandEurope0.962
NorwayEurope0.961
IcelandEurope0.959
DenmarkEurope0.948
SwedenEurope0.947
IrelandEurope0.945
GermanyEurope0.942
NetherlandsEurope0.941
FinlandEurope0.940
BelgiumEurope0.937
table_radio = Array(10) [Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, columns: Array(3)]

2.8 Checkbox

Code
viewof flavors = Inputs.checkbox(["salty", "sweet", "bitter", "sour", "umami"], {label: "Flavors"})
flavors = Array(0) []

Selected items:

2.10 Date

Code
viewof birthday = Inputs.date({label: "Select date"})
birthday = null

Selected date:

2.11 Color

Code
viewof color = Inputs.color({label: "Favorite color", value: "#4682b4"})
#4682b4
color = "#4682b4"

Selected color: #4682b4

2.12 File upload

Code
viewof file = Inputs.file({label: "Upload a CSV file:", accept: ".csv", required: true})
data3 = file.csv({typed: true})
viewof raw_table = Inputs.table(data3)

2.14 Forms

Forms can be used to combine multiple inputs as a set.

Code
viewof rgb = Inputs.form([
  Inputs.range([0, 255], {step: 1, label: "r"}),
  Inputs.range([0, 255], {step: 1, label: "g"}),
  Inputs.range([0, 255], {step: 1, label: "b"})
]);
rgb
rgb = Array(3) [128, 128, 128]
Array(3) [128, 128, 128]
Code
function rgbString (array) {
  return 'rgb(' + array.join(', ') + ')';
}

displayColor = function (rgb) {
  return html`
    <section style="width: 400px; display: grid; grid-gap: 10px;">
      <div class="color" style="grid-column: 1;">
        <div class="swatch" style="background: ${rgbString(rgb)}; padding: 10px; border-radius:5px;">
          <span>${rgbString(rgb)}</span>
        </div>
      </div>
    </section>`
}

displayColor(rgb);
rgbString = ƒ(array)
displayColor = ƒ(rgb)
rgb(128, 128, 128)

OJS input widgets are documented here.

3 Downloads

3.1 Text download

Code
button = (data, filename = 'data.csv') => {
  if (!data) throw new Error('Array of data required as first argument');

  let downloadData;
  if (filename.includes('.csv')) {
    downloadData = new Blob([d3.csvFormat(data)], { type: "text/csv" });
  } else {
    downloadData = new Blob([JSON.stringify(data, null, 2)], {
      type: "application/json"
    });
  }

  const size = (downloadData.size / 1024).toFixed(0);
  const button = DOM.download(
    downloadData,
    filename,
    `Download ${filename} (~${size} KB)`
  );
  return button;
}
button = ƒ(…)
Code
button(data_hdi, 'hdi.csv')

3.2 Zip download

Download table as a zipped file.

Code
JSZip = require('https://unpkg.com/jszip@3.1.5/dist/jszip.min.js')
zip = {
  const zip = new JSZip()
  const folder = zip.folder('data')
  folder.file('data_hdi.txt', data_hdi)
  return zip.generateAsync({type: 'blob'})
}
JSZip = ƒ()
zip = Blob {}
Code
DOM.download(zip, 'data.zip')

3.3 Plot

Code
barplot = Plot.plot({
  marginLeft: 100,
  y: {

  },
  marks: [
    Plot.barX(data_hdi, {
      x: "hdi",
      y: "name"
    })
  ],
  grid: true
})

barplot
AustraliaBelgiumCanadaDenmarkFinlandGermanyHong KongIcelandIrelandNetherlandsNew ZealandNorwaySingaporeSwedenSwitzerlandname0.00.20.40.60.8hdi →
SVGSVGElement {scale: ƒ(e), legend: ƒ(r, o)}

Now with interactive tooltips.

Code
barplot1 = Plot.plot({
  marginLeft: 100,
  y: {

  },
  marks: [
    Plot.barX(data_hdi, {
      x: "hdi",
      y: "name",
      tip: true,
      channels: {country: "name"}
    })
  ],
  grid: true
})

barplot1
AustraliaBelgiumCanadaDenmarkFinlandGermanyHong KongIcelandIrelandNetherlandsNew ZealandNorwaySingaporeSwedenSwitzerlandname0.00.20.40.60.8hdi →
SVGSVGElement {value: null, scale: ƒ(e), legend: ƒ(r, o)}

3.4 Image download

This is based on mbostock/saving-svg.

Download image as SVG.

Code
DOM.download(() => serialize(barplot), undefined, "Save as SVG")

Download image as PNG.

Code
DOM.download(() => rasterize(barplot), undefined, "Save as PNG")
Code
serialize = {
  const xmlns = "http://www.w3.org/2000/xmlns/";
  const xlinkns = "http://www.w3.org/1999/xlink";
  const svgns = "http://www.w3.org/2000/svg";
  return function serialize(svg) {
    svg = svg.cloneNode(true);
    const fragment = window.location.href + "#";
    const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
    while (walker.nextNode()) {
      for (const attr of walker.currentNode.attributes) {
        if (attr.value.includes(fragment)) {
          attr.value = attr.value.replace(fragment, "#");
        }
      }
    }
    svg.setAttributeNS(xmlns, "xmlns", svgns);
    svg.setAttributeNS(xmlns, "xmlns:xlink", xlinkns);
    const serializer = new window.XMLSerializer;
    const string = serializer.serializeToString(svg);
    return new Blob([string], {type: "image/svg+xml"});
  };
}

function rasterize(svg) {
  let resolve, reject;
  const promise = new Promise((y, n) => (resolve = y, reject = n));
  const image = new Image;
  image.onerror = reject;
  image.onload = () => {
    const rect = svg.getBoundingClientRect();
    const context = DOM.context2d(rect.width, rect.height);
    context.drawImage(image, 0, 0, rect.width, rect.height);
    context.canvas.toBlob(resolve);
  };
  image.src = URL.createObjectURL(serialize(svg));
  return promise;
}
serialize = ƒ(svg)
rasterize = ƒ(svg)

3.5 HTML download

Code
FileSaver = (await import("https://cdn.skypack.dev/file-saver@2.0.5?min"))
  .default

html2canvas = (await import("https://esm.sh/html2canvas@1.4.1")).default

async function canvas2blob(canvas) {
  let resolve, reject;
  const promise = new Promise((y, n) => ((resolve = y), (reject = n)));
  canvas.toBlob((blob) => {
    if (blob == null) {
      return reject();
    }
    resolve(blob);
  });
  return promise;
}

function generateDownloader(el, options) {
  return async function () {
    let resolve, reject;
    const { filename = "untitled", ...html2canvasOptions } = options;
    const canvas = await html2canvas(el, html2canvasOptions);
    const blob = await canvas2blob(canvas);
    FileSaver(blob, filename);
  };
}

function downloadHtmlAsImage(el, options) {
  const { label, ...restOptions } = Object.assign(
    { label: "Download as PNG", scale: window.devicePixelRatio },
    options
  );
  const ui = Inputs.button(label, {
    value: null,
    reduce: generateDownloader(el, restOptions)
  });
  return ui;
}
html2canvas = ƒ(e, A)
canvas2blob = async ƒ(canvas)

Download HTML element as PNG.

Code
downloadHtmlAsImage(barplot1)

Adjust the scale parameter.

Code
downloadHtmlAsImage(barplot1, {
    label: "Download 4X PNG",
    filename: "scaled_4x",
    scale: 4
})