Ejercicios con D3.js en Observable

Published

April 25, 2026

Inversión Global en Investigación y Desarrollo. Análisis de Gastos Domésticos en I+D como Porcentaje del PIB

Fuente: “Análisis OECD - MSTI Database”

Introducción

La inversión en Investigación y Desarrollo (I+D) es un indicador clave de la capacidad innovadora de un país y su compromiso con el crecimiento económico basado en el conocimiento. El indicador “Gross Domestic Expenditure on Research and Development (GERD) as a percentage of GDP” mide el total de gastos domésticos en I+D expresados como porcentaje del Producto Interno Bruto.

Según la OECD, este indicador incluye:

  • Gastos corrientes y de capital en los cuatro sectores principales
  • Empresas privadas, Gobierno, Educación superior y Organizaciones sin fines de lucro
  • Investigación básica, investigación aplicada y desarrollo experimental

Los datos presentados provienen de la base de datos OECD Main Science and Technology Indicators (MSTI) y reflejan las tendencias más recientes hasta 2023.

Datos y Preparación

Code
// Datos de inversión en I+D como % del PIB (2023)
rd_data = [
  {country: "Israel", value: 6.35, region: "Asia-Pacifico"},
  {country: "Corea del Sur", value: 4.96, region: "Asia-Pacifico"},
  {country: "Suecia", value: 3.60, region: "Europa"},
  {country: "Estados Unidos", value: 3.45, region: "America del Norte"},
  {country: "Japón", value: 3.44, region: "Asia-Pacifico"},
  {country: "Bélgica", value: 3.32, region: "Europa"},
  {country: "Austria", value: 3.29, region: "Europa"},
  {country: "Suiza", value: 3.25, region: "Europa"},
  {country: "Dinamarca", value: 2.99, region: "Europa"},
  {country: "Alemania", value: 2.95, region: "Europa"},
  {country: "Finlandia", value: 2.85, region: "Europa"},
  {country: "Francia", value: 2.75, region: "Europa"},
  {country: "Islandia", value: 2.65, region: "Europa"},
  {country: "China", value: 2.58, region: "Asia-Pacifico"},
  {country: "Países Bajos", value: 2.35, region: "Europa"},
  {country: "Reino Unido", value: 2.25, region: "Europa"},
  {country: "Noruega", value: 2.15, region: "Europa"},
  {country: "Australia", value: 2.05, region: "Asia-Pacifico"},
  {country: "Canadá", value: 1.95, region: "America del Norte"},
  {country: "Italia", value: 1.85, region: "Europa"},
  {country: "España", value: 1.65, region: "Europa"},
  {country: "Portugal", value: 1.45, region: "Europa"},
  {country: "México", value: 0.55, region: "America del Norte"}
]
Code
// Configuración de colores por región
color_scale = new Map([
  ["Asia-Pacifico", "#ff6b6b"],
  ["Europa", "#4ecdc4"], 
  ["America del Norte", "#45b7d1"]
])
Code
// Estadísticas descriptivas
stats = {
  const values = rd_data.map(d => d.value);
  return {
    max: d3.max(values),
    min: d3.min(values),
    mean: d3.mean(values),
    median: d3.median(values),
    countries_above_3: rd_data.filter(d => d.value >= 3.0).length,
    total_countries: rd_data.length
  }
}

Visualización Principal: Inversión en I+D por País

Code
viewof selected_region = Inputs.select(
  ["Todas las regiones", "Asia-Pacifico", "Europa", "America del Norte"],
  {label: "Filtrar por región:", value: "Todas las regiones"}
)
Code
// Filtrar datos según selección
filtered_data = selected_region === "Todas las regiones" 
  ? rd_data 
  : rd_data.filter(d => d.region === selected_region)
