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:
Generated
+65
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
||||
import { calcularKPIs } from '../../utils/calculos'
|
||||
import { COLOR } from '../../utils/colores'
|
||||
|
||||
const sectors = [
|
||||
{ key: 'fatales', label: 'Fatales', color: COLOR.fatales },
|
||||
{ key: 'conLes', label: 'Con Lesionados', color: COLOR.conLes },
|
||||
{ key: 'sinLes', label: 'Sin Lesiones', color: COLOR.sinLes },
|
||||
{ key: 'fatales', label: 'Fatales', color: COLOR.fatales },
|
||||
{ key: 'conLes', label: 'Con Lesionados', color: COLOR.conLes },
|
||||
{ key: 'sinLes', label: 'Sin Lesiones', color: COLOR.sinLes },
|
||||
]
|
||||
|
||||
// Tooltip personalizado igual al estilo de los otros gráficos
|
||||
function CustomTooltip({ active, payload }) {
|
||||
if (!active || !payload?.length) return null
|
||||
const { name, value, color, pct } = payload[0].payload
|
||||
return (
|
||||
<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,
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 2013–2024: informes anuales OPSV Santa Cruz. Tasas 2025 en adelante:
|
||||
cálculo propio sobre proyecciones INDEC base Censo Nacional de Población,
|
||||
Hogares y Viviendas 2022.
|
||||
</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
|
||||
|
||||
@@ -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={{
|
||||
background: tooltipBg,
|
||||
border: `1px solid ${tooltipBorder}`,
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
<span>{item.name || item.dataKey}</span>
|
||||
</div>
|
||||
<span style={{ fontWeight: 700, color: tooltipLabel }}>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
labelStyle={{ color: tooltipLabel, fontWeight: 700 }}
|
||||
/>
|
||||
|
||||
<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]} />
|
||||
|
||||
@@ -6,19 +6,22 @@ import {
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
Sun,
|
||||
Map, // ← NUEVO
|
||||
} from 'lucide-react'
|
||||
|
||||
const SECCIONES = [
|
||||
{ id: 'resumen', label: 'Resumen General', icon: LayoutDashboard },
|
||||
{ id: 'historica', label: 'Serie Histórica Provincial', icon: TrendingUp },
|
||||
{ id: 'fatales', label: 'Siniestros Fatales', icon: AlertTriangle },
|
||||
{ id: 'lesionados', label: 'Con Lesionados', icon: Activity },
|
||||
{ id: 'sinlesiones', label: 'Sin Lesiones', icon: ShieldCheck },
|
||||
{ id: 'sintesis', label: 'Síntesis', icon: FileText },
|
||||
{ id: 'veranovivo', label: 'Campañas Verano Vivo', icon: Sun },
|
||||
|
||||
const SECCIONES = [
|
||||
{ id: 'resumen', label: 'Resumen General', icon: LayoutDashboard },
|
||||
{ id: 'historica', label: 'Serie Histórica Provincial', icon: TrendingUp },
|
||||
{ id: 'fatales', label: 'Siniestros Fatales', icon: AlertTriangle },
|
||||
{ id: 'lesionados', label: 'Con Lesionados', icon: Activity },
|
||||
{ id: 'sinlesiones',label: 'Sin Lesiones', icon: ShieldCheck },
|
||||
{ id: 'sintesis', label: 'Síntesis', icon: FileText },
|
||||
{ id: 'mapa', label: 'Mapa de Siniestros', icon: Map }, // ← NUEVO
|
||||
{ id: 'veranovivo', label: 'Campañas Verano Vivo', icon: Sun },
|
||||
]
|
||||
|
||||
|
||||
export default function Sidebar({
|
||||
seccion,
|
||||
setSeccion,
|
||||
@@ -33,23 +36,23 @@ export default function Sidebar({
|
||||
<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">
|
||||
Ministerio de Seguridad · Santa Cruz
|
||||
<div className="mt-2 text-[13px] uppercase tracking-[0.25em] text-opsv-blue text-center">
|
||||
Ministerio de Seguridad · Santa Cruz
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* ── 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"
|
||||
>
|
||||
Descargar PDF
|
||||
</button>
|
||||
|
||||
<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>
|
||||
{/* 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"
|
||||
/>
|
||||
)}
|
||||
|
||||
</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-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 />
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
// src/components/ui/ChartCard.jsx
|
||||
const HEIGHTS = {
|
||||
sm: 'h-[300px]',
|
||||
md: 'h-[360px]',
|
||||
lg: 'h-[420px]',
|
||||
sm: 'h-[300px]',
|
||||
md: 'h-[360px]',
|
||||
lg: 'h-[420px]',
|
||||
auto: '',
|
||||
}
|
||||
|
||||
@@ -15,10 +14,13 @@ export default function ChartCard({
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
}) {
|
||||
const isFixed = height !== 'auto'
|
||||
|
||||
return (
|
||||
<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,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 ? (
|
||||
|
||||
+22
-18
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { supabasePublic } from '../lib/supabase'
|
||||
|
||||
|
||||
function estaEnPeriodo(item, periodo) {
|
||||
if (!periodo?.desde || !periodo?.hasta) return true
|
||||
|
||||
@@ -14,12 +15,14 @@ function estaEnPeriodo(item, periodo) {
|
||||
return actualValor >= desdeValor && actualValor <= hastaValor
|
||||
}
|
||||
|
||||
|
||||
export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
||||
const [siniestros, setSiniestros] = useState([])
|
||||
const [involucrados, setInvolucrados] = useState([])
|
||||
const [personas, setPersonas] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [siniestros, setSiniestros] = useState([])
|
||||
const [involucrados, setInvolucrados] = useState([])
|
||||
const [personas, setPersonas] = useState([])
|
||||
const [siniestrosMapa, setSiniestrosMapa] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const periodoKey = JSON.stringify(periodo)
|
||||
|
||||
@@ -34,6 +37,9 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
||||
let qS = supabasePublic.from('siniestros').select('*')
|
||||
let qI = supabasePublic.from('Involucrados').select('*')
|
||||
let qP = supabasePublic.from('Personas').select('*')
|
||||
let qM = supabasePublic
|
||||
.from('siniestros_mapa')
|
||||
.select('id_feu, ano, mes, latitud_norm, longitud_norm')
|
||||
|
||||
if (periodoActivo) {
|
||||
const anoDesde = periodoActual.desde.ano
|
||||
@@ -42,39 +48,37 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
||||
qS = qS.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||
qI = qI.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||
qP = qP.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||
qM = qM.gte('ano', anoDesde).lte('ano', anoHasta)
|
||||
} else if (year) {
|
||||
qS = qS.eq('ano', year)
|
||||
qI = qI.eq('ano', year)
|
||||
qP = qP.eq('ano', year)
|
||||
qM = qM.eq('ano', year)
|
||||
}
|
||||
|
||||
const [resS, resI, resP] = await Promise.all([qS, qI, qP])
|
||||
const [resS, resI, resP, resM] = await Promise.all([qS, qI, qP, qM])
|
||||
|
||||
if (resS.error) throw resS.error
|
||||
if (resI.error) throw resI.error
|
||||
if (resP.error) throw resP.error
|
||||
if (resM.error) throw resM.error
|
||||
|
||||
let dataS = resS.data || []
|
||||
let dataI = resI.data || []
|
||||
let dataP = resP.data || []
|
||||
let dataM = resM.data || []
|
||||
|
||||
if (periodoActivo) {
|
||||
dataS = dataS.filter(item => estaEnPeriodo(item, periodoActual))
|
||||
dataI = dataI.filter(item => estaEnPeriodo(item, periodoActual))
|
||||
dataP = dataP.filter(item => estaEnPeriodo(item, periodoActual))
|
||||
dataS = dataS.filter((item) => estaEnPeriodo(item, periodoActual))
|
||||
dataI = dataI.filter((item) => estaEnPeriodo(item, periodoActual))
|
||||
dataP = dataP.filter((item) => estaEnPeriodo(item, periodoActual))
|
||||
dataM = dataM.filter((item) => estaEnPeriodo(item, periodoActual))
|
||||
}
|
||||
|
||||
console.log('useData fetch', {
|
||||
year,
|
||||
periodo: periodoActual,
|
||||
siniestros: dataS.length,
|
||||
involucrados: dataI.length,
|
||||
personas: dataP.length,
|
||||
})
|
||||
|
||||
setSiniestros(dataS)
|
||||
setInvolucrados(dataI)
|
||||
setPersonas(dataP)
|
||||
setSiniestrosMapa(dataM)
|
||||
} catch (err) {
|
||||
setError(err?.message || String(err))
|
||||
console.error('useData error', err)
|
||||
@@ -87,5 +91,5 @@ export function useData(year = null, periodo = { desde: null, hasta: null }) {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
return { siniestros, involucrados, personas, loading, error, refetch: fetchData }
|
||||
return { siniestros, involucrados, personas, siniestrosMapa, loading, error, refetch: fetchData }
|
||||
}
|
||||
+12
-1
@@ -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"] * {
|
||||
|
||||
+105
-24
@@ -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() {
|
||||
const [seccion, setSeccion] = useState('resumen')
|
||||
const [year, setYear] = useState(2025)
|
||||
const [periodo, setPeriodo] = useState({ desde: null, hasta: null })
|
||||
const [departamentoFiltro, setDepartamentoFiltro] = useState('')
|
||||
const [localidadFiltro, setLocalidadFiltro] = useState('')
|
||||
const [modalPdf, setModalPdf] = useState(false)
|
||||
|
||||
// Hook principal — año seleccionado por el usuario
|
||||
const { siniestros, personas, involucrados, loading, error } = useData(year, periodo)
|
||||
export default function Dashboard() {
|
||||
// ── Estados ──────────────────────────────────────────────────────────────
|
||||
const [seccion, setSeccion] = useState('resumen')
|
||||
const [year, setYear] = useState(2025)
|
||||
const [periodo, setPeriodo] = useState({ desde: null, hasta: null })
|
||||
const [departamentoFiltro, setDepartamentoFiltro] = useState('')
|
||||
const [localidadFiltro, setLocalidadFiltro] = useState('')
|
||||
const [zonaFiltro, setZonaFiltro] = useState('')
|
||||
const [modalPdf, setModalPdf] = useState(false)
|
||||
|
||||
// ── Hook principal — año seleccionado por el usuario ─────────────────────
|
||||
const { siniestros, personas, involucrados, siniestrosMapa, loading, error } = useData(year, periodo)
|
||||
|
||||
// ── Departamentos disponibles para el año/período activo ─────────────────
|
||||
const departamentosDisponibles = useMemo(() => {
|
||||
@@ -78,10 +84,18 @@ export default function Dashboard() {
|
||||
return [...new Set(base.map(s => s.localidad).filter(Boolean))].sort()
|
||||
}, [siniestros, departamentoFiltro])
|
||||
|
||||
// ── Zonas disponibles ─────────────────────────────────────────────────────
|
||||
const zonasDisponibles = useMemo(() => {
|
||||
return [...new Set(
|
||||
(siniestros ?? []).map(s => s.zona_ocurrencia).filter(Boolean)
|
||||
)].sort()
|
||||
}, [siniestros])
|
||||
|
||||
// ── Reset en cascada: año/período → limpia todo ──────────────────────────
|
||||
useEffect(() => {
|
||||
setDepartamentoFiltro('')
|
||||
setLocalidadFiltro('')
|
||||
setZonaFiltro('')
|
||||
}, [year, periodo])
|
||||
|
||||
// ── Reset en cascada: departamento → limpia localidad ───────────────────
|
||||
@@ -89,25 +103,37 @@ export default function Dashboard() {
|
||||
setLocalidadFiltro('')
|
||||
}, [departamentoFiltro])
|
||||
|
||||
// ── Array final con ambos filtros aplicados ──────────────────────────────
|
||||
// ── Array final con todos los filtros aplicados ──────────────────────────
|
||||
const siniestrosFiltrados = useMemo(() => {
|
||||
let result = siniestros ?? []
|
||||
if (departamentoFiltro) result = result.filter(s => s.departamento === departamentoFiltro)
|
||||
if (localidadFiltro) result = result.filter(s => s.localidad === localidadFiltro)
|
||||
if (localidadFiltro) result = result.filter(s => s.localidad === localidadFiltro)
|
||||
if (zonaFiltro) result = result.filter(s => s.zona_ocurrencia === zonaFiltro)
|
||||
return result
|
||||
}, [siniestros, departamentoFiltro, localidadFiltro])
|
||||
}, [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,7 +237,8 @@ export default function Dashboard() {
|
||||
)}
|
||||
<AdminFooter />
|
||||
</main>
|
||||
{/* ── Contenedor oculto para captura PDF ─────────────────────────── */}
|
||||
|
||||
{/* ── Contenedor oculto para captura PDF ─────────────────────────── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-pdf-render="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>
|
||||
)
|
||||
}
|
||||
|
||||
+25
-23
@@ -5,16 +5,16 @@ import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||
import PerfilVictimas from '../components/charts/PerfilVictimas'
|
||||
import ProteccionPersonas from '../components/charts/ProteccionPersonas'
|
||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
import { filtrarPersonasPorSiniestros } from '../utils/calculos'
|
||||
|
||||
const SECTION_COLORS = {
|
||||
total: '#252C61',
|
||||
total: '#252C61',
|
||||
fatales: '#C44228',
|
||||
victimas: '#922B21',
|
||||
urbano: '#80B0DE',
|
||||
rural: '#337C58',
|
||||
victimas:'#922B21',
|
||||
urbano: '#80B0DE',
|
||||
rural: '#337C58',
|
||||
}
|
||||
|
||||
export default function SecFatales({ siniestros, personas, involucrados }) {
|
||||
@@ -23,22 +23,29 @@ export default function SecFatales({ siniestros, personas, involucrados }) {
|
||||
[siniestros],
|
||||
)
|
||||
|
||||
const total = filtrarFatales.length
|
||||
const total = filtrarFatales.length
|
||||
const victimas = filtrarFatales.reduce(
|
||||
(acc, s) => acc + Number((s.cantidad_fallecidos ?? s.fallecidos) || 0),
|
||||
0,
|
||||
)
|
||||
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
|
||||
// ← NUEVO: personas acotadas a los siniestros fatales filtrados
|
||||
const personasFiltradas = useMemo(
|
||||
() => filtrarPersonasPorSiniestros(personas, filtrarFatales),
|
||||
[personas, filtrarFatales],
|
||||
)
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<KPICard label="Víctimas fatales" value={victimas} color={SECTION_COLORS.victimas} />
|
||||
<KPICard label="Promedio diario" value={diarios} unit="x día" color={SECTION_COLORS.urbano} />
|
||||
<KPICard label="% sobre total" value={pct} color={SECTION_COLORS.rural} />
|
||||
<KPICard label="Siniestros fatales" value={total} color={SECTION_COLORS.fatales} />
|
||||
<KPICard label="Víctimas fatales" value={victimas} color={SECTION_COLORS.victimas} />
|
||||
<KPICard label="Promedio diario" value={diarios} unit="x día" color={SECTION_COLORS.urbano} />
|
||||
<KPICard label="% sobre total" value={pct} color={SECTION_COLORS.rural} />
|
||||
</div>
|
||||
|
||||
{/* Evolución y tipo de siniestro */}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+94
-140
@@ -8,6 +8,7 @@ import PorLocalidad from '../components/charts/PorLocalidad'
|
||||
import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||
import KPICard from '../components/ui/KPICard'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -31,29 +32,20 @@ export default function SecHistorica({ siniestros, year }) {
|
||||
const yearNum = Number(year)
|
||||
const kpis = calcularKPIs(siniestros)
|
||||
|
||||
// Hook se usa DENTRO del componente
|
||||
const { tickColor, gridColor, tooltipBg, tooltipBorder, tooltipLabel } =
|
||||
useChartTheme()
|
||||
|
||||
const serieComparativa = useMemo(() => {
|
||||
const base = [...SERIE_HISTORICA]
|
||||
const yaExiste = base.some((row) => row.ano === yearNum)
|
||||
|
||||
if (!yaExiste && kpis.total > 0) {
|
||||
const pob = getPoblacionAnual(yearNum)
|
||||
const tasa = Number(((kpis.victimas / pob) * 100000).toFixed(2))
|
||||
|
||||
return [
|
||||
...base,
|
||||
{
|
||||
ano: yearNum,
|
||||
siniestros: kpis.total,
|
||||
victimas: kpis.victimas,
|
||||
tasa,
|
||||
},
|
||||
{ ano: yearNum, siniestros: kpis.total, victimas: kpis.victimas, tasa },
|
||||
].sort((a, b) => a.ano - b.ano)
|
||||
}
|
||||
|
||||
return base
|
||||
}, [yearNum, kpis.total, kpis.victimas])
|
||||
|
||||
@@ -68,8 +60,7 @@ export default function SecHistorica({ siniestros, year }) {
|
||||
)
|
||||
|
||||
const victimasPorAno = useMemo(
|
||||
() =>
|
||||
serieComparativa.map((row) => ({ ano: row.ano, victimas: row.victimas })),
|
||||
() => serieComparativa.map((row) => ({ ano: row.ano, victimas: row.victimas })),
|
||||
[serieComparativa],
|
||||
)
|
||||
|
||||
@@ -77,20 +68,9 @@ export default function SecHistorica({ siniestros, year }) {
|
||||
|
||||
const serieParaExtremos = useMemo(() => {
|
||||
const base = [...SERIE_HISTORICA]
|
||||
|
||||
const existe2025 = base.some((row) => row.ano === 2025)
|
||||
if (!existe2025) {
|
||||
base.push({
|
||||
ano: 2025,
|
||||
siniestros: 0,
|
||||
victimas: 21,
|
||||
tasa: 6.27,
|
||||
})
|
||||
}
|
||||
|
||||
return base
|
||||
.filter((row) => row.ano !== 2020 && row.victimas != null)
|
||||
.sort((a, b) => a.ano - b.ano)
|
||||
if (!existe2025) base.push({ ano: 2025, siniestros: 0, victimas: 21, tasa: 6.27 })
|
||||
return base.filter((row) => row.ano !== 2020 && row.victimas != null).sort((a, b) => a.ano - b.ano)
|
||||
}, [])
|
||||
|
||||
const victimasActual = yearActualData?.victimas ?? kpis.victimas ?? null
|
||||
@@ -104,128 +84,87 @@ export default function SecHistorica({ siniestros, year }) {
|
||||
(row) => row.ano !== 2020 && row.victimas != null,
|
||||
)
|
||||
|
||||
const maxEntry =
|
||||
serieParaExtremos.length > 0
|
||||
? serieParaExtremos.reduce(
|
||||
(max, row) => (max == null || row.victimas > max.victimas ? row : max),
|
||||
null,
|
||||
)
|
||||
: null
|
||||
const maxEntry = serieParaExtremos.length > 0
|
||||
? serieParaExtremos.reduce((max, row) => (max == null || row.victimas > max.victimas ? row : max), null)
|
||||
: null
|
||||
const maxHistorico = maxEntry?.victimas ?? null
|
||||
const maxAno = maxEntry?.ano ?? null
|
||||
|
||||
const minEntry =
|
||||
serieParaExtremos.length > 0
|
||||
? serieParaExtremos.reduce(
|
||||
(min, row) => (min == null || row.victimas < min.victimas ? row : min),
|
||||
null,
|
||||
)
|
||||
: null
|
||||
const minEntry = serieParaExtremos.length > 0
|
||||
? serieParaExtremos.reduce((min, row) => (min == null || row.victimas < min.victimas ? row : min), null)
|
||||
: null
|
||||
const minHistorico = minEntry?.victimas ?? null
|
||||
const minAno = minEntry?.ano ?? null
|
||||
|
||||
const serieOrdenada = [...serieVictimasValidas].sort((a, b) => a.ano - b.ano)
|
||||
const ultimos10 = serieOrdenada.filter((row) => row.ano <= yearNum).slice(-10)
|
||||
|
||||
const promedio10 =
|
||||
ultimos10.length > 0
|
||||
? (
|
||||
ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length
|
||||
).toFixed(1)
|
||||
: null
|
||||
const promedio10 = ultimos10.length > 0
|
||||
? (ultimos10.reduce((acc, row) => acc + row.victimas, 0) / ultimos10.length).toFixed(1)
|
||||
: null
|
||||
|
||||
const rango10Desde = ultimos10[0]?.ano ?? null
|
||||
const rango10Hasta = ultimos10[ultimos10.length - 1]?.ano ?? null
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* KPIs históricas */}
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* 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]">
|
||||
<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>
|
||||
{/* 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}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Distribución por tipo, franja y localidad */}
|
||||
{/* Tipo, franja y localidad */}
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<PorTipoSiniestro siniestros={siniestros} />
|
||||
<FranjaHoraria siniestros={siniestros} />
|
||||
<PorLocalidad siniestros={siniestros} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
+100
-30
@@ -5,40 +5,116 @@ import PorTipoSiniestro from '../components/charts/PorTipoSiniestro'
|
||||
import FranjaHoraria from '../components/charts/FranjaHoraria'
|
||||
import PorLocalidad from '../components/charts/PorLocalidad'
|
||||
import PerfilVictimas from '../components/charts/PerfilVictimas'
|
||||
import ProteccionPersonas from '../components/charts/ProteccionPersonas'
|
||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
import { calcularLesionadosPorGravedad, filtrarPersonasPorSiniestros } from '../utils/calculos'
|
||||
|
||||
|
||||
export default function SecLesionados({ siniestros, personas, involucrados }) {
|
||||
const filtrarLesionados = useMemo(
|
||||
() =>
|
||||
siniestros.filter((s) => {
|
||||
const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos)
|
||||
const lesion = Number(s.cantidad_lesionados ?? s.heridos)
|
||||
const lesion = Number(s.cantidad_lesionados ?? s.heridos)
|
||||
return fatales === 0 && lesion > 0
|
||||
}),
|
||||
[siniestros],
|
||||
)
|
||||
|
||||
const total = filtrarLesionados.length
|
||||
const lesionados = filtrarLesionados.reduce(
|
||||
(acc, s) => acc + Number((s.cantidad_lesionados ?? s.heridos) || 0),
|
||||
0,
|
||||
)
|
||||
const total = filtrarLesionados.length
|
||||
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
|
||||
const { graves, leves } = useMemo(
|
||||
() => calcularLesionadosPorGravedad(filtrarLesionados, personas),
|
||||
[filtrarLesionados, personas],
|
||||
)
|
||||
const totalPersonas = graves + leves
|
||||
const pctGraves = totalPersonas ? Math.round((graves / totalPersonas) * 100) : 0
|
||||
const pctLeves = totalPersonas ? Math.round((leves / totalPersonas) * 100) : 0
|
||||
|
||||
const personasFiltradas = useMemo(
|
||||
() => filtrarPersonasPorSiniestros(personas, filtrarLesionados),
|
||||
[personas, filtrarLesionados],
|
||||
)
|
||||
|
||||
return (
|
||||
<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="Siniestros con lesionados" value={total} color="#CD9F2B" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+78
-31
@@ -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">
|
||||
<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 className="space-y-8">
|
||||
|
||||
{/* ── 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.7fr_1fr]">
|
||||
<SiniestrosPorMes siniestros={siniestros} />
|
||||
<DonutGravedad siniestros={siniestros} />
|
||||
{/* ── 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>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_2.1fr]">
|
||||
<ZonaOcurrencia siniestros={siniestros} />
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -8,29 +8,37 @@ import TipoInvolucrado from '../components/charts/TipoInvolucrado'
|
||||
import ZonaOcurrencia from '../components/charts/ZonaOcurrencia'
|
||||
import ChartCard from '../components/ui/ChartCard'
|
||||
|
||||
|
||||
export default function SecSinLesiones({ siniestros, involucrados }) {
|
||||
const filtrarSinLesiones = useMemo(
|
||||
() =>
|
||||
siniestros.filter((s) => {
|
||||
const fatales = Number(s.cantidad_fallecidos ?? s.fallecidos)
|
||||
const lesion = Number(s.cantidad_lesionados ?? s.heridos)
|
||||
const lesion = Number(s.cantidad_lesionados ?? s.heridos)
|
||||
return fatales === 0 && lesion === 0
|
||||
}),
|
||||
[siniestros],
|
||||
)
|
||||
|
||||
const total = filtrarSinLesiones.length
|
||||
const total = filtrarSinLesiones.length
|
||||
const diarios = total ? (total / 365).toFixed(1) : '0.0'
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
const ilesos = filtrarSinLesiones.reduce((acc, s) => acc + Number(s.ilesos || 0), 0)
|
||||
const pct = siniestros.length ? `${((total / siniestros.length) * 100).toFixed(1)}%` : '0%'
|
||||
const ilesos = filtrarSinLesiones.reduce((acc, s) => acc + Number(s.ilesos || 0), 0)
|
||||
|
||||
// ← NUEVO: involucrados acotados a los siniestros sin lesiones filtrados
|
||||
const involucradosFiltrados = useMemo(() => {
|
||||
const ids = new Set(filtrarSinLesiones.map(s => s.id_feu).filter(Boolean))
|
||||
return (involucrados ?? []).filter(i => ids.has(i.id_feu))
|
||||
}, [involucrados, filtrarSinLesiones])
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<KPICard label="Promedio diario" value={diarios} unit="x día" color="#80B0DE" />
|
||||
<KPICard label="% sobre total" value={pct} color="#CD9F2B" />
|
||||
<KPICard label="Siniestros sin lesiones" value={total} color="#337C58" />
|
||||
<KPICard label="Promedio diario" value={diarios} unit="x día" color="#80B0DE" />
|
||||
<KPICard label="% sobre total" value={pct} color="#CD9F2B" />
|
||||
</div>
|
||||
|
||||
{/* Evolución y tipo */}
|
||||
@@ -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
@@ -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,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
|
||||
+248
-195
@@ -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]
|
||||
insights.push({
|
||||
tipo: 'dato',
|
||||
texto: `La Ruta ${topRuta.ruta} concentró la mayor cantidad de siniestros con ${topRuta.total} evento${topRuta.total !== 1 ? 's' : ''}.`,
|
||||
})
|
||||
const rutaMasGrave = [...graficoD].sort(
|
||||
(a, b) => (b.fatales - a.fatales) || (b.conLes - a.conLes) || (b.total - a.total)
|
||||
)[0]
|
||||
const tipoVia = (rutaMasGrave.via_publica || '').trim()
|
||||
const nombreVia = (rutaMasGrave.nombre_via || '').trim()
|
||||
const nombreRuta = `${tipoVia} ${nombreVia}`.trim() || rutaMasGrave.ruta || 'la ruta identificada'
|
||||
if (rutaMasGrave.fatales > 0) {
|
||||
insights.push({ tipo: 'alerta', texto: `La ruta de mayor gravedad fue la ${nombreRuta}, con ${rutaMasGrave.fatales} siniestro${rutaMasGrave.fatales !== 1 ? 's' : ''} fatal${rutaMasGrave.fatales !== 1 ? 'es' : ''}${rutaMasGrave.conLes > 0 ? ` y ${rutaMasGrave.conLes} con lesionados` : ''}.` })
|
||||
} else if (rutaMasGrave.conLes > 0) {
|
||||
insights.push({ tipo: 'dato', texto: `La ruta de mayor gravedad fue la ${nombreRuta}, con ${rutaMasGrave.conLes} siniestro${rutaMasGrave.conLes !== 1 ? 's' : ''} con lesionados.` })
|
||||
}
|
||||
}
|
||||
|
||||
// ── NUEVO: insight sobre heridos graves ──
|
||||
if (lesionados) {
|
||||
const { graves, leves } = lesionados
|
||||
const totalLes = graves + leves
|
||||
if (totalLes > 0 && graves > 0) {
|
||||
const pctGraves = Math.round((graves / totalLes) * 100)
|
||||
insights.push({
|
||||
tipo: pctGraves >= 40 ? 'alerta' : 'dato',
|
||||
texto: `De las ${totalLes} personas lesionadas en ruta, ${graves} (${pctGraves}%) fueron heridos graves y ${leves} heridos leves.`,
|
||||
})
|
||||
} else if (totalLes > 0 && graves === 0) {
|
||||
insights.push({
|
||||
tipo: 'logro',
|
||||
texto: `Las ${leves} personas lesionadas en ruta durante la campaña resultaron con heridas leves, sin heridos graves registrados.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return insights
|
||||
@@ -192,18 +175,11 @@ function BloqueInsights({ insights, campanaLabel }) {
|
||||
{insights.map((insight, i) => {
|
||||
const cfg = INSIGHT_CONFIG[insight.tipo]
|
||||
return (
|
||||
<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,28 +227,88 @@ 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 }} />
|
||||
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" radius={[0,0,0,0]} />
|
||||
<Bar dataKey="conLes" name="Con lesiones" fill={COLOR.conLes} stackId="a" />
|
||||
<Bar dataKey="sinLes" name="Sin lesiones" fill={COLOR.sinLes} stackId="a" radius={[0,4,4,0]} />
|
||||
<Bar dataKey="fatales" name="Fatales" fill={COLOR.fatales} stackId="a" radius={[0,0,0,0]} />
|
||||
<Bar dataKey="conLes" name="Con lesiones" fill={COLOR.conLes} stackId="a" />
|
||||
<Bar dataKey="sinLes" name="Sin lesiones" fill={COLOR.sinLes} stackId="a" radius={[0,4,4,0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── 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,32 +320,57 @@ export default function SecVeranoVivo({ siniestros }) {
|
||||
() => CAMPANAS_VV.map(c => ({ label: c.label, datos: filtrarCampanaVV(siniestros, c.anoDesde) })),
|
||||
[siniestros]
|
||||
)
|
||||
const historicoVV = useMemo(() => generarHistoricoVV(siniestros), [siniestros])
|
||||
const promedioHistoricoVV = useMemo(() => calcularPromedioHistoricoVV(siniestros), [siniestros])
|
||||
const kpis = useMemo(() => kpisVV(datosActual), [datosActual])
|
||||
const graficoB = useMemo(() => ruralUrbanoPorCampana(datosPorCampana), [datosPorCampana])
|
||||
const graficoCRural = useMemo(() => distribucionMensualVV(datosActual, 'rural'), [datosActual])
|
||||
const graficoCUrbano = useMemo(() => distribucionMensualVV(datosActual, 'urbano'), [datosActual])
|
||||
const graficoD = useMemo(() => rankingRutas(datosActual), [datosActual])
|
||||
const graficoE = useMemo(() => rankingLocalidades(datosActual), [datosActual])
|
||||
const graficoF = useMemo(() => agruparConUmbral(tiposSiniestroVV(datosActual), 10), [datosActual])
|
||||
|
||||
const kpis = useMemo(() => kpisVV(datosActual), [datosActual])
|
||||
const graficoB = useMemo(() => ruralUrbanoPorCampana(datosPorCampana), [datosPorCampana])
|
||||
const graficoCRural = useMemo(() => distribucionMensualVV(datosActual, 'rural'), [datosActual])
|
||||
const graficoCUrbano = useMemo(() => distribucionMensualVV(datosActual, 'urbano'), [datosActual])
|
||||
const graficoD = useMemo(() => rankingRutas(datosActual), [datosActual])
|
||||
const graficoE = useMemo(() => rankingLocalidades(datosActual), [datosActual])
|
||||
const graficoF = useMemo(() => agruparConUmbral(tiposSiniestroVV(datosActual), 10), [datosActual])
|
||||
// ── NUEVO: lesionados por gravedad filtrados por campaña activa ──
|
||||
const lesionadosVV = useMemo(
|
||||
() => calcularLesionadosPorGravedad(datosActual, personas),
|
||||
[datosActual, personas]
|
||||
)
|
||||
|
||||
const maxComparativoZona = useMemo(() => {
|
||||
const maximo = Math.max(0, ...graficoB.flatMap((d) => [d.ruralFatal ?? 0, d.ruralLes ?? 0, d.urbanaFatal ?? 0, d.urbanaLes ?? 0]))
|
||||
return Math.max(5, Math.ceil(maximo / 5) * 5)
|
||||
}, [graficoB])
|
||||
|
||||
const maxMensualZona = useMemo(() => {
|
||||
const maximo = Math.max(
|
||||
0,
|
||||
...graficoCRural.map((d) => (d.fatales ?? 0) + (d.conLes ?? 0) + (d.sinLes ?? 0)),
|
||||
...graficoCUrbano.map((d) => (d.fatales ?? 0) + (d.conLes ?? 0) + (d.sinLes ?? 0))
|
||||
)
|
||||
return Math.max(5, Math.ceil(maximo / 5) * 5)
|
||||
}, [graficoCRural, graficoCUrbano])
|
||||
|
||||
const graficoDConEtiqueta = useMemo(
|
||||
() => graficoD.map((item) => ({ ...item, viaCompleta: `${item.via_publica || 'Ruta'} ${item.nombre_via || ''}`.trim() })),
|
||||
[graficoD]
|
||||
)
|
||||
|
||||
// ✅ graficoCRural (no graficoC) — datos de ruta para los insights
|
||||
const insights = useMemo(
|
||||
() => calcularInsights(campanaActual, kpis, graficoCRural, graficoD, graficoF),
|
||||
[campanaActual, kpis, graficoCRural, graficoD, graficoF]
|
||||
() => calcularInsights(campanaActual, kpis, graficoCRural, graficoD, graficoF, promedioHistoricoVV, historicoVV, lesionadosVV),
|
||||
[campanaActual, kpis, graficoCRural, graficoD, graficoF, promedioHistoricoVV, historicoVV, lesionadosVV]
|
||||
)
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<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' }} />
|
||||
<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]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
{/* Gráficos C — Siniestros por mes separados por zona · grid 2 col */}
|
||||
{/* Gráfico B — Comparación histórica 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="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 allowDecimals={false} domain={[0, maxComparativoZona]} tick={{ fontSize: 11, fill: '#4A5568' }} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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]">
|
||||
<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}
|
||||
>
|
||||
{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>
|
||||
{/* 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="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 />} />
|
||||
</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>
|
||||
|
||||
+85
-49
@@ -57,6 +57,29 @@ export function calcularKPIs(siniestros) {
|
||||
return { total, fatales, conLes, sinLes, victimas, lesion }
|
||||
}
|
||||
|
||||
// ── Desagrega lesionados graves y leves cruzando con tabla personas ──────────
|
||||
export function calcularLesionadosPorGravedad(siniestros, personas) {
|
||||
const ids = new Set(siniestros.map(s => s.id_feu))
|
||||
return (personas ?? [])
|
||||
.filter(p => ids.has(p.id_feu))
|
||||
.reduce(
|
||||
(acc, p) => {
|
||||
const estado = (p.estado_ocupante_final ?? '').trim()
|
||||
if (estado === 'Herido Grave') acc.graves++
|
||||
if (estado === 'Herido Leve') acc.leves++
|
||||
return acc
|
||||
},
|
||||
{ graves: 0, leves: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Filtra personas que corresponden a un conjunto de siniestros (join id_feu)
|
||||
export function filtrarPersonasPorSiniestros(personas, siniestros) { // ← NUEVO
|
||||
const ids = new Set((siniestros ?? []).map(s => s.id_feu).filter(Boolean))
|
||||
return (personas ?? []).filter(p => ids.has(p.id_feu))
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function evolucionMensual(siniestros) {
|
||||
const mapa = {}
|
||||
MESES.forEach((m) => { mapa[m] = { mes: m.slice(0, 3), fatales: 0, conLes: 0, sinLes: 0, total: 0 } })
|
||||
@@ -152,9 +175,9 @@ export function perfilVictimas(personas, involucrados) {
|
||||
})
|
||||
|
||||
return {
|
||||
genero: Object.entries(genero).map(([name, value]) => ({ name, value })),
|
||||
etario: Object.entries(etario).map(([name, value]) => ({ name, value })),
|
||||
usuario: Object.entries(usuario).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value),
|
||||
genero: Object.entries(genero).map(([name, value]) => ({ name, value })),
|
||||
etario: Object.entries(etario).map(([name, value]) => ({ name, value })),
|
||||
usuario: Object.entries(usuario).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,13 +208,11 @@ export function proteccionPasiva(personas, involucrados) {
|
||||
const habitaculo = personas.filter((p) => TIPOS_HABITACULO.includes(tipoMap.get(String(p.id_involucrado))))
|
||||
|
||||
const cascoBase = motoristas.length
|
||||
const cascoUso = motoristas.filter((p) => hasCasco(p)).length
|
||||
|
||||
const cinBase = habitaculo.length
|
||||
const cinUso = habitaculo.filter((p) => hasCinturon(p)).length
|
||||
|
||||
const cascoUso = motoristas.filter((p) => hasCasco(p)).length
|
||||
const cinBase = habitaculo.length
|
||||
const cinUso = habitaculo.filter((p) => hasCinturon(p)).length
|
||||
const airbagBase = habitaculo.length
|
||||
const airbagUso = habitaculo.filter((p) => hasAirbag(p)).length
|
||||
const airbagUso = habitaculo.filter((p) => hasAirbag(p)).length
|
||||
|
||||
return {
|
||||
casco: { uso: cascoUso, base: cascoBase, pct: cascoBase ? Math.round((cascoUso / cascoBase) * 100) : null },
|
||||
@@ -283,7 +304,7 @@ export function calcularRangoEtario(personas) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── SÍNTESIS ──────────────────────────────────────────────────
|
||||
// ── SÍNTESIS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function topEntry(obj) {
|
||||
return Object.entries(obj).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'Sin dato'
|
||||
@@ -388,26 +409,12 @@ export function calcularSintesis(siniestros, personas = [], involucrados = []) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── VERANO VIVO ───────────────────────────────────────────────
|
||||
|
||||
export const HISTORICO_VERANO_VIVO = [
|
||||
{ campaña: '2015/16', fatales: 11 },
|
||||
{ campaña: '2016/17', fatales: 8 },
|
||||
{ campaña: '2017/18', fatales: 2 },
|
||||
{ campaña: '2018/19', fatales: 6 },
|
||||
{ campaña: '2019/20', fatales: 5 },
|
||||
// 2020/21 excluida — restricciones COVID
|
||||
{ campaña: '2021/22', fatales: 4 },
|
||||
{ campaña: '2022/23', fatales: 3 },
|
||||
{ campaña: '2023/24', fatales: 3 },
|
||||
{ campaña: '2024/25', fatales: 3 },
|
||||
]
|
||||
export const PROMEDIO_HISTORICO_VV = 5.2
|
||||
|
||||
// ── VERANO VIVO ───────────────────────────────────────────────────────────────
|
||||
export const CAMPANAS_VV = [
|
||||
{ label: '2022/23', anoDesde: 2022, anoHasta: 2023 },
|
||||
{ label: '2023/24', anoDesde: 2023, anoHasta: 2024 },
|
||||
{ label: '2024/25', anoDesde: 2024, anoHasta: 2025 },
|
||||
{ label: '2025/26', anoDesde: 2025, anoHasta: 2026 },
|
||||
]
|
||||
|
||||
export function filtrarCampanaVV(siniestros, anoDesde) {
|
||||
@@ -417,15 +424,41 @@ export function filtrarCampanaVV(siniestros, anoDesde) {
|
||||
const ano = PARSE_INT(s.ano)
|
||||
if (ano === anoDesde && mes === 12 && dia >= 20) return true
|
||||
if (ano === anoDesde + 1 && (mes === 1 || mes === 2)) return true
|
||||
if (ano === anoDesde + 1 && mes === 3 && dia <= 20) return true
|
||||
if (ano === anoDesde + 1 && mes === 3 && dia <= 20) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const HISTORICO_PREVIO_VV = [
|
||||
{ campaña: '2015/16', fatales: 11 },
|
||||
{ campaña: '2016/17', fatales: 8 },
|
||||
{ campaña: '2017/18', fatales: 2 },
|
||||
{ campaña: '2018/19', fatales: 6 },
|
||||
{ campaña: '2019/20', fatales: 5 },
|
||||
// 2020/21 excluida — restricciones COVID
|
||||
{ campaña: '2021/22', fatales: 4 },
|
||||
]
|
||||
|
||||
export const HISTORICO_VERANO_VIVO = HISTORICO_PREVIO_VV
|
||||
|
||||
export function generarHistoricoVV(siniestros) {
|
||||
const desdeSupabase = CAMPANAS_VV.map(({ label, anoDesde }) => {
|
||||
const datos = filtrarCampanaVV(siniestros, anoDesde)
|
||||
const rural = datos.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||
const fatales = rural.filter(s => getCantidadFallecidos(s) > 0).length
|
||||
return { campaña: label, fatales }
|
||||
})
|
||||
return [...HISTORICO_PREVIO_VV, ...desdeSupabase]
|
||||
}
|
||||
|
||||
export function calcularPromedioHistoricoVV(siniestros) {
|
||||
const historico = generarHistoricoVV(siniestros)
|
||||
const suma = historico.reduce((acc, c) => acc + c.fatales, 0)
|
||||
return Math.round((suma / historico.length) * 10) / 10
|
||||
}
|
||||
|
||||
export function kpisVV(siniestros) {
|
||||
const rural = siniestros.filter(s =>
|
||||
(s.zona_ocurrencia || '').toLowerCase().includes('rural')
|
||||
)
|
||||
const rural = siniestros.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||
const total = rural.length
|
||||
const fatales = rural.filter(s => getCantidadFallecidos(s) > 0).length
|
||||
const conLes = rural.filter(s => getCantidadFallecidos(s) === 0 && getCantidadLesionados(s) > 0).length
|
||||
@@ -449,16 +482,6 @@ export function ruralUrbanoPorCampana(siniestrosPorCampana) {
|
||||
})
|
||||
}
|
||||
|
||||
const MESES_VV = [
|
||||
{ key: 12, label: 'Dic' },
|
||||
{ key: 1, label: 'Ene' },
|
||||
{ key: 2, label: 'Feb' },
|
||||
{ key: 3, label: 'Mar' },
|
||||
]
|
||||
|
||||
// ANTES — distribucionMensualVV(siniestros)
|
||||
// AHORA — acepta zona opcional: 'rural' | 'urbano' | undefined (todos)
|
||||
|
||||
const esUrbano = (s) =>
|
||||
(s.zona || s.zona_ocurrencia || '').toLowerCase().includes('urban')
|
||||
|
||||
@@ -491,18 +514,32 @@ export function distribucionMensualVV(siniestros, zona) {
|
||||
|
||||
export function rankingRutas(siniestros, topN = 8) {
|
||||
const map = {}
|
||||
|
||||
siniestros
|
||||
.filter(s => (s.zona_ocurrencia || '').toLowerCase().includes('rural'))
|
||||
.forEach(s => {
|
||||
const nombre = (s.nombre_via || '').trim()
|
||||
const tipo = (s.via_publica || '').trim()
|
||||
const ruta = nombre || tipo || 'Sin datos'
|
||||
if (!map[ruta]) map[ruta] = { ruta, tipo, fatales: 0, conLes: 0, sinLes: 0, total: 0 }
|
||||
if (getCantidadFallecidos(s) > 0) map[ruta].fatales++
|
||||
else if (getCantidadLesionados(s) > 0) map[ruta].conLes++
|
||||
else map[ruta].sinLes++
|
||||
map[ruta].total++
|
||||
const nombre_via = (s.nombre_via || '').trim()
|
||||
const via_publica = (s.via_publica || '').trim()
|
||||
const key = `${via_publica}__${nombre_via}` || 'Sin datos'
|
||||
|
||||
if (!map[key]) {
|
||||
map[key] = {
|
||||
ruta: nombre_via || via_publica || 'Sin datos',
|
||||
nombre_via,
|
||||
via_publica,
|
||||
fatales: 0,
|
||||
conLes: 0,
|
||||
sinLes: 0,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (getCantidadFallecidos(s) > 0) map[key].fatales++
|
||||
else if (getCantidadLesionados(s) > 0) map[key].conLes++
|
||||
else map[key].sinLes++
|
||||
map[key].total++
|
||||
})
|
||||
|
||||
return Object.values(map)
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, topN)
|
||||
@@ -526,7 +563,6 @@ export function rankingLocalidades(siniestros, topN = 8) {
|
||||
}
|
||||
|
||||
// ✅ FIX: eliminados logs temporales y normalización redundante
|
||||
// (getTipoSiniestro ya aplica sentence case + trim + normalize)
|
||||
export function tiposSiniestroVV(siniestros) {
|
||||
const map = {}
|
||||
siniestros
|
||||
|
||||
+131
-33
@@ -1,16 +1,18 @@
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import jsPDF from 'jspdf'
|
||||
|
||||
|
||||
export const SECCIONES_EXPORTABLES = [
|
||||
{ id: 'resumen', label: 'Resumen General' },
|
||||
{ id: 'historica', label: 'Serie Histórica Provincial' },
|
||||
{ id: 'fatales', label: 'Siniestros Fatales' },
|
||||
{ id: 'lesionados', label: 'Con Lesionados' },
|
||||
{ id: 'sinlesiones', label: 'Sin Lesiones' },
|
||||
{ id: 'sintesis', label: 'Síntesis' },
|
||||
{ id: 'veranovivo', label: 'Verano Vivo' },
|
||||
{ id: 'resumen', label: 'Resumen General', subtitulo: 'Indicadores y estadísticas generales del período' },
|
||||
{ id: 'historica', label: 'Serie Histórica Provincial', subtitulo: 'Evolución de la siniestralidad vial a lo largo del tiempo' },
|
||||
{ id: 'fatales', label: 'Siniestros Fatales', subtitulo: 'Análisis detallado de siniestros con víctimas fatales' },
|
||||
{ id: 'lesionados', label: 'Con Lesionados', subtitulo: 'Siniestros con personas lesionadas sin fallecidos' },
|
||||
{ id: 'sinlesiones', label: 'Sin Lesiones', subtitulo: 'Siniestros con daños materiales sin víctimas' },
|
||||
{ id: 'sintesis', label: 'Síntesis', subtitulo: 'Resumen ejecutivo y conclusiones del período' },
|
||||
{ id: 'veranovivo', label: 'Verano Vivo', subtitulo: 'Estadísticas del operativo estival' },
|
||||
]
|
||||
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
@@ -21,6 +23,7 @@ function loadImage(src) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) {
|
||||
pdf.setFillColor(37, 44, 97)
|
||||
pdf.rect(0, 0, W, 13, 'F')
|
||||
@@ -38,7 +41,73 @@ function drawHeaderFooter(pdf, secLabel, year, pageNum, W, H) {
|
||||
pdf.text(`Página ${pageNum}`, W - 10, H - 2.5, { align: 'right' })
|
||||
}
|
||||
|
||||
// ── Captura un elemento individual ───────────────────────────────────────────
|
||||
|
||||
// ── Carátula de sección ───────────────────────────────────────────────────────
|
||||
function drawCaratulaSeccion(pdf, secLabel, subtitulo, year, logoImg, W, H) {
|
||||
pdf.setFillColor(37, 44, 97)
|
||||
pdf.rect(0, 0, W, H, 'F')
|
||||
|
||||
// Línea decorativa superior
|
||||
pdf.setDrawColor(128, 176, 222)
|
||||
pdf.setLineWidth(0.8)
|
||||
pdf.line(W / 2 - 40, H / 2 - 22, W / 2 + 40, H / 2 - 22)
|
||||
|
||||
// Label año
|
||||
pdf.setTextColor(128, 176, 222)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setFontSize(10)
|
||||
pdf.text(`INFORME ${year}`, W / 2, H / 2 - 14, { align: 'center' })
|
||||
|
||||
// Título de sección
|
||||
pdf.setTextColor(255, 255, 255)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
pdf.setFontSize(28)
|
||||
pdf.text(secLabel.toUpperCase(), W / 2, H / 2 + 4, { align: 'center' })
|
||||
|
||||
// Subtítulo
|
||||
if (subtitulo) {
|
||||
pdf.setTextColor(180, 195, 230)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setFontSize(11)
|
||||
pdf.text(subtitulo, W / 2, H / 2 + 18, { align: 'center' })
|
||||
}
|
||||
|
||||
// Línea decorativa inferior
|
||||
pdf.setDrawColor(128, 176, 222)
|
||||
pdf.setLineWidth(0.8)
|
||||
pdf.line(W / 2 - 40, H / 2 + 26, W / 2 + 40, H / 2 + 26)
|
||||
|
||||
// ── Logo + texto al pie ──────────────────────────────────────────────────
|
||||
const pieY = H - 14
|
||||
if (logoImg) {
|
||||
const logoH = 8
|
||||
const logoW = (logoImg.width / logoImg.height) * logoH
|
||||
// Calcular posición para centrar logo + texto juntos
|
||||
pdf.setFontSize(8)
|
||||
const textoAncho = pdf.getTextWidth('Observatorio Provincial de Seguridad Vial · Santa Cruz')
|
||||
const gap = 3 // mm entre logo y texto
|
||||
const totalAncho = logoW + gap + textoAncho
|
||||
const startX = (W - totalAncho) / 2
|
||||
|
||||
pdf.addImage(logoImg, 'PNG', startX, pieY - logoH + 1, logoW, logoH)
|
||||
pdf.setTextColor(180, 195, 230)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.text(
|
||||
'Observatorio Provincial de Seguridad Vial · Santa Cruz',
|
||||
startX + logoW + gap,
|
||||
pieY,
|
||||
)
|
||||
} else {
|
||||
// Sin logo — solo texto centrado
|
||||
pdf.setTextColor(180, 195, 230)
|
||||
pdf.setFont('helvetica', 'normal')
|
||||
pdf.setFontSize(8)
|
||||
pdf.text('Observatorio Provincial de Seguridad Vial · Santa Cruz', W / 2, pieY, { align: 'center' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Captura un bloque con html2canvas ────────────────────────────────────────
|
||||
async function capturarBloque(block) {
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
return html2canvas(block, {
|
||||
@@ -51,26 +120,33 @@ async function capturarBloque(block) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function exportarPDF({ seccionesIds, year, onProgress }) {
|
||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
||||
const W = pdf.internal.pageSize.getWidth() // 297mm
|
||||
const H = pdf.internal.pageSize.getHeight() // 210mm
|
||||
const areaW = W - 16 // ancho disponible
|
||||
const areaH = H - 13 - 10 // alto disponible (header 13mm + footer 10mm)
|
||||
const topY = 15 // cursor inicial tras el header
|
||||
const marginX = (W - areaW) / 2
|
||||
const gap = 3 // mm de separación entre bloques
|
||||
|
||||
// ── Portada ───────────────────────────────────────────────────────────────
|
||||
export async function exportarPDF({ seccionesIds, year, onProgress }) {
|
||||
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
||||
const W = pdf.internal.pageSize.getWidth()
|
||||
const H = pdf.internal.pageSize.getHeight()
|
||||
const areaW = W - 16
|
||||
const areaH = H - 13 - 10
|
||||
const topY = 15
|
||||
const marginX = (W - areaW) / 2
|
||||
const gap = 3
|
||||
|
||||
// ── Cargar logo una sola vez ─────────────────────────────────────────────
|
||||
let logoImg = null
|
||||
try {
|
||||
logoImg = await loadImage('/logo-opsv.png')
|
||||
} catch { /* sin logo */ }
|
||||
|
||||
|
||||
// ── Portada ─────────────────────────────────────────────────────────────
|
||||
pdf.setFillColor(37, 44, 97)
|
||||
pdf.rect(0, 0, W, H, 'F')
|
||||
|
||||
try {
|
||||
const logoImg = await loadImage('/logo-opsv.png')
|
||||
if (logoImg) {
|
||||
const logoW = 55
|
||||
const logoH = (logoImg.height / logoImg.width) * logoW
|
||||
pdf.addImage(logoImg, 'PNG', (W - logoW) / 2, 35, logoW, logoH)
|
||||
} catch { /* sin logo */ }
|
||||
}
|
||||
|
||||
pdf.setTextColor(255, 255, 255)
|
||||
pdf.setFont('helvetica', 'bold')
|
||||
@@ -88,42 +164,64 @@ export async function exportarPDF({ seccionesIds, year, onProgress }) {
|
||||
})
|
||||
pdf.text(`Generado el ${fecha}`, W / 2, 190, { align: 'center' })
|
||||
|
||||
// ── Secciones ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Secciones ────────────────────────────────────────────────────────────
|
||||
let pageNum = 1
|
||||
|
||||
for (let i = 0; i < seccionesIds.length; i++) {
|
||||
const secId = seccionesIds[i]
|
||||
const secLabel = SECCIONES_EXPORTABLES.find(s => s.id === secId)?.label ?? secId
|
||||
const secId = seccionesIds[i]
|
||||
const secDef = SECCIONES_EXPORTABLES.find(s => s.id === secId)
|
||||
const secLabel = secDef?.label ?? secId
|
||||
const subtitulo = secDef?.subtitulo ?? ''
|
||||
|
||||
onProgress?.(Math.round(((i + 1) / seccionesIds.length) * 90))
|
||||
|
||||
const el = document.getElementById(`pdf-section-${secId}`)
|
||||
if (!el) continue
|
||||
|
||||
// ✅ Obtener todos los bloques atómicos de esta sección
|
||||
const blocks = Array.from(el.querySelectorAll('[data-pdf-block]'))
|
||||
if (!blocks.length) continue
|
||||
|
||||
// Primera página de la sección
|
||||
// ── Carátula de la sección ────────────────────────────────────────
|
||||
pdf.addPage()
|
||||
pageNum++
|
||||
drawCaratulaSeccion(pdf, secLabel, subtitulo, year, logoImg, W, H)
|
||||
|
||||
// ── Página con los bloques ────────────────────────────────────────
|
||||
pdf.addPage()
|
||||
pageNum++
|
||||
drawHeaderFooter(pdf, secLabel, year, pageNum, W, H)
|
||||
let cursorY = topY
|
||||
|
||||
// Precapturar todos los bloques para calcular altura total
|
||||
const canvases = []
|
||||
for (const block of blocks) {
|
||||
const canvas = await capturarBloque(block)
|
||||
const mmPerPx = areaW / canvas.width
|
||||
let drawH = canvas.height * mmPerPx
|
||||
let drawW = areaW
|
||||
const canvas = await capturarBloque(block)
|
||||
canvases.push(canvas)
|
||||
}
|
||||
|
||||
// Calcular altura total para centrado vertical
|
||||
const alturaTotal = canvases.reduce((acc, canvas) => {
|
||||
const mmPerPx = areaW / canvas.width
|
||||
let drawH = canvas.height * mmPerPx
|
||||
if (drawH > areaH) drawH = areaH
|
||||
return acc + drawH + gap
|
||||
}, 0) - gap
|
||||
|
||||
let cursorY = alturaTotal <= areaH
|
||||
? topY + (areaH - alturaTotal) / 2
|
||||
: topY
|
||||
|
||||
for (const canvas of canvases) {
|
||||
const mmPerPx = areaW / canvas.width
|
||||
let drawH = canvas.height * mmPerPx
|
||||
let drawW = areaW
|
||||
|
||||
// Si el bloque es más alto que toda la página, escalar para que entre
|
||||
if (drawH > areaH) {
|
||||
const ratio = areaH / drawH
|
||||
drawH = areaH
|
||||
drawW = areaW * ratio
|
||||
}
|
||||
|
||||
// ✅ Si no cabe en lo que queda de página → nueva página
|
||||
if (cursorY + drawH > topY + areaH) {
|
||||
pdf.addPage()
|
||||
pageNum++
|
||||
|
||||
Reference in New Issue
Block a user