Pixel Art With Alpine.js
A month ago, I looked at rendering barcodes with ZXing in ColdFusion. Barcodes, in the ZXing Java library, are represented as an array of pixels, each of which can then be rendered graphically by using RGBA colors that are, themselves, each represented as a 4-byte int. This experience got me thinking about "pixel art"; and, about how I might be able to represent a pixel art canvas as a series of colors encoded as bytes. And so, I started to play around with a little pixel art proof-of-concept written in Alpine.js.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
After the ZXing library, I became little fixated on this idea of encoding data into bytes. My first attempt was to use the Uint8Array
typed-array in JavaScript to encode each pixel as a sequence of three bytes:
- X coordinate.
- Y coordinate.
- Named color index.
The X/Y coordinates where the column and row within the pixel art canvas. Then the "color index" was going to be an arbitrary key that I associated with one of the named HTML Colors (courtesy of htmlcolorcodes.com).
Then, I was going to take this Uint8Array
and encode it as a Base64 string by using the btoa()
(Binary to ASCII) function, which would then be used to populate the URL fragment / hash.
I was doing all of this in hopes of creating a short URL representation of the pixel art image. But, once I got it working, the URLs became very long, very quickly. Even with a few colored-in pixels on the canvas, I was surprised at how long the URLs became.
So, I started over from scratch with a new approach: instead of representing each individual pixel, what if I represented "runs" of pixels? Consider a horizontal line on the canvas. It could be thought of as 25 individual pixels; or, it could be thought of as a single color repeated 25 times. This "run"-based representation seemed like it might be able to create a more condensed representation of the overall image.
The URL scheme I came up with was a comma-delimited list of integers:
- The foreground color key.
- The background color key.
- ...
N
-number of "pixel runs".
Each "pixel run" was a colon-limited tuple:
- The foreground color key.
- The number of pixels in the run (optional)
The number of pixels in the run was optional; and only needed if the run was greater than 1 pixel. In other words, the "1" was implicit if not provided.
Then, to further shrink the URL, I encoded each of these integers as a Base36 string by using:
Number.prototype.toString( 36 )
- when encoding.Window.parseInt( value, 36 )
- when decoding.
This way, 3-digit integers such as 101
could be reduced to a 2-digit string, 2t
. Similarly, 2-digit integers such as 22
could be reduced to a 1-digit string, m
. This ended up creating a fairly compact data representation. As an example, this image:
... is fully represented by this URL fragment (line-breaks added for readability):
a, /// Foreground color.
a, /// Background color.
a:30,14:3,a:3,14:3,a:f,14,15:4,b,15:4,
14,a:d,14,15:b,14,a:c,14,15:b,17,a:c,14,
15:b,17,a:c,b,15:a,17,b,a:d,b,15:8,17,b,
a:f,b,15:6,17,b,a:h,b,15:4,17,b,a:j,b,
15:2,17,b,a:l,b,17,b,a:n,b,a:2h,d,a,d,a:3,
d:2,c,a,c,d,c,a,c,d:2,a,d,a,d,a:4,d,b,d,
a:3,d,b,d,a,d,b,d,a,d,a:3,d:2,b,a:4,d,b,d,
a:3,d:2,b,a,d,b,d,a,d,a:3,d,b,d,a:4,c,d,c,
a:3,d,b,d,a,c,d,c,a,c,d:2,a,d,b,d,a:5,b:2,
a:3,b,a,b,a,b:3,a:2,b:2,a,b,a,b,a:r
I can't think of how I might make this much shorter; at least not without getting super complicated and dealing with "compression" maybe? But that would go far beyond my current capabilities. Ultimately, I was pretty pleased with the final result of this experiment and its feature set:
- Select foreground color.
- Select background color.
- Change background color of existing canvas.
- Clear entire canvas.
- Sample pixel color using
CMD
+Click. - Clear pixel color using
ALT
+Click/Mousemove. - Shift canvas up/down/left/right.
- Re-center pixels within canvas.
- Undo pixels with
CMD+Z
(useshistory.go(-1)
. - Redo pixels with
SHIFT+CMD+Z
(useshistory.go(1)
. - Shareable image with no back-end storage.
For anyone curious, here's the code for the HTML. The whole demo is a single Alpine.js component with two x-for
loops (one for the pixels, one for the color palette):
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
Pixel Art With Alpine.js
</title>
<link rel="stylesheet" type="text/css" href="./main.css">
<script type="text/javascript" src="./palette.js" defer></script>
<script type="text/javascript" src="./main.js" defer></script>
<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>
</head>
<body>
<main
x-data="Demo"
data-width="25"
data-height="25"
@mouseup.window="stopDrawing()"
@hashchange.window="handleHashchange()"
@keydown.meta.z.window="handleDo( event )"
@keydown.ctrl.z.window="handleDo( event )">
<h1>
<a href="./index.htm">Pixel Art With Alpine.js</a>
</h1>
<!-- Rendered canvas. -->
<div class="grid">
<template x-for="( pixel, i ) in pixels" :key="i">
<button
@mousedown="startDrawing( $event, i )"
@mouseenter="enterPixel( $event, i )"
:style="{ backgroundColor: pixel?.hex }">
</button>
</template>
</div>
<!-- Color palette. -->
<div class="palette">
<template x-for="option in palette.swatches">
<button
@mousedown="selectSwatch( option )"
:title="option.name"
:style="{ backgroundColor: option.hex }"
:class="{ 'isSelected': ( option === foregroundSwatch ) }">
</button>
</template>
</div>
<dl class="selected">
<div>
<dt>Name:</dt>
<dd x-text="foregroundSwatch.name"></dd>
</div>
<div>
<dt>Hex:</dt>
<dd x-text="foregroundSwatch.hex"></dd>
</div>
</dl>
<div class="fillers">
<button @click="clearCanvas()">
Clear Canvas
<span :style="{ backgroundColor: foregroundSwatch.hex }"></span>
</button>
<button @click="changeCanvasBackground()">
Set Background
<span :style="{ backgroundColor: foregroundSwatch.hex }"></span>
</button>
</div>
<div class="tuggers">
<button @click="pullCanvasCenter()">
Center
</button>
<button @click="pullCanvasUp()">
Up
</button>
<button @click="pullCanvasDown()">
Down
</button>
<button @click="pullCanvasLeft()">
Left
</button>
<button @click="pullCanvasRight()">
Right
</button>
</div>
<div class="tips">
<p>
Use <kbd>CMD</kbd>+Click to sample a pixel.
</p>
<p>
Use <kbd>ALT</kbd>+Click to erase a pixel.
</p>
<p>
Use <kbd>CMD+Z</kbd> to undo pixel and <kbd>Shift+CMD+Z</kbd> to redo pixel.
</p>
</div>
</main>
</body>
</html>
Alpine.js has its problems and quirks that I have to work around. But, for the most part, it keeps the UI markup rather simple!
Drawing a string of pixels seems to have some lag when done quickly; but, I'm not sure if that's an Alpine.js issue or a browser issue. When I look at the Chrome Dev Tools, I can see "dropped frames" in the rendering. But, I am not sure why that is happening. The flame graph of performance and CPU utilization isn't immediately showing any obvious bottle-necks.
Anyway, here's the code for my Alpine.js component.
// Note: this depends on "palette" existing as an external module.
function Demo() {
return {
// Public properties.
pixels: null,
foregroundSwatch: palette.byName.LightSkyBlue,
backgroundSwatch: palette.byName.Snow,
// Private properties.
canvasWidth: 0,
canvasHeight: 0,
isDrawing: false,
palette: palette,
// Life-Cycle methods.
init,
// Public methods.
changeCanvasBackground,
clearCanvas,
enterPixel,
handleDo,
handleHashchange,
pullCanvasCenter,
pullCanvasDown,
pullCanvasLeft,
pullCanvasRight,
pullCanvasUp,
selectSwatch,
startDrawing,
stopDrawing,
// Private methods.
hashDecodeState,
hashEncodeState,
matrixNudge,
matrixRead,
matrixWrite,
stateDecodeString,
stateEncodeString,
};
// ---
// LIFE-CYCLE METHODS.
// ---
/**
* I initialize the alpine component.
*/
function init() {
// Pull grid dimensions from the DOM.
this.canvasWidth = ( +this.$el.dataset.width || 25 );
this.canvasHeight = ( +this.$el.dataset.height || 25 );
// Setup the pixel matrix: a linear set of pixels being rendered in two dimensions
// within the user interface using CSS Grid.
this.pixels = new Array( this.canvasWidth * this.canvasHeight )
.fill( this.backgroundSwatch )
;
// If the current request is a link to an existing pixel configuration, pull it in
// from the URL fragment.
this.hashDecodeState();
}
// ---
// PUBLIC METHODS.
// ---
/**
* I change any pixel with the current background color to be the new foreground color,
* then use the new foreground color as the future background color. Basically, this
* rotates the background color pixels only, leaving foreground color pixels in place.
*/
function changeCanvasBackground() {
this.pixels = this.pixels.map(
( swatch ) => {
return ( swatch === this.backgroundSwatch )
? this.foregroundSwatch
: swatch
;
}
);
this.backgroundSwatch = this.foregroundSwatch;
this.hashEncodeState();
}
/**
* I completely reset the pixel matrix to use one solid color (the selected color).
*/
function clearCanvas() {
this.backgroundSwatch = this.foregroundSwatch;
this.pixels.fill( this.backgroundSwatch );
this.hashEncodeState();
}
/**
* I apply a swatch to the contextual pixel if this is a draw operation.
*/
function enterPixel( event, i ) {
if ( ! this.isDrawing ) {
return;
}
this.pixels[ i ] = event.altKey
? this.backgroundSwatch
: this.foregroundSwatch
;
}
/**
* I attempt to redo / undo a recent change using this history.
*
* Note: these are being handled with a single event handler since Alpine.js doesn't
* limit key-bindings based on modifiers. As such, it's easier to just handle both
* events in a single handler.
*/
function handleDo( event ) {
// Since all pixel changes are persisted in the hash, we should be able to
// navigate back / forward through all of the changes in the canvas. However, I'm
// not keep track of whether or not the commands are available - I'm just blindly
// invoke the history API and letting the hash play-out.
event.preventDefault();
// Redo.
if ( event.shiftKey ) {
history.go( 1 );
// Undo.
} else {
history.go( -1 );
}
}
/**
* I handle the hash change, and push the URL data into the pixel state.
*/
function handleHashchange( event ) {
this.hashDecodeState();
}
/**
* I shift the foreground pixels to the center of the canvas.
*/
function pullCanvasCenter() {
var MAX = 999999;
var colMin = MAX;
var rowMin = MAX;
var colMax = -1;
var rowMax = -1;
var matrix = this.matrixRead();
// Iterate over the pixels and try to identify the smallest bounding box around
// the non-background swatch.
matrix.forEach(
( row, rowIndex ) => {
row.forEach(
( pixel, colIndex ) => {
if ( pixel != this.backgroundSwatch ) {
colMin = Math.min( colMin, colIndex );
colMax = Math.max( colMax, colIndex );
rowMin = Math.min( rowMin, rowIndex );
rowMax = Math.max( rowMax, rowIndex );
}
}
);
}
);
// If we found no foreground pixel data, there's nothing else to do.
if ( rowMin === MAX ) {
return;
}
var boxWidth = ( colMax - colMin + 1 );
var boxHeight = ( rowMax - rowMin + 1 );
var deltaWidth = ( this.canvasWidth - boxWidth );
var deltaHeight = ( this.canvasHeight - boxHeight );
var targetX = Math.floor( deltaWidth / 2 );
var targetY = Math.floor( deltaHeight / 2 );
this.matrixWrite(
this.matrixNudge(
matrix,
( targetX - colMin ), // Delta columns.
( targetY - rowMin ) // Delta rows.
)
);
this.hashEncodeState();
}
/**
* I shift the foreground pixels down 1 row on the canvas.
*/
function pullCanvasDown() {
this.matrixWrite(
this.matrixNudge(
this.matrixRead(),
0, // Delta columns.
1 // Delta rows.
)
);
this.hashEncodeState();
}
/**
* I shift the foreground pixels left 1 column on the canvas.
*/
function pullCanvasLeft() {
this.matrixWrite(
this.matrixNudge(
this.matrixRead(),
-1, // Delta columns.
0 // Delta rows.
)
);
this.hashEncodeState();
}
/**
* I shift the foreground pixels up 1 row on the canvas.
*/
function pullCanvasUp() {
this.matrixWrite(
this.matrixNudge(
this.matrixRead(),
0, // Delta columns.
-1 // Delta rows.
)
);
this.hashEncodeState();
}
/**
* I shift the foreground pixels right 1 column on the canvas.
*/
function pullCanvasRight() {
this.matrixWrite(
this.matrixNudge(
this.matrixRead(),
1, // Delta columns.
0 // Delta rows.
)
);
this.hashEncodeState();
}
/**
* I set the given swatch as the foreground drawing color.
*/
function selectSwatch( swatch ) {
this.foregroundSwatch = swatch;
}
/**
* I start a drawing operation, filling in the contextual pixel.
*/
function startDrawing( event, i ) {
// If the mouse event is modified, first sample the pixel for its swatch.
if ( event.metaKey || event.ctrlKey ) {
this.foregroundSwatch = this.pixels[ i ];
}
this.isDrawing = true;
this.enterPixel( event, i );
}
/**
* I stop a drawing operation and persist the current pixel state to the URL.
*/
function stopDrawing() {
if ( ! this.isDrawing ) {
return;
}
this.isDrawing = false;
this.hashEncodeState();
}
// ---
// PRIVATE METHODS.
// ---
/**
* I decode the canvas state from the URL fragment and use it to set the current pixel
* and color state.
*/
function hashDecodeState() {
var state = this.stateDecodeString( location.hash.slice( 1 ) );
if ( ! state ) {
return;
}
this.foregroundSwatch = state.foregroundSwatch;
this.backgroundSwatch = state.backgroundSwatch;
this.pixels = state.pixels;
}
/**
* I encode the current canvas state into the URL fragment.
*/
function hashEncodeState() {
history.pushState( null, null, `#${ this.stateEncodeString() }` );
}
/**
* I nudge the given pixel 2D matrix by the given column and row deltas. New pixels use
* the currently selected background swatch.
*/
function matrixNudge( matrix, colDelta, rowDelta ) {
// Nudge left.
for ( ; colDelta < 0 ; colDelta++ ) {
for ( var row of matrix ) {
row.shift();
row.push( this.backgroundSwatch );
}
}
// Nudge right.
for ( ; colDelta > 0 ; colDelta-- ) {
for ( var row of matrix ) {
row.pop();
row.unshift( this.backgroundSwatch );
}
}
// Nudge up.
for ( ; rowDelta < 0 ; rowDelta++ ) {
matrix.shift();
matrix.push( new Array( this.canvasWidth ).fill( this.backgroundSwatch ) );
}
// Nudge down.
for ( ; rowDelta > 0 ; rowDelta-- ) {
matrix.pop();
matrix.unshift( new Array( this.canvasWidth ).fill( this.backgroundSwatch ) );
}
return matrix;
}
/**
* I read the current linear pixel state into a 2D matrix.
*/
function matrixRead() {
var matrix = [];
for ( var i = 0 ; i < this.canvasHeight ; i++ ) {
var rowOffset = ( i * this.canvasWidth );
var rowEnd = ( rowOffset + this.canvasWidth );
matrix.push( this.pixels.slice( rowOffset, rowEnd ) );
}
return matrix;
}
/**
* I write the 2D matrix back into the current linear pixel state.
*/
function matrixWrite( matrix ) {
this.pixels = matrix.flat();
}
/**
* I parse the given string value back into a state object that contains the foreground
* swatch, the background swatch, and the pixels.
*/
function stateDecodeString( value = "" ) {
// Every part of the state is represented by either a single Base36 value; or, a
// pair of Base36 values in a ":" delimited list.
var matches = value
.toLowerCase()
.matchAll( /([a-z0-9]+)(:([a-z0-9]+))?/g )
.toArray()
.map(
([ $0, $key, $2, $count ]) => {
return {
key: urlDecodeInt( $key ),
count: urlDecodeInt( $count )
};
}
)
;
// We know that the encoded state will be, at the very smallest, the foreground
// swatch, the background swatch, and then a single run of a solid color.
// Therefore, if we have less than 3 matches, the input is invalid.
if ( matches.length < 3 ) {
return null;
}
// Set up the core state object into which we will parse the input.
var state = {
// First two matches are always the selected swatches.
foregroundSwatch: this.palette.byKey[ matches.shift().key ],
backgroundSwatch: this.palette.byKey[ matches.shift().key ],
pixels: new Array( this.pixels.length )
};
// Blank-out the canvas - we'll fill in pixels next.
state.pixels.fill( state.backgroundSwatch );
// As we iterate over the matches, we need to translate the runs into pixel
// offsets. Will use "i" to keep track of the start offset of the next fill.
var i = 0;
for ( var match of matches ) {
state.pixels.fill(
this.palette.byKey[ match.key ],
i,
( i += match.count ) // Warning: incrementing AND consuming.
);
}
return state;
}
/**
* I encode the current pixel art state into a string representation.
*/
function stateEncodeString() {
// The state will be encoded as a series of "runs". Meaning, each sequence of
// pixels that used the same swatch will be condensed down into the swatch "key"
// followed by the number of repeated pixels (`key`:`count`). If a swatch run is
// only a single pixel, the count can be omitted and will be assumed to be one.
// The first two runs implicitly represent the foreground and background swatches.
var runs = [
{
key: this.foregroundSwatch.key,
count: 1
},
{
key: this.backgroundSwatch.key,
count: 1
}
];
var run = {};
for ( var pixel of this.pixels ) {
// Did we enter a new swatch run?
if ( run.key !== pixel.key ) {
run = {
key: pixel.key,
count: 0
};
runs.push( run );
}
run.count++;
}
// Map runs to a list of `key`:`count` pairs.
return runs
.map(
( run ) => {
// Single pixel runs will be assumed to be "1" during parsing. As
// such, we can omit the count - keep the URL shorter.
if ( run.count === 1 ) {
return urlEncodeInt( run.key );
}
return `${ urlEncodeInt( run.key ) }:${ urlEncodeInt( run.count ) }`;
}
)
.join( "," )
;
}
/**
* In order to create shorter URLs, we're encoding numbers using Base36. This decodes
* the value back into an int.
*/
function urlDecodeInt( value = undefined ) {
if ( value === undefined ) {
return 1;
}
return parseInt( value, 36 );
}
/**
* In order to create shorter URLs, we're encoding numbers using Base36. This encodes
* the int value.
*/
function urlEncodeInt( value ) {
return value.toString( 36 );
}
}
This took way longer than I thought it would; and, I actually abandoned the idea for a few days after my Uint8Array
notion didn't quite pan out. But, in the end, I had a lot of fun.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →