Thursday 1 December 2022

Scrimba's JavaScriptmas and Advent of Code 2022

Day 1

JavaScriptmas

const panic = str => `${str.toUpperCase().split(' ').join(' 😱 ')}!`
console.log(panic("I'm almost out of coffee"))
console.log(panic("winter is coming"))

Advent of Code

const elves = input.split('\n\n').map(e => Number(e.split('\n').reduce((a, c) => Number(a) + Number(c))))
console.log(Math.max(...elves))
elves.sort((a, b) => b - a)
console.log(elves.slice(0, 3).reduce((a, c) => a + c))

Day 2

JavaScriptmas

const transformData = d => d.map(e => ({
    fullName: `${e.name.first} ${e.name.last}`,
    birthday: new Date(e.dob.date).toDateString()
}))

Advent of Code

/**
 * Rock:     A, X. Worth: 1. Beats: Scissors
 * Paper:    B, Y. Worth: 2. Beats: Rock
 * Scissors: C, Z. Worth: 3. Beats: Paper
 * 0 if you lost,
 * 3 if the round was a draw
 * and 6 if you won
 */ 

console.log(input.split('\n').reduce((a, c) => {
  const [them, me] = c.split(' ')
  if(them === 'A'){
    if(me === 'X'){
      a += 3 + 1
    }
    if(me === 'Y'){
      a += 6 + 2
    }
    if(me === 'Z'){
      a += 0 + 3
    }
  }
  if(them === 'B'){
    if(me === 'X'){
      a += 0 + 1
    }
    if(me === 'Y'){
      a += 3 + 2
    }
    if(me === 'Z'){
      a += 6 + 3
    }
  }
  if(them === 'C'){
    if(me === 'X'){
      a += 6 + 1
    }
    if(me === 'Y'){
      a += 0 + 2
    }
    if(me === 'Z'){
      a += 3 + 3
    }
  }
  return a
}, 0))

/**
 * Rock:     A, X. Worth: 1. Beats: Scissors
 * Paper:    B, Y. Worth: 2. Beats: Rock
 * Scissors: C, Z. Worth: 3. Beats: Paper
 * 0 if you lost,
 * 3 if the round was a draw
 * and 6 if you won
 * X: Lose
 * Y: Draw
 * Z: Win
 */ 

console.log(input.split('\n').reduce((a, c) => {
  const [them, me] = c.split(' ')
  if(them === 'A'){ // They've played Rock
    if(me === 'X'){ // I need to lose
      a += 0 + 3    // I play Scissors
    }
    if(me === 'Y'){ // I need to draw
      a += 3 + 1    // I play Rock
    }
    if(me === 'Z'){ // I need to win
      a += 6 + 2    // I play Paper
    }
  }
  if(them === 'B'){ // They've played Paper
    if(me === 'X'){ // I need to lose
      a += 0 + 1    // I play Rock
    }
    if(me === 'Y'){ // I need to draw
      a += 3 + 2    // I play Paper
    }
    if(me === 'Z'){ // I need to win
      a += 6 + 3    // I play Scissors
    }
  }
  if(them === 'C'){ // They've played Scissors
    if(me === 'X'){ // I need to lose
      a += 0 + 2    // I play Paper  
    }
    if(me === 'Y'){ // I need to draw
      a += 3 + 3    // I play Scissors  
    }
    if(me === 'Z'){ // I need to win
      a += 6 + 1    // I play Rock  
    }
  }
  return a
}, 0))

Day 3

JavaScriptmas

const faveFoods = {
    breakfast: 'croissants',
    lunch: 'pasta',
    supper: 'pizza'
}

const {breakfast, lunch, supper} = faveFoods

document.getElementById('meals').innerHTML = `
For breakfast, I only like ${breakfast}. For lunch, I love ${lunch}, and for supper I want usually want ${supper}.`

Advent of Code

// drop 96 for lowercase
// drop 38 for uppercase

const getCodeValue = letter => letter === letter.toLowerCase() 
  ? letter.charCodeAt(0) - 96
  : letter.charCodeAt(0) - 38

