Introducción a D3


¿Qué es D3?

D3 (Data-Driven Documents) es una biblioteca de JavaScript para crear visualizaciones manipulando directamente elementos del DOM — principalmente SVG — a partir de datos (Bostock et al., 2011). A diferencia de Observable Plot, que genera gráficos completos de forma declarativa, D3 ofrece un enfoque imperativo: el programador indica paso a paso qué elementos crear, dónde posicionarlos y cómo estilizarlos.

Declarativo vs Imperativo

Aspecto Observable Plot (declarativo) D3 (imperativo)
Filosofía Describe qué quieres ver Indica cómo construirlo paso a paso
Ejes y escalas Automáticos Se crean manualmente
SVG Generado internamente Tú lo creas y controlas
Curva de aprendizaje Baja Alta
Flexibilidad Limitada a lo que Plot ofrece Total
Mejor para Exploración rápida, gráficos estándar Visualizaciones personalizadas, animaciones

Ambas bibliotecas están disponibles globalmente en Observable Framework. La variable d3 se puede usar directamente en cualquier bloque de código sin necesidad de importarla.

En esta lección aprenderemos a:


Creando SVG con D3

En la lección anterior escribimos SVG directamente en HTML. Con D3, creamos los mismos elementos usando JavaScript:

Ver código D3 rectángulo
d3.create("svg")
  .attr("width", 400)
  .attr("height", 120)
  .append("rect")
  .attr("x", 20)
  .attr("y", 20)
  .attr("width", 200)
  .attr("height", 80)
  .attr("fill", "#9B1C26")
  .node()

¿Qué hace cada parte?

El resultado es idéntico al SVG manual de la lección anterior, pero construido con JavaScript. La diferencia es que ahora los valores pueden venir de variables y datos en lugar de estar escritos a mano.


Encadenamiento de métodos

D3 usa un patrón llamado method chaining (encadenamiento de métodos): cada método devuelve el elemento modificado, lo que permite agregar múltiples atributos en una sola expresión fluida.

Ver código D3 encadenamiento
d3.create("svg")
  .attr("width", 400)
  .attr("height", 120)
  .call((svg) => {
    svg
      .append("rect")
      .attr("x", 20)
      .attr("y", 20)
      .attr("width", 200)
      .attr("height", 80)
      .attr("fill", "#9B1C26")
      .attr("fill-opacity", 0.85)

    svg
      .append("text")
      .attr("x", 230)
      .attr("y", 68)
      .attr("font-size", 14)
      .attr("fill", "#333")
      .text("Chile: 80.0 años")
  })
  .node()

¿Qué cambió?

Hasta aquí seguimos escribiendo valores a mano. La verdadera potencia de D3 aparece cuando esos valores vienen de los datos.



Construyendo con D3: los primitivos SVG

En la lección anterior aprendimos los elementos SVG escribiéndolos directamente en HTML. Ahora veamos cómo crear esos mismos elementos con D3, usando .append() y .attr().


Grupos y translate

El elemento <g> se crea con .append("g") y se posiciona con el atributo transform:

Ver código D3 grupos y translate
{
  const svg = d3.create("svg").attr("width", 400).attr("height", 150)

  // Primer grupo posicionado en (50, 30)
  const grupo = svg.append("g").attr("transform", "translate(50, 30)")

  grupo
    .append("rect")
    .attr("width", 120)
    .attr("height", 60)
    .attr("fill", "#9B1C26")

  grupo
    .append("text")
    .attr("x", 60)
    .attr("y", 35)
    .attr("text-anchor", "middle")
    .attr("fill", "white")
    .attr("font-size", 14)
    .text("Grupo 1")

  // Segundo grupo posicionado en (220, 30)
  const grupo2 = svg.append("g").attr("transform", "translate(220, 30)")

  grupo2
    .append("rect")
    .attr("width", 120)
    .attr("height", 60)
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.5)

  grupo2
    .append("text")
    .attr("x", 60)
    .attr("y", 35)
    .attr("text-anchor", "middle")
    .attr("fill", "white")
    .attr("font-size", 14)
    .text("Grupo 2")

  display(svg.node())
}

