C_W

◄ 1 Million EmojisSource7 files · 1191 lines

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).

app/components/lab/EmojiCanvas.vue336 lines

<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>