diff --git a/package-lock.json b/package-lock.json index 78e874c..af31443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,14 @@ "html2canvas": "^1.4.1", "html2canvas-pro": "^2.0.2", "jspdf": "^4.2.1", + "leaflet": "^1.9.4", + "leaflet-image": "^0.4.0", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^1.8.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.0", "recharts": "^3.8.1" }, @@ -603,6 +608,17 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -1845,6 +1861,12 @@ "node": ">=12" } }, + "node_modules/d3-queue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/d3-queue/-/d3-queue-2.0.3.tgz", + "integrity": "sha512-ejbdHqZYEmk9ns/ljSbEcD6VRiuNwAkZMdFf6rsUb3vHROK5iMFd8xewDQnUVr6m/ba2BG63KmR/LySfsluxbg==", + "license": "BSD-3-Clause" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -2624,6 +2646,35 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet-image": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/leaflet-image/-/leaflet-image-0.4.0.tgz", + "integrity": "sha512-J/vLCHiYNXlcQ/SZbHhj/VF5k3thxTryWijoqMO9sB20KV7hlMNUZDgxcDzXnfjk4hcYcFfGbveVc1tyQ9FgYw==", + "license": "BSD-2-Clause", + "dependencies": { + "d3-queue": "2.0.3" + } + }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3219,6 +3270,20 @@ "license": "MIT", "peer": true }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", diff --git a/package.json b/package.json index 320d167..f112b9c 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,14 @@ "html2canvas": "^1.4.1", "html2canvas-pro": "^2.0.2", "jspdf": "^4.2.1", + "leaflet": "^1.9.4", + "leaflet-image": "^0.4.0", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "lucide-react": "^1.8.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.0", "recharts": "^3.8.1" }, diff --git a/src/components/charts/DonutGravedad.jsx b/src/components/charts/DonutGravedad.jsx index 1a527f2..6c975a3 100644 --- a/src/components/charts/DonutGravedad.jsx +++ b/src/components/charts/DonutGravedad.jsx @@ -1,33 +1,48 @@ -import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts' import { calcularKPIs } from '../../utils/calculos' import { COLOR } from '../../utils/colores' const sectors = [ - { key: 'fatales', label: 'Fatales', color: COLOR.fatales }, - { key: 'conLes', label: 'Con Lesionados', color: COLOR.conLes }, - { key: 'sinLes', label: 'Sin Lesiones', color: COLOR.sinLes }, + { key: 'fatales', label: 'Fatales', color: COLOR.fatales }, + { key: 'conLes', label: 'Con Lesionados', color: COLOR.conLes }, + { key: 'sinLes', label: 'Sin Lesiones', color: COLOR.sinLes }, ] +// Tooltip personalizado igual al estilo de los otros gráficos +function CustomTooltip({ active, payload }) { + if (!active || !payload?.length) return null + const { name, value, color, pct } = payload[0].payload + return ( +
+
+ + {name} +
+
+ {value} + {pct} +
+
+ ) +} + export default function DonutGravedad({ siniestros }) { const kpis = calcularKPIs(siniestros) + const total = kpis.total || 1 + const data = sectors.map((sector) => ({ - name: sector.label, + name: sector.label, value: kpis[sector.key], color: sector.color, + pct: `${((kpis[sector.key] / total) * 100).toFixed(1)}%`, })) return ( -
-
-
-

Gravedad por categoría

-

Distribución de siniestros

-
-
- -
+ <> +
+ } />
-
+ {/* Leyenda con porcentajes */} +
{data.map((item) => (
@@ -59,9 +75,10 @@ export default function DonutGravedad({ siniestros }) { {item.name}
{item.value}
+
{item.pct}
))}
-
+ ) -} +} \ No newline at end of file diff --git a/src/components/charts/MapaSiniestros.jsx b/src/components/charts/MapaSiniestros.jsx new file mode 100644 index 0000000..94a5118 --- /dev/null +++ b/src/components/charts/MapaSiniestros.jsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from 'react' +import L from 'leaflet' +import 'leaflet.markercluster/dist/leaflet.markercluster.js' +import 'leaflet.heat/dist/leaflet-heat.js' + + + +const GRAVEDAD = { + fatal: { color: '#C44228', label: 'Fatal' }, + lesionado: { color: '#E8881A', label: 'Lesionado' }, + sinlesiones: { color: '#6B7280', label: 'Sin lesiones' }, +} + + +const BBOX_SANTA_CRUZ = { + latMin: -52.4, + latMax: -46.0, + lngMin: -73.6, + lngMax: -65.5, +} + + +function dentroDeRango(lat, lng) { + return ( + lat >= BBOX_SANTA_CRUZ.latMin && + lat <= BBOX_SANTA_CRUZ.latMax && + lng >= BBOX_SANTA_CRUZ.lngMin && + lng <= BBOX_SANTA_CRUZ.lngMax + ) +} + + +function getGravedad(s) { + const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos ?? 0) + const lesion = Number(s.cantidad_lesionados ?? s.heridos ?? 0) + if (fatales > 0) return 'fatal' + if (lesion > 0) return 'lesionado' + return 'sinlesiones' +} + + +function formatFecha(s) { + const dia = s.dia ? String(s.dia).padStart(2, '0') : '??' + const mes = s.mes ? String(s.mes).padStart(2, '0') : '??' + const ano = s.ano ?? s.year ?? '????' + return `${dia}/${mes}/${ano}` +} + + +const DARK_FILTER = 'brightness(0.55) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)' + + +export default function MapaSiniestros({ siniestros, siniestrosMapa, vista, darkMode }) { + const containerRef = useRef(null) + const mapRef = useRef(null) + const tileRef = useRef(null) + const clusterRef = useRef(null) + const heatRef = useRef(null) + const pointsRef = useRef(null) + const [mapaListo, setMapaListo] = useState(false) + + + useEffect(() => { + if (!containerRef.current || mapRef.current) return + + const map = L.map(containerRef.current, { + center: [-50.0, -69.0], + zoom: 6, + zoomControl: true, + attributionControl: true, + }) + + tileRef.current = L.tileLayer( + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + { attribution: '© OpenStreetMap contributors', maxZoom: 19 } + ).addTo(map) + + mapRef.current = map + setMapaListo(true) + + return () => { + map.remove() + mapRef.current = null + tileRef.current = null + setMapaListo(false) + } + }, []) + + + useEffect(() => { + const el = tileRef.current?.getContainer() + if (el) el.style.filter = darkMode ? DARK_FILTER : '' + }, [darkMode]) + + + useEffect(() => { + if (!mapRef.current) return + + if (clusterRef.current) { mapRef.current.removeLayer(clusterRef.current); clusterRef.current = null } + if (heatRef.current) { mapRef.current.removeLayer(heatRef.current); heatRef.current = null } + if (pointsRef.current) { mapRef.current.removeLayer(pointsRef.current); pointsRef.current = null } + + const fuenteMapa = siniestrosMapa ?? [] + + const validos = fuenteMapa.filter((s) => { + const lat = Number(s.latitud_norm) + const lng = Number(s.longitud_norm) + return ( + s.latitud_norm != null && + s.longitud_norm != null && + !isNaN(lat) && + !isNaN(lng) && + dentroDeRango(lat, lng) + ) + }) + + if (validos.length === 0) return + + const datosCompletos = Object.fromEntries( + (siniestros ?? []).map((s) => [s.id_feu, s]) + ) + + const latlngs = validos.map((s) => [ + Number(s.latitud_norm), + Number(s.longitud_norm), + ]) + + if (vista === 'marcadores') { + const group = L.layerGroup() + + validos.forEach((s) => { + const dato = datosCompletos[s.id_feu] ?? s + const grav = getGravedad(dato) + const color = GRAVEDAD[grav].color + + const marker = L.circleMarker( + [Number(s.latitud_norm), Number(s.longitud_norm)], + { radius: 6, fillColor: color, color: '#ffffff', weight: 2, opacity: 1, fillOpacity: 0.9 } + ) + + marker.bindPopup(` +
+
+
+ ${GRAVEDAD[grav].label} +
+
+
Fecha: ${formatFecha(dato)}
+
Tipo: ${dato.tipo_siniestro_unico || dato.tipo_siniestro || 'Sin dato'}
+
Localidad: ${dato.localidad || 'Sin dato'}
+
Departamento: ${dato.departamento || 'Sin dato'}
+
+
+ `, { maxWidth: 240 }) + + group.addLayer(marker) + }) + + pointsRef.current = group + mapRef.current.addLayer(group) + + if (latlngs.length === 1) { + mapRef.current.setView(latlngs[0], 12) + } else { + mapRef.current.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] }) + } + + } else { + const points = validos.map((s) => { + const dato = datosCompletos[s.id_feu] ?? s + const grav = getGravedad(dato) + const intensity = grav === 'fatal' ? 1.0 : grav === 'lesionado' ? 0.5 : 0.2 + return [Number(s.latitud_norm), Number(s.longitud_norm), intensity] + }) + + heatRef.current = L.heatLayer(points, { + radius: 25, + blur: 20, + maxZoom: 12, + gradient: { 0.2: '#80B0DE', 0.5: '#E8881A', 0.8: '#C44228', 1.0: '#7B0000' }, + }).addTo(mapRef.current) + + if (latlngs.length === 1) { + mapRef.current.setView(latlngs[0], 12) + } else { + mapRef.current.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] }) + } + } + }, [siniestros, siniestrosMapa, vista, mapaListo]) + + + return ( +
+ ) +} \ No newline at end of file diff --git a/src/components/charts/PorLocalidad.jsx b/src/components/charts/PorLocalidad.jsx index 95068ea..2f4cbb6 100644 --- a/src/components/charts/PorLocalidad.jsx +++ b/src/components/charts/PorLocalidad.jsx @@ -75,6 +75,7 @@ export default function PorLocalidad({ siniestros, tipo = 'todas' }) { -
-

- Serie histórica -

-

- Tasa de mortalidad vial -

-

- Víctimas fatales cada 100.000 habitantes. Provincia de Santa Cruz, {primerAnio}–{ultimoAnio}. -

-
+
@@ -228,12 +218,12 @@ export default function SerieHistorica({
-

+

Tasas 2013–2024: informes anuales OPSV Santa Cruz. Tasas 2025 en adelante: cálculo propio sobre proyecciones INDEC base Censo Nacional de Población, Hogares y Viviendas 2022.

-

+

Nota: el año 2020 no se incluye en la serie histórica. Las restricciones de circulación por la pandemia de COVID-19 generaron una reducción atípica de la movilidad vehicular, por lo que los datos diff --git a/src/components/charts/SiniestrosPorMes.jsx b/src/components/charts/SiniestrosPorMes.jsx index 779dfbd..20436d6 100644 --- a/src/components/charts/SiniestrosPorMes.jsx +++ b/src/components/charts/SiniestrosPorMes.jsx @@ -83,7 +83,6 @@ export default function SiniestrosPorMes({ siniestros }) { return (

-
@@ -99,19 +98,76 @@ export default function SiniestrosPorMes({ siniestros }) { tickLine={false} /> + { + if (!active || !payload?.length) return null + + // Filtramos las series cuyo valor es 0 + const visibles = payload.filter((item) => Number(item.value) > 0) + if (!visibles.length) return null + + return ( +
+
+ {label} +
+ + {visibles.map((item) => ( +
+
+ + {item.name || item.dataKey} +
+ + {item.value} + +
+ ))} +
+ ) }} - labelStyle={{ color: tooltipLabel, fontWeight: 700 }} /> + diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index 5604ec9..891b2fb 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -6,19 +6,22 @@ import { ShieldCheck, FileText, Sun, + Map, // ← NUEVO } from 'lucide-react' + const SECCIONES = [ - { id: 'resumen', label: 'Resumen General', icon: LayoutDashboard }, - { id: 'historica', label: 'Serie Histórica Provincial', icon: TrendingUp }, - { id: 'fatales', label: 'Siniestros Fatales', icon: AlertTriangle }, - { id: 'lesionados', label: 'Con Lesionados', icon: Activity }, - { id: 'sinlesiones', label: 'Sin Lesiones', icon: ShieldCheck }, - { id: 'sintesis', label: 'Síntesis', icon: FileText }, - { id: 'veranovivo', label: 'Campañas Verano Vivo', icon: Sun }, - + { id: 'resumen', label: 'Resumen General', icon: LayoutDashboard }, + { id: 'historica', label: 'Serie Histórica Provincial', icon: TrendingUp }, + { id: 'fatales', label: 'Siniestros Fatales', icon: AlertTriangle }, + { id: 'lesionados', label: 'Con Lesionados', icon: Activity }, + { id: 'sinlesiones',label: 'Sin Lesiones', icon: ShieldCheck }, + { id: 'sintesis', label: 'Síntesis', icon: FileText }, + { id: 'mapa', label: 'Mapa de Siniestros', icon: Map }, // ← NUEVO + { id: 'veranovivo', label: 'Campañas Verano Vivo', icon: Sun }, ] + export default function Sidebar({ seccion, setSeccion, @@ -33,23 +36,23 @@ export default function Sidebar({ Observatorio Provincial de Seguridad Vial
-
OPSV
+
OPSV
-
+
Observatorio Provincial
de Seguridad Vial
-
- APSV +
+ APSV
-
- Ministerio de Seguridad · Santa Cruz +
+ Ministerio de Seguridad · Santa Cruz
diff --git a/src/components/layout/Topbar.jsx b/src/components/layout/Topbar.jsx index 07f59bb..354144b 100644 --- a/src/components/layout/Topbar.jsx +++ b/src/components/layout/Topbar.jsx @@ -1,11 +1,12 @@ // src/components/layout/Topbar.jsx import { useEffect, useMemo, useRef, useState } from 'react' import { CalendarRange, ChevronDown, X } from 'lucide-react' -import { Building2, MapPin } from 'lucide-react' +import { Building2, MapPin, Map } from 'lucide-react' import ThemeToggle from '../ui/ThemeToggle' import FilterSelect from '../ui/FilterSelect' + const TITULOS = { resumen: { title: 'Resumen General', subtitle: 'Indicadores principales del año seleccionado' }, historica: { title: 'Serie Histórica Provincial', subtitle: 'Tasas y tendencias calculadas sobre el total de la provincia · sin filtro geográfico' }, @@ -16,9 +17,11 @@ const TITULOS = { } + const AÑOS = [2026, 2025, 2024, 2023, 2022, 2021] + const MESES = [ { value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, @@ -35,11 +38,13 @@ const MESES = [ ] + function periodoToValue(parte) { if (!parte?.mes || !parte?.ano) return '' return `${parte.ano}-${String(parte.mes).padStart(2, '0')}` } + function valueToPeriodo(value) { if (!value) return null const [ano, mes] = value.split('-').map(Number) @@ -47,11 +52,13 @@ function valueToPeriodo(value) { return { ano, mes } } + function periodoToNumber(parte) { if (!parte?.ano || !parte?.mes) return null return parte.ano * 100 + parte.mes } + function formatPeriodo(periodo) { if (!periodo?.desde && !periodo?.hasta) return 'Filtro por fecha' @@ -67,7 +74,7 @@ function formatPeriodo(periodo) { } -// Clases del botón de período (sigue siendo nativo, no usa FilterSelect) + const pillBase = 'flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-medium transition' const pillInactive = 'border-opsv-border dark:border-slate-600 bg-white dark:bg-slate-800 ' + @@ -78,6 +85,7 @@ const pillActive = 'dark:border-sky-400 dark:bg-sky-500/15 dark:text-white' + export default function Topbar({ seccion, year, @@ -91,6 +99,9 @@ export default function Topbar({ localidadFiltro, setLocalidadFiltro, localidadesDisponibles, + zonaFiltro, + setZonaFiltro, + zonasDisponibles, onExportarPdf, }) { const { title, subtitle } = TITULOS[seccion] || TITULOS.resumen @@ -152,18 +163,24 @@ export default function Topbar({ setOpenFiltro(false) } + return ( -
+
+ + {/* ── Fila 1: título + filtros de datos ───────────────────────────── */}
+ + {/* Título */}
-

OPSV

-

{title}

-

{subtitle}

+

OPSV

+

{title}

+

{subtitle}

+ {/* Filtros de datos */}
- {/* ── Selector de año ── */} + {/* Selector de año */} setYear(Number(v))} @@ -171,7 +188,7 @@ export default function Topbar({ placeholder="Año" /> - {/* ── Filtro por período ── sin cambios, botón nativo ── */} + {/* Filtro por período */}
- - - - {/* ── Contador ── */} -
- {siniestrosCount ?? 0} siniestros cargados -
+ {/* Filtro por zona */} + {seccion !== 'veranovivo' && seccion !== 'historica' && ( + ({ value: z, label: z }))} + placeholder="Todas las zonas" + /> + )}
+ + {/* ── Fila 2: contador (centro) + acciones (derecha) ──────────────── */} +
{/* ← todo a la derecha */} + + {/* Contador — tipografía más grande, destacado */} +
{/* ← ocupa el espacio del medio y centra */} + + {(siniestrosCount ?? 0).toLocaleString('es-AR')} + + + siniestros + +
+ + {/* Descargar PDF */} + + + {/* Modo claro/oscuro */} + + +
+
) } \ No newline at end of file diff --git a/src/components/ui/ChartCard.jsx b/src/components/ui/ChartCard.jsx index dac7a07..8a95ee8 100644 --- a/src/components/ui/ChartCard.jsx +++ b/src/components/ui/ChartCard.jsx @@ -1,8 +1,7 @@ -// src/components/ui/ChartCard.jsx const HEIGHTS = { - sm: 'h-[300px]', - md: 'h-[360px]', - lg: 'h-[420px]', + sm: 'h-[300px]', + md: 'h-[360px]', + lg: 'h-[420px]', auto: '', } @@ -15,10 +14,13 @@ export default function ChartCard({ className = '', contentClassName = '', }) { + const isFixed = height !== 'auto' + return (
{(kicker || title || subtitle) && (
@@ -39,7 +41,12 @@ export default function ChartCard({ )}
)} -
+ {/* overflow-hidden solo en el div del contenido cuando altura es fija */} +
{children}
diff --git a/src/components/ui/KPICard.jsx b/src/components/ui/KPICard.jsx index bdd0e8a..4151049 100644 --- a/src/components/ui/KPICard.jsx +++ b/src/components/ui/KPICard.jsx @@ -4,35 +4,24 @@ export default function KPICard({ color, unit, variation, - centered = false, }) { const formattedValue = typeof value === 'number' ? value.toLocaleString('es-AR') : value return (
-
-
+
+
{formattedValue} {unit ? ( - - {' '} - {unit} - + {unit} ) : null}
- -

- {label} -

+

{label}

{variation ? ( diff --git a/src/hooks/useData.js b/src/hooks/useData.js index 91e33f3..ca84977 100644 --- a/src/hooks/useData.js +++ b/src/hooks/useData.js @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { supabasePublic } from '../lib/supabase' + function estaEnPeriodo(item, periodo) { if (!periodo?.desde || !periodo?.hasta) return true @@ -14,12 +15,14 @@ function estaEnPeriodo(item, periodo) { return actualValor >= desdeValor && actualValor <= hastaValor } + export function useData(year = null, periodo = { desde: null, hasta: null }) { - const [siniestros, setSiniestros] = useState([]) - const [involucrados, setInvolucrados] = useState([]) - const [personas, setPersonas] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [siniestros, setSiniestros] = useState([]) + const [involucrados, setInvolucrados] = useState([]) + const [personas, setPersonas] = useState([]) + const [siniestrosMapa, setSiniestrosMapa] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) const periodoKey = JSON.stringify(periodo) @@ -34,6 +37,9 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) { let qS = supabasePublic.from('siniestros').select('*') let qI = supabasePublic.from('Involucrados').select('*') let qP = supabasePublic.from('Personas').select('*') + let qM = supabasePublic + .from('siniestros_mapa') + .select('id_feu, ano, mes, latitud_norm, longitud_norm') if (periodoActivo) { const anoDesde = periodoActual.desde.ano @@ -42,39 +48,37 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) { qS = qS.gte('ano', anoDesde).lte('ano', anoHasta) qI = qI.gte('ano', anoDesde).lte('ano', anoHasta) qP = qP.gte('ano', anoDesde).lte('ano', anoHasta) + qM = qM.gte('ano', anoDesde).lte('ano', anoHasta) } else if (year) { qS = qS.eq('ano', year) qI = qI.eq('ano', year) qP = qP.eq('ano', year) + qM = qM.eq('ano', year) } - const [resS, resI, resP] = await Promise.all([qS, qI, qP]) + const [resS, resI, resP, resM] = await Promise.all([qS, qI, qP, qM]) if (resS.error) throw resS.error if (resI.error) throw resI.error if (resP.error) throw resP.error + if (resM.error) throw resM.error let dataS = resS.data || [] let dataI = resI.data || [] let dataP = resP.data || [] + let dataM = resM.data || [] if (periodoActivo) { - dataS = dataS.filter(item => estaEnPeriodo(item, periodoActual)) - dataI = dataI.filter(item => estaEnPeriodo(item, periodoActual)) - dataP = dataP.filter(item => estaEnPeriodo(item, periodoActual)) + dataS = dataS.filter((item) => estaEnPeriodo(item, periodoActual)) + dataI = dataI.filter((item) => estaEnPeriodo(item, periodoActual)) + dataP = dataP.filter((item) => estaEnPeriodo(item, periodoActual)) + dataM = dataM.filter((item) => estaEnPeriodo(item, periodoActual)) } - console.log('useData fetch', { - year, - periodo: periodoActual, - siniestros: dataS.length, - involucrados: dataI.length, - personas: dataP.length, - }) - setSiniestros(dataS) setInvolucrados(dataI) setPersonas(dataP) + setSiniestrosMapa(dataM) } catch (err) { setError(err?.message || String(err)) console.error('useData error', err) @@ -87,5 +91,5 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) { fetchData() }, [fetchData]) - return { siniestros, involucrados, personas, loading, error, refetch: fetchData } + return { siniestros, involucrados, personas, siniestrosMapa, loading, error, refetch: fetchData } } \ No newline at end of file diff --git a/src/index.css b/src/index.css index 8b6c9ee..02cf3b7 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,7 @@ @import "tailwindcss"; +@import 'leaflet/dist/leaflet.css'; +@import 'leaflet.markercluster/dist/MarkerCluster.css'; +@import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; @theme { /* Colores de marca */ @@ -30,7 +33,7 @@ html.dark { --color-opsv-text: #e2e8f0; --color-opsv-muted: #94a3b8; --color-opsv-faint: #64748b; - --color-opsv-navy: #a8b4ff; + --color-opsv-navy: #ffffff; } @layer base { @@ -57,6 +60,14 @@ html.dark { color: theme(--color-opsv-navy); margin: 0; } + html.dark h1, + html.dark h2, + html.dark h3, + html.dark h4, + html.dark h5, + html.dark h6 { + color: #ffffff; + } /* ── Override de colores modernos para captura html2canvas ── */ [data-pdf-render="true"], [data-pdf-render="true"] * { diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 7034885..f0e0b19 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -14,10 +14,12 @@ import SecLesionados from './SecLesionados' import SecSinLesiones from './SecSinLesiones' import SecSintesis from './SecSintesis' import SecVeranoVivo from './SecVeranoVivo' -import { useState, useMemo, useEffect, useRef } from 'react' +import SecMapa from './SecMapa' // ← NUEVO +import { useState, useMemo, useEffect } from 'react' import PdfExportModal from '../components/ui/PdfExportModal' + function SectionPlaceholder({ title, description }) { return (
@@ -31,6 +33,7 @@ function SectionPlaceholder({ title, description }) { } + function AdminFooter() { const { user, isAdmin } = useAuth() const navigate = useNavigate() @@ -52,16 +55,19 @@ function AdminFooter() { } -export default function Dashboard() { - const [seccion, setSeccion] = useState('resumen') - const [year, setYear] = useState(2025) - const [periodo, setPeriodo] = useState({ desde: null, hasta: null }) - const [departamentoFiltro, setDepartamentoFiltro] = useState('') - const [localidadFiltro, setLocalidadFiltro] = useState('') - const [modalPdf, setModalPdf] = useState(false) - // Hook principal — año seleccionado por el usuario - const { siniestros, personas, involucrados, loading, error } = useData(year, periodo) +export default function Dashboard() { + // ── Estados ────────────────────────────────────────────────────────────── + const [seccion, setSeccion] = useState('resumen') + const [year, setYear] = useState(2025) + const [periodo, setPeriodo] = useState({ desde: null, hasta: null }) + const [departamentoFiltro, setDepartamentoFiltro] = useState('') + const [localidadFiltro, setLocalidadFiltro] = useState('') + const [zonaFiltro, setZonaFiltro] = useState('') + const [modalPdf, setModalPdf] = useState(false) + + // ── Hook principal — año seleccionado por el usuario ───────────────────── + const { siniestros, personas, involucrados, siniestrosMapa, loading, error } = useData(year, periodo) // ── Departamentos disponibles para el año/período activo ───────────────── const departamentosDisponibles = useMemo(() => { @@ -78,10 +84,18 @@ export default function Dashboard() { return [...new Set(base.map(s => s.localidad).filter(Boolean))].sort() }, [siniestros, departamentoFiltro]) + // ── Zonas disponibles ───────────────────────────────────────────────────── + const zonasDisponibles = useMemo(() => { + return [...new Set( + (siniestros ?? []).map(s => s.zona_ocurrencia).filter(Boolean) + )].sort() + }, [siniestros]) + // ── Reset en cascada: año/período → limpia todo ────────────────────────── useEffect(() => { setDepartamentoFiltro('') setLocalidadFiltro('') + setZonaFiltro('') }, [year, periodo]) // ── Reset en cascada: departamento → limpia localidad ─────────────────── @@ -89,25 +103,37 @@ export default function Dashboard() { setLocalidadFiltro('') }, [departamentoFiltro]) - // ── Array final con ambos filtros aplicados ────────────────────────────── + // ── Array final con todos los filtros aplicados ────────────────────────── const siniestrosFiltrados = useMemo(() => { let result = siniestros ?? [] if (departamentoFiltro) result = result.filter(s => s.departamento === departamentoFiltro) - if (localidadFiltro) result = result.filter(s => s.localidad === localidadFiltro) + if (localidadFiltro) result = result.filter(s => s.localidad === localidadFiltro) + if (zonaFiltro) result = result.filter(s => s.zona_ocurrencia === zonaFiltro) return result - }, [siniestros, departamentoFiltro, localidadFiltro]) - - // ── Verano Vivo: datos históricos de campañas anteriores ───────────────── + }, [siniestros, departamentoFiltro, localidadFiltro, zonaFiltro]) + + // ── Víctimas fatales del período filtrado (igual que SecFatales) ────────── + const victimasFatalesFiltradas = useMemo( + () => siniestrosFiltrados.reduce( + (acc, s) => acc + (Number(s.fallecidos) || 0), + 0 + ), + [siniestrosFiltrados] + ) + // ── Verano Vivo: carga fija de todos los años, independiente del filtro ── const { siniestros: sinVV2022 } = useData(2022, { desde: null, hasta: null }) const { siniestros: sinVV2023 } = useData(2023, { desde: null, hasta: null }) const { siniestros: sinVV2024 } = useData(2024, { desde: null, hasta: null }) + const { siniestros: sinVV2025 } = useData(2025, { desde: null, hasta: null }) + const { siniestros: sinVV2026 } = useData(2026, { desde: null, hasta: null }) const siniestrosVV = useMemo(() => { const all = [ ...(sinVV2022 ?? []), ...(sinVV2023 ?? []), ...(sinVV2024 ?? []), - ...(siniestros ?? []), + ...(sinVV2025 ?? []), + ...(sinVV2026 ?? []), ] const seen = new Set() return all.filter(s => { @@ -117,9 +143,35 @@ export default function Dashboard() { seen.add(id) return true }) - }, [sinVV2022, sinVV2023, sinVV2024, siniestros]) + }, [sinVV2022, sinVV2023, sinVV2024, sinVV2025, sinVV2026]) + + // ── Personas VV — carga fija independiente del filtro ──────────────────── + const { personas: persVV2022 } = useData(2022, { desde: null, hasta: null }) + const { personas: persVV2023 } = useData(2023, { desde: null, hasta: null }) + const { personas: persVV2024 } = useData(2024, { desde: null, hasta: null }) + const { personas: persVV2025 } = useData(2025, { desde: null, hasta: null }) + const { personas: persVV2026 } = useData(2026, { desde: null, hasta: null }) + + const personasVV = useMemo(() => { + const all = [ + ...(persVV2022 ?? []), + ...(persVV2023 ?? []), + ...(persVV2024 ?? []), + ...(persVV2025 ?? []), + ...(persVV2026 ?? []), + ] + const seen = new Set() + return all.filter(p => { + const id = p.id_feu + if (id == null) return true + if (seen.has(id)) return false + seen.add(id) + return true + }) + }, [persVV2022, persVV2023, persVV2024, persVV2025, persVV2026]) // ───────────────────────────────────────────────────────────────────────── + return (
@@ -138,6 +190,9 @@ export default function Dashboard() { localidadFiltro={localidadFiltro} setLocalidadFiltro={setLocalidadFiltro} localidadesDisponibles={localidadesDisponibles} + zonaFiltro={zonaFiltro} + setZonaFiltro={setZonaFiltro} + zonasDisponibles={zonasDisponibles} onExportarPdf={() => setModalPdf(true)} /> @@ -146,7 +201,12 @@ export default function Dashboard() { {loading ? ( ) : seccion === 'resumen' ? ( - + ) : seccion === 'historica' ? ( ) : seccion === 'fatales' ? ( @@ -156,9 +216,19 @@ export default function Dashboard() { ) : seccion === 'sinlesiones' ? ( ) : seccion === 'sintesis' ? ( - + + + ) : seccion === 'mapa' ? ( + ) : seccion === 'veranovivo' ? ( - + ) : ( - {/* ── Contenedor oculto para captura PDF ─────────────────────────── */} + + {/* ── Contenedor oculto para captura PDF ─────────────────────────── */} {/* ────────────────────────────────────────────────────────────────── */} @@ -215,5 +297,4 @@ export default function Dashboard() { )}
) -} - \ No newline at end of file +} \ No newline at end of file diff --git a/src/pages/SecFatales.jsx b/src/pages/SecFatales.jsx index 02e2b56..7764727 100644 --- a/src/pages/SecFatales.jsx +++ b/src/pages/SecFatales.jsx @@ -5,16 +5,16 @@ import PorTipoSiniestro from '../components/charts/PorTipoSiniestro' import FranjaHoraria from '../components/charts/FranjaHoraria' import PorLocalidad from '../components/charts/PorLocalidad' import PerfilVictimas from '../components/charts/PerfilVictimas' -import ProteccionPersonas from '../components/charts/ProteccionPersonas' import ZonaOcurrencia from '../components/charts/ZonaOcurrencia' import ChartCard from '../components/ui/ChartCard' +import { filtrarPersonasPorSiniestros } from '../utils/calculos' const SECTION_COLORS = { - total: '#252C61', + total: '#252C61', fatales: '#C44228', - victimas: '#922B21', - urbano: '#80B0DE', - rural: '#337C58', + victimas:'#922B21', + urbano: '#80B0DE', + rural: '#337C58', } export default function SecFatales({ siniestros, personas, involucrados }) { @@ -23,22 +23,29 @@ export default function SecFatales({ siniestros, personas, involucrados }) { [siniestros], ) - const total = filtrarFatales.length + const total = filtrarFatales.length const victimas = filtrarFatales.reduce( (acc, s) => acc + Number((s.cantidad_fallecidos ?? s.fallecidos) || 0), 0, ) const diarios = total ? (total / 365).toFixed(1) : '0.0' - const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%' + const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%' + + // ← NUEVO: personas acotadas a los siniestros fatales filtrados + const personasFiltradas = useMemo( + () => filtrarPersonasPorSiniestros(personas, filtrarFatales), + [personas, filtrarFatales], + ) return (
+ {/* KPIs */}
- - - - + + + +
{/* Evolución y tipo de siniestro */} @@ -65,8 +72,8 @@ export default function SecFatales({ siniestros, personas, involucrados }) { {/* Franja horaria */} @@ -100,18 +107,13 @@ export default function SecFatales({ siniestros, personas, involucrados }) { subtitle="Distribución de las víctimas fatales según edad, género y Tipo de Vehículo." height="lg" > - + - {/* Seguridad pasiva */} - - -
) } \ No newline at end of file diff --git a/src/pages/SecHistorica.jsx b/src/pages/SecHistorica.jsx index b5d0a7d..0bcb674 100644 --- a/src/pages/SecHistorica.jsx +++ b/src/pages/SecHistorica.jsx @@ -8,6 +8,7 @@ import PorLocalidad from '../components/charts/PorLocalidad' import PorTipoSiniestro from '../components/charts/PorTipoSiniestro' import FranjaHoraria from '../components/charts/FranjaHoraria' import KPICard from '../components/ui/KPICard' +import ChartCard from '../components/ui/ChartCard' import { BarChart, Bar, @@ -31,29 +32,20 @@ export default function SecHistorica({ siniestros, year }) { const yearNum = Number(year) const kpis = calcularKPIs(siniestros) - // Hook se usa DENTRO del componente const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } = useChartTheme() const serieComparativa = useMemo(() => { const base = [...SERIE_HISTORICA] const yaExiste = base.some((row) => row.ano === yearNum) - if (!yaExiste && kpis.total > 0) { const pob = getPoblacionAnual(yearNum) const tasa = Number(((kpis.victimas / pob) * 100000).toFixed(2)) - return [ ...base, - { - ano: yearNum, - siniestros: kpis.total, - victimas: kpis.victimas, - tasa, - }, + { ano: yearNum, siniestros: kpis.total, victimas: kpis.victimas, tasa }, ].sort((a, b) => a.ano - b.ano) } - return base }, [yearNum, kpis.total, kpis.victimas]) @@ -68,8 +60,7 @@ export default function SecHistorica({ siniestros, year }) { ) const victimasPorAno = useMemo( - () => - serieComparativa.map((row) => ({ ano: row.ano, victimas: row.victimas })), + () => serieComparativa.map((row) => ({ ano: row.ano, victimas: row.victimas })), [serieComparativa], ) @@ -77,20 +68,9 @@ export default function SecHistorica({ siniestros, year }) { const serieParaExtremos = useMemo(() => { const base = [...SERIE_HISTORICA] - const existe2025 = base.some((row) => row.ano === 2025) - if (!existe2025) { - base.push({ - ano: 2025, - siniestros: 0, - victimas: 21, - tasa: 6.27, - }) - } - - return base - .filter((row) => row.ano !== 2020 && row.victimas != null) - .sort((a, b) => a.ano - b.ano) + if (!existe2025) base.push({ ano: 2025, siniestros: 0, victimas: 21, tasa: 6.27 }) + return base.filter((row) => row.ano !== 2020 && row.victimas != null).sort((a, b) => a.ano - b.ano) }, []) const victimasActual = yearActualData?.victimas ?? kpis.victimas ?? null @@ -104,128 +84,87 @@ export default function SecHistorica({ siniestros, year }) { (row) => row.ano !== 2020 && row.victimas != null, ) - const maxEntry = - serieParaExtremos.length > 0 - ? serieParaExtremos.reduce( - (max, row) => (max == null || row.victimas > max.victimas ? row : max), - null, - ) - : null + const maxEntry = serieParaExtremos.length > 0 + ? serieParaExtremos.reduce((max, row) => (max == null || row.victimas > max.victimas ? row : max), null) + : null const maxHistorico = maxEntry?.victimas ?? null const maxAno = maxEntry?.ano ?? null - const minEntry = - serieParaExtremos.length > 0 - ? serieParaExtremos.reduce( - (min, row) => (min == null || row.victimas < min.victimas ? row : min), - null, - ) - : null + const minEntry = serieParaExtremos.length > 0 + ? serieParaExtremos.reduce((min, row) => (min == null || row.victimas < min.victimas ? row : min), null) + : null const minHistorico = minEntry?.victimas ?? null const minAno = minEntry?.ano ?? null const serieOrdenada = [...serieVictimasValidas].sort((a, b) => a.ano - b.ano) const ultimos10 = serieOrdenada.filter((row) => row.ano <= yearNum).slice(-10) - const promedio10 = - ultimos10.length > 0 - ? ( - ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length - ).toFixed(1) - : null + const promedio10 = ultimos10.length > 0 + ? (ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length).toFixed(1) + : null const rango10Desde = ultimos10[0]?.ano ?? null const rango10Hasta = ultimos10[ultimos10.length - 1]?.ano ?? null return ( -
- {/* KPIs históricas */} +
+ + {/* KPIs */}
- {/* Gráfico de tasa + barras de víctimas */} -
- -
-
-

- Víctimas fatales -

-

- Evolución anual -

-
+ {/* Serie histórica + barras de víctimas */} +
+ + + + +
- - - - + + + + +
+ +

+ Nota: el año 2020 no se incluye en la + serie histórica. Las restricciones de circulación por la pandemia de COVID-19 + generaron una reducción atípica de la movilidad vehicular, por lo que los datos + no son comparables con el resto de la serie. +

-
+
+
- {/* Distribución por tipo, franja y localidad */} + {/* Tipo, franja y localidad */}
- - - + + + + + + + + + + +
{/* Tabla histórica */} @@ -261,24 +232,13 @@ export default function SecHistorica({ siniestros, year }) {
- +
- - - - + + + + @@ -286,21 +246,14 @@ export default function SecHistorica({ siniestros, year }) { r.ano === yearNum) + row.ano === yearNum && !SERIE_HISTORICA.find((r) => r.ano === yearNum) ? 'bg-blue-500/5 font-semibold' : '' }`} > - - - + + + ))} @@ -308,6 +261,7 @@ export default function SecHistorica({ siniestros, year }) {
- Año - - Siniestros - - Víctimas - - Tasa - AñoSiniestrosVíctimasTasa
- {row.ano} - - {row.siniestros} - - {row.victimas ?? '—'} - {row.ano}{row.siniestros}{row.victimas ?? '—'} {row.tasa ?? '—'}
+
) } \ No newline at end of file diff --git a/src/pages/SecLesionados.jsx b/src/pages/SecLesionados.jsx index 5420f6e..717e19f 100644 --- a/src/pages/SecLesionados.jsx +++ b/src/pages/SecLesionados.jsx @@ -5,40 +5,116 @@ import PorTipoSiniestro from '../components/charts/PorTipoSiniestro' import FranjaHoraria from '../components/charts/FranjaHoraria' import PorLocalidad from '../components/charts/PorLocalidad' import PerfilVictimas from '../components/charts/PerfilVictimas' -import ProteccionPersonas from '../components/charts/ProteccionPersonas' import ZonaOcurrencia from '../components/charts/ZonaOcurrencia' import ChartCard from '../components/ui/ChartCard' +import { calcularLesionadosPorGravedad, filtrarPersonasPorSiniestros } from '../utils/calculos' + export default function SecLesionados({ siniestros, personas, involucrados }) { const filtrarLesionados = useMemo( () => siniestros.filter((s) => { const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos) - const lesion = Number(s.cantidad_lesionados ?? s.heridos) + const lesion = Number(s.cantidad_lesionados ?? s.heridos) return fatales === 0 && lesion > 0 }), [siniestros], ) - const total = filtrarLesionados.length - const lesionados = filtrarLesionados.reduce( - (acc, s) => acc + Number((s.cantidad_lesionados ?? s.heridos) || 0), - 0, - ) + const total = filtrarLesionados.length const diarios = total ? (total / 365).toFixed(1) : '0.0' - const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%' + const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%' + + const { graves, leves } = useMemo( + () => calcularLesionadosPorGravedad(filtrarLesionados, personas), + [filtrarLesionados, personas], + ) + const totalPersonas = graves + leves + const pctGraves = totalPersonas ? Math.round((graves / totalPersonas) * 100) : 0 + const pctLeves = totalPersonas ? Math.round((leves / totalPersonas) * 100) : 0 + + const personasFiltradas = useMemo( + () => filtrarPersonasPorSiniestros(personas, filtrarLesionados), + [personas, filtrarLesionados], + ) return (
- {/* KPIs */} + + {/* ── KPIs ── */}
- - - - + + + +
- {/* Evolución y tipo de siniestro */} + {/* ── Bloque desglose graves / leves ── */} +
+

+ Personas lesionadas · gravedad +

+

+ Proporción de Heridos graves y heridos leves +

+ + {totalPersonas === 0 ? ( +

Sin datos de gravedad para el periodo seleccionado.

+ ) : ( + <> + {/* Barra proporcional */} +
+
+ {pctGraves >= 10 ? `${pctGraves}%` : ''} +
+
+ {pctLeves >= 10 ? `${pctLeves}%` : ''} +
+
+ + {/* Tarjetas de detalle */} +
+ + {/* Heridos graves */} +
+
+
+

{graves}

+

+ Heridos graves — {pctGraves}% del total +

+

+ Personas con lesiones de gravedad que requirieron atención médica de mayor complejidad. +

+
+
+ + {/* Heridos leves */} +
+
+
+

{leves}

+

+ Heridos leves — {pctLeves}% del total +

+

+ Personas con lesiones de menor gravedad atendidas en el lugar o con derivación ambulatoria. +

+
+
+ +
+ + )} +
+ + {/* ── Evolución y tipo de siniestro ── */}
- {/* Franja horaria */} + {/* ── Franja horaria ── */} - {/* Distribución territorial */} + {/* ── Distribución territorial ── */}
- {/* Perfil de víctimas */} + {/* ── Perfil de víctimas ── */} - + - {/* Seguridad pasiva */} - - -
) } \ No newline at end of file diff --git a/src/pages/SecMapa.jsx b/src/pages/SecMapa.jsx new file mode 100644 index 0000000..39e5041 --- /dev/null +++ b/src/pages/SecMapa.jsx @@ -0,0 +1,173 @@ +import { useMemo, useState, useEffect } from 'react' +import ChartCard from '../components/ui/ChartCard' +import KPICard from '../components/ui/KPICard' +import MapaSiniestros from '../components/charts/MapaSiniestros' + + +const FILTROS_GRAVEDAD = [ + { value: 'todos', label: 'Todos' }, + { value: 'fatal', label: 'Fatales' }, + { value: 'lesionado', label: 'Lesionados' }, + { value: 'sinlesiones', label: 'Sin lesiones' }, +] + + +function getGravedad(s) { + const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos ?? 0) + const lesion = Number(s.cantidad_lesionados ?? s.heridos ?? 0) + if (fatales > 0) return 'fatal' + if (lesion > 0) return 'lesionado' + return 'sinlesiones' +} + + +export default function SecMapa({ siniestros, siniestrosMapa }) { + const [vista, setVista] = useState('marcadores') + const [gravedad, setGravedad] = useState('todos') + const [darkMode, setDarkMode] = useState(false) + + useEffect(() => { + const check = () => + setDarkMode(document.documentElement.classList.contains('dark')) + check() + const observer = new MutationObserver(check) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }) + return () => observer.disconnect() + }, []) + + const { conCoords, sinCoords, pctCobertura } = useMemo(() => { + const fuente = siniestrosMapa ?? [] + const conCoords = fuente.filter( + (s) => + s.latitud_norm != null && + s.longitud_norm != null && + !isNaN(Number(s.latitud_norm)) && + !isNaN(Number(s.longitud_norm)) && + Math.abs(Number(s.latitud_norm)) <= 90 && + Math.abs(Number(s.longitud_norm)) <= 180 + ).length + const sinCoords = siniestros.length - conCoords + const pctCobertura = siniestros.length + ? Math.round((conCoords / siniestros.length) * 100) + : 0 + return { conCoords, sinCoords, pctCobertura } + }, [siniestros, siniestrosMapa]) + + const siniestrosFiltrados = useMemo(() => { + if (gravedad === 'todos') return siniestros + return siniestros.filter((s) => getGravedad(s) === gravedad) + }, [siniestros, gravedad]) + + const siniestrosMapaFiltrados = useMemo(() => { + if (gravedad === 'todos') return siniestrosMapa ?? [] + const idsValidos = new Set(siniestrosFiltrados.map((s) => s.id_feu)) + return (siniestrosMapa ?? []).filter((s) => idsValidos.has(s.id_feu)) + }, [siniestrosMapa, siniestrosFiltrados, gravedad]) + + const btnBase = 'px-4 py-2 rounded-2xl text-sm font-semibold transition' + const btnActive = 'bg-opsv-navy dark:bg-slate-600 text-white' + const btnInactive = 'bg-opsv-bg dark:bg-slate-800 text-opsv-muted dark:text-slate-400 border border-opsv-border dark:border-slate-700 hover:text-opsv-navy dark:hover:text-white' + + return ( +
+ + {/* ── KPIs de cobertura ── */} +
+ + + +
+ + {/* ── Aviso si hay siniestros sin coords ── */} + {sinCoords > 0 && ( +
+ ⚠️ {sinCoords} siniestro{sinCoords > 1 ? 's' : ''} no {sinCoords > 1 ? 'tienen' : 'tiene'} coordenadas registradas y no {sinCoords > 1 ? 'aparecen' : 'aparece'} en el mapa. +
+ )} + + {/* ── Mapa ── */} +
+ + {/* Controles */} +
+ + {/* Toggle vista */} +
+ + Vista + +
+ {[ + { value: 'marcadores', label: 'Marcadores' }, + { value: 'calor', label: 'Mapa de calor' }, + ].map((op) => ( + + ))} +
+
+ + {/* Filtro gravedad */} +
+ + Gravedad + +
+ {FILTROS_GRAVEDAD.map((op) => ( + + ))} +
+
+
+ + {/* Leyenda */} + {vista === 'marcadores' && ( +
+ {[ + { color: '#C44228', label: 'Fatal' }, + { color: '#E8881A', label: 'Lesionado' }, + { color: '#6B7280', label: 'Sin lesiones' }, + ].map((item) => ( +
+
+ {item.label} +
+ ))} +
+ )} + + {/* Mapa */} + + +
+ +
+ ) +} \ No newline at end of file diff --git a/src/pages/SecResumen.jsx b/src/pages/SecResumen.jsx index 453af9e..3cfd9a6 100644 --- a/src/pages/SecResumen.jsx +++ b/src/pages/SecResumen.jsx @@ -1,45 +1,82 @@ +import { useMemo } from 'react' import KPICard from '../components/ui/KPICard' import SiniestrosPorMes from '../components/charts/SiniestrosPorMes' import DonutGravedad from '../components/charts/DonutGravedad' import ZonaOcurrencia from '../components/charts/ZonaOcurrencia' -import { - calcularKPIs, - calcularTablaComparativa, - POBLACION_DEPTO, -} from '../utils/calculos' +import ProteccionPersonas from '../components/charts/ProteccionPersonas' +import ChartCard from '../components/ui/ChartCard' +import { calcularKPIs, calcularTablaComparativa, calcularLesionadosPorGravedad, filtrarPersonasPorSiniestros } from '../utils/calculos' -export default function SecResumen({ siniestros }) { - const kpis = calcularKPIs(siniestros) - const tablaData = calcularTablaComparativa(siniestros) - - console.log( - 'Departamentos en BD:', - [...new Set(siniestros.map((s) => s.departamento))] +export default function SecResumen({ siniestros, personas, involucrados }) { + const kpis = useMemo(() => calcularKPIs(siniestros), [siniestros]) + const tablaData = useMemo(() => calcularTablaComparativa(siniestros), [siniestros]) + const lesionados = useMemo( + () => calcularLesionadosPorGravedad(siniestros, personas), + [siniestros, personas] ) - console.log( - 'SecResumen recibe siniestros:', - siniestros.length, - siniestros[0]?.ano, - siniestros[0]?.mes + const personasFiltradas = useMemo( + () => filtrarPersonasPorSiniestros(personas, siniestros), + [personas, siniestros] ) - return ( -
-
- - - - - +
+ + {/* ── Fila 1: KPIs de siniestros ── */} +
+

+ Siniestros +

+
+ + + + +
-
- - + {/* ── Fila 2: KPIs de personas ── */} +
+

+ Personas involucradas +

+
+ + + +
-
- + {/* Evolución y gravedad */} +
+ + + + + + + +
+ + {/* Zona y tabla comparativa */} +
+ + +
@@ -74,7 +111,6 @@ export default function SecResumen({ siniestros }) { - {tablaData.map((row, i) => (
+ + {/* Seguridad pasiva */} + + + +
) } \ No newline at end of file diff --git a/src/pages/SecSinLesiones.jsx b/src/pages/SecSinLesiones.jsx index 7cde7fd..0c7ec07 100644 --- a/src/pages/SecSinLesiones.jsx +++ b/src/pages/SecSinLesiones.jsx @@ -8,29 +8,37 @@ import TipoInvolucrado from '../components/charts/TipoInvolucrado' import ZonaOcurrencia from '../components/charts/ZonaOcurrencia' import ChartCard from '../components/ui/ChartCard' + export default function SecSinLesiones({ siniestros, involucrados }) { const filtrarSinLesiones = useMemo( () => siniestros.filter((s) => { const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos) - const lesion = Number(s.cantidad_lesionados ?? s.heridos) + const lesion = Number(s.cantidad_lesionados ?? s.heridos) return fatales === 0 && lesion === 0 }), [siniestros], ) - const total = filtrarSinLesiones.length + const total = filtrarSinLesiones.length const diarios = total ? (total / 365).toFixed(1) : '0.0' - const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%' - const ilesos = filtrarSinLesiones.reduce((acc, s) => acc + Number(s.ilesos || 0), 0) + const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%' + const ilesos = filtrarSinLesiones.reduce((acc, s) => acc + Number(s.ilesos || 0), 0) + + // ← NUEVO: involucrados acotados a los siniestros sin lesiones filtrados + const involucradosFiltrados = useMemo(() => { + const ids = new Set(filtrarSinLesiones.map(s => s.id_feu).filter(Boolean)) + return (involucrados ?? []).filter(i => ids.has(i.id_feu)) + }, [involucrados, filtrarSinLesiones]) return (
+ {/* KPIs */}
- - - + + +
{/* Evolución y tipo */} @@ -57,8 +65,8 @@ export default function SecSinLesiones({ siniestros, involucrados }) { {/* Franja horaria */} @@ -93,8 +101,9 @@ export default function SecSinLesiones({ siniestros, involucrados }) { height="auto" className="mx-auto w-full max-w-4xl" > - + {/* ← NUEVO */} +
) } \ No newline at end of file diff --git a/src/pages/SecSintesis.jsx b/src/pages/SecSintesis.jsx index a1635df..e50690b 100644 --- a/src/pages/SecSintesis.jsx +++ b/src/pages/SecSintesis.jsx @@ -1,16 +1,15 @@ import { useMemo } from 'react' -import { calcularSintesis } from '../utils/calculos' +import { calcularSintesis, calcularLesionadosPorGravedad } from '../utils/calculos' import { SERIE_HISTORICA } from '../components/charts/SerieHistorica' import ChartCard from '../components/ui/ChartCard' import { COLOR } from '../utils/colores' + // ── Ficha compacta vertical ─────────────────────────────────────────────────── function Ficha({ kicker, title, color, destacado, datos }) { return (
- - {/* Dato destacado */}
- - {/* Grilla de datos secundarios */}
{datos.map(({ label, valor }) => (
@@ -32,36 +29,65 @@ function Ficha({ kicker, title, color, destacado, datos }) {
))}
-
) } + // ── Componente principal ────────────────────────────────────────────────────── -export default function SecSintesis({ siniestros, personas, involucrados }) { +export default function SecSintesis({ siniestros, personas, involucrados, year, victimasActual, siniestrosActual }) { const s = useMemo( () => calcularSintesis(siniestros, personas, involucrados), [siniestros, personas, involucrados], ) - // Serie válida: excluye 2020, agrega 2025 si no está, ordena por año + const lesionados = useMemo( + () => calcularLesionadosPorGravedad(siniestros, personas), + [siniestros, personas], + ) + + // ── SERIE HISTÓRICA: completa, con el año actual actualizado ───────────── const serieValida = useMemo(() => { const base = [...SERIE_HISTORICA] - const existe2025 = base.some((r) => r.ano === 2025) - if (!existe2025) { - base.push({ ano: 2025, siniestros: 0, victimas: 21, tasa: 6.27 }) + + if (year && victimasActual != null) { + const idx = base.findIndex((r) => r.ano === year) + if (idx >= 0) { + // Año ya existe en la serie (ej: 2024) → actualizarlo con datos reales + base[idx] = { + ...base[idx], + siniestros: siniestrosActual ?? base[idx].siniestros, + victimas: victimasActual, + } + } else { + // Año nuevo (ej: 2025 en adelante) → agregarlo + base.push({ + ano: year, + siniestros: siniestrosActual ?? 0, + victimas: victimasActual, + tasa: null, + }) + } } + return base .filter((r) => r.ano !== 2020 && r.victimas != null) .sort((a, b) => a.ano - b.ano) - }, []) + }, [year, victimasActual, siniestrosActual]) + // ───────────────────────────────────────────────────────────────────────── + // ── Derivados de la serie ───────────────────────────────────────────────── const anoMaxVictimas = serieValida.reduce((a, b) => (a.victimas > b.victimas ? a : b)) const anoMinVictimas = serieValida.reduce((a, b) => (a.victimas < b.victimas ? a : b)) - const ultimoAno = serieValida[serieValida.length - 1] - const penultimoAno = serieValida[serieValida.length - 2] + // ultimoAno = el año seleccionado (o el último de la serie si no hay filtro) + const ultimoAno = year + ? (serieValida.find((r) => r.ano === year) ?? serieValida[serieValida.length - 1]) + : serieValida[serieValida.length - 1] + + // penultimoAno = el año inmediatamente anterior al seleccionado en la serie + const penultimoAno = serieValida[serieValida.indexOf(ultimoAno) - 1] ?? null const tendencia = ultimoAno && penultimoAno @@ -71,26 +97,28 @@ export default function SecSintesis({ siniestros, personas, involucrados }) { const tendenciaColor = tendencia.includes('▲') ? COLOR.fatales : COLOR.green const pctVariacion = - ultimoAno && penultimoAno + ultimoAno && penultimoAno && penultimoAno.victimas > 0 ? (((ultimoAno.victimas - penultimoAno.victimas) / penultimoAno.victimas) * 100).toFixed(1) : null + // ───────────────────────────────────────────────────────────────────────── const fichas = [ - { kicker: 'Análisis por tipo', title: 'Siniestros fatales', color: COLOR.fatales, bloque: s.fatalesBloque }, - { kicker: 'Análisis por tipo', title: 'Con lesionados', color: COLOR.conLes, bloque: s.conLesBloque }, - { kicker: 'Análisis por tipo', title: 'Sin lesiones', color: COLOR.sinLes, bloque: s.sinLesBloque }, + { kicker: 'Análisis por tipo', title: 'Siniestros fatales', color: COLOR.fatales, bloque: s.fatalesBloque, + extraDatos: [] }, + { kicker: 'Análisis por tipo', title: 'Con lesionados', color: COLOR.conLes, bloque: s.conLesBloque, + extraDatos: [ + { label: 'Heridos graves', valor: lesionados.graves }, + { label: 'Heridos leves', valor: lesionados.leves }, + ]}, + { kicker: 'Análisis por tipo', title: 'Sin lesiones', color: COLOR.sinLes, bloque: s.sinLesBloque, + extraDatos: [] }, ] return (
{/* ── Siniestralidad general ── */} - +
{[ { label: 'Total siniestros', valor: s.kpis.total, color: COLOR.navy }, @@ -107,36 +135,60 @@ export default function SecSintesis({ siniestros, personas, involucrados }) {
))}
+ + {/* ── Desglose graves/leves ── */} + {(lesionados.graves > 0 || lesionados.leves > 0) && (() => { + const total = lesionados.graves + lesionados.leves + const pctG = total ? Math.round((lesionados.graves / total) * 100) : 0 + const pctL = total ? Math.round((lesionados.leves / total) * 100) : 0 + return ( +
+

+ Desglose de personas lesionadas +

+
+
+ {pctG >= 12 ? `${pctG}%` : ''} +
+
+ {pctL >= 12 ? `${pctL}%` : ''} +
+
+
+ + + {lesionados.graves} heridos graves ({pctG}%) + + + + {lesionados.leves} heridos leves ({pctL}%) + +
+
+ ) + })()} {/* ── Contexto histórico ── */} - +

{anoMaxVictimas.ano}

-

- Año más crítico ({anoMaxVictimas.victimas} víctimas) -

+

Año más crítico ({anoMaxVictimas.victimas} víctimas)

{anoMinVictimas.ano}

-

- Año más bajo ({anoMinVictimas.victimas} víctimas) -

+

Año más bajo ({anoMinVictimas.victimas} víctimas)

-

- {penultimoAno?.victimas ?? '—'} -

-

- Víctimas {penultimoAno?.ano ?? '—'} -

+

{penultimoAno?.victimas ?? '—'}

+

Víctimas {penultimoAno?.ano ?? '—'}

@@ -146,15 +198,15 @@ export default function SecSintesis({ siniestros, personas, involucrados }) { )}

- Tendencia {penultimoAno?.ano}→{ultimoAno?.ano} + Tendencia {penultimoAno?.ano ?? '—'}→{ultimoAno?.ano ?? '—'}

- {/* ── Fichas por tipo: 3 columnas en desktop ── */} + {/* ── Fichas por tipo ── */}
- {fichas.map(({ kicker, title, color, bloque }) => ( + {fichas.map(({ kicker, title, color, bloque, extraDatos }) => ( ))} diff --git a/src/pages/SecVeranoVivo.jsx b/src/pages/SecVeranoVivo.jsx index 39795cd..e61f5c3 100644 --- a/src/pages/SecVeranoVivo.jsx +++ b/src/pages/SecVeranoVivo.jsx @@ -1,5 +1,3 @@ - -SecVeranoVivo import { useState, useMemo } from 'react' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, @@ -9,10 +7,11 @@ import { import ChartCard from '../components/ui/ChartCard' import { COLOR } from '../utils/colores' import { - HISTORICO_VERANO_VIVO, PROMEDIO_HISTORICO_VV, CAMPANAS_VV, + CAMPANAS_VV, filtrarCampanaVV, kpisVV, ruralUrbanoPorCampana, distribucionMensualVV, rankingRutas, rankingLocalidades, - tiposSiniestroVV, + tiposSiniestroVV, generarHistoricoVV, calcularPromedioHistoricoVV, + calcularLesionadosPorGravedad, } from '../utils/calculos' @@ -46,10 +45,11 @@ const CustomPieLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, pct }) => return ( {`${pct.toFixed(0)}%`} @@ -59,109 +59,92 @@ const CustomPieLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, pct }) => // ── INSIGHTS ──────────────────────────────────────────────── -function calcularInsights(campanaActual, kpis, graficoC, graficoD, graficoF) { +function calcularInsights(campanaActual, kpis, graficoC, graficoD, graficoF, promedioHistorico, historicoVV, lesionados) { const insights = [] - // 1. Fatales vs promedio histórico const fatalesActual = kpis.fatales - const diff = fatalesActual - PROMEDIO_HISTORICO_VV + const diff = fatalesActual - promedioHistorico if (fatalesActual === 0) { - insights.push({ - tipo: 'logro', - texto: `La campaña ${campanaActual.label} finalizó sin víctimas fatales en rutas y caminos.`, - }) + insights.push({ tipo: 'logro', texto: `La campaña ${campanaActual.label} finalizó sin víctimas fatales en rutas y caminos.` }) } else if (diff < 0) { - insights.push({ - tipo: 'logro', - texto: `Con ${fatalesActual} víctima${fatalesActual !== 1 ? 's' : ''} fatal${fatalesActual !== 1 ? 'es' : ''} en ruta, la campaña se mantuvo por debajo del promedio histórico de ${PROMEDIO_HISTORICO_VV}.`, - }) + insights.push({ tipo: 'logro', texto: `Con ${fatalesActual} víctima${fatalesActual !== 1 ? 's' : ''} fatal${fatalesActual !== 1 ? 'es' : ''} en ruta, la campaña se mantuvo por debajo del promedio histórico de ${promedioHistorico}.` }) } else if (diff === 0) { - insights.push({ - tipo: 'neutro', - texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, igual al promedio histórico de ${PROMEDIO_HISTORICO_VV}.`, - }) + insights.push({ tipo: 'neutro', texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, igual al promedio histórico de ${promedioHistorico}.` }) } else { - insights.push({ - tipo: 'alerta', - texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, superando el promedio histórico de ${PROMEDIO_HISTORICO_VV}.`, - }) + insights.push({ tipo: 'alerta', texto: `La campaña registró ${fatalesActual} víctimas fatales en ruta, superando el promedio histórico de ${promedioHistorico}.` }) } - // 2. Reducción vs 2015/16 (pico inicial) - const fatalesPico = HISTORICO_VERANO_VIVO[0].fatales + if (!historicoVV || historicoVV.length === 0) return insights + const fatalesPico = historicoVV[0].fatales if (fatalesActual < fatalesPico) { const reduccion = Math.round(((fatalesPico - fatalesActual) / fatalesPico) * 100) - insights.push({ - tipo: 'logro', - texto: `Reducción del ${reduccion}% respecto a la primera campaña Verano Vivo (${HISTORICO_VERANO_VIVO[0].campaña}), que registró ${fatalesPico} víctimas fatales en ruta.`, - }) + insights.push({ tipo: 'logro', texto: `Reducción del ${reduccion}% respecto a la primera campaña Verano Vivo (${historicoVV[0].campaña}), que registró ${fatalesPico} víctimas fatales en ruta.` }) } else if (fatalesActual === fatalesPico) { - insights.push({ - tipo: 'alerta', - texto: `Los fatales en ruta igualaron el máximo histórico de la primera campaña (${HISTORICO_VERANO_VIVO[0].campaña}: ${fatalesPico} víctimas).`, - }) + insights.push({ tipo: 'alerta', texto: `Los fatales en ruta igualaron el máximo histórico de la primera campaña (${historicoVV[0].campaña}: ${fatalesPico} víctimas).` }) } - // 3. Tendencia últimas 3 campañas - if (HISTORICO_VERANO_VIVO.length >= 3) { - const ultimas = HISTORICO_VERANO_VIVO.slice(-3) + if (historicoVV.length >= 3) { + const ultimas = historicoVV.slice(-3) const [a, b, c] = ultimas.map(x => x.fatales) if (c <= b && b <= a && c < a) { - insights.push({ - tipo: 'logro', - texto: `Tendencia a la baja: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.`, - }) + insights.push({ tipo: 'logro', texto: `Tendencia a la baja: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.` }) } else if (c >= b && b >= a && c > a) { - insights.push({ - tipo: 'alerta', - texto: `Tendencia al alza: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.`, - }) + insights.push({ tipo: 'alerta', texto: `Tendencia al alza: los últimos 3 períodos registraron ${a}, ${b} y ${c} víctimas fatales en ruta respectivamente.` }) } else if (a === b && b === c) { - insights.push({ - tipo: 'neutro', - texto: `Los últimos 3 períodos mantuvieron estable la cantidad de víctimas fatales en ruta: ${c} por campaña.`, - }) + insights.push({ tipo: 'neutro', texto: `Los últimos 3 períodos mantuvieron estable la cantidad de víctimas fatales en ruta: ${c} por campaña.` }) } } - // 4. Meses sin siniestros fatales en ruta const NOMBRES_MESES = { 12: 'Diciembre', 1: 'Enero', 2: 'Febrero', 3: 'Marzo' } const mesesSinFatales = graficoC .filter(m => m.fatales === 0) .map(m => Object.values(NOMBRES_MESES).find(n => n.startsWith(m.mes)) || m.mes) if (mesesSinFatales.length === 4) { - insights.push({ - tipo: 'logro', - texto: 'Todos los meses de la campaña transcurrieron sin víctimas fatales en rutas y caminos.', - }) + insights.push({ tipo: 'logro', texto: 'Todos los meses de la campaña transcurrieron sin víctimas fatales en rutas y caminos.' }) } else if (mesesSinFatales.length > 0) { const listaMeses = mesesSinFatales.length === 1 ? mesesSinFatales[0] : mesesSinFatales.slice(0, -1).join(', ') + ' y ' + mesesSinFatales.at(-1) - insights.push({ - tipo: 'logro', - texto: `${listaMeses} transcurrió${mesesSinFatales.length > 1 ? 'ron' : ''} sin víctimas fatales en rutas y caminos.`, - }) + insights.push({ tipo: 'logro', texto: `${listaMeses} transcurrió${mesesSinFatales.length > 1 ? 'ron' : ''} sin víctimas fatales en rutas y caminos.` }) } - // 5. Tipo más frecuente en ruta if (graficoF.length > 0) { const top = graficoF[0] - insights.push({ - tipo: 'dato', - texto: `El tipo de siniestro más frecuente en rutas fue "${top.name}" (${top.pct.toFixed(0)}% de los casos).`, - }) + insights.push({ tipo: 'dato', texto: `El tipo de siniestro más frecuente en rutas fue "${top.name}" (${top.pct.toFixed(0)}% de los casos).` }) } - // 6. Ruta con más siniestros if (graficoD.length > 0) { - const topRuta = graficoD[0] - insights.push({ - tipo: 'dato', - texto: `La Ruta ${topRuta.ruta} concentró la mayor cantidad de siniestros con ${topRuta.total} evento${topRuta.total !== 1 ? 's' : ''}.`, - }) + const rutaMasGrave = [...graficoD].sort( + (a, b) => (b.fatales - a.fatales) || (b.conLes - a.conLes) || (b.total - a.total) + )[0] + const tipoVia = (rutaMasGrave.via_publica || '').trim() + const nombreVia = (rutaMasGrave.nombre_via || '').trim() + const nombreRuta = `${tipoVia} ${nombreVia}`.trim() || rutaMasGrave.ruta || 'la ruta identificada' + if (rutaMasGrave.fatales > 0) { + insights.push({ tipo: 'alerta', texto: `La ruta de mayor gravedad fue la ${nombreRuta}, con ${rutaMasGrave.fatales} siniestro${rutaMasGrave.fatales !== 1 ? 's' : ''} fatal${rutaMasGrave.fatales !== 1 ? 'es' : ''}${rutaMasGrave.conLes > 0 ? ` y ${rutaMasGrave.conLes} con lesionados` : ''}.` }) + } else if (rutaMasGrave.conLes > 0) { + insights.push({ tipo: 'dato', texto: `La ruta de mayor gravedad fue la ${nombreRuta}, con ${rutaMasGrave.conLes} siniestro${rutaMasGrave.conLes !== 1 ? 's' : ''} con lesionados.` }) + } + } + + // ── NUEVO: insight sobre heridos graves ── + if (lesionados) { + const { graves, leves } = lesionados + const totalLes = graves + leves + if (totalLes > 0 && graves > 0) { + const pctGraves = Math.round((graves / totalLes) * 100) + insights.push({ + tipo: pctGraves >= 40 ? 'alerta' : 'dato', + texto: `De las ${totalLes} personas lesionadas en ruta, ${graves} (${pctGraves}%) fueron heridos graves y ${leves} heridos leves.`, + }) + } else if (totalLes > 0 && graves === 0) { + insights.push({ + tipo: 'logro', + texto: `Las ${leves} personas lesionadas en ruta durante la campaña resultaron con heridas leves, sin heridos graves registrados.`, + }) + } } return insights @@ -192,18 +175,11 @@ function BloqueInsights({ insights, campanaLabel }) { {insights.map((insight, i) => { const cfg = INSIGHT_CONFIG[insight.tipo] return ( -
+
{cfg.icon}
- - {cfg.label} - -

- {insight.texto} -

+ {cfg.label} +

{insight.texto}

) @@ -251,28 +227,88 @@ function CustomTooltip({ active, payload, label }) { function BarHorizontalStacked({ data, nameKey }) { return ( - + - + } /> - - - + + + ) } +// ── Bloque de lesionados por gravedad ─────────────────────── +function BloqueLesionadosVV({ graves, leves, campanaLabel }) { + const total = graves + leves + if (total === 0) return null + const pctGraves = Math.round((graves / total) * 100) + const pctLeves = Math.round((leves / total) * 100) + + return ( +
+

+ Personas lesionadas en ruta · {campanaLabel} +

+

+ Heridos graves vs. heridos leves +

+ + {/* Barra proporcional */} +
+
+ {pctGraves >= 10 ? `${pctGraves}%` : ''} +
+
+ {pctLeves >= 10 ? `${pctLeves}%` : ''} +
+
+ + {/* Tarjetas */} +
+
+
+
+

{graves}

+

+ Heridos graves — {pctGraves}% del total +

+

+ Personas con lesiones de gravedad registradas en rutas y caminos provinciales. +

+
+
+ +
+
+
+

{leves}

+

+ Heridos leves — {pctLeves}% del total +

+

+ Personas con lesiones de menor gravedad registradas en rutas y caminos provinciales. +

+
+
+
+
+ ) +} + + // ── Componente principal ──────────────────────────────────── -export default function SecVeranoVivo({ siniestros }) { +export default function SecVeranoVivo({ siniestros, personas }) { const [campanaIdx, setCampanaIdx] = useState(CAMPANAS_VV.length - 1) const campanaActual = CAMPANAS_VV[campanaIdx] @@ -284,32 +320,57 @@ export default function SecVeranoVivo({ siniestros }) { () => CAMPANAS_VV.map(c => ({ label: c.label, datos: filtrarCampanaVV(siniestros, c.anoDesde) })), [siniestros] ) + const historicoVV = useMemo(() => generarHistoricoVV(siniestros), [siniestros]) + const promedioHistoricoVV = useMemo(() => calcularPromedioHistoricoVV(siniestros), [siniestros]) + const kpis = useMemo(() => kpisVV(datosActual), [datosActual]) + const graficoB = useMemo(() => ruralUrbanoPorCampana(datosPorCampana), [datosPorCampana]) + const graficoCRural = useMemo(() => distribucionMensualVV(datosActual, 'rural'), [datosActual]) + const graficoCUrbano = useMemo(() => distribucionMensualVV(datosActual, 'urbano'), [datosActual]) + const graficoD = useMemo(() => rankingRutas(datosActual), [datosActual]) + const graficoE = useMemo(() => rankingLocalidades(datosActual), [datosActual]) + const graficoF = useMemo(() => agruparConUmbral(tiposSiniestroVV(datosActual), 10), [datosActual]) - const kpis = useMemo(() => kpisVV(datosActual), [datosActual]) - const graficoB = useMemo(() => ruralUrbanoPorCampana(datosPorCampana), [datosPorCampana]) - const graficoCRural = useMemo(() => distribucionMensualVV(datosActual, 'rural'), [datosActual]) - const graficoCUrbano = useMemo(() => distribucionMensualVV(datosActual, 'urbano'), [datosActual]) - const graficoD = useMemo(() => rankingRutas(datosActual), [datosActual]) - const graficoE = useMemo(() => rankingLocalidades(datosActual), [datosActual]) - const graficoF = useMemo(() => agruparConUmbral(tiposSiniestroVV(datosActual), 10), [datosActual]) + // ── NUEVO: lesionados por gravedad filtrados por campaña activa ── + const lesionadosVV = useMemo( + () => calcularLesionadosPorGravedad(datosActual, personas), + [datosActual, personas] + ) + + const maxComparativoZona = useMemo(() => { + const maximo = Math.max(0, ...graficoB.flatMap((d) => [d.ruralFatal ?? 0, d.ruralLes ?? 0, d.urbanaFatal ?? 0, d.urbanaLes ?? 0])) + return Math.max(5, Math.ceil(maximo / 5) * 5) + }, [graficoB]) + + const maxMensualZona = useMemo(() => { + const maximo = Math.max( + 0, + ...graficoCRural.map((d) => (d.fatales ?? 0) + (d.conLes ?? 0) + (d.sinLes ?? 0)), + ...graficoCUrbano.map((d) => (d.fatales ?? 0) + (d.conLes ?? 0) + (d.sinLes ?? 0)) + ) + return Math.max(5, Math.ceil(maximo / 5) * 5) + }, [graficoCRural, graficoCUrbano]) + + const graficoDConEtiqueta = useMemo( + () => graficoD.map((item) => ({ ...item, viaCompleta: `${item.via_publica || 'Ruta'} ${item.nombre_via || ''}`.trim() })), + [graficoD] + ) - // ✅ graficoCRural (no graficoC) — datos de ruta para los insights const insights = useMemo( - () => calcularInsights(campanaActual, kpis, graficoCRural, graficoD, graficoF), - [campanaActual, kpis, graficoCRural, graficoD, graficoF] + () => calcularInsights(campanaActual, kpis, graficoCRural, graficoD, graficoF, promedioHistoricoVV, historicoVV, lesionadosVV), + [campanaActual, kpis, graficoCRural, graficoD, graficoF, promedioHistoricoVV, historicoVV, lesionadosVV] ) return (
- {/* Encabezado + selector de campaña */} + {/* Encabezado + selector */}

Campaña

Verano Vivo

20 de diciembre al 20 de marzo

-
+
{CAMPANAS_VV.map((c, i) => (
- {/* KPIs */} + {/* KPIs — siniestros en ruta */}

Rutas y caminos de la provincia · {campanaActual.label}

- - - + + +
- {/* Gráfico A — Serie histórica · ancho completo */} + {/* ── NUEVO: bloque lesionados por gravedad ── */} + + + {/* Gráfico A — Serie histórica */} - + - + } /> - {/* Gráfico B — Comparación histórica · ancho completo */} - - - - - - - } /> - - - - - - - - - - {/* Gráficos C — Siniestros por mes separados por zona · grid 2 col */} + {/* Gráfico B — Comparación histórica por zona */}
- + + + + + + + } /> + + + + + + + + + + + + + + } /> + + + + + + +
+ + {/* Gráficos C — Siniestros por mes por zona */} +
+ - + } /> @@ -415,17 +487,12 @@ export default function SecVeranoVivo({ siniestros }) { - + - + } /> @@ -436,26 +503,16 @@ export default function SecVeranoVivo({ siniestros }) {
- {/* Gráficos D y E — Rutas y Localidades · grid 2 col */} + {/* Gráficos D y E — Rutas y Localidades */}
- + {graficoD.length === 0 ?

Sin datos para esta campaña

- : + : }
- + {graficoE.length === 0 ?

Sin datos para esta campaña

: @@ -463,41 +520,37 @@ export default function SecVeranoVivo({ siniestros }) {
- {/* Gráfico F — Donut tipos · ancho completo */} - -
- - - - {graficoF.map((entry, index) => ( - - ))} - - } /> - {value}} - /> - - + {/* Gráfico F — Donut tipos */} + +
+
+ + + + {graficoF.map((entry, index) => ( + + ))} + + } /> + + +
+
+ {graficoF.map((item, index) => ( +
+
+ + {item.name} +
+
{item.value}
+
{item.pct.toFixed(0)}% del total
+
+ ))} +
- {/* Bloque de insights · ancho completo */} + {/* Bloque de insights */}
diff --git a/src/utils/calculos.js b/src/utils/calculos.js index 3701b90..a6d3168 100644 --- a/src/utils/calculos.js +++ b/src/utils/calculos.js @@ -57,6 +57,29 @@ export function calcularKPIs(siniestros) { return { total, fatales, conLes, sinLes, victimas, lesion } } +// ── Desagrega lesionados graves y leves cruzando con tabla personas ────────── +export function calcularLesionadosPorGravedad(siniestros, personas) { + const ids = new Set(siniestros.map(s => s.id_feu)) + return (personas ?? []) + .filter(p => ids.has(p.id_feu)) + .reduce( + (acc, p) => { + const estado = (p.estado_ocupante_final ?? '').trim() + if (estado === 'Herido Grave') acc.graves++ + if (estado === 'Herido Leve') acc.leves++ + return acc + }, + { graves: 0, leves: 0 } + ) +} + +// ── Filtra personas que corresponden a un conjunto de siniestros (join id_feu) +export function filtrarPersonasPorSiniestros(personas, siniestros) { // ← NUEVO + const ids = new Set((siniestros ?? []).map(s => s.id_feu).filter(Boolean)) + return (personas ?? []).filter(p => ids.has(p.id_feu)) +} +// ───────────────────────────────────────────────────────────────────────────── + export function evolucionMensual(siniestros) { const mapa = {} MESES.forEach((m) => { mapa[m] = { mes: m.slice(0, 3), fatales: 0, conLes: 0, sinLes: 0, total: 0 } }) @@ -152,9 +175,9 @@ export function perfilVictimas(personas, involucrados) { }) return { - genero: Object.entries(genero).map(([name, value]) => ({ name, value })), - etario: Object.entries(etario).map(([name, value]) => ({ name, value })), - usuario: Object.entries(usuario).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value), + genero: Object.entries(genero).map(([name, value]) => ({ name, value })), + etario: Object.entries(etario).map(([name, value]) => ({ name, value })), + usuario: Object.entries(usuario).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value), } } @@ -185,13 +208,11 @@ export function proteccionPasiva(personas, involucrados) { const habitaculo = personas.filter((p) => TIPOS_HABITACULO.includes(tipoMap.get(String(p.id_involucrado)))) const cascoBase = motoristas.length - const cascoUso = motoristas.filter((p) => hasCasco(p)).length - - const cinBase = habitaculo.length - const cinUso = habitaculo.filter((p) => hasCinturon(p)).length - + const cascoUso = motoristas.filter((p) => hasCasco(p)).length + const cinBase = habitaculo.length + const cinUso = habitaculo.filter((p) => hasCinturon(p)).length const airbagBase = habitaculo.length - const airbagUso = habitaculo.filter((p) => hasAirbag(p)).length + const airbagUso = habitaculo.filter((p) => hasAirbag(p)).length return { casco: { uso: cascoUso, base: cascoBase, pct: cascoBase ? Math.round((cascoUso / cascoBase) * 100) : null }, @@ -283,7 +304,7 @@ export function calcularRangoEtario(personas) { }) } -// ── SÍNTESIS ────────────────────────────────────────────────── +// ── SÍNTESIS ───────────────────────────────────────────────────────────────── function topEntry(obj) { return Object.entries(obj).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'Sin dato' @@ -388,26 +409,12 @@ export function calcularSintesis(siniestros, personas = [], involucrados = []) { } } -// ── VERANO VIVO ─────────────────────────────────────────────── - -export const HISTORICO_VERANO_VIVO = [ - { campaña: '2015/16', fatales: 11 }, - { campaña: '2016/17', fatales: 8 }, - { campaña: '2017/18', fatales: 2 }, - { campaña: '2018/19', fatales: 6 }, - { campaña: '2019/20', fatales: 5 }, - // 2020/21 excluida — restricciones COVID - { campaña: '2021/22', fatales: 4 }, - { campaña: '2022/23', fatales: 3 }, - { campaña: '2023/24', fatales: 3 }, - { campaña: '2024/25', fatales: 3 }, -] -export const PROMEDIO_HISTORICO_VV = 5.2 - +// ── VERANO VIVO ─────────────────────────────────────────────────────────────── export const CAMPANAS_VV = [ { label: '2022/23', anoDesde: 2022, anoHasta: 2023 }, { label: '2023/24', anoDesde: 2023, anoHasta: 2024 }, { label: '2024/25', anoDesde: 2024, anoHasta: 2025 }, + { label: '2025/26', anoDesde: 2025, anoHasta: 2026 }, ] export function filtrarCampanaVV(siniestros, anoDesde) { @@ -417,15 +424,41 @@ export function filtrarCampanaVV(siniestros, anoDesde) { const ano = PARSE_INT(s.ano) if (ano === anoDesde && mes === 12 && dia >= 20) return true if (ano === anoDesde + 1 && (mes === 1 || mes === 2)) return true - if (ano === anoDesde + 1 && mes === 3 && dia <= 20) return true + if (ano === anoDesde + 1 && mes === 3 && dia <= 20) return true return false }) } +const HISTORICO_PREVIO_VV = [ + { campaña: '2015/16', fatales: 11 }, + { campaña: '2016/17', fatales: 8 }, + { campaña: '2017/18', fatales: 2 }, + { campaña: '2018/19', fatales: 6 }, + { campaña: '2019/20', fatales: 5 }, + // 2020/21 excluida — restricciones COVID + { campaña: '2021/22', fatales: 4 }, +] + +export const HISTORICO_VERANO_VIVO = HISTORICO_PREVIO_VV + +export function generarHistoricoVV(siniestros) { + const desdeSupabase = CAMPANAS_VV.map(({ label, anoDesde }) => { + const datos = filtrarCampanaVV(siniestros, anoDesde) + const rural = datos.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural')) + const fatales = rural.filter(s => getCantidadFallecidos(s) > 0).length + return { campaña: label, fatales } + }) + return [...HISTORICO_PREVIO_VV, ...desdeSupabase] +} + +export function calcularPromedioHistoricoVV(siniestros) { + const historico = generarHistoricoVV(siniestros) + const suma = historico.reduce((acc, c) => acc + c.fatales, 0) + return Math.round((suma / historico.length) * 10) / 10 +} + export function kpisVV(siniestros) { - const rural = siniestros.filter(s => - (s.zona_ocurrencia || '').toLowerCase().includes('rural') - ) + const rural = siniestros.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural')) const total = rural.length const fatales = rural.filter(s => getCantidadFallecidos(s) > 0).length const conLes = rural.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0).length @@ -449,16 +482,6 @@ export function ruralUrbanoPorCampana(siniestrosPorCampana) { }) } -const MESES_VV = [ - { key: 12, label: 'Dic' }, - { key: 1, label: 'Ene' }, - { key: 2, label: 'Feb' }, - { key: 3, label: 'Mar' }, -] - -// ANTES — distribucionMensualVV(siniestros) -// AHORA — acepta zona opcional: 'rural' | 'urbano' | undefined (todos) - const esUrbano = (s) => (s.zona || s.zona_ocurrencia || '').toLowerCase().includes('urban') @@ -491,18 +514,32 @@ export function distribucionMensualVV(siniestros, zona) { export function rankingRutas(siniestros, topN = 8) { const map = {} + siniestros .filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural')) .forEach(s => { - const nombre = (s.nombre_via || '').trim() - const tipo = (s.via_publica || '').trim() - const ruta = nombre || tipo || 'Sin datos' - if (!map[ruta]) map[ruta] = { ruta, tipo, fatales: 0, conLes: 0, sinLes: 0, total: 0 } - if (getCantidadFallecidos(s) > 0) map[ruta].fatales++ - else if (getCantidadLesionados(s) > 0) map[ruta].conLes++ - else map[ruta].sinLes++ - map[ruta].total++ + const nombre_via = (s.nombre_via || '').trim() + const via_publica = (s.via_publica || '').trim() + const key = `${via_publica}__${nombre_via}` || 'Sin datos' + + if (!map[key]) { + map[key] = { + ruta: nombre_via || via_publica || 'Sin datos', + nombre_via, + via_publica, + fatales: 0, + conLes: 0, + sinLes: 0, + total: 0, + } + } + + if (getCantidadFallecidos(s) > 0) map[key].fatales++ + else if (getCantidadLesionados(s) > 0) map[key].conLes++ + else map[key].sinLes++ + map[key].total++ }) + return Object.values(map) .sort((a, b) => b.total - a.total) .slice(0, topN) @@ -526,7 +563,6 @@ export function rankingLocalidades(siniestros, topN = 8) { } // ✅ FIX: eliminados logs temporales y normalización redundante -// (getTipoSiniestro ya aplica sentence case + trim + normalize) export function tiposSiniestroVV(siniestros) { const map = {} siniestros diff --git a/src/utils/exportPdf.js b/src/utils/exportPdf.js index 233e828..acb5a8a 100644 --- a/src/utils/exportPdf.js +++ b/src/utils/exportPdf.js @@ -1,16 +1,18 @@ import html2canvas from 'html2canvas-pro' import jsPDF from 'jspdf' + export const SECCIONES_EXPORTABLES = [ - { id: 'resumen', label: 'Resumen General' }, - { id: 'historica', label: 'Serie Histórica Provincial' }, - { id: 'fatales', label: 'Siniestros Fatales' }, - { id: 'lesionados', label: 'Con Lesionados' }, - { id: 'sinlesiones', label: 'Sin Lesiones' }, - { id: 'sintesis', label: 'Síntesis' }, - { id: 'veranovivo', label: 'Verano Vivo' }, + { id: 'resumen', label: 'Resumen General', subtitulo: 'Indicadores y estadísticas generales del período' }, + { id: 'historica', label: 'Serie Histórica Provincial', subtitulo: 'Evolución de la siniestralidad vial a lo largo del tiempo' }, + { id: 'fatales', label: 'Siniestros Fatales', subtitulo: 'Análisis detallado de siniestros con víctimas fatales' }, + { id: 'lesionados', label: 'Con Lesionados', subtitulo: 'Siniestros con personas lesionadas sin fallecidos' }, + { id: 'sinlesiones', label: 'Sin Lesiones', subtitulo: 'Siniestros con daños materiales sin víctimas' }, + { id: 'sintesis', label: 'Síntesis', subtitulo: 'Resumen ejecutivo y conclusiones del período' }, + { id: 'veranovivo', label: 'Verano Vivo', subtitulo: 'Estadísticas del operativo estival' }, ] + function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image() @@ -21,6 +23,7 @@ function loadImage(src) { }) } + function drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) { pdf.setFillColor(37, 44, 97) pdf.rect(0, 0, W, 13, 'F') @@ -38,7 +41,73 @@ function drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) { pdf.text(`Página ${pageNum}`, W - 10, H - 2.5, { align: 'right' }) } -// ── Captura un elemento individual ─────────────────────────────────────────── + +// ── Carátula de sección ─────────────────────────────────────────────────────── +function drawCaratulaSeccion(pdf, secLabel, subtitulo, year, logoImg, W, H) { + pdf.setFillColor(37, 44, 97) + pdf.rect(0, 0, W, H, 'F') + + // Línea decorativa superior + pdf.setDrawColor(128, 176, 222) + pdf.setLineWidth(0.8) + pdf.line(W / 2 - 40, H / 2 - 22, W / 2 + 40, H / 2 - 22) + + // Label año + pdf.setTextColor(128, 176, 222) + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(10) + pdf.text(`INFORME ${year}`, W / 2, H / 2 - 14, { align: 'center' }) + + // Título de sección + pdf.setTextColor(255, 255, 255) + pdf.setFont('helvetica', 'bold') + pdf.setFontSize(28) + pdf.text(secLabel.toUpperCase(), W / 2, H / 2 + 4, { align: 'center' }) + + // Subtítulo + if (subtitulo) { + pdf.setTextColor(180, 195, 230) + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(11) + pdf.text(subtitulo, W / 2, H / 2 + 18, { align: 'center' }) + } + + // Línea decorativa inferior + pdf.setDrawColor(128, 176, 222) + pdf.setLineWidth(0.8) + pdf.line(W / 2 - 40, H / 2 + 26, W / 2 + 40, H / 2 + 26) + + // ── Logo + texto al pie ────────────────────────────────────────────────── + const pieY = H - 14 + if (logoImg) { + const logoH = 8 + const logoW = (logoImg.width / logoImg.height) * logoH + // Calcular posición para centrar logo + texto juntos + pdf.setFontSize(8) + const textoAncho = pdf.getTextWidth('Observatorio Provincial de Seguridad Vial · Santa Cruz') + const gap = 3 // mm entre logo y texto + const totalAncho = logoW + gap + textoAncho + const startX = (W - totalAncho) / 2 + + pdf.addImage(logoImg, 'PNG', startX, pieY - logoH + 1, logoW, logoH) + pdf.setTextColor(180, 195, 230) + pdf.setFont('helvetica', 'normal') + pdf.text( + 'Observatorio Provincial de Seguridad Vial · Santa Cruz', + startX + logoW + gap, + pieY, + ) + } else { + // Sin logo — solo texto centrado + pdf.setTextColor(180, 195, 230) + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(8) + pdf.text('Observatorio Provincial de Seguridad Vial · Santa Cruz', W / 2, pieY, { align: 'center' }) + } +} + + +// ── Captura un bloque con html2canvas ──────────────────────────────────────── async function capturarBloque(block) { await new Promise(r => setTimeout(r, 80)) return html2canvas(block, { @@ -51,26 +120,33 @@ async function capturarBloque(block) { }) } -export async function exportarPDF({ seccionesIds, year, onProgress }) { - const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }) - const W = pdf.internal.pageSize.getWidth() // 297mm - const H = pdf.internal.pageSize.getHeight() // 210mm - const areaW = W - 16 // ancho disponible - const areaH = H - 13 - 10 // alto disponible (header 13mm + footer 10mm) - const topY = 15 // cursor inicial tras el header - const marginX = (W - areaW) / 2 - const gap = 3 // mm de separación entre bloques - // ── Portada ─────────────────────────────────────────────────────────────── +export async function exportarPDF({ seccionesIds, year, onProgress }) { + const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }) + const W = pdf.internal.pageSize.getWidth() + const H = pdf.internal.pageSize.getHeight() + const areaW = W - 16 + const areaH = H - 13 - 10 + const topY = 15 + const marginX = (W - areaW) / 2 + const gap = 3 + + // ── Cargar logo una sola vez ───────────────────────────────────────────── + let logoImg = null + try { + logoImg = await loadImage('/logo-opsv.png') + } catch { /* sin logo */ } + + + // ── Portada ───────────────────────────────────────────────────────────── pdf.setFillColor(37, 44, 97) pdf.rect(0, 0, W, H, 'F') - try { - const logoImg = await loadImage('/logo-opsv.png') + if (logoImg) { const logoW = 55 const logoH = (logoImg.height / logoImg.width) * logoW pdf.addImage(logoImg, 'PNG', (W - logoW) / 2, 35, logoW, logoH) - } catch { /* sin logo */ } + } pdf.setTextColor(255, 255, 255) pdf.setFont('helvetica', 'bold') @@ -88,42 +164,64 @@ export async function exportarPDF({ seccionesIds, year, onProgress }) { }) pdf.text(`Generado el ${fecha}`, W / 2, 190, { align: 'center' }) - // ── Secciones ───────────────────────────────────────────────────────────── + + // ── Secciones ──────────────────────────────────────────────────────────── let pageNum = 1 for (let i = 0; i < seccionesIds.length; i++) { - const secId = seccionesIds[i] - const secLabel = SECCIONES_EXPORTABLES.find(s => s.id === secId)?.label ?? secId + const secId = seccionesIds[i] + const secDef = SECCIONES_EXPORTABLES.find(s => s.id === secId) + const secLabel = secDef?.label ?? secId + const subtitulo = secDef?.subtitulo ?? '' onProgress?.(Math.round(((i + 1) / seccionesIds.length) * 90)) const el = document.getElementById(`pdf-section-${secId}`) if (!el) continue - // ✅ Obtener todos los bloques atómicos de esta sección const blocks = Array.from(el.querySelectorAll('[data-pdf-block]')) if (!blocks.length) continue - // Primera página de la sección + // ── Carátula de la sección ──────────────────────────────────────── + pdf.addPage() + pageNum++ + drawCaratulaSeccion(pdf, secLabel, subtitulo, year, logoImg, W, H) + + // ── Página con los bloques ──────────────────────────────────────── pdf.addPage() pageNum++ drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) - let cursorY = topY + // Precapturar todos los bloques para calcular altura total + const canvases = [] for (const block of blocks) { - const canvas = await capturarBloque(block) - const mmPerPx = areaW / canvas.width - let drawH = canvas.height * mmPerPx - let drawW = areaW + const canvas = await capturarBloque(block) + canvases.push(canvas) + } + + // Calcular altura total para centrado vertical + const alturaTotal = canvases.reduce((acc, canvas) => { + const mmPerPx = areaW / canvas.width + let drawH = canvas.height * mmPerPx + if (drawH > areaH) drawH = areaH + return acc + drawH + gap + }, 0) - gap + + let cursorY = alturaTotal <= areaH + ? topY + (areaH - alturaTotal) / 2 + : topY + + for (const canvas of canvases) { + const mmPerPx = areaW / canvas.width + let drawH = canvas.height * mmPerPx + let drawW = areaW - // Si el bloque es más alto que toda la página, escalar para que entre if (drawH > areaH) { const ratio = areaH / drawH drawH = areaH drawW = areaW * ratio } - // ✅ Si no cabe en lo que queda de página → nueva página if (cursorY + drawH > topY + areaH) { pdf.addPage() pageNum++