const getIntersection = (a, b) => {
  for(let i in a){
    if(b.includes(a[i])){
      return a[i]
    }
  }
}

const getIntersectionThree = (a, b, c) => {
  for(let i in a){
    if(b.includes(a[i]) && c.includes(a[i])){
      return a[i]
    }
  }
}

console.log(input.split('\n').reduce((a, c) => a + getCodeValue(getIntersection(...[c.slice(0, Math.ceil(c.length / 2)).split(''), c.slice(Math.ceil(c.length / 2)).split('')]))
, 0))

let total = 0
const lines = input.split('\n')
for (let i = 0; i < lines.length; i += 3) {
  const chunk = lines.slice(i, i + 3);
  total += getCodeValue(getIntersectionThree(...chunk))
}
console.log(total)

console.log(input.split('\n').reduce((p, _, i, a) => !(i%3) 
  ? p + getCodeValue(getIntersectionThree(...a.slice(i, i + 3))) 
  : p, 0))

Day 4

JavaScriptmas

const whisper = s => `shh... ${s.endsWith('!') 
  ? s.toLowerCase().slice(0, -1) 
  : s.toLowerCase()}`

Advent of Code

const range = (start, stop) => Array.from({ length: stop - start + 1 }, (_, i) => start + i)
const contains = (a, b) => a.every(e => b.includes(e)) || b.every(e => a.includes(e))
const overlaps = (a, b) => a.some(e => b.includes(e))
                                                                  
console.log(input.split('\n').reduce((a, line) => {
  const [firstArea, secondArea] = line.split(',').map(elf => range(...elf.split('-').map(e => Number(e))))
  a.contains += contains(firstArea, secondArea) ? 1 : 0
  a.overlaps += overlaps(firstArea, secondArea) ? 1 : 0
  return a
}, {
  contains: 0,
  overlaps: 0
}))

// let containedWithin = 0
// let overlaps = 0

// input.split('\n').forEach(line => {
//   const [firstArea, secondArea] = line.split(',').map(elf => range(...elf.split('-').map(e => Number(e))))
//   if(secondArea.every(e => firstArea.includes(e)) || firstArea.every(e => secondArea.includes(e))){
//     containedWithin += 1
//   }
//   if(secondArea.some(e => firstArea.includes(e))){
//     overlaps += 1
//   }
// })
// console.log(containedWithin, overlaps)

Day 5

JavaScriptmas

const getSaleItems = d => d.filter(e => e.type === "sweet").map(e => ({item: e.item, price: e.price}))

Advent of Code

const chunk = (str, size) => {
  const numChunks = Math.ceil(str.length / size)
  const chunks = new Array(numChunks)
  for (let i = 0, o = 0; i < numChunks; ++i, o += size) {
    chunks[i] = str.substr(o, size)
  }
  return chunks
}

const [initialState, instructions] = input.split('\n\n')
const initialStateLines = initialState.split('\n').reverse()
const positions = chunk(initialStateLines.shift(), 4).reduce((a, c) => {
  a[Number(c.trim())] = []
  return a
}, {})

initialStateLines.forEach(line => {
  chunk(line, 4).forEach((position, index) => {
    if (position.trim().length) {
      const { groups: { letter } } = /\s*\[(?<letter>[A-Z])\]\s*/.exec(position)
      positions[Object.keys(positions)[index]].push(letter)
    }
  })
})

/**
 * Part 1
 */
// instructions.split('\n').forEach(instruction => {
//   const {groups: {num, from, to}} = /move (?<num>[0-9]+) from (?<from>[0-9]+) to (?<to>[0-9]+)/.exec(instruction)
//   for(let i = 0; i < Number(num); i++){
//     positions[Number(to)].push(positions[Number(from)].pop())
//   }
// })

/**
 * Part 2
 */
instructions.split('\n').forEach(instruction => {
  const { groups: { num, from, to } } = /move (?<num>[0-9]+) from (?<from>[0-9]+) to (?<to>[0-9]+)/.exec(instruction)
  positions[Number(to)].push(...positions[Number(from)].slice(-Math.abs(Number(num))))
  positions[Number(from)].splice(positions[Number(from)].length - Number(num), Number(num) )
})

