Monday 20 June 2022

(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>

(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-donut-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 DonutPart 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-donut-part', DonutPart)
})()

These parts are injected into the parent dm-donut-chart in a slot and the parent dm-donut-chart interogates 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)
})()

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 suitable 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>

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.

No comments:

Post a Comment