import type { Tendencia } from "./tendencia"; import { mediaMovilCentrada, mediaMovilConExtremos, regresionLineal, leerTendencia } from "./tendencia"; export { regresionLineal, leerTendencia, mediaMovilCentrada, mediaMovilConExtremos }; export type { Tendencia }; import { Area, Bar, ReferenceArea, Scatter, ScatterChart, BarChart, CartesianGrid, Cell, ComposedChart, LabelList, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, ZAxis, } from "recharts"; /* Slots categóricos en orden fijo — nunca se ciclan ni se reasignan por ranking. El color sigue a la entidad: un mapa serie→slot estable evita que al filtrar se repinten las series sobrevivientes. */ export const SLOTS = [ "var(--series-1)", "var(--series-2)", "var(--series-3)", "var(--series-4)", "var(--series-5)", "var(--series-6)", ]; export const OTROS = "var(--series-other)"; export function colorDe(serie: string, orden: string[]) { if (serie === "Otros") return OTROS; const i = orden.indexOf(serie); return i >= 0 && i < SLOTS.length ? SLOTS[i] : OTROS; } const MESES = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "set", "oct", "nov", "dic"]; export function etiquetaMes(m: string) { const [a, mm] = m.split("-"); return `${MESES[+mm - 1]} ${a.slice(2)}`; } export function mesLargo(m: string) { const [a, mm] = m.split("-"); return `${MESES[+mm - 1]} ${a}`; } export const nf = new Intl.NumberFormat("es-PE"); const ejeComun = { stroke: "var(--axis)", tick: { fill: "var(--text-muted)", fontSize: 11 }, tickLine: false, }; /* ── Tooltips ─────────────────────────────────────────────────────────── */ function TooltipSerie({ active, payload, label, parcial }: any) { if (!active || !payload?.length) return null; const filas = [...payload].filter((p) => p.value != null).sort((a, b) => b.value - a.value); const total = filas.reduce((s, p) => s + p.value, 0); return (
{mesLargo(label)}
{filas.map((p) => (
{p.dataKey} {nf.format(p.value)}
))} {filas.length > 1 && (
Total{nf.format(total)}
)} {label === parcial &&
Mes en curso — registro incompleto
}
); } function TooltipBarra({ active, payload, unidad }: any) { if (!active || !payload?.length) return null; const p = payload[0]; return (
{p.payload.categoria ?? p.payload.x}
{unidad} {nf.format(p.value)}
{p.payload.pct != null &&
{p.payload.pct}% del total filtrado
}
); } /* ── Línea de tendencia ───────────────────────────────────────────────── */ /* ── Serie mensual ────────────────────────────────────────────────────── */ export function SerieMensual({ datos, series, parcial, mostrarMedia = false, mostrarRecta = false, tendencia = null, }: { datos: any[]; series: string[]; parcial: string | null; mostrarMedia?: boolean; mostrarRecta?: boolean; tendencia?: Tendencia | null; }) { // Con 3 años son 36 puntos; más allá se ralean los ticks para que las // etiquetas no colisionen. const intervalo = Math.max(0, Math.ceil(datos.length / 14) - 1); // Las capas de tendencia solo aplican a una serie: superpuestas sobre varias // el gráfico se vuelve ilegible. const unaSerie = series.length === 1; const clave = series[0]; let filas = datos; if (unaSerie && (mostrarMedia || mostrarRecta)) { const valores = datos.map((d) => d[clave] ?? 0); const mm = mostrarMedia ? mediaMovilConExtremos(valores) : null; // El tramo firme y el provisional van en dos series para poder dibujarlos // distinto. Comparten el punto de unión para que la línea no se rompa. filas = datos.map((d, i) => { const p = mm?.[i]; const vecinoFirme = mm && ((mm[i - 1] && !mm[i - 1].provisional) || (mm[i + 1] && !mm[i + 1].provisional)); return { ...d, media: p && !p.provisional ? p.valor : undefined, mediaProv: p && p.valor != null && (p.provisional || vecinoFirme) ? p.valor : undefined, recta: mostrarRecta && tendencia ? tendencia.intercepto + tendencia.pendiente * i : undefined, }; }); } return ( } cursor={{ stroke: "var(--axis)", strokeWidth: 1 }} /> {series.map((s) => ( ))} {unaSerie && mostrarRecta && ( // Recta de regresión: el equivalente a la línea de tendencia de Excel. )} {unaSerie && mostrarMedia && ( <> {/* Extremos con ventana recortada: se recalculan al llegar meses nuevos, así que van punteados para no leerse como definitivos. */} {/* Media móvil por encima de la recta: es la lectura principal. */} )} ); } /* ── Barras por año ───────────────────────────────────────────────────── */ export function BarrasAnuales({ datos, anioCurso }: { datos: any[]; anioCurso: string }) { return ( } cursor={{ fill: "var(--hover-wash)" }} /> {datos.map((d) => ( // El año en curso va en gris: es un dato parcial, no una caída real. ))} ); } /* ── Comparación interanual (mismo mes / acumulado) ───────────────────── */ export function BarrasComparativas({ datos, campo, anioCurso, promedio, etiquetaPromedio, }: { datos: any[]; campo: "mes_n" | "ytd_n"; anioCurso: string; promedio?: number; etiquetaPromedio?: string; }) { return ( } cursor={{ fill: "var(--hover-wash)" }} /> {promedio != null && ( )} {datos.map((d) => ( // El año en curso lleva su propio color porque es una entidad // distinta (periodo aún abierto), no porque encabece un ranking. ))} (typeof v === "number" ? nf.format(v) : "")} /> ); } /* ── Barras horizontales (rankings) ───────────────────────────────────── */ export function BarrasRanking({ datos, alto = 300, unidad = "Defunciones", }: { datos: any[]; alto?: number; unidad?: string; }) { return ( } cursor={{ fill: "var(--hover-wash)" }} /> (typeof v === "number" ? nf.format(v) : ""), }} /> ); } /* ── Heatmap de estacionalidad ────────────────────────────────────────── */ export function Heatmap({ filas, mesParcial, }: { filas: { ANIO: string; MES: string; n: number }[]; mesParcial?: string | null; }) { const anios = [...new Set(filas.map((f) => f.ANIO))].sort(); const mapa = new Map(filas.map((f) => [`${f.ANIO}-${f.MES}`, f.n])); const max = Math.max(...filas.map((f) => f.n), 1); // Total por año. El año en curso queda incompleto, así que se marca: su // total no es comparable con el de los años cerrados. const totales = new Map(); for (const f of filas) totales.set(f.ANIO, (totales.get(f.ANIO) ?? 0) + f.n); const anioEnCurso = mesParcial ? mesParcial.slice(0, 4) : anios.at(-1); // Rampa secuencial de un solo tono: claro = cerca de cero. const paso = (n: number | undefined) => { if (n == null) return "var(--seq-0)"; const t = n / max; const i = Math.min(7, Math.max(1, Math.ceil(t * 7))); return `var(--seq-${i})`; }; return (
))} {anios.map((a) => ( {MESES.map((_, i) => { const mm = String(i + 1).padStart(2, "0"); const n = mapa.get(`${a}-${mm}`); const t = n ? n / max : 0; return ( ); })} {/* El total va fuera de la rampa de color: su magnitud es un orden mayor que la de un mes y aplastaría toda la escala. */} ))}
{MESES.map((m) => ( {m}total
{a} 0.55 ? "#fff" : "var(--text-secondary)", }} title={`${MESES[i]} ${a}: ${n != null ? nf.format(n) : "sin datos"}`} > {n != null ? nf.format(n) : ""} {nf.format(totales.get(a) ?? 0)} {a === anioEnCurso && *}
{anioEnCurso && (

* {anioEnCurso} en curso: el total aún no es comparable con los años cerrados.

)}
); } /* ── Leyenda ──────────────────────────────────────────────────────────── */ export function Leyenda({ series }: { series: string[] }) { if (series.length < 2) return null; // una sola serie: el título ya la nombra return (
{series.map((s) => ( {s} ))}
); } /* ── Proyección: observado + central + banda, mes a mes ───────────────── */ function TooltipProyeccion({ active, payload, label }: any) { if (!active || !payload?.length) return null; const d = payload[0]?.payload ?? {}; const esProy = d.proy != null && d.obs == null; return (
{mesLargo(label)}
{d.obs != null && (
Observado{nf.format(d.obs)}
)} {esProy && (
Proyectado{nf.format(Math.round(d.proy))}
)} {esProy && d.lo != null && (
rango 80%: {nf.format(Math.round(d.lo))} – {nf.format(Math.round(d.hi))}
)}
); } export function ProyeccionChart({ historico, meses, central, bandas, mesesHistoricos = 24, }: { historico: { mes: string; n: number }[]; meses: string[]; central: number[]; bandas: any; mesesHistoricos?: number; }) { // Se acota el histórico para que el tramo proyectado se lea mes a mes; con // los 100+ meses desde 2017 los últimos puntos quedarían aplastados. const hist = historico.slice(-mesesHistoricos); const datos: any[] = hist.map((h) => ({ mes: h.mes, obs: h.n })); // El último observado ancla también la serie proyectada, para que la línea // punteada arranque pegada y no aparezca flotando. if (datos.length) datos[datos.length - 1].proy = datos[datos.length - 1].obs; meses.forEach((m, i) => { datos.push({ mes: m, proy: central[i], lo: bandas?.p10?.[i], hi: bandas?.p90?.[i], rango: bandas?.p10 ? [bandas.p10[i], bandas.p90[i]] : undefined, }); }); return ( } cursor={{ stroke: "var(--axis)", strokeWidth: 1 }} /> {meses.length > 0 && ( )} {/* Cada mes proyectado lleva su valor: es la lectura que se pide. */} (typeof v === "number" ? String(Math.round(v)) : "")} /> ); } /* ── Cierre anual: años cerrados + año en curso (observado + proyectado) ── */ function TooltipCierre({ active, payload }: any) { if (!active || !payload?.length) return null; const d = payload[0]?.payload ?? {}; return (
{d.anio}
{d.observado != null && (
Observado{nf.format(Math.round(d.observado))}
)} {d.proyectado ? (
Proyectado{nf.format(Math.round(d.proyectado))}
) : null} {d.proyectado ? (
Cierre estimado{nf.format(Math.round(d.observado + d.proyectado))}
) : null} {d.rango &&
rango 80%: {d.rango}
}
); } export function CierreAnual({ datos }: { datos: any[] }) { return ( } cursor={{ fill: "var(--hover-wash)" }} /> {/* Apiladas: la parte ya ocurrida y la proyectada del año en curso se distinguen a simple vista en lugar de esconderse en un solo total. */} (typeof v === "number" ? nf.format(Math.round(v)) : "")} /> ); } /* ── Cierre diario: registrado + rezago + proyección ───────────────────── */ function TooltipDiario({ active, payload, label }: any) { if (!active || !payload?.length) return null; const d = payload[0]?.payload ?? {}; const DOW = ["lunes","martes","miércoles","jueves","viernes","sábado","domingo"]; return (
{label} · {DOW[d.dow]}
{d.tipo === "observado" ? ( <>
Registrado{d.observado}
{d.rezago > 0.05 && (
{/* "menos de 1" en vez de "+0": un rezago pequeño existe, y mostrarlo como cero lo haría desaparecer. */} Rezago estimado{d.rezago < 0.5 ? "menos de 1" : `+${Math.round(d.rezago)}`}
)} {d.rezago > 0.05 && (
Total estimado{Math.round(d.corregido ?? 0)}
)}
{(d.fraccion * 100).toFixed(0)}% de este día ya está registrado {d.observado === 0 && d.rezago > 0.05 && " — aún sin certificados cargados"}
) : ( <> {/* Se muestra el rango entero como dato principal: el número de muertes de un día es entero, y el valor esperado (decimal) es un promedio, no un pronóstico de lo que marcará el contador. */}
Esperado{Math.round(d.proyectado ?? 0)}
{/* El intervalo se redondea hacia afuera: con round() un rango como [2.6, 8.4] quedaría en [3, 8], estrechando la cobertura que se promete. Piso abajo y techo arriba la conservan. */}
rango probable: {Math.floor(d.lo ?? 0)} – {Math.ceil(d.hi ?? 0)} muertes
)}
); } export function CierreDiario({ dias }: { dias: any[] }) { const datos = dias.map((d) => ({ ...d, dia: d.fecha.slice(8), // El rezago se apila encima de lo registrado: juntos son el total estimado // de ese día. Viene calculado del backend como μ·(1−fracción), de modo que // un día con cero registros igual muestra su parte pendiente. rezago: d.tipo === "observado" ? (d.pendiente ?? 0) : 0, rango: d.tipo === "proyectado" ? [d.lo, d.hi] : undefined, })); return ( } cursor={{ fill: "var(--hover-wash)" }} /> ); } /* ── Curva de rezago ──────────────────────────────────────────────────── */ export function CurvaRezago({ datos }: { datos: { antiguedad: number; fraccion: number }[] }) { const d = datos.map((x) => ({ ...x, pct: x.fraccion * 100 })); return ( `${v}%`} /> active && payload?.length ? (
{label} días después
Registrado{payload[0].value.toFixed(1)}%
) : null } />
); } /* ── Gestión: cuadrantes de tendencia ─────────────────────────────────── */ /* El color aquí codifica polaridad (mejoró / empeoró), no identidad, así que usa la paleta de estado y no los slots categóricos. */ /* Cinco niveles ordinales, de mejor a peor. La escala está validada con scripts/validate_palette.js en claro y oscuro: el par crítico (amarillo↔rojo) queda en ΔE 10.2 para daltonismo y 16.9 en visión normal sobre fondo oscuro. El gris es el punto neutro de la escala, no una categoría más. */ export const COLOR_VEREDICTO: Record = { bajo: "var(--v-bajo)", freno: "var(--v-freno)", sigue: "var(--v-sigue)", empeoro: "var(--v-empeoro)", disparo: "var(--v-disparo)", }; export const ETIQUETA_VEREDICTO: Record = { bajo: "Bajó", freno: "Frenó el alza", sigue: "Sin cambio", empeoro: "Empeoró", disparo: "Se disparó", }; function TooltipGestion({ active, payload }: any) { if (!active || !payload?.length) return null; const d = payload[0]?.payload ?? {}; return (
{d.territorio}{d.padre ? ` · ${d.padre}` : ""}
Recibió{d.x > 0 ? "+" : ""}{Math.round(d.x)}%/año
Su gestión{d.y > 0 ? "+" : ""}{Math.round(d.y)}%/año
2022 → últimos 12m{nf.format(d.recibido ?? 0)} → {nf.format(d.actual ?? 0)}
{d.contraf != null && (
Si nada cambiaba{nf.format(d.contraf)}
)}
{ETIQUETA_VEREDICTO[d.veredicto] ?? d.veredicto}
); } export function CuadrantesGestion({ territorios, onSelect, }: { territorios: any[]; onSelect?: (t: string) => void; }) { const datos = territorios .filter((t) => t.tend1.pct != null && t.tend2.pct != null) .map((t) => ({ id: t.id, territorio: t.territorio, padre: t.padre, veredicto: t.veredicto, // Ejes en % anual y no en casos/año: así un punto de Lima y uno de una // provincia chica son comparables en la misma escala. // Mismos campos que usa la tabla. Antes leía anual1/anual2 (promedios // anualizados del periodo completo) mientras la tabla ya mostraba // recibido/actual, así que el tooltip y la fila decían cifras distintas // para el mismo territorio. x: t.tend1.pct, y: t.tend2.pct, recibido: t.recibido, actual: t.actual, contraf: t.contrafactual, z: t.n2, })); if (!datos.length) return null; const mx = Math.max(...datos.map((d) => Math.abs(d.x)), 1) * 1.15; const my = Math.max(...datos.map((d) => Math.abs(d.y)), 1) * 1.15; return ( {/* El cuadrante inferior derecho es el que responde la pregunta: recibió subiendo (x>0) y lo dejó bajando (y<0). */} `${Math.round(v)}%`} label={{ value: "Tendencia que RECIBIÓ (% anual)", position: "insideBottom", offset: -18, fill: "var(--text-muted)", fontSize: 11 }} /> `${Math.round(v)}%`} label={{ value: "Tendencia de SU GESTIÓN (% anual)", angle: -90, position: "insideLeft", offset: 8, fill: "var(--text-muted)", fontSize: 11 }} /> } cursor={{ strokeDasharray: "3 3" }} /> onSelect?.(e?.id)} style={{ cursor: onSelect ? "pointer" : "default" }} > {datos.map((d, i) => ( ))} ); } /* ── Gestión: serie mensual de un territorio con los dos periodos ──────── */ export function SerieGestion({ serie, p1, }: { serie: { mes: string; n: number }[]; p1: { desde: string; hasta: string }; }) { const datos = serie.map((r) => ({ ...r, periodo: r.mes <= p1.hasta ? 1 : 2 })); const intervalo = Math.max(0, Math.ceil(datos.length / 14) - 1); const corte = datos.find((d) => d.periodo === 2)?.mes; // Recta de mínimos cuadrados dentro de cada periodo: es lo que traduce // "venía subiendo" y "lo dejó bajando" en algo visible. const recta = (tramo: typeof datos) => { const n = tramo.length; if (n < 3) return new Map(); const mx = (n - 1) / 2; const my = tramo.reduce((s, r) => s + r.n, 0) / n; let sxy = 0, sxx = 0; tramo.forEach((r, i) => { sxy += (i - mx) * (r.n - my); sxx += (i - mx) ** 2; }); const b = sxx ? sxy / sxx : 0; return new Map(tramo.map((r, i) => [r.mes, my + b * (i - mx)])); }; const r1 = recta(datos.filter((d) => d.periodo === 1)); const r2 = recta(datos.filter((d) => d.periodo === 2)); const filas = datos.map((d) => ({ ...d, t1: r1.get(d.mes), t2: r2.get(d.mes) })); return ( active && payload?.length ? (
{mesLargo(label)}
Homicidios{payload[0].payload.n}
{payload[0].payload.periodo === 1 ? "periodo heredado" : "periodo de gestión"}
) : null } /> {corte && ( )}
); }