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}`;
}
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, excluding agriculture."
},
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 precarious conditions: too few hours involuntarily, or long hours at below the legal minimum wage. Sensitive to minimum-wage changes — treat level shifts with caution, trend direction is more reliable."
},
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 (discouraged workers). 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} (${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)"
})