console.log(Object.keys(positions).reduce((a, c, i) => {
  return a + positions[c].at(-1)
}, ''))

Day 6

JavaScriptmas

const getRandomNumberOfTacos = () => new Array(Math.floor(Math.random() * (10 - 1) + 1)).fill('🌮')

Advent of Code

const test_input_1 = `mjqjpqmgbljsphdztnvjfqwrcgsmlb`
const test_input_2 = `bvwbjplbgvbhsrlpgdmjqwftvncz`
const test_input_3 = `nppdvjthqldpwncqszvftbrmjlhg`
const test_input_4 = `nznrnfrfntjfmvfwmzdfjlvtqnbhcprsg`
const test_input_5 = `zcfzfwzzqfrljwzlrfnpqdbhtmscgvjw`

const findPacket = stream => {
  for(let i = 3; i <= stream.length; i++){
    if(new Set(Array.from({length: 4}, (_, a) => stream.charAt(i - a))).size === 4){
      return i + 1
    }
  }
}

const findMessage = stream => {
  for(let i = 13; i <= stream.length; i++){
    if(new Set(Array.from({length: 14}, (_, a) => stream.charAt(i - a))).size === 14){
      return i + 1
    }
  }
}

console.log(findPacket(test_input_1))
console.log(findPacket(test_input_2))
console.log(findPacket(test_input_3))
console.log(findPacket(test_input_4))
console.log(findPacket(test_input_5))
console.log(findPacket(input))

console.log(findMessage(test_input_1))
console.log(findMessage(test_input_2))
console.log(findMessage(test_input_3))
console.log(findMessage(test_input_4))
console.log(findMessage(test_input_5))
console.log(findMessage(input))

Day 7

JavaScriptmas

const altCaps = str => str.split('').map((c, i) => i % 2 ? c : str.charCodeAt(i) > 96 || str.charCodeAt(i) < 123 ? c.toUpperCase() : c).join('')

Advent of Code

// import { Tree } from './Tree.js'
// import { TreeNode } from './TreeNode.js'

class TreeNode {
  constructor(key, value = key, parent = null) {
    this.key = key;
    this.value = value;
    this.parent = parent;
    this.children = [];
  }

  get isLeaf() {
    return this.children.length === 0;
  }

  get hasChildren() {
    return !this.isLeaf;
  }
}

class Tree {
  constructor(key, value = key) {
    this.root = new TreeNode(key, value);
  }

  *preOrderTraversal(node = this.root) {
    yield node;
    if (node.children.length) {
      for (let child of node.children) {
        yield* this.preOrderTraversal(child);
      }
    }
  }

  *postOrderTraversal(node = this.root) {
    if (node.children.length) {
      for (let child of node.children) {
        yield* this.postOrderTraversal(child);
      }
    }
    yield node;
  }

  insert(parentNodeKey, key, value = key) {
    for (let node of this.preOrderTraversal()) {
      if (node.key === parentNodeKey) {
        node.children.push(new TreeNode(key, value, node));
        return true;
      }
    }
    return false;
  }

  remove(key) {
    for (let node of this.preOrderTraversal()) {
      const filtered = node.children.filter(c => c.key !== key);
      if (filtered.length !== node.children.length) {
        node.children = filtered;
        return true;
      }
    }
    return false;
  }

  find(key) {
    for (let node of this.preOrderTraversal()) {
      if (node.key === key) return node;
    }
    return undefined;
  }
}

/*
 * https://www.30secondsofcode.org/articles/s/js-data-structures-tree
 */

