DEV Community

Jae Sung Park
Jae Sung Park

Posted on

billboard.js 4.1.0: Live resizing, configurable subchart, React subpath & CSP-safe worker

Glad to announce billboard.js v4.1.0! 🎉

This minor release is about giving back control over things that used to be fixed: how the chart behaves while its container is being dragged, what the subchart overview renders, and where the Web Worker source comes from. The React component also moved into the main package as a subpath export.

For the detailed release info, please check out the release note:

📌 What's new?

1. Live resizing (resize.live)

Until now resize.timer delayed the redraw, so while a container was being dragged the chart kept its previous pixel size and only snapped once the drag stopped. The new resize.live makes the chart follow the container in between.

bb.generate({
  data: {
    columns: [["data1", 30, 200, 100, 400]],
    type: "line"
  },
  resize: {
    // follow the container size while resizing
    live: true
  }
});
Enter fullscreen mode Exit fullscreen mode

Dragging the container with resize.live: true. The counter below the chart shows the redraw count and the last redraw time.

How the size is followed is measured, not configured. Two strategies are used:

  • Redraw: while a resize redraw fits in one animation frame (16ms), the chart is redrawn every frame, so every intermediate size is an exact rendering.
  • Stretch: once a redraw misses that budget, the rendering is stretched to the new size for the rest of the resize and redrawn when it settles. For SVG the viewBox is set to the drawn size and the width/height attributes to the new one; for canvas only the element's CSS box changes, with the backing store left as is.

Within one resize the strategy only moves from redraw to stretch, never back, so frames can't alternate between an exact and a stretched rendering. The measured time outlives the resize, so a chart already known to be expensive stretches from the first frame of the next one.

Resize redraws skip the shape data join and the axis tick measurement, so they cost far less than an initial render. A 5x1,000 line chart redraws in about 6ms (SVG) and 3ms (canvas); a 10x10,000 one in about 78ms and 53ms. Ordinary charts therefore redraw every frame, and only large ones fall back to stretching.

Two things to keep in mind:

  • It applies only when resize.auto is true or "parent".
  • The budget is measured per chart, and charts don't know about each other. Eight charts of 2,000 points each spend about 75ms per frame together while each one measures about 9ms and keeps redrawing. Turn it on for the charts the user actually watches while resizing, not for every chart on a dense page.

2. Configurable subchart rendering

The subchart used to be a small copy of the main chart. Now the overview can be rendered as its own chart, with its own type, its own axes, and without brush interaction.

Before: the overview repeated the main chart's line type.

