Tuesday 28 June 2022

Cross the floor, please!

Quite frankly, we're in a pretty terrible situation in this country, and I've been trying to think of how we could get out of it. Last week I got to thinking about how to remove our MP (Lucy Frazer); I've written to her a couple of times in the past and always received a reply - she even sent a follow up when it came to Ukraine, so I sort of have semi-positive vibes about her (despite her political party). After reading up though, it seems as though there's no way I, as a constituent, can remove her, despite being the one to pay her wages; the PM can, but not I as someone who she's is supposed to represent. The salient parts of the Recall of MPs Act 2015:

...the Speaker of the House of Commons would trigger the recall process, namely:

  • A custodial prison sentence (including a suspended sentence)
    • Note that MPs imprisoned with sentences greater than one year are automatically removed due to the Representation of the People Act 1981
  • Suspension from the House of at least 10 sitting days or 14 calendar days, following a report by the Committee on Standards;
  • A conviction for providing false or misleading expenses claims.

As I noted, though, I don't hate her, I wouldn't say I like her policies and her support for our disaster of a PM, but she always struck me as being reasonable; misguided perhaps, but generally pretty decent. But things are getting serious now.

So, we can't remove her unless she seriously messes up according to the Recall of MPs Act 2015, but what else would trigger a by-election?

According to parliment:

A by-election is held when a seat becomes vacant. This can happen when an MP:

  • resigns or dies
  • is declared bankrupt
  • takes a seat in the House of Lords
  • is convicted of a serious criminal offence.

A by-election does not have to take place if an MP changes political party.

Let's take those one-by-one - I don't want her dead, and I can't see her resigning soon. I can't see her being short of a few bob. I can't see her moving into the Lords or being convicted of a serious criminal offence. It's that there last sentence, though. I'm pretty sure the Liberal Democrats would welcome her - and in light of the probably local voting pattern in any future election - it's likely to be the only way she can retain her seat in the Commons. And what's more, no by-election would be triggered either.

Now, who's shell-like should I whisper this to?

Why leave it at that - the current working majority is 75 - we'd only need a few Conservatives to either see the writing on the wall - or grow a conscience - before we could oust our PM. Christian Wakeford did it and 148 have no confidence in Johnson, if only a third of those voted with their feet and joined another party, then we might be on the start of recovering.

Might it be time for our MPs to stand up for their constituents rather than bolstering the reign of lying Johnson (I do like Rory Stewart - the Tories messed up by not electing him!)?

Friday 24 June 2022

GZipping JSON for localStorage

I've written before about trimming JSON before but I've recently been thinking about how we can trim it even further.

I came across the fantastic pako earlier in the week and decided to try it out with some data. You'll see from the repo that we're testing two different JSON files, one was quite small (and was, indeed, the example I used earlier), the other is significantly larger.

If you download and run the repo, you'll see I've added a check to see if the data retrieved from localStorage is the same as the data retrieved from the file system - it is!

You'll also notice something interesting in the developer console: the smaller JSON ends up being larger (119.93%) when gzipped compared to the stringified JSON; the larger JSON file was 37.49% the size of the stringified JSON.

That sort of makes sense, though, doesn't it? Gzipping a file adds some overhead (hash table), and that overhead might end up making the final gzipped file larger. Not that I know a great deal about the process: I'm cribbing this from an answer on Quora.

The compressAndStore process is quite interesting, with these steps:

  1. The JSON is fetched form a .json file as a string
  2. The fetched string is ocnverted into a JSON object
  3. The JSON is stringified (IKR - we've only just converted into an object)
  4. The stringified JSON is defalted with pako
  5. The resulting Uint8Array is converted into a regular array
  6. The array is saved to localStorage, along with the original stringified JSON
async function compressAndStore(file, name) {
  // Get our data
  const response = await fetch(file)
  const fetchedJSON = await response.json()
  // Convert our JSON to a string
  const stingifiedJSON = JSON.stringify(fetchedJSON)
  // Deflate our data with Pako
  const deflatedStringifiedJSON = pako.deflate(stingifiedJSON)
  // Convert the resulting Uint8Array into a regular array
  const regularArray = Array.from(deflatedStringifiedJSON)
  // Store our data (both deflated and the original)
  localStorage.setItem(`${name}Array`, JSON.stringify(regularArray))
  localStorage.setItem(`${name}JSON`, stingifiedJSON)
}

The retrieveAndDecompress process is almost a direct reverse:

  1. The array is retrieved from localStorage as a string
  2. That string is converted back into an array
  3. The array is converted into a Uint8Array array
  4. The Uint8Array is inflated with pako
  5. The inflated Uint8Array is decoded and then converted back into a JSON object.
  6. The original file is compared with the retrieved and decompressed file
async function retrieveAndDecompress(file, name) {
  // Get our data for later testing
  const response = await fetch(file)
  const fetchedJSON = await response.json()
  // Get our data from localStorage
  const retrievedData = localStorage.getItem(`${name}Array`)
  // Convert it into an array again using JSON.parse()
  const retrievedArray = JSON.parse(retrievedData);
  // Convert the array back into a Uint8Array array
  const retrievedTypedArray = new Uint8Array(retrievedArray);
  // inflate the Uint8Array array using Pako
  const deflatedTypedArray = pako.inflate(retrievedTypedArray)
  // convert it back into the original data
  const json = JSON.parse(new TextDecoder().decode(deflatedTypedArray))
  console.info(`Is the fetched ${file} the same as the retrieve and decompressed ${name}Array: ${JSON.stringify(fetchedJSON) === JSON.stringify(json)}`)
  const regularArraySize = (localStorage[`${name}Array`].length * 2) / 1024
  const stingifiedJSONSize = (localStorage[`${name}JSON`].length * 2) / 1024
  console.log(`${name}Array (${regularArraySize}) is ${((regularArraySize / stingifiedJSONSize) * 100).toFixed(2)}% of the size of ${name}JSON ${stingifiedJSONSize}`)
}

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.