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

lineCap, lineJoin y miterLimit

Controlar cómo terminan y cómo se unen los trazos, entender por qué existe el límite de inglete y qué pasa cuando se supera.

⏱ 15 min

Un trazo no es una línea: es una región con área, y toda región necesita decidir qué hace en sus extremos y en sus esquinas. El canvas ofrece tres remates y tres uniones, más un parámetro numérico —el límite de inglete— cuya existencia solo se entiende cuando ves qué pasa sin él: una púa que sale disparada al infinito.

🎯 Al terminar esta lección sabrás
  • Elegir el valor de lineCap adecuado según el efecto buscado y su impacto en la longitud.
  • Distinguir los tres valores de lineJoin y sus consecuencias visuales.
  • Explicar qué mide miterLimit y qué ocurre exactamente al superarlo.
  • Detectar los artefactos típicos de una mala combinación de remate y unión.

lineCap: los extremos

Tres valores, y el que está por defecto no es el que la gente espera.

butt (por defecto): el trazo termina exactamente en el punto final. La línea mide justo lo que mide.

round: se añade un semicírculo de radio igual a la mitad del grosor. La línea sobresale medio grosor por cada extremo.

square: se añade un rectángulo del mismo ancho y de medio grosor de largo. Sobresale lo mismo que round pero con esquinas vivas.

['butt', 'round', 'square'].forEach((cap, i) => {
  ctx.lineCap = cap;
  ctx.lineWidth = 20;
  ctx.strokeStyle = '#89b4fa';
  ctx.beginPath();
  ctx.moveTo(40, 40 + i * 50);
  ctx.lineTo(200, 40 + i * 50);
  ctx.stroke();
  // Marcas de referencia en los extremos exactos
  ctx.fillStyle = '#f38ba8';
  ctx.fillRect(39, 34 + i * 50, 2, 12);
  ctx.fillRect(199, 34 + i * 50, 2, 12);
});

Con las marcas se ve el efecto: butt acaba en la marca, los otros dos la sobrepasan en diez píxeles.

Esa diferencia de longitud tiene consecuencias prácticas concretas. Un indicador de progreso circular con lineCap = 'round' empieza y acaba fuera de su ángulo teórico, así que al cien por cien el extremo final se solapa con el inicial. Y una barra de progreso con extremos redondeados nunca puede mostrar un cero real, porque el semicírculo ya ocupa espacio.

// Progreso circular con extremos redondeados: hay que compensar el desborde
function progreso(ctx, cx, cy, r, fraccion, grosor = 14) {
  const compensacion = grosor / 2 / r;      // el semicirculo en radianes
  const inicio = -Math.PI / 2 + compensacion;
  const total = Math.PI * 2 - compensacion * 2;
  ctx.lineCap = 'round';
  ctx.lineWidth = grosor;
  ctx.strokeStyle = '#313244';
  ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
  if (fraccion <= 0) return;
  ctx.strokeStyle = '#a6e3a1';
  ctx.beginPath();
  ctx.arc(cx, cy, r, inicio, inicio + total * Math.min(1, fraccion));
  ctx.stroke();
}

Un detalle importante: lineCap afecta también a los guiones. Con round, cada guion es una cápsula y la separación efectiva se reduce en un grosor. Con un patrón de guiones cortos y un grosor grueso, round puede hacer que los guiones se toquen y la línea parezca continua.

Y otro que sorprende: con butt, una subruta de longitud cero no dibuja nada. Con round o square, dibuja un punto. Es la forma de dibujar puntos con un solo stroke:

ctx.lineCap = 'round';
ctx.lineWidth = 8;
ctx.beginPath();
for (const p of puntos) { ctx.moveTo(p.x, p.y); ctx.lineTo(p.x, p.y); }
ctx.stroke();   // un punto redondo por cada par moveTo/lineTo

lineJoin: las esquinas

Tres valores para decidir qué pasa donde dos segmentos se encuentran.

miter (por defecto): se prolongan los bordes exteriores hasta que se cortan, formando una punta.

round: se rellena la esquina con un arco de radio igual a la mitad del grosor.

bevel: se rellena con un triángulo que une los dos extremos exteriores, cortando la punta en recto.

Con ángulos abiertos los tres se parecen. La diferencia explota con ángulos cerrados, y de ahí sale el parámetro siguiente.

miterLimit: la púa que se dispara