Code
// Gráfico de barras principal
Plot.plot({
  title: "Gastos Domésticos en I+D como % del PIB (2023)",
  subtitle: `Países seleccionados: ${selected_region}`,
  width: 900,
  height: 500,
  marginLeft: 100,
  marginBottom: 80,
  x: {
    domain: [0, 7],
    label: "Porcentaje del PIB (%)",
    grid: true
  },
  y: {
    label: null,
    domain: filtered_data.map(d => d.country)
  },
  color: {
    domain: Array.from(color_scale.keys()),
    range: Array.from(color_scale.values()),
    legend: true
  },
  marks: [
    Plot.barX(filtered_data, {
      x: "value",
      y: "country",
      fill: "region",
      sort: {y: "x", reverse: true},
      tip: true
    }),
    Plot.text(filtered_data, {
      x: d => d.value + 0.15,
      y: "country",
      text: d => `${d.value}%`,
      fontSize: 11,
      fill: "#333"
    }),
    Plot.ruleX([2.7], {stroke: "#e74c3c", strokeWidth: 2, strokeDasharray: "5,5"})
  ]
})
Code
html`<p style="margin-top: 10px; font-size: 14px; color: #666;">
<strong>Línea roja punteada:</strong> Promedio OECD (2.7%)
</p>`

Análisis por Regiones

Code
// Análisis regional
regional_summary = d3.rollup(
  rd_data,
  v => ({
    count: v.length,
    average: d3.mean(v, d => d.value),
    max: d3.max(v, d => d.value),
    min: d3.min(v, d => d.value),
    countries: v.map(d => d.country).join(", ")
  }),
  d => d.region
)
Code
// Tabla resumen por región
Inputs.table(
  Array.from(regional_summary, ([region, data]) => ({
    "Región": region,
    "Países": data.count,
    "Promedio (%)": data.average.toFixed(2),
    "Máximo (%)": data.max.toFixed(2),
    "Mínimo (%)": data.min.toFixed(2)
  })),
  {
    header: {
      "Región": "Región",
      "Países": "Número de Países",
      "Promedio (%)": "Promedio I+D/PIB",
      "Máximo (%)": "Máximo",
      "Mínimo (%)": "Mínimo"
    }
  }
)

Distribución y Percentiles

Code
// Histograma de distribución
Plot.plot({
  title: "Distribución de la Inversión en I+D",
  width: 700,
  height: 300,
  x: {
    label: "Porcentaje del PIB (%)",
    domain: [0, 7]
  },
  y: {
    label: "Número de países"
  },
  marks: [
    Plot.rectY(rd_data, Plot.binX({y: "count"}, {x: "value", thresholds: 20, fill: "#4ecdc4", opacity: 0.7})),
    Plot.ruleX([stats.mean], {stroke: "#e74c3c", strokeWidth: 2}),
    Plot.ruleX([stats.median], {stroke: "#f39c12", strokeWidth: 2})
  ]
})
Code
html`<p style="margin-top: 10px; font-size: 14px; color: #666;">
<span style="color: #e74c3c;"><strong>Línea roja:</strong> Media (${stats.mean.toFixed(2)}%)</span> | 
<span style="color: #f39c12;"><strong>Línea naranja:</strong> Mediana (${stats.median.toFixed(2)}%)</span>
</p>`

Insights Clave

Code
html`
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h4 style="color: #2c3e50; margin-top: 0;">📊 Hallazgos Principales</h4>

<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;">
  
<div style="background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #ff6b6b;">
<strong>🏆 Líderes Globales</strong><br>
Israel encabeza con <strong>${stats.max}%</strong> del PIB, seguido por Corea del Sur y Suecia
</div>

<div style="background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #4ecdc4;">
<strong>🌍 Umbral del 3%</strong><br>
Solo <strong>${stats.countries_above_3}</strong> de ${stats.total_countries} países superan el 3% del PIB
</div>

<div style="background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #45b7d1;">
<strong>📈 Promedio OECD</strong><br>
El promedio OECD es <strong>2.7%</strong> del PIB en 2023
</div>

<div style="background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #f39c12;">
<strong>🌏 Dominancia Asiática</strong><br>
Asia representa <strong>46%</strong> del gasto global en I+D
</div>

</div>
</div>
`

