html`<div class="indicator-card" style="max-width:280px">
<div class="indicator-name">Participación laboral, nacional
<span class="indicator-value">${ultimoNacional.valor}%</span>
</div>
<div class="indicator-meta">± ${(1.96 * ultimoNacional.ee).toFixed(2)} pp · ${ultimoNacional.anio}-T${ultimoNacional.trimestre}</div>
</div>`Mercado Laboral MX — Participación
Fecha de última modificación
30 de agosto de 2026
ENOE, INEGI · Trimestral, 2005–2026 · Por sexo, edad, escolaridad y entidad
Participación laboral desglosada por los cortes que INEGI no publica en su propio sitio — sexo, grupo de edad, nivel educativo y entidad. Ningún corte usa una definición propia: todos son la misma fórmula de TPEA 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
.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: ["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"]
})
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}%`
})
]
});
}
graficaSerieMultiple = (corte, categorias, { width = 680, height = 320 } = {}) => {
const serie = categorias.flatMap(cat => 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(serie, {
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 })
]
});
}
graficaSerieCorte = (serie, { width = 680, height = 260, color = "var(--teal)" } = {}) => {
const ultimo = serie[serie.length - 1];
return Plot.plot({
width, height, marginLeft: 46, marginBottom: 32,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "13px" },
y: { label: "%", grid: true, nice: true },
x: { label: null },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.areaY(serie, {
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(serie, {
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 corte
Serie de tiempo
Historial completo del corte elegido arriba: todas sus 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 — ver su sección “Cómo está hecho” para la descripción completa. La única diferencia aquí: los datos fuente incluyen los cortes demográficos y por entidad, que las cifras nacionales de la página de panorama no necesitan. 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).