Cuando dos segmentos forman un ángulo muy agudo, la punta del inglete se alarga enormemente. En el límite de un ángulo que tiende a cero, la punta tiende a infinito.

miterLimit es el cociente máximo permitido entre la longitud del inglete y el grosor de la línea. Su valor por defecto es 10, lo que significa que la punta puede llegar a medir diez veces el grosor antes de que el navegador intervenga.

Cuando se supera, la unión pasa a comportarse como bevel para esa esquina concreta. No se recorta la punta a la longitud del límite: se cambia de tipo de unión.

const angulos = [10, 20, 40, 90];
angulos.forEach((grados, i) => {
  const a = (grados * Math.PI) / 180;
  const x = 60 + i * 90, y = 140;
  ctx.lineWidth = 14;
  ctx.lineJoin = 'miter';
  ctx.miterLimit = 10;
  ctx.strokeStyle = '#f9e2af';
  ctx.beginPath();
  ctx.moveTo(x - 50 * Math.cos(a / 2), y - 50 * Math.sin(a / 2));
  ctx.lineTo(x, y);
  ctx.lineTo(x - 50 * Math.cos(a / 2), y + 50 * Math.sin(a / 2));
  ctx.stroke();
});

Con diez grados la punta es larguísima o ya ha saltado a bisel; con noventa es corta. La relación exacta es que la longitud del inglete dividida por el grosor vale 1 / sin(θ/2), donde θ es el ángulo interior. Con el límite por defecto de 10, el cambio a bisel ocurre alrededor de los 11,5 grados.

El artefacto de la púa suelta en polilíneas de datos, y por qué aparece solo con ciertos datos

Hay un bug visual que aparece en gráficas de líneas y en mapas y que es imposible de diagnosticar si no conoces miterLimit: en un punto concreto de la serie, y solo en ese, sale una púa larguísima que atraviesa media gráfica. El código es el mismo para todos los puntos, no hay ninguna condición especial, y el problema desaparece si cambias un solo valor de los datos. La causa es que en ese punto la polilínea hace un giro casi completo sobre sí misma —el valor sube mucho y baja mucho entre dos muestras consecutivas muy próximas en el eje X— y el inglete se dispara. Con un grosor de 3 y un ángulo de un grado, la punta mediría 344 píxeles. Y como el límite por defecto es 10, la punta se recorta ahí… salvo que hayas subido miterLimit para conseguir esquinas bonitas en otro sitio, que es exactamente lo que mucha gente hace. Hay tres remedios y solo uno es correcto. Bajar miterLimit funciona pero convierte todas las esquinas agudas en biseles feos. Suavizar los datos oculta el problema y falsea la gráfica. El remedio correcto es lineJoin = 'round' para cualquier polilínea cuya geometría venga de datos que no controlas. Los datos reales tienen picos, los picos generan ángulos agudos, y el inglete es una decisión estética que solo tiene sentido cuando tú controlas la forma. La unión redonda no tiene ningún caso degenerado, no depende de ningún límite y en trazos finos es indistinguible. Es la opción por defecto que el canvas debería haber tenido para este caso, y no la tiene.

Combinaciones que funcionan

Caso lineCap lineJoin Comentario
Rejilla, ejes, separadores butt irrelevante Longitud exacta, sin desbordes
Gráfica de líneas con datos reales round round Sin púas, extremos limpios
Trazo a mano alzada round round Se siente como un rotulador
Contorno de polígono butt miter Esquinas nítidas, ángulos controlados
Indicador de progreso round irrelevante Recuerda compensar el desborde
Flecha o guía técnica butt miter Precisión sobre estética

Y una advertencia sobre el estado: lineCap, lineJoin y miterLimit forman parte del estado del contexto y persisten hasta que los cambies. Una función que pone lineCap = 'round' para dibujar puntos y no lo restaura deja todas las líneas siguientes con extremos redondeados, y el síntoma —una rejilla cuyas líneas sobresalen unos píxeles— es lo bastante sutil como para pasar desapercibido durante meses.

⚔️ Reto práctico

Dibuja la misma polilínea de veinte puntos aleatorios cuatro veces, con las cuatro combinaciones de lineJoin en miter y round y miterLimit en 2 y 20, con lineWidth = 12. Localiza en cuál de las cuatro aparece una púa y comprueba con la fórmula 1 / sin(θ/2) qué ángulo la produjo.