import { useMemo, useState } from "react"; import "./theme.css"; import type { Filtros, Meta } from "./api"; import { qs, useApi } from "./api"; import { BarrasAnuales, BarrasComparativas, BarrasRanking, Heatmap, Leyenda, SerieMensual, leerTendencia, regresionLineal, colorDe, etiquetaMes, mesLargo, nf, } from "./charts"; const NOMBRE_MES = [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "setiembre", "octubre", "noviembre", "diciembre", ]; /* La app no crea categorías: todo lo que se ve aquí es un conteo sobre las columnas tal como las publica MINSA. "Otros" es solo una agregación de presentación de las series fuera del top, no un atributo nuevo. */ const ABRIR_POR = [ { v: "", t: "Sin abrir" }, { v: "sexo", t: "SEXO" }, { v: "lugar", t: "TIPO_LUGAR" }, { v: "tipo", t: "MUERTE_VIOLENTA" }, { v: "necropsia", t: "NECROPSIA" }, ] as const; const MAX_SERIES = 6; export default function Dashboard({ meta }: { meta: Meta }) { const [tipo, setTipo] = useState("HOMICIDIO"); const [sexo, setSexo] = useState(""); const [departamento, setDepartamento] = useState(""); const [lugar, setLugar] = useState(""); const [rango, setRango] = useState<"3a" | "todo">("3a"); const [abrirPor, setAbrirPor] = useState(""); const [incluirParcial, setIncluirParcial] = useState(false); const [mostrarMedia, setMostrarMedia] = useState(true); const [mostrarRecta, setMostrarRecta] = useState(false); const [mesCorte, setMesCorte] = useState(null); // "Últimos 3 años" por defecto: el año en curso y los dos anteriores. const desde = useMemo(() => { if (!meta) return undefined; return rango === "3a" ? String(+meta.anio_max - 2) : meta.anio_min; }, [meta, rango]); const f: Filtros = { tipo, sexo, departamento, lugar, desde }; const listo = !!meta && !!desde; const query = listo ? qs(f) : ""; const serie = useApi<{ datos: any[]; mes_parcial: string }>( listo ? `/api/serie-mensual?${qs(f, abrirPor ? { serie_por: abrirPor } : {})}` : null ); const resumen = useApi(listo ? `/api/resumen?${query}` : null); const deptos = useApi(listo ? `/api/dimension/departamento?${qs(f, { limite: "12" })}` : null); const lugares = useApi(listo ? `/api/dimension/lugar?${query}` : null); const edades = useApi(listo ? `/api/edades?${query}` : null); const estacion = useApi(listo ? `/api/estacionalidad?${query}` : null); const mesParcial = serie.data?.mes_parcial ?? null; /* Mes de corte de la comparación interanual: por defecto el último mes ya cerrado, que es el que se puede comparar de igual a igual contra los mismos meses de años anteriores. */ const mesCerrado = useMemo(() => { if (!mesParcial) return null; const m = Number(mesParcial.split("-")[1]); return String(m === 1 ? 12 : m - 1).padStart(2, "0"); }, [mesParcial]); const mesComp = mesCorte ?? mesCerrado; const interanual = useApi( listo && mesComp ? `/api/interanual?${qs( { tipo, sexo, departamento, lugar }, { mes_corte: mesComp } )}` : null ); /* Pivotea la respuesta larga (mes, serie, n) a ancha para Recharts, deja las series en orden fijo por volumen total y agrupa la cola en "Otros". */ const { filas, series } = useMemo(() => { const d = serie.data?.datos ?? []; const visibles = incluirParcial ? d : d.filter((r: any) => r.mes !== mesParcial); if (!abrirPor) { return { filas: visibles.map((r: any) => ({ mes: r.mes, Total: r.n })), series: ["Total"], }; } const totales = new Map(); for (const r of visibles) totales.set(r.serie, (totales.get(r.serie) ?? 0) + r.n); const orden = [...totales.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k); const top = orden.slice(0, MAX_SERIES); const enTop = new Set(top); const conCola = orden.length > MAX_SERIES; const porMes = new Map(); for (const r of visibles) { if (!porMes.has(r.mes)) porMes.set(r.mes, { mes: r.mes }); const fila = porMes.get(r.mes); const k = enTop.has(r.serie) ? r.serie : "Otros"; fila[k] = (fila[k] ?? 0) + r.n; } return { filas: [...porMes.values()].sort((a, b) => a.mes.localeCompare(b.mes)), series: conCola ? [...top, "Otros"] : top, }; }, [serie.data, abrirPor, incluirParcial, mesParcial]); /* Tendencia sobre la ventana que se está viendo: el veredicto tiene que corresponder a lo que hay en pantalla, no a toda la historia. Se calcula sobre los conteos crudos, nunca sobre la media móvil: suavizar antes de regresionar infla el R² y el estadístico t, y daría "señal marcada" donde no la hay. */ const tendencia = useMemo( () => (series.length === 1 ? regresionLineal(filas.map((r: any) => r[series[0]] ?? 0)) : null), [filas, series] ); const veredicto = useMemo(() => leerTendencia(tendencia), [tendencia]); const porAnioBarras = useMemo( () => (resumen.data?.por_anio ?? []).map((r: any) => ({ categoria: r.ANIO, n: r.n })), [resumen.data] ); const conPct = (arr: any[] | null) => { if (!arr) return []; const t = arr.reduce((s, r) => s + r.n, 0) || 1; return arr.map((r) => ({ ...r, pct: Math.round((1000 * r.n) / t) / 10 })); }; const edadBarras = useMemo(() => { const d = edades.data?.decadas ?? []; return d.map((r: any) => ({ categoria: r.decada >= 100 ? "100+" : `${r.decada}-${r.decada + 9}`, n: r.n, })); }, [edades.data]); const picoMes = useMemo(() => { if (!filas.length || series.length !== 1) return null; return filas.reduce((a: any, b: any) => (b.Total > a.Total ? b : a)); }, [filas, series]); const promedio = useMemo(() => { if (!filas.length) return null; const t = filas.reduce( (s: number, r: any) => s + series.reduce((x: number, k: string) => x + (r[k] ?? 0), 0), 0 ); return Math.round(t / filas.length); }, [filas, series]); const v = resumen.data?.variacion; const etiquetaFiltro = [tipo || "todas las causas", sexo, lugar, departamento].filter(Boolean).join(" · ") || "sin filtros"; return ( <>
Total del periodo
{resumen.data ? nf.format(resumen.data.total) : "—"}
{etiquetaFiltro}
Variación último año cerrado
{v ? ( = 0 ? "up" : "down"}> {v.pct >= 0 ? "+" : ""} {v.pct}% ) : ( "—" )}
{v ? `${v.anio}: ${nf.format(v.valor)} vs ${v.anio_previo}: ${nf.format(v.valor_previo)}` : "—"}
Promedio mensual
{promedio != null ? nf.format(promedio) : "—"}
sobre {filas.length} meses
Mes más alto
{picoMes ? nf.format(picoMes.Total) : "—"}
{picoMes ? mesLargo(picoMes.mes) : "cierra las series para verlo"}

