Cómo crear una imagen en JavaScript añadiendo celdas hexagonales siguiendo la guía de un espiral.
Hace ya un tiempo, en mi trabajo tenía entre manos un proyecto en el que debía mostrar de forma visual pods de varios clústeres de Kubernetes. Buscando ideas me topé con una aplicación de observabilidad de New Relic. Lo que llamó mi atención fue cómo representaban los pods usando hexágonos. Esa imagen me hizo pensar en la posibilidad de generar un algoritmo en forma de espiral que dibujara un panal (¡como el de las abejas!) en SVG. Así nació la idea de crear este ejemplo, un pequeño experimento que une programación, gráficos, matemáticas y un poco de lógica.
Aquí te dejo el resultado de la visualización de la imagen SVG. En la pestaña de código puedes ver todo el algoritmo en JavaScript. Además al final del código puedes ver una función donde se define el número de celdas hexagonales que se dibujarán:
<!DOCTYPE html><html lang="en">
<head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Generar hexágonos JavaScript</title> <style> </style></head>
<body> <script> /** * Webtutoriales.com * Genera un patrón de panal (honeycomb) en SVG con N celdas hexagonales. * * El algoritmo utiliza coordenadas axiales y crea celdas en orden espiral. * Se rota cada capa (anillo) para que la primera celda del nuevo anillo * sea adyacente a la última celda del anillo anterior y, entre las opciones, * se elija la que esté lo más cerca posible de la posición ideal en el lado suroeste. * * @param {number} N - Número total de celdas hexagonales a generar. * @returns {SVGElement} - Elemento SVG que contiene el panal. */ function generateHoneycomb(N) { // Tamaño del hexágono: distancia desde el centro a cualquier vértice. const hexSize = 30;
// Direcciones axiales para hexágonos con punta arriba. // Índice: 0: Este, 1: Noreste, 2: Noroeste, 3: Oeste, 4: Suroeste, 5: Sureste. const directions = [ { q: 1, r: 0 }, { q: 1, r: -1 }, { q: 0, r: -1 }, { q: -1, r: 0 }, { q: -1, r: 1 }, { q: 0, r: 1 } ];
// --- Funciones auxiliares ---
// Suma dos coordenadas axiales. function axialAdd(a, b) { return { q: a.q + b.q, r: a.r + b.r }; }
// Escala una coordenada axial por un factor k. function axialScale(a, k) { return { q: a.q * k, r: a.r * k }; }
// Obtiene el vecino en la dirección indicada (índice 0–5). function axialNeighbor(hex, directionIndex) { const dir = directions[directionIndex]; return { q: hex.q + dir.q, r: hex.r + dir.r }; }
// Calcula la distancia axial entre dos hexágonos. function axialDistance(a, b) { return ( (Math.abs(a.q - b.q) + Math.abs((a.q + a.r) - (b.q + b.r)) + Math.abs(a.r - b.r)) / 2 ); }
// Convierte coordenadas axiales a coordenadas en píxeles. // Para hexágonos con punta arriba: // x = tamaño * √3 * (q + r/2) // y = tamaño * (3/2) * r function axialToPixel(hex, size) { const x = size * Math.sqrt(3) * (hex.q + hex.r / 2); const y = size * 1.5 * hex.r; return { x, y }; }
// --- Generar posiciones hexagonales con algoritmo espiral modificado --- let cells = []; if (N <= 0) return null; // Agrega la celda central. cells.push({ q: 0, r: 0 });
let radius = 1; // Continúa agregando capas hasta alcanzar N celdas. while (cells.length < N) { let ringCells = []; // Punto de partida ideal: centro + (radio * dirección Suroeste) let start = axialAdd({ q: 0, r: 0 }, axialScale(directions[4], radius)); let hex = start; // Recorre los 6 lados del anillo. for (let side = 0; side < 6; side++) { for (let step = 0; step < radius; step++) { ringCells.push(hex); hex = axialNeighbor(hex, side); } }
// --- Rotar el anillo para que la primera celda sea adyacente a la última celda anterior // y se acerque lo máximo posible a la posición ideal en el lado suroeste. const lastCell = cells[cells.length - 1]; const ideal = axialAdd({ q: 0, r: 0 }, axialScale(directions[4], radius)); // posición ideal Suroeste // Obtener los índices candidatos cuya celda sea adyacente a lastCell. const candidatos = ringCells .map((cell, index) => ({ cell, index })) .filter(obj => axialDistance(obj.cell, lastCell) === 1);
let startIndex = 0; if (candidatos.length > 0) { // Seleccionar el candidato con menor distancia al ideal. let mejor = candidatos[0]; for (let cand of candidatos) { if (axialDistance(cand.cell, ideal) < axialDistance(mejor.cell, ideal)) { mejor = cand; } } startIndex = mejor.index; } // Rota el arreglo para que el candidato seleccionado sea el primero. ringCells = ringCells.slice(startIndex).concat(ringCells.slice(0, startIndex));
// Agrega las celdas del anillo hasta alcanzar N. for (let cell of ringCells) { if (cells.length < N) { cells.push(cell); } else { break; } } radius++; }
// --- Crear el SVG y los elementos hexagonales --- const svgns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgns, "svg");
// Variables para calcular la caja de contorno de todos los hexágonos. let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
// Para cada celda, calcular su centro, dibujar el hexágono y añadir el número. cells.forEach((cell, index) => { const center = axialToPixel(cell, hexSize); let points = [];
// Calcular los 6 vértices del hexágono (punta arriba, iniciando en -30°). for (let i = 0; i < 6; i++) { const angleDeg = 60 * i - 30; const angleRad = (Math.PI / 180) * angleDeg; const x = center.x + hexSize * Math.cos(angleRad); const y = center.y + hexSize * Math.sin(angleRad); points.push(`${x},${y}`); minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); }
// Crear el elemento <polygon> para el hexágono. const polygon = document.createElementNS(svgns, "polygon"); polygon.setAttribute("points", points.join(" ")); polygon.setAttribute("fill", "transparent"); // Color uniforme sin variaciones polygon.setAttribute("stroke", "#000"); polygon.setAttribute("stroke-width", "1"); svg.appendChild(polygon);
// Crear el elemento <text> para mostrar el número (orden espiral). const text = document.createElementNS(svgns, "text"); text.setAttribute("x", center.x); text.setAttribute("y", center.y); text.setAttribute("text-anchor", "middle"); text.setAttribute("dominant-baseline", "central"); text.setAttribute("font-size", "12"); text.setAttribute("fill", "#000"); text.textContent = index + 1; // Numeración iniciando en 1 svg.appendChild(text); });
// --- Configurar viewBox y dimensiones del SVG --- const margin = hexSize; minX -= margin; minY -= margin; maxX += margin; maxY += margin; const width = maxX - minX; const height = maxY - minY; svg.setAttribute("viewBox", `${minX} ${minY} ${width} ${height}`); svg.setAttribute("width", width); svg.setAttribute("height", height);
return svg; }
// ----- EJEMPLO DE USO ----- // Genera un panal con N celdas y lo añade al documento. const honeycombSVG = generateHoneycomb(7); if (honeycombSVG) { document.body.appendChild(honeycombSVG); } </script></body>
</html>
A continuación explicamos los conceptos clave de la función.
Coordenadas axiales y la base del hexágono
Usamos coordenadas axiales para trabajar de forma natural con rejillas hexagonales. Esto facilita la determinación de vecinos y distancias. Por ejemplo, definimos las direcciones para hexágonos con punta hacia arriba de la siguiente manera:
// Índice: 0: Este, 1: Noreste, 2: Noroeste, 3: Oeste, 4: Suroeste, 5: Sureste.const directions = [ { q: 1, r: 0 }, { q: 1, r: -1 }, { q: 0, r: -1 }, { q: -1, r: 0 }, { q: -1, r: 1 }, { q: 0, r: 1 }];
Generación en espiral y rotación de anillos
El algoritmo comienza en el centro y añade celdas en anillos concéntricos. Cada anillo se recorre y rota para que la primera celda esté lo más cerca posible de una posición ideal (en este caso, hacia el suroeste), manteniendo la continuidad del patrón. Aquí un extracto del bucle principal:
while (cells.length < N) { let ringCells = []; let start = axialAdd({ q: 0, r: 0 }, axialScale(directions[4], radius)); let hex = start; for (let side = 0; side < 6; side++) { for (let step = 0; step < radius; step++) { ringCells.push(hex); hex = axialNeighbor(hex, side); } } // Aquí se rota el anillo para alinear la celda de inicio for (let cell of ringCells) { if (cells.length < N) { cells.push(cell); } else { break; } } radius++;}
Funciones auxiliares para operaciones geométricas
Para mantener el código limpio y modular, se utilizan funciones auxiliares que realizan operaciones como sumar coordenadas o convertir de coordenadas axiales a píxeles. Por ejemplo, la función que encuentra el vecino de un hexágono es la siguiente:
function axialNeighbor(hex, directionIndex) { const dir = directions[directionIndex]; return { q: hex.q + dir.q, r: hex.r + dir.r };}
El siguiente paso es traducir la lista de celdas generadas en un dibujo SVG. Cada celda se representa como un polígono con 6 vértices, calculados usando funciones trigonométricas. Además, se coloca un número en cada hexágono, siguiendo el orden en que se generaron. Esto ayuda a visualizar claramente la espiral y a comprobar que el algoritmo está funcionando como se esperaba.
El código se encarga también de calcular la caja de contorno de todos los hexágonos, ajustando dinámicamente el viewBox
del SVG para que se adapte perfectamente a la figura. Es un proceso que, aunque técnico, resulta muy intuitivo una vez que lo ves en acción.
// --- Crear el SVG y los elementos hexagonales ---const svgns = "http://www.w3.org/2000/svg";const svg = document.createElementNS(svgns, "svg");
// Variables para calcular la caja de contorno de todos los hexágonos.let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
// Para cada celda, calcular su centro, dibujar el hexágono y añadir el número.cells.forEach((cell, index) => { const center = axialToPixel(cell, hexSize); let points = [];
// Calcular los 6 vértices del hexágono (punta arriba, iniciando en -30°). for (let i = 0; i < 6; i++) { const angleDeg = 60 * i - 30; const angleRad = (Math.PI / 180) * angleDeg; const x = center.x + hexSize * Math.cos(angleRad); const y = center.y + hexSize * Math.sin(angleRad); points.push(`${x},${y}`); minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); }
// Crear el elemento <polygon> para el hexágono. const polygon = document.createElementNS(svgns, "polygon"); polygon.setAttribute("points", points.join(" ")); polygon.setAttribute("fill", "#fff"); // Color uniforme sin variaciones polygon.setAttribute("stroke", "#000"); polygon.setAttribute("stroke-width", "1"); svg.appendChild(polygon);
// Crear el elemento <text> para mostrar el número (orden espiral). const text = document.createElementNS(svgns, "text"); text.setAttribute("x", center.x); text.setAttribute("y", center.y); text.setAttribute("text-anchor", "middle"); text.setAttribute("dominant-baseline", "central"); text.setAttribute("font-size", "12"); text.setAttribute("fill", "#000"); text.textContent = index + 1; // Numeración iniciando en 1 svg.appendChild(text);});
// --- Configurar viewBox y dimensiones del SVG ---const margin = hexSize;minX -= margin;minY -= margin;maxX += margin;maxY += margin;const width = maxX - minX;const height = maxY - minY;svg.setAttribute("viewBox", `${minX} ${minY} ${width} ${height}`);svg.setAttribute("width", width);svg.setAttribute("height", height);
Y la conversión de coordenadas axiales a píxeles para posicionar los hexágonos en el SVG se realiza con:
function axialToPixel(hex, size) { const x = size * Math.sqrt(3) * (hex.q + hex.r / 2); const y = size * 1.5 * hex.r; return { x, y };}
En resumen, este código es un ejemplo práctico de cómo se pueden combinar conceptos geométricos y gráficos en la web para crear visualizaciones interesantes. Espero que te sirva de inspiración para tus propios proyectos y que encuentres útiles las técnicas presentadas aquí.