{
const mobile = vizWidth < 640
const make = height => Plot.plot({
width: vizWidth,
height,
marginBottom: 50,
marginTop: 34,
style: plotStyle,
x: {label: i18n.year, padding: mobile ? 0.3 : 0.1, tickFormat: d => "'" + String(d).slice(2), ticks: mobile ? [1990, 2000, 2010, 2020] : [1990, 1995, 2000, 2005, 2010, 2015, 2020]},
y: {label: i18n.pctEvents, domain: [0, 100]},
color: {
domain: Object.keys(grupoColors),
range: Object.values(grupoColors),
legend: true,
tickFormat: d => i18n.grupos[d] ?? d
},
marks: [
Plot.barY(transpose(aseg_comp_data), Plot.stackY({
order: Object.keys(grupoColors),
x: "anio",
y: "pct",
fill: "grupo",
tip: true,
title: d => `${i18n.grupos[d.grupo] ?? d.grupo}: ${d.pct.toFixed(1)}% (${fmtNum(d.n_eventos)} ${d.n_eventos === 1 ? i18n.event : i18n.events})`
})),
Plot.ruleY([0])
]
})
// Computadora: proporción 2:1
if (!mobile) return make(Math.round(vizWidth * 0.5))
// Celular: la gráfica ocupa exactamente el alto que queda en el marco debajo del
// título y la leyenda, para no dejar una franja vacía abajo (antes se reservaban
// 200px fijos, que era más de lo que ocupan título y leyenda).
const measure = el => {
const box = document.createElement("div")
box.style.cssText = `position:absolute; left:-9999px; top:0; width:${vizWidth}px; visibility:hidden`
box.appendChild(el)
document.body.appendChild(box)
// Alto + margen inferior, medidos mientras el elemento está en la página
const h = el.getBoundingClientRect().height + (parseFloat(getComputedStyle(el).marginBottom) || 0)
box.remove()
return h
}
const title = document.querySelector("h2.viz-title")
const chartTop = title ? title.getBoundingClientRect().bottom + parseFloat(getComputedStyle(title).marginBottom) : 70
const padBottom = parseFloat(getComputedStyle(document.body).paddingBottom) || 8
const guess = Math.max(300, frameHeight - 200)
const extra = measure(make(guess)) - guess // leyenda + separación + margen
const height = Math.max(260, Math.floor(frameHeight - chartTop - extra - padBottom))
const fig = make(height)
// Ajuste fino ya insertada en la página: corrige lo que quede (márgenes de Quarto,
// escalado del svg por CSS) para que la gráfica termine justo en el borde del marco.
setTimeout(() => {
if (!fig.isConnected) return
const svg = fig.querySelector("svg[class^='plot-']") ?? fig
const scale = svg.getBoundingClientRect().width / (+svg.getAttribute("width") || svg.getBoundingClientRect().width) || 1
const bottom = fig.getBoundingClientRect().bottom + parseFloat(getComputedStyle(fig).marginBottom)
const diff = window.innerHeight - padBottom - bottom
// Se cambian los hijos (leyenda + svg) y no el nodo <figure>, porque el runtime de OJS
// conserva la referencia al nodo devuelto y la usa al volver a ejecutar la celda.
if (Math.abs(diff) > 2) fig.replaceChildren(...make(Math.max(260, Math.round(height + diff / scale))).childNodes)
}, 0)
return fig
}Seizures by drug type, Mexico, 1990–2024
brand = ({
purple: "#403A5E",
taupe: "#AEA793",
green: "#5D8E74",
blue: "#3D8CBC",
maroon: "#9F344D",
orange: "#E3A261"
})
grupoColors = ({
"Mariguana": brand.green,
"Cocaina": brand.blue,
"Opioides tradicionales": brand.orange,
"Metanfetamina": brand.purple,
"Fentanilo": brand.maroon
})
labColors = ({
"COCAINA": brand.blue,
"HEROINA": brand.orange,
"METANFETAMINA": brand.purple
})
metodoColors = ({
"Fumigación": brand.blue,
"Manual": brand.green
})
// Grupos presentes en incineraciones (sin fentanilo), en orden
incGrupos = ["Mariguana", "Cocaina", "Opioides tradicionales", "Metanfetamina"]
plotStyle = ({fontFamily: "Lato, system-ui, sans-serif", fontSize: "16px"})i18n = ({
es: {
locale: "es-MX",
period: "Período",
group: "Grupo de droga",
all: "Todos",
events: "eventos",
event: "evento",
year: "Año",
pctEvents:"% de eventos",
drugType: "Tipo de droga",
term: "Administración presidencial",
ops: "Operaciones militares",
download: "⬇ Descargar datos (CSV)",
grupos: {
"Mariguana": "Mariguana",
"Cocaina": "Cocaína",
"Opioides tradicionales": "Opioides tradicionales",
"Metanfetamina": "Metanfetamina",
"Fentanilo": "Fentanilo"
},
labs: {COCAINA: "Cocaína", HEROINA: "Heroína", METANFETAMINA: "Metanfetamina"},
drogas: {
mariguana: "Mariguana", semilla_de_mariguana: "Semilla de mariguana",
cocaina: "Cocaína", goma_de_opio: "Goma de opio", heroina: "Heroína",
semilla_de_amapola: "Semilla de amapola", metanfetamina: "Metanfetamina",
fentanilo: "Fentanilo", pastillas_de_fentanilo: "Pastillas de fentanilo",
ampolletas_de_fentanilo: "Ampolletas de fentanilo"
},
unidades: {kg: "kg", pastillas: "pastillas", ampolletas: "ampolletas"}
},
en: {
locale: "en-US",
period: "Period",
group: "Drug group",
all: "All",
events: "events",
event: "event",
year: "Year",
pctEvents:"% of events",
drugType: "Drug type",
term: "Presidential term",
ops: "Military operations",
download: "⬇ Download data (CSV)",
grupos: {
"Mariguana": "Marijuana",
"Cocaina": "Cocaine",
"Opioides tradicionales": "Traditional opioids",
"Metanfetamina": "Methamphetamine",
"Fentanilo": "Fentanyl"
},
labs: {COCAINA: "Cocaine", HEROINA: "Heroin", METANFETAMINA: "Methamphetamine"},
drogas: {
mariguana: "Marijuana", semilla_de_mariguana: "Marijuana seed",
cocaina: "Cocaine", goma_de_opio: "Opium gum", heroina: "Heroin",
semilla_de_amapola: "Poppy seed", metanfetamina: "Methamphetamine",
fentanilo: "Fentanyl", pastillas_de_fentanilo: "Fentanyl pills",
ampolletas_de_fentanilo: "Fentanyl ampoules"
},
unidades: {kg: "kg", pastillas: "pills", ampolletas: "ampoules"}
}
})[lang]
// Números: miles separados con espacio indivisible ("10 000") y punto decimal,
// igual en español e inglés. Sin coma de miles (estilo de la casa).
fmtNum = (v, maxDec = 2) => {
const s = Number(v).toLocaleString("en-US", {maximumFractionDigits: maxDec, useGrouping: false})
const [int, dec] = s.split(".")
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0")
return dec ? `${grouped}.${dec}` : grouped
}
// Rangos con guion corto ("1990-2000") se muestran con raya ("1990–2000")
fmtRange = s => String(s).replace("-", "–")
// Ancho de las gráficas: llena el contenedor, con tope para pantallas grandes.
// Se mide directamente el ancho de la columna de contenido y se vuelve a avisar cada vez
// que cambia (p. ej. cuando el iframe se acomoda tarde en celular), para que las gráficas
// se redibujen al ancho real y no se queden con el ancho provisional del primer dibujo.
vizWidth = Generators.observe(notify => {
const main = document.querySelector("main.content") || document.body
let last = -1
const send = () => {
const w = Math.min(Math.floor(main.clientWidth || width), 1000)
if (w > 0 && w !== last) { last = w; notify(w) }
}
send()
const ro = new ResizeObserver(send)
ro.observe(main)
window.addEventListener("resize", send)
return () => { ro.disconnect(); window.removeEventListener("resize", send) }
})