Web Components Community 🔷

Web Components Community 🔷 is a community of amazing users

The Community Platform for Web Components Users and Developers

Create account Log in
Cover image for (Doughnut) Donut Chart Web Component
Dominic Myers
Dominic Myers

Posted on

(Doughnut) Donut Chart Web Component

About three months back I saw a CodePen by Hilario Goes and I was inspired to convert it into a Web Component. That I did, but it ended up being really messy:

<wc-donut-chart id="test"
                part-1-value="5"
                part-1-name="Part 1"
                part-1-color="#E64C65"
                part-2-value="5"
                part-2-name="Part 2"
                part-2-color="#11A8AB"
                part-3-value="5"
                part-3-name="Part 3"
                part-3-color="#4FC4F6"
                animation-duration="3"
                hole-color="#394264"></wc-donut-chart>
Enter fullscreen mode Exit fullscreen mode

(you can see it in action if you download the repo and run index.html.)

It worked, but it relied upon a mechanism I developed a little while back for injecting CSS into components, and the code was all over the place. It also went against a best practice I read a little while back regarding components doing too much.

The segments of the doughnut chart didn't need to be created by the doughnut but could - instead - be their own concern - so I made the dm-arc-part Component:

(() => {
  const mainSheet = new CSSStyleSheet()
  mainSheet.replaceSync(`
    :host { 
      --end: 20deg;
    }
    * {
      box-sizing: border-box;
    }
    .segment-holder {
      border-radius: 50%;
      clip: rect(0, var(--dimension), var(--dimension), calc(var(--dimension) * 0.5));
      height: 100%;
      position: absolute;
      width: 100%;
    }
    .segment {
      border-radius: 50%;
      clip: rect(0, calc(var(--dimension) * .5), var(--dimension), 0);
      height: 100%;
      position: absolute;
      width: 100%;
      font-size: 1.5rem;
      animation-fill-mode: forwards;
      animation-iteration-count: 1;
      animation-timing-function: ease;
      animation-name: rotate;
    }
    @keyframes rotate {
      from {
        transform: rotate(0deg);
      }
      to {
        transform: rotate(var(--end));
      }
    }
  `)

  class ArcPart extends HTMLElement {
    static get observedAttributes() {
      return [
        'name',
        'color',
        'rotate',
        'duration',
        'start'
      ];
    }
    constructor() {
      super()
      this.shadow = this.attachShadow({
        mode: 'open'
      })
      this.shadow.adoptedStyleSheets = [mainSheet];
      this.shadow.innerHTML = `
        <div class="segment-holder">
          <div class="segment"></div>
        </div>
      `
      this.render()
    }
    render() {
      const sheet = new CSSStyleSheet()
      sheet.replaceSync( `
        :host { 
          --end: ${this.end};
        }
        .segment-holder {
          transform: rotate(${this.rotate});
        }
        .segment {
          background-color: ${this.color};
          animation-delay: ${this.delay};
          animation-duration: ${this.duration};
        }
      `)
      this.shadowRoot.adoptedStyleSheets = [mainSheet, sheet]
    }

    get end() {
      return this.getAttribute('end') || '120deg'
    }
    get color() {
      return this.getAttribute('color') || '#000000'
    }
    get delay() {
      return this.getAttribute('delay') || '0s'
    }
    get duration() {
      return this.getAttribute('duration') || '0s'
    }
    get rotate() {
      return this.getAttribute('rotate') || '0deg'
    }
    get title() {
      return this.getAttribute('title') || null
    }
    attributeChangedCallback(name, oldValue, newValue) {
      if((oldValue !== newValue)){
        this.render()
      }
    }
  }
  window.customElements.define('dm-arc-part', ArcPart)
})()
Enter fullscreen mode Exit fullscreen mode

These parts are injected into the parent dm-donut-chart in a slot and the parent dm-donut-chart interrogates them in order to populate their attributes.

