wandres.dev
INTERACCIÓN EN CANVAS · Hit testing sin DOM

El modelo de escena y los eventos que hay que inventarse

Construir la capa de interacción que el canvas no tiene: identidad y orden de los objetos, un índice espacial, y la máquina de estados que sintetiza hover, clic y arrastre.

⏱ 19 min

El canvas no mantiene el modelo, así que lo mantienes tú. Eso ya lo sabes: sin un array de objetos no hay nada que redibujar. Lo que cambia cuando aparece la interacción es que ese modelo deja de ser una lista de cosas que pintar y pasa a ser la única fuente de verdad sobre qué existe, dónde está, quién está encima de quién y en qué estado se encuentra cada elemento. Un canvas interactivo es, en realidad, una aplicación con estado que casualmente se dibuja en píxeles.

🎯 Al terminar esta lección sabrás
  • Diseñar el modelo de escena con identidad, orden y estado de interacción separados.
  • Implementar una rejilla espacial que reduzca los candidatos de una consulta.
  • Sintetizar entrada, salida, clic y doble clic con una máquina de estados explícita.
  • Implementar el arrastre con captura del puntero y sin fallos en táctil.

El modelo mínimo que la interacción exige

Un modelo pensado solo para dibujar suele ser una lista de formas con posición y color. En cuanto hay interacción, hace falta añadir tres cosas que no tienen nada que ver con el aspecto.

Identidad estable. Cada objeto necesita un identificador que no cambie entre fotogramas ni entre recargas de datos. Comparar por referencia funciona mientras nadie reconstruya la lista; en cuanto los datos vienen de la red y se refrescan, la referencia cambia y el objeto seleccionado se pierde sin motivo aparente.

Orden explícito. El orden de pintado decide quién tapa a quién, y por tanto quién recibe el clic. Si lo dejas implícito en el orden del array, cualquier sort de la interfaz cambia el comportamiento de la interacción. Un campo de profundidad separado del orden del array evita esa dependencia oculta.

Estado de interacción separado del estado del dominio. Que un objeto esté resaltado o seleccionado no es una propiedad del objeto: es una propiedad de la sesión. Guardarlo dentro del objeto parece cómodo y se vuelve un problema al serializar, al comparar y al tener dos vistas del mismo dato.

const escena = {
  objetos: [],                    // datos del dominio, sin estado de interfaz
  porId: new Map(),               // acceso directo por identificador
  orden: [],                      // identificadores ordenados de atras hacia delante
  interaccion: {
    resaltado: null,              // solo identificadores, nunca referencias
    seleccion: new Set(),
    arrastrando: null,
  },
  version: 0,                     // sube cuando cambia algo que obliga a redibujar
};

function anadir(objeto) {
  escena.objetos.push(objeto);
  escena.porId.set(objeto.id, objeto);
  escena.orden.push(objeto.id);
  escena.version++;
}

function dibujarOrdenado(ctx) {
  for (const id of escena.orden) dibujarObjeto(ctx, escena.porId.get(id));
}

function buscarOrdenado(x, y) {
  for (let i = escena.orden.length - 1; i >= 0; i--) {   // al reves: lo de delante primero
    const o = escena.porId.get(escena.orden[i]);
    if (contiene(o, x, y)) return o.id;
  }
  return null;
}

Que resaltado guarde un identificador y no una referencia parece un detalle pedante. Es lo que hace que el resaltado sobreviva a una recarga de datos, y lo que permite comparar estados con una igualdad simple.

El índice espacial: una rejilla uniforme

Recorrer diez mil objetos por consulta funciona; recorrer doscientos mil, no. El índice espacial cambia la pregunta “¿cuál de todos?” por “¿cuál de los pocos que están cerca?”.

De todas las estructuras posibles, la rejilla uniforme es la que mejor relación da entre complejidad y resultado para escenas 2D: cincuenta líneas, inserción y consulta en tiempo casi constante, y ningún equilibrado que mantener.

class Rejilla {
  constructor(tamCelda = 64) {
    this.tam = tamCelda;
    this.celdas = new Map();      // clave "cx,cy" -> array de identificadores
  }

  clave(cx, cy) { return cx + ',' + cy; }

  insertar(id, caja) {
    const c0x = Math.floor(caja.x / this.tam);
    const c0y = Math.floor(caja.y / this.tam);
    const c1x = Math.floor((caja.x + caja.ancho) / this.tam);
    const c1y = Math.floor((caja.y + caja.alto) / this.tam);
    for (let cy = c0y; cy <= c1y; cy++) {
      for (let cx = c0x; cx <= c1x; cx++) {
        const k = this.clave(cx, cy);
        let lista = this.celdas.get(k);
        if (!lista) this.celdas.set(k, (lista = []));
        lista.push(id);
      }
    }
  }

