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; | |
} | |
body { | |
background: #888888; | |
} | |
</style> | |
</head> | |
<body> | |
<script> | |
'use strict'; | |
const TAU = 2 * Math.PI; | |
// --- | |
class Canvas { | |
static fromDimensions(w, h) { | |
let canvas = document.createElement('canvas'); | |
canvas.style.touchAction = 'none'; | |
canvas.width = w; | |
canvas.height = h; | |
return canvas; | |
} | |
static fromImage(image) { | |
let copy = Canvas.fromDimensions(image.width, image.height); | |
copy.getContext('2d').drawImage(image, 0, 0); | |
return copy; | |
} | |
static fromCanvas(canvas) { | |
let copy = Canvas.fromDimensions(canvas.width, canvas.height); | |
copy.getContext('2d').drawImage(canvas, 0, 0); | |
return copy; | |
} | |
static async fromURL(url) { | |
return Canvas.fromImage(await loadImageAsync(url)); | |
} | |
static async toURL(canvas) { | |
return canvas.toDataURL('image/png'); | |
} | |
} | |
function loadImageAsync(url) { | |
return new Promise((resolve, reject) => { | |
let img = new window.Image(); | |
img.onload = () => resolve(img); | |
img.src = url; | |
}); | |
} | |
async function asyncArrayMap(xs, f) { | |
let ys = []; | |
for (let x of xs) { | |
ys.push(await f(x)); | |
} | |
return ys; | |
} | |
function drawImageVec(ctx, src, spos, sdiag, dpos, ddiag) { | |
ctx.drawImage( | |
src, | |
spos.x, | |
-spos.y, | |
sdiag.x, | |
-sdiag.y, | |
dpos.x, | |
-dpos.y, | |
ddiag.x, | |
-ddiag.y | |
); | |
ctx.fillStyle = 'red'; | |
ctx.fillText(spos.toString(), 0, 10); | |
ctx.fillText(sdiag.toString(), 0, 30); | |
} | |
function fillCanvas(canvas, color) { | |
let ctx = canvas.getContext('2d'); | |
ctx.fillStyle = color; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
} | |
function fillCircle(ctx, pos, r) { | |
ctx.beginPath(); | |
ctx.arc(pos.x, -pos.y, r, 0, TAU); | |
ctx.fill(); | |
} | |
// --- | |
class Layer { | |
constructor(canvas) { | |
this.canvas = canvas; | |
} | |
get w() { return this.canvas.width; } | |
get h() { return this.canvas.height; } | |
get diag() { | |
return Vec2.fromWH(this.w, this.h); | |
} | |
clone() { | |
return new Layer(Canvas.fromCanvas(this.canvas)); | |
} | |
static fromDimensions(w, h) { | |
return new Layer(Canvas.fromDimensions(w, h)); | |
} | |
// --- | |
async toJSON() { | |
return { | |
canvasURL: await Canvas.toURL(this.canvas) | |
}; | |
} | |
static async fromJSON({canvasURL}) { | |
return new Layer(await Canvas.fromURL(canvasURL)); | |
} | |
} | |
// --- | |
class LayerStack { | |
constructor(layers) { | |
this.layers = layers || []; | |
} | |
push(layer) { | |
this.layers.push(layer); | |
} | |
clone() { | |
return new LayerStack( | |
this.layers.map(layer => layer.clone()) | |
); | |
} | |
*[Symbol.iterator]() { | |
for (let layer of this.layers) { | |
yield layer; | |
} | |
} | |
// --- | |
async toJSON() { | |
return { | |
layers: await asyncArrayMap(this.layers, layer => layer.toJSON()) | |
}; | |
} | |
static async fromJSON({layers}) { | |
return new LayerStack( | |
await asyncArrayMap(layers, layer => Layer.fromJSON(layer)) | |
); | |
} | |
} | |
// --- | |
const BACKDROP_COLOR = '#888888'; | |
class Viewport { | |
constructor(x, y, zoom) { | |
this.pos = new Vec2(x, y); | |
this.zoom = zoom; | |
} | |
get x() { return this.pos.x; } | |
get y() { return this.pos.y; } | |
panBy(ds) { | |
this.pos.mutSub(ds); | |
} | |
zoomAtOriginBy(origin, dz) { | |
this.zoom *= dz; | |
this.pos.mutAdd(origin); | |
this.pos.mutScale(1 / dz); | |
this.pos.mutSub(origin); | |
} | |
render(layerStack, display) { | |
display.ctx.clearRect(0, 0, display.w, display.h); | |
for (let layer of layerStack) { | |
drawImageVec( | |
display.ctx, | |
layer.canvas, | |
this.pos.scaled(this.zoom), | |
display.diag.scaled(this.zoom), | |
Vec2.ZERO, | |
display.diag | |
); | |
} | |
} | |
// --- | |
async toJSON() { | |
return { | |
x: this.x, | |
y: this.y, | |
zoom: this.zoom | |
}; | |
} | |
static async fromJSON({x, y, zoom}) { | |
return new Viewport(x, y, zoom); | |
} | |
} | |
// --- | |
// Resolution is 1:1 with device screen | |
class Display { | |
constructor(canvas) { | |
this.canvas = canvas; | |
this.ctx = canvas.getContext('2d'); | |
} | |
get w() { return this.canvas.width; } | |
get h() { return this.canvas.height; } | |
get diag() { | |
return Vec2.fromWH(this.w, this.h); | |
} | |
resize(w, h) { | |
this.canvas.width = w; | |
this.canvas.height = h; | |
} | |
} | |
// --- | |
class Minimap { | |
constructor(canvas) { | |
this.canvas = canvas; | |
} | |
get w() { return this.canvas.width; } | |
get h() { return this.canvas.height; } | |
} | |
// --- | |
class PointerBuffer { | |
constructor() { | |
this.items = new CircularBuffer(5); | |
} | |
addEvent(ev) { | |
this.items.prepend(Vec2.fromPointerEvent(ev)); | |
} | |
get curr() { | |
return this.items.at(0); | |
} | |
get prev() { | |
return this.items.at(1) || this.curr; | |
} | |
get delta() { | |
return this.curr.sub(this.prev); | |
} | |
static fromEvent(ev) { | |
let buffer = new PointerBuffer(); | |
buffer.addEvent(ev); | |
return buffer; | |
} | |
} | |
// --- | |
class Application { | |
constructor() { | |
this.display = new Display(Canvas.fromDimensions(500, 500)); | |
this.viewport = new Viewport(0, 0, 1); | |
this.layerStack = new LayerStack(); | |
} | |
async initialize() { | |
let w = window.innerWidth | |
let h = window.innerHeight; | |
this.display.resize(w, h); | |
// this.layerStack.push(new Layer(await Canvas.fromURL('big.jpg'))); | |
let background = Canvas.fromDimensions(500, 500); | |
fillCanvas(background, 'white'); | |
this.layerStack.push(new Layer(background)); | |
// this.viewport.pos.x = -w/2 + 250; | |
// this.viewport.pos.y = h/2 - 250; | |
document.body.appendChild(this.display.canvas); | |
} | |
render() { | |
this.viewport.render(this.layerStack, this.display); | |
} | |
} | |
// --- | |
function isZoomJitter(dz) { | |
return Math.abs(1 - dz) <= 0.01; | |
} | |
window.onerror = function(message, source, lineno, colno, error) { | |
alert(message); | |
}; | |
window.addEventListener('load', async function () { | |
let app = new Application(); | |
await app.initialize(); | |
let buffers = new Map(); | |
let paintBuffer = new CircularBuffer(100); | |
app.display.canvas.addEventListener('pointerdown', ev => { | |
app.display.canvas.setPointerCapture(ev.pointerId); | |
buffers.set(ev.pointerId, PointerBuffer.fromEvent(ev)); | |
}); | |
app.display.canvas.addEventListener('pointermove', ev => { | |
if (!buffers.has(ev.pointerId)) return; | |
switch (buffers.size) { | |
case 1: { | |
if (ev.pointerType === 'pen') { | |
paintBuffer.append( | |
{ev: ev, f: ev.pressure} | |
); | |
} else { | |
// app.viewport.panBy(bufferA.delta); | |
} | |
} break; | |
case 2: { | |
let bufferA = buffers.get(ev.pointerId); | |
bufferA.addEvent(ev); | |
let [bufferP, bufferQ] = buffers.values(); | |
let currP = bufferP.curr; | |
let currQ = bufferQ.curr; | |
let prevP = bufferP.prev; | |
let prevQ = bufferQ.prev; | |
let currC = currP.midwayWith(currQ); | |
let dz = prevP.distanceTo(prevQ) / currP.distanceTo(currQ); | |
if (!isZoomJitter(dz)) { | |
app.viewport.zoomAtOriginBy(currC, dz); | |
} | |
if (ev.isPrimary) { | |
let prevC = prevP.midwayWith(prevQ); | |
app.viewport.panBy(currC.sub(prevC)); | |
} | |
} break; | |
} | |
}); | |
app.display.canvas.addEventListener('pointerup', ev => { | |
app.display.canvas.releasePointerCapture(ev.pointerId); | |
buffers.delete(ev.pointerId); | |
}); | |
app.display.canvas.addEventListener('pointercancel', ev => { | |
app.display.canvas.releasePointerCapture(ev.pointerId); | |
buffers.delete(ev.pointerId); | |
}); | |
function loop() { | |
for (let pp of paintBuffer) { | |
let p = Vec2.fromPointerEvent(pp.ev); | |
let ctx = app.layerStack.layers[0].canvas.getContext('2d'); | |
let p_ = p.add(app.viewport.pos).scaled(app.viewport.zoom); | |
ctx.fillStyle = 'black'; | |
fillCircle(ctx, p_, 3 * pp.f); | |
} | |
paintBuffer.clear(); | |
app.render(); | |
requestAnimationFrame(loop); | |
} | |
loop(); | |
}); | |
// --- | |
class Vec2 { | |
constructor(x, y) { | |
this.x = x; | |
this.y = y; | |
} | |
clone() { | |
return new Vec2(this.x, this.y); | |
} | |
neg() { | |
return this.clone().mutNeg(); | |
} | |
add(v) { | |
return this.clone().mutAdd(v); | |
} | |
sub(v) { | |
return this.clone().mutSub(v); | |
} | |
scaled(k) { | |
return this.clone().mutScale(k); | |
} | |
mutNeg() { | |
this.x *= -1; | |
this.y *= -1; | |
return this; | |
} | |
mutAdd(v) { | |
this.x += v.x; | |
this.y += v.y; | |
return this; | |
} | |
mutSub(v) { | |
this.x -= v.x; | |
this.y -= v.y; | |
return this; | |
} | |
mutScale(k) { | |
this.x *= k; | |
this.y *= k; | |
return this; | |
} | |
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()); | |
} | |
distanceTo(v) { | |
return this.sub(v).length(); | |
} | |
midwayWith(v) { | |
return this.add(v).scaled(0.5); | |
} | |
toString() { | |
return `Vec2(${this.x.toFixed(2)}, ${this.y.toFixed(2)})`; | |
} | |
static fromRad(r) { | |
return new Vec2(Math.cos(r), Math.sin(r)); | |
} | |
static fromPointerEvent(ev) { | |
return Vec2.fromWH(ev.clientX, ev.clientY); | |
} | |
static fromWH(w, h) { | |
return new Vec2(w, -h); | |
} | |
} | |
Vec2.ZERO = new Vec2(0, 0); | |
Vec2.X = new Vec2(1, 0); | |
Vec2.Y = new Vec2(0, 1); | |
// --- | |
class CircularBuffer { | |
constructor(size) { | |
this.size = size; | |
this.items = []; | |
} | |
get length() { | |
return this.items.length; | |
} | |
at(ix) { | |
return this.items[ix]; | |
} | |
prepend(item) { | |
this.items.unshift(item); | |
if (this.items.length > this.size) | |
this.items.pop(); | |
} | |
append(item) { | |
this.items.push(item); | |
if (this.items.length > this.size) | |
this.items.shift(); | |
} | |
clear() { | |
this.items.length = 0; | |
} | |
*[Symbol.iterator]() { | |
for (let item of this.items) { | |
yield item; | |
} | |
} | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment