raw = FileAttachment("data/labor-indicators.csv").csv({ typed: true })
datos = raw.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
definiciones = ({
TPEA: {
nombre: "Labor force participation",
texto: "Share of the population 15 and older that is working or actively looking for work."
},
TD: {
nombre: "Unemployment",
texto: "Share of the labor force that is not working but is actively looking for work."
},
TIL1: {
nombre: "Labor informality",
texto: "Share of employed people working without the legal and social protections of a formal job (social security, benefits), regardless of whether the business itself is registered."
},
TIL2: {
nombre: "Labor informality, excl. agriculture",
texto: "Same as labor informality, excluding agriculture — a sector where informality is structurally high and can obscure the trend in the rest of the economy."
},
TOSI1: {
nombre: "Employment in the informal sector",
texto: "Share of employed people working for a business that operates without registering with the authorities. Measures the type of business, not whether the individual has social security."
},
TOSI2: {
nombre: "Informal-sector employment, excl. agriculture",
texto: "Same as employment in the informal sector, excluding agriculture."
},
TSUB: {
nombre: "Underemployment",
texto: "Share of employed people who want, and are able, to work more hours than their current job gives them."
},
TCCO: {
nombre: "Critical employment conditions",
texto: "Share of employed people in precarious conditions: too few hours involuntarily, or long hours at below the legal minimum wage. Sensitive to minimum-wage changes — treat level shifts with caution, trend direction is more reliable."
}
})
indicadoresPrincipales = ["TPEA", "TIL1", "TD"]
indicadoresSecundarios = Object.keys(definiciones)
.filter(k => !indicadoresPrincipales.includes(k))
.sort()
serieDe = codigo => datos
.filter(d => d.indicador === codigo)
.sort((a, b) => a.fecha - b.fecha)
valorEnPeriodo = (serie, anio, trimestre) => {
const fila = serie.find(d => d.anio === anio && d.trimestre === trimestre);
return fila ? fila.valor : null;
}
calcularDeltas = serie => {
const ultimo = serie[serie.length - 1];
const trimAnt = ultimo.trimestre === 1
? { anio: ultimo.anio - 1, trimestre: 4 }
: { anio: ultimo.anio, trimestre: ultimo.trimestre - 1 };
const valorTrimAnt = valorEnPeriodo(serie, trimAnt.anio, trimAnt.trimestre);
const valorAnioAnt = valorEnPeriodo(serie, ultimo.anio - 1, ultimo.trimestre);
return {
deltaTrim: valorTrimAnt != null ? ultimo.valor - valorTrimAnt : null,
deltaAnual: valorAnioAnt != null ? ultimo.valor - valorAnioAnt : null
};
}
// Neutral on purpose: arrows and numbers, no red/green "good or bad" coding.
formatoDelta = delta => {
if (delta == null) return "n/a";
if (Math.abs(delta) < 0.005) return "no change";
const signo = delta > 0 ? "▲" : "▼";
return `${signo} ${Math.abs(delta).toFixed(2)} pp`;
}
// nice:true rounds the domain to "nice" numbers, which can pad it well past
// the series' actual range and flatten a series with little variation (e.g.
// TOSI1/TOSI2, which move within ~3 points). Fit the domain to each series'
// own min/max plus a fixed margin instead, so every mini chart uses its own
// scale without exaggerating or flattening.
dominioAjustado = (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];
}
graficaSerie = (serie, { width = 780, height = 340, marginLeft = 42 } = {}) => {
const ultimo = serie[serie.length - 1];
return Plot.plot({
width, height, marginLeft,
marginBottom: 30,
style: { fontFamily: "IBM Plex Sans, sans-serif",
fontSize: width < 400 ? "11px" : "13px",
color: "currentColor", background: "transparent" },
y: { label: "%", grid: true, domain: dominioAjustado(serie), ticks: 4 },
x: { label: null },
marks: [
Plot.areaY(serie, {
x: "fecha",
y1: d => d.valor - 1.96 * d.ee,
y2: d => d.valor + 1.96 * d.ee,
fill: "var(--teal)", fillOpacity: 0.12
}),
Plot.lineY(serie, {
x: "fecha", y: "valor",
stroke: "var(--teal)", strokeWidth: 2,
strokeLinejoin: "round", strokeLinecap: "round"
}),
Plot.dot(serie, {
x: "fecha", y: "valor", r: 3.5,
fill: "var(--teal)", filter: d => d === ultimo
}),
Plot.ruleY([0], { stroke: "currentColor", strokeOpacity: 0.15 }),
Plot.tip(serie, Plot.pointerX({
x: "fecha", y: "valor",
title: d => `${d.anio}-Q${d.trimestre} (${d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("en-US")}`
}))
]
});
}Labor Market MX
Interactive figures. Everything runs in your browser; series are prepared in R and published as CSV in data/ in the site’s repository.
ENOE, INEGI · Quarterly, 2005–2026 · National
Official statistics from Mexico’s National Survey of Occupation and Employment (ENOE), public data from INEGI. Recomputed here from microdata using the institute’s own methodology — survey design, strata, and expansion factors, not a simple average — and checked against INEGI’s published bulletin for 2024-T1 (largest gap: 0.05 percentage points). Not an original finding: these are public figures, presented here for quick reference.
Other indicators
Six more series, including the informality and informal-sector rates excluding agriculture.
How this is built
1. A validated pipeline, not a one-off script. The full computation — downloading each quarter directly from INEGI, harmonizing field names and weights across the ENOE/ENOE-N/ENOE regime changes since 2005, and applying the survey’s complex design (survey::svydesign, ids = upm, strata = est_d_tri, weights = fac_tri) — lives in a separate private R project, not in this site’s repository. Every rate above is checked against INEGI’s own published figures for 2024-T1; the largest gap is 0.05 percentage points.
2. This site only reads a public CSV. scripts/02-labor-indicators.R exports the national-level rates from that pipeline into data/labor-indicators.csv — nothing else. Re-run it with Rscript scripts/02-labor-indicators.R after the source pipeline updates, which happens once per quarter as INEGI publishes new microdata.
3. The chart reads the CSV client-side. Plain FileAttachment(...).csv() and Observable Plot — no database engine, so nothing depends on a CDN beyond what Quarto already loads for Observable itself.