Skip to content

Instantly share code, notes, and snippets.

@Garciat
Last active May 7, 2021 18:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Garciat/05754d64c9f59ea847284cc280eab897 to your computer and use it in GitHub Desktop.
Save Garciat/05754d64c9f59ea847284cc280eab897 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Mandala</title>
<style type="text/css">
html, body {
overflow: hidden;
width: 100%;
height: 100%;
margin: 0px;
}
#scene {
position: absolute;
z-index: 1;
touch-action: none;
}
#drawInput {
position: absolute;
top: 0;
left: 0;
z-index: 5;
touch-action: none;
}
#menu {
position: absolute;
z-index: 10;
width: 5em;
}
#pallet {
position: absolute;
top: 0px;
right: 0px;
z-index: 10;
width: 30px;
border: 1px solid black;
border-width: 0 0 1px 1px;
}
#colorPicker {
display: block;
box-sizing: border-box;
width: 100%;
}
#pallet .memory {
height: 20px;
}
</style>
</head>
<body>
<select id="menu">
<option value="">Menu</option>
<option value="undo">Undo</option>
<option value="save">Save</option>
<option value="load">Load</option>
<option value="fill">Fill</option>
<option value="mirroring">Mirroring</option>
<option value="toggleFullscreen">Toggle Fullscreen</option>
</select>
<div id="pallet">
<input type="color" id="colorPicker" />
</div>
<div id="drawInput"></div>
<canvas id="scene" />
<input type="color" id="backgroundPicker" value="#FFFFFF" />
<input type="file" id="filePicker" />
<script>
'use strict';
const TAU = 2 * Math.PI;
const STORAGE_KEY_SCENE = 'scene';
const MAX_SNAPSHOT_HISTORY = 5;
const MAX_COLOR_HISTORY = 25;
const drawInput = document.getElementById('drawInput');
const canvas = document.getElementById('scene');
const menuInput = document.getElementById('menu');
const colorPicker = document.getElementById('colorPicker');
const backgroundPicker = document.getElementById('backgroundPicker');
const filePicker = document.getElementById('filePicker');
const pallet = document.getElementById('pallet');
const ctx = canvas.getContext('2d');
let scene = {
w: 0,
h: 0,
dx: 0,
dy: 0,
drawers: new Map(),
strokeMin: 0,
strokeMax: 3,
strokeWith(x) {
return this.strokeMin + x * (this.strokeMax - this.strokeMin);
},
color: 'rgb(0, 0, 0)',
colorHistory: [],
background: 'rgb(255, 255, 255)',
snapshots: [],
sections: 32,
get section() { return TAU / this.sections; },
screenToPos(x, y) {
return new Vec2(
x - this.w / 2 + scene.dx,
this.h / 2 - y - scene.dy
);
}
};
class DrawPoint {
constructor(pos, force) {
this.pos = pos;
this.force = force;
}
}
class Drawer {
constructor(color) {
this.points = [];
this.color = color || 'rgb(0, 0, 0)';
}
popPoints() {
let points;
[points, this.points] = [this.points, []];
return points;
}
appendMouse(ev) {
this.points.push(new DrawPoint(
scene.screenToPos(ev.clientX, ev.clientY),
1
));
}
appendTouch(touch) {
this.points.push(new DrawPoint(
scene.screenToPos(touch.pageX, touch.pageY),
touch.force
));
}
appendPointer(ev) {
let force;
if (ev.pointerType === 'mouse') {
force = 0.5;
} else if (ev.pointerType === 'touch') {
force = 0.5;
} else {
force = ev.pressure;
}
this.points.push(new DrawPoint(
scene.screenToPos(ev.clientX, ev.clientY),
force
));
}
}
function clear() {
ctx.clearRect(0, 0, scene.w, scene.h);
fillCanvas(scene.background);
}
function fillCircle(x, y, r) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fill();
}
function fillCanvas(color) {
ctx.save();
ctx.fillStyle = color;
ctx.fillRect(0, 0, scene.w, scene.h);
ctx.restore();
}
function cloneCanvas() {
var newCanvas = document.createElement('canvas');
var context = newCanvas.getContext('2d');
newCanvas.width = canvas.width;
newCanvas.height = canvas.height;
context.drawImage(canvas, 0, 0);
return newCanvas;
}
function paint(p, force) {
for (let i = 0; i < scene.sections; ++i) {
let b = scene.section * i - p.angle() * Math.pow(-1, i);
let q = Vec2.fromRad(b).scaled(p.length());
fillCircle(q.x, q.y, scene.strokeWith(force));
}
}
function draw(t) {
ctx.save();
ctx.translate(scene.w/2, scene.h/2);
for (let drawer of scene.drawers.values()) {
ctx.fillStyle = drawer.color;
for (let point of drawer.popPoints()) {
paint(point.pos, point.force);
}
}
ctx.restore();
}
// ---
function loop(t) {
draw(t);
requestAnimationFrame(loop);
}
async function onLoad() {
initialize(await restoreState());
requestAnimationFrame(loop);
}
function setCanvasSize() {
initialize(cloneCanvas());
}
function initialize(source) {
let oldS;
if (source) {
oldS = Math.max(source.width, source.height);
} else {
oldS = 0;
}
let newW = window.innerWidth;
let newH = window.innerHeight;
let newS = Math.max(oldS, Math.max(newW, newH));
let adjX = (newS - newW) / 2;
let adjY = (newS - newH) / 2;
let adjS = (newS - oldS) / 2;
canvas.width = scene.w = newS;
canvas.height = scene.h = newS;
scene.dx = adjX;
scene.dy = adjY;
canvas.style.left = `${-adjX}px`;
canvas.style.top = `${-adjY}px`;
clear();
if (source) {
ctx.drawImage(source, adjS, adjS);
}
drawInput.style.width = `${newW}px`;
drawInput.style.height = `${newH}px`;
}
function pushSnapshot() {
let snapshot = cloneCanvas();
pushCircular(scene.snapshots, snapshot, MAX_SNAPSHOT_HISTORY);
}
function popSnapshot() {
let snapshot = scene.snapshots.pop();
if (snapshot) initialize(snapshot);
saveState();
}
function saveState() {
let url = cloneCanvas().toDataURL();
window.localStorage.setItem(STORAGE_KEY_SCENE, url);
}
async function restoreState() {
let url = window.localStorage.getItem(STORAGE_KEY_SCENE);
if (!url) return null;
return await loadImageAsync(url);
}
window.addEventListener('load', ev => {
onLoad();
});
window.addEventListener('resize', ev => {
setCanvasSize();
});
drawInput.addEventListener('pointerdown', ev => {
pushSnapshot();
let drawer = new Drawer(colorPicker.value);
drawer.appendPointer(ev);
scene.drawers.set(ev.pointerId, drawer);
});
drawInput.addEventListener('pointermove', ev => {
let events = 'getCoalescedEvents' in ev ? ev.getCoalescedEvents() : [ev];
for (let e of events) {
let drawer = scene.drawers.get(ev.pointerId);
if (drawer) drawer.appendPointer(ev);
}
});
drawInput.addEventListener('pointerup', ev => {
scene.drawers.delete(ev.pointerId);
saveState();
});
// ---
const menuActions = {
toggleFullscreen() {
if (currentFullscreen()) {
exitFullscreen();
} else {
openFullscreen(document.body);
}
},
save() {
canvas.toBlob(blob => {
let url = window.URL.createObjectURL(blob);
window.open(url, '_blank');
});
},
load() {
filePicker.click();
},
fill() {
backgroundPicker.value = '#000001';
backgroundPicker.click();
},
undo() {
popSnapshot();
},
mirroring() {
let input = window.prompt('Pick mirroring (even number)', scene.sections);
let value = parseInt(input, 10);
if (isNaN(value) || value <= 0 || value % 2 != 0) {
alert('Try again with an even number.');
return;
}
scene.sections = value;
}
};
menuInput.addEventListener('change', ev => {
menuActions[menuInput.value]();
menuInput.value = '';
});
backgroundPicker.addEventListener('change', ev => {
scene.background = backgroundPicker.value;
clear();
saveState();
});
filePicker.addEventListener('change', async function (ev) {
let files = ev.target.files;
let file = files[0];
if (!file.type.match('image.*')) {
alert('Only image files, please.');
return;
}
filePicker.value = null;
let url = await readFileAsDataURLAsync(file);
let img = await loadImageAsync(url);
pushSnapshot();
initialize(img);
saveState();
});
function readFileAsDataURLAsync(file) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(file);
});
}
function loadImageAsync(url) {
return new Promise((resolve, reject) => {
let img = new window.Image();
img.onload = () => resolve(img);
img.src = url;
});
}
// ---
function setColor(value) {
addColorHistory(scene.color);
colorPicker.value = value;
scene.color = value;
}
function addColorHistory(value) {
if (isInColorHistory(value)) return;
unshiftCircular(scene.colorHistory, value, MAX_COLOR_HISTORY);
renderColorHistory();
}
function isInColorHistory(value) {
return scene.colorHistory.indexOf(value) >= 0;
}
function renderColorHistory() {
for (let memory of document.querySelectorAll('#pallet .memory')) {
memory.parentNode.removeChild(memory);
}
for (let value of scene.colorHistory) {
let memory = document.createElement('div');
memory.dataset.value = value;
memory.classList.add('memory');
memory.style.background = value;
pallet.appendChild(memory);
}
}
colorPicker.addEventListener('change', ev => {
setColor(colorPicker.value);
});
pallet.addEventListener('click', ev => {
if (ev.target.classList.contains('memory')) {
setColor(ev.target.dataset.value);
}
});
window.addEventListener('load', ev => {
addColorHistory(rgbToHex(248,244,238));
addColorHistory(rgbToHex(255,228,225));
addColorHistory(rgbToHex(246,84,106));
addColorHistory(rgbToHex(0,206,209));
addColorHistory(rgbToHex(26,28,114));
});
// ---
function rgbToHex(r, g, b) {
return `#${octetHex(r)}${octetHex(g)}${octetHex(b)}`;
}
function octetHex(x) {
return x.toString(16).padStart(2, '0');
}
// ---
class Vec2 {
constructor(x, y) {
this.x = x;
this.y = y;
}
atan2() {
return Math.atan2(this.y, this.x);
}
angle() {
let a = this.atan2();
return a >= 0 ? a : (TAU + a);
}
lengthSq() {
return this.x*this.x + this.y*this.y;
}
length() {
return Math.sqrt(this.lengthSq());
}
scaled(k) {
return new Vec2(k * this.x, k * this.y);
}
static fromRad(r) {
return new Vec2(Math.cos(r), Math.sin(r));
}
}
Vec2.X = new Vec2(1, 0);
Vec2.Y = new Vec2(0, 1);
// ---
function openFullscreen(elem) {
if (elem.requestFullscreen) {
elem.requestFullscreen();
} else if (elem.mozRequestFullScreen) {
elem.mozRequestFullScreen();
} else if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
} else if (elem.msRequestFullscreen) {
elem.msRequestFullscreen();
}
}
function exitFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
function currentFullscreen() {
return (
document.fullscreenElement
|| document.webkitFullscreenElement
|| document.mozFullScreenElement
|| document.msFullScreenElement
);
}
// ---
function lastElement(xs) {
return xs[xs.length - 1];
}
function pushCircular(xs, x, max) {
xs.push(x);
while (xs.length > max) xs.shift();
}
function unshiftCircular(xs, x, max) {
xs.unshift(x);
while (xs.length > max) xs.pop();
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment