Sunday, 23 February 2025

Dot Spinner

Last week, I got an itch, as I often do when checking the emails sent from Pinterest. There was one pin of an excellent animation of rings comprised of dots rotating around a centre, and, on each segment of the rotation, the dot would arrive back at its starting point - that's a terrible explanation. What I mean is, should the ring be comprised of 6 dots, then for every sixth of the whole rotation, the original dots location would match the location of the following dot; a ring consisting of 12 dots would be similar - in the time that the first ring took for the original dots location to be matched by the subsequent dots location, the original dots location would be matched by the subsequent dots location. I'm not explaining it well, sorry. It inspired me to replicate it in p5js, so that's what I did, though with some measure of trepidation, as I was sure it would require some level of trigonometry.

Now, I took GCSE maths, but trigonometry has always been something I was utterly terrified of. I blame it on slide rules that my Dad had and being completely unable to get it clear in my head—I've even taken courses on Khan Academy, but it's still something I can't seem to get straight in my head (geddit?).

Anyway, I figured out how to do the math to do it eventually (after significant research) but then got stuck trying to rotate the dots: interestingly, the number of dots increased by six on each ring, so 6, 12, 18, 24, 30, 36.

After pestering Oliwia, who was stuck in a meeting, I called my Dad and explained my situation. After he started talking about trigonometry for a few minutes, I remembered why it wouldn't stick. But, in the process of explaining it, I clocked that the rotation was based on an arbitrary figure within a loop and that by incrementing that figure, I could alter the placement of the dots:

draw() {
  this.p5.fill(this.fill);
  this.p5.noStroke();
  for (
    let j = 0 + this.increment;
    j < this.p5.TWO_PI + this.increment;
    j += this.step
  ) {
    this.p5.circle(
      this.x + (Math.cos(j) * this.diameter) / 2,
      this.y + (Math.sin(j) * this.diameter) / 2,
      this.dotWidth,
    );
  }
  const incrementValue = 360 / this.number / 3000;
  this.increment =
    this.increment + incrementValue >= this.step
      ? 0
      : this.increment + incrementValue;
}

After clocking that, I realised I didn't need to keep on incrementing the increment but could set it to zero once it had reached the step value, which was derived from:

this.step = p5.TWO_PI / this.number;

This means that each time the subsequent dot reached the position of the original dot, the animation could be reset! Instead of each ring rotating for the entire three hundred and sixty degrees, the inner ring rotated sixty degrees and returned to its original position. The next outer ring rotated twenty-one and two-thirds degrees, and so on. Neat eh? The original and my animation are here and here.

Friday, 14 February 2025

Sort HTML table only on child columns

I was recently asked to help sort table columns, but there was a specific use case. The original data was a mixture of arrays and objects; some of the object keys represented the text to display in the table cell, some of the arrays represented child elements, and some object keys would become further text within cells, meaning that the elements at the top of the JSON tree would be repeated an arbitrary number of times. The use case was that if a column header representing a child element should be clicked, only the children would be sorted, and the parents would remain in the same order.

After some head-scratching, I concluded that the table had to effectively be chunked into blocks identified by the ancestors of the currently selected column. These chunks could then be sorted, and after discarding the idea of sorting them in place, I realised I'd need to empty the table and place them all back in order - thankfully, good HTML practice meant that I had a table header and a table body to play with (do you hate it as much as I when people neglect to use a thead element?).

That explanation seems relatively straightforward, but it wasn't—it took an age of thinking and ensuring things worked as expected.

The following is the relevant function:

const headers = Array.from(thead.querySelectorAll("th"))

headers.forEach((header, index) => {
  header.addEventListener("click", () => {
    const rows = Array.from(tbody.querySelectorAll("tr"))
    const groupedRows = rows.reduce((acc, row) => {
      const cells = Array.from(row.querySelectorAll("td"))
      const key = cells.slice(0, index).map(cell => cell.textContent).join("|")
      if(!acc[key]) {
        acc[key] = []
      }
      acc[key].push(row)
      return acc
    }, {})
    Object.keys(groupedRows).forEach(key => {
      if(header.classList.contains("asc")) {
        groupedRows[key].sort((a, b) => {
          return a.children[index].textContent.localeCompare(b.children[index].textContent)
        })
      } else {
        groupedRows[key].sort((a, b) => {
          return b.children[index].textContent.localeCompare(a.children[index].textContent)
        })
      }
    })
    tbody.innerHTML = ""
    Object.keys(groupedRows).forEach(key => {
      groupedRows[key].forEach(row => {
        tbody.appendChild(row)
      })
    })
    headers.forEach(h => {
      if(h !== header) {
        h.classList.remove("asc")
        h.classList.remove("desc")
      }
    })
    if (header.classList.contains("asc")) {
      header.classList.remove("asc")
      header.classList.add("desc")
    } else {
      header.classList.remove("desc")
      header.classList.add("asc")
    }
  })
})

