2020 / 04 / 18
2022 / 08 / 03
DOM manipulation with D3 in Vue 3

Showcase the evolution of D3's general update pattern.

frontend
vue 3
d3
merge
join
update
typescript

Prerequisites

Install the required packages:

pnpm add d3-selection d3-transition
pnpm add -D @types/d3-selection @types/d3-transition

How to import D3

You can import the whole of D3:

// Needs `pnpm add d3`

import * as d3 from 'd3'

// OR...

const d3 = await import('d3')

But the whole of D3 might contain functionality you won’t be using at the moment.

I think it’s better to install and import only the parts you know for sure you’ll use.

For this tutorial we only need d3-selection and d3-transition.
Here’s how you can build a d3 object with limited functionality:

import * as d3Selection from 'd3-selection'
import * as d3Transition from 'd3-transition'

const d3 = Object.assign({}, d3Selection, d3Transition)

// OR...

const d3 = await Promise.all([
  import('d3-selection'),
  import('d3-transition'),
]).then((d3s) => Object.assign({}, ...d3s))

The general update pattern

The original way to update the DOM with D3 was usually met with a lot of confusion by developers around the globe. :dizzy:

Though it isn’t really a difficult concept. It just requires a little bit of mind bending. :grin:

Here, I’ll try to distill the main idea using a simple example written as a Vue component.

With Vue you’ll learn how to:

  • Reference a DOM node and pass it to D3

With D3 you’ll learn how to:

  • Make the initial data binding
  • Update existing items
  • Append entering items
  • Remove exiting items

Here’s the source code for the demo at the beginning:

<script setup lang="ts">
import * as d3Selection from 'd3-selection'
import * as d3Transition from 'd3-transition'
import { shuffle } from 'lodash-es'
import { onBeforeUnmount, onMounted, ref } from 'vue'

const d3 = Object.assign({}, d3Selection, d3Transition)

type MaybeInterval = ReturnType<typeof setInterval> | null

const containerRef = ref<HTMLElement | null>(null)
const interval = ref<MaybeInterval>(null)

onMounted(() => {
  render()
  interval.value = setInterval(() => render(), 3000)
})

onBeforeUnmount(() => {
  if (interval.value) {
    clearInterval(interval.value)
  }
})

function randomLetters() {
  return shuffle('abcdefghijklmnopqrstuvwxyz'.split(''))
    .slice(0, Math.floor(5 + Math.random() * 22))
    .sort()
}

function render() {
  const container = d3.select(containerRef.value)

  const selection = container
    .selectAll('.letter')
    .data(randomLetters(), (d) => d as string)

  // Update
  selection.attr('class', 'letter text-stone-700')

  // Enter
  selection
    .enter()
    .append('div')
    .attr('class', 'letter text-lime-600')
    .style('opacity', 0)
    .style('transform', 'translateY(-28px)')
    .text((d) => d)
    .transition()
    .duration(1500)
    .style('opacity', 1)
    .style('transform', 'translateY(0px)')

  // Exit
  selection
    .exit()
    .attr('class', 'letter text-red-600')
    .transition()
    .duration(1500)
    .style('opacity', 0)
    .style('transform', 'translateY(28px)')
    .remove()
}
</script>

<template>
  <div ref="containerRef" class="flex h-7 flex-wrap gap-2"></div>
</template>

Introducing join

I just found out about join, and it seems to simplify working with D3’s general update pattern by quite a lot.

Here’s an example with auto appending, updating and removing:

const container = d3.select(containerRef.value)

container
  .selectAll('.item')
  .data(this.randomLetters(), d => d)
  .join('em')
  .text(d => d)
  .attr('class', 'item mx-1')

If you need more control on what happens when entering, updating and removing you can have that too.

Here is the code rewritten in the join style:

<script setup lang="ts">
import * as d3Selection from 'd3-selection'
import * as d3Transition from 'd3-transition'
import { shuffle } from 'lodash-es'
import { onBeforeUnmount, onMounted, ref } from 'vue'

const d3 = Object.assign({}, d3Selection, d3Transition)

type MaybeInterval = ReturnType<typeof setInterval> | null

const containerRef = ref<HTMLElement | null>(null)
const interval = ref<MaybeInterval>(null)

onMounted(() => {
  render()
  interval.value = setInterval(() => render(), 3000)
})

onBeforeUnmount(() => {
  if (interval.value) {
    clearInterval(interval.value)
  }
})

function randomLetters() {
  return shuffle('abcdefghijklmnopqrstuvwxyz'.split(''))
    .slice(0, Math.floor(5 + Math.random() * 22))
    .sort()
}

function render() {
  const container = d3.select<d3Selection.BaseType, unknown>(containerRef.value)

  const t = container.transition().duration(1500)

  container
    .selectAll('.letter')
    .data(randomLetters(), (d) => d as string)
    .join(
      (enter) =>
        enter
          .append('div')
          .attr('class', 'letter text-lime-600')
          .style('opacity', 0)
          .style('transform', 'translateY(-28px)')
          .text((d) => d)
          .call((enter) =>
            enter
              .transition(t)
              .style('opacity', 1)
              .style('transform', 'translateY(0px)')
          ),
      (update) => update.attr('class', 'letter text-stone-700'),
      (exit) =>
        exit.call((exit) =>
          exit
            .attr('class', 'letter text-red-600')
            .transition(t)
            .style('opacity', 0)
            .style('transform', 'translateY(28px)')
            .remove()
        )
    )
}
</script>

<template>
  <div ref="containerRef" class="flex h-7 flex-wrap gap-2"></div>
</template>

This doesn’t necessarily mean the general update pattern is deprecated or anything.

It just means we now have another —and most probably, better— way to do the same thing. :)

Resources