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
30 de agosto 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 los cortes que INEGI no publica en su propio sitio. 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) }))
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 sin buscar activamente (desalentados). 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: ["14 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} (${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} (${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.
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 por los cortes que INEGI no publica. 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.