html`<div class="indicator-grid">${Object.keys(definiciones).map(codigo => {
const serie = nacionalDe(codigo);
const ultimo = serie[serie.length - 1];
const def = definiciones[codigo];
const div = document.createElement("div");
div.className = "indicator-card";
div.innerHTML = `<div class="indicator-name">${def.nombre}
<span class="indicator-value">${ultimo.valor}%</span>
</div>
<div class="indicator-meta">± ${(1.96 * ultimo.ee).toFixed(2)} pp · ${ultimo.anio}-T${ultimo.trimestre}</div>
<div class="indicator-desc">${def.texto}</div>`;
return div;
})}</div>`Mercado Laboral MX — Desocupación
Fecha de última modificación
24 de septiembre de 2026
ENOE, INEGI · Trimestral, 2005–2026 · Por sexo, edad, escolaridad y entidad
Tercera página de esta serie, mismo patrón que Participación e Informalidad: la escalera de subutilización laboral (TD, TDAMPL, SUBUTIL) desglosada por sexo, grupo de edad, nivel educativo y entidad. Ningún corte usa una definición propia: todos son la misma fórmula aplicada dentro de cada grupo, usando campos que la ENOE ya trae precodificados.
raw = FileAttachment("../data/labor-indicators-cuts.csv").csv({ typed: true })
mx = FileAttachment("../data/mx-estados.json").json()
datos = raw.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
etiquetaMarco = ({
tradicional: "ENOE",
enoen: "ENOEN",
enoe_2023: "ENOE 2023+"
})
definiciones = ({
TD: {
nombre: "Desocupación",
texto: "De cada 100 personas que trabajan o buscan trabajo, cuántas no tienen empleo pero lo están buscando activamente."
},
TDAMPL: {
nombre: "Desocupación ampliada",
texto: "La tasa de desocupación, pero ampliando tanto el numerador como el denominador para incluir a quienes están disponibles para trabajar, no buscan activamente y aceptarían un empleo si se lo ofrecieran. La ENOE los clasifica como población disponible. Los desalentados son solo la parte de ese grupo que dejó de buscar por razones del mercado laboral. No es una definición propia: solo une categorías que la ENOE ya distingue por separado."
},
SUBUTIL: {
nombre: "Subutilización laboral",
texto: "Lo mismo que la desocupación ampliada, sumando además a quienes ya tienen empleo pero trabajan menos horas de las que quisieran (subocupados). La medida más amplia aquí de cuánta fuerza laboral disponible no se está aprovechando."
}
})
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" }
})
// Mismo orden fijo que Participacion/Informalidad -- nunca ordenado por
// nivel del indicador, para no sugerir un ranking donde solo hay una
// categorizacion.
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"]
})
nacionalDe = codigo => datos
.filter(d => d.indicador === codigo && d.corte === "nacional")
.sort((a, b) => a.fecha - b.fecha)
filasCorte = (indicador, corte) => datos.filter(d => d.indicador === indicador && d.corte === corte)
ultimoPeriodoCorte = (indicador, corte) => {
const filas = filasCorte(indicador, corte);
const fechaMax = new Date(Math.max(...filas.map(d => +d.fecha)));
return filas.filter(d => +d.fecha === +fechaMax);
}
serieCategoria = (indicador, corte, categoria) => filasCorte(indicador, corte)
.filter(d => d.categoria_origen === categoria)
.sort((a, b) => a.fecha - b.fecha);
graficaBarras = (filas, dominioX, etiquetaY, { 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: etiquetaY, grid: true },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.barY(filas, {
x: "categoria_origen", y: "valor",
fill: "var(--ink-soft)", 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, etiquetaY, { 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: "Greys", label: etiquetaY, legend: true
},
marks: [
Plot.geo(features, {
fill: d => d.valor,
stroke: "var(--ink)", strokeWidth: 1,
tip: true,
title: d => `${d.nombre}\n${etiquetaY}: ${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 = (indicador, corte, categorias, etiquetaY, { width = 680, height = 320 } = {}) => {
const serie = categorias.flatMap(cat => serieCategoria(indicador, corte, cat));
const serieConHuecos = categorias.flatMap(cat => conHuecos(serieCategoria(indicador, corte, cat)));
const ultimoPorCategoria = categorias.map(cat => {
const s = serieCategoria(indicador, 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: etiquetaY, 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} (${etiquetaMarco[d.regimen] ?? 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, etiquetaY, { width = 680, height = 260, color = "var(--ink-soft)" } = {}) => {
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: etiquetaY, 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} (${etiquetaMarco[d.regimen] ?? 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 })
]
});
}En conjunto
Por definición y corte
Serie de tiempo
Historial completo de la definición y el corte elegidos arriba: todas las categorías juntas en una sola gráfica. Entidad es la excepción — 32 líneas encimadas no se leen — así que ahí se elige una a la vez.
Todas las series de arriba son originales, sin ajuste estacional.
Cómo está hecho
Mismo pipeline y mismo repositorio del sitio que la página de panorama. TD, TDAMPL y SUBUTIL son la misma escalera de subutilización que se muestra ahí, desglosada además. La banda sombreada y las líneas de error son un intervalo de 95% de confianza (±1.96 errores estándar) del diseño muestral complejo de la ENOE. El corte por entidad usa las 32 estimaciones estatales que la ENOE trimestral sí soporta (a diferencia de la mensual). 2020-T2 (la ETOE, encuesta telefónica sustituta durante el confinamiento más estricto) se excluye por completo de la serie en vez de mostrarse como cero o interpolarse — su metodología no es comparable con la ENOE regular, así que es un hueco real, no un dato faltante.