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}`;
}
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: "Lo mismo que la ocupación en el sector informal, excluyendo el campo."
},
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 precarias: muy pocas horas de forma involuntaria, o muchas horas ganando menos del salario mínimo legal. Sensible a cambios en el salario mínimo — la tendencia es más confiable que el nivel."
},
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 sin buscar activamente (desalentados). 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} (${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)"
})