wandres.dev
FORMAS SVG · El catálogo de elementos

circle y ellipse: el radio y sus trampas

Por qué SVG habla de radios y no de diámetros, contra qué se resuelve un porcentaje en r, el valor auto de rx y ry en la elipse, y el coste de aproximar un círculo con curvas.

⏱ 15 min

circle y ellipse son los dos elementos más simples del catálogo y los que producen el bug más desconcertante de todo SVG: un r="50%" que no mide la mitad de nada reconocible. La explicación está en una fórmula de la especificación que casi nadie ha leído, y una vez la conoces deja de parecer arbitraria. Además, saber cuánto cuesta un círculo frente a su aproximación con curvas de Bézier decide qué exportas desde una herramienta de diseño.

🎯 Al terminar esta lección sabrás
  • Posicionar un circle y una ellipse con su centro y sus radios, sin confundir radio con diámetro.
  • Calcular a mano contra qué resuelve un porcentaje en r, y explicar por qué esa referencia es la diagonal normalizada.
  • Aplicar el valor auto de rx y ry en ellipse y anticipar el resultado.
  • Elegir entre la forma nativa y su equivalente en path con un criterio de precisión y de peso.

Centro y radio, nunca caja

Un circle tiene tres atributos y ninguno más: cx, cy y r. Una ellipse tiene cuatro: cx, cy, rx y ry. Todos con valor inicial cero.

<svg viewBox="0 0 200 100" width="400">
  <circle  cx="50"  cy="50" r="40"           fill="#f38ba8" />
  <ellipse cx="140" cy="50" rx="50" ry="30"  fill="#94e2d5" />
</svg>

La decisión de posicionar por centro y no por caja envolvente es la primera diferencia mental con CSS, y no es un capricho. Un círculo definido por su centro rota sobre sí mismo con un transform sin corrección; definido por su esquina superior izquierda, cualquier rotación exige recalcular el origen. Como SVG nació para gráficos técnicos donde las rotaciones son constantes, el centro era la parametrización natural. La consecuencia práctica es que centrar un círculo en un lienzo es trivial (cx y cy a la mitad) mientras que centrar un rect obliga a restar la mitad del ancho.

La segunda diferencia es que se habla de radio, no de diámetro. Un punto de datos de 12 píxeles de diámetro se escribe r="6". Suena obvio hasta que generas una nube de puntos desde datos y todos salen del doble de tamaño.

Un r negativo es un error de valor y el elemento no se renderiza. Un r de cero es válido y desactiva el renderizado del elemento, que es exactamente lo que quieres cuando una escala te devuelve cero: la forma desaparece sin ensuciar el DOM ni lanzar nada.

El porcentaje y la diagonal normalizada

Este es el apartado que hay que leer despacio. Cuando un valor de longitud es un porcentaje, SVG lo resuelve contra el viewport actual, pero elige la referencia según el eje de esa longitud:

  • Longitud horizontal (cx, rx, x, width): porcentaje del ancho del viewport.
  • Longitud vertical (cy, ry, y, height): porcentaje del alto del viewport.
  • Longitud sin eje (r, stroke-width, stroke-dashoffset, font-size): porcentaje de la diagonal normalizada.

La diagonal normalizada se define así, con w y h las dimensiones del viewport:

const diagonalNormalizada = Math.sqrt(w * w + h * h) / Math.SQRT2;

Es la longitud de la diagonal dividida por raíz de dos. En un viewport cuadrado coincide con el lado, y por eso en un lienzo cuadrado los porcentajes de r parecen comportarse «bien» y nadie nota nada. En un viewport de 400 por 100, en cambio, la diagonal normalizada vale unos 291,5, así que r="50%" son 145,7 unidades: un círculo que desborda el alto por completo. Ese es el bug, y no tiene arreglo salvo dejar de usar porcentajes en r.

ℹ️
Por qué la especificación eligió esa fórmula

El problema es real: r es una longitud que no pertenece ni al eje x ni al eje y, y hay que resolverla contra algo. Usar el ancho sería arbitrario, usar el mínimo rompería la simetría entre ejes, y usar la diagonal a secas haría que en un cuadrado 100% valiese 1,41 veces el lado, que es peor. Dividir la diagonal por raíz de dos es la única elección que hace coincidir el porcentaje con el lado cuando el viewport es cuadrado, y que se degrada de forma continua cuando no lo es. Es una decisión razonada. Sigue siendo una fuente inagotable de confusión.

La lección operativa es simple: en r usa unidades de usuario, no porcentajes. Si necesitas un círculo relativo al contenedor, el sitio donde se resuelve la proporción es el viewBox, no el radio.

auto en la elipse

ellipse comparte con rect el mecanismo de auto. rx y ry valen auto por omisión, y auto significa «el valor usado del otro». De modo que:

<svg viewBox="0 0 300 100" width="600">
  <!-- Circulo de radio 30 escrito como elipse -->
  <ellipse cx="50"  cy="50" rx="30"          fill="#89b4fa" />
  <!-- Identico: ry copia rx -->
  <ellipse cx="150" cy="50" ry="30"          fill="#89b4fa" />
  <!-- Elipse de verdad -->
  <ellipse cx="250" cy="50" rx="45" ry="20"  fill="#cba6f7" />
</svg>

Si ambos son auto, ambos valen cero y no se pinta nada. No hay recorte aquí, a diferencia de rect: una elipse no tiene ninguna caja contra la que limitarse, así que rx y ry se usan tal cual y la forma puede desbordar el viewport sin problema. Lo que desborda simplemente se recorta contra el viewport si el overflow del elemento raíz lo dice, que es lo habitual.

Merece la pena notar que ellipse con rx igual a ry y circle con ese mismo radio producen exactamente el mismo rasterizado. No hay una ruta de código más rápida para el círculo. La única razón para preferir circle es semántica y de tamaño de fichero: un atributo menos y una intención más clara para quien lea el documento.

Círculo nativo frente a círculo con curvas

Ninguna curva de Bézier puede describir un arco de circunferencia de forma exacta: la circunferencia es una cónica y la Bézier es un polinomio. Lo que hacen todas las herramientas es aproximar cada cuarto de círculo con una cúbica cuyos puntos de control están a distancia k · r del extremo, con la constante mágica:

// Constante de aproximacion de un cuarto de circulo con una cubica
const k = 4 / 3 * (Math.SQRT2 - 1); // 0.5522847498307933

Con cuatro cúbicas así, el error máximo respecto al círculo verdadero es del orden de 2 diezmilésimas del radio. Invisible en pantalla, irrelevante para impresión, y perfectamente medible si haces cálculos geométricos sobre la forma.

Eso significa que un circle de SVG no se aproxima: el rasterizador dibuja la cónica directamente y es exacto a resolución de subpíxel. Un path con las cuatro cúbicas es una aproximación. En la práctica visual da igual, pero hay dos consecuencias que no dan igual:

El peso. Un <circle cx="50" cy="50" r="40"/> son unos 35 bytes. El mismo círculo como path con cuatro cúbicas y tres decimales pasa de 120. Multiplicado por los cientos de puntos de una nube de datos, la diferencia es real. Por eso el plugin convertShapeToPath de SVGO deja los círculos en paz por omisión, y por eso hay que revisar qué hace tu herramienta de exportación: Illustrator convierte círculos a paths con frecuencia y Figma también, según cómo se haya construido la forma.

El punto de partida del trazo. Un circle empieza su recorrido en el punto de las tres en punto y avanza en sentido horario. Un path empieza donde tú digas. Eso importa en el momento en que aplicas stroke-dasharray para dibujar un anillo de progreso: la posición de la marca del cero depende de dónde arranque la ruta, y con un circle tienes que rotar la forma noventa grados para que el progreso empiece arriba.

<svg viewBox="0 0 120 120" width="240">
  <circle cx="60" cy="60" r="50" fill="none" stroke="#313244" stroke-width="10" />
  <circle cx="60" cy="60" r="50" fill="none" stroke="#a6e3a1" stroke-width="10"
          stroke-linecap="round"
          stroke-dasharray="314.159"
          stroke-dashoffset="94.248"
          transform="rotate(-90 60 60)" />
</svg>

La circunferencia de radio 50 mide 2 · Math.PI * 50, es decir 314,159. Un desfase de 94,248 deja fuera el 30 por ciento, así que el arco visible representa el 70 por ciento. La rotación de menos noventa grados alrededor del centro traslada el origen de las tres en punto a las doce.

Elegir el radio para que el número sea redondo

Ese 314.159 tiene un olor característico a número calculado a mano que alguien tendrá que recalcular cuando cambie el radio. Hay dos salidas mejores. La primera es el atributo pathLength: si escribes pathLength="100" en el círculo, todas las longitudes de trazo (stroke-dasharray, stroke-dashoffset) pasan a expresarse en esa escala ficticia, y el porcentaje de progreso es literalmente el número que pones. La segunda, si necesitas el valor en JavaScript, es preguntarle a la forma: circulo.getTotalLength() funciona sobre cualquier elemento de geometría en los motores actuales, no solo sobre path, porque SVG 2 subió ese método a la interfaz común. Entre las dos, pathLength es la que no requiere JavaScript y la que sobrevive al SVG estático servido desde el servidor.

⚔️ Reto práctico

Construye un gráfico de dispersión con veinte puntos donde el área de cada círculo, no su radio, sea proporcional al valor del dato. Escribe la fórmula que va de valor a r y comprueba con dos valores concretos que un dato del doble produce un círculo del doble de área. Guarda el resultado: la razón por la que el radio tiene que ir con una raíz cuadrada se justifica con datos experimentales en el nivel 29.