¿Qué hace cada parte?

Esto es idéntico al SVG manual de la lección anterior, pero con una ventaja: las posiciones y atributos ahora pueden venir de variables en lugar de estar escritas a mano.


Círculos, líneas y texto

Veamos cómo crear los demás primitivos con D3, combinándolos en un mini scatter plot estático:

Ver código D3 círculos, líneas y texto
{
  const svg = d3.create("svg").attr("width", 400).attr("height", 200)

  const g = svg.append("g").attr("transform", "translate(50, 20)")

  // Líneas de ejes
  g.append("line")
    .attr("x1", 0)
    .attr("y1", 160)
    .attr("x2", 320)
    .attr("y2", 160)
    .attr("stroke", "#ccc")
    .attr("stroke-width", 1)

  g.append("line")
    .attr("x1", 0)
    .attr("y1", 0)
    .attr("x2", 0)
    .attr("y2", 160)
    .attr("stroke", "#ccc")
    .attr("stroke-width", 1)

  // Tres puntos con diferentes posiciones y tamaños
  g.append("circle")
    .attr("cx", 80)
    .attr("cy", 40)
    .attr("r", 8)
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.7)

  g.append("circle")
    .attr("cx", 160)
    .attr("cy", 90)
    .attr("r", 12)
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.7)

  g.append("circle")
    .attr("cx", 260)
    .attr("cy", 60)
    .attr("r", 6)
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.7)

  // Etiquetas de ejes
  g.append("text")
    .attr("x", 160)
    .attr("y", 180)
    .attr("text-anchor", "middle")
    .attr("font-size", 12)
    .attr("fill", "#666")
    .text("Eje X")

  g.append("text")
    .attr("x", -10)
    .attr("y", 80)
    .attr("text-anchor", "middle")
    .attr("font-size", 12)
    .attr("fill", "#666")
    .attr("transform", "rotate(-90, -10, 80)")
    .text("Eje Y")

  display(svg.node())
}

¿Qué hace cada parte?

El patrón que se repite

Todos los primitivos siguen la misma estructura en D3:

contenedor.append("tipo_elemento")
  .attr("atributo1", valor)
  .attr("atributo2", valor)
  ...

Lo único que cambia son los atributos específicos de cada elemento (cx/cy para círculos, x1/y1 para líneas, etc.). Una vez que conoces el patrón, crear cualquier elemento SVG con D3 es mecánico.

El problema de este enfoque

Este scatter plot tiene 3 puntos hardcodeados. Si tuviéramos 195 países, necesitaríamos escribir 195 veces .append("circle") con coordenadas diferentes. Eso es exactamente lo que el data join resuelve en la siguiente sección.


Data join: vinculando datos a elementos

El data join es el concepto central de D3: toma un array de datos y crea un elemento SVG por cada elemento del array. Es lo que convierte a D3 en una herramienta de visualización y no solo una biblioteca de dibujo.

Ver código D3 data join
{
  const svg = d3.create("svg").attr("width", 400).attr("height", 200)

  svg
    .selectAll("rect")
    .data([80.0, 77.8, 77.1, 76.8, 76.5])
    .join("rect")
    .attr("x", 0)
    .attr("y", (d, i) => i * 38)
    .attr("width", (d) => d * 4)
    .attr("height", 30)
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.85)

  display(svg.node())
}

¿Qué hace cada parte?

La función (d, i) => ...

Dentro de un data join, cada método .attr() puede recibir una función en lugar de un valor fijo. Esta función recibe dos argumentos:

Esto es lo que hace que D3 sea "Data-Driven": los atributos visuales se calculan a partir de los datos.


Usando datos reales del CSV

En lugar de un array manual, vinculemos los datos de nuestro CSV. La variable sudamerica ya contiene los países filtrados y ordenados:

