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"],
  ]),
);

Saturday, 26 October 2024

React and Markdown: Better to intercept or replace anchor elements in markdown?

We're setting up an Archery Wiki on our site and using Firebase as our back end. Adding wikis is simple enough, as we can use a simple markdown editor, and displaying them is easy enough using the marvellous react-markdown package. However, navigation triggered a whole page refresh, which was less than ideal.

I thought there must be a way of intercepting anchor element clicks in React, but after searching for a while, I realised that intercepting clicks on all links was less than ideal anyway. Wouldn't it be better to listen to internal links, replace them with pseudo anchor elements, and trigger React's navigation instead?

So that's what I did!

This is the relevant component in context:

<ReactMarkdown
  components={{
    a: (props) => {
      return props.href.startsWith(
        "https://witchfordarchers.club/wiki/",
      ) ? (
        <span
          style={{
            cursor: "pointer",
            textDecoration: "underline",
          }}
          title={props.href.replace(
            "https://witchfordarchers.club/wiki/",
            "",
          )}
          onClick={handleNavigation.bind(
            this,
            props.href.replace("https://witchfordarchers.club/wiki/", ""),
          )}
        >
          {props.children}
        </span>
      ) : (
        <a href={props.href}>{props.children}</a> // All other links
      );
    },
  }}
>
  {wiki.content}
</ReactMarkdown>

Of course, it requires a handleNavigation handler being passed down, but that's dead simple to add to the calling page.

This is an example of the code working, and this is the implementation on Witchford Archers.

Saturday, 19 October 2024

HEDGE CLOCK

Ages ago I wrote a simple clock, and recently I came across some lovely images by TransientCode, the combination of these factors led me to create a simple clock using the images; this is the result:

If you follow the source then you might notice that it makes noises each time the numerals change - each numeral has it's own sound - but this can become annoying! It won't make any sound until you click on the page though.

Saturday, 31 August 2024

AskJS Toy problem

There was a post on Reddit last week that got me sort of excited (I probably need to get out more).

It's been removed now, but my RSS feed still has it:

Write a function that takes a string representing open hours and returns an object where the keys are days of the week and the values are the corresponding open hours. Missing days should still be represented as a key but with an empty string value.

Examples:

"Mon-Sun 11:00 am - 10 pm" would return:

{
  Mon: "11:00 am - 10:00 pm", 
  Tues: "11:00 am - 10:00 pm", 
  Wed: "11:00 am - 10:00 pm", 
  Thu: "11:00 am - 10:00 pm", 
  Fri: "11:00 am - 10:00 pm", 
  Sat: "11:00 am - 10:00 pm", 
  Sun: "11:00 am - 10:00 pm"
}

"Mon-Thu, Sat 11 am - 9 pm / Fri 5 pm - 9 pm" would return:

{
  Mon: "11:00 am - 9:00 pm",
  Tues: "11:00 am - 9:00 pm",
  Wed: "11:00 am - 9:00 pm",
  Thu: "11:00 am - 9:00 pm",
  Fri: "5:00 pm - 9:00 pm",
  Sat: 11:00 am - 9:00 pm",
  Sun: ""
}

Additional test cases:

  • "Mon-Sun 11 am - 12 am"
  • "Mon-Fri, Sat 11 am - 12 pm / Sun 11 am - 10 pm"
  • "Mon-Thu, Sun 11:30 am - 10 pm / Fri-Sat 11:30 am - 11 pm"
  • "Mon-Thu 11 am - 11 pm / Fri-Sat 11 am - 12:30 am / Sun 10 am - 11 pm"

This is what I came up with:

/*
 * Names regex groups are really handy! Remember to end a regex with a ";"!
 */
const timeRegex = /(?<sHour>\d{1,2})(\:(?<sMinute>\d{1,2}))?\s(?<sAmPm>am|pm)\s\-\s(?<eHour>\d{1,2})(\:(?<eMinute>\d{1,2}))?\s(?<eAmPm>am|pm)/;
const daysRegex = /\b([a-zA-Z]{3,4}\-?)\b/g;
/*
 * Our tests:
 */
