unkleho / d3-render

Declarative and reusable D3. Replace select, append, data and more with one function.
MIT License
175 stars 11 forks source link
d3

D3 Render

Declarative and reusable D3. Replace select, append, data, join, enter, exit, transition and more with one function.

Warning, highly experimental at this stage. API will change.

Articles

What's the difference?

Instead of imperative code:

import * as d3 from 'd3';

// HTML file has an <svg> element
const svg = d3.select('svg');
svg
  .append('rect')
  .attr('fill', 'pink')
  .attr('x', 0)
  .attr('width', 100)
  .attr('height', 100);

Write declarative style like this:

import render from 'd3-render';

const data = [{ append: 'rect', fill: 'pink', x: 0, width: 100, height: 100 }];

// HTML file has an <svg> element
render('svg', data);

Getting Started

$ npm install d3-render d3-selection d3-transition

D3 Render needs d3-selection (>=2) and d3-transition (>=2) as peerDependencies.

Documentation

Render

One function to rule them all. To use, add this to your JavaScript or TypeScript file:

import render from 'd3-render';

render(selector, data);

// Render also returns the full D3 selection for advanced use cases
const selection = render(selector, data);

render takes two arguments:

selector

A D3 selector string, HTML node or D3 selection to specify the root element where render will run. Works a bit like d3.select. Most common usage is with an id or class.

// Selects first <svg> element
render('svg', data);

// Select by id
render('#root', data);

// Select first element with this class name
render('.data-viz', data);

// Select by DOM node
const node = document.querySelector('.data-viz');
render(node, data);

// Select by D3 selection
const selection = d3.select('svg');
render(selection, data);

// Or called by D3
d3.select('svg').call(render, data);

data

An array of objects describing elements that D3 will append or update. For example:

const data = [
  {
    append: 'circle',
    r: 50,
    cx: 50,
    cy: 50,
    fill: 'purple',
  },
  {
    append: 'rect',
    width: 100,
    height: 100,
    x: 100,
    y: 0,
    fill: 'blue',
  },
];

render('#root', data);

render uses this data to append two elements to #root, with the following result:

<svg id="root">
  <circle r="50" cx="50" cy="50" fill="purple" />
  <rect width="100" height="100" x="100" y="0" fill="blue" />
</svg>

The D3 selection API is called for you, hiding imperative code like selection.append() or selection.attr().

Nesting

data can be hierarchical in structure with the special children key.

Say we want to wrap the circle and rectangle above within a group element:

const data = [
  {
    append: 'g',
    children: [
      {
        append: 'circle',
        ...
      },
      {
        append: 'rect',
        ...
      },
    ],
  },
];

render handles D3's nested appends for you and produces:

<svg id="root">
  <g>
    <circle r="50" cx="50" cy="50" fill="purple" />
    <rect width="100" height="100" x="100" y="0" fill="blue" />
  </g>
</svg>

The children key can be applied to any element on any level, so you can deeply nest to your hearts content.

Element Keys

Below is a list of important element keys:

Element Key Description
append* Any SVG or HTML element to append. eg. rect, circle, path, g, div, img and more. Runs D3's selection.append() behind the scenes.
key Unique identifier used to match elements on the same nesting level. Useful for transitions.
class Class name of appended element
id Id of appended element
x, y, width, height, cx, cy, r, d, fillOpacity etc Any valid attribute and value for the appended SVG element. Same as using selection.attr(), but camel case the key, eg. fillOpacity instead of fill-opacity. Can optionally use { enter, exit } for animation (but must have a duration). For example, to expand or contract height from 0 to 100px when element enters or exits, use: height: { enter: 100, exit: 0 }
text Text string to display in element. Only works for text elements. eg. { append: text, text: 'Greetings'}
html String that is evaluated into html via element.innerHTML. Useful for inlined text formatting eg. { html: '<strong>Important</strong> normal'}. Replaces any children.
style An object with style property keys and values. Keys are camel cased. eg. style: { fillOpacity: 0.5 }. Runs selection.style() in the background.
duration Number in milliseconds. Activates a D3 transition, setting the time it takes for the element to enter, update or exit. Calls selection.transition().duration(myDuration).
delay Number in milliseconds. Delays the start of the transition.
ease Sets the easing function for D3 transition. Use any D3 easing function here. eg. { append: 'rect', ease: d3.easeQuadInOut }
children Array of element objects, which will be nested under the current element.
onClick, onMouseOver, onMouseOut, supports any on* event A function for element event. Function can be used like this: { onClick: (event, data, index) => {} }
call A function with a D3 selection as the first argument. Useful for creating an axis or for advanced functionality, eg. { call: xAxis }. Essentially runs selection.call().

* Required

Updating Elements

To make updates to rendered elements, just run render again, but with a different data value. Add a duration value for a smooth transition.

// Initial data
const data = [
  { append: 'ellipse', fill: 'red', rx: 100, ry: 50, duration: 1000 },
];

// Initial render on <svg id="#root"></svg>
render('#root', data);

