raw = FileAttachment("../data/labor-indicators.csv").csv({ typed: true })
ajustadaRaw = FileAttachment("../data/labor-seasonal.csv").csv({ typed: true })
datos = raw.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
// Trimestre mas reciente en el CSV, cualquiera que sea el indicador -- todos
// comparten el mismo ultimo periodo porque vienen del mismo export.
ultimoPeriodoGlobal = {
const ordenado = [...datos].sort((a, b) => a.fecha - b.fecha);
const u = ordenado[ordenado.length - 1];
return `${u.anio}-T${u.trimestre}`;
}
etiquetaMarco = ({
tradicional: "ENOE",
enoen: "ENOEN",
enoe_2023: "ENOE 2023+"
})
definiciones = ({
TPEA: {
nombre: "Participación laboral",
texto: "De cada 100 personas de 15 años o más, cuántas trabajan o están buscando activamente un trabajo."
},
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."
},
TIL1: {
nombre: "Informalidad laboral",
texto: "De cada 100 personas ocupadas, cuántas trabajan sin la protección legal y social de un empleo formal (seguridad social, prestaciones), sin importar si el negocio para el que trabajan está registrado o no."
},
TIL2: {
nombre: "Informalidad laboral, sin agropecuario",
texto: "Lo mismo que la informalidad laboral general, excluyendo el campo — un sector con informalidad estructuralmente alta que puede oscurecer la tendencia del resto de la economía."
},
TOSI1: {
nombre: "Ocupación en el sector informal",
texto: "De cada 100 personas ocupadas, cuántas trabajan en un negocio que opera sin registrarse ante las autoridades. Mide el tipo de negocio, no si la persona tiene seguridad social."
},
TOSI2: {
nombre: "Sector informal, sin agropecuario",
texto: "El mismo conteo de ocupación en el sector informal, pero medido contra la población ocupada no agropecuaria. El sector informal ya está definido sobre unidades no agropecuarias, así que solo cambia la base."
},
TSUB: {
nombre: "Subocupación",
texto: "De cada 100 personas ocupadas, cuántas quieren y pueden trabajar más horas de las que su empleo actual les da."
},
TCCO: {
nombre: "Condiciones críticas de ocupación",
texto: "De cada 100 personas ocupadas, cuántas están en condiciones críticas de ocupación: menos de 35 horas a la semana por razones de mercado, más de 35 horas semanales con ingresos mensuales por debajo de un salario mínimo, o más de 48 horas semanales con un pago de hasta dos salarios mínimos. Los umbrales están fijados en salarios mínimos y esta serie usa los nominales, así que la tasa sube por sí sola cuando sube el salario mínimo. INEGI publica una serie aparte en salarios mínimos equivalentes para comparar entre años."
},
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, no buscan activamente y aceptarían un empleo si se lo ofrecieran. La ENOE los clasifica como población disponible. Los desalentados son solo la parte de ese grupo que dejó de buscar por razones del mercado laboral. 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."
}
})
indicadoresPrincipales = ["TPEA", "TIL1", "TD"]
indicadoresEscalera = ["TDAMPL", "SUBUTIL"]
indicadoresSecundarios = Object.keys(definiciones)
.filter(k => !indicadoresPrincipales.includes(k) && !indicadoresEscalera.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
};
}
// Deliberadamente neutro: flechas y números, sin verde/rojo de "bueno/malo".
formatoDelta = delta => {
if (delta == null) return "n/d";
if (Math.abs(delta) < 0.005) return "sin cambio";
const signo = delta > 0 ? "▲" : "▼";
return `${signo} ${Math.abs(delta).toFixed(2)} pp`;
}
// nice:true redondea el dominio a numeros "bonitos", lo que a veces alarga
// el eje mucho mas alla del rango real de la serie y aplana series con poco
// margen de variacion (ej. TOSI1/TOSI2, que se mueven en ~3 puntos). En vez
// de eso: dominio ajustado al min/max real de cada serie con un margen fijo,
// para que cada mini-grafica use su propia escala sin exagerar ni aplanar.
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];
}
// 2020-T2 no existe en la fuente. La ENOE se suspendio y fue sustituida por
// la ETOE telefonica, que INEGI no publica como estimacion comparable. Unir
// los dos lados con una recta lee un cambio de metodologia como una
// transicion gradual, asi que se inserta un punto con valor indefinido en el
// hueco y Plot corta la linea ahi.
conHuecos = serie => {
const salida = [];
for (let i = 0; i < serie.length; i++) {
salida.push(serie[i]);
if (i < serie.length - 1) {
const a = serie[i], b = serie[i + 1];
const meses = (b.fecha.getFullYear() - a.fecha.getFullYear()) * 12 +
(b.fecha.getMonth() - a.fecha.getMonth());
if (meses > 3) {
salida.push({ ...a,
fecha: new Date(a.fecha.getFullYear(), a.fecha.getMonth() + 3, 1),
valor: undefined, ee: undefined });
}
}
}
return salida;
}
// Serie de tendencia-ciclo, publicada por el propio INEGI en su Banco de
// Informacion Economica. Las trimestrales vienen en porcentaje, a diferencia
// de las mensuales, que son numeros indice, asi que caben directo en el mismo
// eje que la serie original sin reescalar. Seis indicadores la tienen; el
// resto devuelve un arreglo vacio y simplemente va sin linea de fondo.
serieAjustada = (codigo, tipo = "tendencia_ciclo") => ajustadaRaw
.filter(d => d.indicador === codigo && d.tipo === tipo)
.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
.sort((a, b) => a.fecha - b.fecha)
graficaSerie = (serie, { width = 780, height = 340, marginLeft = 42, color = "var(--teal)", ajustada = null } = {}) => {
const ultimo = serie[serie.length - 1];
const serieRota = conHuecos(serie);
const ajustadaRota = ajustada && ajustada.length ? conHuecos(ajustada) : null;
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(serieRota, {
x: "fecha",
y1: d => d.valor == null ? undefined : d.valor - 1.96 * d.ee,
y2: d => d.valor == null ? undefined : d.valor + 1.96 * d.ee,
fill: color, fillOpacity: 0.12
}),
// La linea de tendencia-ciclo va mas delgada y atenuada a proposito. Es
// contexto de lectura de la serie original, no un segundo indicador, y
// la cifra de la tarjeta sale siempre de la original.
...(ajustadaRota ? [Plot.lineY(ajustadaRota, {
x: "fecha", y: "valor",
stroke: color, strokeWidth: 1.4, strokeOpacity: 0.5,
strokeLinejoin: "round", strokeLinecap: "round"
})] : []),
Plot.lineY(serieRota, {
x: "fecha", y: "valor",
stroke: color, strokeWidth: 2,
strokeLinejoin: "round", strokeLinecap: "round"
}),
Plot.dot(serie, {
x: "fecha", y: "valor", r: 3.5,
fill: color, 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}-T${d.trimestre} (${etiquetaMarco[d.regimen] ?? d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("es-MX")}`
}))
]
});
}
coloresPrincipales = ({
TPEA: "var(--teal)",
TIL1: "var(--clay)",
TD: "var(--ink-soft)"
})
// Un par claro/oscuro del mismo neutro (no --teal ni --clay, que ya
// pertenecen a TPEA/TIL1) para que las dos tarjetas se lean como el mismo
// tema -- desocupación ampliada -- sin duplicar el color de TD ni entre sí.
coloresEscalera = ({
TDAMPL: "var(--ink-soft-light)",
SUBUTIL: "var(--ink-soft-deep)"
})