The code behind the canvas.
The canvas’s browser code: the page, the canvas and its renderer (WebGPU through vgpu, else Canvas 2D), the live stream and the palette, collected from the site’s repository each time the site is built, so what you read is what runs. The rules they share and Jev’s painter are open source too, in cwdx/1-million-emojis (MIT).
<script setup lang="ts">
import { cellAt, cellLabel, line, SIZE, xy } from '@cw/emoji/canvas'
import { emojiInfo } from '@cw/emoji'
import { painterOf } from '#core/domain/painters'
import type { EmojiRenderer, EmojiScene } from '#infra/graphics/emoji-render'
import type { CanvasCell } from '~/composables/useEmojiCanvas'
const props = defineProps<{
cells: Map<number, CanvasCell>
version: number
arrived: Map<number, number>
brush: string
tool: 'paint' | 'move' | 'pick'
ink: number
label: string
/** Jev weighing its options: each shown faintly, by how likely it is. */
ghosts?: { cell: number; emoji: string; p: number }[]
}>()
const emit = defineEmits<{ stroke: [number[]]; pick: [string]; need: [number, number, number, number]; busy: [boolean] }>()
// the cell at the centre (fractional) and a cell's size in CSS pixels
const view = defineModel<{ x: number; y: number; size: number }>('view', { required: true })
const MIN = 3, MAX = 64
const box = ref<HTMLElement>()
const canvas = ref<HTMLCanvasElement>()
const width = ref(0), height = ref(0)
const hover = ref<number>()
const stroke = ref<number[]>([])
const status = refAutoReset('', 1600)
const colors: Record<string, string> = {}
const toCell = (px: number, py: number) => {
const s = view.value.size
return cellAt(Math.floor(view.value.x + (px - width.value / 2) / s), Math.floor(view.value.y + (py - height.value / 2) / s))
}
const bounds = () => {
const s = view.value.size, hw = width.value / 2 / s, hh = height.value / 2 / s
return { x0: Math.floor(view.value.x - hw), y0: Math.floor(view.value.y - hh), x1: Math.ceil(view.value.x + hw), y1: Math.ceil(view.value.y + hh) }
}
let renderer: EmojiRenderer | undefined
// on touch screens the grid's hairlines vanish, so alternate cells are shaded instead
const coarse = useMediaQuery('(pointer: coarse)')
watch(coarse, () => redraw())
// a ring, not the arrival flash: a cell marked as arrived is one the tiles must not overwrite
const pinged = refAutoReset<{ cell: number; key: number } | undefined>(undefined, 2000)
const centreOf = (cell: number) => {
const { x, y } = xy(cell), s = view.value.size
return { left: `${width.value / 2 + (x + 0.5 - view.value.x) * s}px`, top: `${height.value / 2 + (y + 0.5 - view.value.y) * s}px` }
}
const pingStyle = computed(() => (pinged.value ? { ...centreOf(pinged.value.cell), '--ring': `${Math.max(view.value.size, 20)}px` } : {}))
const ghostStyle = (g: { cell: number; p: number }) => ({ ...centreOf(g.cell), '--cell': `${view.value.size}px`, 'opacity': 0.3 + 0.7 * g.p })
const ping = (cell: number) => { pinged.value = { cell, key: Date.now() } }
const renderMode = ref<'webgpu' | 'canvas'>()
const canvasKey = ref(0)
let frame = 0
const redraw = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(draw) }
function draw() {
if (!renderer) return
const scene: EmojiScene = {
...view.value, width: width.value, height: height.value, cells: props.cells, arrived: props.arrived,
pending: new Set(stroke.value), brush: props.brush, hover: hover.value, colors: colors as EmojiScene['colors'], checker: coarse.value,
}
if (renderer.render(scene)) redraw()
}
function resize() {
const el = box.value
if (!el) return
width.value = el.clientWidth
height.value = el.clientHeight
renderer?.resize(width.value, height.value)
view.value = clampView(view.value)
redraw()
}
function clampView(v: { x: number; y: number; size: number }) {
const size = Math.min(MAX, Math.max(MIN, v.size))
const keep = (c: number, half: number) => (half * 2 >= SIZE ? SIZE / 2 : Math.min(SIZE - half, Math.max(half, c)))
return { size, x: keep(v.x, width.value / 2 / size), y: keep(v.y, height.value / 2 / size) }
}
// Like a map: buttons, keys and a double-click zoom with an ease, a released drag glides on and slows, and any touch
// of the canvas stops either. Under reduced motion every change lands at once.
const reduced = useReducedMotion()
let easing = 0
const stopEasing = () => cancelAnimationFrame(easing)
function easeTo(to: { x: number; y: number; size: number }, ms = 260) {
stopEasing()
const end = clampView(to), from = { ...view.value }, t0 = performance.now()
if (reduced) { view.value = end; return }
const step = (t: number) => {
const k = Math.min(1, (t - t0) / ms), e = 1 - (1 - k) ** 3
// the size on a log scale, so each step of the zoom feels even
view.value = { x: from.x + (end.x - from.x) * e, y: from.y + (end.y - from.y) * e, size: Math.exp(Math.log(from.size) + (Math.log(end.size) - Math.log(from.size)) * e) }
if (k < 1) easing = requestAnimationFrame(step)
}
easing = requestAnimationFrame(step)
}
/** The view after zooming by `factor` about the point (px, py), which stays under the pointer. */
function zoomed(factor: number, px: number, py: number) {
const v = view.value, size = Math.min(MAX, Math.max(MIN, v.size * factor))
const wx = v.x + (px - width.value / 2) / v.size, wy = v.y + (py - height.value / 2) / v.size
return { size, x: wx - (px - width.value / 2) / size, y: wy - (py - height.value / 2) / size }
}
function zoomAt(factor: number, px = width.value / 2, py = height.value / 2, smooth = false) {
if (smooth) easeTo(zoomed(factor, px, py))
else { stopEasing(); view.value = clampView(zoomed(factor, px, py)) }
}
const panBy = (dx: number, dy: number) => { const v = view.value; view.value = clampView({ ...v, x: v.x - dx / v.size, y: v.y - dy / v.size }) }
// the drag's speed, in CSS pixels a millisecond, smoothed over its last moves
let fling = { vx: 0, vy: 0, at: 0 }
function glideOn() {
let { vx, vy } = fling, last = performance.now()
if (reduced || Math.hypot(vx, vy) < 0.15) return
const step = (t: number) => {
const dt = Math.min(48, t - last), decay = 0.94 ** (dt / 16)
last = t
panBy(vx * dt, vy * dt)
vx *= decay; vy *= decay
if (Math.hypot(vx, vy) > 0.02) easing = requestAnimationFrame(step)
}
stopEasing()
easing = requestAnimationFrame(step)
}
// A double tap or click zooms in about that point (with shift, out), in every tool and on touch, where no dblclick comes.
// So a single tap on one cell paints only once no second tap follows: a drag paints at once.
const TAP_GAP = 250
let lastTap = { at: 0, x: 0, y: 0 }, downAt = 0
const tapped = useTimeoutFn((cells: number[]) => { emit('stroke', cells); stroke.value = []; redraw() }, TAP_GAP, { immediate: false })
const pointers = new Map<number, { x: number; y: number }>()
let mode: 'paint' | 'pan' | undefined
let space = false
let start = { x: 0, y: 0 }
const local = (e: PointerEvent | WheelEvent) => { const r = canvas.value!.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top } }
function pickAt(p: { x: number; y: number }) {
const cell = toCell(p.x, p.y), c = cell === undefined ? undefined : props.cells.get(cell)
if (!c) { status.value = 'Nothing to pick up there'; return }
emit('pick', c.emoji)
status.value = `Picked ${c.emoji}`
navigator.vibrate?.(12)
}
// until a hold is ruled out the stroke does not grow, so a wobble across a cell edge is not a stroke
const HOLD = 400
let slack = 6
let holding = false
const press = useTimeoutFn((p: { x: number; y: number }) => { if (mode === 'paint' && holding) { mode = undefined; holding = false; stroke.value = []; pickAt(p); redraw() } }, HOLD, { immediate: false })
// `busy` lets the page follow new paint only when that disturbs nobody
const TOUCH_REST = 4000
const rest = useTimeoutFn(() => emit('busy', false), TOUCH_REST, { immediate: false })
function busy(on: boolean, touch = false) {
rest.stop()
if (on || !touch) emit('busy', on)
else rest.start()
}
function onDown(e: PointerEvent) {
stopEasing()
fling = { vx: 0, vy: 0, at: performance.now() }
if (e.pointerType !== 'mouse') busy(true, true)
try { canvas.value!.setPointerCapture(e.pointerId) } catch {}
const p = local(e)
pointers.set(e.pointerId, p)
const now = performance.now()
if (pointers.size === 1 && now - lastTap.at < TAP_GAP + 50 && Math.hypot(p.x - lastTap.x, p.y - lastTap.y) < 24) {
tapped.stop(); stroke.value = []; lastTap.at = 0; mode = undefined
zoomAt(e.shiftKey ? 0.5 : 2, p.x, p.y, true)
return
}
downAt = now
start = p
if (pointers.size === 2) { tapped.stop(); mode = 'pan'; stroke.value = []; press.stop(); return }
if (props.tool === 'pick' || e.altKey) { pickAt(p); mode = undefined; return }
if (props.tool === 'move' || e.button === 1 || e.button === 2 || space) { mode = 'pan'; return }
const cell = toCell(p.x, p.y)
if (cell === undefined || props.ink < 1) { mode = undefined; if (props.ink < 1) status.value = 'Out of ink'; return }
mode = 'paint'
stroke.value = [cell]
slack = e.pointerType === 'mouse' ? 6 : 14
holding = true
press.start(p)
redraw()
}
function onMove(e: PointerEvent) {
const p = local(e), prev = pointers.get(e.pointerId)
hover.value = e.pointerType === 'mouse' ? toCell(p.x, p.y) : undefined
hoverAt.value = e.pointerType === 'mouse' ? p : undefined
if (!prev) { redraw(); return }
if (pointers.size === 2) {
const [a, b] = [...pointers.values()]
const other = a === prev ? b! : a!
const before = Math.hypot(prev.x - other.x, prev.y - other.y), after = Math.hypot(p.x - other.x, p.y - other.y)
pointers.set(e.pointerId, p)
panBy((p.x - prev.x) / 2, (p.y - prev.y) / 2)
if (before > 0) zoomAt(after / before, (p.x + other.x) / 2, (p.y + other.y) / 2)
return
}
pointers.set(e.pointerId, p)
if (mode === 'pan') {
const now = performance.now(), dt = Math.max(1, now - fling.at)
fling = { vx: fling.vx * 0.6 + ((p.x - prev.x) / dt) * 0.4, vy: fling.vy * 0.6 + ((p.y - prev.y) / dt) * 0.4, at: now }
panBy(p.x - prev.x, p.y - prev.y)
return
}
if (mode === 'paint') {
if (holding && Math.hypot(p.x - start.x, p.y - start.y) <= slack) return
if (holding) { holding = false; press.stop() }
const cell = toCell(p.x, p.y), last = stroke.value.at(-1)
if (cell === undefined || last === undefined || cell === last) return
const seen = new Set(stroke.value), next = [...stroke.value]
for (const c of line(last, cell).slice(1)) if (!seen.has(c) && next.length < props.ink) { seen.add(c); next.push(c) }
stroke.value = next
redraw()
}
}
function onUp(e: PointerEvent) {
pointers.delete(e.pointerId)
if (e.pointerType !== 'mouse' && !pointers.size) busy(false, true)
press.stop()
holding = false
const p = local(e), now = performance.now()
const tap = e.type === 'pointerup' && now - downAt < 300 && Math.hypot(p.x - start.x, p.y - start.y) < 12
if (tap) lastTap = { at: now, x: p.x, y: p.y }
let waiting = false
if (mode === 'paint' && stroke.value.length && e.type === 'pointerup') {
if (tap && stroke.value.length === 1) { waiting = true; tapped.start([...stroke.value]) } else emit('stroke', stroke.value)
}
// a drag that ends while still moving glides on; one that paused first stays put
if (mode === 'pan' && !pointers.size && e.type === 'pointerup' && performance.now() - fling.at < 60) glideOn()
if (!pointers.size) { mode = undefined; if (!waiting) stroke.value = [] }
redraw()
}
function onWheel(e: WheelEvent) {
e.preventDefault()
stopEasing()
const p = local(e)
// a pinch (ctrlKey) or a mouse wheel zooms; a trackpad's two-finger scroll pans
const mouse = e.deltaMode === 1 || (Math.abs(e.deltaY) >= 50 && e.deltaX === 0 && Number.isInteger(e.deltaY))
if (e.ctrlKey || mouse) zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0025)), p.x, p.y)
else panBy(-e.deltaX, -e.deltaY)
}
function onKey(e: KeyboardEvent) {
const step = 8 * view.value.size
const keys: Record<string, () => void> = {
'ArrowLeft': () => panBy(step, 0), 'ArrowRight': () => panBy(-step, 0), 'ArrowUp': () => panBy(0, step), 'ArrowDown': () => panBy(0, -step),
'+': () => zoomAt(1.5, undefined, undefined, true), '=': () => zoomAt(1.5, undefined, undefined, true), '-': () => zoomAt(1 / 1.5, undefined, undefined, true),
}
if (e.key === ' ') { space = e.type === 'keydown'; e.preventDefault(); return }
if (e.type === 'keydown' && keys[e.key]) { keys[e.key]!(); e.preventDefault() }
}
async function mount(canvas2dOnly = false) {
const { mountEmojiRenderer } = await import('#infra/graphics/emoji-render')
try {
renderer = await mountEmojiRenderer(canvas.value!, { canvas2dOnly })
} catch {
// WebGPU failed after claiming the canvas, which a 2D context then cannot use
canvasKey.value++
await nextTick()
return mount(true)
}
renderMode.value = renderer.mode
resize()
}
useEventListener(() => (renderMode.value ? canvas.value : undefined), 'wheel', onWheel, { passive: false })
useResizeObserver(box, resize)
onMounted(() => {
const css = getComputedStyle(document.documentElement)
for (const k of ['paper', 'enamel', 'stone', 'amber', 'amber-light']) colors[k] = css.getPropertyValue(`--color-${k}`).trim()
void mount()
})
onBeforeUnmount(() => { cancelAnimationFrame(frame); stopEasing(); renderer?.destroy() })
watch(() => props.version, redraw)
watch(() => props.brush, redraw)
watch([view, width, height], () => {
redraw()
const { x0, y0, x1, y1 } = bounds()
emit('need', x0, y0, x1, y1)
}, { deep: true })
// Hovering a painted cell, a HUD card beside the pointer: the emoji by name, where, and (after a moment, from the server)
// who painted it last, when, and how often the cell has been painted. Mouse only; a touch paints.
type History = { emoji: string | null; painter?: 'human' | 'jev'; who?: string; at?: string; times?: number }
const histories = new Map<number, History>()
const history = ref<History>()
const hoverAt = ref<{ x: number; y: number }>()
watchDebounced(hover, async (cell) => {
history.value = undefined
if (cell === undefined || !props.cells.has(cell)) return
const known = histories.get(cell)
if (known && known.emoji === props.cells.get(cell)?.emoji) { history.value = known; return }
try {
const h = await $fetch<History>('/api/emoji/cell', { query: { cell } })
histories.set(cell, h)
if (hover.value === cell) history.value = h
} catch {}
}, { debounce: 250 })
const since = (at: string) => { const s = Math.max(0, (Date.now() - Date.parse(at)) / 1000); return s < 60 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
const card = computed(() => {
const cell = hover.value, c = cell === undefined ? undefined : props.cells.get(cell)
if (cell === undefined || !c || !hoverAt.value || mode) return
const h = history.value
const by = h?.painter === 'jev' || c.jev ? 'Jev' : h?.who ? painterOf(h.who).name : undefined
return {
head: `${c.emoji} ${emojiInfo(c.emoji).name}`,
where: cellLabel(cell),
by: h?.at ? `${by ?? 'Someone'} · ${since(h.at)}${h.times && h.times > 1 ? ` · painted ${h.times}×` : ''}` : by,
style: { left: `${Math.min(hoverAt.value.x + 16, width.value - 220)}px`, top: `${Math.min(hoverAt.value.y + 16, height.value - 70)}px` },
}
})
const hoverLabel = computed(() => {
// a painted cell has the card instead
if (hover.value === undefined || card.value) return ''
const c = props.cells.get(hover.value)
return `${cellLabel(hover.value)}${c ? ` ${c.emoji}${c.jev ? ' · Jev' : ''}` : ''}`
})
/** The page's zoom buttons: eased, about the middle. */
const zoomBy = (factor: number) => zoomAt(factor, undefined, undefined, true)
defineExpose({ zoomBy, easeTo, renderMode, ping })
</script>
<template>
<div ref="box" class="canvas-box" :class="`is-${tool}`">
<canvas
:key="canvasKey" ref="canvas" class="canvas" tabindex="0" role="img" :aria-label="label" :data-mode="renderMode"
@pointerdown="onDown" @pointermove="onMove" @pointerup="onUp" @pointercancel="onUp" @pointerenter="$event.pointerType === 'mouse' && busy(true)" @pointerleave="hover = undefined; redraw(); $event.pointerType === 'mouse' && busy(false)"
@keydown="onKey" @keyup="onKey" @contextmenu.prevent
/>
<p v-if="!renderMode" class="canvas__loading mono-label"><GlyphSpinner set="shade" /> Drawing the canvas</p>
<span v-if="pinged" :key="pinged.key" class="canvas__ping" :style="pingStyle" aria-hidden="true" />
<p v-if="card" class="canvas__card mono-label" :style="card.style" aria-hidden="true">
<span class="canvas__card-head">{{ card.head }}</span><span>{{ card.where }}</span><span v-if="card.by">{{ card.by }}</span>
</p>
<span v-for="g in ghosts" :key="`${g.cell}-${g.emoji}`" class="canvas__ghost" :style="ghostStyle(g)" aria-hidden="true">{{ g.emoji }}</span>
<!-- on touch screens a lane down each side scrolls the page, so a thumb near the edge never paints -->
<span class="canvas__lane canvas__lane--left" aria-hidden="true" /><span class="canvas__lane canvas__lane--right" aria-hidden="true" />
<p class="canvas__at mono-label" aria-live="polite">{{ hoverLabel || status }}</p>
</div>
</template>