wandres.dev
GSAP X · Plugins de SVG

Coordenadas de SVG y las utilidades de path

Por qué convertir entre espacios de coordenadas es el problema real del SVG animado, y el catálogo de métodos estáticos que MotionPathPlugin expone para resolverlo.

⏱ 18 min

La mitad de los problemas de animación en SVG que parecen misteriosos son problemas de coordenadas. Un elemento que aparece a 300 píxeles de donde debería, un trazado que se recorre a escala equivocada, un punto calculado con getBoundingClientRect que no cuadra con las coordenadas del viewBox. La causa siempre es la misma: hay varios sistemas de coordenadas en juego y se están mezclando números de unos con números de otros. MotionPathPlugin expone, casi como efecto colateral, el juego de herramientas más completo que hay para operar entre esos espacios.

🎯 Al terminar esta lección sabrás
  • Enumerar los sistemas de coordenadas que intervienen en un SVG dentro de una página.
  • Convertir un punto de un elemento a otro con convertCoordinates.
  • Obtener la posición relativa entre dos elementos con getRelativePosition.
  • Manipular trazados con las utilidades de RawPath.

Cuántos sistemas hay en juego

En una página con un SVG hay al menos cuatro espacios distintos, y confundirlos es lo que produce los desajustes.

El espacio del viewport, que es el que devuelve getBoundingClientRect: píxeles CSS medidos desde la esquina superior izquierda de la ventana visible.

El espacio del documento, igual que el anterior pero sin restar el scroll.

El espacio del viewBox, que es el sistema interno del SVG. Si un SVG tiene viewBox="0 0 100 100" y ocupa 400 píxeles de ancho en pantalla, cada unidad del viewBox vale cuatro píxeles.

Y el espacio local de cada elemento, resultado de componer todas las transformaciones de sus ancestros. Un grupo con transform="translate(20,30) scale(2)" define un espacio para sus hijos que no coincide con el del viewBox.

Las coordenadas del atributo d de un path están en el espacio local del elemento. Las que devuelve getBoundingClientRect están en el del viewport. Y las transformaciones x e y que aplica GSAP a un elemento se expresan en el espacio de su padre. Mezclar tres espacios sin convertir es la receta del desajuste.

// Estos dos numeros NO son comparables aunque ambos se llamen x
const rect = elemento.getBoundingClientRect();   // espacio del viewport
const cx = circulo.getAttribute("cx");           // espacio local del circulo

convertCoordinates

El método que resuelve el caso general. Recibe un elemento de origen, un elemento de destino y un punto en el espacio del primero, y devuelve el punto equivalente en el espacio del segundo.

import { gsap } from "gsap";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";

gsap.registerPlugin(MotionPathPlugin);

// Punto en coordenadas del SVG, expresado en coordenadas del contenedor HTML
const punto = MotionPathPlugin.convertCoordinates(
  document.querySelector("svg"),
  document.querySelector(".panel"),
  { x: 50, y: 80 }
);

gsap.set(".marcador", { x: punto.x, y: punto.y });

Acepta window como cualquiera de los dos elementos, lo que da conversiones a y desde el espacio del viewport. Si en lugar de un punto no le pasas nada, devuelve la matriz de conversión, que puedes reutilizar para muchos puntos sin recalcularla.

Este es el método que hace posible el caso que parece imposible: colocar un elemento de HTML exactamente encima de un punto de un SVG, con el SVG escalado, dentro de un contenedor rotado, con la página desplazada. Todo eso lo absorbe la composición de matrices.

getRelativePosition y getGlobalMatrix

getRelativePosition es la versión abreviada del caso más habitual: dónde está un elemento respecto a otro, medido desde un punto de origen concreto de cada uno.

// Vector desde el centro del boton hasta el centro del objetivo
const d = MotionPathPlugin.getRelativePosition(
  document.querySelector(".boton"),
  document.querySelector(".objetivo"),
  [0.5, 0.5],   // origen en el elemento de partida
  [0.5, 0.5]    // origen en el elemento de destino
);

gsap.to(".boton", { x: d.x, y: d.y, duration: 0.6 });

Los dos arrays son progresos dentro de la caja de cada elemento, igual que alignOrigin: [0.5, 0.5] es el centro, [0, 0] la esquina superior izquierda.

Es exactamente lo que necesitas para “mueve este elemento hasta encima de aquel otro”, sin importar cuántos contenedores transformados haya entre ellos, y es notablemente más fiable que restar dos getBoundingClientRect, porque esa resta ignora las escalas de los ancestros.

getGlobalMatrix devuelve la matriz de transformación acumulada de un elemento respecto al viewport, y con el segundo parámetro a true devuelve su inversa. Es el nivel más bajo y el que usarías si estuvieras construyendo tu propia capa.

Las utilidades de RawPath

Un RawPath es la representación interna que GSAP usa para los trazados: un array que contiene un array por cada subcontorno, y dentro, coordenadas alternadas de curvas cúbicas de Bézier. Un path con dos comandos M produce dos subarrays.