Ver código D3 con datos del CSV
{
  const svg = d3
    .create("svg")
    .attr("width", 500)
    .attr("height", sudamerica.length * 38)

  // Una barra por cada país
  svg
    .selectAll("rect")
    .data(sudamerica)
    .join("rect")
    .attr("x", 100)
    .attr("y", (d, i) => i * 38)
    .attr("width", (d) => d.life_expectancy * 4)
    .attr("height", 30)
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.85)

  // Una etiqueta por cada país
  svg
    .selectAll("text")
    .data(sudamerica)
    .join("text")
    .attr("x", 95)
    .attr("y", (d, i) => i * 38 + 20)
    .attr("text-anchor", "end")
    .attr("font-size", 13)
    .attr("fill", "#333")
    .text((d) => d.country)

  display(svg.node())
}

¿Qué cambió?

El gráfico ya muestra todos los países con sus valores reales. Pero los anchos de las barras siguen calculándose con una regla de tres manual (d.life_expectancy * 4). Esto funciona para este caso, pero no es robusto: si los datos cambian de rango, las barras podrían salirse del SVG o ser demasiado pequeñas. Las escalas de D3 resuelven este problema.


Escalas: traduciendo datos a píxeles

Las escalas de D3 son funciones que traducen valores de datos a valores visuales. Reciben un domain (rango de datos) y un range (rango de píxeles) y devuelven una función de conversión.

Este concepto ya lo conocemos de Observable Plot: cuando escribíamos r: { range: [2, 20] } o color: { domain: [50, 85] }, Plot creaba escalas internamente. En D3, las creamos explícitamente.


d3.scaleLinear: valores continuos

Traduce un rango numérico continuo a otro. Es la escala más usada para posiciones y tamaños.

Ver código escala lineal
const escalaX = d3
  .scaleLinear()
  .domain([0, 85]) // Valores de datos: 0 a 85 años
  .range([0, 350]) // Valores en píxeles: 0 a 350px

¿Qué hace?

Es exactamente el cálculo que hicimos a mano en la lección de SVG (valor / 85 * 350), pero encapsulado en una función reutilizable.


d3.scaleBand: categorías en un eje

Distribuye categorías (países, continentes, meses) equitativamente en un espacio disponible, calculando la posición y el ancho de cada banda.

Ver código escala de bandas
const escalaY = d3
  .scaleBand()
  .domain(sudamerica.map((d) => d.country)) // Lista de países
  .range([0, 400]) // Espacio vertical disponible
  .padding(0.2) // 20% de espacio entre bandas

¿Qué hace?

Esto elimina la necesidad de calcular i * 38 manualmente: la escala distribuye los países equitativamente y calcula el espaciado.


Aplicando escalas al gráfico

Ahora reemplazamos los cálculos manuales por las escalas:

Ver código D3 con escalas
{
  const width = 500
  const height = 400
  const margin = { top: 10, right: 40, bottom: 10, left: 100 }
  const innerWidth = width - margin.left - margin.right
  const innerHeight = height - margin.top - margin.bottom

  // Escala horizontal: expectativa de vida → píxeles
  const x = d3
    .scaleLinear()
    .domain([0, d3.max(sudamerica, (d) => d.life_expectancy)])
    .range([0, innerWidth])

  // Escala vertical: países → posiciones
  const y = d3
    .scaleBand()
    .domain(sudamerica.map((d) => d.country))
    .range([0, innerHeight])
    .padding(0.2)

  const svg = d3.create("svg").attr("width", width).attr("height", height)

  // Grupo principal desplazado por los márgenes
  const g = svg
    .append("g")
    .attr("transform", `translate(${margin.left},${margin.top})`)

  // Barras
  g.selectAll("rect")
    .data(sudamerica)
    .join("rect")
    .attr("x", 0)
    .attr("y", (d) => y(d.country))
    .attr("width", (d) => x(d.life_expectancy))
    .attr("height", y.bandwidth())
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.85)

  // Etiquetas de países
  g.selectAll(".label")
    .data(sudamerica)
    .join("text")
    .attr("class", "label")
    .attr("x", -10)
    .attr("y", (d) => y(d.country) + y.bandwidth() / 2)
    .attr("dy", "0.35em")
    .attr("text-anchor", "end")
    .attr("font-size", 13)
    .attr("fill", "#333")
    .text((d) => d.country)

  display(svg.node())
}

¿Qué cambió?

Los datos ahora controlan completamente las dimensiones visuales a través de las escalas.


Ejes: generación automática