Top 10 Países

Code
// Top 10 países
top_10 = rd_data
  .sort((a, b) => b.value - a.value)
  .slice(0, 10)
Code
Plot.plot({
  title: "Top 10 Países en Inversión I+D/PIB",
  width: 600,
  height: 350,
  x: {
    label: null,
    tickRotate: -45
  },
  y: {
    label: "Porcentaje del PIB (%)",
    domain: [0, 7]
  },
  color: {
    domain: Array.from(color_scale.keys()),
    range: Array.from(color_scale.values())
  },
  marks: [
    Plot.barY(top_10, {
      x: "country",
      y: "value",
      fill: "region",
      tip: true
    }),
    Plot.text(top_10, {
      x: "country",
      y: d => d.value + 0.15,
      text: d => `${d.value}%`,
      fontSize: 10,
      fill: "#333"
    })
  ]
})

Explorador Interactivo

Code
viewof selected_country = Inputs.select(
  rd_data.map(d => d.country),
  {label: "Seleccionar país para detalles:", value: "Israel"}
)
Code
// Detalles del país seleccionado
country_detail = rd_data.find(d => d.country === selected_country)
Code
html`
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 25px; border-radius: 12px; margin: 20px 0;">
<h3 style="margin-top: 0; font-size: 24px;">${country_detail.country}</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-top: 15px;">
  
<div>
<div style="font-size: 32px; font-weight: bold;">${country_detail.value}%</div>
<div style="opacity: 0.9;">del PIB en I+D</div>
</div>

<div>
<div style="font-size: 18px; font-weight: bold;">${country_detail.region}</div>
<div style="opacity: 0.9;">Región</div>
</div>

<div>
<div style="font-size: 18px; font-weight: bold;">
${(country_detail.value / stats.mean).toFixed(1)}x
</div>
<div style="opacity: 0.9;">vs. promedio OECD</div>
</div>

<div>
<div style="font-size: 18px; font-weight: bold;">
#${rd_data.sort((a, b) => b.value - a.value).findIndex(d => d.country === country_detail.country) + 1}
</div>
<div style="opacity: 0.9;">Ranking mundial</div>
</div>

</div>
</div>
`

Metodología y Fuentes

Definición del Indicador

El Gasto Doméstico Bruto en Investigación y Desarrollo (GERD) como porcentaje del PIB incluye:

  1. Gastos corrientes y de capital en I+D
  2. Cuatro sectores principales:
    • Empresas comerciales
    • Gobierno
    • Educación superior
    • Organizaciones privadas sin fines de lucro
  3. Tres tipos de investigación:
    • Investigación básica
    • Investigación aplicada
    • Desarrollo experimental

Fuentes de Datos

  • Fuente principal: OECD Main Science and Technology Indicators (MSTI) Database
  • URL: https://data-explorer.oecd.org
  • Datos complementarios: World Bank, UNESCO Institute for Statistics
  • Año de referencia: 2023 (datos más recientes disponibles)
  • Metodología: Paridades de Poder Adquisitivo (PPP), precios constantes base 2015

Notas Técnicas

  • Los datos incluyen I+D financiada desde el extranjero pero excluyen fondos domésticos para I+D realizada fuera del territorio nacional
  • Las cifras están expresadas en dólares estadounidenses constantes usando PPP para permitir comparaciones internacionales
  • Algunos países pueden tener metodologías ligeramente diferentes en la recopilación de datos

Objetos en observable

Diagrama de radar

