raw = FileAttachment("data/labor-indicators.csv").csv({ typed: true })
rawCortes = FileAttachment("data/labor-indicators-cuts.csv").csv({ typed: true })
validaciones = FileAttachment("data/validations.csv").csv({ typed: true })
datos = raw.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
datosCortes = rawCortes.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
nombres = ({
TPEA: "Labor force participation",
TD: "Unemployment",
TIL1: "Labor informality",
TIL2: "Labor informality (excl. agriculture)",
TOSI1: "Employment in the informal sector",
TOSI2: "Informal-sector employment (excl. agriculture)",
TSUB: "Underemployment",
TCCO: "Critical employment conditions",
TDAMPL: "Broader unemployment",
SUBUTIL: "Labor underutilization"
})
notas = ({
TCCO: "Likely a rebase of INEGI’s minimum-wage threshold (the bulletin cites a “January 2026 base”); verified correct by independently reconstructing TCCO from raw microdata."
})
// Only these have sex/age/education/state breakdowns computed so far (see
// Participation/Informality/Unemployment) -- TSUB and TCCO stay national-only.
indicadoresConCortes = ["TPEA", "TIL1", "TIL2", "TOSI1", "TOSI2", "TD", "TDAMPL", "SUBUTIL"]
cortes = ({
sexo: "Sex", edad: "Age", nivel_educativo: "Education", entidad: "State"
})
// categoria_origen labels in the CSV are Spanish -- map to English for
// display, same lookup used on the three theme pages.
etiquetaEn = ({
"Hombre": "Men", "Mujer": "Women",
"14 a 19": "14 to 19", "20 a 29": "20 to 29", "30 a 39": "30 to 39",
"40 a 49": "40 to 49", "50 a 59": "50 to 59", "60 y más": "60 and over",
"Primaria incompleta": "Incomplete primary", "Primaria completa": "Primary",
"Secundaria completa": "Secondary", "Medio superior y superior": "High school and beyond",
"Ciudad de México": "Mexico City", "Coahuila de Zaragoza": "Coahuila",
"México": "Mexico State", "Michoacán de Ocampo": "Michoacán",
"Veracruz de Ignacio de la Llave": "Veracruz"
})
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,
baseTrim: valorTrimAnt, baseAnual: valorAnioAnt
};
}
// Same as valorEnPeriodo/calcularDeltas above, but also carries the
// standard error of each period's estimate through, so the by-group
// section below can tell a real move from sampling noise.
valorEnPeriodoConSE = (serie, anio, trimestre) => {
const fila = serie.find(d => d.anio === anio && d.trimestre === trimestre);
return fila ? { valor: fila.valor, ee: fila.ee } : null;
}
// SE of a difference between two survey estimates: combine in quadrature,
// assuming independence. ENOE's rotating panel means neighboring quarters
// actually share some of the same respondents, so this slightly overstates
// the true SE of the quarter-over-quarter difference (positive correlation
// between overlapping observations would shrink it) -- the filter below
// errs toward under-claiming significance, not over-claiming it.
calcularDeltasConSE = 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 trimAntDato = valorEnPeriodoConSE(serie, trimAnt.anio, trimAnt.trimestre);
const anioAntDato = valorEnPeriodoConSE(serie, ultimo.anio - 1, ultimo.trimestre);
return {
deltaTrim: trimAntDato ? ultimo.valor - trimAntDato.valor : null,
deltaAnual: anioAntDato ? ultimo.valor - anioAntDato.valor : null,
seTrim: trimAntDato ? Math.sqrt(ultimo.ee ** 2 + trimAntDato.ee ** 2) : null,
seAnual: anioAntDato ? Math.sqrt(ultimo.ee ** 2 + anioAntDato.ee ** 2) : null,
baseTrim: trimAntDato ? trimAntDato.valor : null,
baseAnual: anioAntDato ? anioAntDato.valor : null
};
}
// A change counts as distinguishable from noise only if it clears 1.96
// combined standard errors -- the same 95% threshold as the confidence
// bands on every chart elsewhere on this site. Without this filter,
// "biggest mover" would almost always just mean "smallest sample": small
// states/categories have the largest standard errors, so their point
// estimates swing the most from quarter to quarter for no real reason.
esSignificativo = (delta, se) => delta != null && se != null && Math.abs(delta) > 1.96 * se
// Unlike the rest of this series (which leaves color neutral on purpose,
// without implying good/bad), this page does color the direction of
// change -- because it's a scan-many-rows-at-once summary, not a
// case-by-case reading of one indicator. Only the arrow carries color,
// and it's kept pale: coloring the whole value read as too loud and too
// normative (good/bad), which this page shouldn't imply.
// base is the prior period's own value (the denominator this change is
// measured against) -- pass it to also show the relative percent change
// alongside the percentage-point change, e.g. "▲ 0.41 pp (0.7%)".
// The pp figure stays unsigned (direction carried by the arrow alone, the
// site's usual convention), but the percent figure keeps its sign -- it's
// also the sort key for these columns, and an unsigned percent next to a
// down arrow read as if sorting ignored direction, which it doesn't.
formatoDelta = (delta, base) => {
if (delta == null) return html`<span>n/a</span>`;
if (Math.abs(delta) < 0.005) return html`<span class="delta-flat">no change</span>`;
const subida = delta > 0;
const clase = subida ? "delta-arrow-up" : "delta-arrow-down";
const signo = subida ? "▲" : "▼";
const relativo = (base != null && base !== 0)
? html` <span class="data-table-period">(${(delta / base * 100).toFixed(1)}%)</span>`
: "";
return html`<span><span class="${clase}">${signo}</span> ${Math.abs(delta).toFixed(2)} pp${relativo}</span>`;
}
cambiosTrimestre = Object.keys(nombres).map(codigo => {
const serie = serieDe(codigo);
if (serie.length === 0) return null;
const ultimo = serie[serie.length - 1];
const { deltaTrim, deltaAnual, baseTrim, baseAnual } = calcularDeltas(serie);
return {
codigo, nombre: nombres[codigo], valor: ultimo.valor,
periodo: `${ultimo.anio}-Q${ultimo.trimestre}`,
deltaTrim, deltaAnual, baseTrim, baseAnual
};
}).filter(d => d !== null)
categoriasDeCorte = corte => [...new Set(datosCortes.filter(d => d.corte === corte).map(d => d.categoria_origen))]
// One row per category within a cut, latest value plus QoQ/YoY change --
// same calculation as cambiosTrimestre above, just applied per category
// instead of at the national level. Unsorted here -- ordenarFilas below
// applies whichever order the reader picks at render time.
cambiosPorCorte = (indicador, corte) => categoriasDeCorte(corte).map(cat => {
const serie = datosCortes
.filter(d => d.indicador === indicador && d.corte === corte && d.categoria_origen === cat)
.sort((a, b) => a.fecha - b.fecha);
if (serie.length === 0) return null;
const ultimo = serie[serie.length - 1];
const { deltaTrim, deltaAnual, baseTrim, baseAnual } = calcularDeltasConSE(serie);
return {
categoria: etiquetaEn[cat] ?? cat, valor: ultimo.valor,
periodo: `${ultimo.anio}-Q${ultimo.trimestre}`,
deltaTrim, deltaAnual, baseTrim, baseAnual
};
}).filter(d => d !== null)
// Sort state for both tables' clickable column headers -- each table gets
// its own independent column/direction pair, so sorting one doesn't
// resort the other. Clicking a header sorts by that column, a fresh
// column starts descending, and a second click on the active column
// toggles descending/ascending. The change columns sort by the relative
// PERCENT change (delta divided by the prior period's own value), not the
// raw percentage-point delta -- pp isn't comparable across rows with very
// different bases (a 0.1 pp move means something different for a 60% rate
// than a 2% one), so sorting on it would rank rows in an order that
// doesn't match what the parenthetical percent figure actually says.
mutable columnaOrdenTrim = "deltaTrim"
mutable direccionOrdenTrim = "desc"
mutable columnaOrdenCorte = "deltaTrim"
mutable direccionOrdenCorte = "desc"
alternarOrdenTrim = columna => {
if (columnaOrdenTrim === columna) {
mutable direccionOrdenTrim = direccionOrdenTrim === "desc" ? "asc" : "desc";
} else {
mutable columnaOrdenTrim = columna;
mutable direccionOrdenTrim = "desc";
}
}
alternarOrdenCorte = columna => {
if (columnaOrdenCorte === columna) {
mutable direccionOrdenCorte = direccionOrdenCorte === "desc" ? "asc" : "desc";
} else {
mutable columnaOrdenCorte = columna;
mutable direccionOrdenCorte = "desc";
}
}
// Every header shows a faint double arrow when sortable but inactive, and
// a bold single arrow matching the current direction when it's the active
// sort column -- so the affordance is visible before a reader clicks
// anything, not just after.
flechaOrden = (columnaActiva, columna, direccion) => columnaActiva === columna
? html` <span class="data-table-sort-arrow">${direccion === "desc" ? "▼" : "▲"}</span>`
: html` <span class="data-table-sort-arrow data-table-sort-arrow-inactive">⇅</span>`
// Setting onclick as an attribute inside the html`` template (rather than
// as a DOM property on the returned node) doesn't wire up as an event
// listener in Quarto's ojs runtime -- it just stringifies the function into
// the cell's text. Building the <th> node and assigning .onclick directly
// is what actually works. subtitulo (e.g. "(t vs t-1)") renders on its own
// line below the label when given.
celdaEncabezado = (etiqueta, subtitulo, columna, columnaActiva, direccion, alClick) => {
const flecha = flechaOrden(columnaActiva, columna, direccion);
const celda = subtitulo
? html`<th class="data-table-sortable">${etiqueta}${flecha}<br><span class="data-table-header-sub">${subtitulo}</span></th>`
: html`<th class="data-table-sortable">${etiqueta}${flecha}</th>`;
celda.onclick = () => alClick(columna);
return celda;
}
// The relative percent change this sorts by: null when there's no prior
// value to compare against (new category, or a value of exactly zero,
// where "percent change" is undefined) -- pushed to the bottom regardless
// of sort direction via the sentinel below, same as a missing pp delta
// would be.
cambioPct = (delta, base) => (delta != null && base != null && base !== 0) ? delta / base * 100 : null
ordenarFilas = (filas, columnaActiva, direccion) => {
const copia = [...filas];
const signo = direccion === "desc" ? -1 : 1;
const centinela = direccion === "desc" ? -Infinity : Infinity;
if (columnaActiva === "valor") return copia.sort((a, b) => signo * (a.valor - b.valor));
if (columnaActiva === "deltaAnual") return copia.sort((a, b) => signo * ((cambioPct(a.deltaAnual, a.baseAnual) ?? centinela) - (cambioPct(b.deltaAnual, b.baseAnual) ?? centinela)));
return copia.sort((a, b) => signo * ((cambioPct(a.deltaTrim, a.baseTrim) ?? centinela) - (cambioPct(b.deltaTrim, b.baseTrim) ?? centinela)));
}
// Every (indicator, cut, category) combination's change, filtered to only
// the ones that clear the significance bar -- the pool "biggest mover"
// picks from below. Significance is still evaluated on the raw pp delta
// against its standard error (that's the level estimate the SE actually
// describes), but ranked by relative PERCENT change, same as the table
// below it -- ranking by raw pp here would pick, say, a 6.9 pp move on a
// 27% base over a 4.4 pp move on a 14% base, even though the second is
// the larger swing in relative terms, and disagree with how the table
// right below this headline sorts the exact same category-level data.
todosLosCambios = (campoDelta, campoSE, campoBase) => {
const filas = [];
for (const indicador of indicadoresConCortes) {
for (const corte of Object.keys(cortes)) {
for (const cat of categoriasDeCorte(corte)) {
const serie = datosCortes
.filter(d => d.indicador === indicador && d.corte === corte && d.categoria_origen === cat)
.sort((a, b) => a.fecha - b.fecha);
if (serie.length === 0) continue;
const ultimo = serie[serie.length - 1];
const deltas = calcularDeltasConSE(serie);
const delta = deltas[campoDelta], se = deltas[campoSE], base = deltas[campoBase];
if (esSignificativo(delta, se)) {
filas.push({
indicador, corte, categoria: etiquetaEn[cat] ?? cat,
valor: ultimo.valor, delta, base, periodo: `${ultimo.anio}-Q${ultimo.trimestre}`
});
}
}
}
}
return filas.sort((a, b) => Math.abs(cambioPct(b.delta, b.base) ?? 0) - Math.abs(cambioPct(a.delta, a.base) ?? 0));
}
// Split into a biggest increase and a biggest decrease per period, rather
// than one overall winner -- todosLosCambios is already sorted by
// |percent change| descending, so the first row matching each sign is
// that direction's biggest mover.
cambiosTrimOrdenados = todosLosCambios("deltaTrim", "seTrim", "baseTrim")
cambiosAnualOrdenados = todosLosCambios("deltaAnual", "seAnual", "baseAnual")
mayorMovimientoTrimSubida = cambiosTrimOrdenados.find(f => f.delta > 0) ?? null
mayorMovimientoTrimBajada = cambiosTrimOrdenados.find(f => f.delta < 0) ?? null
mayorMovimientoAnualSubida = cambiosAnualOrdenados.find(f => f.delta > 0) ?? null
mayorMovimientoAnualBajada = cambiosAnualOrdenados.find(f => f.delta < 0) ?? null
filaMovimiento = (etiqueta, fila) => fila
? html`<tr>
<td>${etiqueta}</td>
<td>${nombres[fila.indicador]}<span class="data-table-period"> — ${fila.categoria} (${cortes[fila.corte]})</span></td>
<td class="data-table-num">${formatoDelta(fila.delta, fila.base)}</td>
<td class="data-table-num">${fila.valor}%<span class="data-table-period">(${fila.periodo})</span></td>
</tr>`
: html`<tr>
<td>${etiqueta}</td>
<td colspan="3" class="data-table-note">No move clears the 95% confidence threshold this period.</td>
</tr>`
ultimoPeriodoValidacion = validaciones.length
? `${validaciones[validaciones.length - 1].anio}-Q${validaciones[validaciones.length - 1].trimestre}`
: ""
ultimasValidaciones = {
const ultimoAnio = validaciones[validaciones.length - 1]?.anio;
const ultimoTrim = validaciones[validaciones.length - 1]?.trimestre;
return validaciones.filter(v => v.anio === ultimoAnio && v.trimestre === ultimoTrim);
}