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-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)
})()
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)
})()
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>
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 (4)
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?
I'ver updated this now: community.webcomponents.dev/annoyi... it should work a treat.
Updated the code on GitHub to remove the hole - that was easy!
Cool article! But I love to draw drawnbyhislight.com how to draw easily and simply my favorite hobby!