addEventListener('DOMContentLoaded', async () => {
  //const res = await fetch("./test.txt")
  const res = await fetch("./input.txt")
  const text = await res.text()
  const input = text.split('\n')
  let target = null
  let tree = null
  let lineParts
  input.forEach((line, index) => {
    if (!index) {
      tree = new Tree('/', 0)
      target = tree.root
    } else {
      if (line.charAt(0) === '$') {
        lineParts = line.split(' ')
        if (lineParts.length === 3) {
          if (lineParts[2] === '..') {
            target = target.parent
          } else if (lineParts[2] === '/') {
            target = tree.root
          } else {
            target = target.children.find(ch => ch.key === lineParts[2])
          }
        }
      } else {
        lineParts = line.split(' ')
        if (lineParts[0] === 'dir') {
          target.children.push(new TreeNode(lineParts[1], 0, target))
        } else {
          const value = Number(lineParts[0])
          target.children.push(new TreeNode(lineParts[1], value, target))
          target.value += value
          let parent = target.parent
          while (parent) {
            parent.value += value
            parent = parent.parent
          }
        }
      }
    }
  })

  const dirs = [...tree.postOrderTraversal()].filter(x => x.hasChildren)
  console.log('Part 1:', dirs.reduce((a, c) => {
    if (c.value <= 100000) {
      a += c.value
    }
    return a
  }, 0))

  const spaceRequired = 30000000 - (70000000 - tree.root.value)
  console.log('Space required: ', spaceRequired)
  console.log('Part 2:', dirs.filter(dir => {
    if (dir.value >= spaceRequired) {
      console.log(dir.key, dir.value)
      return true
    }
    return false
  }).sort((a, b) => a.value - b.value)[0].value)
})

Day 8

JavaScriptmas

const validTime = str => {
  const [hours, minutes] = str.split(':').map(n => Number(n))
  return hours >= 1 && hours <= 24 && minutes >= 0 && minutes < 60
}

Day 9

JavaScriptmas

const capitalizeWord = w => `${w.charAt(0).toUpperCase()}${w.slice(1, w.length)}`

const toTitleCase = s => s.split(' ').map(w => capitalizeWord(w)).join(' ')

Day 10

JavaScriptmas

const sortByLength = s => s.sort((a, b) => a.length - b.length)

Day 11

JavaScriptmas

const reverseString = arr => arr.split('').reverse().join('')

const reverseStringsInArray = arr => arr.map(a => reverseString(a))

Day 12

JavaScriptmas

class MenuItem extends HTMLElement{
    static get observedAttributes() {
        return [
            'food'
        ]
    }
    constructor() {
        super()
    }
    render() {
        this.innerHTML = this.html
    }
    get html() {
        return `<div class="food">${this.food}</div>`
    }
    attributeChangedCallback(name, oldValue, newValue) {
        if (oldValue !== newValue) {
            this.render()
        }
    }
    set food(v){
        this.setAttribute('food', v)
    }
    get food() {
        return this.getAttribute('food')
    }
}
window.customElements.define('menu-item', MenuItem)
const menu = document.getElementById('menu')
const dinnerFoods = ['🍝','🍔','🌮']
menu.innerHTML = dinnerFoods.map(food => `<menu-item food="${food}"/></menu-item>`).join('')

Day 13

JavaScriptmas

const emojifyWord = w => w.charAt(0) === ':' && w.charAt(w.length - 1) === ':' ? emojis[w.slice(1, -1)] ? emojis[w.slice(1, -1)] : w.slice(1, -1) : w

const emojifyPhrase = phrase => phrase.split(' ').map(word => emojifyWord(word)).join(' ')

Day 14

JavaScriptmas

const countVowelConsonant = str => str.split('').reduce((a, c) => a += ['a','e','i','o','u'].includes(c) ? 1 : 2, 0)

Day 15

JavaScriptmas

const isPalindrome = str => str === str.split('').reverse().join('')

Day 16

JavaScriptmas

const insertDashes = arr => arr.split(' ').map(w => w.split('').join('-')).join(' ')

Day 17

JavaScriptmas

const flatten = arr => arr.flat()
const flatten = arr => Array.prototype.concat.apply([], arr)
const flatten = arr => arr.toString().split(',')

Day 18

JavaScriptmas

const candies = (ch, ca) => Math.floor(ca/ch) * ch

Day 19

JavaScriptmas

const centuryFromYear = num => num % 100 === 0 ? num / 100 : Math.floor(num / 100) + 1

