Last active
May 7, 2021 18:44
-
-
Save Garciat/05754d64c9f59ea847284cc280eab897 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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