308 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			308 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| // src/components/MapaBsAsSecciones.tsx
 | |
| import { useState, useMemo, useCallback, useEffect } 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 { geoCentroid } from 'd3-geo';
 | |
| import { getDetalleSeccion } from '../apiService';
 | |
| import type { ResultadoDetalleSeccion } from '../apiService';
 | |
| import './MapaBsAs.css';
 | |
| 
 | |
| // --- Interfaces y Tipos ---
 | |
| type PointTuple = [number, number];
 | |
| interface ResultadoMapaSeccion {
 | |
|   seccionId: string;
 | |
|   seccionNombre: string;
 | |
|   agrupacionGanadoraId: string | null;
 | |
|   colorGanador: string | null;
 | |
| }
 | |
| interface Agrupacion { id: string; nombre: string; }
 | |
| interface Categoria { id: number; nombre: string; }
 | |
| type SeccionGeography = {
 | |
|   rsmKey: string;
 | |
|   properties: { seccion: string; fna: string; };
 | |
| };
 | |
| 
 | |
| // --- Constantes ---
 | |
| const API_BASE_URL = 'http://localhost:5217/api';
 | |
| const DEFAULT_MAP_COLOR = '#E0E0E0';
 | |
| const CATEGORIAS: Categoria[] = [{ id: 5, nombre: 'Senadores' }, { id: 6, nombre: 'Diputados' }];
 | |
| const SECCION_ID_TO_ROMAN: Record<string, string> = { '1': 'I', '2': 'II', '3': 'III', '4': 'IV', '5': 'V', '6': 'VI', '7': 'VII', '8': 'VIII' };
 | |
| const ROMAN_TO_SECCION_ID: Record<string, string> = { 'I': '1', 'II': '2', 'III': '3', 'IV': '4', 'V': '5', 'VI': '6', 'VII': '7', 'VIII': '8' };
 | |
| const MIN_ZOOM = 1;
 | |
| const MAX_ZOOM = 5;
 | |
| 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 de Detalle ---
 | |
