full-stack overflow

08 Jan 2018

Pixl: Color Palette & Enhanced UI [part 2]

Gif of mouse drag drawing a red smiley face using pixl

Let’s make things more interesting

In part one of our tutorial, we got a pretty good start. We have a grid that refreshes gracefully based on a data array, and the user can toggle a single colors on and off for each pixel. Nice.

But in order to let the user create a wider variety of art, we need two new features.

  1. More colors.
  2. Mouse-dragging: the user can hold down the cursor, drag the mouse, and paint multiple squares.

1. Add More Colors!

<div id="palette">
  <button id="randomPalette">rando</button>
  <button id="reset">reset</button>
  <input class="jscolor" id="swatch">
</div>

We’ll add a div to the top of our editor that will hold “color chips”, as well as buttons to generate a random palette and reset the current one.

We’re also introducing a tiny dependency called jscolor. The <input type="color"> tag has poor cross-browser support. We want to enable the user to select whatever colors they choose, so we’re pulling in a simple colorpicker element.

I’m going to use a library called tinycolor to generate random palettes of related colors.

Here’s how it’ll work.

function generatePalette(startColor = "goldenrod") {
  let lastC = tinycolor(startColor);
  let analogues = [];
  let temp = lastC.analogous();
  analogues.push(temp[temp.length - 1]);
  for (var i = 0; i < 10; i++) {
    if (i % 2 == 0) {
      temp = analogues[i].analogous();
    } else {
      temp = analogues[i].tetrad();
    }
    analogues.push(temp[temp.length - 1].spin(Math.random() * 20));
  }
  let res = analogues.map((e) => e.toHexString());
  return res;
}
div#rainbow {
  display: flex;
  width: 100%;
  justify-content: space-evenly;
}
div#outputColors {
  font-size: 1em;
  width: 80%;
}
const randomRGBA = () =>
  `rgba(${new Array(3)
    .fill()
    .map((e) => Math.floor(Math.random() * 255))
    .concat(1.0)
    .join(",")})`;

var startingColor = "goldenrod";

function randomize() {
  const rainbow = document.getElementById("rainbow");
  let lastC = tinycolor(startingColor);
  let analogues = [];
  let temp = lastC.analogous();
  analogues.push(temp[temp.length - 1]);
  for (var i = 0; i < 10; i++) {
    if (i % 2 == 0) {
      temp = analogues[i].analogous();
    } else {
      temp = analogues[i].tetrad();
    }
    analogues.push(temp[temp.length - 1].spin(Math.random() * 20));
  }
  let res = analogues.map((e) => e.toHexString());
  rainbow.innerHTML = null;
  res.forEach((c) => {
    let d = document.createElement("div");
    d.style.width = 20 + "px";
    d.style.height = 20 + "px";
    d.style.borderRadius = "5%";
    d.style.backgroundColor = c;
    d.display = "inline-block";
    rainbow.appendChild(d);
  });
  document.getElementById("outputColors").textContent = JSON.stringify(res);
}

window.onload = function () {
  randomize("goldenrod");
  document.getElementById("randomDemo").addEventListener("click", randomize);
  document.getElementById("randomStart").addEventListener("click", () => {
    startingColor = randomRGBA();
    randomize();
  });
};

See how this works? I’m using the analogues, tetrad, and spin methods of tinycolor. I take a starting color. I then want to generate ten colors (analogues) that are visually related. If the index is even, I push the last analog generated by tinycolor into the array. If the index is odd, I push the last of the tetrads from that color into the array. Then I spin the hue by a random amount within 20 degrees to add a touch more variation. There’s no algorithm behind this. I just played around with the color transformation functions until I got something that looked nice.

Try playing with the buttons. “Randomize start color” is what the user can select to refresh the entire palette. “Randomize relative” leaves the start color intact but randomizes the remaining colors in a (IMO) visually pleasing way.

Once we generate this array of random colors, all that’s left to do is to display those colors to the user, allow the user to select from them, and update the currentColor of our editor to the selected color. Pretty simple, right?

Coding the palette

We need a function to populate a random palette. Each color will have its own color “chip”, a small div with its background color equal to the palette color.

function populatePalette() {
  let pals = generatePalette();
  const P = document.getElementById("palette");
  pals.forEach((c, idx) => {
    let chip = document.createElement("div");
    chip.classList.add("chip");
    chip.id = "c_" + idx;
    chip.dataset.hex = c;
    chip.style.backgroundColor = c;
    P.appendChild(chip);
  });
}

We’re using the dataset (Docs) property here to store the hex color code for each div. This allows us to set and access custom data-* attributes on our HTML elements.

We also need a function to call when the user clicks the random palette button. Since we’ve already called populatePalette on canvas initialization, we don’t need to create all those divs again. Instead, we can just re-select them and update their values with the results of generatePalette.

We write a one-liner to generate a randomRGBA (well, with opacity 1.0) using ES6 template literals.

const randomRGBA = () =>
  `rgba(${new Array(3)
    .fill()
    .map((e) => Math.floor(Math.random() * 255))
    .concat(1.0)
    .join(",")})`;

function randomizePalette() {
  let rColor = randomRGBA();
  let randos = generatePalette(rColor);
  const P = document.querySelectorAll("div.chip");
  P.forEach((c, idx) => {
    c.dataset.hex = randos[idx];
    c.style.backgroundColor = randos[idx];
    c.classList.remove("activeColor");
  });
  setColor(document.getElementById("c_0")); // set default color;
}

Listen my code, and ye shall hear!

All we have to do to wire up the palette is set listeners on all the color chips, the random palette button, and the reset button for good measure.

document
  .querySelector("button#randomPalette")
  .addEventListener("click", randomizePalette);
