Feat Front Widgets Refactizados y Ajustes Backend
This commit is contained in:
		
							
								
								
									
										35
									
								
								Elecciones-Web/frontend/src/components/BancasWidget.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								Elecciones-Web/frontend/src/components/BancasWidget.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| /* src/components/BancasWidget.css */ | ||||
| .bancas-widget-container { | ||||
|     background-color: #2a2a2e; | ||||
|     padding: 15px 20px; | ||||
|     border-radius: 8px; | ||||
|     max-width: 800px; | ||||
|     margin: 20px auto; | ||||
|     color: #e0e0e0; | ||||
| } | ||||
|  | ||||
| .bancas-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .bancas-header h4 { | ||||
|     margin: 0; | ||||
|     color: white; | ||||
|     font-size: 1.2em; | ||||
| } | ||||
|  | ||||
| .bancas-header select { | ||||
|     background-color: #3a3a3a; | ||||
|     color: white; | ||||
|     border: 1px solid #555; | ||||
|     border-radius: 4px; | ||||
|     padding: 5px 10px; | ||||
| } | ||||
|  | ||||
| .waffle-chart-container { | ||||
|     height: 300px; | ||||
|     font-family: system-ui, sans-serif; | ||||
| } | ||||
| @@ -1,52 +1,91 @@ | ||||
| // src/components/BancasWidget.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { getBancasPorSeccion, type ProyeccionBancas } from '../services/api'; | ||||
| import { ResponsiveWaffle } from '@nivo/waffle'; | ||||
| import { getBancasPorSeccion } from '../apiService'; | ||||
| import type { ProyeccionBancas } from '../types/types'; | ||||
| import './BancasWidget.css'; | ||||
|  | ||||
| interface Props { | ||||
|   seccionId: string; | ||||
| } | ||||
| // Paleta de colores consistente | ||||
| const NIVO_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"]; | ||||
|  | ||||
| export const BancasWidget = ({ seccionId }: Props) => { | ||||
|   const [data, setData] = useState<ProyeccionBancas | null>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
| // Las Secciones Electorales de la Provincia (esto podría venir de la API en el futuro) | ||||
| const secciones = [ | ||||
|     { id: '1', nombre: 'Primera Sección' }, | ||||
|     { id: '2', nombre: 'Segunda Sección' }, | ||||
|     { id: '3', nombre: 'Tercera Sección' }, | ||||
|     { id: '4', nombre: 'Cuarta Sección' }, | ||||
|     { id: '5', nombre: 'Quinta Sección' }, | ||||
|     { id: '6', nombre: 'Sexta Sección' }, | ||||
|     { id: '7', nombre: 'Séptima Sección' }, | ||||
|     { id: '8', nombre: 'Octava Sección (Capital)' }, | ||||
| ]; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       try { | ||||
|         setLoading(true); | ||||
|         const proyeccion = await getBancasPorSeccion(seccionId); | ||||
|         setData(proyeccion); | ||||
|       } catch (err) { | ||||
|         console.error("Error cargando bancas", err); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [seccionId]); | ||||
| export const BancasWidget = () => { | ||||
|     const [seccionActual, setSeccionActual] = useState('1'); // Empezamos con la Primera Sección | ||||
|     const [data, setData] = useState<ProyeccionBancas | null>(null); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   if (loading) return <div>Cargando proyección de bancas...</div>; | ||||
|   if (!data) return <div>No hay datos de bancas disponibles.</div>; | ||||
|     useEffect(() => { | ||||
|         const fetchData = async () => { | ||||
|             setLoading(true); | ||||
|             try { | ||||
|                 const result = await getBancasPorSeccion(seccionActual); | ||||
|                 setData(result); | ||||
|             } catch (error) { | ||||
|                 console.error(`Error cargando datos de bancas para sección ${seccionActual}:`, error); | ||||
|                 setData(null); // Limpiar datos en caso de error | ||||
|             } finally { | ||||
|                 setLoading(false); | ||||
|             } | ||||
|         }; | ||||
|         fetchData(); | ||||
|     }, [seccionActual]); // Se ejecuta cada vez que cambia la sección | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}> | ||||
|       <h3>Proyección de Bancas - {data.seccionNombre}</h3> | ||||
|       <table style={{ width: '100%', borderCollapse: 'collapse' }}> | ||||
|         <thead> | ||||
|           <tr> | ||||
|             <th style={{ textAlign: 'left' }}>Agrupación</th> | ||||
|             <th style={{ textAlign: 'right' }}>Bancas Obtenidas</th> | ||||
|           </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|           {data.proyeccion.map((partido) => ( | ||||
|             <tr key={partido.agrupacionNombre}> | ||||
|               <td>{partido.agrupacionNombre}</td> | ||||
|               <td style={{ textAlign: 'right' }}>{partido.bancas}</td> | ||||
|             </tr> | ||||
|           ))} | ||||
|         </tbody> | ||||
|       </table> | ||||
|     </div> | ||||
|   ); | ||||
|     const waffleData = data?.proyeccion.map(p => ({ | ||||
|         id: p.agrupacionNombre, | ||||
|         label: p.agrupacionNombre, | ||||
|         value: p.bancas, | ||||
|     })) || []; | ||||
|  | ||||
|     const totalBancas = waffleData.reduce((sum, current) => sum + current.value, 0); | ||||
|  | ||||
|     return ( | ||||
|         <div className="bancas-widget-container"> | ||||
|             <div className="bancas-header"> | ||||
|                 <h4>Distribución de Bancas</h4> | ||||
|                 <select value={seccionActual} onChange={e => setSeccionActual(e.target.value)}> | ||||
|                     {secciones.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)} | ||||
|                 </select> | ||||
|             </div> | ||||
|             <div className="waffle-chart-container"> | ||||
|                 {loading ? <p>Cargando...</p> : !data ? <p>No hay datos disponibles para esta sección.</p> : | ||||
|                 <ResponsiveWaffle | ||||
|                     data={waffleData} | ||||
|                     total={totalBancas} | ||||
|                     rows={8} | ||||
|                     columns={10} | ||||
|                     fillDirection="bottom" | ||||
|                     padding={3} | ||||
|                     colors={NIVO_COLORS} | ||||
|                     borderColor={{ from: 'color', modifiers: [['darker', 0.3]] }} | ||||
|                     animate={true} | ||||
|                     legends={[ | ||||
|                         { | ||||
|                             anchor: 'bottom', | ||||
|                             direction: 'row', | ||||
|                             justify: false, | ||||
|                             translateX: 0, | ||||
|                             translateY: 40, | ||||
|                             itemsSpacing: 4, | ||||
|                             itemWidth: 100, | ||||
|                             itemHeight: 20, | ||||
|                             itemTextColor: '#999', | ||||
|                             itemDirection: 'left-to-right', | ||||
|                             symbolSize: 20, | ||||
|                         }, | ||||
|                     ]} | ||||
|                 />} | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										125
									
								
								Elecciones-Web/frontend/src/components/MapaBsAs.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								Elecciones-Web/frontend/src/components/MapaBsAs.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| /* src/components/MapaBsAs.css */ | ||||
| :root { | ||||
|   --primary-accent-color: #FF5722; | ||||
|   --background-panel-color: #2f2f2f; | ||||
|   --border-color: #444; | ||||
|   --text-color: #f0f0f0; | ||||
|   --text-color-muted: #aaa; | ||||
|   --progress-bar-background: #4a4a4a; | ||||
|   --scrollbar-thumb-color: #666; | ||||
|   --scrollbar-track-color: #333; | ||||
|   --map-background-color: #242424; /* Color de fondo del mapa */ | ||||
| } | ||||
|  | ||||
| .mapa-wrapper { | ||||
|   display: flex; | ||||
|   gap: 1.5rem; | ||||
|   background-color: var(--map-background-color); | ||||
|   padding: 1rem; | ||||
|   border-radius: 12px; | ||||
|   max-width: 1600px; /* Incrementado para pantallas más grandes */ | ||||
|   margin: auto; | ||||
|   height: 88vh; /* Ligeramente más alto */ | ||||
|   min-height: 650px; | ||||
| } | ||||
|  | ||||
| .mapa-container { | ||||
|   flex: 0 0 70%; | ||||
|   height: 100%; | ||||
|   border: 1px solid var(--border-color); | ||||
|   border-radius: 8px; | ||||
|   position: relative; | ||||
|   overflow: hidden; | ||||
|   /* CORRECCIÓN: Se añade el color de fondo para eliminar el marco blanco */ | ||||
|   background-color: var(--map-background-color); | ||||
| } | ||||
|  | ||||
| .mapa-container .rsm-svg { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| .rsm-geography { | ||||
|   transition: opacity 0.3s ease-in-out, transform 0.2s ease-in-out, filter 0.2s ease-in-out, fill 0.3s ease; | ||||
|   cursor: pointer; | ||||
|   stroke-width: 1px; | ||||
| } | ||||
|  | ||||
| .rsm-geography:hover { | ||||
|   filter: drop-shadow(0px 0px 6px rgba(255, 255, 255, 0.6)); | ||||
|   transform: translateY(-1px); | ||||
|   stroke-width: 1.5px; | ||||
| } | ||||
|  | ||||
| .rsm-geography.selected { | ||||
|   fill: var(--primary-accent-color); /* Rellena el partido seleccionado con el color principal */ | ||||
|   stroke: #ffffff; /* Añade un borde blanco para un mejor contraste */ | ||||
|   stroke-width: 2px; /* Un grosor de borde definido */ | ||||
|   filter: none; /* Elimina el efecto de sombra/resplandor */ | ||||
|   outline: none; /* Previene el recuadro de enfoque del navegador */ | ||||
|   pointer-events: none; /* Mantenemos esto para evitar interacciones no deseadas */ | ||||
| } | ||||
|  | ||||
| .rsm-geography.faded { | ||||
|   opacity: 0.15; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .info-panel { | ||||
|   flex: 1; | ||||
|   height: 100%; | ||||
|   background-color: var(--background-panel-color); | ||||
|   border-radius: 8px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   overflow-y: auto; | ||||
|   padding: 1.5rem; | ||||
| } | ||||
|  | ||||
| .info-panel::-webkit-scrollbar { width: 8px; } | ||||
| .info-panel::-webkit-scrollbar-track { background: var(--scrollbar-track-color); border-radius: 4px; } | ||||
| .info-panel::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb-color); border-radius: 4px; border: 2px solid var(--scrollbar-track-color); } | ||||
| .info-panel::-webkit-scrollbar-thumb:hover { background-color: #888; } | ||||
| .info-panel h3 { margin-top: 0; color: var(--primary-accent-color); border-bottom: 2px solid var(--border-color); padding-bottom: 0.5rem; } | ||||
| .info-panel p { color: var(--text-color-muted); } | ||||
|  | ||||
| .reset-button-panel { | ||||
|   background: none; border: 1px solid var(--primary-accent-color); color: var(--primary-accent-color); padding: 0.5rem 1rem; border-radius: 5px; cursor: pointer; transition: all 0.2s; margin-bottom: 1rem; align-self: flex-start; | ||||
| } | ||||
| .reset-button-panel:hover { background-color: var(--primary-accent-color); color: white; } | ||||
|  | ||||
| .detalle-placeholder { text-align: center; margin: auto; } | ||||
| .detalle-loading, .detalle-error { text-align: center; margin: auto; color: var(--text-color-muted); } | ||||
| .detalle-metricas { display: flex; justify-content: space-between; font-size: 0.9em; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); margin-bottom: 1rem; } | ||||
| .resultados-lista { list-style: none; padding: 0; margin: 0; } | ||||
| .resultados-lista li { margin-bottom: 1rem; } | ||||
| .resultado-info { display: flex; justify-content: space-between; margin-bottom: 0.25rem; font-size: 0.9em; } | ||||
| .partido-nombre { font-weight: 500; } | ||||
| .partido-votos { font-weight: 300; color: var(--text-color-muted); } | ||||
| .progress-bar { height: 8px; background-color: var(--progress-bar-background); border-radius: 4px; overflow: hidden; } | ||||
| .progress-fill { height: 100%; background-color: var(--primary-accent-color); border-radius: 4px; transition: width 0.5s ease-out; } | ||||
|  | ||||
| .spinner { width: 40px; height: 40px; border: 4px solid var(--border-color); border-top-color: var(--primary-accent-color); border-radius: 50%; animation: spin 1s linear infinite; margin: 1rem auto; } | ||||
| @keyframes spin { to { transform: rotate(360deg); } } | ||||
|  | ||||
| .map-controls { | ||||
|   position: absolute; | ||||
|   top: 15px; | ||||
|   right: 15px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 5px; | ||||
|   z-index: 10; /* <-- AÑADIDO: Esta línea asegura que los controles estén por encima del mapa. */ | ||||
| } | ||||
| .map-controls button { | ||||
|   width: 32px; height: 32px; font-size: 1.2rem; font-weight: bold; background-color: rgba(0, 0, 0, 0.7); color: white; border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; padding: 0; line-height: 1; | ||||
| } | ||||
| .map-controls button:hover { background-color: rgba(0, 0, 0, 0.9); border-color: var(--primary-accent-color); } | ||||
|  | ||||
| .legend { margin-top: auto; padding-top: 1rem; border-top: 1px solid var(--border-color); } | ||||
| .legend h4 { margin-top: 0; } | ||||
| .legend-item { display: flex; align-items: center; margin-bottom: 0.5rem; font-size: 0.85em; } | ||||
| .legend-color-box { width: 16px; height: 16px; margin-right: 8px; border-radius: 3px; } | ||||
							
								
								
									
										264
									
								
								Elecciones-Web/frontend/src/components/MapaBsAs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								Elecciones-Web/frontend/src/components/MapaBsAs.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,264 @@ | ||||
| // src/components/MapaBsAs.tsx | ||||
| import { useState, useMemo, useCallback, useEffect } from 'react'; | ||||
| import type { MouseEvent } from 'react'; | ||||
| import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; | ||||
| import { Tooltip } from 'react-tooltip'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import axios from 'axios'; | ||||
| import type { Feature, Geometry } from 'geojson'; | ||||
| import { geoCentroid } from 'd3-geo'; | ||||
|  | ||||
| import './MapaBsAs.css'; | ||||
|  | ||||
| // --- Interfaces y Tipos --- | ||||
| type PointTuple = [number, number]; | ||||
|  | ||||
| interface ResultadoMapa { | ||||
|   ambitoId: number; | ||||
|   departamentoNombre: string; | ||||
|   agrupacionGanadoraId: string; | ||||
| } | ||||
|  | ||||
| interface ResultadoDetalladoMunicipio { | ||||
|   municipioNombre: string; | ||||
|   ultimaActualizacion: string; | ||||
|   porcentajeEscrutado: number; | ||||
|   porcentajeParticipacion: number; | ||||
|   resultados: { nombre: string; votos: number; porcentaje: number }[]; | ||||
|   votosAdicionales: { enBlanco: number; nulos: number; recurridos: number }; | ||||
| } | ||||
|  | ||||
| interface Agrupacion { | ||||
|   id: string; | ||||
|   nombre: string; | ||||
| } | ||||
|  | ||||
| interface PartidoProperties { | ||||
|   id: string; | ||||
|   departamento: string; | ||||
|   cabecera: string; | ||||
|   provincia: string; | ||||
| } | ||||
|  | ||||
| type PartidoGeography = Feature<Geometry, PartidoProperties> & { rsmKey: string }; | ||||
|  | ||||
| // --- Constantes --- | ||||
| const API_BASE_URL = 'http://localhost:5217/api'; | ||||
| const COLORES_BASE: string[] = ["#FF5733", "#33FF57", "#3357FF", "#FF33A1", "#A133FF", "#33FFA1", "#FFC300", "#C70039", "#900C3F", "#581845"]; | ||||
| const MIN_ZOOM = 1; | ||||
| const MAX_ZOOM = 8; | ||||
| // Define los límites del paneo: [[x0, y0], [x1, y1]]. | ||||
| // Esto evita que el mapa se "pierda" fuera de la vista. | ||||
| // Estos valores pueden necesitar ajuste fino según el tamaño final del contenedor del mapa. | ||||
| const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -600], [1100, 300]]; | ||||
| const INITIAL_POSITION = { center: [-60.5, -37.2] as PointTuple, zoom: MIN_ZOOM }; | ||||
|  | ||||
| // --- Componente Principal --- | ||||
| const MapaBsAs = () => { | ||||
|   const [position, setPosition] = useState(INITIAL_POSITION); | ||||
|   const [selectedAmbitoId, setSelectedAmbitoId] = useState<number | null>(null); | ||||
|  | ||||
|   const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapa[]>({ | ||||
|     queryKey: ['mapaResultados'], | ||||
|     queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa`)).data, | ||||
|   }); | ||||
|   const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({ | ||||
|     queryKey: ['mapaGeoData'], | ||||
|     queryFn: async () => (await axios.get('/partidos-bsas.topojson')).data, | ||||
|   }); | ||||
|   const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({ | ||||
|     queryKey: ['catalogoAgrupaciones'], | ||||
|     queryFn: async () => (await axios.get(`${API_BASE_URL}/Catalogos/agrupaciones`)).data, | ||||
|   }); | ||||
|  | ||||
|   const { nombresAgrupaciones, coloresPartidos, resultadosPorDepartamento } = useMemo(() => { | ||||
|     const nombresMap = new Map<string, string>(); | ||||
|     const coloresMap = new Map<string, string>(); | ||||
|     const resultadosMap = new Map<string, ResultadoMapa>(); | ||||
|     if (agrupacionesData) { | ||||
|       agrupacionesData.forEach((agrupacion, index) => { | ||||
|         nombresMap.set(agrupacion.id, agrupacion.nombre); | ||||
|         coloresMap.set(agrupacion.id, COLORES_BASE[index % COLORES_BASE.length]); | ||||
|       }); | ||||
|     } | ||||
|     coloresMap.set('default', '#D6D6DA'); | ||||
|     if (resultadosData) { | ||||
|       resultadosData.forEach(r => resultadosMap.set(r.departamentoNombre.toUpperCase(), r)); | ||||
|     } | ||||
|     return { nombresAgrupaciones: nombresMap, coloresPartidos: coloresMap, resultadosPorDepartamento: resultadosMap }; | ||||
|   }, [agrupacionesData, resultadosData]); | ||||
|  | ||||
|   const isLoading = isLoadingResultados || isLoadingAgrupaciones || isLoadingGeo; | ||||
|  | ||||
|   const handleReset = useCallback(() => { | ||||
|     setSelectedAmbitoId(null); | ||||
|     setPosition(INITIAL_POSITION); | ||||
|   }, []); | ||||
|  | ||||
|   const handleGeographyClick = useCallback((geo: PartidoGeography) => { | ||||
|     const departamentoNombre = geo.properties.departamento.toUpperCase(); | ||||
|     const resultado = resultadosPorDepartamento.get(departamentoNombre); | ||||
|     if (!resultado) return; | ||||
|     const ambitoIdParaSeleccionar = resultado.ambitoId; | ||||
|     if (selectedAmbitoId === ambitoIdParaSeleccionar) { | ||||
|       handleReset(); | ||||
|     } else { | ||||
|       const centroid = geoCentroid(geo) as PointTuple; | ||||
|       setPosition({ center: centroid, zoom: 5 }); | ||||
|       setSelectedAmbitoId(ambitoIdParaSeleccionar); | ||||
|     } | ||||
|   }, [selectedAmbitoId, handleReset, resultadosPorDepartamento]); | ||||
|  | ||||
|   const handleMoveEnd = (newPosition: { coordinates: PointTuple; zoom: number }) => { | ||||
|     // La lógica de reseteo cuando se hace zoom out completamente con la rueda del ratón se mantiene. | ||||
|     // El `translateExtent` ya previene que el mapa se mueva fuera de los límites. | ||||
|     if (newPosition.zoom <= MIN_ZOOM) { | ||||
|       if (position.zoom > MIN_ZOOM || selectedAmbitoId !== null) { | ||||
|         handleReset(); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Si se está haciendo zoom out desde una vista detallada, se deselecciona el municipio | ||||
|     // para volver a la vista general sin resetear completamente la posición. | ||||
|     if (newPosition.zoom < position.zoom && selectedAmbitoId !== null) { | ||||
|       setSelectedAmbitoId(null); | ||||
|     } | ||||
|  | ||||
|     // Actualiza el estado con la nueva posición y zoom del paneo/zoom del usuario. | ||||
|     setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom }); | ||||
|   }; | ||||
|  | ||||
|   const handleZoomIn = () => { | ||||
|     if (position.zoom < MAX_ZOOM) { | ||||
|       setPosition(p => ({ ...p, zoom: Math.min(p.zoom * 1.5, MAX_ZOOM) })); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleZoomOut = () => { | ||||
|     // Al presionar el botón de zoom out, siempre se vuelve al estado inicial. | ||||
|     handleReset(); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset(); | ||||
|     window.addEventListener('keydown', handleKeyDown); | ||||
|     return () => window.removeEventListener('keydown', handleKeyDown); | ||||
|   }, [handleReset]); | ||||
|  | ||||
|   const getPartyFillColor = (departamentoNombre: string) => { | ||||
|     const resultado = resultadosPorDepartamento.get(departamentoNombre.toUpperCase()); | ||||
|     if (!resultado) return coloresPartidos.get('default') || '#D6D6DA'; | ||||
|     return coloresPartidos.get(resultado.agrupacionGanadoraId) || coloresPartidos.get('default'); | ||||
|   }; | ||||
|  | ||||
|   const handleMouseEnter = (e: MouseEvent<SVGPathElement>) => { | ||||
|     const path = e.target as SVGPathElement; | ||||
|     if (path.parentNode) { | ||||
|       path.parentNode.appendChild(path); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (isLoading) return <div className="loading-container">Cargando datos del mapa...</div>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="mapa-wrapper"> | ||||
|       <div className="mapa-container"> | ||||
|         <ComposableMap projection="geoMercator" projectionConfig={{ scale: 4700, center: [-60.5, -37.2] }} className="rsm-svg" style={{ backgroundColor: "#242424" }}> | ||||
|           <ZoomableGroup | ||||
|             center={position.center} | ||||
|             zoom={position.zoom} | ||||
|             onMoveEnd={handleMoveEnd} | ||||
|             style={{ transition: "transform 400ms ease-in-out" }} | ||||
|             translateExtent={TRANSLATE_EXTENT} | ||||
|             minZoom={MIN_ZOOM} | ||||
|             maxZoom={MAX_ZOOM} | ||||
|             filterZoomEvent={(e: WheelEvent) => { | ||||
|               // Detectamos si la rueda se mueve hacia atrás (zoom out) | ||||
|               if (e.deltaY > 0) { | ||||
|                 handleReset(); | ||||
|               }else if (e.deltaY < 0) { | ||||
|                 handleZoomIn(); | ||||
|               } | ||||
|               return true; | ||||
|             }} | ||||
|           > | ||||
|             {geoData && ( | ||||
|               <Geographies geography={geoData}> | ||||
|                 {({ geographies }: { geographies: PartidoGeography[] }) => | ||||
|                   geographies.map((geo) => { | ||||
|                     const departamentoNombre = geo.properties.departamento.toUpperCase(); | ||||
|                     const resultado = resultadosPorDepartamento.get(departamentoNombre); | ||||
|                     const isSelected = resultado ? selectedAmbitoId === resultado.ambitoId : false; | ||||
|                     const isFaded = selectedAmbitoId !== null && !isSelected; | ||||
|                     const nombreAgrupacionGanadora = resultado ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) : 'Sin datos'; | ||||
|  | ||||
|                     return ( | ||||
|                       <Geography | ||||
|                         key={geo.rsmKey} | ||||
|                         geography={geo} | ||||
|                         data-tooltip-id="partido-tooltip" | ||||
|                         data-tooltip-content={`${geo.properties.departamento}: ${nombreAgrupacionGanadora}`} | ||||
|                         className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''}`} | ||||
|                         fill={getPartyFillColor(geo.properties.departamento)} | ||||
|                         stroke="#FFF" | ||||
|                         onClick={() => handleGeographyClick(geo)} | ||||
|                         onMouseEnter={handleMouseEnter} | ||||
|                       /> | ||||
|                     ); | ||||
|                   }) | ||||
|                 } | ||||
|               </Geographies> | ||||
|             )} | ||||
|           </ZoomableGroup> | ||||
|         </ComposableMap> | ||||
|         <Tooltip id="partido-tooltip" /> | ||||
|         <ControlesMapa onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onReset={handleReset} /> | ||||
|       </div> | ||||
|       <div className="info-panel"> | ||||
|         <DetalleMunicipio ambitoId={selectedAmbitoId} onReset={handleReset} /> | ||||
|         <Legend colores={coloresPartidos} nombres={nombresAgrupaciones} /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| // --- Sub-componentes (sin cambios) --- | ||||
| const ControlesMapa = ({ onZoomIn, onZoomOut, onReset }: { onZoomIn: () => void; onZoomOut: () => void; onReset: () => void }) => ( | ||||
|   <div className="map-controls"> | ||||
|     <button onClick={onZoomIn}>+</button> | ||||
|     <button onClick={onZoomOut}>-</button> | ||||
|     <button onClick={onReset}>⌖</button> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| const DetalleMunicipio = ({ ambitoId, onReset }: { ambitoId: number | null; onReset: () => void }) => { | ||||
|   const { data, isLoading, error } = useQuery<ResultadoDetalladoMunicipio>({ | ||||
|     queryKey: ['municipioDetalle', ambitoId], | ||||
|     queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/municipio/${ambitoId}`)).data, | ||||
|     enabled: !!ambitoId, | ||||
|   }); | ||||
|  | ||||
|   if (!ambitoId) return (<div className="detalle-placeholder"><h3>Provincia de Buenos Aires</h3><p>Seleccione un municipio en el mapa para ver los resultados detallados.</p></div>); | ||||
|   if (isLoading) return (<div className="detalle-loading"><div className="spinner"></div><p>Cargando resultados...</p></div>); | ||||
|   if (error) return <div className="detalle-error">Error al cargar los datos del municipio.</div>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="detalle-content"> | ||||
|       <button className="reset-button-panel" onClick={onReset}>← Ver Provincia</button> | ||||
|       <h3>{data?.municipioNombre}</h3> | ||||
|       <div className="detalle-metricas"> | ||||
|         <span><strong>Escrutado:</strong> {data?.porcentajeEscrutado.toFixed(2)}%</span> | ||||
|         <span><strong>Participación:</strong> {data?.porcentajeParticipacion.toFixed(2)}%</span> | ||||
|       </div> | ||||
|       <ul className="resultados-lista">{data?.resultados.map(r => (<li key={r.nombre}><div className="resultado-info"><span className="partido-nombre">{r.nombre}</span><span className="partido-votos">{r.votos.toLocaleString('es-AR')} ({r.porcentaje.toFixed(2)}%)</span></div><div className="progress-bar"><div className="progress-fill" style={{ width: `${r.porcentaje}%` }}></div></div></li>))}</ul> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const Legend = ({ colores, nombres }: { colores: Map<string, string>; nombres: Map<string, string> }) => { | ||||
|   const legendItems = Array.from(colores.entries()).filter(([id]) => id !== 'default').map(([id, color]) => ({ nombre: nombres.get(id) || 'Desconocido', color: color })); | ||||
|   return (<div className="legend"><h4>Leyenda de Ganadores</h4>{legendItems.map(item => (<div key={item.nombre} className="legend-item"><div className="legend-color-box" style={{ backgroundColor: item.color }} /><span>{item.nombre}</span></div>))}</div>); | ||||
| }; | ||||
|  | ||||
| export default MapaBsAs; | ||||
| @@ -1,112 +0,0 @@ | ||||
| import { useEffect, useRef, useState } from 'react'; | ||||
| import * as d3 from 'd3'; | ||||
| // FIX: Usamos 'import type' para los tipos y quitamos la importación de 'MunicipioSimple' | ||||
| import type { FeatureCollection } from 'geojson'; | ||||
|  | ||||
| // --- Interfaces y Constantes --- | ||||
| interface MapaResultado { | ||||
|   municipioId: string; | ||||
|   agrupacionGanadoraId: string; | ||||
| } | ||||
| const COLOR_MAP: { [key: string]: string } = { "018": "#FFC107", "025": "#03A9F4", "031": "#4CAF50", "045": "#9C27B0", "default": "#E0E0E0" }; | ||||
|  | ||||
| interface Props { | ||||
|   onMunicipioClick: (municipioId: string) => void; | ||||
| } | ||||
|  | ||||
| export const MapaD3Widget = ({ onMunicipioClick }: Props) => { | ||||
|   const svgRef = useRef<SVGSVGElement | null>(null); | ||||
|   const containerRef = useRef<HTMLDivElement | null>(null); | ||||
|   const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string } | null>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const svgElement = svgRef.current; | ||||
|     const containerElement = containerRef.current; | ||||
|     if (!svgElement || !containerElement) return; | ||||
|  | ||||
|     const drawMap = ( | ||||
|       geoData: FeatureCollection, // Usamos el tipo correcto | ||||
|       resultsMap: Map<string, MapaResultado>, | ||||
|       idMap: Record<string, string> | ||||
|     ) => { | ||||
|       const { width, height } = containerElement.getBoundingClientRect(); | ||||
|       if (width === 0 || height === 0) return; | ||||
|  | ||||
|       const svg = d3.select(svgElement); | ||||
|       svg.selectAll('*').remove(); | ||||
|       svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`); | ||||
|  | ||||
|       const projection = d3.geoMercator().fitSize([width, height], geoData); | ||||
|       const pathGenerator = d3.geoPath().projection(projection); | ||||
|  | ||||
|       const features = geoData.features; | ||||
|  | ||||
|       svg.append('g') | ||||
|         .selectAll('path') | ||||
|         .data(features) | ||||
|         .join('path') | ||||
|           .attr('d', pathGenerator as any) | ||||
|           .attr('stroke', '#FFFFFF') | ||||
|           .attr('stroke-width', 0.5) | ||||
|           .attr('fill', (d: any) => { | ||||
|             const geoJsonId = d.properties.cca; | ||||
|             const apiId = idMap[geoJsonId]; | ||||
|             const resultado = resultsMap.get(apiId); | ||||
|             return resultado ? COLOR_MAP[resultado.agrupacionGanadoraId] || COLOR_MAP.default : COLOR_MAP.default; | ||||
|           }) | ||||
|           .style('cursor', 'pointer') | ||||
|           .on('click', (_, d: any) => { | ||||
|             const apiId = idMap[d.properties.cca]; | ||||
|             if (apiId) onMunicipioClick(apiId); | ||||
|           }) | ||||
|           .on('mouseover', (event, d: any) => { | ||||
|             d3.select(event.currentTarget).attr('stroke', 'black').attr('stroke-width', 2); | ||||
|             setTooltip({ x: event.pageX, y: event.pageY, content: d.properties.nam }); | ||||
|           }) | ||||
|           .on('mouseout', (event) => { | ||||
|             d3.select(event.currentTarget).attr('stroke', '#FFFFFF').attr('stroke-width', 0.5); | ||||
|             setTooltip(null); | ||||
|           }); | ||||
|     }; | ||||
|  | ||||
|     (async () => { | ||||
|       try { | ||||
|         const [geoData, resultsData, idMap] = await Promise.all([ | ||||
|           d3.json<FeatureCollection>('/buenos-aires-municipios.geojson'), | ||||
|           d3.json<MapaResultado[]>('http://localhost:5217/api/resultados/mapa'), | ||||
|           d3.json<Record<string, string>>('/municipioIdMap.json') | ||||
|         ]); | ||||
|         if (geoData && resultsData && idMap) { | ||||
|           const resultsMap = new Map(resultsData.map(item => [item.municipioId, item])); | ||||
|           drawMap(geoData, resultsMap, idMap); | ||||
|         } else { | ||||
|           throw new Error("Faltan datos para renderizar el mapa."); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         console.error("Error cargando datos para el mapa:", err); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     })(); | ||||
|   }, [onMunicipioClick]); | ||||
|  | ||||
|   if (loading) { | ||||
|     return <div ref={containerRef} style={{ width: '100%', height: '600px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>Cargando datos del mapa...</div>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div ref={containerRef} style={{ width: '100%', height: '600px', border: '1px solid #eee' }}> | ||||
|       <svg ref={svgRef}></svg> | ||||
|       {tooltip && ( | ||||
|           <div style={{ | ||||
|               position: 'fixed', top: tooltip.y + 10, left: tooltip.x + 10, | ||||
|               backgroundColor: 'rgba(0, 0, 0, 0.75)', color: 'white', padding: '8px', | ||||
|               borderRadius: '4px', pointerEvents: 'none', fontSize: '14px', zIndex: 1000, | ||||
|           }}> | ||||
|               {tooltip.content} | ||||
|           </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,17 +0,0 @@ | ||||
| import { type MunicipioSimple } from '../services/api'; | ||||
|  | ||||
| interface Props { | ||||
|   municipios: MunicipioSimple[]; | ||||
|   onMunicipioChange: (municipioId: string) => void; | ||||
| } | ||||
|  | ||||
| export const MunicipioSelector = ({ municipios, onMunicipioChange }: Props) => { | ||||
|   return ( | ||||
|     <select onChange={(e) => onMunicipioChange(e.target.value)} defaultValue=""> | ||||
|       <option value="" disabled>Seleccione un municipio</option> | ||||
|       {municipios.map(m => ( | ||||
|         <option key={m.id} value={m.id}>{m.nombre}</option> | ||||
|       ))} | ||||
|     </select> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,69 +0,0 @@ | ||||
| // src/components/MunicipioWidget.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { getResultadosPorMunicipio, type MunicipioResultados } from '../services/api'; | ||||
|  | ||||
| interface Props { | ||||
|   municipioId: string; | ||||
| } | ||||
|  | ||||
| export const MunicipioWidget = ({ municipioId }: Props) => { | ||||
|   const [data, setData] = useState<MunicipioResultados | null>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       try { | ||||
|         setLoading(true); | ||||
|         const resultados = await getResultadosPorMunicipio(municipioId); | ||||
|         setData(resultados); | ||||
|         setError(null); | ||||
|       } catch (err) { | ||||
|         setError('No se pudieron cargar los datos.'); | ||||
|         console.error(err); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     // Hacemos la primera llamada inmediatamente | ||||
|     fetchData(); | ||||
|  | ||||
|     // Creamos un intervalo para refrescar los datos cada 10 segundos | ||||
|     const intervalId = setInterval(fetchData, 10000); | ||||
|  | ||||
|     // ¡Importante! Limpiamos el intervalo cuando el componente se desmonta | ||||
|     return () => clearInterval(intervalId); | ||||
|   }, [municipioId]); // El efecto se volverá a ejecutar si el municipioId cambia | ||||
|  | ||||
|   if (loading && !data) return <div>Cargando resultados...</div>; | ||||
|   if (error) return <div style={{ color: 'red' }}>{error}</div>; | ||||
|   if (!data) return <div>No hay datos disponibles.</div>; | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}> | ||||
|       <h2>{data.municipioNombre}</h2> | ||||
|       <p>Escrutado: {data.porcentajeEscrutado.toFixed(2)}% | Participación: {data.porcentajeParticipacion.toFixed(2)}%</p> | ||||
|       <hr /> | ||||
|       <table style={{ width: '100%', borderCollapse: 'collapse' }}> | ||||
|         <thead> | ||||
|           <tr> | ||||
|             <th style={{ textAlign: 'left' }}>Agrupación</th> | ||||
|             <th style={{ textAlign: 'right' }}>Votos</th> | ||||
|             <th style={{ textAlign: 'right' }}>%</th> | ||||
|           </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|           {data.resultados.map((partido) => ( | ||||
|             <tr key={partido.nombre}> | ||||
|               <td>{partido.nombre}</td> | ||||
|               <td style={{ textAlign: 'right' }}>{partido.votos.toLocaleString('es-AR')}</td> | ||||
|               <td style={{ textAlign: 'right' }}>{partido.porcentaje.toFixed(2)}%</td> | ||||
|             </tr> | ||||
|           ))} | ||||
|         </tbody> | ||||
|       </table> | ||||
|        <p style={{fontSize: '0.8em', color: '#666'}}>Última actualización: {new Date(data.ultimaActualizacion).toLocaleTimeString('es-AR')}</p> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,52 +0,0 @@ | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { getResumenProvincial, type ResumenProvincial } from '../services/api'; | ||||
|  | ||||
| interface Props { | ||||
|   distritoId: string; | ||||
| } | ||||
|  | ||||
| export const ResumenProvincialWidget = ({ distritoId }: Props) => { | ||||
|   const [data, setData] = useState<ResumenProvincial | null>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       try { | ||||
|         const resumen = await getResumenProvincial(distritoId); | ||||
|         setData(resumen); | ||||
|       } catch (err) { | ||||
|         console.error("Error cargando resumen provincial", err); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     fetchData(); | ||||
|     const intervalId = setInterval(fetchData, 15000); // Actualizamos cada 15s | ||||
|     return () => clearInterval(intervalId); | ||||
|   }, [distritoId]); | ||||
|  | ||||
|   if (loading) return <div>Cargando resumen provincial...</div>; | ||||
|   if (!data) return <div>No hay datos provinciales disponibles.</div>; | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '16px', borderRadius: '8px', backgroundColor: '#f9f9f9' }}> | ||||
|       <h2>Resumen Provincial - {data.provinciaNombre}</h2> | ||||
|       <p><strong>Mesas Escrutadas:</strong> {data.porcentajeEscrutado.toFixed(2)}% | <strong>Participación:</strong> {data.porcentajeParticipacion.toFixed(2)}%</p> | ||||
|        | ||||
|       {data.resultados.map((partido) => ( | ||||
|         <div key={partido.nombre} style={{ margin: '10px 0' }}> | ||||
|           <span>{partido.nombre}</span> | ||||
|           <div style={{ display: 'flex', alignItems: 'center' }}> | ||||
|             <div style={{ backgroundColor: '#ddd', width: '100%', borderRadius: '4px', marginRight: '10px' }}> | ||||
|               <div style={{ width: `${partido.porcentaje}%`, backgroundColor: 'royalblue', color: 'white', padding: '4px', borderRadius: '4px', textAlign: 'right' }}> | ||||
|                 <strong>{partido.porcentaje.toFixed(2)}%</strong> | ||||
|               </div> | ||||
|             </div> | ||||
|             <span>{partido.votos.toLocaleString('es-AR')}</span> | ||||
|           </div> | ||||
|         </div> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,65 +0,0 @@ | ||||
| // src/components/TelegramasView.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { getListaTelegramas, getTelegramaPorId, type TelegramaDetalle } from '../services/api'; | ||||
|  | ||||
| export const TelegramasView = () => { | ||||
|   const [listaIds, setListaIds] = useState<string[]>([]); | ||||
|   const [selectedTelegrama, setSelectedTelegrama] = useState<TelegramaDetalle | null>(null); | ||||
|   const [loadingList, setLoadingList] = useState(true); | ||||
|   const [loadingDetail, setLoadingDetail] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const loadList = async () => { | ||||
|       try { | ||||
|         const ids = await getListaTelegramas(); | ||||
|         setListaIds(ids); | ||||
|       } catch (error) { | ||||
|         console.error("Error al cargar lista de telegramas", error); | ||||
|       } finally { | ||||
|         setLoadingList(false); | ||||
|       } | ||||
|     }; | ||||
|     loadList(); | ||||
|   }, []); | ||||
|  | ||||
|   const handleSelectTelegrama = async (mesaId: string) => { | ||||
|     try { | ||||
|         setLoadingDetail(true); | ||||
|         const detalle = await getTelegramaPorId(mesaId); | ||||
|         setSelectedTelegrama(detalle); | ||||
|     } catch (error) { | ||||
|         console.error(`Error al cargar telegrama ${mesaId}`, error); | ||||
|     } finally { | ||||
|         setLoadingDetail(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ display: 'flex', gap: '20px', height: '500px' }}> | ||||
|       <div style={{ flex: 1, border: '1px solid #ccc', overflowY: 'auto' }}> | ||||
|         <h4>Telegramas Disponibles</h4> | ||||
|         {loadingList ? <p>Cargando...</p> : ( | ||||
|             <ul style={{ listStyle: 'none', padding: '10px' }}> | ||||
|                 {listaIds.map(id => ( | ||||
|                     <li key={id} onClick={() => handleSelectTelegrama(id)} style={{ cursor: 'pointer', padding: '5px', borderBottom: '1px solid #eee' }}> | ||||
|                         Mesa: {id} | ||||
|                     </li> | ||||
|                 ))} | ||||
|             </ul> | ||||
|         )} | ||||
|       </div> | ||||
|       <div style={{ flex: 3, border: '1px solid #ccc', display: 'flex', justifyContent: 'center', alignItems: 'center' }}> | ||||
|         {loadingDetail ? <p>Cargando telegrama...</p> :  | ||||
|          selectedTelegrama ? ( | ||||
|             <iframe  | ||||
|                 src={`data:application/pdf;base64,${selectedTelegrama.contenidoBase64}`}  | ||||
|                 width="100%"  | ||||
|                 height="100%"  | ||||
|                 title={selectedTelegrama.id} | ||||
|             /> | ||||
|          ) : <p>Seleccione un telegrama de la lista para visualizarlo.</p> | ||||
|         } | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										82
									
								
								Elecciones-Web/frontend/src/components/TickerWidget.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								Elecciones-Web/frontend/src/components/TickerWidget.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| /* src/components/TickerWidget.css */ | ||||
| .ticker-container { | ||||
|   background-color: #2a2a2e; | ||||
|   padding: 15px 20px; | ||||
|   border-radius: 8px; | ||||
|   max-width: 800px; | ||||
|   margin: 20px auto; | ||||
|   font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; | ||||
|   color: #e0e0e0; | ||||
| } | ||||
|  | ||||
| .ticker-container.loading, .ticker-container.error { | ||||
|     text-align: center; | ||||
|     padding: 30px; | ||||
|     font-style: italic; | ||||
|     color: #999; | ||||
| } | ||||
|  | ||||
| .ticker-header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   border-bottom: 1px solid #444; | ||||
|   padding-bottom: 10px; | ||||
|   margin-bottom: 15px; | ||||
| } | ||||
|  | ||||
| .ticker-header h3 { | ||||
|   margin: 0; | ||||
|   color: white; | ||||
|   font-size: 1.4em; | ||||
| } | ||||
|  | ||||
| .ticker-stats { | ||||
|   display: flex; | ||||
|   gap: 20px; | ||||
|   font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| .ticker-stats strong { | ||||
|   color: #a7c7e7; | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .ticker-results { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | ||||
|   gap: 20px; | ||||
| } | ||||
|  | ||||
| .ticker-party .party-info { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   margin-bottom: 5px; | ||||
|   font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| .ticker-party .party-name { | ||||
|   font-weight: 500; | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   padding-right: 10px; | ||||
| } | ||||
|  | ||||
| .ticker-party .party-percent { | ||||
|   font-weight: bold; | ||||
| } | ||||
|  | ||||
| .party-bar-background { | ||||
|   background-color: #444; | ||||
|   border-radius: 4px; | ||||
|   height: 10px; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .party-bar-foreground { | ||||
|   background-color: #646cff; | ||||
|   height: 100%; | ||||
|   border-radius: 4px; | ||||
|   transition: width 0.5s ease-in-out; | ||||
| } | ||||
							
								
								
									
										67
									
								
								Elecciones-Web/frontend/src/components/TickerWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								Elecciones-Web/frontend/src/components/TickerWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| // src/components/TickerWidget.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { getResumenProvincial } from '../apiService'; | ||||
| import type { ResumenProvincial } from '../types/types'; | ||||
| import './TickerWidget.css'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`; | ||||
| const COLORS = [ | ||||
|     "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", | ||||
|     "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf" | ||||
| ]; | ||||
|  | ||||
| export const TickerWidget = () => { | ||||
|   const [data, setData] = useState<ResumenProvincial | null>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       try { | ||||
|         const result = await getResumenProvincial(); | ||||
|         setData(result); | ||||
|       } catch (error) { | ||||
|         console.error("Error cargando resumen provincial:", error); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     fetchData(); // Carga inicial | ||||
|     const intervalId = setInterval(fetchData, 30000); // Actualiza cada 30 segundos | ||||
|  | ||||
|     return () => clearInterval(intervalId); // Limpia el intervalo al desmontar el componente | ||||
|   }, []); | ||||
|  | ||||
|   if (loading) { | ||||
|     return <div className="ticker-container loading">Cargando resultados provinciales...</div>; | ||||
|   } | ||||
|  | ||||
|   if (!data) { | ||||
|     return <div className="ticker-container error">No se pudieron cargar los datos.</div>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-container"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>TOTAL PROVINCIA {data.provinciaNombre}</h3> | ||||
|         <div className="ticker-stats"> | ||||
|           <span>Mesas Escrutadas: <strong>{formatPercent(data.porcentajeEscrutado)}</strong></span> | ||||
|           <span>Participación: <strong>{formatPercent(data.porcentajeParticipacion)}</strong></span> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {data.resultados.slice(0, 3).map((partido, index) => ( | ||||
|           <div key={`${partido.nombre}-${index}`} className="ticker-party"> | ||||
|             <div className="party-info"> | ||||
|               <span className="party-name">{partido.nombre}</span> | ||||
|               <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|             </div> | ||||
|             <div className="party-bar-background"> | ||||
|               <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor:COLORS[index % COLORS.length] }}></div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user