| const DetalleSeccion = ({ seccion, categoriaId, onReset }: { seccion: SeccionGeography | null, categoriaId: number, onReset: () => void }) => {
 | |
|   // Obtenemos el ID numérico de la sección a partir de su número romano para llamar a la API
 | |
|   const seccionId = seccion ? ROMAN_TO_SECCION_ID[seccion.properties.seccion] : null;
 | |
| 
 | |
|   const { data: resultadosDetalle, isLoading, error } = useQuery<ResultadoDetalleSeccion[]>({
 | |
|     queryKey: ['detalleSeccion', seccionId, categoriaId],
 | |
|     queryFn: () => getDetalleSeccion(seccionId!, categoriaId),
 | |
|     enabled: !!seccionId, // La query solo se ejecuta si hay una sección seleccionada
 | |
|   });
 | |
| 
 | |
|   if (!seccion) {
 | |
|     return (
 | |
|       <div className="detalle-placeholder">
 | |
|         <h3>Resultados por Sección</h3>
 | |
|         <p>Haga clic en una sección del mapa para ver los resultados detallados.</p>
 | |
|       </div>
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   if (!seccion) return (<div className="detalle-placeholder"><h3>Resultados por Sección</h3><p>Haga clic en una sección del mapa para ver los resultados detallados.</p></div>);
 | |
|   if (isLoading) return (<div className="detalle-loading"><div className="spinner"></div><p>Cargando resultados de la sección...</p></div>);
 | |
|   if (error) return <div className="detalle-error">Error al cargar los datos de la sección.</div>;
 | |
| 
 | |
|   // --- LÓGICA DE NOMBRE DE SECCIÓN ---
 | |
|   // Mapeo de número romano a nombre legible. Se puede mejorar en el futuro.
 | |
|   const NOMBRES_SECCIONES: Record<string, string> = {
 | |
|     'I': 'Sección Primera', 'II': 'Sección Segunda', 'III': 'Sección Tercera', 'IV': 'Sección Cuarta',
 | |
|     'V': 'Sección Quinta', 'VI': 'Sección Sexta', 'VII': 'Sección Séptima', 'VIII': 'Sección Capital'
 | |
|   };
 | |
|   const nombreSeccionLegible = NOMBRES_SECCIONES[seccion.properties.seccion] || "Sección Desconocida";
 | |
| 
 | |
|   return (
 | |
|     <div className="detalle-content">
 | |
|       <button className="reset-button-panel" onClick={onReset}>← VOLVER</button>
 | |
|       {/* Mostramos el nombre legible de la sección */}
 | |
|       <h3>{nombreSeccionLegible}</h3>
 | |
| 
 | |
|       <ul className="resultados-lista">
 | |
|         {/* Mapeamos los resultados obtenidos de la API */}
 | |
|         {resultadosDetalle?.map((r) => (
 | |
|           <li key={r.id}>
 | |
|             <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>
 | |
|   );
 | |
| };
 | |
| 
 | |
| // --- Componente de Controles del Mapa ---
 | |
| const ControlesMapa = ({ onReset }: { onReset: () => void }) => (
 | |
|   <div className="map-controls">
 | |
|     <button onClick={onReset}>← VOLVER</button>
 | |
|   </div>
 | |
| );
 | |
| 
 | |
| // --- Componente Principal ---
 | |
| const MapaBsAsSecciones = () => {
 | |
|   const [position, setPosition] = useState(INITIAL_POSITION);
 | |
|   const [selectedCategoriaId, setSelectedCategoriaId] = useState<number>(6);
 | |
|   const [clickedSeccion, setClickedSeccion] = useState<SeccionGeography | null>(null);
 | |
|   const [tooltipContent, setTooltipContent] = useState('');
 | |
| 
 | |
|   const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({
 | |
|     queryKey: ['mapaGeoDataSecciones'],
 | |
|     queryFn: async () => (await axios.get('./secciones-electorales-pba.topojson')).data,
 | |
|   });
 | |
| 
 | |
|   const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapaSeccion[]>({
 | |
|     queryKey: ['mapaResultadosPorSeccion', selectedCategoriaId],
 | |
|     queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa-por-seccion?categoriaId=${selectedCategoriaId}`)).data,
 | |
|   });
 | |
| 
 | |
|   const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({
 | |
|     queryKey: ['catalogoAgrupaciones'],
 | |
|     queryFn: async () => (await axios.get(`${API_BASE_URL}/Catalogos/agrupaciones`)).data,
 | |
|   });
 | |
| 
 | |
|   const { nombresAgrupaciones, resultadosPorSeccionRomana } = useMemo<{
 | |
|     nombresAgrupaciones: Map<string, string>;
 | |
|     resultadosPorSeccionRomana: Map<string, ResultadoMapaSeccion>;
 | |
|   }>(() => {
 | |
|     const nombresMap = new Map<string, string>();
 | |
|     const resultadosMap = new Map<string, ResultadoMapaSeccion>();
 | |
| 
 | |
|     if (agrupacionesData) {
 | |
|       agrupacionesData.forEach(a => nombresMap.set(a.id, a.nombre));
 | |
|     }
 | |
|     if (resultadosData) {
 | |
|       resultadosData.forEach(r => {
 | |
|         const roman = SECCION_ID_TO_ROMAN[r.seccionId];
 | |
|         if (roman) resultadosMap.set(roman, r);
 | |
|       });
 | |
|     }
 | |
|     return { nombresAgrupaciones: nombresMap, resultadosPorSeccionRomana: resultadosMap };
 | |
|   }, [agrupacionesData, resultadosData]);
 | |
| 
 | |
|   const isLoading = isLoadingGeo || isLoadingResultados || isLoadingAgrupaciones;
 | |
| 
 | |
|   const handleReset = useCallback(() => {
 | |
|     setClickedSeccion(null);
 | |
|     setPosition(INITIAL_POSITION);
 | |
|   }, []);
 | |
| 
 | |
|   const handleGeographyClick = useCallback((geo: SeccionGeography) => {
 | |
|     if (clickedSeccion?.rsmKey === geo.rsmKey) {
 | |
|       handleReset();
 | |
|     } else {
 | |
|       const centroid = geoCentroid(geo as any) as PointTuple;
 | |
|       setPosition({ center: centroid, zoom: 2 });
 | |
|       setClickedSeccion(geo);
 | |
|     }
 | |
|   }, [clickedSeccion, handleReset]);
 | |
| 
 | |
|   const handleMoveEnd = (newPosition: { coordinates: PointTuple; zoom: number }) => {
 | |
|     if (newPosition.zoom <= MIN_ZOOM) {
 | |
|       if (position.zoom > MIN_ZOOM || clickedSeccion !== null) {
 | |
|         handleReset();
 | |
|       }
 | |
|       return;
 | |
|     }
 | |
|     // Si el usuario hace zoom out, deseleccionamos la sección para volver a la vista general
 | |
|     if (newPosition.zoom < position.zoom && clickedSeccion !== null) {
 | |
|       setClickedSeccion(null);
 | |
|     }
 | |
|     setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom });
 | |
|   };
 | |
| 
 | |
|   useEffect(() => {
 | |
|     const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset();
 | |
|     window.addEventListener('keydown', handleKeyDown);
 | |
|     return () => window.removeEventListener('keydown', handleKeyDown);
 | |
|   }, [handleReset]);
 | |
| 
 | |
|   const getSectionFillColor = (seccionRomana: string) => {
 | |
|     return resultadosPorSeccionRomana.get(seccionRomana)?.colorGanador || DEFAULT_MAP_COLOR;
 | |
|   };
 | |
| 
 | |
|   const handleZoomIn = () => {
 | |
|     if (position.zoom < MAX_ZOOM) {
 | |
|       setPosition(p => ({ ...p, zoom: Math.min(p.zoom * 1.5, MAX_ZOOM) }));
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   return (
 | |
|     <div className="mapa-wrapper">
 | |
|       <div className="mapa-container">
 | |
|         {isLoading ? <div className="spinner"></div> : (
 | |
|           <ComposableMap
 | |
|             key={selectedCategoriaId}
 | |
|             projection="geoMercator"
 | |
|             projectionConfig={{ scale: 4400, center: [-60.5, -37.2] }}
 | |
|             className="rsm-svg"
 | |
|             data-tooltip-id="seccion-tooltip"
 | |
|           >
 | |
|             <ZoomableGroup
 | |
|               center={position.center}
 | |
|               zoom={position.zoom}
 | |
|               onMoveEnd={handleMoveEnd}
 | |
|               minZoom={MIN_ZOOM}
 | |
|               maxZoom={MAX_ZOOM}
 | |
|               translateExtent={TRANSLATE_EXTENT}
 | |
|               style={{ transition: "transform 400ms ease-in-out" }}
 | |
|               filterZoomEvent={(e: WheelEvent) => {
 | |
|                 if (e.deltaY > 0) {
 | |
|                   handleReset();
 | |
|                 } else if (e.deltaY < 0) {
 | |
|                   handleZoomIn();
 | |
|                 }
 | |
|                 return true;
 | |
|               }}
 | |
|             >
 | |
|               {geoData && (
 | |
|                 <Geographies geography={geoData}>
 | |
|                   {({ geographies }: { geographies: SeccionGeography[] }) =>
 | |
|                     geographies.map((geo) => {
 | |
|                       const seccionRomana = geo.properties.seccion;
 | |
|                       const resultado = resultadosPorSeccionRomana.get(seccionRomana);
 | |
|                       const nombreGanador = resultado?.agrupacionGanadoraId ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) : 'Sin datos';
 | |
|                       const isSelected = clickedSeccion?.rsmKey === geo.rsmKey;
 | |
|                       const isFaded = clickedSeccion && !isSelected;
 | |
|                       const isClickable = !!resultado;
 | |
| 
 | |
|                       return (
 | |
|                         <Geography
 | |
|                           key={geo.rsmKey + (isSelected ? '-selected' : '')}
 | |
|                           geography={geo as any}
 | |
|                           data-tooltip-id="seccion-tooltip"
 | |
|                           data-tooltip-content={`${geo.properties.fna}: ${nombreGanador}`}
 | |
|                           onClick={isClickable ? () => handleGeographyClick(geo) : undefined}
 | |
|                           onMouseEnter={() => {
 | |
|                             if (isClickable) {
 | |
|                               setTooltipContent(`${geo.properties.fna}: ${nombreGanador}`);
 | |
|                             }
 | |
|                           }}
 | |
|                           onMouseLeave={() => setTooltipContent("")}
 | |
|                           className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''} ${!isClickable ? 'no-results' : ''}`}
 | |
|                           fill={getSectionFillColor(seccionRomana)}
 | |
|                         />
 | |
|                       );
 | |
|                     })
 | |
|                   }
 | |
|                 </Geographies>
 | |
|               )}
 | |
|             </ZoomableGroup>
 | |
|           </ComposableMap>
 | |
|         )}
 | |