  candidatos(x, y, radio = 0) {
    const fuera = new Set();
    const c0x = Math.floor((x - radio) / this.tam);
    const c0y = Math.floor((y - radio) / this.tam);
    const c1x = Math.floor((x + radio) / this.tam);
    const c1y = Math.floor((y + radio) / this.tam);
    for (let cy = c0y; cy <= c1y; cy++) {
      for (let cx = c0x; cx <= c1x; cx++) {
        const lista = this.celdas.get(this.clave(cx, cy));
        if (lista) for (const id of lista) fuera.add(id);
      }
    }
    return fuera;
  }

  vaciar() { this.celdas.clear(); }
}

Un objeto que ocupa varias celdas se inserta en todas, y por eso la consulta devuelve un conjunto y no un array: evita duplicados sin ordenar nada.

Dos decisiones determinan si la rejilla ayuda o estorba. El tamaño de celda debería ser del orden del tamaño típico de un objeto: celdas mucho más pequeñas multiplican las inserciones, y celdas mucho más grandes devuelven demasiados candidatos. Cuándo se reconstruye: con objetos estáticos, una vez; con objetos que se mueven, lo barato es reconstruirla entera al inicio de cada fotograma, que para cien mil objetos son unos pocos milisegundos y evita toda la contabilidad de mover elementos entre celdas.

El resultado se combina con la consulta exacta, respetando el orden de profundidad:

function buscarConIndice(rejilla, x, y, tolerancia = 0) {
  const cand = rejilla.candidatos(x, y, tolerancia);
  for (let i = escena.orden.length - 1; i >= 0; i--) {
    const id = escena.orden[i];
    if (!cand.has(id)) continue;              // descartado por el indice
    if (contiene(escena.porId.get(id), x, y, tolerancia)) return id;
  }
  return null;
}

Sintetizar los eventos

Con la consulta resuelta, todo lo demás es una máquina de estados sobre dos datos: qué hay bajo el puntero ahora y qué había antes.

class Interaccion {
  constructor(canvas, buscar, alEvento) {
    this.canvas = canvas;
    this.buscar = buscar;              // (x, y) -> id o null
    this.emitir = alEvento;            // (tipo, id, detalle) -> void
    this.sobre = null;
    this.pulsadoSobre = null;
    this.ultimoClic = { id: null, t: 0 };
    this.pendiente = null;
    this.inicioPulsacion = null;
    canvas.addEventListener('pointermove', e => { this.pendiente = this.punto(e); });
    canvas.addEventListener('pointerleave', () => { this.pendiente = null; this.salir(); });
    canvas.addEventListener('pointerdown', e => this.abajo(e));
    canvas.addEventListener('pointerup', e => this.arriba(e));
  }

  punto(e) {
    const caja = this.canvas.getBoundingClientRect();
    return {
      x: (e.clientX - caja.left) * (this.canvas.width / caja.width),
      y: (e.clientY - caja.top) * (this.canvas.height / caja.height),
      t: e.timeStamp,
      idPuntero: e.pointerId,
      tipo: e.pointerType,
    };
  }

  salir() {
    if (this.sobre !== null) { this.emitir('salir', this.sobre); this.sobre = null; }
  }

  // Se llama una vez por fotograma, no por evento
  actualizar() {
    if (!this.pendiente) return;
    const p = this.pendiente;
    this.pendiente = null;
    const id = this.buscar(p.x, p.y);
    if (id !== this.sobre) {
      if (this.sobre !== null) this.emitir('salir', this.sobre);
      this.sobre = id;
      if (id !== null) this.emitir('entrar', id);
      this.canvas.style.cursor = id !== null ? 'pointer' : 'default';
    }
    if (this.inicioPulsacion) this.emitir('arrastrar', this.pulsadoSobre, p);
  }

  abajo(e) {
    const p = this.punto(e);
    this.canvas.setPointerCapture(e.pointerId);
    this.pulsadoSobre = this.buscar(p.x, p.y);
    this.inicioPulsacion = p;
    if (this.pulsadoSobre !== null) this.emitir('pulsar', this.pulsadoSobre, p);
  }

  arriba(e) {
    const p = this.punto(e);
    const id = this.buscar(p.x, p.y);
    const movido = this.inicioPulsacion
      ? Math.hypot(p.x - this.inicioPulsacion.x, p.y - this.inicioPulsacion.y) > 4
      : false;
    if (!movido && id !== null && id === this.pulsadoSobre) {
      const doble = id === this.ultimoClic.id && p.t - this.ultimoClic.t < 300;
      this.emitir(doble ? 'dobleclic' : 'clic', id, p);
      this.ultimoClic = doble ? { id: null, t: 0 } : { id, t: p.t };
    }
    if (this.inicioPulsacion && movido) this.emitir('soltar', this.pulsadoSobre, p);
    this.canvas.releasePointerCapture(e.pointerId);
    this.pulsadoSobre = null;
    this.inicioPulsacion = null;
  }
}