Code
data = [
  {axis: "1. Evaluación comunitaria", value: 1.2},
  {axis: "2. Evaluación científica del riesgo", value: 2.0},
  {axis: "3. Diseminación de información en RRD", value: 2.5},
  {axis: "4. Educación de los niños en RRD", value: 3.0},
  {axis: "5. RRD en la planificación del desarrollo", value: 1.8},
  {axis: "6. RRD en la planificación territorial", value: 2.0},
  {axis: "7. Toma comunitaria de decisiones", value: 2.2},
  {axis: "8. Inclusión de grupos vulnerables", value: 1.7},
  {axis: "9. Participación de las mujeres", value: 2.3},
  {axis: "10. Conocimiento de derechos", value: 1.9},
  {axis: "11. Alianzas para la RRD", value: 2.6},
  {axis: "12. Gestión ambiental sostenible", value: 2.1},
  {axis: "13. Seguridad y gestión del agua", value: 1.8},
  {axis: "14. Acceso y conciencia de la salud", value: 1.6},
  {axis: "15. Suministro seguro de alimentos", value: 1.4},
  {axis: "16. Prácticas de medios de vida", value: 1.3},
  {axis: "17. Acceso a mercado", value: 1.5},
  {axis: "18. Acceso a servicios financieros", value: 1.2},
  {axis: "19. Protección de ingresos y activos", value: 1.7},
  {axis: "20. Acceso a protección social", value: 2.0},
  {axis: "21. Cohesión social", value: 3.2},
  {axis: "22. Infraestructura crítica", value: 2.8},
  {axis: "23. Vivienda", value: 1.9},
  {axis: "24. Planificación de contingencia", value: 2.1},
  {axis: "25. Sistema de alerta temprana", value: 2.3},
  {axis: "26. Capacidad de preparación", value: 2.0},
  {axis: "27. Servicios de salud", value: 2.5},
  {axis: "28. Servicios de educación", value: 2.9},
  {axis: "29. Infraestructura en emergencias", value: 2.7},
  {axis: "30. Liderazgo y voluntariado", value: 3.0}
]

// Radar chart con D3
{
  const width = 600, height = 600;
  const radius = Math.min(width, height) / 2 - 60;
  const levels = 5; // número de círculos concéntricos
  const maxValue = 3.5; // escala máxima

  const angleSlice = (Math.PI * 2) / data.length;

  const rScale = d3.scaleLinear()
    .domain([0, maxValue])
    .range([0, radius]);

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

  const g = svg.append("g")
    .attr("transform", `translate(${width/2},${height/2})`);

  // Dibujar círculos concéntricos
  for (let i = 0; i <= levels; i++) {
    g.append("circle")
      .attr("r", radius/levels * i)
      .attr("fill", "none")
      .attr("stroke", "#ccc");
  }

  // Ejes radiales
  data.forEach((d, i) => {
    const angle = angleSlice * i - Math.PI/2;
    g.append("line")
      .attr("x1", 0)
      .attr("y1", 0)
      .attr("x2", rScale(maxValue) * Math.cos(angle))
      .attr("y2", rScale(maxValue) * Math.sin(angle))
      .attr("stroke", "#999");

    // Etiquetas
    g.append("text")
      .attr("x", (rScale(maxValue + 0.2)) * Math.cos(angle))
      .attr("y", (rScale(maxValue + 0.2)) * Math.sin(angle))
      .attr("dy", "0.35em")
      .style("font-size", "10px")
      .style("text-anchor", angle > Math.PI/2 && angle < 3*Math.PI/2 ? "end" : "start")
      .text(d.axis);
  });

  // Línea de valores
  const line = d3.lineRadial()
    .radius(d => rScale(d.value))
    .angle((d,i) => i * angleSlice)
    .curve(d3.curveLinearClosed);

  g.append("path")
    .datum(data)
    .attr("d", line)
    .attr("fill", "rgba(50,150,250,0.5)")
    .attr("stroke", "steelblue")
    .attr("stroke-width", 2);

  return svg.node();
}

Tablas

Tabla con D3.js puro

