Pixl: Color Palette & Enhanced UI [part 2]
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.
- More colors.
- 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.