wandres.dev
TRAZO Y RELLENO · Estilos de línea y pintura

Líneas discontinuas: setLineDash y lineDashOffset

Manejar el patrón de guiones del canvas, entender cómo se recorre a lo largo de la ruta y usar el desfase para animar y para dibujar trazos progresivos.

⏱ 15 min

El patrón de guiones parece un detalle decorativo y es una de las herramientas más versátiles del canvas: además de las líneas discontinuas obvias, permite animar el recorrido de una ruta, dibujar un trazo que aparece progresivamente y construir efectos de marcha de hormigas. La clave es entender que el patrón se recorre a lo largo de la longitud de la ruta y que hay un desfase controlable.

🎯 Al terminar esta lección sabrás
  • Definir un patrón de guiones con setLineDash y leerlo con getLineDash.
  • Explicar cómo se recorre el patrón a lo largo de las subrutas.
  • Usar lineDashOffset para animar y para revelar un trazo progresivamente.
  • Anticipar la interacción del patrón con lineCap y con la escala del contexto.

El patrón

ctx.setLineDash([12, 6]);       // 12 unidades pintadas, 6 vacias, y repite
ctx.setLineDash([12, 6, 3, 6]); // guion largo, hueco, punto, hueco
ctx.setLineDash([]);            // vuelve a linea continua
ctx.getLineDash();              // devuelve una copia del array actual

El array son longitudes alternas de trazo y hueco, en unidades del espacio actual, no en píxeles. Si el contexto está escalado por la densidad de pantalla, un patrón de [12, 6] mide doce y seis píxeles lógicos, que es lo que quieres.

Hay una regla de normalización: si el array tiene un número impar de elementos, se duplica. Así, [10] equivale a [10, 10], y [6, 3, 2] equivale a [6, 3, 2, 6, 3, 2]. Merece la pena conocerlo porque un patrón de tres elementos no hace lo que parece a simple vista.

Un array con valores negativos o no finitos se ignora por completo, dejando el patrón anterior intacto, con la misma trampa silenciosa que otras asignaciones del contexto.

ctx.strokeStyle = '#a6e3a1';
ctx.lineWidth = 3;
[[8, 8], [16, 4], [1, 5], [20, 5, 4, 5]].forEach((patron, i) => {
  ctx.setLineDash(patron);
  ctx.beginPath();
  ctx.moveTo(20, 30 + i * 28);
  ctx.lineTo(300, 30 + i * 28);
  ctx.stroke();
});
ctx.setLineDash([]);    // restaurar SIEMPRE

Cómo se recorre

El patrón avanza a lo largo de la longitud acumulada de la subruta, no por segmento. Eso significa que en una polilínea, un guion puede empezar en un segmento y acabar en el siguiente, doblando la esquina. Es lo que quieres visualmente y es lo que hace que el patrón se vea uniforme en curvas.

Cada subruta reinicia el patrón. Una ruta con tres subrutas empieza el patrón tres veces, cada una en el desfase actual.

Ese detalle tiene una consecuencia práctica en rejillas: si dibujas todas las líneas de la rejilla en una sola ruta con moveTo/lineTo, cada línea arranca el patrón desde el principio y todas quedan alineadas. Si las dibujaras encadenadas, el patrón se desplazaría en cada una.

Y una consecuencia estética en formas cerradas: en un rectángulo con guiones, el patrón recorre el perímetro y casi nunca acaba justo donde empezó, así que hay una discontinuidad visible en la esquina inicial. Si eso molesta, hay que elegir un patrón que divida exactamente el perímetro:

function guionesQueCierran(perimetro, guionDeseado) {
  const par = guionDeseado * 2;              // guion + hueco
  const n = Math.max(1, Math.round(perimetro / par));
  const real = perimetro / n / 2;
  return [real, real];
}

lineDashOffset: animar y revelar

lineDashOffset desplaza el punto de inicio del patrón a lo largo de la ruta. Con él se consiguen dos efectos muy útiles.

El primero es la marcha de hormigas, la selección animada de los editores gráficos:

let desfase = 0;
function animarSeleccion(rect) {
  desfase -= 1;
  ctx.save();
  ctx.setLineDash([6, 4]);
  ctx.lineDashOffset = desfase;
  ctx.strokeStyle = '#f9e2af';
  ctx.lineWidth = 1.5;
  ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w, rect.h);
  ctx.restore();
  requestAnimationFrame(() => animarSeleccion(rect));
}