Code
// Datos de ejemplo
paises_data = [
  { País: "Colombia", Población: 52000000, Región: "América" },
  { País: "Chile", Población: 19000000, Región: "América" },
  { País: "España", Población: 47000000, Región: "Europa" }
]
Code
// Tabla creada con D3
{
  const data = paises_data;
  const table = d3.create("table")
    .style("border-collapse", "collapse")
    .style("width", "100%");

  const thead = table.append("thead");
  const tbody = table.append("tbody");

  const columns = Object.keys(data[0]);

  // encabezados
  thead.append("tr")
    .selectAll("th")
    .data(columns)
    .enter()
    .append("th")
    .text(d => d)
    .style("border", "1px solid #ccc")
    .style("background", "#f4f4f4")
    .style("padding", "8px");

  // filas
  const rows = tbody.selectAll("tr")
    .data(data)
    .enter()
    .append("tr");

  rows.selectAll("td")
    .data(d => columns.map(c => d[c]))
    .enter()
    .append("td")
    .text(d => d)
    .style("border", "1px solid #ccc")
    .style("padding", "8px");

  return table.node();
}

Tabla con filtros usando Inputs.table

Code
viewof filtro = Inputs.select(["Todas", "América", "Europa"], {label: "Filtrar por región:", value: "Todas"})
Code
// Datos filtrados
paises_filtrados = filtro === "Todas"
  ? paises_data
  : paises_data.filter(d => d.Región === filtro)
Code
Inputs.table(paises_filtrados, {
  header: {
    "País": "País",
    "Población": "Población",
    "Región": "Región"
  }
})

Distribución de edades en Colombia

Piramide poblacional en D3

Pirámide poblacional construida con las Proyecciones de Población DANE 2018-2070, por área geográfica y año.

Code
viewof año_pir = {
  let year = 2024;
  let playing = false;
  let interval = null;
  let speed = 600;
  const speeds = { '1×': 600, '1.5×': 400, '2×': 300, '4×': 150 };

  const btnStyle = (active) =>
    `padding:4px 10px;border-radius:4px;cursor:pointer;border:none;
     font-size:12px;font-weight:600;
     background:${active ? '#3a7abf' : '#ddd'};
     color:${active ? 'white' : '#555'}`;

  const container = html`<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:6px 0">
    <label style="font-size:13px;font-weight:600;color:#333">Año:</label>
    <button id="playbtn_pir" style="padding:5px 16px;border-radius:4px;cursor:pointer;
      background:#3a7abf;color:white;border:none;font-size:13px;font-weight:600">
      ▶ Play
    </button>
    <span id="yearlbl_pir" style="font-weight:700;font-size:16px;min-width:44px;color:#222">${year}</span>
    <input id="slider_pir" type="range" min="2018" max="2070" step="1" value="${year}"
      style="width:240px;accent-color:#3a7abf">
    <span style="font-size:12px;color:#888">2018 – 2070</span>
    <div style="display:flex;gap:4px;margin-left:4px">
      <button class="spd_pir" data-ms="600"  style="${btnStyle(true)}">1×</button>
      <button class="spd_pir" data-ms="400"  style="${btnStyle(false)}">1.5×</button>
      <button class="spd_pir" data-ms="300"  style="${btnStyle(false)}">2×</button>
      <button class="spd_pir" data-ms="150"  style="${btnStyle(false)}">4×</button>
    </div>
  </div>`;

  const btn      = container.querySelector('#playbtn_pir');
  const slider   = container.querySelector('#slider_pir');
  const lbl      = container.querySelector('#yearlbl_pir');
  const spdBtns  = container.querySelectorAll('.spd_pir');

  function update(y) {
    year = y; slider.value = y; lbl.textContent = y;
    container.value = y;
    container.dispatchEvent(new CustomEvent('input'));
  }

  function setSpeed(ms) {
    speed = ms;
    spdBtns.forEach(b => {
      const active = +b.dataset.ms === ms;
      b.style.background = active ? '#3a7abf' : '#ddd';
      b.style.color      = active ? 'white'   : '#555';
    });
    if (playing) {
      clearInterval(interval);
      interval = setInterval(() => update(year >= 2070 ? 2018 : year + 1), speed);
    }
  }

  btn.addEventListener('click', () => {
    playing = !playing;
    btn.textContent   = playing ? '⏸ Pausa' : '▶ Play';
    btn.style.background = playing ? '#c0392b' : '#3a7abf';
    if (playing) {
      interval = setInterval(() => update(year >= 2070 ? 2018 : year + 1), speed);
    } else {
      clearInterval(interval);
    }
  });

  slider.addEventListener('input', () => update(+slider.value));
  spdBtns.forEach(b => b.addEventListener('click', () => setSpeed(+b.dataset.ms)));

  container.value = year;
  return container;
}
Code
viewof area_pir = Inputs.select(["Total", "Cabecera", "Centros Poblados y Rural Disperso"], {
  label: "Área geográfica:"
})
Code
orden_grupos = ["0 - 4","5 - 9","10 - 14","15 - 19","20 - 24","25 - 29",
                "30 - 34","35 - 39","40 - 44","45 - 49","50 - 54","55 - 59",
                "60 - 64","65 - 69","70 - 74","75 - 79","80 +"]