bb.generate({
  data: {
    columns: [
      ["data1", 30, 200, 100, 400, 150, 250],
      ["data2", 130, 100, 140, 200, 150, 50]
    ],
    type: "line"
  },
  subchart: {
    show: true,

    // render the overview with a different chart type than the main chart
    type: "bar",

    // or override the type per data series
    types: {
      data2: "area"
    },

    brush: {
      // render the subchart as a static overview, without zoom interaction
      enabled: false
    },

    grid: {
      // hide the x focus grid line in the subchart
      // focus: false,
      focus: {
        // draw one continuous focus line across main chart and subchart
        // NOTE: works only when 'brush.enabled=false'
        continuous: true
      }
    },

    axis: {
      x: {
        tick: {
          format: x => `${x}`,
          text: { show: false }
        }
      },
      y: {
        show: true,
        tick: { count: 3 }
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

After: a line main chart with subchart.type: "bar", subchart.types.data2: "area", subchart y axis ticks and a focus line continuous across both charts.

The subchart axes accept the same tick options as the main axes: count, values, culling, outer, format and text.show, for x, y and y2. Candlestick values are projected with stock chart semantics, so a candlestick main chart can show a bar, line or area overview below it. SVG and canvas render the subchart axis, grid and interaction the same way.

3. Grid line class selectors on canvas

Canvas mode has no SVG DOM, so grid.x.lines[].class and grid.y.lines[].class had nothing to style. canvas.theme.selectors now maps those class names to canvas draw styles.

bb.generate({
  render: { mode: "canvas" },
  grid: {
    y: {
      lines: [
        { value: 200, text: "Label 200", class: "threshold-good" },
        { value: 350, text: "Label 350", class: "threshold-bad" }
      ]
    }
  },
  canvas: {
    theme: {
      selectors: {
        ".bb-grid line": { stroke: "#ddd", "stroke-width": 1 },
        ".threshold-good line": { stroke: "green" },
        ".threshold-bad line": { stroke: "red", "stroke-dasharray": "2 2" }
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Both the line and its label can be styled (.<class> line and .<class> text), and the axis-specific forms .bb-xgrid-line.<class> and .bb-ygrid-line.<class> are supported too. They match optional grid lines only, not the tick grid lines generated by grid.x.show or grid.y.show. Direct canvas.theme keys still win over anything mapped from a selector.

The full selector table is in CANVAS_THEME_SELECTORS.md.

4. React component as a package subpath

The React component is no longer a separate @billboard.js/react package. It ships from the billboard.js/react subpath, and the billboard namespace is passed in through the bb prop, so importing it never pulls the root bundle into a non-React bundle.

import bb, {line} from "billboard.js";
import BillboardJS from "billboard.js/react";

<BillboardJS
  bb={bb}
  options={{
    data: {
      columns: [["data1", 30, 120, 80]],
      type: line()
    }
  }}
/>;
Enter fullscreen mode Exit fullscreen mode

Without a bundler, load dist/billboard.react.js. It is a UMD build exposing the BillboardReact global, which holds the component as both .Chart and .default. Note that this path reads the React global, so it needs a UMD build of React: React 18 and below ship one, React 19 does not.

5. Worker source you can serve yourself (boost.workerUrl)

boost.useWorker used to build its worker by stringifying a function, which breaks whenever a host toolchain rewrites function bodies. Coverage instrumentation, for one, made it fail with cov_xxx is not defined. The worker is now pre-bundled as a separate entry and work is addressed by op name, so no application function is ever stringified.

On top of that, boost.workerUrl points at a static script instead of an inline Blob worker, for environments whose CSP disallows blob::

bb.generate({
  boost: {
    // 'workerUrl' only selects where the worker source comes from.
    // It does not turn offloading on by itself.
    useWorker: true,
    workerUrl: "/billboard.worker.js"
  },
  data: { columns: [["data1", 30, 200, 100]] }
});
Enter fullscreen mode Exit fullscreen mode

The script is shipped as dist/billboard.worker.js (~1.5KB). With a bundler, make sure it is emitted as a real asset: inlining turns it into a data: URI, which a strict CSP blocks exactly like blob:. Copying it into the static/public directory works everywhere.

boost.useWorker also gains "auto", which offloads only past ~5,000 cells, since smaller payloads lose more to structured cloning than they gain:

boost: {
  useWorker: "auto"
}
Enter fullscreen mode Exit fullscreen mode

Any failure (load error, unknown op, timeout, result mismatch) falls back to the main thread, as before.

6. TextOverlap plugin without d3-delaunay

The TextOverlap plugin computed label positions from a Delaunay triangulation, which was its only third-party dependency and had to be loaded through a dynamic import(). It now clips bounded Voronoi cells by half-planes instead, so the dependency and the two lazy chunks it emitted are gone, and label positioning is synchronous again.

Other improvements

  • The ESM build moved from Rollup to Rolldown.
  • Repeated data aggregation and legend updates during redraw were reduced.
  • chart.select() now honors data.selection.multiple: false and point.focus.only.
  • bar.radius no longer produces invalid SVG arc flags on load().
  • The TableView plugin validates its constructor options and warns on invalid input.
  • Empty object detection is correct for objects with a polluted prototype.

👋 Going forward

Most of this release came from issues reported on GitHub, and the subchart and resize work in particular follows what people said they were trying to build. Keep the feedback coming on GitHub and community (medium & dev.to) channels!

Happy charting!

Top comments (0)