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: "Participación laboral",
TD: "Desocupación",
TIL1: "Informalidad laboral",
TIL2: "Informalidad laboral (sin agropecuario)",
TOSI1: "Ocupación en el sector informal",
TOSI2: "Ocupación en el sector informal (sin agropecuario)",
TSUB: "Subocupación",
TCCO: "Condiciones críticas de ocupación",
TDAMPL: "Desocupación ampliada",
SUBUTIL: "Subutilización laboral"
})
notas = ({
TCCO: "Probablemente un rebase del umbral de salario mínimo de INEGI (el boletín cita una “base enero de 2026”); se verificó correcto reconstruyendo TCCO de forma independiente desde el microdato crudo."
})
// Solo estos ya tienen cortes por sexo/edad/escolaridad/entidad calculados
// (ver Participación/Informalidad/Desocupación) -- TSUB y TCCO se quedan
// solo a nivel nacional por ahora.
indicadoresConCortes = ["TPEA", "TIL1", "TIL2", "TOSI1", "TOSI2", "TD", "TDAMPL", "SUBUTIL"]
cortes = ({
sexo: "Sexo", edad: "Edad", nivel_educativo: "Nivel educativo", entidad: "Entidad"
})
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
};
}
// Igual que valorEnPeriodo/calcularDeltas arriba, pero además carga el
// error estándar de cada periodo, para que la sección por corte de abajo
// pueda distinguir un movimiento real de ruido muestral.
valorEnPeriodoConSE = (serie, anio, trimestre) => {
const fila = serie.find(d => d.anio === anio && d.trimestre === trimestre);
return fila ? { valor: fila.valor, ee: fila.ee } : null;
}
// Error estándar de una diferencia entre dos estimaciones muestrales: se
// combinan en cuadratura, asumiendo independencia. El panel rotativo de la
// ENOE hace que trimestres vecinos compartan parte de los mismos
// entrevistados, así que esto sobreestima un poco el verdadero error
// estándar de la diferencia trimestral (la correlación positiva entre
// observaciones traslapadas la reduciría) -- el filtro de abajo tiende a
// subestimar la significancia, no a sobreestimarla.
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
};
}
// Un cambio cuenta como distinguible del ruido solo si supera 1.96 errores
// estándar combinados -- el mismo umbral de 95% que las bandas de confianza
// en cualquier otra gráfica del sitio. Sin este filtro, "el mayor
// movimiento" casi siempre significaría solo "la muestra más chica": los
// estados/categorías con menos observaciones tienen el error estándar más
// grande, así que sus estimaciones puntuales se mueven más de trimestre a
// trimestre sin que sea un cambio real.
esSignificativo = (delta, se) => delta != null && se != null && Math.abs(delta) > 1.96 * se
// A diferencia del resto de esta serie (que deja el color neutro a
// propósito, sin implicar bueno/malo), esta página sí colorea la dirección
// del cambio -- porque es un resumen de lectura rápida pensado para
// escanear muchas filas de un vistazo. Solo la flecha lleva color, y
// pálido a propósito: colorear todo el valor se leía como demasiado
// llamativo y como un juicio normativo, que esta página no debería
// implicar.
// base es el valor del periodo anterior (el denominador contra el que se
// mide el cambio) -- se pasa para mostrar también el cambio porcentual
// relativo junto al cambio en puntos porcentuales, ej. "▲ 0.41 pp (0.7%)".
// El pp se queda sin signo (la dirección la lleva solo la flecha, la
// convención habitual del sitio), pero el porcentaje sí conserva su signo
// -- también es la clave de orden de estas columnas, y un porcentaje sin
// signo junto a una flecha hacia abajo se leía como si el orden ignorara
// la dirección, cuando no es así.
formatoDelta = (delta, base) => {
if (delta == null) return html`<span>n/d</span>`;
if (Math.abs(delta) < 0.005) return html`<span class="delta-flat">sin cambio</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}-T${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))]
// Una fila por categoría dentro de un corte, con el último dato y el
// cambio trimestral/anual -- el mismo cálculo que cambiosTrimestre arriba,
// solo que aplicado por categoría en vez de a nivel nacional. Sin ordenar
// aquí -- ordenarFilas de abajo aplica el orden que elija quien lee al
// momento de renderizar.
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: cat, valor: ultimo.valor,
periodo: `${ultimo.anio}-T${ultimo.trimestre}`,
deltaTrim, deltaAnual, baseTrim, baseAnual
};
}).filter(d => d !== null)
// Estado de orden para los encabezados de columna clicables de las dos
// tablas -- cada tabla tiene su propio par columna/dirección independiente,
// así que ordenar una no reordena la otra. Un clic en un encabezado ordena
// por esa columna, una columna nueva empieza en descendente, y un segundo
// clic sobre la misma alterna entre descendente y ascendente. Las columnas
// de cambio ordenan por el cambio PORCENTUAL relativo (el delta dividido
// entre el valor del periodo anterior), no por el delta crudo en puntos
// porcentuales -- pp no es comparable entre filas con bases muy distintas
// (un movimiento de 0.1 pp significa algo distinto sobre una tasa de 60%
// que sobre una de 2%), así que ordenar por pp daría un orden que no
// coincide con lo que dice el porcentaje entre paréntesis.
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";
}
}
// Cada encabezado muestra una doble flecha tenue cuando es ordenable pero
// no está activo, y una flecha sencilla marcada cuando es la columna de
// orden activa -- así la posibilidad de ordenar se ve antes de hacer clic,
// no solo después.
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>`
// Poner onclick como atributo dentro del template html`` (en vez de como
// propiedad del nodo DOM devuelto) no se conecta como event listener en el
// runtime ojs de Quarto -- solo convierte la función en texto dentro de la
// celda. Construir el nodo <th> y asignar .onclick directamente es lo que
// sí funciona. subtitulo (ej. "(t vs t-1)") se muestra en su propia línea
// debajo de la etiqueta cuando se da.
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;
}
// El cambio porcentual relativo por el que se ordena: null cuando no hay
// valor anterior con qué comparar (categoría nueva, o un valor de
// exactamente cero, donde "cambio porcentual" no está definido) -- se
// manda al final sin importar la dirección del orden, vía el centinela de
// abajo, igual que un delta en pp faltante.
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)));
}
// El cambio de cada combinación (indicador, corte, categoría), filtrado a
// las que superan el umbral de significancia -- el conjunto del que sale
// "el mayor movimiento" abajo. La significancia se sigue evaluando sobre
// el delta crudo en pp contra su error estándar (es la estimación de nivel
// que ese error realmente describe), pero se ordena por el cambio
// PORCENTUAL relativo, igual que la tabla de abajo -- ordenar aquí por pp
// crudo elegiría, por ejemplo, un movimiento de 6.9 pp sobre una base de
// 27% antes que uno de 4.4 pp sobre una base de 14%, aunque el segundo sea
// el movimiento relativo más grande, y no coincidiría con el orden de la
// tabla justo debajo de este destacado, que usa los mismos datos por
// categoría.
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: cat,
valor: ultimo.valor, delta, base, periodo: `${ultimo.anio}-T${ultimo.trimestre}`
});
}
}
}
}
return filas.sort((a, b) => Math.abs(cambioPct(b.delta, b.base) ?? 0) - Math.abs(cambioPct(a.delta, a.base) ?? 0));
}
// Separado en mayor aumento y mayor caída por periodo, en vez de un solo
// ganador general -- todosLosCambios ya viene ordenado por |cambio
// porcentual| descendente, así que la primera fila que coincide con cada
// signo es el mayor movimiento de esa dirección.
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">Ningún movimiento de este periodo supera el umbral de 95% de confianza.</td>
</tr>`
ultimoPeriodoValidacion = validaciones.length
? `${validaciones[validaciones.length - 1].anio}-T${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);
}