Feat Front Widgets Refactizados y Ajustes Backend
This commit is contained in:
42
Elecciones-Web/Restaurar/Nuevos/App.css
Normal file
42
Elecciones-Web/Restaurar/Nuevos/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
18
Elecciones-Web/Restaurar/Nuevos/App.tsx
Normal file
18
Elecciones-Web/Restaurar/Nuevos/App.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/App.tsx
|
||||
import 'react-tooltip/dist/react-tooltip.css';
|
||||
import MapaBsAs from './components/MapaBsAs';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ width: '50%', margin: '2rem auto' }}>
|
||||
<h1>Resultados Electorales - Provincia de Buenos Aires</h1>
|
||||
<p>Pasa el ratón sobre un partido para ver el ganador. Haz clic para más detalles.</p>
|
||||
|
||||
<MapaBsAs /> {/* Ya no necesita un div contenedor aquí */}
|
||||
|
||||
{/* Próximo paso: Crear una Leyenda aquí */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
58
Elecciones-Web/Restaurar/Nuevos/components/MapaBsAs.css
Normal file
58
Elecciones-Web/Restaurar/Nuevos/components/MapaBsAs.css
Normal file
@@ -0,0 +1,58 @@
|
||||
/* src/components/MapaBsAs.css */
|
||||
.mapa-wrapper {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.mapa-container {
|
||||
flex: 3; /* El mapa ocupa 3/4 del espacio */
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
/* Proporción aproximada para la provincia de Bs As */
|
||||
padding-top: 80%;
|
||||
}
|
||||
|
||||
.mapa-container .rsm-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Efecto "lift" al pasar el ratón */
|
||||
.rsm-geography {
|
||||
transition: transform 0.2s ease-in-out, fill 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rsm-geography:hover {
|
||||
transform: scale(1.03);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
flex: 1; /* El panel ocupa 1/4 del espacio */
|
||||
padding: 1rem;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.info-panel h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.legend-color-box {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
border: 1px solid #fff;
|
||||
outline: 1px solid #ccc;
|
||||
}
|
||||
217
Elecciones-Web/Restaurar/Nuevos/components/MapaBsAs.tsx
Normal file
217
Elecciones-Web/Restaurar/Nuevos/components/MapaBsAs.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
// src/components/MapaBsAs.tsx
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ComposableMap, Geographies, Geography } 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'; // Para calcular el centro de cada partido
|
||||
import { useSpring, animated } from 'react-spring'; // Para animar el zoom
|
||||
|
||||
//import geoUrl from '/partidos-bsas.topojson';
|
||||
import './MapaBsAs.css';
|
||||
|
||||
// --- Interfaces y Tipos ---
|
||||
interface ResultadoMapa {
|
||||
partidoId: string;
|
||||
agrupacionGanadoraId: string;
|
||||
porcentajeGanador: number; // Nueva propiedad desde el backend
|
||||
}
|
||||
|
||||
interface Agrupacion {
|
||||
id: string;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
interface PartidoProperties {
|
||||
id: number;
|
||||
departamento: string;
|
||||
cabecera: string; // Asegúrate de que coincida con tu topojson
|
||||
provincia: string;
|
||||
}
|
||||
|
||||
type PartidoGeography = Feature<Geometry, PartidoProperties> & { rsmKey: string };
|
||||
|
||||
const PALETA_COLORES: { [key: string]: [number, number, number] } = {
|
||||
'default': [214, 214, 218] // RGB para el color por defecto
|
||||
};
|
||||
|
||||
const INITIAL_PROJECTION = {
|
||||
center: [-59.8, -37.0] as [number, number],
|
||||
scale: 5400,
|
||||
};
|
||||
|
||||
const MapaBsAs = () => {
|
||||
const [selectedPartido, setSelectedPartido] = useState<PartidoGeography | null>(null);
|
||||
const [projectionConfig, setProjectionConfig] = useState(INITIAL_PROJECTION);
|
||||
|
||||
// --- Carga de Datos ---
|
||||
const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapa[]>({
|
||||
queryKey: ['mapaResultados'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('http://localhost:5217/api/Resultados/mapa');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: geoData, isLoading: isLoadingGeo } = useQuery({
|
||||
queryKey: ['mapaGeoData'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('/partidos-bsas.topojson');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({
|
||||
queryKey: ['catalogoAgrupaciones'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('http://localhost:5217/api/Catalogos/agrupaciones');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { nombresAgrupaciones, coloresPartidos } = useMemo(() => {
|
||||
if (!agrupacionesData) return { nombresAgrupaciones: {}, coloresPartidos: {} };
|
||||
|
||||
const nombres = agrupacionesData.reduce((acc, agrupacion) => {
|
||||
acc[agrupacion.id] = agrupacion.nombre;
|
||||
return acc;
|
||||
}, {} as { [key: string]: string });
|
||||
|
||||
const colores = agrupacionesData.reduce((acc, agrupacion, index) => {
|
||||
const baseColor = [255, 87, 51, 51, 255, 87, 51, 87, 255, 255, 51, 161, 161, 51, 255, 255, 195, 0, 199, 0, 57, 144, 12, 63, 88, 24, 69];
|
||||
acc[agrupacion.nombre] = [baseColor[index*3], baseColor[index*3+1], baseColor[index*3+2]];
|
||||
return acc;
|
||||
}, {} as { [key: string]: [number, number, number] });
|
||||
|
||||
colores['default'] = [214, 214, 218];
|
||||
return { nombresAgrupaciones: nombres, coloresPartidos: colores };
|
||||
}, [agrupacionesData]);
|
||||
|
||||
const animatedProps = useSpring({
|
||||
to: { scale: projectionConfig.scale, cx: projectionConfig.center[0], cy: projectionConfig.center[1] },
|
||||
config: { tension: 170, friction: 26 },
|
||||
});
|
||||
|
||||
if (isLoadingResultados || isLoadingGeo || isLoadingAgrupaciones) {
|
||||
return <div>Cargando datos del mapa...</div>;
|
||||
}
|
||||
|
||||
const getPartyStyle = (partidoIdGeo: string) => {
|
||||
const resultado = resultadosData?.find(r => r.partidoId === partidoIdGeo);
|
||||
if (!resultado) {
|
||||
return { fill: `rgb(${PALETA_COLORES.default.join(',')})` };
|
||||
}
|
||||
const nombreAgrupacion = nombresAgrupaciones[resultado.agrupacionGanadoraId] || 'Otro';
|
||||
const baseColor = coloresPartidos[nombreAgrupacion] || PALETA_COLORES.default;
|
||||
|
||||
// Calcula la opacidad basada en el porcentaje. 0.4 (débil) a 1.0 (fuerte)
|
||||
const opacity = 0.4 + (resultado.porcentajeGanador / 100) * 0.6;
|
||||
|
||||
return { fill: `rgba(${baseColor.join(',')}, ${opacity})` };
|
||||
};
|
||||
|
||||
const handleGeographyClick = (geo: PartidoGeography) => {
|
||||
const centroid = geoCentroid(geo);
|
||||
setSelectedPartido(geo);
|
||||
setProjectionConfig({
|
||||
center: centroid,
|
||||
scale: 18000, // Zoom más cercano
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedPartido(null);
|
||||
setProjectionConfig(INITIAL_PROJECTION);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mapa-wrapper">
|
||||
<div className="mapa-container">
|
||||
<animated.div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: animatedProps.scale.to(s => `scale(${s / INITIAL_PROJECTION.scale})`),
|
||||
}}>
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: animatedProps.scale,
|
||||
center: [animatedProps.cx, animatedProps.cy],
|
||||
}}
|
||||
className="rsm-svg"
|
||||
>
|
||||
<Geographies geography={geoData}>
|
||||
{({ geographies }: { geographies: PartidoGeography[] }) =>
|
||||
geographies.map((geo) => {
|
||||
const partidoId = String(geo.properties.id);
|
||||
const partidoNombre = geo.properties.departamento;
|
||||
const resultado = resultadosData?.find(r => r.partidoId === partidoId);
|
||||
const agrupacionNombre = resultado ? (nombresAgrupaciones[resultado.agrupacionGanadoraId] || 'Desconocido') : 'Sin datos';
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
data-tooltip-id="partido-tooltip"
|
||||
data-tooltip-content={`${partidoNombre}: ${agrupacionNombre}`}
|
||||
fill={getPartyStyle(partidoId).fill}
|
||||
stroke="#FFF"
|
||||
className="rsm-geography"
|
||||
style={{
|
||||
default: { outline: 'none' },
|
||||
hover: { outline: 'none', stroke: '#FF5722', strokeWidth: 2, fill: getPartyStyle(partidoId).fill },
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
onClick={() => handleGeographyClick(geo)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ComposableMap>
|
||||
</animated.div>
|
||||
<Tooltip id="partido-tooltip" />
|
||||
</div>
|
||||
|
||||
<div className="info-panel">
|
||||
<button onClick={handleReset}>Resetear Vista</button>
|
||||
{selectedPartido ? (
|
||||
<div>
|
||||
<h3>{selectedPartido.properties.departamento}</h3>
|
||||
<p><strong>Cabecera:</strong> {selectedPartido.properties.cabecera}</p>
|
||||
<p><strong>ID:</strong> {selectedPartido.properties.id}</p>
|
||||
{/* Aquí mostrarías más datos del partido seleccionado */}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3>Provincia de Buenos Aires</h3>
|
||||
<p>Selecciona un partido para ver más detalles.</p>
|
||||
</div>
|
||||
)}
|
||||
<Legend colores={coloresPartidos} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Componente de Leyenda separado
|
||||
const Legend = ({ colores }: { colores: { [key: string]: [number, number, number] } }) => {
|
||||
return (
|
||||
<div className="legend">
|
||||
<h4>Leyenda</h4>
|
||||
{Object.entries(colores).map(([nombre, color]) => {
|
||||
if (nombre === 'default') return null;
|
||||
return (
|
||||
<div key={nombre} className="legend-item">
|
||||
<div className="legend-color-box" style={{ backgroundColor: `rgb(${color.join(',')})` }} />
|
||||
<span>{nombre}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapaBsAs;
|
||||
68
Elecciones-Web/Restaurar/Nuevos/index.css
Normal file
68
Elecciones-Web/Restaurar/Nuevos/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
17
Elecciones-Web/Restaurar/Nuevos/main.tsx
Normal file
17
Elecciones-Web/Restaurar/Nuevos/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
// src/main.tsx
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
// Crea un cliente
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
12
Elecciones-Web/Restaurar/Nuevos/types/custom.d.ts
vendored
Normal file
12
Elecciones-Web/Restaurar/Nuevos/types/custom.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// src/types/custom.d.ts
|
||||
|
||||
// Solución para el problema 1: Le dice a TypeScript que el módulo 'react-simple-maps' existe.
|
||||
// Esto evita el error de "módulo no encontrado" y trata sus componentes como 'any'.
|
||||
declare module 'react-simple-maps';
|
||||
|
||||
// Solución para el problema 2: Le dice a TypeScript cómo manejar la importación de archivos .topojson.
|
||||
// Define que cuando importemos un archivo con esa extensión, el contenido será de tipo 'any'.
|
||||
declare module '*.topojson' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
56
Elecciones-Web/Restaurar/apiService.ts
Normal file
56
Elecciones-Web/Restaurar/apiService.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// src/apiService.ts
|
||||
import axios from 'axios';
|
||||
import type { MunicipioSimple, MunicipioDetalle, ResumenProvincial, ProyeccionBancas } from './types';
|
||||
|
||||
// La URL base de tu API. En un proyecto real, esto iría en un archivo .env
|
||||
const API_BASE_URL = 'http://localhost:5217/api'; // Ajusta el puerto si es necesario
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtiene los resultados para colorear el mapa inicial.
|
||||
*/
|
||||
export const getResultadosParaMapa = async (): Promise<any[]> => { // Usamos any[] temporalmente
|
||||
const response = await apiClient.get('/resultados/mapa');
|
||||
// Mapeamos la respuesta para que coincida con lo que el frontend espera
|
||||
return response.data.map((item: any) => ({
|
||||
municipioId: item.partidoId, // La propiedad en el frontend se llama municipioId
|
||||
agrupacionGanadoraId: item.agrupacionGanadoraId,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene la lista de todos los municipios con sus IDs y nombres.
|
||||
*/
|
||||
export const getMunicipios = async (): Promise<MunicipioSimple[]> => {
|
||||
const response = await apiClient.get('/catalogos/municipios');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene el detalle completo de los resultados para un municipio específico.
|
||||
*/
|
||||
export const getDetallePorMunicipio = async (partidoId: string): Promise<MunicipioDetalle> => {
|
||||
const response = await apiClient.get(`/resultados/partido/${partidoId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene el resumen de resultados a nivel provincial.
|
||||
* El distritoId para la PBA es "02" según la estructura de la API.
|
||||
*/
|
||||
export const getResumenProvincial = async (): Promise<ResumenProvincial> => {
|
||||
// Hardcodeamos el distritoId '02' para Buenos Aires
|
||||
const response = await apiClient.get('/resultados/provincia/02');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getBancasPorSeccion = async (seccionId: string): Promise<ProyeccionBancas> => {
|
||||
const response = await apiClient.get(`/resultados/bancas/${seccionId}`);
|
||||
return response.data;
|
||||
};
|
||||
35
Elecciones-Web/Restaurar/components/BancasWidget.css
Normal file
35
Elecciones-Web/Restaurar/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;
|
||||
}
|
||||
91
Elecciones-Web/Restaurar/components/BancasWidget.tsx
Normal file
91
Elecciones-Web/Restaurar/components/BancasWidget.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// src/components/BancasWidget.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ResponsiveWaffle } from '@nivo/waffle';
|
||||
import { getBancasPorSeccion } from '../apiService';
|
||||
import type { ProyeccionBancas } from '../types';
|
||||
import './BancasWidget.css';
|
||||
|
||||
// Paleta de colores consistente
|
||||
const NIVO_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"];
|
||||
|
||||
// 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)' },
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
82
Elecciones-Web/Restaurar/components/TickerWidget.css
Normal file
82
Elecciones-Web/Restaurar/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/Restaurar/components/TickerWidget.tsx
Normal file
67
Elecciones-Web/Restaurar/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';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
|
||||
const NIVO_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>{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"> {/* <-- CAMBIO AQUÍ */}
|
||||
<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: NIVO_COLORS[index % NIVO_COLORS.length] }}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user