// After two seconds, change ellipse to blue
setTimeout(() => {
  // Set some updated data
  const newData = [
    { append: 'ellipse', fill: 'blue', rx: 100, ry: 50, duration: 1000 },
  ];

  // Call render again
  render('#root', newData);
}, 2000);

Behind the scenes, render does a lot of heavy lifting for you. It binds your data, appends the ellipse and then rebinds the newData to trigger an update and transition. This is the equivalent vanilla D3 code:

const data = [{ fill: 'red' }];
const svg = d3.select('#root');

function update(data) {
  svg
    .selectAll('ellipse')
    .data(data)
    .join(
      enter =>
        enter
          .append('ellipse')
          .attr('rx', 100)
          .attr('ry', 50)
          .attr('fill', d => d.fill),
      update =>
        update.call(update =>
          update
            .transition()
            .duration(1000)
            .attr('fill', d => d.fill)
        )
    );
}

update(data);

// After two seconds, turn ellipse blue
setTimeout(() => {
  update([{ fill: 'blue' }]);
}, 2000);

We are using d3-selection 1.4's join function, which is much easier to remember than the old general update pattern. This article from Mike Bostock explains join extremely well and is the underlying inspiration for our render function.

Enter/Exit Elements

The render function not only tracks updated elements, but also new or removed elements since the last data change.

Here is a simple example below:

// Build an svg with nothing in it
render('#root', []);
// Renders: <svg id="root"></svg>

// Two seconds later, re-render with a new rect element
setTimeout(() => {
  render('#root', [{ append: 'rect', width: 100, height: 100, fill: 'pink' }]);
  // Renders: <svg id="root"><text>Howdy there!</text></svg>

  // Two seconds after text element appears, remove it
  setTimeout(() => {
    render('#root', []);
    // Renders: <svg id="root"></svg>
  }, 2000);
}, 2000);

D3's data binding works much the same way. In fact, we are also data binding in the background, but we've also added some boilerplate to make enter/exit transitions easy to do.

Enter/Exit Transition

Let's enable enter/exit transitions by adding two lines of code:

render('#root', []);

setTimeout(() => {
  render('#root', [
    {
      append: 'rect',
      // Was width: 0
      width: { enter: 100, exit: 0 },
      height: 100,
      fill: 'pink',
      // Length of transition in milliseconds
      duration: 1000,
    },
  ]);

  setTimeout(() => {
    render('#root', []);
  }, 2000);
}, 2000);

The width has been changed to an object with an enter value, and exit value. When the rect enters, the width is 0, it then takes 1 second (from duration) to animate to 100px.

When the rect is removed from data, the exit transition kicks in, animating the width from 100px to 0 in 1 second. The rect element is then removed from the DOM.

The { enter, exit } animation object is a powerful pattern that can be applied to any attribute in the element.

Event Handlers

An event handler for the element can be defined along with the rest of the attributes.

render('#root', [
  {
    append: 'circle',
    r: 50,
    cx: 50,
    cy: 50,
    // Circle can call function when it is clicked or tapped
    onClick: (event, datum) => {},
    // Or when the mouse is over
    onMouseOver: (event, datum) => {},
  },
]);

Any on* event can be used eg. onDrag, onScroll, onWheel etc. D3 Render maps the declared event function to selection.on().

Inline Styles

An element can be styled inline with the style key and a style object value. Style properties must be in camel case.

render('#root', [
  {
    append: 'rect',
    width: 50,
    height: 50,
    style: {
      fillOpacity: 0.5,
    },
  },
]);

React Example

D3 Render is actually inspired by React's declarative mental model, so it is no suprise that integration between the two is quite simple:

// App.js

import React from 'react';
import render from 'd3-render';

const App = () => {
  const svg = React.useRef();
  const [data, setData] = React.useState([
    {
      append: 'rect',
      width: 100,
      height: 100,
      fill: 'green',
      duration: 1000,
      // Add some interactivity to the <rect> element
      onClick: () => {
        setData([{ ...data[0], fill: 'yellow' }]);
      },
    },
  ]);

  React.useEffect(() => {
    if (svg && svg.current) {
      // Pass svg node to D3 render, along with data.
      // render runs whenever data changes
      render(svg.current, data);
    }
  }, [data]);

  return <svg ref={svg}></svg>;
};

Todos

Documentation

API

Local Development

This project was bootstrapped with TSDX.

Below is a list of commands you will probably find useful.

npm start or yarn start

Runs the project in development/watch mode. Your project will be rebuilt upon changes. TSDX has a special logger for you convenience. Error messages are pretty printed and formatted for compatibility VS Code's Problems tab.

Your library will be rebuilt if you make edits.

npm run build or yarn build

Bundles the package to the dist folder. The package is optimized and bundled with Rollup into multiple formats (CommonJS, UMD, and ES Module).

npm test or yarn test

Runs the test watcher (Jest) in an interactive mode. By default, runs tests related to files changed since the last commit.