Day 20

JavaScriptmas

const getFreePodcasts = data => podcasts.filter(p => !p.paid).map(({title, rating, paid}) => ({title, rating, paid}))

Day 21

JavaScriptmas

const awardBonuses = num => Array.from({length: num}, (_, i) => i + 1).forEach(i => console.log(`${i} - ${!(i%3) && !(i%5) ? 'JACKPOT! 1 Million and a Yacht!' : !(i%3) ? 'Vacation!' : !(i%5) ? '$100,000 bonus!' : ':('}`))

awardBonuses(100)

Day 22

JavaScriptmas

function getReadyTables() {
  const readyTables = []
  for (let i = 0; i < 2; i++) {
    readyTables.push(Math.round(Math.random()*20) + 1)
  }
  return readyTables
}

const displayTables = () => getReadyTables().map(el => {
  const table =  document.createElement('div')
  table.classList.add('table')
  table.innerText = el
  return table
})

document.getElementById('tables').append(...displayTables())

Day 23

JavaScriptmas

const sortProducts = data => data.sort((a, b) => a.price - b.price)

Day 24

JavaScriptmas

const player = document.getElementById("player")
const playSong = id => player.src = `https://www.youtube.com/embed/${id}?autoplay=1`

Tuesday 15 November 2022

Create dot notation array of strings from an Object

Last night, I created an object from an array of strings with dot notation; I needed to do that because I had previously flattened an object into those strings. I did this long and laboriously, but I asked a colleague to see if he could figure out how to do it using recursion. He did, and I'd like to share it here.

If we have this object:

{
  "thingOne": {
    "thingTwo": {
      "thingThree": true,
      "thingFour": true
    }
  },
  "thingyOne": {
    "thingyTwo": {
      "thingyThree": true,
      "thingyFour": true
    }
  }
}

Then this code:

(() => {

  const obj = {
    "thingOne": {
      "thingTwo": {
        "thingThree": true,
        "thingFour": true
      }
    },
    "thingyOne": {
      "thingyTwo": {
        "thingyThree": true,
        "thingyFour": true
      }
    }
  }

  const getObjStringsArr = (o = {}, arr = [], name = '') => {
    Object.keys(o).forEach(key => {
      if (o[key] === true) {
        arr.push(`${name}${key}`)
      } else {
        const nested = getObjStringsArr(o[key], arr, `${name}${key}.`)
        arr.concat(nested)
      }
    });
    return arr
  }

  console.log(getObjStringsArr(obj))

})()

It's brilliant having colleagues; it's even better having colleagues with huge brains! Thanks, Hamish!

It's not necessarily faster than my cludge, but it is far more elegant!

Monday 14 November 2022

Create an Object from dot notation

This was puzzling me all afternoon, and I came up with a bloody terrible work-around using eval, this evening I decided to do a little more research and found this answer on StackOverflow.

I've adapted it and it seems to work a treat:

(() => {

  const obj = {}

  const expand = (output, mystr, value) => {
    const items = mystr.split(".") // split on dot notation
    let ref = output // keep a reference of the new object
    //  loop through all nodes, except the last one
    for (let i = 0; i < items.length - 1; i++) {
      if (!ref[items[i]]) {
        // create a new element inside the reference
        ref[items[i]] = {}
      }
      ref = ref[items[i]] // shift the reference to the newly created object 
    }
    ref[items[items.length - 1]] = value // apply the final value
    return output // return the full object
  }

  const arr = [
    "thingOne.thingTwo.thingThree",
    "thingOne.thingTwo.thingFour",
    "thingyOne.thingyTwo.thingyThree",
    "thingyOne.thingyTwo.thingyFour"
  ]

  arr.forEach(a => {
    expand(obj, a, true)
  })
  
  console.log(obj)

})()

I was nearly there on my tod TBH, but wussed out at the end, thisis lovely though! It produces this lovely object:

{
  "thingOne": {
    "thingTwo": {
      "thingThree": true,
      "thingFour": true
    }
  },
  "thingyOne": {
    "thingyTwo": {
      "thingyThree": true,
      "thingyFour": true
    }
  }
}