El segundo es más interesante: revelar un trazo progresivamente. La técnica consiste en usar un patrón cuyo guion sea la longitud total de la ruta y cuyo hueco también lo sea, y desplazar el desfase de la longitud total a cero.

/** Dibuja la ruta como si se estuviera trazando, de 0 a 1. */
function trazarProgresivo(ctx, longitud, progreso) {
  ctx.setLineDash([longitud, longitud]);
  ctx.lineDashOffset = longitud * (1 - progreso);
  ctx.stroke();
  ctx.setLineDash([]);
}

El problema es conocer la longitud de la ruta, y el canvas no la expone. Para rutas rectas se calcula sumando distancias; para curvas hay que aproximar, o recurrir a un truco: crear un elemento <path> de SVG con la misma geometría y preguntarle su longitud.

function longitudDeD(d) {
  const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  p.setAttribute('d', d);
  return p.getTotalLength();
}

const d = 'M20 120 C 80 20, 180 20, 240 120';
const largo = longitudDeD(d);
const ruta = new Path2D(d);

let t = 0;
function marco() {
  ctx.clearRect(0, 0, 300, 160);
  ctx.strokeStyle = '#cba6f7';
  ctx.lineWidth = 4;
  ctx.setLineDash([largo, largo]);
  ctx.lineDashOffset = largo * (1 - t);
  ctx.stroke(ruta);
  ctx.setLineDash([]);
  t = Math.min(1, t + 0.01);
  if (t < 1) requestAnimationFrame(marco);
}
marco();

Ese getTotalLength sobre un elemento SVG desconectado del documento funciona y es la forma estándar de medir longitudes de ruta en la web. El elemento ni siquiera necesita estar insertado.

Los guiones tienen un coste de rasterización que crece con la longitud, y hay un límite práctico

El patrón de guiones no es una decoración gratuita: obliga al rasterizador a subdividir el contorno en tantas piezas como guiones haya, y cada pieza se convierte en una subruta cerrada con sus propios extremos. Una línea de mil unidades con un patrón de [1, 1] genera quinientos guiones, es decir, quinientos rectángulos con dos remates cada uno. En una rejilla con doscientas líneas de ese tipo estás pidiendo cien mil formas por fotograma, y el rendimiento se desploma sin que nada en el código sugiera por qué. El caso patológico que aparece en producción es el de una gráfica con guiones muy finos sobre un eje muy largo, o el de un contorno punteado sobre una ruta con mucha longitud acumulada. Hay dos síntomas delatores: el tiempo de dibujo crece con el zoom —porque al ampliar, la longitud en unidades crece y el número de guiones también—, y desactivar los guiones multiplica el rendimiento. Las dos defensas que funcionan son escalar el patrón con el nivel de zoom, de forma que el número de guiones visibles se mantenga aproximadamente constante, y usar patrones más largos de lo que la intuición estética pide. Un [8, 6] se lee igual de bien que un [2, 2] y genera cuatro veces menos geometría. Y para el caso extremo de una línea punteada muy larga, es más rápido dibujarla como un patrón de relleno repetido que como un trazo con guiones.

Interacciones que hay que recordar

Con lineCap. Los remates se aplican a cada guion, no solo a los extremos de la línea. Con round y un patrón de [4, 4] y grosor 8, cada guion crece cuatro píxeles por lado y el hueco desaparece: la línea se ve continua. Si usas guiones, butt suele ser lo correcto, y si quieres puntos redondos usa un guion de longitud cero con round.

// Linea de puntos redondos perfectos
ctx.lineCap = 'round';
ctx.lineWidth = 6;
ctx.setLineDash([0, 14]);    // guion de longitud cero + hueco
ctx.stroke();

Con la escala. El patrón está en unidades del espacio actual, así que un contexto escalado escala también los guiones. Es coherente, y significa que al hacer zoom los guiones crecen. Si quieres guiones de tamaño constante en pantalla independientemente del zoom, divide el patrón por la escala.

Con el estado. setLineDash y lineDashOffset forman parte del estado y los guarda save. Como el patrón afecta a todo lo que se trace después, la disciplina de restaurarlo es obligatoria: una función que deja guiones activos convierte todos los trazos posteriores en discontinuos, y ese bug se diagnostica fatal porque el síntoma aparece lejos de la causa.