Preparación Legislativas Nacionales 2025
This commit is contained in:
		| @@ -0,0 +1,12 @@ | ||||
| // src/features/legislativas/nacionales/components/AnimatedNumber.tsx | ||||
| import { useAnimatedNumber } from '../hooks/useAnimatedNumber'; | ||||
|  | ||||
| interface AnimatedNumberProps { | ||||
|   value: number; | ||||
|   formatter: (value: number) => string; | ||||
| } | ||||
|  | ||||
| export const AnimatedNumber = ({ value, formatter }: AnimatedNumberProps) => { | ||||
|   const animatedValue = useAnimatedNumber(value); | ||||
|   return <span>{formatter(animatedValue)}</span>; | ||||
| }; | ||||
| @@ -0,0 +1,28 @@ | ||||
| // src/features/legislativas/nacionales/components/Breadcrumbs.tsx | ||||
| interface BreadcrumbsProps { | ||||
|   nivel: 'pais' | 'provincia' | 'municipio'; | ||||
|   nombreAmbito: string; | ||||
|   nombreProvincia?: string; | ||||
|   onReset: () => void; | ||||
|   onVolverProvincia: () => void; | ||||
| } | ||||
|  | ||||
| export const Breadcrumbs = ({ nivel, nombreAmbito, nombreProvincia, onReset, onVolverProvincia }: BreadcrumbsProps) => { | ||||
|   return ( | ||||
|     <div className="breadcrumbs"> | ||||
|       {nivel !== 'pais' && ( | ||||
|         <> | ||||
|           <button onClick={onReset} className="breadcrumb-link">Argentina</button> | ||||
|           <span className="breadcrumb-separator">{'>'}</span> | ||||
|         </> | ||||
|       )} | ||||
|       {nivel === 'municipio' && nombreProvincia && ( | ||||
|         <> | ||||
|           <button onClick={onVolverProvincia} className="breadcrumb-link">{nombreProvincia}</button> | ||||
|           <span className="breadcrumb-separator">{'>'}</span> | ||||
|         </> | ||||
|       )} | ||||
|       <span className="breadcrumb-actual">{nombreAmbito}</span> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,104 @@ | ||||
| // src/features/legislativas/nacionales/components/MapaNacional.tsx | ||||
| import axios from 'axios'; | ||||
| import { Suspense, useState, useEffect, useCallback } from 'react'; // <-- Asegúrate de que useCallback esté importado | ||||
| import { useSuspenseQuery } from '@tanstack/react-query'; | ||||
| import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; | ||||
| import { Tooltip } from 'react-tooltip'; | ||||
| import { API_BASE_URL, assetBaseUrl } from '../../../../apiService'; | ||||
| import type { ResultadoMapaDto, AmbitoGeography } from '../../../../types/types'; | ||||
| import { MapaProvincial } from './MapaProvincial'; | ||||
|  | ||||
| const DEFAULT_MAP_COLOR = '#E0E0E0'; | ||||
| const FADED_BACKGROUND_COLOR = '#F0F0F0'; | ||||
| const normalizarTexto = (texto: string = '') => texto.trim().toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||||
|  | ||||
| type PointTuple = [number, number]; | ||||
|  | ||||
| interface MapaNacionalProps { | ||||
|   eleccionId: number; | ||||
|   categoriaId: number; | ||||
|   nivel: 'pais' | 'provincia' | 'municipio'; | ||||
|   nombreAmbito: string; | ||||
|   provinciaDistritoId: string | null; | ||||
|   onAmbitoSelect: (ambitoId: string, nivel: 'provincia' | 'municipio', nombre: string) => void; | ||||
|   onVolver: () => void; | ||||
| } | ||||
|  | ||||
| export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, provinciaDistritoId, onAmbitoSelect, onVolver }: MapaNacionalProps) => { | ||||
|   const [position, setPosition] = useState({ zoom: 1, center: [-65, -40] as PointTuple }); | ||||
|  | ||||
|   const { data: mapaDataNacional } = useSuspenseQuery<ResultadoMapaDto[]>({ | ||||
|     queryKey: ['mapaResultados', eleccionId, categoriaId, null], | ||||
|     queryFn: async () => { | ||||
|       const url = `${API_BASE_URL}/elecciones/${eleccionId}/mapa-resultados?categoriaId=${categoriaId}`; | ||||
|       const response = await axios.get(url); | ||||
|       return response.data; | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   const { data: geoDataNacional } = useSuspenseQuery<any>({ | ||||
|     queryKey: ['geoDataNacional'], | ||||
|     queryFn: () => axios.get(`${assetBaseUrl}/maps/argentina-provincias.topojson`).then(res => res.data), | ||||
|   }); | ||||
|  | ||||
|   const resultadosNacionalesPorNombre = new Map<string, ResultadoMapaDto>(mapaDataNacional.map(d => [normalizarTexto(d.ambitoNombre), d])); | ||||
|    | ||||
|   const nombreMunicipioSeleccionado = nivel === 'municipio' ? nombreAmbito : null; | ||||
|  | ||||
|   // El useEffect para el zoom provincial y nacional sigue siendo correcto. | ||||
|   useEffect(() => { | ||||
|     if (nivel === 'pais') { | ||||
|       setPosition({ zoom: 1, center: [-65, -40] }); | ||||
|     } else if (nivel === 'provincia') { | ||||
|       setPosition({ zoom: 7, center: [-60.5, -37] }); | ||||
|     } | ||||
|     // La lógica de centrado en municipio se delega al hijo, que llamará a `handleCalculatedCenter` | ||||
|   }, [nivel]); | ||||
|  | ||||
|   // **LA SOLUCIÓN CLAVE**: Estabilizamos la función que se pasa al hijo. | ||||
|   const handleCalculatedCenter = useCallback((center: PointTuple, zoom: number) => { | ||||
|     setPosition({ center, zoom }); | ||||
|   }, []); // El array de dependencias vacío asegura que la función nunca cambie | ||||
|  | ||||
|   return ( | ||||
|     <div className="mapa-componente-container"> | ||||
|       {nivel !== 'pais' && <button onClick={onVolver} className="mapa-volver-btn">← Volver</button>} | ||||
|       <ComposableMap projection="geoMercator" projectionConfig={{ scale: 700, center: [-65, -40] }} style={{ width: "100%", height: "100%" }}> | ||||
|         <ZoomableGroup center={position.center} zoom={position.zoom} filterZoomEvent={() => false}> | ||||
|           <Geographies geography={geoDataNacional}> | ||||
|             {({ geographies }: { geographies: AmbitoGeography[] }) => geographies.map((geo) => { | ||||
|               const resultado = resultadosNacionalesPorNombre.get(normalizarTexto(geo.properties.nombre)); | ||||
|               const esProvinciaActiva = provinciaDistritoId && resultado?.ambitoId === provinciaDistritoId; | ||||
|  | ||||
|               return ( | ||||
|                 <Geography | ||||
|                   key={geo.rsmKey} geography={geo} | ||||
|                   className={`rsm-geography ${nivel !== 'pais' ? 'rsm-geography-faded' : ''}`} | ||||
|                   style={{ visibility: esProvinciaActiva ? 'hidden' : 'visible' }} | ||||
|                   fill={nivel === 'pais' ? (resultado?.colorGanador || DEFAULT_MAP_COLOR) : FADED_BACKGROUND_COLOR} | ||||
|                   onClick={() => resultado && onAmbitoSelect(resultado.ambitoId, 'provincia', resultado.ambitoNombre)} | ||||
|                 /> | ||||
|               ); | ||||
|             })} | ||||
|           </Geographies> | ||||
|  | ||||
|           {provinciaDistritoId && ( | ||||
|             <Suspense fallback={null}> | ||||
|               <MapaProvincial | ||||
|                 eleccionId={eleccionId} | ||||
|                 categoriaId={categoriaId} | ||||
|                 distritoId={provinciaDistritoId} | ||||
|                 nombreProvincia={"BUENOS AIRES"} // Esto se podría hacer dinámico si fuera necesario | ||||
|                 nombreMunicipioSeleccionado={nombreMunicipioSeleccionado} | ||||
|                 onMunicipioSelect={(ambitoId, nombre) => onAmbitoSelect(ambitoId, 'municipio', nombre)} | ||||
|                 onCalculatedCenter={handleCalculatedCenter} // Pasamos la función estabilizada | ||||
|                 nivel={nivel as 'provincia' | 'municipio'} // El cast de tipo sigue siendo necesario y correcto | ||||
|               /> | ||||
|             </Suspense> | ||||
|           )} | ||||
|         </ZoomableGroup> | ||||
|       </ComposableMap> | ||||
|       <Tooltip id="mapa-tooltip" /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,82 @@ | ||||
| // src/features/legislativas/nacionales/components/MapaProvincial.tsx | ||||
| import axios from 'axios'; | ||||
| import { useEffect } from 'react'; | ||||
| import { useSuspenseQuery } from '@tanstack/react-query'; | ||||
| import { Geographies, Geography } from 'react-simple-maps'; | ||||
| import { geoCentroid } from 'd3-geo'; | ||||
| import { feature } from 'topojson-client'; | ||||
| import { API_BASE_URL, assetBaseUrl } from '../../../../apiService'; | ||||
| import type { ResultadoMapaDto, AmbitoGeography } from '../../../../types/types'; | ||||
|  | ||||
| const DEFAULT_MAP_COLOR = '#E0E0E0'; | ||||
| const normalizarTexto = (texto: string = ''): string => texto.trim().toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||||
| type PointTuple = [number, number]; | ||||
|  | ||||
| interface MapaProvincialProps { | ||||
|   eleccionId: number; | ||||
|   categoriaId: number; | ||||
|   distritoId: string; | ||||
|   nombreProvincia: string; | ||||
|   nombreMunicipioSeleccionado: string | null; | ||||
|   nivel: 'provincia' | 'municipio'; | ||||
|   onMunicipioSelect: (ambitoId: string, nombre: string) => void; | ||||
|   onCalculatedCenter: (center: PointTuple, zoom: number) => void; | ||||
| } | ||||
|  | ||||
| export const MapaProvincial = ({ eleccionId, categoriaId, distritoId, nombreProvincia, nombreMunicipioSeleccionado, nivel, onMunicipioSelect, onCalculatedCenter }: MapaProvincialProps) => { | ||||
|   const { data: mapaData = [] } = useSuspenseQuery<ResultadoMapaDto[]>({ | ||||
|     queryKey: ['mapaResultados', eleccionId, categoriaId, distritoId], | ||||
|     queryFn: async () => { | ||||
|       const url = `${API_BASE_URL}/elecciones/${eleccionId}/mapa-resultados?categoriaId=${categoriaId}&distritoId=${distritoId}`; | ||||
|       const response = await axios.get(url); | ||||
|       return response.data; | ||||
|     }, | ||||
|   }); | ||||
|    | ||||
|   // El nombre del archivo ahora es completamente dinámico | ||||
|   const { data: geoData } = useSuspenseQuery<any>({ | ||||
|     queryKey: ['geoDataProvincial', nombreProvincia], | ||||
|     queryFn: async () => { | ||||
|       const nombreNormalizado = nombreProvincia.toLowerCase().replace(/ /g, '_'); | ||||
|       const mapFile = `departamentos-${nombreNormalizado}.topojson`; | ||||
|       return axios.get(`${assetBaseUrl}/maps/${mapFile}`).then(res => res.data); | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   // useEffect para calcular y "exportar" la posición del municipio al padre | ||||
|   useEffect(() => { | ||||
|     if (nivel === 'municipio' && geoData?.objects && nombreMunicipioSeleccionado) { | ||||
|       const geometries = geoData.objects[Object.keys(geoData.objects)[0]].geometries; | ||||
|       const municipioGeo = geometries.find((g: any) => normalizarTexto(g.properties.departamento) === normalizarTexto(nombreMunicipioSeleccionado)); | ||||
|       if (municipioGeo) { | ||||
|           const municipioFeature = feature(geoData, municipioGeo); | ||||
|           const centroid = geoCentroid(municipioFeature); | ||||
|           // Usamos un zoom genérico alto para cualquier municipio | ||||
|           onCalculatedCenter(centroid as PointTuple, 40); | ||||
|       } | ||||
|     } | ||||
|   }, [nivel, nombreMunicipioSeleccionado, geoData, onCalculatedCenter]); | ||||
|  | ||||
|   const resultadosPorNombre = new Map<string, ResultadoMapaDto>(mapaData.map(d => [normalizarTexto(d.ambitoNombre), d])); | ||||
|  | ||||
|   return ( | ||||
|     <Geographies geography={geoData}> | ||||
|       {({ geographies }: { geographies: AmbitoGeography[] }) => geographies.map((geo) => { | ||||
|         const resultado = resultadosPorNombre.get(normalizarTexto(geo.properties.departamento)); | ||||
|         const esSeleccionado = nombreMunicipioSeleccionado ? normalizarTexto(geo.properties.departamento) === normalizarTexto(nombreMunicipioSeleccionado) : false; | ||||
|  | ||||
|         return ( | ||||
|           <Geography | ||||
|             key={geo.rsmKey} | ||||
|             geography={geo} | ||||
|             className={`rsm-geography ${esSeleccionado ? 'selected' : ''} ${nombreMunicipioSeleccionado && !esSeleccionado ? 'rsm-geography-faded-municipality' : ''}`} | ||||
|             fill={resultado?.colorGanador || DEFAULT_MAP_COLOR} | ||||
|             onClick={resultado ? () => onMunicipioSelect(resultado.ambitoId.toString(), resultado.ambitoNombre) : undefined} | ||||
|             data-tooltip-id="mapa-tooltip" | ||||
|             data-tooltip-content={geo.properties.departamento} | ||||
|           /> | ||||
|         ); | ||||
|       })} | ||||
|     </Geographies> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,55 @@ | ||||
| // src/features/legislativas/nacionales/components/PanelResultados.tsx | ||||
| import type { ResultadoTicker, EstadoRecuentoTicker } from '../../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../../components/common/ImageWithFallback'; | ||||
| import { assetBaseUrl } from '../../../../apiService'; | ||||
| import { AnimatedNumber } from './AnimatedNumber'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const formatVotes = (num: number) => Math.round(num).toLocaleString('es-AR'); | ||||
|  | ||||
| interface PanelResultadosProps { | ||||
|   resultados: ResultadoTicker[]; | ||||
|   estadoRecuento: EstadoRecuentoTicker; | ||||
| } | ||||
|  | ||||
| export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosProps) => { | ||||
|   return ( | ||||
|     <div className="panel-resultados"> | ||||
|       <div className="panel-partidos-container"> | ||||
|         {resultados.map(partido => ( | ||||
|           <div key={partido.id} className="partido-fila"> | ||||
|             <div className="partido-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} /> | ||||
|             </div> | ||||
|             <div className="partido-info-wrapper"> | ||||
|               <span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span> | ||||
|               {partido.nombreCandidato && <span className="candidato-nombre">{partido.nombreCandidato}</span>} | ||||
|               <div className="partido-barra-background"> | ||||
|                 <div className="partido-barra-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }} /> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div className="partido-stats"> | ||||
|               <span className="partido-porcentaje"> | ||||
|                 <AnimatedNumber value={partido.porcentaje} formatter={formatPercent} /> | ||||
|               </span> | ||||
|               <span className="partido-votos"> | ||||
|                 <AnimatedNumber value={partido.votos} formatter={formatVotes} /> votos | ||||
|               </span> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|  | ||||
|       <div className="panel-estado-recuento"> | ||||
|         <div className="estado-item"> | ||||
|           <span>Participación</span> | ||||
|           <strong><AnimatedNumber value={estadoRecuento.participacionPorcentaje} formatter={formatPercent} /></strong> | ||||
|         </div> | ||||
|         <div className="estado-item"> | ||||
|           <span>Mesas Escrutadas</span> | ||||
|           <strong><AnimatedNumber value={estadoRecuento.mesasTotalizadasPorcentaje} formatter={formatPercent} /></strong> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user