(() => {
  const mainSheet = new CSSStyleSheet()
  mainSheet.replaceSync(`
    :host {
      --dimension: 200px;
    }
    * {
      box-sizing: border-box;
    }
    .donut-chart {
      position: relative;
      width: var(--dimension);
      height: var(--dimension);
      margin: 0 auto;
      border-radius: 100%
    }
    .center {
      position: absolute;
      top:0;
      left:0;
      bottom:0;
      right:0;
      width: calc(var(--dimension) * .65);
      height: calc(var(--dimension) * .65);
      margin: auto;
      border-radius: 50%;
    }
  `)
  class DonutChart extends HTMLElement {
    static get observedAttributes() {
      return [
        'duration',
        'color',
        'delay',
        'diameter',
        'dimension'
      ];
    }
    constructor() {
      super()
      this.shadow = this.attachShadow({
        mode: 'open'
      })
      this.shadow.adoptedStyleSheets = [mainSheet];
      this.shadow.innerHTML = `
        <div class="donut-chart">
          <slot name='segments'></slot>
          <div class="center"></div>
        </div>
      `
      this.render()
    }
    render() {
      const segments = [...this.querySelectorAll('dm-donut-part')]
      const total = segments.reduce((p, c) => p + Number(c.getAttribute('value')), 0)
      let durationTotal = this.delay;
      let rotationTotal = 0
      const totalDegree = 360/total
      segments.forEach(segment => {
        const currentRotation = totalDegree * Number(segment.getAttribute('value'))
        const animationDuration = currentRotation / (360/Number(this.duration))
        segment.setAttribute('end', `${currentRotation}deg`)
        segment.setAttribute('rotate', `${rotationTotal}deg`)
        segment.setAttribute('delay', `${durationTotal}s`)
        segment.setAttribute('duration', `${animationDuration}s`)
        rotationTotal += currentRotation
        durationTotal += animationDuration
      })
      const sheet = new CSSStyleSheet()
      sheet.replaceSync( `
        :host {
          --dimension: ${this.dimension}px;
        }
        .center {
          background-color: ${this.color};
          width: calc(var(--dimension) * ${this.diameter});
          height: calc(var(--dimension) * ${this.diameter});
        }
      `)
      this.shadowRoot.adoptedStyleSheets = [mainSheet, sheet]
    }
    get color() {
      return this.getAttribute('color') || '#000000'
    }
    get duration() {
      return Number(this.getAttribute('duration')) || 4.5
    }
    get delay() {
      return Number(this.getAttribute('delay')) || 0
    }
    get diameter() {
      return Number(this.getAttribute('diameter')) || .65
    }
    get dimension() {
      return Number(this.getAttribute('dimension')) || 200
    }
  }
  window.customElements.define('dm-donut-chart', DonutChart)
})()
Enter fullscreen mode Exit fullscreen mode

A much cleaner approach, and it allowed me to slim down the code and remove the need for the DomHelpers (though I do love them).

I also got a chance to play with replaceSync, which is brilliant and even works on Safari with a polyfill.

This is how I invoke the doughnut:

<dm-donut-chart color="#394264"
                duration="4.5"
                delay="2"
                diameter=".6"
                dimension="200">
  <div slot="segments">
    <dm-donut-part color="#E64C65"
                   value="5"></dm-donut-part>
    <dm-donut-part color="#11A8AB"
                   value="5"></dm-donut-part>
    <dm-donut-part color="#4FC4F6"
                   value="5"></dm-donut-part>
  </div>
</dm-donut-chart>
Enter fullscreen mode Exit fullscreen mode

I'm still not overly happy with having to have a hole within the doughnut - I'll do some more work and see if I can use arcs instead of squares - but I guess, without the hole, the doughnut chart acts like a regular pie chart.

Top comments (3)

Collapse
 
annoyingmouse profile image
Dominic Myers Author

Ah! Found a bug while using it in anger: drmsite.blogspot.com/2022/07/donut... Does anyone have any other ideas apart from using SVGs?

Collapse
 
annoyingmouse profile image
Dominic Myers Author

I'ver updated this now: community.webcomponents.dev/annoyi... it should work a treat.

Collapse
 
annoyingmouse profile image
Dominic Myers Author

Updated the code on GitHub to remove the hole - that was easy!