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 (
|
{MESES.map((m) => (
{m} |
))}
total |
{anios.map((a) => (
| {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 (
0.55 ? "#fff" : "var(--text-secondary)",
}}
title={`${MESES[i]} ${a}: ${n != null ? nf.format(n) : "sin datos"}`}
>
{n != null ? nf.format(n) : ""}
|
);
})}
{/* 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. */}
{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 && (
)}
);
}