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