document.querySelector("button#reset").addEventListener("click", resetCanvas);
document.getElementById("palette").addEventListener("click", switchColor);
document.getElementById("swatch").addEventListener("change", changeColorSwatch);

We add a change event listener to the color picker element. (Docs) gets fired when an element’s value changes: in this case, when the user has chosen a color.

function changeColorSwatch(e) {
  currentColor = "#" + e.target.value;
  currentColorDiv.classList.remove("activeColor");
}

And now the handler for switching colors. We define a currentColorDiv so that when the colors change, we can remove the .activeColor CSS class, and we add the .activeColor class to the currently selected color.

function setColor(div) {
  currentColor = div.dataset.hex;
  currentColorDiv = div;
  div.classList.add("activeColor");
}

In switchColor we use Event Delegation to determine which chip is selected. We add a single event listener on the color chip container and then filter events on that container based on the class of the element clicked. We know it’s a click on the color chip (and not the container itself) if e.target.classList contains our class of interest, .chip. The classList property has a handy method contains that returns a boolean based on whether or not the supplied class string is present in the element’s class list.

If the event is a click on a color, we ensure that it’s not a click on the current color by checking the classList for .activeColor.

function switchColor(e) {
  let cL = e.target.classList;
  if (!cL.contains("chip") || cL.contains("activeColor")) {
    return false;
  }
  currentColorDiv.classList.remove("activeColor");
  setColor(e.target);
}

Finally, we add one line to our initEditor function that calls setColor onload to set the default color to the first swatch:

setColor(document.getElementById("c_0")); // set default color;

2. Mouse Dragging

We’ve talked before about the mousemove event and the need to “debounce” or not respond to every single event that this listener emits. We also need to distinguish between the user clicking a square on and off and the user holding down the mouse and dragging across multiple squares.

In the first case, we want to erase a square if the user clicks in a square with the same color as the current color. When dragging, we do not want to have this behavior.

We’ll set some flags, as well as an amount of time after which we consider the user to be clicking-and-dragging instead of just clicking. We set it to 50ms.

let mouseDown = false;
let mouseDownAt = null;
let clickAndDrag = true;
let DRAG_DELAY_MS = 50; // ms before mouse "click-and-drag" event is handled

There are three phases to a mouse click-and-drag event, so we add a listener to the canvas for all three events: mousemove, mousedown, and mouseup.

canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("mousedown", handleMouseDown);
canvas.addEventListener("mouseup", handleMouseUp);

function handleMouseDown(e) {
  mouseDown = true;
  mouseDownAt = Date.now();
}

When the mouse is clicked, we set mouseDown and record the time at which the click occurred.

function handleMouseUp(e) {
  if (clickAndDrag) {
    let X = e.offsetX;
    let Y = e.offsetY;
    if (X >= WIDTH || X <= 0 || Y >= HEIGHT || Y <= 0) {
      return false;
    }
    setData(coordsToIdx(X, Y), currentColor);
  }
  mouseDown = false;
  mouseDownAt = null;
  clickAndDrag = false;
}

When the mouse is released, we unset mouseDown and reset the time at which the original mouseDown event occurred. If the user was clicking and dragging during the mouse event (clickAndDrag===true), we record the final square that the mouse was over when the button was released. If we did not do this, clicking and dragging would always leave an empty square wherever the mouse was when the button was released on the end of the drag.

As always, we validate the coordinates of the drag so that there is no spillover.

Finally, we define a handler for mouseMove. If the mouse is not down, we ignore the event entirely. If the mouse is down, we determine how long it has been held for. If it has been held longer than 50ms, we set clickAndDrag to true, validate the mouse coordinates, and call setData to color in the squares the mouse drags over while held.

function handleMouseMove(e) {
  if (!mouseDown) {
    return false;
  }
  if (Date.now() - mouseDownAt > DRAG_DELAY_MS) {
    let X = e.offsetX;
    let Y = e.offsetY;
    clickAndDrag = true;
    if (X >= WIDTH || X <= 0 || Y >= HEIGHT || Y <= 0) {
      return false;
    } else {
      setData(coordsToIdx(X, Y), currentColor);
    }
  }
}

What about setData though: won’t there be multiple calls there? Good thinking. We refactor our setData function slightly to include the new clickAndDrag functionality. Now it looks like this:

function setData(idx, color) {
  if (dirtyIndices.includes(idx)) {
    return false;
  }
  let currentColor = canvasData[idx];
  if (!clickAndDrag) {
    if (color !== currentColor) {
      canvasData[idx] = color;
    } else {
      canvasData[idx] = DEFAULT_COLOR;
    }
  } else {
    canvasData[idx] = color;
  }
  dirtyIndices.push(idx);
}

What’s the big difference? Here, we add a flag to filter out clickAndDrag events. If the user is clicking and dragging, we ignore the code that erases squares when they are clicked with the same color. If we didn’t do this, the square would be toggled on and off a heck of a lot of times per second: every time that mousemove fired, in fact. Finally, we don’t need to worry about the data array being needlessly toggled back and forth, because once we call setData prior to the square being drawn, the index is added to our dirtyIndices array.

setData ignores calls to make changes to the dirtyIndices array, as we discussed, until the dirtyIndices have been redrawn and the array emptied with the next tick of requestAnimationFrame.

Step back & take a deep breath

Whew. That is a lot of code. But look at this cool thing that we’ve made! There are only a few steps left to turning this into a really sweet pixel art generator.

In the next tutorial, we’ll look at how to extract data into a .png from the canvas once an artwork has been drawn, scale the image to selected sizes, apply transparency or a background color, and display these finished product canvases in a tray so the user can save their masterpieces.