|         {/* El botón de volver ahora está aquí, en el componente principal */}
 | |
|         {clickedSeccion && <ControlesMapa onReset={handleReset} />}
 | |
|         <Tooltip id="seccion-tooltip" content={tooltipContent} />
 | |
|       </div>
 | |
|       <div className="info-panel">
 | |
|         <div className="mapa-categoria-selector">
 | |
|           <select
 | |
|             className="mapa-categoria-combobox"
 | |
|             value={selectedCategoriaId}
 | |
|             onChange={(e) => {
 | |
|               setSelectedCategoriaId(Number(e.target.value));
 | |
|               handleReset();
 | |
|             }}
 | |
|           >
 | |
|             {CATEGORIAS.map(cat => (
 | |
|               <option key={cat.id} value={cat.id}>
 | |
|                 {cat.nombre}
 | |
|               </option>
 | |
|             ))}
 | |
|           </select>
 | |
|         </div>
 | |
|         <DetalleSeccion seccion={clickedSeccion} categoriaId={selectedCategoriaId} onReset={handleReset} />
 | |
|         <LegendSecciones resultados={resultadosPorSeccionRomana} nombresAgrupaciones={nombresAgrupaciones} />
 | |
|       </div>
 | |
|     </div>
 | |
|   );
 | |
| };
 | |
| 
 | |
| // --- Sub-componente para la Leyenda ---
 | |
| const LegendSecciones = ({ resultados, nombresAgrupaciones }: { resultados: Map<string, ResultadoMapaSeccion>, nombresAgrupaciones: Map<string, string> }) => {
 | |
|   const legendItems = useMemo(() => {
 | |
|     const ganadoresUnicos = new Map<string, { nombre: string; color: string }>();
 | |
|     resultados.forEach(resultado => {
 | |
|       if (resultado.agrupacionGanadoraId && resultado.colorGanador && !ganadoresUnicos.has(resultado.agrupacionGanadoraId)) {
 | |
|         ganadoresUnicos.set(resultado.agrupacionGanadoraId, {
 | |
|           nombre: nombresAgrupaciones.get(resultado.agrupacionGanadoraId) || 'Desconocido',
 | |
|           color: resultado.colorGanador
 | |
|         });
 | |
|       }
 | |
|     });
 | |
|     return Array.from(ganadoresUnicos.values());
 | |
|   }, [resultados, nombresAgrupaciones]);
 | |
| 
 | |
|   return (
 | |
|     <div className="legend">
 | |
|       <h4>Ganadores por Sección</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 MapaBsAsSecciones; |