Tendencia mensual — {etiquetaFiltro}

Conteo de defunciones por mes de ocurrencia (columna FECHA). {!incluirParcial && mesParcial && ` Se excluye ${mesLargo(mesParcial)} por estar en curso.`}

{veredicto && (
{veredicto.icono} {veredicto.titulo} {veredicto.detalle} {tendencia && tendencia.r2 < 0.25 && tendencia.direccion !== "estable" && ( la recta explica poco de la variación: leer la media móvil )}
)}
{series.length > 1 && ( Las capas de tendencia requieren una sola serie: pon «Sin abrir». )}

Comparación interanual — {NOMBRE_MES[+(mesComp ?? "01") - 1]} y acumulado enero– {NOMBRE_MES[+(mesComp ?? "01") - 1]}

Todos los años disponibles ({meta.anio_min}–{meta.anio_max}), sin importar el filtro de periodo de arriba. Solo se comparan años en los que ese mes ya transcurrió.

{interanual.data && ( <>
Solo {NOMBRE_MES[+interanual.data.mes_corte - 1]}, año por año {interanual.data.variacion_mes && ( {interanual.data.variacion_mes.anio} vs{" "} {interanual.data.variacion_mes.anio_previo}:{" "} = 0 ? "up" : "down"}> {interanual.data.variacion_mes.pct >= 0 ? "+" : ""} {interanual.data.variacion_mes.pct}% {" · vs promedio histórico: "} = 0 ? "up" : "down"}> {interanual.data.variacion_mes.pct_vs_promedio >= 0 ? "+" : ""} {interanual.data.variacion_mes.pct_vs_promedio}% )}
Acumulado enero–{NOMBRE_MES[+interanual.data.mes_corte - 1]} (YTD) {interanual.data.variacion_ytd && ( {interanual.data.variacion_ytd.anio} vs{" "} {interanual.data.variacion_ytd.anio_previo}:{" "} = 0 ? "up" : "down"}> {interanual.data.variacion_ytd.pct >= 0 ? "+" : ""} {interanual.data.variacion_ytd.pct}% {" · vs promedio histórico: "} = 0 ? "up" : "down"}> {interanual.data.variacion_ytd.pct_vs_promedio >= 0 ? "+" : ""} {interanual.data.variacion_ytd.pct_vs_promedio}% )}
Años previos {interanual.data.anio_en_curso} (año en curso) — — promedio de los años previos

