20.1 De funciones aisladas a una biblioteca
En los temas anteriores implementamos sumas, módulos, productos y proyecciones donde hacían falta. En una aplicación real, copiar esas funciones entre archivos produce variantes incompatibles y correcciones repetidas. Una biblioteca define una única forma de representar y operar vectores.
Su propósito no es acumular métodos, sino ofrecer un vocabulario estable. Cada operación debe indicar qué acepta, qué devuelve, si modifica algún argumento y qué ocurre en casos sin solución, como normalizar el vector nulo.
20.2 Decisiones antes de programar
Usaremos objetos {x, y}, funciones puras y resultados nuevos. Esta elección prioriza claridad. Una biblioteca destinada a WebGL podría usar arreglos tipados y parámetros de salida, pero no debería mezclar ambas convenciones de manera accidental.
| Aspecto | Decisión del ejemplo | Consecuencia |
|---|---|---|
| Representación | {x, y} | Componentes legibles |
| Mutabilidad | Funciones puras | Los argumentos se conservan |
| Dimensión | API exclusiva para 2D | No se mezclan Vector2 y Vector3 |
| Vector nulo | normalizar devuelve null | El caso debe tratarse explícitamente |
| Errores de entrada | TypeError | Los datos inválidos fallan cerca de su origen |
20.3 El núcleo mínimo
Comenzamos con construcción, copia, suma, resta y multiplicación escalar. Los nombres describen acciones y todas las funciones conservan sus argumentos:
const crear = (x = 0, y = 0) => ({ x, y });
const copiar = v => ({ x: v.x, y: v.y });
const sumar = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
const restar = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
const escalar = (v, k) => ({ x: v.x * k, y: v.y * k });
const a = crear(2, 3);
const b = crear(-1, 4);
console.log(sumar(a, b)); // { x: 1, y: 7 }
console.log(a); // no cambió20.4 Magnitudes y relaciones
Algunas operaciones devuelven escalares. El módulo usa Math.hypot; la distancia es el módulo de la diferencia y el producto escalar combina componentes correspondientes.
const restar = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
const modulo2 = v => v.x * v.x + v.y * v.y;
const modulo = v => Math.hypot(v.x, v.y);
const distancia = (a, b) => modulo(restar(a, b));
const punto = (a, b) => a.x * b.x + a.y * b.y;
console.log(modulo({x: 3, y: 4})); // 5
console.log(distancia({x: 1,y: 1},{x: 4,y: 5})); // 5modulo2 evita la raíz cuadrada y resulta útil para comparar distancias: si solo queremos saber qué objeto está más cerca, comparar distancias al cuadrado produce el mismo orden.
20.5 Normalización y dirección
Normalizar conserva la dirección y cambia el módulo a uno. El vector nulo no tiene dirección, por lo que el contrato devuelve null en vez de fabricar una respuesta físicamente falsa.
const modulo = v => Math.hypot(v.x, v.y);
const escalar = (v, k) => ({ x: v.x * k, y: v.y * k });
const restar = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
function normalizar(v) {
const m = modulo(v);
return m === 0 ? null : escalar(v, 1 / m);
}
function direccion(desde, hasta) {
return normalizar(restar(hasta, desde));
}
console.log("Normalizado:", normalizar({ x: 3, y: 4 }));
console.log("Dirección:", direccion({ x: 1, y: 2 }, { x: 4, y: 6 }));
console.log("Vector nulo:", normalizar({ x: 0, y: 0 }));Otra biblioteca podría lanzar una excepción o devolver el vector nulo. Lo importante es documentarlo y aplicarlo de forma uniforme. En el tema siguiente estudiaremos por qué la comparación exacta con cero necesita más cuidado cuando intervienen cálculos previos.
20.6 Proyección, reflexión y perpendicular
Las operaciones compuestas deben construirse sobre el núcleo ya probado:
const modulo2 = v => v.x * v.x + v.y * v.y;
const punto = (a, b) => a.x * b.x + a.y * b.y;
const escalar = (v, k) => ({ x: v.x * k, y: v.y * k });
const restar = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
const perpendicular = v => ({ x: -v.y, y: v.x });
function proyectar(v, sobre) {
const denominador = modulo2(sobre);
return denominador === 0
? null
: escalar(sobre, punto(v, sobre) / denominador);
}
function reflejar(v, normalUnitaria) {
return restar(v, escalar(normalUnitaria, 2 * punto(v, normalUnitaria)));
}
const v = { x: 3, y: -2 };
console.log("Perpendicular:", perpendicular(v));
console.log("Proyección sobre x:", proyectar(v, { x: 1, y: 0 }));
console.log("Reflexión sobre normal vertical:", reflejar(v, { x: 0, y: 1 }));reflejar exige que la normal sea unitaria. Podemos normalizarla dentro de la función para mayor comodidad, pero eso añade costo y también requiere decidir qué hacer con una normal nula.
20.7 Ángulos con una API segura
El cociente del coseno puede quedar apenas fuera de [-1, 1] por redondeo. Limitarlo evita que Math.acos produzca NaN.
const modulo = v => Math.hypot(v.x, v.y);
const punto = (a, b) => a.x * b.x + a.y * b.y;
function anguloEntre(a, b) {
const productoModulos = modulo(a) * modulo(b);
if (productoModulos === 0) return null;
const coseno = punto(a, b) / productoModulos;
return Math.acos(Math.max(-1, Math.min(1, coseno)));
}
const desdeAngulo = (radianes, longitud = 1) => ({
x: Math.cos(radianes) * longitud,
y: Math.sin(radianes) * longitud
});
const angulo = anguloEntre({ x: 1, y: 0 }, { x: 0, y: 1 });
console.log("Ángulo en radianes:", angulo);
console.log("Ángulo en grados:", angulo * 180 / Math.PI);
console.log("Vector a 60°:", desdeAngulo(Math.PI / 3, 2));La biblioteca trabaja internamente en radianes, como las funciones trigonométricas de JavaScript. La interfaz de usuario puede convertir grados en sus límites.
20.8 Limitar la longitud
En movimiento es frecuente fijar una rapidez máxima sin cambiar la dirección. Si el vector ya está dentro del límite, devolvemos una copia para conservar la regla de no compartir resultados accidentalmente.
const modulo2 = v => v.x * v.x + v.y * v.y;
const copiar = v => ({ x: v.x, y: v.y });
const escalar = (v, k) => ({ x: v.x * k, y: v.y * k });
function limitar(v, maximo) {
if (maximo < 0) throw new RangeError("El máximo no puede ser negativo");
const m2 = modulo2(v);
return m2 > maximo * maximo
? escalar(v, maximo / Math.sqrt(m2))
: copiar(v);
}
console.log("Limitado a 5:", limitar({ x: 6, y: 8 }, 5));
console.log("Ya dentro del límite:", limitar({ x: 2, y: 1 }, 5));20.9 Interpolación y evolución temporal
La interpolación lineal mezcla dos vectores. Con t = 0 devuelve el primero; con t = 1, el segundo. Los valores intermedios recorren el segmento.
const sumar = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
const restar = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
const escalar = (v, k) => ({ x: v.x * k, y: v.y * k });
const lerp = (a, b, t) =>
sumar(a, escalar(restar(b, a), t));
const integrarEuler = (posicion, velocidad, dt) =>
sumar(posicion, escalar(velocidad, dt));
const siguiente = integrarEuler({x: 2,y: 1}, {x: 3,y: -2}, 0.5);
console.log(siguiente); // { x: 3.5, y: 0 }dt debe estar expresado en una unidad compatible: una velocidad en m/s multiplicada por segundos produce un desplazamiento en metros.
20.10 Separar API pública y auxiliares
El archivo puede exportar únicamente las operaciones destinadas a consumidores. Las funciones internas quedan sin export. Esto permite modificar la implementación sin romper programas externos.
// index.js: fachada pública de la biblioteca
export { crear, sumar, restar, escalar, modulo, punto,
distancia, normalizar, proyectar, reflejar,
anguloEntre, limitar, lerp } from "./vector2.js";
// simulacion.js
import { sumar, escalar } from "./index.js";Los módulos ES evitan variables globales, hacen explícitas las dependencias y permiten que cada consumidor importe solo lo que necesita.
20.11 Validar en las fronteras
Una función pública puede rechazar componentes ausentes o no finitas. Para no repetir código, centralizamos la comprobación:
function comprobar(v, nombre = "vector") {
if (v === null || !Number.isFinite(v.x) || !Number.isFinite(v.y)) {
throw new TypeError(`${nombre} debe tener x e y finitas`);
}
return v;
}
function sumarSeguro(a, b) {
comprobar(a, "a"); comprobar(b, "b");
return { x: a.x + b.x, y: a.y + b.y };
}
console.log(sumarSeguro({x: 2, y: 1}, {x: -1, y: 3}));En código interno de alto rendimiento puede validarse una vez al ingresar los datos. En una API educativa o pública, mensajes precisos suelen valer más que una optimización prematura.
20.12 Pruebas por ejemplo y por propiedad
Una prueba con valores conocidos detecta errores concretos. Las propiedades matemáticas comprueban familias enteras de casos: sumar cero no cambia el vector, restar un vector de sí mismo da cero y el módulo nunca es negativo.
import { strict as assert } from "node:assert";
assert.deepEqual(sumar({x:2,y:3}, {x:-1,y:4}), {x:1,y:7});
assert.equal(modulo({x:3,y:4}), 5);
const v = {x: 2.5, y: -7};
assert.deepEqual(sumar(v, {x:0,y:0}), v);
assert.deepEqual(restar(v, v), {x:0,y:0});
assert.ok(modulo(v) >= 0);Con números decimales no siempre corresponde usar igualdad exacta. El próximo tema formalizará comparaciones aproximadas y tolerancias.
20.13 Documentación útil
La documentación debe explicar aquello que el nombre no puede: unidades esperadas, radianes o grados, mutabilidad, dimensión y casos excepcionales. JSDoc ayuda al editor a mostrar tipos y descripciones.
/**
* Proyecta v sobre el vector sobre.
* @param {{x:number,y:number}} v
* @param {{x:number,y:number}} sobre No debe ser nulo.
* @returns {{x:number,y:number}|null}
*/
export function proyectar(v, sobre) { /* ... */ }20.14 Laboratorio de operaciones
Elegí una operación y modificá los vectores a y b. El laboratorio utiliza la misma API pura para calcular y dibujar el resultado amarillo.
La suma combina las componentes correspondientes.
20.15 Extender sin romper
Una biblioteca puede crecer con rotación, transformación de coordenadas o utilidades geométricas. Antes de agregar una función conviene preguntar si pertenece al núcleo vectorial o a otro módulo, como colisiones o cuerpos físicos.
Cambiar el nombre de una exportación, el tipo de retorno o la política de mutación rompe a sus consumidores. Una versión nueva debe mantener compatibilidad o comunicar el cambio explícitamente. Las pruebas actúan como contrato ejecutable.
20.16 Funciones o clase
Una API funcional permite sumar(a, b) y acepta objetos simples. Una clase permite a.sumar(b) y agrupa comportamiento con datos. Ambas son válidas; incluso puede existir una capa de clase construida sobre las mismas funciones.
Conviene evitar dos implementaciones independientes porque pueden divergir. Si el proyecto ya intercambia objetos con Canvas, JSON o servidores, las funciones sobre objetos planos suelen integrarse con menos conversiones.
20.17 Errores frecuentes
- Mezclar operaciones que mutan con otras que devuelven objetos nuevos.
- No definir qué sucede con el vector nulo.
- Mezclar vectores 2D y 3D silenciosamente.
- Usar grados donde la API espera radianes.
- Suponer que la normal recibida por
reflejarya es unitaria. - Duplicar fórmulas en lugar de componer funciones probadas.
- Exportar detalles internos que luego resultan difíciles de cambiar.
- Comparar resultados decimales complejos con igualdad exacta.
- Confundir una biblioteca matemática con el estado completo de la simulación.
20.18 Ejercicio integrador
Agregá moverHacia(actual, objetivo, pasoMaximo). Debe avanzar hacia el objetivo como máximo la distancia indicada, no sobrepasarlo, conservar los argumentos y rechazar pasos negativos.
Ver solución y pruebas
const copiar = v => ({ x: v.x, y: v.y });
const sumar = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
const restar = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
const escalar = (v, k) => ({ x: v.x * k, y: v.y * k });
const modulo = v => Math.hypot(v.x, v.y);
const distancia = (a, b) => modulo(restar(a, b));
function moverHacia(actual, objetivo, pasoMaximo) {
if (pasoMaximo < 0) throw new RangeError("El paso debe ser no negativo");
const delta = restar(objetivo, actual);
const distanciaPendiente = modulo(delta);
if (distanciaPendiente <= pasoMaximo || distanciaPendiente === 0) {
return copiar(objetivo);
}
return sumar(actual, escalar(delta, pasoMaximo / distanciaPendiente));
}
const inicio = {x:0,y:0}, meta = {x:3,y:4};
console.assert(distancia(inicio, moverHacia(inicio, meta, 2)) === 2);
console.assert(distancia(moverHacia(inicio, meta, 10), meta) === 0);
console.assert(inicio.x === 0 && inicio.y === 0);Como el vector hacia la meta mide 5, un paso 2 produce {x: 1.2, y: 1.6}. Un paso 10 devuelve una copia exacta de la meta y evita sobrepasarla.
20.19 Ideas para recordar
- Una biblioteca define un contrato común, no solamente una colección de fórmulas.
- Representación, dimensión y mutabilidad deben decidirse de forma explícita.
- Las operaciones complejas se construyen sobre un núcleo pequeño y probado.
- Los casos sin solución matemática requieren una política documentada.
- Los módulos separan la API pública de los detalles internos.
- Las pruebas con ejemplos y propiedades protegen el comportamiento.
- Las unidades y los radianes forman parte del contrato aunque JavaScript solo almacene números.
En el próximo tema estudiaremos precisión numérica, tolerancias y casos límite para que esta biblioteca responda correctamente ante los efectos del cálculo en coma flotante.