Thursday 6 October 2022

Democide

As reported in The Guardian in 2019, the Institute of Public Policy Research (IPPR) said more than "130,000 deaths in the UK since 2012 could have been prevented if improvements in public health policy had not stalled as a direct result of austerity cuts". More conservatively, the British Medical Journal (BMJ) said that in 2017, austerity was linked to 120,000 extra deaths in England (just in England, not Scotland, Wales or Northern Ireland). In 2018, the Office for National Statistics (ONS) showed a fall in life expectancy for poorer socioeconomic groups and those living in more impoverished areas.

So, it's not like we've not known the Tories have had an appalling impact on our population, but, on top of these utterly shocking figures, we're now presented with yet more. The Independent reported today (05/10/2022), "The UK government's economic policies are 'likely' to have caused a 'great many more deaths' than the Covid Pandemic, an academic has claimed". The Independent used an article from the Journal of Epidemiology and Community Health (JECH) titled "Bearing the burden of austerity: how do changing mortality rates in the UK compare between men and women?" by Walsh, Dundas, McCartney, Gibson and Seaman. Walsh et al. looked at previous statistics, compared actual figures with predicted figures, and came up with the eye-watering figure of 335,000 deaths between 2012 and 2019. Hang about, though; those figures don't include those deaths from the COVID-19 epidemic.

According to the Government's own website today (05/10/2022), there have been 177,977 "deaths within 28 days of being identified as a COVID-19 case by a positive test, reported up to Friday, 20 May 2022". Is anyone else confused by that date? 20 May 2022? Maybe I'm being paranoid; maybe it's not been updated in a while. Perhaps we don't know how many actual deaths from COVID-19 there have been since the start of the Pandemic.

There can be little doubt that the Tories cost lives during the Pandemic. A joint report by the House of Commons Science and Technology Committee and the Health and Social Care Committee condemned severe errors, including delayed lockdowns and how a test, trace and isolate system was set up. It did praise the vaccination programme, though. Funnily enough, the Government took credit for the vaccination programme while crediting the NHS with their disastrous Test and Trace program.

I'm not sure what proportion of that 177,977 needs to be added to the 335,000 death toll of the Tories; whatever figure we'll end up with, it's fair to say that the Tories are guilty of Democide.

If we look at the percentages, the Tories have done a grand job of killing half a per cent of the population; "let the bodies pile high in their thousands", indeed!

The chart at the top, it must be noted, uses a logarithmic scale. So, while the Tories aren't guilty of Mega-murder, nor Deka-mega-murder, they are guilty of Hecto-kilo-murder - and that's us they've been killing (Thank you, Wikipedia, for the pre-fixes)!

Friday 30 September 2022

wc-slider (An alternative to the range input)

HTML is brilliant; it's an ever-growing standard; CSS is also pretty amazing! I say this because I was bored over the evenings this week and brushed off an old thing I started years ago to help me with the inability to style range inputs. You can do some cool styling on range inputs now, but when I created my initial component, it wasn't easy and involved many vendor prefixes, with some target browsers unable to handle even those. So I decided to write a component to get our designer's desired effect.

But I was bored this week, so I decided to revisit it; you can see the result on JSFiddle.

It was a lot of fun doing this, I'm not sure if I'll ever need it again, but it was a valuable learning experience. Asking for help from a mate meant that she could echo back something I've said to her many times before: "Have you tried flex?", so embarrassing, but it just goes to show! One issue was that I was sticking to the original CSS too much and didn't have to cope with variable-sized wc-sliders. You see, the little arrows behind the slider element moved depending on the number of elements in the slider.

The Drag-and-Drop mechanism took the most time and - as a result of checking my workings - I clocked I was using the getters for range, colourRange and deselectedRange far too much. I moved these to Private class features and only calculate them on render(). Thanks to the dictates of working with a designer, I also spent a fair bit of effort working with colours and gradients - a bit of a ballache, TBH, but I've got the functions now so that I can use them again in the future!

