Water Silhouette in JavaScript

Using ApexCharts with JavaScript

This Water Silhouette example uses ApexCharts.js directly in JavaScript, with no wrapper component.

Install it with npm install apexcharts, then mount the chart with new ApexCharts(element, options).render().

JavaScript installation guide
// Shared by the vanilla-js, React and Vue builds.
//
// Drinking water access, one dot per 10 million people (2022 JMP shares,
// rounded). The point of the demo is the LAYOUT, not the chart type: the
// silhouette below is not a built-in arrangement and the engine knows nothing
// about shapes. It is a plain function registered with
// ApexCharts.registerUnitLayout, and switching to it keeps every dot's
// identity, colour and size, so the crowd re-forms instead of being rebuilt.
var WATER = {
  values: [576, 168, 42, 34],
  labels: ['Safely managed', 'Basic', 'Limited', 'Unsafe'],
  colors: ['#008FFB', '#00B8D9', '#FFAB00', '#FF4560'],
}

// A droplet, in a 100x100 box.
var DROPLET = 'M50 4 C50 4 14 46 14 66 A36 36 0 0 0 86 66 C86 46 50 4 50 4 Z'
// Where the shape is "heaviest" - inset rings shrink toward this, not the box
// centre, so the fill stays inside a shape that is not symmetric top-to-bottom.
var DROPLET_CORE = { x: 50, y: 60 }
// Roughly how far the droplet outline sits from its core, in box units. Used
// to turn a dot radius into a ring inset.
var DROPLET_REACH = 40

var _path = null
function dropletPath() {
  if (_path) return _path
  var NS = 'http://www.w3.org/2000/svg'
  var svg = document.createElementNS(NS, 'svg')
  svg.setAttribute('width', '0')
  svg.setAttribute('height', '0')
  svg.setAttribute(
    'style',
    'position:absolute;width:0;height:0;overflow:hidden',
  )
  var p = document.createElementNS(NS, 'path')
  p.setAttribute('d', DROPLET)
  svg.appendChild(p)
  // getTotalLength needs the element in the document to measure reliably.
  document.body.appendChild(svg)
  _path = p
  return _path
}

// The whole extension point: objects in, positions out. No animation, no
// keying, no knowledge of the chart - the engine already tweens position and
// keeps identity across a relayout.
function silhouetteLayout(objects, rect) {
  var path = dropletPath()
  var len = path.getTotalLength()
  if (!len) return []

  // Fit the 100x100 box into the plot rect, leaving a little breathing room.
  var scale = (Math.min(rect.width, rect.height) / 100) * 0.94
  var offX = rect.x + rect.width / 2 - 50 * scale
  var offY = rect.y + rect.height / 2 - 50 * scale

  // Inset copies of the outline, outermost first. Sampling one path only draws
  // a wire outline; nesting scaled copies fills the shape, and keeps this to
  // the widely supported getPointAtLength rather than point-in-fill testing.
  //
  // The inset step comes from the mark radius the engine hands us, so rings sit
  // about one dot apart whatever the plot size or dot count. A fixed list of
  // insets looks like a bullseye as soon as the dots are smaller than the gaps.
  var r = objects[0] && objects[0].r > 0 ? objects[0].r : 3
  var step = (2.1 * r) / (scale * DROPLET_REACH)
  var rings = []
  for (var k = 1; k > 0.06 && rings.length < 60; k -= step) rings.push(k)
  if (!rings.length) rings.push(1)

  // Each ring gets dots in proportion to its perimeter, so density stays even
  // rather than crowding the middle.
  var totalWeight = rings.reduce(function (a, kk) {
    return a + kk
  }, 0)

  var out = []
  var idx = 0
  rings.forEach(function (k, ri) {
    var remaining = objects.length - idx
    var count =
      ri === rings.length - 1
        ? remaining
        : Math.min(remaining, Math.round((objects.length * k) / totalWeight))
    for (var i = 0; i < count; i++) {
      var pt = path.getPointAtLength(((i + 0.5) / count) * len)
      // Shrink toward the core to make this ring's inset copy.
      var sx = DROPLET_CORE.x + (pt.x - DROPLET_CORE.x) * k
      var sy = DROPLET_CORE.y + (pt.y - DROPLET_CORE.y) * k
      out.push({
        id: objects[idx].id,
        x: offX + sx * scale,
        y: offY + sy * scale,
      })
      idx++
    }
  })
  return out
}

ApexCharts.registerUnitLayout('droplet', silhouetteLayout)

var VIEWS = {
  droplet: { layout: 'custom', positions: 'droplet' },
  packed: { layout: 'packed', positions: undefined },
  columns: { layout: 'columns', positions: undefined },
}

function setActive(id) {
  document
    .querySelectorAll('.actions button[data-view]')
    .forEach(function (btn) {
      btn.classList.toggle('active', btn.getAttribute('data-view') === id)
    })
}

var options = {
  series: [576, 168, 42, 34],
  chart: {
    id: 'waterChart',
    type: 'unit',
    height: 460,
    animations: {
      enabled: true,
      speed: 900,
    },
  },
  labels: ['Safely managed', 'Basic', 'Limited', 'Unsafe'],
  colors: ['#008FFB', '#00B8D9', '#FFAB00', '#FF4560'],
  plotOptions: {
    unit: {
      layout: 'custom',
      positions: 'droplet',
      transition: 'flow',
      spacing: 1.15,
      clusterLabels: {
        show: false,
      },
    },
  },
  legend: {
    position: 'bottom',
  },
}

var chart = new ApexCharts(document.querySelector('#chart'), options)
chart.render()

// WATER, silhouetteLayout, VIEWS and setActive live in the shared head script,
// which also registers the 'droplet' layout.
var current = 'droplet'
setActive(current)

document.querySelectorAll('.actions button[data-view]').forEach(function (btn) {
  btn.addEventListener('click', function () {
    var id = btn.getAttribute('data-view')
    if (id === current) return
    current = id
    setActive(current)
    chart.updateOptions({
      plotOptions: { unit: VIEWS[id] },
    })
  })
})