Saltar al contenido

La interacción: lo que SVG regala y el lienzo te cobra

Los eventos por elemento y el CSS de estado frente a las cuatro técnicas de hit testing en canvas, con el coste real de cada una y el detalle del alfa que rompe la selección por color.

Lo que SVG regala

Cada marca es un elemento, así que:

CSS
.barra { fill: #89b4fa; transition: fill .15s; }.barra:hover { fill: #b4befe; }.barra:focus-visible { outline: 2px solid #f9e2af; outline-offset: 2px; }

Cero JavaScript para el resaltado al pasar el ratón y para el anillo de foco. Y con delegación de eventos, un solo escuchador para todas las marcas:

JS
svg.addEventListener('pointerover', (e) => {  const marca = e.target.closest('[data-clave]');  if (marca) mostrarTooltip(marca.dataset.clave);});

Ese closest es la delegación: un escuchador en la raíz, sin importar cuántas marcas haya. Es el patrón correcto también en SVG con muchos elementos, porque registrar diez mil escuchadores es caro en memoria y en tiempo de registro.

Y hay tres cosas más que SVG da y que casi nadie contabiliza al comparar:

  • pointer-events permite decidir por CSS qué capas reciben eventos, incluido el valor none para una capa puramente decorativa y visiblePainted para que las zonas transparentes no capturen.
  • El foco por teclado. Un elemento con tabindex="0" entra en el orden de tabulación y responde a Enter y Espacio. Recorrer una serie de datos con el teclado es directo.
  • La zona de acierto se puede ampliar sin cambiar el dibujo. Una marca de tres píxeles es imposible de acertar con el ratón; un circle transparente de doce píxeles encima lo resuelve. Es el equivalente al área táctil mínima.

El coste real de todo esto: son nodos, y cuentan para el umbral del artículo anterior. Una capa de zonas de acierto duplica el número de elementos.

Las cuatro técnicas en canvas

En un lienzo no hay elementos, solo píxeles. El puntero da unas coordenadas y tú tienes que averiguar qué había ahí.

Uno: recorrer y comprobar con isPointInPath. Guardas un Path2D por marca y preguntas.

JS
const marcas = datos.map(d => ({ d, ruta: rutaDe(d) }));function acierto(x, y) {  // Al reves: la ultima dibujada esta encima  for (let i = marcas.length - 1; i >= 0; i--) {    if (ctx.isPointInPath(marcas[i].ruta, x, y)) return marcas[i].d;  }  return null;}

Exacto para cualquier forma, incluidas las cóncavas, y es lineal en el número de marcas. Con doscientas marcas es instantáneo; con veinte mil, un movimiento de ratón cuesta veinte mil comprobaciones y el puntero se arrastra.

Ojo con el sistema de coordenadas: isPointInPath interpreta el punto en el espacio del contexto, es decir, con la transformación actual aplicada. Si has escalado por el ratio de píxeles del dispositivo, tienes que pasar coordenadas CSS y no de dispositivo, o al revés, según cuándo lo llames. Es la fuente número uno de aciertos desplazados.

Dos: un índice espacial. Un quadtree o una simple rejilla de cubos. Con la rejilla, que es la que casi siempre basta:

JS
const CELDA = 32;const rejilla = new Map();const clave = (x, y) => `${(x / CELDA) | 0},${(y / CELDA) | 0}`;for (const m of marcas) {  const k = clave(m.x, m.y);  if (!rejilla.has(k)) rejilla.set(k, []);  rejilla.get(k).push(m);}function cercano(x, y, radio = 8) {  let mejor = null, mejorD = radio * radio;  const cx = (x / CELDA) | 0, cy = (y / CELDA) | 0;  for (let i = cx - 1; i <= cx + 1; i++) {    for (let j = cy - 1; j <= cy + 1; j++) {      for (const m of rejilla.get(`${i},${j}`) ?? []) {        const dx = m.x - x, dy = m.y - y, d2 = dx * dx + dy * dy;        if (d2 < mejorD) { mejorD = d2; mejor = m; }      }    }  }  return mejor;}

Nueve celdas por consulta en lugar de veinte mil marcas. Funciona bien para marcas pequeñas y compactas (puntos, cuadrados); mal para formas grandes o alargadas, que ocupan muchas celdas.

Tres: la matemática de las escalas. Para un gráfico de líneas, no hace falta ninguna estructura: x.invert(px) te da el valor del eje y una búsqueda binaria sobre el array ordenado te da el punto, que es exactamente el patrón de qué es una escala. Es la técnica más barata de todas y solo funciona cuando la geometría es regular.

Cuatro: la selección por color. Dibujas la escena una segunda vez en un lienzo oculto, dando a cada marca un color único que codifica su identificador, y lees el píxel bajo el puntero.

JS
const oculto = new OffscreenCanvas(ancho * dpr, alto * dpr);const octx = oculto.getContext('2d', { willReadFrequently: true });const colorDeId = (i) =>  `rgb(${(i >> 16) & 255}, ${(i >> 8) & 255}, ${i & 255})`;function pintarMapa() {  octx.setTransform(dpr, 0, 0, dpr, 0, 0);  octx.clearRect(0, 0, ancho, alto);  marcas.forEach((m, i) => {    octx.fillStyle = colorDeId(i + 1);      // el 0 se reserva para el fondo    octx.fill(m.ruta);  });}function aciertoPorColor(cssX, cssY) {  const p = octx.getImageData(cssX * dpr, cssY * dpr, 1, 1).data;  const id = (p[0] << 16) | (p[1] << 8) | p[2];  return id === 0 ? null : marcas[id - 1];}

Es independiente de la forma: funciona igual con un polígono cóncavo, con un texto o con una imagen recortada. Y es de coste constante en la consulta, sea cual sea el número de marcas. Su precio es una segunda pasada de dibujado cada vez que la escena cambia, y las tres trampas del callout.

Cómo elegir

Situación Técnica
Menos de unos cientos de marcas Recorrer con isPointInPath
Muchas marcas pequeñas y compactas Rejilla o quadtree
Serie temporal o geometría regular La inversa de la escala
Formas arbitrarias, superposiciones, imágenes Selección por color
El resaltado tiene que ser instantáneo con miles de marcas Selección por color

Y dos reglas transversales que valen para todas.

Estrangula al fotograma. Un pointermove puede dispararse muchas más veces por segundo que los fotogramas que dibujas. Acumula la última posición y resuelve una vez por fotograma:

JS
let pendiente = null, ultimo = null;lienzo.addEventListener('pointermove', (e) => {  const caja = lienzo.getBoundingClientRect();  ultimo = { x: e.clientX - caja.left, y: e.clientY - caja.top };  if (pendiente) return;  pendiente = requestAnimationFrame(() => {    pendiente = null;    resaltar(acierto(ultimo.x, ultimo.y));  });});

Guarda lo resaltado y redibuja solo si cambia. Sin esa comprobación, cada movimiento del ratón repinta la escena entera aunque el resultado sea idéntico.