I do like the invertColor(hex, bw) though. That's tremendous fun and might be useful should you check what colour you need to set text atop different coloured backgrounds. Looking at it again now, though, I think the none bw might be a little borked; I'll fix it as and when I've time.

It's a bit CSS-heavy, but no worse for that, TBH; if you can do something with CSS, then it's better than firing up JS - let the browser do the work!

From the README:

  • Dragging the slider element will alter the value surfaced by the component from its value attribute (should the number in which the drop ends be greater than or equal to the constrain-min or less than or equal to the constrain-max)
  • Clicking on the numbers shown at the top of each step will alter the value surfaced by the component from its value attribute (should the number be greater than or equal to the constrain-min or less than or equal to the constrain-max).
  • Clicking on the triangles on either side of the slider will alter the value surfaced by the component from its value attribute by +/- 1 (should the number resulting from the click be greater than or equal to the constrain-min or less than or equal to the constrain-max).

As you can doubtless see, there are three ways of changing the value and - given the work I put into checking the bugger, no way for an invalid value to be produced - though you might not like the value. Having said that, if you manage to break it, please let me know, so I can fix it!

It has some default attributes so that you can test it for your use case.

Again, from the README:

  • The range of numbers, inclusive of min and max, should not be too large - tests have found that the ranges should not have more than 15 numbers, but YMMV.

One thing to consider in the future is whether or not to allow for a comma-separated list of hex values so that designers can adequately define what colours should appear on the wc-slider.

Wednesday 21 September 2022

Fixing the Donut

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 DMPieChart extends HTMLElement {
  static get observedAttributes() {
    return [
      'width',    // width of the chart
      'duration', // duration of the animation
      'delay',    // delay before the animation
      'thickness' // thickness of the slices
    ];
  }
  get style() {
    return `
      <style>
        * {
          box-sizing: border-box;
        }
        div {
          width: ${this.width}px;
          height: ${this.width}px;
          position: relative;
        }
      </style>
    `
  }
  constructor() {
    super()
    this.shadow = this.attachShadow({
      mode: 'closed'
    })
    this.shadow.innerHTML = `
      ${this.style}
      <div>
        <slot name='pie-slices'></slot>
      </div>
    `
    try {
      window.CSS.registerProperty({
        name: '--p',
        syntax: '<number>',
        inherits: true,
        initialValue: 0,
      })
    }catch (e) {
      console.warn('Browser does not support animated conical gradients')
    }
  }
  connectedCallback() {
    const segments = [...this.querySelectorAll('dm-pie-slice')]
    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
    })
  }
  get width() {
    return Number(this.getAttribute('width')) || 150      // 150px by default
  }
  get duration() {
    return Number(this.getAttribute('duration')) || 5000  // 5 seconds by default
  }
  get delay() {
    return Number(this.getAttribute('delay')) || 500      // half a second by default
  }
  get thickness() {
    return Number(this.getAttribute('thickness')) || 0.2   // 60% of width by default
  }
}

class DMPieSlice extends HTMLElement{
  static get observedAttributes() {
    return [
      'width',      // width of the chart
      'duration',   // duration of the animation
      'delay',      // delay before the animation
      'color',      // color of the arc
      'thickness',   // thickness of the arc
      'rotate'      // how far to rotate the arc
    ];
  }
  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.color} 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,#0000 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.shadow.innerHTML = `${this.style}<div/>`
  }
  get width() {
    return Number(this.getAttribute('width')) || 150      // 150px by default
  }
  get duration() {
    return Number(this.getAttribute('duration'))
  }
  get delay() {
    return Number(this.getAttribute('delay'))
  }
  get color() {
    return this.getAttribute('color') || '#000000'        // black by default
  }
  get thickness() {
    return Number(this.getAttribute('thickness'))
  }
  get rotate() {
    return Number(this.getAttribute('rotate'))
  }
  get end() {
    return Number(this.getAttribute('end'))
  }
}

window.customElements.define('dm-pie-chart', DMPieChart)
window.customElements.define('dm-pie-slice', DMPieSlice)

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.