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({
-
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 */}
- {/* ── Filtro por departamento (oculto en Verano Vivo) ── */}
- {seccion !== 'veranovivo' !== 'historica'&& (
+ {/* Filtro por departamento */}
+ {seccion !== 'veranovivo' && seccion !== 'historica' && (
)}
- {/* ── Filtro por localidad (oculto en Verano Vivo, deshabilitado sin depto.) ── */}
- {seccion !== 'veranovivo' !== 'historica' && (
+ {/* Filtro por localidad */}
+ {seccion !== 'veranovivo' && seccion !== 'historica' && (
)}
-
- {/* ── Descargar PDF ── */}
-
- Descargar PDF
-
-
-
-
- {/* ── 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 */}
+
+ 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 ─────────────────────────── */}
-
+
@@ -196,10 +267,21 @@ export default function Dashboard() {
-
+
+
+
+
+
-
+
{/* ────────────────────────────────────────────────────────────────── */}
@@ -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 }) {
-
+
-
- Año
-
-
- Siniestros
-
-
- Víctimas
-
-
- Tasa
-
+ Año
+ Siniestros
+ Víctimas
+ Tasa
@@ -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'
: ''
}`}
>
-
- {row.ano}
-
-
- {row.siniestros}
-
-
- {row.victimas ?? '—'}
-
+ {row.ano}
+ {row.siniestros}
+ {row.victimas ?? '—'}
{row.tasa ?? '—'}
))}
@@ -308,6 +261,7 @@ export default function SecHistorica({ siniestros, year }) {
+
)
}
\ 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) => (
+ setVista(op.value)}
+ className={`${btnBase} ${vista === op.value ? btnActive : btnInactive}`}
+ >
+ {op.label}
+
+ ))}
+
+
+
+ {/* Filtro gravedad */}
+
+
+ Gravedad
+
+
+ {FILTROS_GRAVEDAD.map((op) => (
+ setGravedad(op.value)}
+ className={`${btnBase} ${gravedad === op.value ? btnActive : btnInactive}`}
+ >
+ {op.label}
+
+ ))}
+
+
+
+
+ {/* Leyenda */}
+ {vista === 'marcadores' && (
+
+ {[
+ { color: '#C44228', label: 'Fatal' },
+ { color: '#E8881A', label: 'Lesionado' },
+ { color: '#6B7280', label: 'Sin lesiones' },
+ ].map((item) => (
+
+ ))}
+
+ )}
+
+ {/* 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++