249 lines
6.6 KiB
JavaScript
249 lines
6.6 KiB
JavaScript
|
const {cos, sin, min, max, atan2, acos, sqrt, abs, PI} = Math
|
||
|
const clamp = (a, b, x) => x < a ? a : x > b ? b : x
|
||
|
const on = (t, e, f) => t.addEventListener(e, f)
|
||
|
const ons = (t, es, f) => es.forEach(e => t.addEventListener(e, f))
|
||
|
const canvasses = {}
|
||
|
const ratio = devicePixelRatio
|
||
|
|
||
|
const vec = (x = 0, y = 0, z = 0) => ({x, y, z})
|
||
|
|
||
|
vec.set = (o, x = 0, y = 0, z = 0) => {
|
||
|
o.x = x
|
||
|
o.y = y
|
||
|
o.z = z
|
||
|
return o
|
||
|
}
|
||
|
|
||
|
const X = vec(1, 0, 0)
|
||
|
const Y = vec(0, 1, 0)
|
||
|
const Z = vec(0, 0, 1)
|
||
|
|
||
|
// vec used for intermediate results
|
||
|
// avoids creating a bunch of new objects in loops
|
||
|
|
||
|
const _V = vec()
|
||
|
|
||
|
// prevent out of view canvasses from animating
|
||
|
|
||
|
const observer = new IntersectionObserver((entries, obv) =>
|
||
|
entries.forEach(({ target, isIntersecting }) => {
|
||
|
let canvas = canvasses[target.id]
|
||
|
canvas.visible = isIntersecting
|
||
|
if (canvas.animate) {
|
||
|
if (entry.isIntersecting) canvas.step()
|
||
|
else cancelAnimationFrame(canvas.stepId)
|
||
|
}
|
||
|
else if (canvas.mustRedraw && isIntersecting) {
|
||
|
canvas.render()
|
||
|
canvas.mustRedraw = false
|
||
|
}
|
||
|
}), {})
|
||
|
|
||
|
|
||
|
// project point on orbital camera
|
||
|
|
||
|
function project(o, {theta, phi}, {x, y, z}) {
|
||
|
let ct = cos(theta), st = sin(theta)
|
||
|
let cp = cos(phi), sp = sin(phi)
|
||
|
let a = x * ct + y * st
|
||
|
o.x = y * ct - x * st
|
||
|
o.y = cp * z - sp * a
|
||
|
o.z = cp * a + sp * z
|
||
|
return o
|
||
|
}
|
||
|
|
||
|
// adaptative color
|
||
|
|
||
|
const palette = {
|
||
|
red : ['#f00', '#f0f'],
|
||
|
green : ['#0f0', '#0f0'],
|
||
|
blue : ['#00f', '#0ff'],
|
||
|
cyan : ['#0ff', '#00f'],
|
||
|
yellow : ['#f8c325', '#f8c325'],
|
||
|
dark : ['#333', '#fff'],
|
||
|
light : ['#bbb', '#666'],
|
||
|
}
|
||
|
|
||
|
let colorscheme = matchMedia('(prefers-color-scheme: dark)').matches || 'light'
|
||
|
|
||
|
const color = name => palette[name][colorscheme == 'light' ? 0 : 1]
|
||
|
|
||
|
on(matchMedia('(prefers-color-scheme: dark)'), 'change', ({matches}) => {
|
||
|
colorscheme = matches
|
||
|
console.log(colorscheme)
|
||
|
Object.values(canvasses).forEach(cvs => {
|
||
|
if (cvs.animate) return
|
||
|
if (cvs.visible) cvs.render()
|
||
|
else cvs.mustRedraw = true
|
||
|
})
|
||
|
})
|
||
|
|
||
|
// generic canvas
|
||
|
|
||
|
class Canvas {
|
||
|
constructor(id, meta) {
|
||
|
this.id = id
|
||
|
this.meta = meta
|
||
|
this.cvs = window[id]
|
||
|
this.ctx = this.cvs.getContext('2d')
|
||
|
this.mustRedraw = false
|
||
|
this.hasFocus = false
|
||
|
|
||
|
if (meta.init) meta.init.call(this)
|
||
|
|
||
|
on(window, 'resize', this.resize.bind(this))
|
||
|
this.resize()
|
||
|
|
||
|
if (meta.inputs) {
|
||
|
meta.inputs.forEach(el => on(el, 'input', this.render.bind(this)))
|
||
|
}
|
||
|
|
||
|
observer.observe(this.cvs)
|
||
|
canvasses[id] = this
|
||
|
|
||
|
if (meta.animate) this.stepId = requestAnimationFrame(this.step.bind(this))
|
||
|
}
|
||
|
|
||
|
resize() {
|
||
|
let width = this.width = this.cvs.clientWidth
|
||
|
let height = this.height = this.cvs.clientHeight
|
||
|
|
||
|
this.cvs.width = ratio * width
|
||
|
this.cvs.height = ratio * height
|
||
|
|
||
|
this.ctx.scale(ratio, ratio)
|
||
|
this.ctx.lineCap = 'round'
|
||
|
this.render()
|
||
|
}
|
||
|
|
||
|
render() {
|
||
|
this.ctx.save()
|
||
|
this.meta.render.call(this, this.ctx)
|
||
|
this.ctx.restore()
|
||
|
}
|
||
|
|
||
|
step() {
|
||
|
this.stepId = requestAnimationFrame(this.step.bind(this))
|
||
|
this.render()
|
||
|
}
|
||
|
|
||
|
drawCircle(x, y, r, c, w = 1) {
|
||
|
let ctx = this.ctx
|
||
|
ctx.strokeStyle = c
|
||
|
ctx.lineWidth = w
|
||
|
ctx.beginPath()
|
||
|
ctx.arc(x, y, r, 0, 2 * PI)
|
||
|
ctx.stroke()
|
||
|
}
|
||
|
|
||
|
drawVec(camera, v, r, c, w = 1) {
|
||
|
let ctx = this.ctx
|
||
|
let {x, y} = project(_V, camera, v)
|
||
|
ctx.lineWidth = w
|
||
|
ctx.strokeStyle = c
|
||
|
ctx.beginPath()
|
||
|
ctx.moveTo(0, 0)
|
||
|
ctx.lineTo(x * r, y * r)
|
||
|
ctx.stroke()
|
||
|
}
|
||
|
|
||
|
// draw 3d basis at (0, 0), from orbital camera
|
||
|
|
||
|
drawBasis(camera, r) {
|
||
|
this.drawVec(camera, X, r, color('red'), 2)
|
||
|
this.drawVec(camera, Y, r, color('green'), 2)
|
||
|
this.drawVec(camera, Z, r, color('blue'), 2)
|
||
|
}
|
||
|
|
||
|
// given a sphere at (0, 0) of radius R, draw sphere section
|
||
|
// along normal n and offset o, from orbital camera
|
||
|
|
||
|
drawSectionFront(camera, n, R, o = 0) {
|
||
|
let {x, y, z} = project(_V, camera, n) // project normal on camera
|
||
|
let a = atan2(y, x) // angle of projected normal -> angle of ellipse
|
||
|
let ry = sqrt(1 - o * o) // radius of section -> y-radius of ellipse
|
||
|
let rx = ry * abs(z) // x-radius of ellipse
|
||
|
|
||
|
let W = sqrt(x * x + y * y)
|
||
|
|
||
|
let sa = acos(clamp(-1, 1, o * (1 / W - W) / rx || 0)) // ellipse start angle
|
||
|
let sb = z > 0 ? 2 * PI - sa : - sa // ellipse end angle
|
||
|
let ctx = this.ctx
|
||
|
|
||
|
|
||
|
ctx.beginPath()
|
||
|
ctx.ellipse(x * o * R, y * o * R, rx * R, ry * R, a, sa, sb, z <= 0)
|
||
|
ctx.stroke()
|
||
|
}
|
||
|
|
||
|
pathSection(camera, n, R, o = 0) {
|
||
|
let {x, y, z} = project(_V, camera, n)
|
||
|
let a = atan2(y, x)
|
||
|
let ry = sqrt(1 - o * o)
|
||
|
let rx = ry * abs(z)
|
||
|
this.ctx.ellipse(x * o * R, y * o * R, rx * R, ry * R, a, 0, 2 * PI)
|
||
|
}
|
||
|
|
||
|
drawSection(camera, n, R, o, c, w) {
|
||
|
this.ctx.strokeStyle = c
|
||
|
this.ctx.lineWidth = w
|
||
|
this.ctx.beginPath()
|
||
|
this.pathSection(camera, n, R, o)
|
||
|
this.ctx.stroke()
|
||
|
}
|
||
|
|
||
|
drawWireframe(camera, r) {
|
||
|
this.drawSectionFront(camera, X, r)
|
||
|
this.drawSectionFront(camera, Y, r)
|
||
|
this.drawSectionFront(camera, Z, r)
|
||
|
}
|
||
|
|
||
|
drawSections(camera, r) {
|
||
|
this.drawSectionFront(camera, X, r)
|
||
|
this.drawSectionFront(camera, X, r, .5)
|
||
|
this.drawSectionFront(camera, X, r, -.5)
|
||
|
this.drawSectionFront(camera, Y, r)
|
||
|
this.drawSectionFront(camera, Y, r, .5)
|
||
|
this.drawSectionFront(camera, Y, r, -.5)
|
||
|
this.drawSectionFront(camera, Z, r)
|
||
|
this.drawSectionFront(camera, Z, r, .5)
|
||
|
this.drawSectionFront(camera, Z, r, -.5)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function setup_orbiter(cvs, theta, phi) {
|
||
|
cvs.cvs.classList.toggle('grabber')
|
||
|
|
||
|
// TODO: parametrized scaling
|
||
|
|
||
|
// mouse events
|
||
|
on(cvs.cvs, 'mousedown', e => {
|
||
|
cvs.grabbing = true
|
||
|
cvs.cvs.style.setProperty('cursor', 'grabbing')
|
||
|
document.body.style.setProperty('cursor', 'grabbing')
|
||
|
})
|
||
|
on(window, 'mousemove', e => {
|
||
|
if (!cvs.grabbing) return
|
||
|
e.preventDefault()
|
||
|
phi.value = clamp(-1, 1, parseFloat(phi.value) + e.movementY / 400)
|
||
|
let th = (parseFloat(theta.value) - e.movementX / 800 + 1) % 1
|
||
|
theta.value = th
|
||
|
if (!cvs.animate) cvs.render()
|
||
|
})
|
||
|
on(window, 'mouseup', e => {
|
||
|
if (!cvs.grabbing) return
|
||
|
cvs.cvs.style.removeProperty('cursor')
|
||
|
document.body.style.removeProperty('cursor')
|
||
|
cvs.grabbing = false
|
||
|
})
|
||
|
|
||
|
/* Ok, SO: for the orbiter to work on mobile and NOT interfere with regular scrolling
|
||
|
* we require the canvas to be focused BEFORE (hence tabindex="-1" on the element).
|
||
|
* A bit sad to require one more interaction from the user,
|
||
|
* but I can't stand having interactive canvases prevent me from scrolling so this is the lesser
|
||
|
* of two evils, I hope.
|
||
|
*/
|
||
|
on(cvs.cvs, 'focus', () => cvs.hasFocus = true)
|
||
|
on(cvs.cvs, 'blur', () => cvs.hasFocus = false)
|
||
|
}
|