feat: múltiples mejoras y correcciones

- Agrega SecMapa con visualización geográfica de siniestros
- Actualiza exportación a PDF con nuevas secciones
- Corrige colores de interfaz (modo claro/oscuro)
- Corrige cálculo de víctimas fatales en SecSintesis usando campo 'fallecidos'
- Corrige serie histórica para reflejar año filtrado correctamente
This commit is contained in:
2026-05-11 12:13:00 -03:00
parent ca7b159657
commit b587ea7328
25 changed files with 1672 additions and 710 deletions
+65
View File
@@ -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",
+5
View File
@@ -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"
},
+29 -12
View File
@@ -1,4 +1,4 @@
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'
@@ -8,26 +8,41 @@ const sectors = [
{ 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 (
<div className="rounded-2xl border border-opsv-border bg-opsv-surface px-4 py-3 shadow-md">
<div className="flex items-center gap-2 text-sm font-semibold text-opsv-muted">
<span className="h-2.5 w-2.5 rounded-full" style={{ background: color }} />
{name}
</div>
<div className="mt-1 text-2xl font-black text-opsv-navy">
{value}
<span className="ml-2 text-sm font-semibold text-opsv-muted">{pct}</span>
</div>
</div>
)
}
export default function DonutGravedad({ siniestros }) {
const kpis = calcularKPIs(siniestros)
const total = kpis.total || 1
const data = sectors.map((sector) => ({
name: sector.label,
value: kpis[sector.key],
color: sector.color,
pct: `${((kpis[sector.key] / total) * 100).toFixed(1)}%`,
}))
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="mb-6 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">Gravedad por categoría</p>
<h3 className="mt-2 text-xl font-black text-opsv-navy">Distribución de siniestros</h3>
</div>
</div>
<div className="relative h-[320px] w-full">
<>
<div className="relative h-[280px] w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Tooltip content={<CustomTooltip />} />
<Pie
data={data}
dataKey="value"
@@ -51,7 +66,8 @@ export default function DonutGravedad({ siniestros }) {
</div>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{/* Leyenda con porcentajes */}
<div className="mt-6 grid gap-3 sm:grid-cols-3">
{data.map((item) => (
<div key={item.name} className="rounded-3xl bg-opsv-bg p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-opsv-muted">
@@ -59,9 +75,10 @@ export default function DonutGravedad({ siniestros }) {
{item.name}
</div>
<div className="mt-3 text-3xl font-black text-opsv-navy">{item.value}</div>
<div className="mt-1 text-sm font-semibold text-opsv-muted">{item.pct}</div>
</div>
))}
</div>
</div>
</>
)
}
+199
View File
@@ -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(`
<div style="font-family:sans-serif;min-width:180px;line-height:1.5">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
<div style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0"></div>
<strong style="font-size:13px">${GRAVEDAD[grav].label}</strong>
</div>
<div style="font-size:12px;color:#555">
<div><b>Fecha:</b> ${formatFecha(dato)}</div>
<div><b>Tipo:</b> ${dato.tipo_siniestro_unico || dato.tipo_siniestro || 'Sin dato'}</div>
<div><b>Localidad:</b> ${dato.localidad || 'Sin dato'}</div>
<div><b>Departamento:</b> ${dato.departamento || 'Sin dato'}</div>
</div>
</div>
`, { 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 (
<div
ref={containerRef}
className="w-full rounded-[20px] overflow-hidden"
style={{ height: '520px', zIndex: 0 }}
/>
)
}
+1
View File
@@ -75,6 +75,7 @@ export default function PorLocalidad({ siniestros, tipo = 'todas' }) {
<XAxis
type="number"
allowDecimals={false}
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
+2 -2
View File
@@ -77,8 +77,8 @@ export default function PorTipoSiniestro({ siniestros }) {
data={data}
dataKey="value"
nameKey="name"
cx="60%"
cy="50%"
cx="100%"
cy="30%"
outerRadius={95}
innerRadius={58}
paddingAngle={4}
+6 -16
View File
@@ -35,9 +35,9 @@ export const SERIE_HISTORICA = [
{ ano: 2019, siniestros: 1178, victimas: 31, tasa: 8.64 },
// 2020 excluido
{ ano: 2021, siniestros: 1043, victimas: 24, tasa: 6.40 },
{ ano: 2022, siniestros: 1134, victimas: 26, tasa: 7.80 },
{ ano: 2023, siniestros: 1198, victimas: 26, tasa: 7.71 },
{ ano: 2024, siniestros: 1238, victimas: 24, tasa: 7.12 },
{ ano: 2022, siniestros: 1134, victimas: 27, tasa: 7.80 },
{ ano: 2023, siniestros: 1198, victimas: 25, tasa: 7.71 },
{ ano: 2024, siniestros: 1238, victimas: 26, tasa: 7.12 },
]
// ─── Tooltip ──────────────────────────
@@ -151,17 +151,7 @@ export default function SerieHistorica({
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="mb-6">
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">
Serie histórica
</p>
<h3 className="mt-2 text-xl font-black text-opsv-navy">
Tasa de mortalidad vial
</h3>
<p className="mt-2 text-sm text-opsv-muted">
Víctimas fatales cada 100.000 habitantes. Provincia de Santa Cruz, {primerAnio}{ultimoAnio}.
</p>
</div>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
@@ -228,12 +218,12 @@ export default function SerieHistorica({
</div>
<div className="mt-4 space-y-1">
<p className="text-xs text-opsv-muted">
<p className="text-sm text-opsv-muted">
Tasas 20132024: 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.
</p>
<p className="text-xs text-amber-600/80">
<p className="text-sm text-amber-600/80">
<span className="font-semibold">Nota:</span> 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
+59 -3
View File
@@ -83,7 +83,6 @@ export default function SiniestrosPorMes({ siniestros }) {
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10, right: 0, left: -10, bottom: 0 }}>
@@ -99,19 +98,76 @@ export default function SiniestrosPorMes({ siniestros }) {
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
content={({ active, payload, label }) => {
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 (
<div
style={{
background: tooltipBg,
border: `1px solid ${tooltipBorder}`,
borderRadius: 16,
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
padding: '10px 12px',
}}
>
<div
style={{
color: tooltipLabel,
fontWeight: 700,
marginBottom: 6,
fontSize: 13,
}}
>
{label}
</div>
{visibles.map((item) => (
<div
key={item.dataKey}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
fontSize: 12,
color: tickColor,
marginTop: 4,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
width: 10,
height: 10,
borderRadius: 9999,
background: item.color,
display: 'inline-block',
}}
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
/>
<span>{item.name || item.dataKey}</span>
</div>
<span style={{ fontWeight: 700, color: tooltipLabel }}>
{item.value}
</span>
</div>
))}
</div>
)
}}
/>
<Bar dataKey="fatales" stackId="a" fill={COLOR.fatales} radius={[8, 8, 0, 0]} />
<Bar dataKey="conLes" stackId="a" fill={COLOR.conLes} radius={[8, 8, 0, 0]} />
<Bar dataKey="sinLes" stackId="a" fill={COLOR.sinLes} radius={[8, 8, 0, 0]} />
+9 -6
View File
@@ -6,8 +6,10 @@ 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 },
@@ -15,10 +17,11 @@ const SECCIONES = [
{ 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,22 +36,22 @@ export default function Sidebar({
<img
src="/logo-opsv.png"
alt="Observatorio Provincial de Seguridad Vial"
className="h-12 w-12 rounded-xl object-contain bg-white/5 p-1"
className="h-25 w-25 rounded-xl object-contain bg-white/5 p-1"
/>
<div className="min-w-0">
<div className="text-3xl font-black tracking-tight">OPSV</div>
<div className="text-3xl font-black tracking-tight text-center">OPSV</div>
<div className="mt-1 text-sm leading-5 text-slate-200">
<div className="mt-1 text-sm leading-5 text-slate-200 text-center">
Observatorio Provincial
<br />
de Seguridad Vial
</div>
<div className="mt-2 text-[11px] uppercase tracking-[0.25em] text-opsv-blue text-center">
<div className="mt-2 text-[13px] uppercase tracking-[0.25em] text-opsv-blue text-center">
APSV
</div>
<div className="mt-2 text-[11px] uppercase tracking-[0.25em] text-opsv-blue">
<div className="mt-2 text-[13px] uppercase tracking-[0.25em] text-opsv-blue text-center">
Ministerio de Seguridad · Santa Cruz
</div>
</div>
+58 -19
View File
@@ -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 (
<header className="flex flex-col gap-4 border-b border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-900 px-6 py-5">
<header className="flex flex-col gap-3 border-b border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-900 px-6 py-5">
{/* ── Fila 1: título + filtros de datos ───────────────────────────── */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{/* Título */}
<div>
<p className="text-xs uppercase tracking-[0.35em] text-opsv-muted dark:text-slate-400">OPSV</p>
<h1 className="mt-2 text-3xl font-black text-opsv-navy dark:text-white">{title}</h1>
<p className="mt-1 text-sm text-opsv-muted dark:text-slate-400">{subtitle}</p>
<p className="text-sm uppercase tracking-[0.35em] text-opsv-muted dark:text-slate-400">OPSV</p>
<h1 className="mt-2 text-3xl font-black !text-white">{title}</h1>
<p className="mt-1 text-base !text-white/70">{subtitle}</p>
</div>
{/* Filtros de datos */}
<div className="flex flex-wrap items-center gap-3">
{/* ── Selector de año ── */}
{/* Selector de año */}
<FilterSelect
value={String(year)}
onChange={(v) => 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 */}
<div className="relative" ref={panelRef}>
<button
type="button"
@@ -245,8 +262,8 @@ export default function Topbar({
)}
</div>
{/* ── Filtro por departamento (oculto en Verano Vivo) ── */}
{seccion !== 'veranovivo' !== 'historica'&& (
{/* Filtro por departamento */}
{seccion !== 'veranovivo' && seccion !== 'historica' && (
<FilterSelect
icon={Building2}
value={departamentoFiltro}
@@ -256,8 +273,8 @@ export default function Topbar({
/>
)}
{/* ── Filtro por localidad (oculto en Verano Vivo, deshabilitado sin depto.) ── */}
{seccion !== 'veranovivo' !== 'historica' && (
{/* Filtro por localidad */}
{seccion !== 'veranovivo' && seccion !== 'historica' && (
<FilterSelect
icon={MapPin}
value={localidadFiltro}
@@ -269,25 +286,47 @@ export default function Topbar({
/>
)}
{/* Filtro por zona */}
{seccion !== 'veranovivo' && seccion !== 'historica' && (
<FilterSelect
icon={Map}
value={zonaFiltro}
onChange={setZonaFiltro}
options={(zonasDisponibles ?? []).map((z) => ({ value: z, label: z }))}
placeholder="Todas las zonas"
/>
)}
{/* ── Descargar PDF ── */}
</div>
</div>
{/* ── Fila 2: contador (centro) + acciones (derecha) ──────────────── */}
<div className="flex items-center justify-end gap-3"> {/* ← todo a la derecha */}
{/* Contador — tipografía más grande, destacado */}
<div className="flex-1 text-center"> {/* ← ocupa el espacio del medio y centra */}
<span className="text-2xl font-black text-opsv-navy dark:text-white tabular-nums">
{(siniestrosCount ?? 0).toLocaleString('es-AR')}
</span>
<span className="ml-2 text-sm font-medium text-opsv-muted dark:text-slate-400">
siniestros
</span>
</div>
{/* Descargar PDF */}
<button
type="button"
onClick={onExportarPdf}
className="rounded-2xl bg-opsv-navy dark:bg-slate-700 px-4 py-3 text-sm font-semibold text-white transition hover:bg-opsv-navy-dark dark:hover:bg-slate-600"
className="rounded-2xl bg-opsv-navy dark:bg-slate-700 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-opsv-navy-dark dark:hover:bg-slate-600"
>
Descargar PDF
</button>
{/* Modo claro/oscuro */}
<ThemeToggle />
{/* ── Contador ── */}
<div className="rounded-2xl border border-opsv-border dark:border-slate-700 bg-opsv-surface dark:bg-slate-800 px-4 py-3 text-sm font-medium text-opsv-navy dark:text-slate-200">
{siniestrosCount ?? 0} siniestros cargados
</div>
</div>
</div>
</header>
)
}
+11 -4
View File
@@ -1,4 +1,3 @@
// src/components/ui/ChartCard.jsx
const HEIGHTS = {
sm: 'h-[300px]',
md: 'h-[360px]',
@@ -15,10 +14,13 @@ export default function ChartCard({
className = '',
contentClassName = '',
}) {
const isFixed = height !== 'auto'
return (
<section
data-pdf-block // única línea nueva
className={`rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm overflow-hidden ${className}`}
data-pdf-block
className={`rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm ${className}`}
// overflow-hidden ELIMINADO del section
>
{(kicker || title || subtitle) && (
<header className="mb-5">
@@ -39,7 +41,12 @@ export default function ChartCard({
)}
</header>
)}
<div className={`min-w-0 w-full ${HEIGHTS[height]} ${contentClassName}`}>
{/* overflow-hidden solo en el div del contenido cuando altura es fija */}
<div
className={`min-w-0 w-full ${HEIGHTS[height]} ${
isFixed ? 'overflow-hidden' : ''
} ${contentClassName}`}
>
{children}
</div>
</section>
+4 -15
View File
@@ -4,35 +4,24 @@ export default function KPICard({
color,
unit,
variation,
centered = false,
}) {
const formattedValue =
typeof value === 'number' ? value.toLocaleString('es-AR') : value
return (
<div className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div
className={`flex items-start justify-between gap-4 ${
centered ? 'flex-col items-center text-center' : ''
}`}
>
<div className={centered ? 'flex flex-col items-center' : ''}>
<div className="flex flex-col items-center text-center gap-4">
<div className="flex flex-col items-center">
<div
className="text-3xl font-black text-opsv-navy"
style={color ? { color } : undefined}
>
{formattedValue}
{unit ? (
<span className="text-base font-semibold text-opsv-muted">
{' '}
{unit}
</span>
<span className="text-base font-semibold text-opsv-muted"> {unit}</span>
) : null}
</div>
<p className="mt-3 text-sm text-opsv-muted">
{label}
</p>
<p className="mt-3 text-sm text-opsv-muted">{label}</p>
</div>
{variation ? (
+17 -13
View File
@@ -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,10 +15,12 @@ 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 [siniestrosMapa, setSiniestrosMapa] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -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 }
}
+12 -1
View File
@@ -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"] * {
+96 -15
View File
@@ -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 (
<section className="rounded-3xl border border-opsv-border dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm">
@@ -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() {
// ── 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, loading, error } = useData(year, periodo)
// ── 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 (zonaFiltro) result = result.filter(s => s.zona_ocurrencia === zonaFiltro)
return result
}, [siniestros, departamentoFiltro, localidadFiltro])
}, [siniestros, departamentoFiltro, localidadFiltro, zonaFiltro])
// ── Verano Vivo: datos históricos de campañas anteriores ─────────────────
// ── 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 (
<div className="flex min-h-screen overflow-hidden bg-opsv-bg">
<Sidebar seccion={seccion} setSeccion={setSeccion} year={year} />
@@ -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 ? (
<LoadingSpinner />
) : seccion === 'resumen' ? (
<SecResumen siniestros={siniestrosFiltrados} periodo={periodo} />
<SecResumen
siniestros={siniestrosFiltrados}
personas={personas}
involucrados={involucrados}
periodo={periodo}
/>
) : seccion === 'historica' ? (
<SecHistorica siniestros={siniestros} year={year} />
) : seccion === 'fatales' ? (
@@ -156,9 +216,19 @@ export default function Dashboard() {
) : seccion === 'sinlesiones' ? (
<SecSinLesiones siniestros={siniestrosFiltrados} involucrados={involucrados} periodo={periodo} />
) : seccion === 'sintesis' ? (
<SecSintesis siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
<SecSintesis
siniestros={siniestrosFiltrados}
personas={personas}
involucrados={involucrados}
year={year}
victimasActual ={victimasFatalesFiltradas}
siniestrosActual={siniestrosFiltrados.length}
/>
) : seccion === 'mapa' ? (
<SecMapa siniestros={siniestrosFiltrados} siniestrosMapa={siniestrosMapa}/>
) : seccion === 'veranovivo' ? (
<SecVeranoVivo siniestros={siniestrosVV} />
<SecVeranoVivo siniestros={siniestrosVV} personas={personasVV} />
) : (
<SectionPlaceholder
title={seccion}
@@ -167,6 +237,7 @@ export default function Dashboard() {
)}
<AdminFooter />
</main>
{/* ── Contenedor oculto para captura PDF ─────────────────────────── */}
<div
aria-hidden="true"
@@ -181,7 +252,7 @@ export default function Dashboard() {
}}
>
<div id="pdf-section-resumen" className="p-6 bg-opsv-bg">
<SecResumen siniestros={siniestrosFiltrados} periodo={periodo} />
<SecResumen siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
</div>
<div id="pdf-section-historica" className="p-6 bg-opsv-bg">
<SecHistorica siniestros={siniestros} year={year} />
@@ -196,10 +267,21 @@ export default function Dashboard() {
<SecSinLesiones siniestros={siniestrosFiltrados} involucrados={involucrados} periodo={periodo} />
</div>
<div id="pdf-section-sintesis" className="p-6 bg-opsv-bg">
<SecSintesis siniestros={siniestrosFiltrados} personas={personas} involucrados={involucrados} periodo={periodo} />
<SecSintesis
siniestros={siniestrosFiltrados}
personas={personas}
involucrados={involucrados}
year={year}
victimasActual={victimasFatalesFiltradas}
siniestrosActual={siniestrosFiltrados.length}
/>
</div>
<div id="pdf-section-mapa" className="p-6 bg-opsv-bg">
<SecMapa siniestros={siniestrosFiltrados} siniestrosMapa={siniestrosMapa} />
</div>
<div id="pdf-section-veranovivo" className="p-6 bg-opsv-bg">
<SecVeranoVivo siniestros={siniestrosVV} />
<SecVeranoVivo siniestros={siniestrosVV} personas={personasVV} />
</div>
</div>
{/* ────────────────────────────────────────────────────────────────── */}
@@ -216,4 +298,3 @@ export default function Dashboard() {
</div>
)
}
+15 -13
View File
@@ -5,9 +5,9 @@ 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',
@@ -31,8 +31,15 @@ export default function SecFatales({ siniestros, personas, involucrados }) {
const diarios = total ? (total / 365).toFixed(1) : '0.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 (
<div className="space-y-8">
{/* KPIs */}
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KPICard label="Siniestros fatales" value={total} color={SECTION_COLORS.fatales} />
@@ -65,8 +72,8 @@ export default function SecFatales({ siniestros, personas, involucrados }) {
{/* Franja horaria */}
<ChartCard
kicker="Condiciones temporales"
title="Franja horaria de los siniestros fatales"
subtitle="Cantidad de siniestros fatales según la franja horaria en que ocurrieron."
title="Franja horaria de los siniestros fatales según zona de ocurrencia"
subtitle="Cantidad de siniestros fatales según la franja horaria y la zona en que ocurrieron."
height="md"
>
<FranjaHoraria siniestros={filtrarFatales} />
@@ -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"
>
<PerfilVictimas personas={personas} involucrados={involucrados} soloFatales={true} />
<PerfilVictimas
personas={personasFiltradas}
involucrados={involucrados}
soloFatales={true}
/>
</ChartCard>
{/* Seguridad pasiva */}
<ChartCard
kicker="Seguridad pasiva"
title="Uso de elementos de protección"
subtitle="Uso de cinturón de seguridad, casco y otros elementos de protección en los siniestros fatales. (Bases ajustadas por tipo de vehículo)"
height="auto"
>
<ProteccionPersonas personas={personas} involucrados={involucrados} />
</ChartCard>
</div>
)
}
+81 -127
View File
@@ -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,22 +84,14 @@ 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,
)
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,
)
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
@@ -127,11 +99,8 @@ export default function SecHistorica({ siniestros, year }) {
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)
const promedio10 = ultimos10.length > 0
? (ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length).toFixed(1)
: null
const rango10Desde = ultimos10[0]?.ano ?? null
@@ -139,93 +108,63 @@ export default function SecHistorica({ siniestros, year }) {
return (
<div className="space-y-8">
{/* KPIs históricas */}
{/* KPIs */}
<div data-pdf-block className="grid gap-4 xl:grid-cols-5">
<KPICard
label={
yearActualData
? `Víctimas fatales (${yearActualData.ano})`
: 'Víctimas fatales'
}
label={yearActualData ? `Víctimas fatales (${yearActualData.ano})` : 'Víctimas fatales'}
value={victimasActual ?? '—'}
color={COLOR.red}
/>
<KPICard
label={
prevYearData && yearActualData
? `Variación vs. año anterior (${prevYearData.ano}${yearActualData.ano})`
: 'Variación vs. año anterior'
}
label={prevYearData && yearActualData ? `Variación vs. año anterior (${prevYearData.ano}${yearActualData.ano})` : 'Variación vs. año anterior'}
value={comparativoVictimas}
color={COLOR.gold}
/>
<KPICard
label={
maxAno
? `Máximo histórico de víctimas (${maxAno})`
: 'Máximo histórico de víctimas'
}
label={maxAno ? `Máximo histórico de víctimas (${maxAno})` : 'Máximo histórico de víctimas'}
value={maxHistorico ?? '—'}
color={COLOR.navy}
/>
<KPICard
label={
minAno
? `Mínimo histórico de víctimas (${minAno})`
: 'Mínimo histórico de víctimas'
}
label={minAno ? `Mínimo histórico de víctimas (${minAno})` : 'Mínimo histórico de víctimas'}
value={minHistorico ?? '—'}
color={COLOR.blue}
/>
<KPICard
label={
rango10Desde && rango10Hasta
? `Promedio últimos 10 años (${rango10Desde}${rango10Hasta})`
: 'Promedio últimos 10 años'
}
label={rango10Desde && rango10Hasta ? `Promedio últimos 10 años (${rango10Desde}${rango10Hasta})` : 'Promedio últimos 10 años'}
value={promedio10 ?? '—'}
color={COLOR.green}
/>
</div>
{/* Gráfico de tasa + barras de víctimas */}
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
{/* Serie histórica + barras de víctimas */}
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr] items-start">
<ChartCard
kicker="Análisis histórico"
title="Evolución de la tasa de siniestralidad"
subtitle="Serie histórica de siniestros y tasa de víctimas fatales cada 100.000 habitantes."
height="lg"
>
<SerieHistorica
year={yearNum}
siniestrosActual={kpis.total}
victimasActual={kpis.victimas}
/>
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="mb-6">
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-opsv-muted">
Víctimas fatales
</p>
<h3 className="mt-2 text-xl font-black text-opsv-navy">
Evolución anual
</h3>
</div>
</ChartCard>
<ChartCard
kicker="Víctimas fatales"
title="Evolución anual"
subtitle="Cantidad de víctimas fatales por año en el periodo histórico registrado."
height="lg"
>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={victimasPorAno}
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke={gridColor}
vertical={false}
/>
<XAxis
dataKey="ano"
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: tickColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<BarChart data={victimasPorAno} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
<XAxis dataKey="ano" tick={{ fill: tickColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: tickColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{
background: tooltipBg,
@@ -239,15 +178,47 @@ export default function SecHistorica({ siniestros, year }) {
<Bar dataKey="victimas" fill={COLOR.red} radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="mt-4 space-y-1">
<p className="text-sm text-amber-600/80">
<span className="font-semibold">Nota:</span> 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.
</p>
</div>
</div>
</ChartCard>
</div>
{/* Distribución por tipo, franja y localidad */}
{/* Tipo, franja y localidad */}
<div className="grid gap-6 xl:grid-cols-3">
<ChartCard
kicker="Características"
title="Por tipo de siniestro"
subtitle="Distribución según el tipo de evento vial registrado en el periodo."
height="md"
>
<PorTipoSiniestro siniestros={siniestros} />
</ChartCard>
<ChartCard
kicker="Condiciones temporales"
title="Franja horaria"
subtitle="Cantidad de siniestros según la franja horaria en que ocurrieron."
height="md"
>
<FranjaHoraria siniestros={siniestros} />
</ChartCard>
<ChartCard
kicker="Distribución territorial"
title="Top 10 localidades"
subtitle="Las 10 localidades con mayor cantidad de siniestros en el periodo seleccionado."
height="md"
>
<PorLocalidad siniestros={siniestros} />
</ChartCard>
</div>
{/* Tabla histórica */}
@@ -261,24 +232,13 @@ export default function SecHistorica({ siniestros, year }) {
</h3>
</div>
<div className="overflow-x-auto">
<table
className="min-w-full text-left text-sm"
style={{ borderCollapse: 'collapse' }}
>
<table className="min-w-full text-left text-sm" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr className="border-b-2 border-opsv-border">
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
Año
</th>
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
Siniestros
</th>
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">
Víctimas
</th>
<th className="pb-3 font-bold uppercase tracking-wider text-opsv-navy">
Tasa
</th>
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">Año</th>
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">Siniestros</th>
<th className="pb-3 pr-6 font-bold uppercase tracking-wider text-opsv-navy">Víctimas</th>
<th className="pb-3 font-bold uppercase tracking-wider text-opsv-navy">Tasa</th>
</tr>
</thead>
<tbody>
@@ -286,21 +246,14 @@ export default function SecHistorica({ siniestros, year }) {
<tr
key={row.ano}
className={`border-b border-opsv-border/60 transition-colors hover:bg-opsv-bg ${
row.ano === yearNum &&
!SERIE_HISTORICA.find((r) => r.ano === yearNum)
row.ano === yearNum && !SERIE_HISTORICA.find((r) => r.ano === yearNum)
? 'bg-blue-500/5 font-semibold'
: ''
}`}
>
<td className="py-3 pr-6 font-medium text-opsv-navy">
{row.ano}
</td>
<td className="py-3 pr-6 text-opsv-text">
{row.siniestros}
</td>
<td className="py-3 pr-6 text-opsv-text">
{row.victimas ?? '—'}
</td>
<td className="py-3 pr-6 font-medium text-opsv-navy">{row.ano}</td>
<td className="py-3 pr-6 text-opsv-text">{row.siniestros}</td>
<td className="py-3 pr-6 text-opsv-text">{row.victimas ?? '—'}</td>
<td className="py-3 text-opsv-text">{row.tasa ?? '—'}</td>
</tr>
))}
@@ -308,6 +261,7 @@ export default function SecHistorica({ siniestros, year }) {
</table>
</div>
</div>
</div>
)
}
+96 -26
View File
@@ -5,9 +5,10 @@ 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(
@@ -21,24 +22,99 @@ export default function SecLesionados({ siniestros, personas, involucrados }) {
)
const total = filtrarLesionados.length
const lesionados = filtrarLesionados.reduce(
(acc, s) => acc + Number((s.cantidad_lesionados ?? s.heridos) || 0),
0,
)
const diarios = total ? (total / 365).toFixed(1) : '0.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 (
<div className="space-y-8">
{/* KPIs */}
{/* ── KPIs ── */}
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KPICard label="Siniestros con lesionados" value={total} color="#CD9F2B" />
<KPICard label="Total lesionados" value={lesionados} color="#E8881A" />
<KPICard label="Promedio diario" value={diarios} unit="x día" color="#80B0DE" />
<KPICard label="% sobre total" value={pct} color="#337C58" />
<KPICard label="Heridos graves" value={graves} color="#C44228" />
<KPICard label="Heridos leves" value={leves} color="#E8881A" />
<KPICard label="% sobre total siniestros" value={pct} color="#337C58" />
</div>
{/* Evolución y tipo de siniestro */}
{/* ── Bloque desglose graves / leves ── */}
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">
Personas lesionadas · gravedad
</p>
<h3 className="mt-2 text-xl font-black text-opsv-navy dark:text-white">
Proporción de Heridos graves y heridos leves
</h3>
{totalPersonas === 0 ? (
<p className="mt-4 text-sm text-opsv-muted">Sin datos de gravedad para el periodo seleccionado.</p>
) : (
<>
{/* Barra proporcional */}
<div className="mt-6 flex h-5 w-full overflow-hidden rounded-full">
<div
className="flex items-center justify-center text-[11px] font-bold text-white transition-all"
style={{ width: `${pctGraves}%`, backgroundColor: '#C44228' }}
>
{pctGraves >= 10 ? `${pctGraves}%` : ''}
</div>
<div
className="flex items-center justify-center text-[11px] font-bold text-white transition-all"
style={{ width: `${pctLeves}%`, backgroundColor: '#E8881A' }}
>
{pctLeves >= 10 ? `${pctLeves}%` : ''}
</div>
</div>
{/* Tarjetas de detalle */}
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Heridos graves */}
<div className="flex items-center gap-4 rounded-2xl bg-white dark:bg-opsv-bg border border-opsv-border dark:border-slate-700 p-5">
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: '#C44228' }} />
<div>
<p className="text-2xl font-black text-opsv-text">{graves}</p>
<p className="text-sm font-semibold text-opsv-muted">
Heridos graves {pctGraves}% del total
</p>
<p className="mt-1 text-xs text-opsv-muted">
Personas con lesiones de gravedad que requirieron atención médica de mayor complejidad.
</p>
</div>
</div>
{/* Heridos leves */}
<div className="flex items-center gap-4 rounded-2xl bg-white dark:bg-opsv-bg border border-opsv-border dark:border-slate-700 p-5">
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: '#E8881A' }} />
<div>
<p className="text-2xl font-black text-opsv-text">{leves}</p>
<p className="text-sm font-semibold text-opsv-muted">
Heridos leves {pctLeves}% del total
</p>
<p className="mt-1 text-xs text-opsv-muted">
Personas con lesiones de menor gravedad atendidas en el lugar o con derivación ambulatoria.
</p>
</div>
</div>
</div>
</>
)}
</div>
{/* ── Evolución y tipo de siniestro ── */}
<div className="grid gap-6 xl:grid-cols-[1.6fr_1fr] items-start">
<ChartCard
kicker="Evolución temporal"
@@ -59,17 +135,17 @@ export default function SecLesionados({ siniestros, personas, involucrados }) {
</ChartCard>
</div>
{/* Franja horaria */}
{/* ── Franja horaria ── */}
<ChartCard
kicker="Condiciones temporales"
title="Franja horaria de los siniestros con lesionados"
subtitle="Cantidad de siniestros con lesionados según la franja horaria del día en que ocurrieron."
title="Franja horaria de los siniestros con lesionados según zona de ocurrencia"
subtitle="Cantidad de siniestros con lesionados según la franja horaria y la zona en que ocurrieron."
height="md"
>
<FranjaHoraria siniestros={filtrarLesionados} />
</ChartCard>
{/* Distribución territorial */}
{/* ── Distribución territorial ── */}
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr] items-start">
<ChartCard
kicker="Distribución territorial"
@@ -90,25 +166,19 @@ export default function SecLesionados({ siniestros, personas, involucrados }) {
</ChartCard>
</div>
{/* Perfil de víctimas */}
{/* ── Perfil de víctimas ── */}
<ChartCard
kicker="Perfil de víctimas"
title="Características de las personas lesionadas"
subtitle="Distribución de las personas lesionadas según edad, género y Tipo de Vehículo."
subtitle="Distribución de las personas lesionadas según edad, género y tipo de vehículo."
height="lg"
>
<PerfilVictimas personas={personas} involucrados={involucrados} />
<PerfilVictimas
personas={personasFiltradas}
involucrados={involucrados}
/>
</ChartCard>
{/* Seguridad pasiva */}
<ChartCard
kicker="Seguridad pasiva"
title="Uso de elementos de protección"
subtitle="Uso de cinturón de seguridad, casco y otros elementos de protección en los siniestros con lesionados."
height="auto"
>
<ProteccionPersonas personas={personas} involucrados={involucrados} />
</ChartCard>
</div>
)
}
+173
View File
@@ -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 (
<div className="space-y-6">
{/* ── KPIs de cobertura ── */}
<div data-pdf-block className="grid gap-4 sm:grid-cols-3">
<KPICard label="Siniestros georreferenciados" value={conCoords} color="#252C61" />
<KPICard label="Sin coordenadas" value={sinCoords} color="#6B7280" />
<KPICard label="Cobertura" value={`${pctCobertura}%`} color="#337C58" />
</div>
{/* ── Aviso si hay siniestros sin coords ── */}
{sinCoords > 0 && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 dark:border-amber-900/40 dark:bg-amber-950/20 px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
<strong>{sinCoords} siniestro{sinCoords > 1 ? 's' : ''}</strong> no {sinCoords > 1 ? 'tienen' : 'tiene'} coordenadas registradas y no {sinCoords > 1 ? 'aparecen' : 'aparece'} en el mapa.
</div>
)}
{/* ── Mapa ── */}
<div data-pdf-block>
<ChartCard
kicker="Distribución geográfica"
title="Mapa de siniestros viales"
subtitle="Ubicación georreferenciada de los siniestros del período seleccionado. Los colores indican la gravedad del evento."
height="auto"
>
{/* Controles */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
{/* Toggle vista */}
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-opsv-muted dark:text-slate-400">
Vista
</span>
<div className="flex gap-1">
{[
{ value: 'marcadores', label: 'Marcadores' },
{ value: 'calor', label: 'Mapa de calor' },
].map((op) => (
<button
key={op.value}
onClick={() => setVista(op.value)}
className={`${btnBase} ${vista === op.value ? btnActive : btnInactive}`}
>
{op.label}
</button>
))}
</div>
</div>
{/* Filtro gravedad */}
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-opsv-muted dark:text-slate-400">
Gravedad
</span>
<div className="flex gap-1 flex-wrap">
{FILTROS_GRAVEDAD.map((op) => (
<button
key={op.value}
onClick={() => setGravedad(op.value)}
className={`${btnBase} ${gravedad === op.value ? btnActive : btnInactive}`}
>
{op.label}
</button>
))}
</div>
</div>
</div>
{/* Leyenda */}
{vista === 'marcadores' && (
<div className="mb-3 flex flex-wrap gap-4">
{[
{ color: '#C44228', label: 'Fatal' },
{ color: '#E8881A', label: 'Lesionado' },
{ color: '#6B7280', label: 'Sin lesiones' },
].map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<div
className="h-3 w-3 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: item.color }}
/>
<span className="text-xs text-opsv-muted dark:text-slate-400">{item.label}</span>
</div>
))}
</div>
)}
{/* Mapa */}
<MapaSiniestros
siniestros={siniestrosFiltrados}
siniestrosMapa={siniestrosMapaFiltrados}
vista={vista}
darkMode={darkMode}
/>
</ChartCard>
</div>
</div>
)
}
+70 -23
View File
@@ -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 (
<div className="space-y-8">
<div data-pdf-block className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{/* ── Fila 1: KPIs de siniestros ── */}
<div data-pdf-block className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">
Siniestros
</p>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KPICard label="Total siniestros" value={kpis.total} color="#252C61" />
<KPICard label="Siniestros fatales" value={kpis.fatales} color="#C44228" />
<KPICard label="Con lesionados" value={kpis.conLes} color="#CD9F2B" />
<KPICard label="Sin lesiones" value={kpis.sinLes} color="#337C58" />
<KPICard label="Victimas fatales" value={kpis.victimas} color="#922B21" />
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.7fr_1fr]">
{/* ── Fila 2: KPIs de personas ── */}
<div data-pdf-block className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">
Personas involucradas
</p>
<div className="grid gap-4 sm:grid-cols-3">
<KPICard label="Víctimas fatales" value={kpis.victimas} color="#922B21" />
<KPICard label="Heridos graves" value={lesionados.graves} color="#C44228" badge="personas" />
<KPICard label="Heridos leves" value={lesionados.leves} color="#CD9F2B" badge="personas" />
</div>
</div>
{/* Evolución y gravedad */}
<div className="grid gap-6 xl:grid-cols-[1.7fr_1fr] items-start">
<ChartCard
kicker="Evolución temporal"
title="Siniestros por mes"
subtitle="Cantidad total de siniestros viales agregados por mes del periodo seleccionado."
height="lg"
>
<SiniestrosPorMes siniestros={siniestros} />
</ChartCard>
<ChartCard
kicker="Gravedad"
title="Distribución por gravedad"
subtitle="Proporción de siniestros según su nivel de gravedad: fatales, con lesionados y sin lesiones."
height="auto"
>
<DonutGravedad siniestros={siniestros} />
</ChartCard>
</div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr]">
{/* Zona y tabla comparativa */}
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr] items-start">
<ChartCard
kicker="Distribución territorial"
title="Zona de ocurrencia"
subtitle="Proporción de siniestros ocurridos en ámbitos urbanos y rurales."
height="sm"
>
<ZonaOcurrencia siniestros={siniestros} />
</ChartCard>
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<div className="mb-6">
@@ -74,7 +111,6 @@ export default function SecResumen({ siniestros }) {
</th>
</tr>
</thead>
<tbody>
{tablaData.map((row, i) => (
<tr
@@ -101,6 +137,17 @@ export default function SecResumen({ siniestros }) {
)}
</div>
</div>
{/* Seguridad pasiva */}
<ChartCard
kicker="Seguridad pasiva"
title="Uso de elementos de protección"
subtitle="Uso de cinturón de seguridad, casco y otros elementos de protección en el total de personas involucradas. (Bases ajustadas por tipo de vehículo)"
height="auto"
>
<ProteccionPersonas personas={personas} involucrados={involucrados} />
</ChartCard>
</div>
)
}
+12 -3
View File
@@ -8,6 +8,7 @@ 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(
() =>
@@ -24,8 +25,15 @@ export default function SecSinLesiones({ siniestros, involucrados }) {
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 (
<div className="space-y-8">
{/* KPIs */}
<div data-pdf-block className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<KPICard label="Siniestros sin lesiones" value={total} color="#337C58" />
@@ -57,8 +65,8 @@ export default function SecSinLesiones({ siniestros, involucrados }) {
{/* Franja horaria */}
<ChartCard
kicker="Condiciones temporales"
title="Franja horaria de los siniestros sin lesiones"
subtitle="Cantidad de siniestros sin lesiones según la franja horaria del día en que ocurrieron y la zona."
title="Franja horaria de los siniestros sin lesiones según zona de ocurrencia"
subtitle="Cantidad de siniestros sin lesiones según la franja horaria y la zona en que ocurrieron."
height="auto"
>
<FranjaHoraria siniestros={filtrarSinLesiones} />
@@ -93,8 +101,9 @@ export default function SecSinLesiones({ siniestros, involucrados }) {
height="auto"
className="mx-auto w-full max-w-4xl"
>
<TipoInvolucrado involucrados={involucrados} />
<TipoInvolucrado involucrados={involucradosFiltrados} /> {/* ← NUEVO */}
</ChartCard>
</div>
)
}
+99 -46
View File
@@ -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 (
<ChartCard kicker={kicker} title={title} height="auto" contentClassName="min-h-0">
<div className="flex flex-col gap-3">
{/* Dato destacado */}
<div
className="flex flex-col justify-center rounded-[14px] px-4 py-4"
style={{ background: `${color}12` }}
@@ -22,8 +21,6 @@ function Ficha({ kicker, title, color, destacado, datos }) {
{destacado.valor ?? '—'}
</p>
</div>
{/* Grilla de datos secundarios */}
<div className="grid grid-cols-2 gap-2">
{datos.map(({ label, valor }) => (
<div key={label} className="rounded-[12px] bg-opsv-bg px-3 py-2.5">
@@ -32,36 +29,65 @@ function Ficha({ kicker, title, color, destacado, datos }) {
</div>
))}
</div>
</div>
</ChartCard>
)
}
// ── 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 (
<div className="space-y-6">
{/* ── Siniestralidad general ── */}
<ChartCard
kicker="Resumen general"
title="Siniestralidad del período"
height="auto"
contentClassName="min-h-0"
>
<ChartCard kicker="Resumen general" title="Siniestralidad del período" height="auto" contentClassName="min-h-0">
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
{[
{ label: 'Total siniestros', valor: s.kpis.total, color: COLOR.navy },
@@ -107,36 +135,60 @@ export default function SecSintesis({ siniestros, personas, involucrados }) {
</div>
))}
</div>
{/* ── 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 (
<div className="mt-3 rounded-[14px] border border-opsv-border bg-opsv-surface px-4 py-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.3em] text-opsv-muted">
Desglose de personas lesionadas
</p>
<div className="flex h-4 w-full overflow-hidden rounded-full">
<div
className="flex items-center justify-center text-[10px] font-bold text-white"
style={{ width: `${pctG}%`, backgroundColor: COLOR.fatales }}
>
{pctG >= 12 ? `${pctG}%` : ''}
</div>
<div
className="flex items-center justify-center text-[10px] font-bold text-white"
style={{ width: `${pctL}%`, backgroundColor: COLOR.conLes }}
>
{pctL >= 12 ? `${pctL}%` : ''}
</div>
</div>
<div className="mt-2 flex gap-4 text-xs text-opsv-muted">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: COLOR.fatales }} />
<strong className="text-opsv-navy">{lesionados.graves}</strong> heridos graves ({pctG}%)
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: COLOR.conLes }} />
<strong className="text-opsv-navy">{lesionados.leves}</strong> heridos leves ({pctL}%)
</span>
</div>
</div>
)
})()}
</ChartCard>
{/* ── Contexto histórico ── */}
<ChartCard
kicker="Serie histórica"
title="Evolución de víctimas fatales"
subtitle="Excluye 2020 por restricciones de movilidad COVID-19"
height="auto"
contentClassName="min-h-0"
>
<ChartCard kicker="Serie histórica" title="Evolución de víctimas fatales" subtitle="Excluye 2020 por restricciones de movilidad COVID-19" height="auto" contentClassName="min-h-0">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
<p className="text-3xl font-black text-opsv-navy">{anoMaxVictimas.ano}</p>
<p className="mt-1 text-sm text-opsv-muted">
Año más crítico ({anoMaxVictimas.victimas} víctimas)
</p>
<p className="mt-1 text-sm text-opsv-muted">Año más crítico ({anoMaxVictimas.victimas} víctimas)</p>
</div>
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
<p className="text-3xl font-black text-opsv-navy">{anoMinVictimas.ano}</p>
<p className="mt-1 text-sm text-opsv-muted">
Año más bajo ({anoMinVictimas.victimas} víctimas)
</p>
<p className="mt-1 text-sm text-opsv-muted">Año más bajo ({anoMinVictimas.victimas} víctimas)</p>
</div>
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
<p className="text-3xl font-black text-opsv-navy">
{penultimoAno?.victimas ?? '—'}
</p>
<p className="mt-1 text-sm text-opsv-muted">
Víctimas {penultimoAno?.ano ?? '—'}
</p>
<p className="text-3xl font-black text-opsv-navy">{penultimoAno?.victimas ?? '—'}</p>
<p className="mt-1 text-sm text-opsv-muted">Víctimas {penultimoAno?.ano ?? '—'}</p>
</div>
<div className="rounded-[14px] bg-opsv-bg px-4 py-3 text-center">
<p className="text-3xl font-black" style={{ color: tendenciaColor }}>
@@ -146,15 +198,15 @@ export default function SecSintesis({ siniestros, personas, involucrados }) {
)}
</p>
<p className="mt-1 text-sm text-opsv-muted">
Tendencia {penultimoAno?.ano}{ultimoAno?.ano}
Tendencia {penultimoAno?.ano ?? '—'}{ultimoAno?.ano ?? '—'}
</p>
</div>
</div>
</ChartCard>
{/* ── Fichas por tipo: 3 columnas en desktop ── */}
{/* ── Fichas por tipo ── */}
<div className="grid gap-6 xl:grid-cols-3">
{fichas.map(({ kicker, title, color, bloque }) => (
{fichas.map(({ kicker, title, color, bloque, extraDatos }) => (
<Ficha
key={title}
kicker={kicker}
@@ -169,6 +221,7 @@ export default function SecSintesis({ siniestros, personas, involucrados }) {
{ label: 'Género predominante', valor: bloque.genero },
{ label: 'Rango etario más afectado', valor: bloque.rangoEtario },
{ label: 'Tipo de involucrado', valor: bloque.tipoInvolucrado },
...extraDatos,
]}
/>
))}
+218 -165
View File
@@ -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 (
<text
x={x} y={y}
fill="white"
fill="currentColor"
className="text-opsv-navy dark:text-white"
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
fontSize={13}
fontWeight="700"
>
{`${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]
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: 'dato',
texto: `La Ruta ${topRuta.ruta} concentró la mayor cantidad de siniestros con ${topRuta.total} evento${topRuta.total !== 1 ? 's' : ''}.`,
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 (
<div
key={i}
className={`flex gap-3 rounded-2xl border p-4 ${cfg.bg} ${cfg.border}`}
>
<div key={i} className={`flex gap-3 rounded-2xl border p-4 ${cfg.bg} ${cfg.border}`}>
<span className="mt-0.5 text-lg leading-none">{cfg.icon}</span>
<div className="flex flex-col gap-1">
<span className={`text-sm font-bold uppercase tracking-widest ${cfg.labelColor}`}>
{cfg.label}
</span>
<p className="text-[16px] leading-relaxed text-slate-900 dark:text-slate-100 font-medium">
{insight.texto}
</p>
<span className={`text-sm font-bold uppercase tracking-widest ${cfg.labelColor}`}>{cfg.label}</span>
<p className="text-[16px] leading-relaxed text-slate-900 dark:text-slate-100 font-medium">{insight.texto}</p>
</div>
</div>
)
@@ -251,14 +227,9 @@ function CustomTooltip({ active, payload, label }) {
function BarHorizontalStacked({ data, nameKey }) {
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
layout="vertical"
margin={{ top: 4, right: 16, left: 8, bottom: 4 }}
barCategoryGap="30%"
>
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 16, left: 30, bottom: 4 }} barCategoryGap="30%">
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11, fill: '#4A5568' }} />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 11, fill: '#4A5568' }} />
<YAxis type="category" dataKey={nameKey} width={130} tick={{ fontSize: 11, fill: '#4A5568' }} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
@@ -271,8 +242,73 @@ function BarHorizontalStacked({ data, nameKey }) {
}
// ── 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 (
<div data-pdf-block className="rounded-[28px] border border-opsv-border bg-opsv-surface p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">
Personas lesionadas en ruta · {campanaLabel}
</p>
<h3 className="mt-2 text-xl font-black text-opsv-navy dark:text-white">
Heridos graves vs. heridos leves
</h3>
{/* Barra proporcional */}
<div className="mt-6 flex h-5 w-full overflow-hidden rounded-full">
<div
className="flex items-center justify-center text-[11px] font-bold text-white transition-all"
style={{ width: `${pctGraves}%`, backgroundColor: COLOR.fatales }}
>
{pctGraves >= 10 ? `${pctGraves}%` : ''}
</div>
<div
className="flex items-center justify-center text-[11px] font-bold text-white transition-all"
style={{ width: `${pctLeves}%`, backgroundColor: COLOR.conLes }}
>
{pctLeves >= 10 ? `${pctLeves}%` : ''}
</div>
</div>
{/* Tarjetas */}
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex items-center gap-4 rounded-2xl bg-opsv-bg p-5">
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: COLOR.fatales }} />
<div>
<p className="text-2xl font-black text-opsv-text">{graves}</p>
<p className="text-sm font-semibold text-opsv-muted">
Heridos graves {pctGraves}% del total
</p>
<p className="mt-1 text-xs text-opsv-muted">
Personas con lesiones de gravedad registradas en rutas y caminos provinciales.
</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-2xl bg-opsv-bg p-5">
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: COLOR.conLes }} />
<div>
<p className="text-2xl font-black text-opsv-text">{leves}</p>
<p className="text-sm font-semibold text-opsv-muted">
Heridos leves {pctLeves}% del total
</p>
<p className="mt-1 text-xs text-opsv-muted">
Personas con lesiones de menor gravedad registradas en rutas y caminos provinciales.
</p>
</div>
</div>
</div>
</div>
)
}
// ── 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,7 +320,8 @@ 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])
@@ -293,23 +330,47 @@ export default function SecVeranoVivo({ siniestros }) {
const graficoE = useMemo(() => rankingLocalidades(datosActual), [datosActual])
const graficoF = useMemo(() => agruparConUmbral(tiposSiniestroVV(datosActual), 10), [datosActual])
// ✅ graficoCRural (no graficoC) — datos de ruta para los insights
// ── 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]
)
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 (
<div className="space-y-6">
{/* Encabezado + selector de campaña */}
{/* Encabezado + selector */}
<div data-pdf-block className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-opsv-muted">Campaña</p>
<h2 className="mt-1 text-2xl font-black text-opsv-navy dark:text-white">Verano Vivo</h2>
<p className="text-sm text-slate-500">20 de diciembre al 20 de marzo</p>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
{CAMPANAS_VV.map((c, i) => (
<button
key={c.label}
@@ -326,20 +387,27 @@ export default function SecVeranoVivo({ siniestros }) {
</div>
</div>
{/* KPIs */}
{/* KPIs — siniestros en ruta */}
<div data-pdf-block>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.3em] text-opsv-muted">
Rutas y caminos de la provincia · {campanaActual.label}
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiVV label="Total siniestros en ruta" value={kpis.total} color={COLOR.navy} />
<KpiVV label="Fatales en Ruta" value={kpis.fatales} color={COLOR.fatales} />
<KpiVV label="Con lesiones en Ruta" value={kpis.conLes} color={COLOR.conLes} />
<KpiVV label="Sin lesiones en Ruta" value={kpis.sinLes} color={COLOR.sinLes} />
<KpiVV label="Fatales en ruta" value={kpis.fatales} color={COLOR.fatales} />
<KpiVV label="Con lesiones en ruta" value={kpis.conLes} color={COLOR.conLes} />
<KpiVV label="Sin lesiones en ruta" value={kpis.sinLes} color={COLOR.sinLes} />
</div>
</div>
{/* Gráfico A — Serie histórica · ancho completo */}
{/* ── NUEVO: bloque lesionados por gravedad ── */}
<BloqueLesionadosVV
graves={lesionadosVV.graves}
leves={lesionadosVV.leves}
campanaLabel={campanaActual.label}
/>
{/* Gráfico A — Serie histórica */}
<ChartCard
kicker="Serie histórica"
title="Evolución de siniestros fatales en ruta"
@@ -347,16 +415,16 @@ export default function SecVeranoVivo({ siniestros }) {
height="md"
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={HISTORICO_VERANO_VIVO} margin={{ top: 16, right: 24, left: 0, bottom: 8 }}>
<LineChart data={historicoVV} margin={{ top: 16, right: 24, left: 0, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} domain={[0, 14]} />
<YAxis allowDecimals={false} tick={{ fontSize: 11, fill: '#4A5568' }} domain={[0, 14]} />
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={PROMEDIO_HISTORICO_VV}
y={promedioHistoricoVV}
stroke={COLOR.conLes}
strokeDasharray="6 3"
label={{ value: `Promedio ${PROMEDIO_HISTORICO_VV}`, position: 'insideTopRight', fontSize: 11, fill: COLOR.conLes }}
label={{ value: `Promedio ${promedioHistoricoVV}`, position: 'insideTopRight', fontSize: 11, fill: COLOR.conLes }}
/>
<Line
type="monotone"
@@ -371,41 +439,45 @@ export default function SecVeranoVivo({ siniestros }) {
</ResponsiveContainer>
</ChartCard>
{/* Gráfico B — Comparación histórica · ancho completo */}
<ChartCard
kicker="Comparación entre campañas"
title="Siniestros por zona y severidad"
subtitle="Incluye siniestros en rutas/caminos y en zonas urbanas."
height="lg"
>
{/* Gráfico B — Comparación histórica por zona */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ChartCard kicker="Comparación entre campañas" title="Rutas y caminos" subtitle="Evolución histórica de siniestros fatales y con lesiones en zona rural." height="lg">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={graficoB} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="25%" barGap={2}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
<YAxis allowDecimals={false} domain={[0, maxComparativoZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="ruralFatal" name="Ruta — fatal" fill={COLOR.fatales} radius={[3,3,0,0]} />
<Bar dataKey="urbanaFatal" name="Urbano — fatal" fill={COLOR.orange} radius={[3,3,0,0]} />
<Bar dataKey="ruralLes" name="Ruta — con lesiones" fill={COLOR.navy} radius={[3,3,0,0]} />
<Bar dataKey="urbanaLes" name="Urbano — con lesiones" fill={COLOR.blue} radius={[3,3,0,0]} />
<Bar dataKey="ruralFatal" name="Fatales" fill={COLOR.fatales} radius={[3,3,0,0]} />
<Bar dataKey="ruralLes" name="Con lesiones" fill={COLOR.conLes} radius={[3,3,0,0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Gráficos C — Siniestros por mes separados por zona · grid 2 col */}
<ChartCard kicker="Comparación entre campañas" title="Ejido urbano" subtitle="Evolución histórica de siniestros fatales y con lesiones en zona urbana." height="lg">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={graficoB} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="25%" barGap={2}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis dataKey="campaña" tick={{ fontSize: 11, fill: '#4A5568' }} />
<YAxis allowDecimals={false} domain={[0, maxComparativoZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="urbanaFatal" name="Fatales" fill={COLOR.orange} radius={[3,3,0,0]} />
<Bar dataKey="urbanaLes" name="Con lesiones" fill={COLOR.blue} radius={[3,3,0,0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* Gráficos C — Siniestros por mes por zona */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ChartCard
kicker={`Rutas y caminos · ${campanaActual.label}`}
title="Siniestros por mes en ruta"
subtitle="Solo siniestros ocurridos en rutas y caminos provinciales."
height="lg"
>
<ChartCard kicker={`Rutas y caminos · ${campanaActual.label}`} title="Siniestros por mes en ruta" subtitle="Solo siniestros ocurridos en rutas y caminos provinciales." height="lg">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={graficoCRural} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="35%">
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis dataKey="mes" tick={{ fontSize: 12, fill: '#4A5568' }} />
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
<YAxis allowDecimals={false} domain={[0, maxMensualZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" />
@@ -415,17 +487,12 @@ export default function SecVeranoVivo({ siniestros }) {
</ResponsiveContainer>
</ChartCard>
<ChartCard
kicker={`Zona urbana · ${campanaActual.label}`}
title="Siniestros por mes en ejido urbano"
subtitle="Solo siniestros ocurridos en ejidos urbanos de la provincia."
height="lg"
>
<ChartCard kicker={`Zona urbana · ${campanaActual.label}`} title="Siniestros por mes en ejido urbano" subtitle="Solo siniestros ocurridos en ejidos urbanos de la provincia." height="lg">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={graficoCUrbano} margin={{ top: 8, right: 16, left: 0, bottom: 8 }} barCategoryGap="35%">
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis dataKey="mes" tick={{ fontSize: 12, fill: '#4A5568' }} />
<YAxis tick={{ fontSize: 11, fill: '#4A5568' }} />
<YAxis allowDecimals={false} domain={[0, maxMensualZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" />
@@ -436,26 +503,16 @@ export default function SecVeranoVivo({ siniestros }) {
</ChartCard>
</div>
{/* Gráficos D y E — Rutas y Localidades · grid 2 col */}
{/* Gráficos D y E — Rutas y Localidades */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ChartCard
kicker={`Rutas y caminos · ${campanaActual.label}`}
title="Rutas con más siniestros"
subtitle="Siniestros ocurridos en Rutas de la Provincia."
height="lg"
>
<ChartCard kicker={`Rutas y caminos · ${campanaActual.label}`} title="Rutas con más siniestros" subtitle="Siniestros ocurridos en Rutas de la Provincia." height="lg">
{graficoD.length === 0
? <p className="text-sm text-opsv-muted text-center pt-8">Sin datos para esta campaña</p>
: <BarHorizontalStacked data={graficoD} nameKey="ruta" />
: <BarHorizontalStacked data={graficoDConEtiqueta} nameKey="viaCompleta" />
}
</ChartCard>
<ChartCard
kicker={`Zona urbana · ${campanaActual.label}`}
title="Localidades con más siniestros"
subtitle="Siniestros ocurridos en Ejidos Urbanos."
height="lg"
>
<ChartCard kicker={`Zona urbana · ${campanaActual.label}`} title="Localidades con más siniestros" subtitle="Siniestros ocurridos en Ejidos Urbanos." height="lg">
{graficoE.length === 0
? <p className="text-sm text-opsv-muted text-center pt-8">Sin datos para esta campaña</p>
: <BarHorizontalStacked data={graficoE} nameKey="localidad" />
@@ -463,41 +520,37 @@ export default function SecVeranoVivo({ siniestros }) {
</ChartCard>
</div>
{/* Gráfico F — Donut tipos · ancho completo */}
<ChartCard
kicker={`Solo rutas y caminos · ${campanaActual.label}`}
title="Tipo de siniestro en rutas y caminos"
subtitle="Solo siniestros en zona rural / ruta. Excluye siniestros en zona urbana."
height="sm"
>
<div className="h-[320px]">
{/* Gráfico F — Donut tipos */}
<ChartCard kicker={`Solo rutas y caminos · ${campanaActual.label}`} title="Tipo de siniestro en rutas y caminos" subtitle="Solo siniestros en zona rural / ruta. Excluye siniestros en zona urbana." height="auto">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.5fr_1fr] lg:items-start">
<div className="h-[380px] w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={graficoF}
dataKey="value"
nameKey="name"
cx="60%" cy="50%"
outerRadius={95} innerRadius={58}
paddingAngle={4}
labelLine={false}
label={CustomPieLabel}
>
<Pie data={graficoF} dataKey="value" nameKey="name" cx="50%" cy="52%" outerRadius={125} innerRadius={72} paddingAngle={4} labelLine={false} label={CustomPieLabel}>
{graficoF.map((entry, index) => (
<Cell key={entry.name} fill={COLORS_F[index % COLORS_F.length]} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
layout="vertical" verticalAlign="bottom" align="right" iconType="circle"
formatter={(value) => <span className="text-sm text-opsv-text">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-col gap-3 pt-1 lg:pt-2">
{graficoF.map((item, index) => (
<div key={item.name} className="rounded-2xl bg-opsv-bg p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-opsv-muted">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: COLORS_F[index % COLORS_F.length] }} />
{item.name}
</div>
<div className="mt-2 text-lg font-black text-opsv-text">{item.value}</div>
<div className="text-xs text-opsv-muted">{item.pct.toFixed(0)}% del total</div>
</div>
))}
</div>
</div>
</ChartCard>
{/* Bloque de insights · ancho completo */}
{/* Bloque de insights */}
<BloqueInsights insights={insights} campanaLabel={campanaActual.label} />
</div>
+77 -41
View File
@@ -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 } })
@@ -186,10 +209,8 @@ export function proteccionPasiva(personas, involucrados) {
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 airbagBase = habitaculo.length
const airbagUso = habitaculo.filter((p) => hasAirbag(p)).length
@@ -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) {
@@ -422,10 +429,36 @@ export function filtrarCampanaVV(siniestros, anoDesde) {
})
}
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
+123 -25
View File
@@ -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 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 // mm de separación entre bloques
const gap = 3
// ── Portada ───────────────────────────────────────────────────────────────
// ── 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 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)
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++