Tres decisiones que no son evidentes y que evitan comportamientos raros. El clic exige el mismo objeto en la pulsación y en la soltada, igual que hace el navegador con los botones; sin eso, arrastrar desde un botón y soltar sobre otro dispara un clic que el usuario no ha hecho. El umbral de cuatro píxeles distingue un clic de un arrastre corto y es lo que impide que un temblor de mano cancele un clic. Y el doble clic reinicia el contador, para que tres clics seguidos no cuenten como dos dobles.

El arrastre táctil no funciona por CSS, no por JavaScript, y se pierde una tarde entera

Escribes el arrastre, lo pruebas con el ratón y funciona a la primera. Lo abres en un móvil y al deslizar el dedo la página hace scroll mientras el objeto se queda quieto o da un salto y se congela. El código de JavaScript es correcto; el problema está en el CSS, y es de los que se buscan durante horas en el sitio equivocado. La causa es touch-action. Por defecto, el navegador reserva los gestos de desplazamiento y de zoom para sí mismo, y en cuanto decide que tu deslizamiento es un scroll, cancela el puntero: te envía un pointercancel y deja de mandarte movimientos para siempre en ese gesto. La solución es una línea de CSS sobre el canvas: touch-action: none desactiva por completo la gestión táctil del navegador sobre ese elemento, o touch-action: pan-y si quieres conservar el scroll vertical de la página y solo capturar el movimiento horizontal. Sin eso, ningún arrastre táctil funciona de forma fiable, hagas lo que hagas en JavaScript. Y como corolario: escucha pointercancel siempre y trátalo como una soltada abortada, porque llega también cuando el sistema abre el menú contextual del dedo largo o cuando se acaba la batería del lápiz. El segundo asunto de la misma familia es la captura del puntero. Sin setPointerCapture, en cuanto el dedo o el cursor sale del canvas dejas de recibir eventos: el objeto se queda pegado en el borde y al volver a entrar aparece un estado incoherente. Con captura, los eventos siguen llegando a tu elemento aunque el puntero esté fuera de la ventana, que es exactamente el comportamiento de cualquier arrastre nativo. Hay un tercero, menos conocido: si además escuchas eventos de rueda para hacer zoom, el manejador no puede ser pasivo si vas a llamar a preventDefault, y los navegadores registran algunos de estos manejadores como pasivos por defecto. Se resuelve declarándolo explícitamente con passive: false al añadir el escuchador. Los tres problemas comparten la misma moraleja: la interacción en canvas empieza en la configuración del elemento, no en el código de dibujo.

El arrastre completo

Con las piezas anteriores, el arrastre son unas pocas líneas más, y conviene ver el ensamblaje entero porque es donde se aprecia que el modelo lo aguanta todo.

<canvas id="c" style="width:600px;height:360px;display:block;touch-action:none"></canvas>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let desplazamiento = null;

const interaccion = new Interaccion(canvas, buscarEnEscena, (tipo, id, p) => {
  const o = escena.porId.get(id);
  switch (tipo) {
    case 'entrar':  escena.interaccion.resaltado = id; escena.version++; break;
    case 'salir':   escena.interaccion.resaltado = null; escena.version++; break;
    case 'pulsar':
      desplazamiento = { dx: p.x - o.x, dy: p.y - o.y };
      escena.interaccion.arrastrando = id;
      escena.orden.splice(escena.orden.indexOf(id), 1);
      escena.orden.push(id);                  // traer al frente
      escena.version++;
      break;
    case 'arrastrar':
      if (!desplazamiento || id === null) break;
      o.x = p.x - desplazamiento.dx;
      o.y = p.y - desplazamiento.dy;
      escena.version++;
      break;
    case 'soltar':
      desplazamiento = null;
      escena.interaccion.arrastrando = null;
      escena.version++;
      break;
    case 'clic':
      escena.interaccion.seleccion.has(id)
        ? escena.interaccion.seleccion.delete(id)
        : escena.interaccion.seleccion.add(id);
      escena.version++;
      break;
  }
});

let ultimaVersion = -1;
function marco() {
  interaccion.actualizar();
  if (escena.version !== ultimaVersion) {
    dibujarOrdenado(ctx);
    ultimaVersion = escena.version;
  }
  requestAnimationFrame(marco);
}
requestAnimationFrame(marco);

El contador version hace de invalidación global: cualquier cambio lo incrementa y el bucle redibuja solo cuando ha cambiado. Es la forma más simple de no redibujar sesenta veces por segundo una escena que está quieta, y es la base sobre la que se construye después el redibujado por región.