Los ejes son uno de los componentes más tediosos de crear manualmente: requieren líneas, ticks, etiquetas y formato numérico. D3 los genera automáticamente a partir de una escala.

Ver código D3 con ejes
{
  const width = 500
  const height = 430
  const margin = { top: 10, right: 40, bottom: 30, left: 100 }
  const innerWidth = width - margin.left - margin.right
  const innerHeight = height - margin.top - margin.bottom

  const x = d3
    .scaleLinear()
    .domain([0, d3.max(sudamerica, (d) => d.life_expectancy)])
    .range([0, innerWidth])
    .nice()

  const y = d3
    .scaleBand()
    .domain(sudamerica.map((d) => d.country))
    .range([0, innerHeight])
    .padding(0.2)

  const svg = d3.create("svg").attr("width", width).attr("height", height)

  const g = svg
    .append("g")
    .attr("transform", `translate(${margin.left},${margin.top})`)

  // Eje X (inferior)
  g.append("g")
    .attr("transform", `translate(0,${innerHeight})`)
    .call(d3.axisBottom(x).ticks(5))

  // Eje Y (izquierdo)
  g.append("g").call(d3.axisLeft(y))

  // Barras
  g.selectAll("rect")
    .data(sudamerica)
    .join("rect")
    .attr("x", 0)
    .attr("y", (d) => y(d.country))
    .attr("width", (d) => x(d.life_expectancy))
    .attr("height", y.bandwidth())
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.85)

  display(svg.node())
}

¿Qué cambió?

Ya no necesitamos crear las etiquetas de países manualmente: el eje izquierdo las genera a partir de la escala de bandas.


Gráfico completo: todas las piezas juntas

Agreguemos los elementos finales: título, valores al final de las barras y una línea base:

Ver código D3 gráfico completo
{
  const width = 550
  const height = 480
  const margin = { top: 40, right: 50, bottom: 30, left: 100 }
  const innerWidth = width - margin.left - margin.right
  const innerHeight = height - margin.top - margin.bottom

  // Escalas
  const x = d3
    .scaleLinear()
    .domain([0, d3.max(sudamerica, (d) => d.life_expectancy)])
    .range([0, innerWidth])
    .nice()

  const y = d3
    .scaleBand()
    .domain(sudamerica.map((d) => d.country))
    .range([0, innerHeight])
    .padding(0.2)

  // Contenedor SVG
  const svg = d3.create("svg").attr("width", width).attr("height", height)

  // Título
  svg
    .append("text")
    .attr("x", width / 2)
    .attr("y", 22)
    .attr("text-anchor", "middle")
    .attr("font-size", 16)
    .attr("font-weight", "bold")
    .attr("fill", "#333")
    .text("Expectativa de vida en Sudamérica")

  // Grupo principal con márgenes
  const g = svg
    .append("g")
    .attr("transform", `translate(${margin.left},${margin.top})`)

  // Eje X (inferior, sin línea de dominio)
  g.append("g")
    .attr("transform", `translate(0,${innerHeight})`)
    .call(d3.axisBottom(x).ticks(5))
    .call((g) => g.select(".domain").remove())

  // Eje Y (izquierdo, sin línea de dominio)
  g.append("g")
    .call(d3.axisLeft(y))
    .call((g) => g.select(".domain").remove())

  // Línea base
  g.append("line")
    .attr("x1", 0)
    .attr("y1", 0)
    .attr("x2", 0)
    .attr("y2", innerHeight)
    .attr("stroke", "#ccc")
    .attr("stroke-width", 1)

  // Barras
  g.selectAll("rect")
    .data(sudamerica)
    .join("rect")
    .attr("x", 0)
    .attr("y", (d) => y(d.country))
    .attr("width", (d) => x(d.life_expectancy))
    .attr("height", y.bandwidth())
    .attr("fill", "#9B1C26")
    .attr("fill-opacity", 0.85)

  // Valores al final de cada barra
  g.selectAll(".value")
    .data(sudamerica)
    .join("text")
    .attr("class", "value")
    .attr("x", (d) => x(d.life_expectancy) + 5)
    .attr("y", (d) => y(d.country) + y.bandwidth() / 2)
    .attr("dy", "0.35em")
    .attr("font-size", 12)
    .attr("fill", "#333")
    .text((d) => d.life_expectancy)

  display(svg.node())
}

