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.
La interacción es el eje de decisión donde la diferencia entre los dos modelos es más grande, y donde la comparación se hace peor. SVG te da eventos por elemento, estados de CSS, foco de teclado y tooltips nativos sin escribir una línea. En un lienzo, todo eso lo escribes tú, y las cuatro técnicas que existen tienen costes muy distintos que conviene conocer antes de elegir, no después.
- Enumerar lo que SVG resuelve de serie en interacción y su coste real.
- Implementar hit testing en canvas con índice espacial y con selección por color.
- Elegir la técnica según la forma de las marcas y su número.
- Evitar los tres fallos que rompen la selección por color.
Lo que SVG regala
Cada marca es un elemento, así que:
.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:
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-eventspermite decidir por CSS qué capas reciben eventos, incluido el valornonepara una capa puramente decorativa yvisiblePaintedpara 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
circletransparente 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.
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:
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.
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:
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.
La técnica es elegante y tiene tres trampas que no aparecen en ningún tutorial y que se manifiestan como aciertos intermitentes en los bordes, que es lo más difícil de depurar que hay.
Uno: el alfa destruye el identificador. Un lienzo almacena el color premultiplicado por el alfa, y getImageData deshace esa multiplicación con pérdida de precisión. Si pintas una marca con rgba(0, 1, 2, 0.5), lo que leas después no será 0, 1, 2. Y peor: el borrado deja alfa cero, y cualquier composición contra él mezcla. La regla es absoluta: en el lienzo de aciertos, todo se pinta con alfa 255, nada de transparencias, nada de globalAlpha, nada de sombras. Si tu escena real tiene opacidad, el mapa de aciertos no la copia.
Dos: el antialiasing inventa colores. Los bordes de una forma se dibujan mezclando el color de la marca con el del fondo, y esa mezcla produce un color que no corresponde a ningún identificador. En el borde de cada marca hay una franja de un píxel de identificadores inexistentes, y si tu tabla de búsqueda es un array indexado, un acierto ahí devuelve undefined o, peor, el elemento equivocado. El contexto 2D no permite desactivar el suavizado de las rutas, así que hay que defenderse en la lectura:
const id = (p[0] << 16) | (p[1] << 8) | p[2];
const m = mapaPorId.get(id); // un Map, no un array indexado
return m ?? null; // un color mezclado es un fallo, no un aciertoUsar un Map en vez de indexar un array convierte el problema de «devuelve la marca equivocada» en «no devuelve nada», que es un fallo aceptable de un píxel de ancho. Y si quieres que los bordes acierten, engorda las formas del mapa un píxel respecto a las reales.
Tres: el ratio de píxeles del dispositivo. El lienzo oculto tiene el mismo tamaño de búfer que el visible, es decir, multiplicado por el ratio de píxeles. Las coordenadas del puntero llegan en píxeles CSS. Leer getImageData(cssX, cssY, 1, 1) en una pantalla con ratio 2 lee el píxel de la esquina superior izquierda del cuadrante equivocado, y el acierto se desplaza hacia arriba y a la izquierda proporcionalmente a la distancia al origen. Es el bug que hace que funcione perfectamente en el monitor externo y no funcione en el portátil. Multiplica siempre por el ratio al leer, como en el código de arriba, y vuelve a pintar el mapa cuando el ratio cambie.
Y una nota de rendimiento que decide si la técnica es viable: getImageData fuerza una sincronización con la GPU y es de las operaciones más caras del contexto 2D. Con la opción willReadFrequently: true al pedir el contexto, el navegador mantiene el búfer en memoria del sistema en lugar de en la GPU, lo que hace la lectura mucho más barata a costa de un dibujado algo más lento. Para el lienzo de aciertos esa opción es obligatoria, y para el lienzo visible no la pongas nunca.
Implementa el hit testing de un gráfico de dispersión con las cuatro técnicas y mide el tiempo de resolución con 200, 2.000 y 20.000 puntos. Después provoca los tres fallos de la selección por color a propósito: pinta con alfa, indexa con un array en lugar de un Map, y lee sin multiplicar por el ratio. Ver los tres síntomas una vez te ahorrará reconocerlos a ciegas dentro de seis meses.