const tests = [
  "Mon-Sun 11:00 am - 10 pm",
  "Mon-Sun 11 am - 12 am",
  "Mon-Thu, Sat 11 am - 9 pm / Fri 5 pm - 9 pm",
  "Mon-Fri, Sat 11 am - 12 pm / Sun 11 am - 10 pm",
  "Mon-Thu, Sun 11:30 am - 10 pm / Fri-Sat 11:30 am - 11 pm",
  "Mon-Thu 11 am - 11 pm / Fri-Sat 11 am - 12:30 am / Sun 10 am - 11 pm"
]
/*
 * Our original object
 */
const daysObj = { "Mon": "", "Tues": "", "Wed": "", "Thu": "", "Fri": "", "Sat": "", "Sun": "" }
/*
 * Our array of days from the object above
 */
const days = Object.keys(daysObj)
/*
 * Our function to format a time
 */
const formatTime = (hour, minute, AmPm) => `${hour}:${minute} ${AmPm}`
/*
 * Our function to format a time duration
 */
const generateTime = (sHour, sMinute, sAmPm, eHour, eMinute, eAmPm) => {
  const sTime = formatTime(sHour, sMinute, sAmPm)
  const eTime = formatTime(eHour, eMinute, eAmPm)
  return `${sTime} - ${eTime}`
}
/*
 * Iterate over the tests
 */
for (const test of tests) {
  /*
   * Start with a fresh version of the days object by copying the one from above.
   */
  const returnObj = Object.assign({}, daysObj)
  /*
   * First we need to chunks of the string seperated by " / "
   * We'll then iterate over each chunk.
   */
  for (chunk of test.split(" / ")) {
    /*
     * We'll then get all the days from the chunk.
     */
    const daysResult = chunk.match(daysRegex)
    /*
     * We'll then destructure the groups from the regex match. Named regex groups are really helpful for this as we
     * we can then access the values by name from the groups object, and have default values for those that might be 
     * undefined.
     */
    const { sHour, sMinute = '00', sAmPm, eHour, eMinute = '00', eAmPm } = timeRegex.exec(chunk.trim()).groups
    /*
     * We'll then generate the time from the values we've extracted from the regex match.
     */
    const timeString = generateTime(sHour, sMinute, sAmPm, eHour, eMinute, eAmPm)
    /*
     * We'll then iterate over the days we've extracted from the chunk.
     */
    for (let i = 0; i < daysResult.length; i++) {
      /*
       * If the last character of the day is a hyphen, we know we need to iterate over the days array we created above.
       */
      if (daysResult[i].charAt(daysResult[i].length - 1) === "-") {
        /*
         * We'll then iterate over the days array we created above and populate it with the time string we've generated.
         */
        for (let l = days.findIndex(e => e === daysResult[i].slice(0, -1)); l < days.length; l++) {
          if (days[l] !== daysResult[i + 1]) {
            returnObj[days[l]] = timeString
          } else {
            /*
             * If the current day isn't the same as the next day in the array, we'll break.
             */
            break;
          }
        }
      } else {
        /*
         * If the last character of the day is not a hyphen, we can just set taht day with the time string we've 
         * generated.
         */
        returnObj[daysResult[i]] = timeString
      }
    }
  }
  /*
   * Display the result alng with the test
   */
  console.log(test, JSON.stringify(returnObj, null, 2))
}

That was fun!

Notes:

Be aware that there's no error checking... because that wasn't in the requirements, though it should be easy enough to add. For instance, we need to consider:

  • In a range of days does the second have an index before the first?
  • Is the start time after the end time?
  • What should happen with duplicate days, such that an date range is then partially or totally replicated within another range, or a single day exists within a previous range?