raw = FileAttachment("../data/labor-indicators-cuts.csv").csv({ typed: true })
mx = FileAttachment("../data/mx-estados.json").json()
datos = raw
.filter(d => d.indicador === "TPEA")
.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
nacional = datos.filter(d => d.corte === "nacional").sort((a, b) => a.fecha - b.fecha)
ultimoNacional = nacional[nacional.length - 1]
entidadPorCodigo = ({
"1": "Aguascalientes", "2": "Baja California", "3": "Baja California Sur",
"4": "Campeche", "5": "Coahuila de Zaragoza", "6": "Colima", "7": "Chiapas",
"8": "Chihuahua", "9": "Ciudad de México", "10": "Durango", "11": "Guanajuato",
"12": "Guerrero", "13": "Hidalgo", "14": "Jalisco", "15": "México",
"16": "Michoacán de Ocampo", "17": "Morelos", "18": "Nayarit",
"19": "Nuevo León", "20": "Oaxaca", "21": "Puebla", "22": "Querétaro",
"23": "Quintana Roo", "24": "San Luis Potosí", "25": "Sinaloa", "26": "Sonora",
"27": "Tabasco", "28": "Tamaulipas", "29": "Tlaxcala",
"30": "Veracruz de Ignacio de la Llave", "31": "Yucatán", "32": "Zacatecas"
})
cortes = ({
sexo: { nombre: "Sexo" },
edad: { nombre: "Edad" },
nivel_educativo: { nombre: "Nivel educativo" },
entidad: { nombre: "Entidad" }
})
// Orden fijo de categorias por corte -- las barras y el selector de
// categoria respetan este orden (edad cronologico, escolaridad creciente),
// nunca el orden por nivel de TPEA.
ordenCategorias = ({
sexo: ["Hombre", "Mujer"],
edad: ["15 a 19", "20 a 29", "30 a 39", "40 a 49", "50 a 59", "60 y más"],
nivel_educativo: ["Primaria incompleta", "Primaria completa", "Secundaria completa", "Medio superior y superior"]
})
filasCorte = corte => datos.filter(d => d.corte === corte)
ultimoPeriodoCorte = corte => {
const filas = filasCorte(corte);
const fechaMax = new Date(Math.max(...filas.map(d => +d.fecha)));
return filas.filter(d => +d.fecha === +fechaMax);
}
serieCategoria = (corte, categoria) => filasCorte(corte)
.filter(d => d.categoria_origen === categoria)
.sort((a, b) => a.fecha - b.fecha);
graficaBarras = (filas, dominioX, { width = 640, height = 380 } = {}) => {
return Plot.plot({
width, height,
marginBottom: 86, marginLeft: 46, marginRight: 20,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "12px" },
x: { label: null, domain: dominioX, tickRotate: -30 },
y: { label: "TPEA (%)", grid: true },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.barY(filas, {
x: "categoria_origen", y: "valor",
fill: "var(--teal)", fillOpacity: 0.85
}),
Plot.ruleX(filas, {
x: "categoria_origen",
y1: d => d.valor - 1.96 * d.ee, y2: d => d.valor + 1.96 * d.ee,
stroke: "var(--ink-soft)", strokeWidth: 1.5
}),
Plot.text(filas, {
x: "categoria_origen", y: d => d.valor,
text: d => `${d.valor}%`, dy: -12,
fill: "var(--ink)", fontWeight: 600
}),
Plot.ruleY([0], { stroke: "var(--rule)" })
]
});
}
mxFeatures = topojson.feature(mx, mx.objects.state).features
graficaMapa = (filas, { width = 640, height = 440 } = {}) => {
const valorPorEntidad = new Map(filas.map(d => [d.categoria_origen, d.valor]));
const features = mxFeatures.map(f => ({
...f,
nombre: entidadPorCodigo[+f.properties.id],
valor: valorPorEntidad.get(entidadPorCodigo[+f.properties.id])
}));
return Plot.plot({
width, height,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "12px", color: "var(--ink)" },
projection: { type: "mercator", domain: { type: "FeatureCollection", features } },
color: {
type: "linear", scheme: "BuGn", label: "TPEA (%)", legend: true
},
marks: [
Plot.geo(features, {
fill: d => d.valor,
stroke: "var(--ink)", strokeWidth: 1,
tip: true,
title: d => `${d.nombre}\nTPEA: ${d.valor}%`
})
]
});
}
// Un trimestre genuinamente ausente de los datos (2020-T2, el hueco de la
// ETOE -- ver periodos_validos() en el pipeline privado) no es lo mismo que
// un trimestre que Plot sepa omitir: lineY/areaY solo conectan las filas
// que existen, así que los trimestres vecinos quedan unidos por una
// diagonal recta que se lee como una transición real, no como un dato
// faltante. Insertar un punto explícito con valor indefinido en cualquier
// hueco así, para que las marcas de Plot se corten ahí en vez de conectar
// -- Plot trata un canal de posición indefinido/NaN como "inválido" e
// interrumpe la línea/área en ese punto, el mismo mecanismo que
// line().defined() en d3. Se hace spread de ...actual primero para que
// categoria_origen (necesario para agrupar por categoría en la gráfica
// multilínea) se propague; solo fecha/valor/ee se sobrescriben.
conHuecos = serie => {
const resultado = [];
for (let i = 0; i < serie.length; i++) {
resultado.push(serie[i]);
if (i < serie.length - 1) {
const actual = serie[i], siguiente = serie[i + 1];
const meses = (siguiente.fecha.getFullYear() - actual.fecha.getFullYear()) * 12
+ (siguiente.fecha.getMonth() - actual.fecha.getMonth());
if (meses > 3) {
resultado.push({
...actual,
fecha: new Date(actual.fecha.getFullYear(), actual.fecha.getMonth() + 3, 1),
valor: undefined, ee: undefined
});
}
}
}
return resultado;
}
graficaSerieMultiple = (corte, categorias, { width = 680, height = 320 } = {}) => {
const serie = categorias.flatMap(cat => serieCategoria(corte, cat));
const serieConHuecos = categorias.flatMap(cat => conHuecos(serieCategoria(corte, cat)));
const ultimoPorCategoria = categorias.map(cat => {
const s = serieCategoria(corte, cat);
return s[s.length - 1];
}).filter(Boolean);
return Plot.plot({
width, height, marginLeft: 46, marginBottom: 32, marginRight: 100,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "13px" },
y: { label: "TPEA (%)", grid: true, nice: true },
x: { label: null },
color: { legend: true, domain: categorias, scheme: "tableau10" },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.lineY(serieConHuecos, {
x: "fecha", y: "valor", z: "categoria_origen",
stroke: "categoria_origen", strokeWidth: 2,
strokeLinejoin: "round", strokeLinecap: "round"
}),
Plot.dot(ultimoPorCategoria, {
x: "fecha", y: "valor", r: 3.5, fill: "categoria_origen",
stroke: "var(--plot-background)", strokeWidth: 1.5
}),
Plot.tip(serie, Plot.pointerX({
x: "fecha", y: "valor",
title: d => `${d.categoria_origen}\n${d.anio}-T${d.trimestre} (${d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("es-MX")}`
})),
Plot.axisX({ stroke: "var(--rule)", tickSize: 0 }),
Plot.axisY({ stroke: "var(--rule)", tickSize: 0 })
]
});
}
// nice:true escala automáticamente al alcance de cada marca, incluida la
// banda de confianza -- y 2020-T3 (primer trimestre de vuelta tras el hueco
// de la ETOE) tiene una banda mucho más ancha de lo normal en algunos
// estados por una muestra reducida pospandemia, lo que por sí solo puede
// estirar todo el eje Y y aplanar el resto de una serie de 20 años. Ajustar
// el dominio solo a las estimaciones puntuales, para que la incertidumbre
// de un trimestre ruidoso no distorsione la escala de todos los demás.
dominioAjustadoSerie = (serie, margenMinimo = 0.4) => {
const valores = serie.map(d => d.valor);
const min = Math.min(...valores), max = Math.max(...valores);
const margen = Math.max((max - min) * 0.15, margenMinimo);
return [min - margen, max + margen];
}
graficaSerieCorte = (serie, { width = 680, height = 260, color = "var(--teal)" } = {}) => {
const ultimo = serie[serie.length - 1];
const serieConHuecos = conHuecos(serie);
return Plot.plot({
width, height, marginLeft: 46, marginBottom: 32,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "13px" },
y: { label: "%", grid: true, domain: dominioAjustadoSerie(serie) },
x: { label: null },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.areaY(serieConHuecos, {
x: "fecha", y1: d => d.valor - 1.96 * d.ee, y2: d => d.valor + 1.96 * d.ee,
fill: color, fillOpacity: 0.1
}),
Plot.lineY(serieConHuecos, {
x: "fecha", y: "valor", stroke: color, strokeWidth: 2,
strokeLinejoin: "round", strokeLinecap: "round"
}),
Plot.dot(serie, {
x: "fecha", y: "valor", r: 4, fill: color,
stroke: "var(--plot-background)", strokeWidth: 2,
filter: d => d === ultimo
}),
Plot.tip(serie, Plot.pointerX({
x: "fecha", y: "valor",
title: d => `${d.anio}-T${d.trimestre} (${d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("es-MX")}`
})),
Plot.axisX({ stroke: "var(--rule)", tickSize: 0 }),
Plot.axisY({ stroke: "var(--rule)", tickSize: 0 })
]
});
}