topo = FileAttachment("data/colombia_moderate.topojson").json()
pob_raw = FileAttachment("data/poblacion_mun_2026.json").json()
dept = topojson.feature(topo, topo.objects.departamentos)
mun = topojson.feature(topo, topo.objects.municipios)
nombres_deptos = ["— Colombia (todos) —",
...new Set(dept.features.map(f => f.properties.departamento).sort())
]
// Índice rápido departamento|municipio → población
pob_index = new Map(pob_raw.map(d => [`${d.departamento}|${d.municipio}`, d.poblacion]))
// Densidad por municipio: hab/km² usando área geodésica real
mun_densidad = mun.features.map(f => {
const key = `${f.properties.departamento}|${f.properties.municipio}`
const pob = pob_index.get(key) ?? 0
const area = d3.geoArea(f) * 6371 ** 2 // esteradianes → km²
return { feature: f, densidad: pob / area, pob, area,
nombre: f.properties.municipio, depto: f.properties.departamento }
})Objetos de Fichas Departamentales
Mapas del departamento y municipios
dept_filtrado = depto_map === "— Colombia (todos) —"
? dept
: ({
type: "FeatureCollection",
features: dept.features.filter(
f => f.properties.departamento === depto_map
)
})
mun_filtrado = depto_map === "— Colombia (todos) —"
? null
: ({
type: "FeatureCollection",
features: mun.features.filter(
f => f.properties.departamento === depto_map
)
})
n_municipios = mun_filtrado ? mun_filtrado.features.length : null{
const width = 860
const height = 680
const container = d3.create("div")
.style("position", "relative")
.style("font-family", "system-ui, sans-serif")
const svg = container.append("svg")
.attr("width", "100%")
.attr("viewBox", `0 0 ${width} ${height}`)
.style("background", "#f0f4f8")
.style("border-radius", "12px")
const geo_base = depto_map === "— Colombia (todos) —" ? dept : dept_filtrado
const projection = d3.geoMercator()
.fitExtent([[20, 20], [width - 20, height - 60]], geo_base)
const path = d3.geoPath().projection(projection)
svg.append("g")
.selectAll("path")
.data(dept.features)
.join("path")
.attr("d", path)
.attr("fill", "#e8ddc4")
.attr("stroke", "#b8a98a")
.attr("stroke-width", 0.5)
if (mun_filtrado) {
svg.append("g")
.selectAll("path")
.data(mun_filtrado.features)
.join("path")
.attr("d", path)
.attr("fill", d => color_mun(d.properties.municipio))
.attr("fill-opacity", 0.7)
.attr("stroke", "#92400e")
.attr("stroke-width", 0.7)
.append("title")
.text(d => d.properties.municipio)
}
if (mun_filtrado) {
const MIN_AREA = 400
svg.append("g")
.attr("pointer-events", "none")
.selectAll("text")
.data(mun_filtrado.features.filter(d => {
const bounds = path.bounds(d)
const w = bounds[1][0] - bounds[0][0]
const h = bounds[1][1] - bounds[0][1]
return w * h > MIN_AREA
}))
.join("text")
.attr("x", d => path.centroid(d)[0])
.attr("y", d => path.centroid(d)[1])
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("font-size", "9px")
.attr("font-family", "system-ui, sans-serif")
.attr("font-weight", "600")
.attr("fill", "#3b1f07")
.attr("paint-order", "stroke")
.attr("stroke", "white")
.attr("stroke-width", "2.5px")
.attr("stroke-linejoin", "round")
.text(d => d.properties.municipio)
}
if (depto_map !== "— Colombia (todos) —") {
svg.append("g")
.selectAll("path")
.data(dept_filtrado.features)
.join("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", "#c0392b")
.attr("stroke-width", 2.5)
}
if (depto_map === "— Colombia (todos) —") {
const tooltip = container.append("div")
.style("position", "absolute")
.style("pointer-events", "none")
.style("background", "rgba(26,60,94,0.9)")
.style("color", "white")
.style("padding", "5px 10px")
.style("border-radius", "6px")
.style("font-size", "12px")
.style("font-weight", "600")
.style("display", "none")
svg.selectAll("path")
.on("mouseover", function(event, d) {
d3.select(this).attr("fill", "#b5975a").attr("fill-opacity", 0.85)
tooltip
.style("display", "block")
.text(d.properties?.departamento || "")
})
.on("mousemove", function(event) {
const [mx, my] = d3.pointer(event, container.node())
tooltip
.style("left", (mx + 12) + "px")
.style("top", (my - 28) + "px")
})
.on("mouseout", function() {
d3.select(this)
.attr("fill", "#e8ddc4")
.attr("fill-opacity", 1)
tooltip.style("display", "none")
})
} else {
const tooltip = container.append("div")
.style("position", "absolute")
.style("pointer-events", "none")
.style("background", "rgba(26,60,94,0.9)")
.style("color", "white")
.style("padding", "5px 10px")
.style("border-radius", "6px")
.style("font-size", "12px")
.style("display", "none")
svg.selectAll("g:nth-child(2) path")
.on("mouseover", function(event, d) {
d3.select(this).attr("fill-opacity", 1).attr("stroke-width", 2)
tooltip
.style("display", "block")
.html(`<b>${d.properties.municipio}</b><br>
<span style="font-size:10px;opacity:.8">${d.properties.departamento}</span>`)
})
.on("mousemove", function(event) {
const [mx, my] = d3.pointer(event, container.node())
tooltip
.style("left", (mx + 12) + "px")
.style("top", (my - 40) + "px")
})
.on("mouseout", function(event, d) {
d3.select(this)
.attr("fill-opacity", 0.7)
.attr("stroke-width", 0.7)
tooltip.style("display", "none")
})
}
const info_y = height - 35
const infobar = svg.append("g")
infobar.append("rect")
.attr("x", 0).attr("y", info_y - 10)
.attr("width", width).attr("height", 50)
.attr("fill", "rgba(26,60,94,0.75)")
const info_text = depto_map === "— Colombia (todos) —"
? `Colombia · 33 departamentos · 1119 municipios · Fuente: IGAC / GADM v4.1`
: `${depto_map} · ${n_municipios} municipio(s) · Fuente: IGAC / GADM v4.1`
infobar.append("text")
.attr("x", width / 2).attr("y", info_y + 12)
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-size", "11px")
.text(info_text)
return container.node()
}Pirámide poblacional departamental
viewof año_dep = {
let year = 2024;
let playing = false;
let interval = null;
let speed = 600;
const speeds = { "1x": 600, "1.5x": 400, "2x": 300, "4x": 150 };
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" 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" style="font-weight:700;font-size:16px;min-width:44px;color:#222">${year}</span>
<input id="slider" 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 id="speedbtns" style="display:flex;gap:4px;margin-left:6px">
${Object.keys(speeds).map(s => `<button class="spdbtn" data-spd="${s}" style="
padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;
background:${s === '1x' ? '#3a7abf' : '#e0e0e0'};
color:${s === '1x' ? 'white' : '#444'};border:none">${s}</button>`).join('')}
</div>
</div>`;
const btn = container.querySelector('#playbtn');
const slider = container.querySelector('#slider');
const lbl = container.querySelector('#yearlbl');
const spdBtns = container.querySelectorAll('.spdbtn');
function update(y) {
year = y;
slider.value = y;
lbl.textContent = y;
container.value = y;
container.dispatchEvent(new CustomEvent('input'));
}
function restartInterval() {
clearInterval(interval);
if (playing) 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';
restartInterval();
});
slider.addEventListener('input', () => update(+slider.value));
spdBtns.forEach(b => {
b.addEventListener('click', () => {
speed = speeds[b.dataset.spd];
spdBtns.forEach(x => {
x.style.background = '#e0e0e0';
x.style.color = '#444';
});
b.style.background = '#3a7abf';
b.style.color = 'white';
restartInterval();
});
});
container.value = year;
return container;
}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 : 0Plot.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 })
]
})