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