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.

Monday, 19 September 2022

Sierpiński triangle

I was scrolling through TikTok a couple of evenings ago (I know, I'm probably far too old to do so, but now and again, there's gold!) and I came across the following video:

@flipperzer0

♬ Levels - Live Session - Sarah Coponat

Anyway, inspired to give it a go, I worked up a p5 test, and it works!

const points = [
  [-75, 130],
  [-150, 0],
  [-75, -130],
  [75, -130],
  [150, 0],
  [75, 130],
]

const rand = (int) => Math.round(Math.random() * int)

const width = 600
const height = 600


function setup() {
  createCanvas(width, height)
  noStroke()
}

function draw() {
  background(0)
  fill(255, 255, 255)
  text(points.length, 50, 50)
  points.forEach(point => {
    circle(point[0] + (width /2), point[1] + (height /2), 1)
  })
  if(points.length === 6){
    const target = points[rand(6)]
    const source = points[0]
    points.push([
        (source[0] + (2/3 * (target[0] - source[0]))),
        (source[1] + (2/3 * (target[1] - source[1])))
    ])
  }else{
    populateArray(1000, points)
  }
}

const populateArray = (count, arr) => {
  for(let i = 0; i < count; i++){
    const target = points[rand(6)]
    const source = points[points.length - 1]
    points.push([
      (source[0] + (2/3) * (target[0] - source[0])),
      (source[1] + (2/3) * (target[1] - source[1]))
    ])
  }
}

for some unknown reason, and only every so often, the pattern goes wrong; does anyone have any idea why or how to fix it?

Monday, 12 September 2022

Sorting colours is a pain!

I've written before about sorting colours, and this got me thinking about some work I'm doing in terms of converting some CSS to SCSS (with the proper use of CSS variables, I must add).

As such, I've been wading through some CSS, extracting colours, and then popping them into a :root CSS pseudo-class. I do this by looking for `#` and popping the colour into the function as and when I find them. The resulting text is well messy! I thought it couldn't be that hard to sort them similarly as employed in Google Sheets; so I came up with CSSColourSorter, which you're more than welcome to use or steal/borrow as you see fit.

It works by pasting in your whole :root directive - you'll then need to copy and paste the resulting test into your CSS/SCSS as noted before, though, sorting colours is a pain!

I've added a swatch beneath the textareas so you can see the new spectrum - mainly to check that I was doing things right!

Thursday, 14 July 2022

Merging two SQLite DBs

We've got an MVAS (well, we've got two, but only one team to manage the one) in Witchford, and I'm part of the team that manages it.

I hold ladders and carry batteries, and it's not all that hard a task, quite good fun. One chap downloads the data and lets me cast my eye over it (it's stored as in SQLite database). He went on holiday a little while back, so I had to collect the data. That meant there was an issue with the data integrity between his copy of the DB and mine. Thus, I was left with a need to merge two SQLite DBs; with the help of SQLiteStudio, it was pretty easy to resolve. Each DB has three tables: campaign, campaign_details and measure.

Thinking about it, after going through this process, I realised I just needed to copy over the entirety of my DB to him; it had the same data apart from the latest readings... but hindsight is a beautiful thing.

Anyway, there was an extra row in campaign - so that was easy to copy using some SQL; there were an additional 13 rows in campaign_details - again, dead easy to copy over using a bit of SQL. But, there were an extra 17K+ rows in measure...

I got in touch with my mate Oliwia to see if she could help, but she was busy, so I had to cobble together my SQL; this is what I came up with:

INSERT INTO 
  original.measure
SELECT 
  *
FROM 
  new.measure a 
WHERE 
  a.meas_id 
NOT IN (
  SELECT 
    meas_id
  FROM 
    original.measure b
)

Not pretty, but it worked! I blame the heat for not realising sooner

Tuesday, 5 July 2022

Donut bugs

I noted before that my Donut Component has a bug in it when it comes to drawing segments with a value over 50% of the total - such a pain!

I was hoping to avoid using SVGs for this component, but I think that is what I'll have to do - not such a bad thing, TBH, but still annoying!

Comparing JSON (gzipped or not) in localStorage with IndexedDB (using PouchDB)

I wrote last week about GZipping JSON for localStorage and, over the weekend, I decided to do some checks on the efficacy of this approach. I noted that the larger the payload, the more significant the space saving; smaller payloads often negated any saving, with the gzipped file being significantly larger than the original JSON for smaller JSON payloads.

I didn't only compare the original JSON and the gzipped JSON though, I decided to take a wee look into IndexedDB, with the help of PouchDB.

I worked with three JSON files:

  1. bodies.json (330 KB)
  2. employees.json (2284 KB)
  3. family.json (2 KB)

As you'll be able to see from the code, I do a fair bit of computation using both console.time() and performance.now().

The results are output to the developer console (I did try using a Donut Chart - but I discovered a bug in my Component - I'll fix it this week, hopefully!).

The following are my results for saving on Firefox:

    1. Time taken to save bodies using localStorage: 0ms
    2. Time taken to save bodies with Pako: 20ms
    3. Time taken to save bodies with PouchDB: 34ms
    1. Time taken to save employees using localStorage: 0.002ms
    2. Time taken to save employees with Pako: 69ms
    3. Time taken to save employees with PouchDB: 149ms
    1. Time taken to save family using localStorage: 0ms
    2. Time taken to save family with Pako: 2ms
    3. Time taken to save family with PouchDB: 157ms

And these are my results for retrieving the data on Firefox:

    1. Time taken to get bodies using localStorage: 2ms
    2. Time taken to get bodies with Pako: 10ms
    3. Time taken to get bodies with PouchDB: 45ms
    1. Time taken to get employees using localStorage: 12ms
    2. Time taken to get employees with Pako: 25ms
    3. Time taken to get employees with PouchDB: 22ms
    1. Time taken to get family using localStorage: 1ms
    2. Time taken to get family with Pako: 1ms
    3. Time taken to get family with PouchDB: 43ms

The following are my results for saving on Chrome:

    1. Time taken to save bodies using localStorage: 0ms
    2. Time taken to save bodies with Pako: 6.5ms
    3. Time taken to save bodies with PouchDB: 185.1ms
    1. Time taken to save employees using localStorage: 0ms
    2. Time taken to save employees with Pako: 54ms
    3. Time taken to save employees with PouchDB: 143.89ms
    1. Time taken to save family using localStorage: 0ms
    2. Time taken to save family with Pako: 0.4ms
    3. Time taken to save family with PouchDB: 178.5ms

And these are my results for retrieving the data on Chrome:

    1. Time taken to get bodies using localStorage: 0.9ms
    2. Time taken to get bodies with Pako: 4.29ms
    3. Time taken to get bodies with PouchDB: 39.6ms
    1. Time taken to get employees using localStorage: 5.19ms
    2. Time taken to get employees with Pako: 27.9ms
    3. Time taken to get employees with PouchDB: 19.2ms
    1. Time taken to get family using localStorage: 0ms
    2. Time taken to get family with Pako: 0.19ms
    3. Time taken to get family with PouchDB: 34.69ms

The results (in chart form) are here:

I think we can probably all tell that localStorage wins, and wins handsomely. Though the limitations of space in localStorage means that IndexedDB still has a place; perhaps not for smaller data sets though.

I'm happy that I've taken the time to find this out; it certainly means that I have evidence for future decisions - I'd be interested in analysing it further, though, especially as there are discrepancies between the different browsers. Saving to IndexedDB on Firefox seems much faster than on Chrome, though the situation is reversed when retrieving data from IndexedDB.