Web Components Community 🔷

Cover image for Fixing the Donut
Dominic Myers
Dominic Myers

Posted on

Fixing the Donut

(cover image by Jessica May)

I've been playing with a pie-chart web component for a little while now, and I was thrilled with the result... until I started to use it in anger, that is.

The thing was, angles over 180 messed something up - I decided to revisit it this week after reading this article by Temani Afif, and I'm once again thrilled to see it working - and working correctly (at least in Chrome and Edge):

class WCPieChart extends HTMLElement {
  static get observedAttributes() {
    return ['width', 'duration', 'delay', 'thickness']
  }
  get style() {
    return `
      <style>
        :host {
          --width: ${this.width} 
        }
        * {
          box-sizing: border-box;
        }
        div {
          width: calc(var(--width)*1px);
          height: calc(var(--width)*1px);
          position: relative;
        }
      </style>`
  }
  constructor() {
    super()
    this.shadow = this.attachShadow({
      mode: 'closed',
    })
    this.shadow.innerHTML = `${this.style}<div></div>`
    try {
      window.CSS.registerProperty({
        name: '--p',
        syntax: '<number>',
        inherits: true,
        initialValue: 0,
      })
    } catch (e) {
      if (e.name === 'InvalidModificationError') {
        return // We've already added it before!
      } else {
        console.warn(
          'Browser does not support animated conical gradients',
          e.name
        )
      }
    }
  }
  render() {
    if (Array.isArray(this.segments)) {
      const segments = [...this.segments]
      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 value = Number(segment.getAttribute('value'))
        const currentRotation = totalDegree * value
        const animationDuration =
          currentRotation / (360 / Number(this.duration))
        segment.setAttribute('thickness', this.thickness * this.width)
        segment.setAttribute('end', (value / total) * 100)
        segment.setAttribute('rotate', rotationTotal)
        segment.setAttribute('delay', durationTotal)
        segment.setAttribute('duration', animationDuration)
        segment.setAttribute('width', this.width)
        rotationTotal += currentRotation
        durationTotal += animationDuration
        this.div.append(segment)
      })
    }
  }
  connectedCallback() {
    this.div = this.shadow.querySelector('div')
    this.segments = [...this.querySelectorAll('wc-pie-slice')]
    this.render()
  }
  attributeChangedCallback(name, oldValue, newValue) {
    if (newValue !== oldValue) {
      this.shadow.innerHTML = `${this.style}<div></div>`
      this.div = this.shadow.querySelector('div')
      this.render()
    }
  }
  get width() {
    return Number(this.getAttribute('width')) || 150
  }
  get duration() {
    return Number(this.getAttribute('duration')) || 2000
  }
  get delay() {
    return Number(this.getAttribute('delay')) || 500
  }
  get thickness() {
    return Number(this.getAttribute('thickness')) || 0.2
  }
}

class WCPieSlice extends HTMLElement {
  static get observedAttributes() {
    return ['width', 'duration', 'delay', 'color', 'thickness', 'rotate']
  }
  get style() {
    return `
      <style>
        * {
          box-sizing: border-box;
        }
        div {
          --p: ${this.end};
          width: ${this.width}px;
          aspect-ratio: 1;
          margin: 0;
          position: absolute;
          left: 50%;
          top: 50%;
          animation-name: p;
          animation-fill-mode: both;
          animation-timing-function: ease-in-out;
          transform: translate(-50%, -50%);
          animation-duration: ${this.duration}ms;
          animation-delay: ${this.delay}ms;
        }
        div:before {
          content: "";
          position: absolute;
          border-radius: 50%;
          inset: 0;
          background: conic-gradient(from ${this.rotate}deg, ${this.specifiedColour} calc(var(--p)*1%), transparent 0);
          -webkit-mask: radial-gradient(farthest-side, transparent calc(99% - ${this.thickness}px), #000 calc(100% - ${this.thickness}px));
          mask: radial-gradient(farthest-side, transparent calc(99% - ${this.thickness}px), #000 calc(100% - ${this.thickness}px));
        }
        @keyframes p {
          from {
            --p: 0;
          }
        }
      </style>
    `
  }
  constructor() {
    super()
    this.shadow = this.attachShadow({
      mode: 'closed',
    })
    this.specifiedColour = this.color
  }
  render() {
    this.shadow.innerHTML = `${this.style}<div></div>`
  }
  connectedCallback() {
    this.render()
  }
  attributeChangedCallback(name, oldValue, newValue) {
    this.render()
  }
  get width() {
    return Number(this.getAttribute('width'))
  }
  get duration() {
    return Number(this.getAttribute('duration'))
  }
  get delay() {
    return Number(this.getAttribute('delay'))
  }
  get color() {
    return this.getAttribute('color') || this.getRandomColor
  }
  get thickness() {
    return Number(this.getAttribute('thickness'))
  }
  get rotate() {
    return Number(this.getAttribute('rotate'))
  }
  get end() {
    return Number(this.getAttribute('end'))
  }
  get getRandomColor() {
    const letters = '0123456789ABCDEF'
    let color = '#'
    for (let i = 0; i < 6; i++) {
      color += letters[Math.floor(Math.random() * 16)]
    }
    return color
  }
}

window.customElements.define('wc-pie-chart', WCPieChart)
window.customElements.define('wc-pie-slice', WCPieSlice)
Enter fullscreen mode Exit fullscreen mode

It is significantly cleaner but only animates within Chrome and Edge (everywhere else, it should just appear without animation, which is a shame).

I've also moved away from using the CSSStyleSheet interface as Safari throws a wobble when it's used without a polyfill, which is a shame as constructible/adoptable style sheets rock!

The animation is thanks to @property CSS at-rule. It is utterly brilliant, especially once I understood you could inject it via JS, as injecting it into the component's CSS didn't work at all - either via the static CSS or the constructible/adoptable CSS.

Bundling it into one file should also ease its adoption.

Should you want to give it a go I've published it on npm, please do give it a spin and let me know if things are broken!

There's a demo on GitHub where you can see that the component can be manipulated from outside, and even added dynamically - which is something I though was a brilliant idea from the original component.

From the README:

<wc-pie-chart id="existing"
              thickness="0.1">
  <wc-pie-slice value="5"
                color="#E64C65"/>
  <wc-pie-slice value="5"
                color="#11A8AB"/>
  <wc-pie-slice value="5"
                color="#394264"/>
</wc-pie-chart>
<script type="module"
        src="https://unpkg.com/wc-pie-chart/wc-pie-chart.js"></script>
Enter fullscreen mode Exit fullscreen mode

If anyone can help get it so I can pop a demo into the listing on
WEBCOMPONENTS.ORG, I'd be really grateful.

Top comments (0)