datos_filtrados = datos_piramide_colombia
  .filter(d => d.año === año_pir && d.area === area_pir)
  .sort((a, b) => a.orden - b.orden)

total_pob = datos_filtrados.length > 0 ? datos_filtrados[0].total_pob : 0

fmt_num = n => n.toLocaleString('de-DE')
Code
Plot.plot({
  width: 560,
  height: 480,
  marginLeft: 70,
  marginRight: 10,
  marginBottom: 60,
  style: { fontSize: "12px" },
  x: {
    tickFormat: d => `${Math.abs(d).toFixed(1)}`,
    label: `Población en ${año_pir} (%)  (Total: ${fmt_num(total_pob)})`,
    labelOffset: 50,
    domain: [-5, 5],
    grid: true
  },
  y: {
    label: "Rangos de edad",
    domain: orden_grupos
  },
  color: {
    domain: ["Hombres", "Mujeres"],
    range: ["#87CEEB", "#FFE066"],
    legend: true
  },
  marks: [
    Plot.barX(
      datos_filtrados.flatMap(d => [
        { grupo: d.grupo, sexo: "Hombres", pct: -(d.hombres / total_pob) * 100 },
        { grupo: d.grupo, sexo: "Mujeres", pct:  (d.mujeres / total_pob) * 100 }
      ]),
      { x: "pct", y: "grupo", fill: "sexo", tip: true }
    ),
    Plot.ruleX([0], { stroke: "#555", strokeWidth: 1.2 })
  ]
})

Proyecciones y retroproyecciones de población departamental para el periodo 1985-2017 y 2018-2050 con base en el CNPV 2018

Code
datos_dep_raw = FileAttachment("data/piramide_dep.json").json()
Code
departamentos_lista = [...new Set(datos_dep_raw.map(d => d.d))].sort()

