Feat Front Widgets Refactizados y Ajustes Backend

This commit is contained in:
2025-08-22 21:55:03 -03:00
parent 18e6e8d3c0
commit 5de9d6729c
54 changed files with 2443 additions and 1680 deletions

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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

View 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;
}