// <path d="M0,0 C10,20,15,30,5,18 M0,100 C50,120,80,110,100,100">
// se convierte en:
[
  [0, 0, 10, 20, 15, 30, 5, 18],
  [0, 100, 50, 120, 80, 110, 100, 100],
]

Sea cual sea el comando original —líneas, arcos, cuadráticas— el RawPath siempre son cúbicas. Esa uniformidad es lo que hace posible operar sobre él.

Método Qué hace
getRawPath(valor) Convierte un elemento o una cadena d en RawPath
stringToRawPath(datos) Igual, partiendo de una cadena de datos de path
rawPathToString(rawPath) El camino inverso: RawPath a cadena d
arrayToRawPath(puntos, config) Construye un RawPath a partir de un array de puntos
pointsToSegment(puntos, curviness) Construye un segmento a partir de puntos, con curvatura
sliceRawPath(rawPath, start, end) Devuelve el tramo entre dos progresos
getPositionOnPath(rawPath, progreso, conAngulo) Punto en un progreso dado, opcionalmente con su ángulo
getLength(path) Longitud total
convertToPath(forma, swap) Convierte primitivos de SVG en path

La combinación más útil es la de obtener puntos a lo largo de un trazado para colocar cosas.

// Repartir doce puntos a lo largo de una curva
const raw = MotionPathPlugin.getRawPath("#ruta");

for (let i = 0; i < 12; i++) {
  const p = MotionPathPlugin.getPositionOnPath(raw, i / 11, true);
  const punto = document.createElement("div");
  punto.className = "punto";
  document.body.appendChild(punto);
  gsap.set(punto, { x: p.x, y: p.y, rotation: p.angle });
}

Y sliceRawPath permite construir el path de un tramo y asignarlo a un elemento, lo que da un efecto de trazado que crece por segmentos con control total.

const raw = MotionPathPlugin.getRawPath("#ruta");
const tramo = MotionPathPlugin.sliceRawPath(raw, 0.2, 0.6);
document.querySelector("#tramo").setAttribute("d", MotionPathPlugin.rawPathToString(tramo));
ℹ️
Un dato útil sobre convertToPath

El método convertToPath está expuesto por dos plugins distintos, MotionPathPlugin y MorphSVGPlugin, y hace lo mismo en ambos: sustituye elementos primitivos de SVG por path equivalentes conservando los atributos. Con cargar uno cualquiera de los dos ya lo tienes.

Trabajar con SVG animado es trabajar con espacios afines, y quien no lo asume acaba con factores de corrección mágicos por todo el código

Hay una firma inconfundible de código escrito por alguien que no ha interiorizado el problema de las coordenadas: constantes sin explicar. Un * 0.75 aquí, un - 12 allá, un / ratio que alguien puso hace dos años y que nadie se atreve a tocar. Todas esas constantes son la misma cosa: una matriz de transformación descompuesta a mano, mal, y congelada en un valor concreto que era correcto para el tamaño de pantalla del portátil en el que se escribió. Funcionan hasta que alguien cambia el viewBox, o añade un padding, o mete el SVG en un contenedor con transform, y entonces hay que reajustar los números por prueba y error, y cada reajuste añade otra constante. La raíz del problema es conceptual: se está tratando un punto como un par de números, cuando un punto es un par de números más un espacio al que pertenece, y las operaciones entre puntos de espacios distintos no están definidas hasta que se convierte uno al otro. Es exactamente el mismo error que sumar metros con pies, y los lenguajes con tipos de unidades lo detectan; JavaScript no, así que el compilador aquí eres tú. La disciplina que lo evita cabe en una regla: cada vez que escribas una coordenada, escribe en el nombre de la variable a qué espacio pertenece. puntoEnViewBox, centroEnViewport, deltaEnPadre. En el momento en que hagas eso, las sumas incorrectas se vuelven visibles al leer, y los sitios donde falta una conversión saltan a la vista. Y cuando la conversión haga falta, ya sabes que existe un método que la hace y que no hay que inventar ningún factor: componer matrices es exactamente el trabajo del que se encargan convertCoordinates y getGlobalMatrix, y lo hacen bien para cualquier profundidad de anidamiento y cualquier combinación de escalas y rotaciones, que es más de lo que va a conseguir ninguna constante escrita a mano.

⚔️ Convertir en lugar de corregir
  1. Coloca un div de HTML exactamente encima de un punto concreto de un SVG escalado, usando convertCoordinates.
  2. Intenta lo mismo restando dos getBoundingClientRect y comprueba qué falla cuando metes el SVG en un contenedor con transform: scale(0.7).
  3. Usa getRelativePosition para mover un botón hasta el centro exacto de otro elemento y verifica que funciona con la página desplazada.
  4. Reparte doce marcadores a lo largo de un trazado con getPositionOnPath, orientados según el ángulo.
  5. Extrae el tramo central de un path con sliceRawPath y píntalo como un elemento nuevo.