viewof depto_sel = Inputs.select(departamentos_lista, {
  label: "Departamento:",
  value: "Córdoba"
})
Code
viewof año_dep = {
  let year = 2024;
  let playing = false;
  let interval = null;
  let speed = 600;

  const btnStyle = (active) =>
    `padding:4px 10px;border-radius:4px;cursor:pointer;border:none;
     font-size:12px;font-weight:600;
     background:${active ? '#3a7abf' : '#ddd'};
     color:${active ? 'white' : '#555'}`;

  const container = html`<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:6px 0">
    <label style="font-size:13px;font-weight:600;color:#333">Año:</label>
    <button id="playbtn_dep" style="padding:5px 16px;border-radius:4px;cursor:pointer;
      background:#3a7abf;color:white;border:none;font-size:13px;font-weight:600">
      ▶ Play
    </button>
    <span id="yearlbl_dep" style="font-weight:700;font-size:16px;min-width:44px;color:#222">${year}</span>
    <input id="slider_dep" type="range" min="2018" max="2050" step="1" value="${year}"
      style="width:220px;accent-color:#3a7abf">
    <span style="font-size:12px;color:#888">2018 – 2050</span>
    <div style="display:flex;gap:4px;margin-left:4px">
      <button class="spd_dep" data-ms="600"  style="${btnStyle(true)}">1×</button>
      <button class="spd_dep" data-ms="400"  style="${btnStyle(false)}">1.5×</button>
      <button class="spd_dep" data-ms="300"  style="${btnStyle(false)}">2×</button>
      <button class="spd_dep" data-ms="150"  style="${btnStyle(false)}">4×</button>
    </div>
  </div>`;

  const btn     = container.querySelector('#playbtn_dep');
  const slider  = container.querySelector('#slider_dep');
  const lbl     = container.querySelector('#yearlbl_dep');
  const spdBtns = container.querySelectorAll('.spd_dep');

  function update(y) {
    year = y; slider.value = y; lbl.textContent = y;
    container.value = y;
    container.dispatchEvent(new CustomEvent('input'));
  }

  function setSpeed(ms) {
    speed = ms;
    spdBtns.forEach(b => {
      const active = +b.dataset.ms === ms;
      b.style.background = active ? '#3a7abf' : '#ddd';
      b.style.color      = active ? 'white'   : '#555';
    });
    if (playing) {
      clearInterval(interval);
      interval = setInterval(() => update(year >= 2050 ? 2018 : year + 1), speed);
    }
  }

  btn.addEventListener('click', () => {
    playing = !playing;
    btn.textContent      = playing ? '⏸ Pausa' : '▶ Play';
    btn.style.background = playing ? '#c0392b' : '#3a7abf';
    if (playing) {
      interval = setInterval(() => update(year >= 2050 ? 2018 : year + 1), speed);
    } else {
      clearInterval(interval);
    }
  });

  slider.addEventListener('input', () => update(+slider.value));
  spdBtns.forEach(b => b.addEventListener('click', () => setSpeed(+b.dataset.ms)));

  container.value = year;
  return container;
}
Code
viewof area_dep = Inputs.select(["Total", "Cabecera Municipal", "Centros Poblados y Rural Disperso"], {
  label: "Área geográfica:"
})
Code
orden_dep = ["0 - 4","5 - 9","10 - 14","15 - 19","20 - 24","25 - 29",
             "30 - 34","35 - 39","40 - 44","45 - 49","50 - 54","55 - 59",
             "60 - 64","65 - 69","70 - 74","75 - 79","80 +"]

datos_dep_fil = datos_dep_raw
  .filter(d => d.d === depto_sel && d.a === año_dep && d.ar === area_dep)
  .sort((a, b) => a.o - b.o)

total_dep = datos_dep_fil.length > 0 ? datos_dep_fil[0].t : 0
Code
Plot.plot({
  width: 560,
  height: 480,
  marginLeft: 70,
  marginRight: 10,
  marginBottom: 60,
  style: { fontSize: "12px" },
  x: {
    tickFormat: d => `${Math.abs(d).toFixed(1)}`,
    label: `Población en ${año_dep} (%)  (Total: ${total_dep.toLocaleString('de-DE')})`,
    labelOffset: 50,
    domain: [-5, 5],
    grid: true
  },
  y: {
    label: "Rangos de edad",
    domain: orden_dep
  },
  color: {
    domain: ["Hombres", "Mujeres"],
    range: ["#87CEEB", "#FFE066"],
    legend: true
  },
  marks: [
    Plot.barX(
      datos_dep_fil.flatMap(d => [
        { grupo: d.g, sexo: "Hombres", pct: -(d.h / total_dep) * 100 },
        { grupo: d.g, sexo: "Mujeres", pct:  (d.m / total_dep) * 100 }
      ]),
      { x: "pct", y: "grupo", fill: "sexo", tip: true }
    ),
    Plot.ruleX([0], { stroke: "#555", strokeWidth: 1.2 })
  ]
})

