skip to content
Dominik Schröder

Reactive Observable Plot with Qwik

/ 2 min read

Observable Plot is a powerful library for exploratory data analysis, Qwik is a minimalistic Javascript framework. This article shows how to render Observable Plot graphics using Qwik, on the server and on the client. The goal is two-fold:

  • static rendering during build time for fast page loads, and full functionality without Javascript
  • interactive rendering on the client side for progressive layout, animations, and user interaction

Example

Penguin Culmen Length vs Depth

Data from Palmer Station, Antarctica

BiscoeDreamTorgersen
AdelieChinstrapGentoo
1415161718192021↑ culmen_depth_mm3540455055culmen_length_mm →
Horst AM, Hill AP, Gorman KB (2020). palmerpenguins: Palmer Archipelago (Antarctica) penguin data.

Usage

The Chart component has two main props: plotFunction$ and args, and some optional props for controlling the rendering:

<Chart
plotFunction$={T => Plot.PlotOptions}
args={Signal<T>}
fullWidth={boolean?}
fullHeight={boolean?}
aspectRatio={number?}
/>

Here plotFunction$ is a function accepting an argument of an arbitrary (serializable) type T and returning a Plot.PlotOptions object1. The args prop is a Qwik signal containing the argument of type T. The plot will be re-rendered whenever the argument changes2. The fullWidth and fullHeight props control whether the plot should take the full width or height of the container. The aspectRatio prop can be used to set the aspect ratio of the plot if specifying exactly either the width or height. Note that for server-side rendering it makes sense to set set width and height explicitly, as the container size is not known.

The following example shows how to use the Chart component to render the plot above. Here Select is a component changing the value of the colorby and symbolby signals when the user selects an option.

example.tsx
import { useSignal, component$, useComputed$ } from "@builder.io/qwik";
import * as d3 from "d3";
import * as Plot from "@observablehq/plot";
import { Chart } from "src/components/plot";
import { Select } from "src/components/inputs";
import csv from "./penguins.csv?raw";
const penguins = d3.csvParse(csv, d3.autoType);
export const Example = component$(() => {
const colorby = useSignal("species");
const symbolby = useSignal("island");
const args = useComputed$(() => ({ stroke: colorby.value, symbol: symbolby.value }));
return (
<div>
<div class="mb-2 flex gap-5">
<Select value={colorby} options={["species", "island", "sex"]} label="Color" />
<Select value={symbolby} options={["species", "island", "sex"]} label="Symbol" />
</div>
<Chart
plotFunction$={({ stroke, symbol }: { stroke: string; symbol: string }) => ({
grid: true,
color: { legend: true },
symbol: { legend: true },
height: 300,
width: 672,
marks: [
Plot.dot(penguins, {
x: "culmen_length_mm",
y: "culmen_depth_mm",
tip: true,
stroke,
symbol,
}),
],
title: "Penguin Culmen Length vs Depth",
subtitle: "Data from Palmer Station, Antarctica",
caption:
"Horst AM, Hill AP, Gorman KB (2020). palmerpenguins: Palmer Archipelago (Antarctica) penguin data.",
})}
args={args}
fullWidth={true}
class="rounded-lg border bg-bgColorAlt p-2"
/>
</div>
);
});

Code for Chart

The implementation of the Chart component is shown below. The main ingredients are

  • JSDOM for server side rendering, unfortunately the document option 3 does not seem to work with Qwik (?)
  • ResizeObserver for tracking the size of the container
  • useVisibleTask$ for rendering the plot when the argument signal changes, or the container is resized
plot.tsx
import {
component$,
useSignal,
useVisibleTask$,
type Signal,
type QRL,
useTask$,
} from "@builder.io/qwik";
import styles from "./plot.module.css";
import * as Plot from "@observablehq/plot";
import { JSDOM } from "jsdom";
interface ChartProps<T> {
plotFunction$: QRL<(x: T) => Plot.PlotOptions>;
args: Signal<T>;
class?: string | string[];
fullWidth?: boolean;
fullHeight?: boolean;
aspectRatio?: number;
}
const renderJSDOM = (options: Plot.PlotOptions) => {
const jsdom = new JSDOM("");
global.window = jsdom.window as unknown as Window & typeof globalThis;
global.document = jsdom.window.document;
global.Event = jsdom.window.Event;
global.Node = jsdom.window.Node;
global.NodeList = jsdom.window.NodeList;
global.HTMLCollection = jsdom.window.HTMLCollection;
return Plot.plot(options).outerHTML;
};
export const Chart = component$<ChartProps<any>>(
({ plotFunction$, args, class: classList, fullWidth, fullHeight, aspectRatio }) => {
const outputRef = useSignal<Element>();
const width = useSignal(0);
const height = useSignal(0);
const chart = useSignal<string>("");
useVisibleTask$(({ track }) => {
track(() => outputRef.value);
if (outputRef.value) {
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const rect = entry.contentRect;
width.value = rect.width;
height.value = rect.height;
}
});
ro.observe(outputRef.value);
return () => ro.disconnect();
}
});
useTask$(async () => {
chart.value = renderJSDOM(await plotFunction$(args.value));
});
useVisibleTask$(async ({ track }) => {
if (!outputRef.value) return;
if (fullHeight) track(() => height.value);
if (fullWidth) track(() => width.value);
track(() => args.value);
const plotarg = await plotFunction$(args.value);
if (fullWidth && width.value > 0) {
plotarg.width = width.value;
if (aspectRatio) plotarg.height = width.value / aspectRatio;
}
if (fullHeight && height.value > 0) {
plotarg.height = height.value;
if (aspectRatio) plotarg.width = height.value * aspectRatio;
}
const currentChart = outputRef.value.firstChild;
if (currentChart) outputRef.value.removeChild(currentChart);
outputRef.value.appendChild(Plot.plot(plotarg));
});
return (
<div
class={[classList, styles.container, "not-prose"]}
ref={outputRef}
dangerouslySetInnerHTML={chart.value}
/>
);
},
);

Footnotes

  1. $ sign is used to tell the Qwik optimizer to extract the argument into a separate file which then can be loaded by both server and client code, see https://qwik.dev/docs/advanced/dollar/ for more information.

  2. A full rerender is triggered whenever the argument changes, which is not the most efficient way to update the plot. A more sophisticated approach would be to update only the changed parts of the plot, e.g. using the render transform as demonstrated in https://observablehq.com/@fil/plot-animate-a-bar-chart/2

  3. https://github.com/observablehq/plot/pull/969