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) }))
// Latest quarter present in the CSV, regardless of indicator -- they all
// share the same latest period since they come from the same export.
ultimoPeriodoGlobal = {
const ordenado = [...datos].sort((a, b) => a.fecha - b.fecha);
const u = ordenado[ordenado.length - 1];
return `${u.anio}-Q${u.trimestre}`;
}
etiquetaMarco = ({
tradicional: "ENOE",
enoen: "ENOEN",
enoe_2023: "ENOE 2023+"
})
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, but measured against non-agricultural employment only. The informal sector is already defined over non-agricultural units, so only the base changes."
},
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 critical conditions of hours and pay: under 35 hours a week for market reasons, more than 35 hours a week with monthly income below one minimum wage, or more than 48 hours a week for up to two minimum wages. The thresholds are set in minimum wages and this series uses nominal ones, so the rate rises on its own when the minimum wage does. INEGI publishes a separate series in equivalent minimum wages for comparison across years."
},
TDAMPL: {
nombre: "Broader unemployment",
texto: "The unemployment rate, widened in both numerator and denominator to include people available to work who aren't actively searching but would take a job if offered. ENOE counts them as disponibles. Discouraged workers are only the part of that group who stopped searching for labor-market reasons. Not a custom definition: it only combines categories ENOE already reports separately."
},
SUBUTIL: {
nombre: "Labor underutilization",
texto: "Same as broader unemployment, adding people who already have a job but work fewer hours than they'd like (underemployed). The broadest measure here of how much available labor is going unused."
}
})
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
};
}
// 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];
}
// 2020-Q2 does not exist in the source. ENOE was suspended and replaced by
// the ETOE telephone survey, which INEGI does not publish as a comparable
// estimate. Joining the two sides with a straight line reads a change of
// method as a gradual transition, so a point with an undefined value is
// inserted in the gap and Plot breaks the line there instead.
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;
}
// Trend-cycle series, published by INEGI itself in its Banco de Informacion
// Economica. Quarterly ones come as percentages, unlike the monthly ones,
// which are index numbers, so they sit directly on the same axis as the
// original series with no rescaling. Six indicators have one; the rest return
// an empty array and simply get no background line.
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
}),
// The trend-cycle line is thinner and faded on purpose. It is reading
// context for the original series, not a second indicator, and the
// number quoted on the card always comes from the 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}-Q${d.trimestre} (${etiquetaMarco[d.regimen] ?? d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("en-US")}`
}))
]
});
}
coloresPrincipales = ({
TPEA: "var(--teal)",
TIL1: "var(--clay)",
TD: "var(--ink-soft)"
})
// A light/deep pair of the same neutral (not --teal or --clay, already used
// by TPEA/TIL1) so both cards read as the same theme -- broader unemployment
// -- without duplicating TD's color or each other's.
coloresEscalera = ({
TDAMPL: "var(--ink-soft-light)",
SUBUTIL: "var(--ink-soft-deep)"
})