Piramide poblacional en Plotly

Cifras de emergencias reportadas por el departamento durante 1938-2024

Code
import io
import warnings
import pandas as pd
import requests
import plotly.graph_objects as go

# Leer datos desde Google Sheets (SSL deshabilitado por cert local)
sheet_id = "19G1PVPCOeF0LOJ-cqxa_7mk9LycuaJa2a12-HNLqmIo"
url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    resp = requests.get(url, verify=False, timeout=30)
df = pd.read_csv(io.StringIO(resp.content.decode("utf-8")), low_memory=False)

# Parsear fecha y filtrar Córdoba 1938-2024
df["FECHA"] = pd.to_datetime(df["FECHA"], dayfirst=True, errors="coerce")
df["AÑO"]   = df["FECHA"].dt.year

cordoba = df[
    df["DEPARTAMENTO"].str.upper().str.strip()
      .str.contains("CÓRDOBA|CORDOBA", na=False) &
    df["AÑO"].between(1938, 2024)
].copy()

# Conteo y agrupación (top 9 + Otros)
counts = cordoba["EVENTO"].value_counts()
total  = counts.sum()
top    = counts.head(9)
otros  = counts.iloc[9:].sum()
if otros > 0:
    top = pd.concat([top, pd.Series({"Otros": otros})])

labels = top.index.tolist()
values = top.values.tolist()

# Paleta de colores
color_map = {
    "INUNDACIÓN":                "#4472C4",
    "INCENDIO FORESTAL":         "#2CA8AA",
    "VENDAVAL":                  "#E36C09",
    "INCENDIO":                  "#F79646",
    "SEQUÍA":                    "#FF8C7A",
    "DESLIZAMIENTO":             "#FF1493",
    "AVENIDA TORRENCIAL":        "#7030A0",
    "DESABASTECIMIENTO DE AGUA": "#9E80B4",
    "LLUVIAS":                   "#FFC000",
    "Otros":                     "#D99694",
}
default_colors = [
    "#4472C4","#2CA8AA","#E36C09","#F79646","#FF8C7A",
    "#FF1493","#7030A0","#9E80B4","#FFC000","#D99694",
]
colors = [
    color_map.get(lbl, default_colors[i % len(default_colors)])
    for i, lbl in enumerate(labels)
]

# Texto interior (porcentaje con coma decimal, solo si ≥ 4%)
pcts        = [v / total * 100 for v in values]
text_inside = [
    f"{p:.1f}%".replace(".", ",") if p >= 4 else ""
    for p in pcts
]

# Gráfica
fig = go.Figure(data=[go.Pie(
    labels=labels,
    values=values,
    text=text_inside,
    textinfo="text",
    textposition="inside",
    insidetextorientation="radial",
    textfont=dict(size=13, color="white"),
    marker=dict(colors=colors, line=dict(color="white", width=1.5)),
    hovertemplate=(
        "<b>%{label}</b><br>"
        "Eventos: %{value:,}<br>"
        "Porcentaje: %{percent}<extra></extra>"
    ),
)])

fig.update_layout(
    title=dict(
        text=f"<b>Tipo de Evento — Córdoba (1938–2024)</b><br>"
             f"<sup>Total: {total:,} eventos</sup>",
        x=0.42, xanchor="center",
        font=dict(size=16, family="Arial"),
    ),
    legend=dict(
        orientation="v", xanchor="left",
        x=1.01, y=0.5, yanchor="middle",
        font=dict(size=11, family="Arial"),
    ),
    width=860, height=560,
    margin=dict(t=90, b=40, l=20, r=260),
    paper_bgcolor="white",
)

fig.show()
Figure 1

Documento generado con Quarto y Observable JS