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