Published
Edited
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
class PixelArt {
constructor(pixelData, pixelColors) {
this.pixelData = pixelData;
this.pixelColors = pixelColors;
this.fontColors = this.pixelColors.map((c) => {
const rgb = PIXI.utils.hex2rgb(c).map((x) => x * 255);
const useBlack = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114 > 176;
return useBlack ? 0x000000 : 0xffffff;
});

this.isDragging = false;
this.dragStarted = false;
this.fillMode = false;

this.prevX = 0;
this.prevY = 0;

this.minZoomLevel = Math.min(
limitingDimension / (gridSpace * pixelData.length),
1
);

this.statuses = this.pixelData.map((row) => row.map(() => 0));

this.app = new PIXI.Application({ width: width, height: height });
this.gridContainer = new PIXI.Container();
this.gridContainer.position.x = gridLeft;
this.gridContainer.position.y = gridTop;
this.app.stage.addChild(this.gridContainer);

this.controlsContainer = new PIXI.Container();
this.controlsContainer.position.x = width - 75;
this.controlsContainer.position.y = buttonRadius + 20 + gridTop;
this.app.stage.addChild(this.controlsContainer);

this.setupApp();
this.setupGrid();
this.setupControls();

this.mousePosition = {
x: 0,
y: 0
};

this.remainingPixels = this.pixelColors.map(() => 0);
this.totalPixels = this.pixelColors.map(() => 0);
this.pixelData.forEach((row, i) => {
row.forEach((cell, j) => {
if (cell === null) return;
this.remainingPixels[cell] += 1;
this.totalPixels[cell] += 1;
});
});

this.setCurrentColor(-1);
this.firstPanelColor = 0;
this.updatePanelButtons();

this.gridContainer.scale.x = this.minZoomLevel;
this.gridContainer.scale.y = this.minZoomLevel;
}

setupApp() {
this.app.renderer.backgroundColor = backgroundColor;
this.gridContainer.interactive = true;
this.gridContainer.hitArea = new PIXI.Rectangle(
0,
0,
gridSpace * this.pixelData[0].length,
gridSpace * this.pixelData.length
);
this.gridContainer.on("mousedown", (event) => {
const pos = JSON.parse(JSON.stringify(event.data.global));
this.prevX = pos.x;
this.prevY = pos.y;
this.isDragging = true;

const currentMS = Date.now() % 1000;
this.mousedownStarted = currentMS;

const currentLocals = this.gridContainer.localTransform.applyInverse(pos);

// check long press in similar location
setTimeout(() => {
if (
this.isDragging &&
this.mousedownStarted === currentMS &&
distanceBetween(pos, this.mousePosition) < 10
) {
this.fillMode = true;
this.fillSquare(
Math.floor(currentLocals.y / gridSpace),
Math.floor(currentLocals.x / gridSpace)
);
}
}, fillDelay);
});
this.gridContainer.on("mousemove", (event) => {
this.mousePosition.x = event.data.global.x;
this.mousePosition.y = event.data.global.y;
if (!this.isDragging || this.fillMode) {
return;
} else {
var pos = event.data.global;
var dx = pos.x - this.prevX;
var dy = pos.y - this.prevY;
if (this.dragStarted || Math.abs(dx) > 0.3 || Math.abs(dy) > 0.3) {
this.dragStarted = true;
this.gridContainer.position.x += dx;
this.gridContainer.position.y += dy;
}
this.prevX = pos.x;
this.prevY = pos.y;
}
});
this.gridContainer.on("mouseup", (event) => {
this.isDragging = false;
this.dragStarted = false;
this.fillMode = false;
});
this.gridContainer.on("mouseupoutside", (event) => {
this.isDragging = false;
this.dragStarted = false;
this.fillMode = false;
});
this.app.view.addEventListener("wheel", (event) => {
event.preventDefault();
this.handleZoom(this.mousePosition.x, this.mousePosition.y, event.deltaY);
});
}

setupGrid() {
// create container for each square
this.squareGraphics = pixelData.map((row, i) =>
row.map((cell, j) => {
if (this.pixelData[i][j] === null) return null;
const grp = new PIXI.Graphics();
grp.position.x = gridSpace * j;
grp.position.y = gridSpace * i;
this.gridContainer.addChild(grp);
return grp;
})
);

// draw grid
this.squareGraphics.forEach((row, i) => {
row.forEach((grp, j) => {
if (this.pixelData[i][j] === null) return;
grp.lineStyle(2, 0x000000);
grp.beginFill(backgroundColor);
grp.drawRect(0, 0, gridSpace, gridSpace);
});
});

// add numbers in each square
pixelData.forEach((row, i) => {
row.forEach((cell, j) => {
if (this.pixelData[i][j] === null) return;
const squareText = new PIXI.Text(cell, {
fontFamily: "Arial",
fontSize: gridSpace * 0.6,
fill: 0x000000
});
squareText.anchor.set(0.5);
squareText.position.x = gridSpace / 2;
squareText.position.y = gridSpace / 2;
this.squareGraphics[i][j].addChild(squareText);
});
});

//add click listeners for each square and set hit boxes
this.squareGraphics.forEach((row, i) => {
row.forEach((grp, j) => {
if (this.pixelData[i][j] === null) return;
grp.interactive = true;
grp.hitArea = new PIXI.Rectangle(
squareHitMargin,
squareHitMargin,
gridSpace - 2 * squareHitMargin,
gridSpace - 2 * squareHitMargin
);

grp.on("mouseup", () => {
if (!this.dragStarted && grp.children.length > 0) {
this.fillSquare(i, j);
}
});
grp.on("mouseover", () => {
if (this.fillMode) {
this.fillSquare(i, j);
}
});
});
});
}

setupControls() {
const panelHeight =
2 * buttonRadius + (panelNumColors - 1) * buttonSpace + 40 + 25;
const buttonPanel = new PIXI.Graphics();
this.controlsContainer.addChild(buttonPanel);
buttonPanel.lineStyle(2, 0x000000);
buttonPanel.beginFill(backgroundColor);
buttonPanel.drawRoundedRect(
-buttonRadius - 20,
-buttonRadius - 20,
2 * buttonRadius + 40,
panelHeight
);

const bottomOfBottomCircle =
(panelNumColors - 1) * buttonSpace + buttonRadius;

this.nextColorPageButton = new PIXI.Graphics();
this.controlsContainer.addChild(this.nextColorPageButton);
// this.nextColorPageButton.lineStyle(2, 0x000000);
// this.nextColorPageButton.beginFill(0x000000);
// this.nextColorPageButton.drawPolygon(
// 5,
// bottomOfBottomCircle + 10,
// 5,
// bottomOfBottomCircle + 30,
// buttonRadius,
// bottomOfBottomCircle + 20
// );
this.nextColorPageButton.interactive = true;
this.nextColorPageButton.on("click", () => {
if (this.firstPanelColor + panelNumColors < this.pixelColors.length) {
this.firstPanelColor += panelNumColors;
this.updatePanelButtons();
}
});

this.prevColorPageButton = new PIXI.Graphics();
this.controlsContainer.addChild(this.prevColorPageButton);
// this.prevColorPageButton.lineStyle(2, 0x000000);
// this.prevColorPageButton.beginFill(0x000000);
// this.prevColorPageButton.drawPolygon(
// -5,
// bottomOfBottomCircle + 10,
// -5,
// bottomOfBottomCircle + 30,
// -buttonRadius,
// bottomOfBottomCircle + 20
// );
this.prevColorPageButton.interactive = true;
this.prevColorPageButton.on("click", () => {
if (this.firstPanelColor >= panelNumColors) {
this.firstPanelColor -= panelNumColors;
this.updatePanelButtons();
}
});

this.colorHighlightRing = new PIXI.Graphics();
this.controlsContainer.addChild(this.colorHighlightRing);

this.panelButtonContainer = new PIXI.Container();
this.controlsContainer.addChild(this.panelButtonContainer);

this.pixelColors.slice(0, panelNumColors).forEach((color, i) => {
const button = new PIXI.Graphics();
button.interactive = true;
this.panelButtonContainer.addChild(button);
button.lineStyle(2, 0x000000);
button.beginFill(color);
button.drawCircle(0, buttonSpace * i, buttonRadius);

console.log(this.fontColors[i]);
const buttonLabel = new PIXI.Text(i, {
fontFamily: "Arial",
fontSize: gridSpace * 0.6,
fill: this.fontColors[i]
});
buttonLabel.anchor.set(0.5);
buttonLabel.position.y = buttonSpace * i;
button.addChild(buttonLabel);

button.on("click", () => {
this.setCurrentColor(i);
});
});
}

updatePanelButtons() {
this.panelButtonContainer.removeChildren();
this.pixelColors
.slice(this.firstPanelColor, this.firstPanelColor + panelNumColors)
.forEach((color, i) => {
const button = new PIXI.Graphics();
button.interactive = true;
this.panelButtonContainer.addChild(button);
button.lineStyle(2, 0x000000);
button.beginFill(color);
button.drawCircle(0, buttonSpace * i, buttonRadius);

const thisColorIdx = i + this.firstPanelColor;
const buttonLabel = new PIXI.Text(
this.remainingPixels[thisColorIdx] > 0 ? thisColorIdx : "✓",
{
fontFamily: "Arial",
fontSize: gridSpace * 0.6,
fill: this.fontColors[thisColorIdx]
}
);
buttonLabel.anchor.set(0.5);
buttonLabel.position.y = buttonSpace * i;
button.addChild(buttonLabel);

button.on("click", () => {
this.setCurrentColor(thisColorIdx);
this.updateHighlightRing();
});
});

this.updateHighlightRing();

// update button colors
const bottomOfBottomCircle =
(panelNumColors - 1) * buttonSpace + buttonRadius;

// next page button
this.nextColorPageButton.clear();
if (this.firstPanelColor + panelNumColors >= this.pixelColors.length) {
this.nextColorPageButton.lineStyle(2, 0xcccccc);
this.nextColorPageButton.beginFill(0xcccccc);
this.nextColorPageButton.drawPolygon(
5,
bottomOfBottomCircle + 10,
5,
bottomOfBottomCircle + 30,
buttonRadius,
bottomOfBottomCircle + 20
);
} else {
this.nextColorPageButton.lineStyle(2, 0x000000);
this.nextColorPageButton.beginFill(0x000000);
this.nextColorPageButton.drawPolygon(
5,
bottomOfBottomCircle + 10,
5,
bottomOfBottomCircle + 30,
buttonRadius,
bottomOfBottomCircle + 20
);
}

// prev page button
this.prevColorPageButton.clear();
if (this.firstPanelColor < panelNumColors) {
this.prevColorPageButton.lineStyle(2, 0xcccccc);
this.prevColorPageButton.beginFill(0xcccccc);
this.prevColorPageButton.drawPolygon(
-5,
bottomOfBottomCircle + 10,
-5,
bottomOfBottomCircle + 30,
-buttonRadius,
bottomOfBottomCircle + 20
);
} else {
this.prevColorPageButton.lineStyle(2, 0x000000);
this.prevColorPageButton.beginFill(0x000000);
this.prevColorPageButton.drawPolygon(
-5,
bottomOfBottomCircle + 10,
-5,
bottomOfBottomCircle + 30,
-buttonRadius,
bottomOfBottomCircle + 20
);
}
}

updateHighlightRing() {
this.colorHighlightRing.clear();
if (
this.currentColor >= this.firstPanelColor &&
this.currentColor < this.firstPanelColor + panelNumColors &&
this.currentColor >= 0 &&
this.remainingPixels[this.currentColor] > 0
) {
const frac =
1 -
this.remainingPixels[this.currentColor] /
this.totalPixels[this.currentColor];
this.colorHighlightRing.lineStyle(13, 0xaaaaaa);
this.colorHighlightRing.drawCircle(0, 0, buttonRadius);
this.colorHighlightRing.lineStyle(13, 0x000000);
this.colorHighlightRing.arc(
0,
0,
buttonRadius,
1.5 * Math.PI,
(1.5 + frac * 2) * Math.PI
);
this.colorHighlightRing.position.y =
buttonSpace * (this.currentColor - this.firstPanelColor);
}
}

setCurrentColor(i) {
if (this.remainingPixels[i] === 0) {
if (this.currentColor === i) {
this.currentColor = -1;
} else {
return;
}
}
this.currentColor = i;
this.colorHighlightRing.position.y = buttonSpace * i;

// color all of the corresponding squares grey, and non-corresponding ones white
this.pixelData.forEach((row, i) => {
row.forEach((cell, j) => {
if (cell === this.currentColor) {
this.setSquareActive(i, j);
} else {
this.setSquareInactive(i, j);
}
});
});
}

handleZoom(x, y, deltaY) {
if (this.gridContainer.scale.x < this.minZoomLevel && deltaY > 0) return;
if (this.gridContainer.scale.x > 3 && deltaY < 0) return;
// scale up by delta factor
const zoomFactor =
deltaY > 0 ? Math.min(deltaY, 13) : Math.max(deltaY, -13);
const beforeGlobal = JSON.parse(JSON.stringify(this.mousePosition));
const beforeLocal = this.gridContainer.localTransform.applyInverse(
beforeGlobal
);

this.gridContainer.scale.x *= 1 - 0.005 * zoomFactor;
this.gridContainer.scale.y *= 1 - 0.005 * zoomFactor;
this.gridContainer.updateTransform();

const afterGlobal = this.gridContainer.localTransform.apply(beforeLocal);

const dx = beforeGlobal.x - afterGlobal.x;
const dy = beforeGlobal.y - afterGlobal.y;

this.gridContainer.position.x += dx;
this.gridContainer.position.y += dy;
this.gridContainer.updateTransform();

// console.log(`dx:${dx}`);
// console.log(`dy:${dy}`);
// console.log(`deltaY:${deltaY}`);
}

fillSquare(i, j) {
if (this.currentColor < 0) return;
if (this.pixelData[i][j] === null) return;
const grp = this.squareGraphics[i][j];
if (grp.children.length > 0) {
grp.clear();
if (this.currentColor === pixelData[i][j]) {
grp.children[0].destroy();
grp.lineStyle(2, this.pixelColors[this.pixelData[i][j]]);
grp.beginFill(this.pixelColors[this.pixelData[i][j]]);
grp.drawRect(0, 0, gridSpace, gridSpace);
this.statuses[i][j] = 3;
this.remainingPixels[this.pixelData[i][j]] -= 1;
this.updatePanelButtons();
if (!this.remainingPixels.some((n) => n > 0)) {
this.complete();
}
} else {
grp.lineStyle(2, 0x000000);
grp.beginFill(getWrongColor(this.pixelColors[this.currentColor]));
grp.drawRect(0, 0, gridSpace, gridSpace);
this.statuses[i][j] = 2;
}
}
}

setSquareActive(i, j) {
if (this.statuses[i][j] !== 0 || this.pixelData[i][j] === null) {
return;
} else {
const grp = this.squareGraphics[i][j];
grp.clear();
grp.lineStyle(2, 0x000000);
grp.beginFill(activeColor);
grp.drawRect(0, 0, gridSpace, gridSpace);
this.statuses[i][j] = 1;
}
}

setSquareInactive(i, j) {
if (this.statuses[i][j] !== 1 || this.pixelData[i][j] == null) {
return;
} else {
const grp = this.squareGraphics[i][j];
grp.clear();
grp.lineStyle(2, 0x000000);
grp.beginFill(backgroundColor);
grp.drawRect(0, 0, gridSpace, gridSpace);
this.statuses[i][j] = 0;
}
}

complete() {
const completionText = new PIXI.Text("✓", {
fontFamily: "Arial",
fontSize: 200,
fill: 0xffffff
});
completionText.anchor.set(0.5);
completionText.position.x = gridSpace * (pixelData[0].length / 2);
completionText.position.y = gridSpace * (pixelData.length / 2);
this.gridContainer.addChild(completionText);
}

render() {
return this.app.view;
}
}
Insert cell
panelNumColors = 7
Insert cell
allStatuses = ["inactive", "active", "wrong", "filled"]
Insert cell
buttonRadius = 25
Insert cell
buttonSpace = 60
Insert cell
activeColor = 0xaaaaaa
Insert cell
getWrongColor = (curColor) => {
return PIXI.utils.string2hex(
Color.default(PIXI.utils.hex2string(curColor))
.mix(Color.default("#aaaaaa"), 0.8)
.hex()
);
}
Insert cell
squareHitMargin = gridSpace / 10
Insert cell
distanceBetween = (p1, p2) => {
return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
}
Insert cell
fillRadius = 1
Insert cell
fillDelay = 200
Insert cell
backgroundColor = 0xffffff
Insert cell
gridSpace = Math.min(Math.max(limitingDimension / pixelData.length, 30), 70)
Insert cell
gridTop = margin
Insert cell
gridLeft = margin
Insert cell
limitingDimension = Math.min(width - 2 * margin, height - 2 * margin)
Insert cell
margin = 20
Insert cell
height = 500
Insert cell
width = 700
Insert cell
pixelColors = {
const lines = fileText.split("\n");
const colors = lines[1].split(" ").slice(1).map(PIXI.utils.string2hex);
return colors;
}
Insert cell
pixelData = {
const lines = fileText.split("\n");
const data = lines
.slice(2)
.filter((row) => row.trim() !== "")
.map((row) =>
row
.trim()
.split(" ")
.map((x) => parseInt(x))
.map((x) => (x < 0 ? null : x))
);
return data;
}
Insert cell
fileText = await FileAttachment("frog_nowhite.pixart").text()
Insert cell
Insert cell
Color = import("https://cdn.skypack.dev/color@4.1.0?min")
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more