Here is a working example with data that bears no responsibility for that in the example data provided: https://replit.com/@annoyingmouse/Sort-only-on-child-columns?v=1

Monday, 20 January 2025

wc-trim-middle

I've lost count of the number of times I've needed to truncate text online, and I've tried all sorts of mechanisms, so when I came across Christian Heilmann's trimMiddle() function, I was happy as Larry.

So happy that I just had to convert it into a web component. After reading about how problematic people are finding styling web components from outside, I also decided to create it without a shadow DOM, and I'm pleased with the result.

There's a handy demo, and I have to say that getting it to respect changes to the inner text dynamically was an utter PITA! Have a play and see if it fits your requirements.

Friday, 15 November 2024

Container queries and column layouts.

We've recently introduced the possibility of new starters borrowing some of our old kit. A couple of factors have prompted this: we've found that beginners prefer using wooden takedown bows rather than the fancy aluminium ILF kit we purchased a few years - we don't know why; the feel of wood is nicer I guess, and the weight of the bow is lighter (people seem less inclined to lift heavy weights after the pandemic) - the other reason is that we can't be bothered popping stuff onto eBay and figuring out postage and all that stuff.

This means we need a way of recording our kit and who has it, so rather than killing another spreadsheet, I've added it to our web application. Using React Bootstrap, we have cards available, so each kit has a card, sometimes with images. But displaying the details of the kit is awkward. Adding and editing are easy, as I'm a fan of simple forms, but showing it is less straightforward and likely to take up a lot of space, so I thought about using CSS columns; so far, so good!

But it's responsive, so we need to alter the number of columns depending on the screen's width—which isn't helped by my adding a sidebar on broader screens. When the screen hit a specific width and the menu switched to the side, we were left with a hinterland situation when I used media queries on the whole document. What was once extensive in the main content ended up shrinking, thus affecting the columns' layout: I needed container queries, something I read about a little while ago and bookmarked.

After reading the article by Josh W Comeau (A Friendly Introduction to Container Queries) I knew how to do it too:

.responsive_multi_columns {
  container-type: inline-size;
  .columns {
    column-count: 1;
    column-gap: 0;
    dd {
      break-before: avoid-column;
    }
    @container (min-width: 36rem) { // 576px (sm)
      column-count: 2;
      column-gap: .25rem;
    }
    @container (min-width: 48rem) { // 768px (md)
      column-count: 3;
      column-gap: .5rem;
    }
    @container (min-width: 60rem) { // 960px (lg)
      column-count: 4;
      column-gap: 1rem;
    }
    @container (min-width: 75rem) { // 1200px (xl)
      column-count: 5;
      column-gap: 2rem;
    }
    @container (min-width: 87.5rem) { // 1400px (xxl)
      column-count: 6;
      column-gap: 2rem;
    }
  }
}

What was annoying, though, was the tendency for the definition list to break at what I found to be wrong places; this was solved by adding break-before: avoid-column; to the dd element - that worked a treat!

Saturday, 2 November 2024

Halloween 2024

I spent a happy few hours working on these challenges, these are my solutions:

function createMagicPotion(potions, target) {
  const c = potions.reduce((acc, _, index) => {
    for (let i = index + 1; i < potions.length; i++) {
      if (potions[index] + potions[i] === target) {
        acc.push([index, i]);
      }
    }
    return acc;
  }, []);
  return c[0].length === 1
    ? c[0]
    : c[0].length > 1
      ? c.sort((a, b) => a[1] - b[1])[0]
      : undefined;
}
console.log(createMagicPotion([4, 5, 6, 2], 8));
console.log(createMagicPotion([1, 2, 3, 4], 9));
console.log(createMagicPotion([1, 2, 3, 4], 5));

function battleHorde(zombies, humans) {
  const iteration = Math.max(zombies.length, humans.length);
  const z = zombies.split("").map((e) => Number(e));
  const h = humans.split("").map((e) => Number(e));
  for (let i = 0; i < iteration; i++) {
    if (i + 1 < iteration) {
      console.log(`zombie ${z[i]} vs human ${h[i]}`);
      console.log(`start: humans ${h.join("")}, zombies ${z.join("")}`);
      if (z[i] === h[i]) {
        console.log("tie");
      } else {
        if (z[i] > h[i]) {
          console.log(`zombie wins (${z[i] - h[i]})`);
          z[i + 1] = z[i + 1] + (z[i] - h[i]);
        } else {
          console.log(`human wins (${h[i] - z[i]})`);
          h[i + 1] = h[i + 1] + (h[i] - z[i]);
        }
      }
      console.log(`end: humans ${h.join("")}, zombies ${z.join("")}`);
    } else {
      if (z[i] > h[i]) {
        return `${z[i] - h[i]}z`;
      } else if (z[i] < h[i]) {
        return `${h[i] - z[i]}h`;
      } else {
        return "x";
      }
    }
  }
}
console.log(battleHorde("242", "334"));
console.log(battleHorde("444", "282"));

function findSafestPath(dream) {
  function findPaths(arr, path, i, j, paths) {
    if (i === M - 1 && j === N - 1) {
      path.push(arr[i][j]);
      paths.push(path.reduce((a, c) => a + c, 0));
      path.pop();
      return;
    }
    if (i < 0 || i >= M || j < 0 || j >= N) {
      return;
    }
    path.push(arr[i][j]);
    if (j + 1 < N) {
      findPaths(arr, path, i, j + 1, paths);
    }
    if (i + 1 < M) {
      findPaths(arr, path, i + 1, j, paths);
    }
    path.pop();
  }
  const arr = JSON.parse(JSON.stringify(dream));
  const path = [];
  const paths = [];
  let i = 0,
    j = 0;
  const M = arr.length;
  const N = arr[0].length;
  findPaths(arr, path, i, j, paths);
  return Math.min(...paths);
}
console.log(
  findSafestPath([
    [1, 3, 1],
    [1, 5, 1],
    [4, 2, 1],
  ]),
);

function findTheKiller(whisper, suspects) {
  const regex = new RegExp(
    whisper.substring(whisper.length - 1) === "$"
      ? whisper.replaceAll("~", ".")
      : `${whisper.replaceAll("~", ".")}.*`,
    "i",
  );
  return suspects
    .filter((suspect) => regex.exec(suspect)?.[0] === suspect)
    .join(",");
}
console.log(
  findTheKiller("d~~~~~a", [
    "Dracula",
    "Freddy Krueger",
    "Jason Voorhees",
    "Michael Myers",
  ]),
);
console.log(findTheKiller("~r~dd~", ["Freddy", "Freddier", "Fredderic"]));
console.log(findTheKiller("~r~dd$", ["Freddy", "Freddier", "Fredderic"]));
console.log(findTheKiller("mi~~def", ["Midudev", "Midu", "Madeval"]));

function escapePyramidHead(room) {
  const rows = room.length;
  const cols = room[0].length;

  // Find start (▲) and end (T) positions
  let start, end;
  for (let y = 0; y < rows; y++) {
    for (let x = 0; x < cols; x++) {
      if (room[y][x] === "") {
        start = [x, y];
      } else if (room[y][x] === "T") {
        end = [x, y];
      }
    }
  }

  // If either start or end not found
  if (!start || !end) {
    return -1;
  }

  // Possible moves: up, right, down, left
  const directions = [
    [-1, 0], // left
    [0, 1], // down
    [1, 0], // right
    [0, -1], // up
  ];

  // Helper function to check if a point is valid
  function isValidPoint(row, col) {
    return row >= 0 && row < rows && col >= 0 && col < cols;
  }

  // Initialize visited array and queue for BFS
  const visited = Array(rows)
    .fill()
    .map(() => Array(cols).fill(false));
  const queue = [];

  // Start BFS
  queue.push({ y: start[1], x: start[0], distance: 0 });
  visited[start[1]][start[0]] = true;

  while (queue.length > 0) {
    const current = queue.shift();

    // Check if we reached the end point
    if (current.y === end[1] && current.x === end[0]) {
      return current.distance;
    }

    // Try all possible directions
    for (const [dx, dy] of directions) {
      const newY = current.y + dy;
      const newX = current.x + dx;

      // Check if the new position is valid and not visited
      if (
        isValidPoint(newY, newX) &&
        !visited[newY][newX] &&
        room[newY][newX] !== "#"
      ) {
        visited[newY][newX] = true;
        queue.push({
          y: newY,
          x: newX,
          distance: current.distance + 1,
        });
      }
    }
  }

  // If we get here, no path was found
  return -1;
}

console.log(
  escapePyramidHead([
    [".", ".", "#", ".", ""],
    ["#", ".", "#", ".", "#"],
    [".", ".", ".", ".", "."],
    ["#", "#", "#", ".", "#"],
    ["T", ".", ".", ".", "."],
  ]),
);
console.log(
  escapePyramidHead([
    ["", ".", "#", "."],
    [".", ".", ".", "."],
    ["T", ".", ".", "#"],
    [".", "#", "#", "#"],
  ]),
);
console.log(
  escapePyramidHead([
    ["#", "#", "#"],
    ["", ".", "#"],
    [".", "#", "T"],
  ]),
);