SINADEF sigue recibiendo certificados con retraso, así que los meses recién cerrados todavía suman registros durante las semanas siguientes. Un mes reciente tiende a quedar por debajo de su valor definitivo frente a los mismos meses de años ya consolidados.

)}

Total por año

El año en curso aparece en gris: lleva registro parcial.

DEPARTAMENTO_FALLECIMIENTO

Dónde ocurrió el fallecimiento, no dónde vivía la persona.

TIPO_LUGAR

Lugar del hecho tal como lo consigna el certificado.

EDAD por década

Solo registros con TIEMPO_EDAD = AÑOS {edades.data?.otras_unidades?.length ? ` (${nf.format( edades.data.otras_unidades.reduce((s: number, r: any) => s + r.n, 0) )} en otras unidades quedan fuera)` : ""} .

Estacionalidad — año × mes

Misma escala en toda la matriz; el tono más oscuro es el mes con más registros del periodo.

{estacion.data && }

Datos de la serie

La misma información del gráfico, en tabla.

{series.map((s: string) => ( ))} {[...filas].reverse().map((r: any) => ( {series.map((s: string) => ( ))} ))}
Mes {s}
{etiquetaMes(r.mes)} {r[s] != null ? nf.format(r[s]) : "—"}
Sobre la data. Todo lo que se muestra son conteos directos sobre las columnas de SINADEF tal como las publica MINSA. La app no crea ninguna categoría propia: los filtros son los valores literales de MUERTE_VIOLENTA, SEXO, TIPO_LUGAR y{" "} DEPARTAMENTO_FALLECIMIENTO. La interpretación de cualquier cruce queda a criterio de quien consulta.
Cobertura. MUERTE_VIOLENTA solo se consigna en muertes violentas; el resto figura como SIN REGISTRO (~95% de los registros, proporción estable año a año).{" "} CAUSA_*_CIEX está llenado en ~81% de los registros en general, pero solo en ~21% de los marcados como HOMICIDIO. El mes en curso siempre está incompleto. Base actualizada a diario desde el repositorio de MINSA.
); }