¿Qué se agregó?


Comparación: el mismo gráfico en Plot vs D3

Ver código Plot.plot equivalente
Plot.plot({
  width: 550,
  height: 480,
  marginLeft: 100,
  marks: [
    Plot.barX(sudamerica, {
      y: "country",
      x: "life_expectancy",
      fill: "#9B1C26",
      fillOpacity: 0.85,
      sort: { y: "-x" },
    }),
    Plot.ruleX([0]),
    Plot.text(sudamerica, {
      y: "country",
      x: "life_expectancy",
      text: (d) => `${d.life_expectancy}`,
      textAnchor: "start",
      dx: 5,
      fontSize: 12,
      fill: "#333",
    }),
  ],
})

La diferencia en números

Aspecto Observable Plot D3
Líneas de código ~20 ~60
Escalas Automáticas Creadas manualmente
Ejes Automáticos Creados y posicionados manualmente
Márgenes marginLeft: 100 Objeto margin + cálculos de innerWidth/innerHeight
Ordenamiento sort: {y: "-x"} Pre-ordenar el array antes de bindear
Data binding Implícito en la marca selectAll → data → join explícito

Entonces, ¿por qué usar D3? Porque el código de D3 nos da control sobre cada píxel. Podemos agregar animaciones, interacciones complejas, layouts que Plot no ofrece, o personalizar cualquier aspecto visual sin estar limitados por la API de una marca. La próxima lección explorará precisamente esas capacidades.


Resumen de conceptos D3

Concepto Método Función
Crear SVG d3.create("svg") Crea un elemento SVG en memoria
Agregar elemento .append("tipo") Agrega un hijo al elemento actual
Establecer atributo .attr("nombre", valor) Define atributos SVG (posición, tamaño, color)
Establecer texto .text(valor) Define el contenido de un <text>
Seleccionar d3.select() / d3.selectAll() Selecciona elementos del DOM
Vincular datos .data(array) Asocia un array de datos a una selección
Crear por dato .join("tipo") Crea un elemento por cada dato del array
Extraer nodo .node() Devuelve el elemento del DOM para mostrarlo

Resumen de escalas

Escala Método D3 Equivalente en Plot Mejor para
Lineal d3.scaleLinear() type: "linear" (default) Valores numéricos continuos
Bandas d3.scaleBand() Automática en barras Categorías equiespaciadas
Logarítmica d3.scaleLog() type: "log" Valores con rangos extremos (PIB)
Ordinal d3.scaleOrdinal() color: { domain, range } Mapear categorías a colores
Tiempo d3.scaleTime() type: "time" Fechas y series temporales

Todas las escalas de D3 comparten la misma interfaz: .domain() para los datos y .range() para los valores visuales.


Conclusiones de la lección

En esta lección se ha construido un gráfico de barras completo con D3, paso a paso, desde los elementos más básicos hasta un resultado profesional.


Conceptos clave

Creación de elementos

Data join

Escalas

Ejes

Patrón de márgenes


Reflexión final

Construir un gráfico de barras en D3 requiere significativamente más código que en Observable Plot. Esto no es un defecto sino una consecuencia del control total que D3 ofrece: cada elemento, cada posición, cada atributo está bajo nuestro control directo.

La clave está en reconocer cuándo ese control adicional es necesario:

En la próxima lección exploraremos las capacidades de D3 que justifican su complejidad adicional: transiciones, interacciones avanzadas y visualizaciones que Plot no puede crear.


Próximo paso: En la siguiente lección, D3 en la práctica, exploraremos transiciones, interacciones avanzadas y la combinación de D3 con Plot para crear visualizaciones que aprovechen lo mejor de cada herramienta.

Bibliografía

Bostock, M., Ogievetsky, V., & Heer, J. (2011). D3: Data-Driven Documents. IEEE Transactions on Visualization and Computer Graphics, 17(12), 2301–2309. https://doi.org/10.1109/TVCG.2011.185