Feat Widgets Controles y Estilos
This commit is contained in:
		| @@ -1,4 +1,3 @@ | ||||
| // src/features/legislativas/nacionales/components/MapaNacional.tsx | ||||
| import axios from 'axios'; | ||||
| import { Suspense, useState, useEffect, useCallback, useRef } from 'react'; | ||||
| import { useSuspenseQuery } from '@tanstack/react-query'; | ||||
| @@ -12,6 +11,7 @@ import { MapaProvincial } from './MapaProvincial'; | ||||
| import { CabaLupa } from './CabaLupa'; | ||||
| import { BiZoomIn, BiZoomOut } from "react-icons/bi"; | ||||
| import toast from 'react-hot-toast'; | ||||
| import { useMediaQuery } from '../hooks/useMediaQuery'; | ||||
|  | ||||
| const DEFAULT_MAP_COLOR = '#E0E0E0'; | ||||
| const FADED_BACKGROUND_COLOR = '#F0F0F0'; | ||||
| @@ -19,15 +19,21 @@ const normalizarTexto = (texto: string = '') => texto.trim().toUpperCase().norma | ||||
|  | ||||
| type PointTuple = [number, number]; | ||||
|  | ||||
| const PROVINCE_VIEW_CONFIG: Record<string, { center: PointTuple; zoom: number }> = { | ||||
|   "BUENOS AIRES": { center: [-60.5, -37.3], zoom: 5 }, | ||||
|   "SANTA CRUZ": { center: [-69.5, -49.3], zoom: 5 }, | ||||
|   "CIUDAD AUTONOMA DE BUENOS AIRES": { center: [-58.45, -34.6], zoom: 85 }, | ||||
|   "CHUBUT": { center: [-68.5, -44.5], zoom: 5.5 }, | ||||
|   "SANTA FE": { center: [-61, -31.2], zoom: 6 }, | ||||
|   "CORRIENTES": { center: [-58, -29], zoom: 7 }, | ||||
|   "RIO NEGRO": { center: [-67.5, -40], zoom: 5.5 }, | ||||
|   "TIERRA DEL FUEGO": { center: [-66.5, -54.2], zoom: 7 }, | ||||
| interface ViewConfig { | ||||
|   center: PointTuple; | ||||
|   zoom: number; | ||||
| } | ||||
|  | ||||
| const PROVINCE_VIEW_CONFIG: Record<string, { desktop: ViewConfig; mobile?: ViewConfig }> = { | ||||
|   "BUENOS AIRES": { desktop: { center: [-60.5, -37.3], zoom: 5 }, mobile: { center: [-60, -38], zoom: 5.5 } }, | ||||
|   "SANTA CRUZ": { desktop: { center: [-69.5, -49.3], zoom: 5 }, mobile: { center: [-69.5, -50], zoom: 4 } }, | ||||
|   "CIUDAD AUTONOMA DE BUENOS AIRES": { desktop: { center: [-58.44, -34.65], zoom: 150 } }, | ||||
|   "CHUBUT": { desktop: { center: [-68.5, -44.5], zoom: 5.5 }, mobile: { center: [-68, -44.5], zoom: 4.5 } }, | ||||
|   "SANTA FE": { desktop: { center: [-61, -31.2], zoom: 6 }, mobile: { center: [-61, -31.5], zoom: 7.5 } }, | ||||
|   "CORRIENTES": { desktop: { center: [-58, -29], zoom: 7 }, mobile: { center: [-57.5, -28.8], zoom: 9 } }, | ||||
|   "RIO NEGRO": { desktop: { center: [-67.5, -40], zoom: 5.5 }, mobile: { center: [-67.5, -40], zoom: 4.3 } }, | ||||
|   "SALTA": { desktop: { center: [-64.5, -24], zoom: 7 }, mobile: { center: [-65.5, -24.5], zoom: 6 } }, | ||||
|   "TIERRA DEL FUEGO": { desktop: { center: [-66.5, -54.2], zoom: 7 }, mobile: { center: [-66, -54], zoom: 7.5 } }, | ||||
| }; | ||||
|  | ||||
| const LUPA_SIZE_RATIO = 0.2; | ||||
| @@ -48,15 +54,20 @@ interface MapaNacionalProps { | ||||
|  | ||||
| // --- CONFIGURACIONES DEL MAPA --- | ||||
| const desktopProjectionConfig = { scale: 700, center: [-65, -40] as [number, number] }; | ||||
| const mobileProjectionConfig = { scale: 1100, center: [-64, -41] as [number, number] }; | ||||
| const mobileProjectionConfig = { scale: 1100, center: [-64, -42.5] as [number, number] }; | ||||
| // --- LÍNEA A CALIBRAR --- | ||||
| const mobileSmallProjectionConfig = { scale: 900, center: [-64, -43] as [number, number] }; | ||||
|  | ||||
|  | ||||
| export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nombreProvinciaActiva, provinciaDistritoId, onAmbitoSelect, onVolver, isMobileView }: MapaNacionalProps) => { | ||||
|   const isMobileSmall = useMediaQuery('(max-width: 380px)'); | ||||
|  | ||||
|   const [position, setPosition] = useState({ | ||||
|     zoom: isMobileView ? 1.5 : 1.05, // 1.5 para móvil, 1.05 para desktop | ||||
|     center: [-65, -40] as PointTuple | ||||
|     zoom: isMobileView ? 1.5 : 1.05, | ||||
|     center: isMobileView ? mobileProjectionConfig.center : desktopProjectionConfig.center as PointTuple | ||||
|   }); | ||||
|   const [isPanning, setIsPanning] = useState(false); | ||||
|   const initialProvincePositionRef = useRef<{ zoom: number, center: PointTuple } | null>(null); | ||||
|   const initialProvincePositionRef = useRef<ViewConfig | null>(null); | ||||
|  | ||||
|   const containerRef = useRef<HTMLDivElement | null>(null); | ||||
|   const lupaRef = useRef<HTMLDivElement | null>(null); | ||||
| @@ -82,32 +93,38 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (nivel === 'pais') { | ||||
|       const currentMobileConfig = isMobileSmall ? mobileSmallProjectionConfig : mobileProjectionConfig; | ||||
|       const currentMobileZoom = isMobileSmall ? 1.4 : 1.5; | ||||
|  | ||||
|       setPosition({ | ||||
|         zoom: isMobileView ? 1.4 : 1.05, | ||||
|         center: [-65, -40] | ||||
|         zoom: isMobileView ? currentMobileZoom : 1.05, | ||||
|         center: isMobileView ? currentMobileConfig.center : desktopProjectionConfig.center | ||||
|       }); | ||||
|       initialProvincePositionRef.current = null; | ||||
|     } else if (nivel === 'provincia') { | ||||
|       const nombreNormalizado = normalizarTexto(nombreAmbito); | ||||
|       const manualConfig = PROVINCE_VIEW_CONFIG[nombreNormalizado]; | ||||
|  | ||||
|       let provinceConfig = { zoom: 7, center: [-65, -40] as PointTuple }; | ||||
|        | ||||
|       let provinceConfig: ViewConfig | undefined; | ||||
|  | ||||
|       if (manualConfig) { | ||||
|         provinceConfig = manualConfig; | ||||
|         provinceConfig = (isMobileView && manualConfig.mobile) ? manualConfig.mobile : manualConfig.desktop; | ||||
|       } else { | ||||
|         const provinciaGeo = geoDataNacional.objects.provincias.geometries.find((g: any) => normalizarTexto(g.properties.nombre) === nombreNormalizado); | ||||
|         if (provinciaGeo) { | ||||
|           const provinciaFeature = feature(geoDataNacional, provinciaGeo); | ||||
|           const centroid = geoCentroid(provinciaFeature); | ||||
|           provinceConfig = { zoom: 7, center: centroid as PointTuple }; | ||||
|           provinceConfig = { zoom: isMobileView ? 8 : 7, center: centroid as PointTuple }; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       setPosition(provinceConfig); | ||||
|       initialProvincePositionRef.current = provinceConfig; | ||||
|       if (provinceConfig) { | ||||
|           setPosition(provinceConfig); | ||||
|           initialProvincePositionRef.current = provinceConfig; | ||||
|       } | ||||
|     } | ||||
|   }, [nivel, nombreAmbito, geoDataNacional, isMobileView]); | ||||
|   }, [nivel, nombreAmbito, geoDataNacional, isMobileView, isMobileSmall]); | ||||
|  | ||||
|  | ||||
|   const resultadosNacionalesPorNombre = new Map<string, ResultadoMapaDto>(mapaDataNacional.map(d => [normalizarTexto(d.ambitoNombre), d])); | ||||
|   const nombreMunicipioSeleccionado = nivel === 'municipio' ? nombreAmbito : null; | ||||
| @@ -173,14 +190,9 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom | ||||
|     position.zoom > initialProvincePositionRef.current.zoom && | ||||
|     !nombreMunicipioSeleccionado; | ||||
|  | ||||
|   // --- INICIO DE LA CORRECCIÓN --- | ||||
|  | ||||
|   const handleZoomIn = () => { | ||||
|     // Solo mostramos la notificación si el paneo NO está ya habilitado | ||||
|     if (!panEnabled && initialProvincePositionRef.current) { | ||||
|       // Calculamos cuál será el nuevo nivel de zoom | ||||
|       const newZoom = position.zoom * 1.8; | ||||
|       // Si el nuevo zoom supera el umbral inicial, activamos la notificación | ||||
|       if (newZoom > initialProvincePositionRef.current.zoom) { | ||||
|         toast.success('Desplazamiento Habilitado', { | ||||
|           icon: '🖐️', | ||||
| @@ -193,10 +205,8 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom | ||||
|   }; | ||||
|  | ||||
|   const handleZoomOut = () => { | ||||
|     // Solo mostramos la notificación si el paneo SÍ está habilitado actualmente | ||||
|     if (panEnabled && initialProvincePositionRef.current) { | ||||
|       const newZoom = position.zoom / 1.8; | ||||
|       // Si el nuevo zoom es igual o menor al umbral, desactivamos | ||||
|       if (newZoom <= initialProvincePositionRef.current.zoom) { | ||||
|         toast.error('Desplazamiento Deshabilitado', { | ||||
|           icon: '🔒', | ||||
| @@ -205,7 +215,6 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|     // La lógica para actualizar la posición no cambia | ||||
|     setPosition(prev => { | ||||
|       const newZoom = Math.max(prev.zoom / 1.8, 1); | ||||
|       const initialPos = initialProvincePositionRef.current; | ||||
| @@ -254,7 +263,7 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom | ||||
|       <div className="mapa-render-area"> | ||||
|         <ComposableMap | ||||
|           projection="geoMercator" | ||||
|           projectionConfig={isMobileView ? mobileProjectionConfig : desktopProjectionConfig} | ||||
|           projectionConfig={isMobileSmall ? mobileSmallProjectionConfig : (isMobileView ? mobileProjectionConfig : desktopProjectionConfig)} | ||||
|           style={{ width: "100%", height: "100%" }} | ||||
|         > | ||||
|           <ZoomableGroup | ||||
|   | ||||
| @@ -0,0 +1,69 @@ | ||||
| // src/features/legislativas/nacionales/components/MunicipioSearch.tsx | ||||
| import { useState } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select, { type SingleValue } from 'react-select'; | ||||
| import { getMunicipiosPorDistrito } from '../../../../apiService'; | ||||
| import type { CatalogoItem } from '../../../../types/types'; | ||||
|  | ||||
| interface MunicipioSearchProps { | ||||
|     distritoId: string; | ||||
|     onMunicipioSelect: (municipioId: string, municipioNombre: string) => void; | ||||
| } | ||||
|  | ||||
| interface OptionType { | ||||
|     value: string; | ||||
|     label: string; | ||||
| } | ||||
|  | ||||
| const customSelectStyles = { | ||||
|     control: (base: any) => ({ | ||||
|         ...base, | ||||
|         borderRadius: '8px', | ||||
|         borderColor: '#e0e0e0', | ||||
|         boxShadow: 'none', | ||||
|         '&:hover': { borderColor: '#007bff' } | ||||
|     }), | ||||
|     menu: (base: any) => ({ | ||||
|         ...base, | ||||
|         borderRadius: '8px', | ||||
|         zIndex: 30 | ||||
|     }) | ||||
| }; | ||||
|  | ||||
| export const MunicipioSearch = ({ distritoId, onMunicipioSelect }: MunicipioSearchProps) => { | ||||
|     const [selectedOption, setSelectedOption] = useState<SingleValue<OptionType>>(null); | ||||
|  | ||||
|     const { data: municipios = [], isLoading } = useQuery<CatalogoItem[]>({ | ||||
|         queryKey: ['municipiosPorDistrito', distritoId], | ||||
|         queryFn: () => getMunicipiosPorDistrito(distritoId), | ||||
|         enabled: !!distritoId, | ||||
|     }); | ||||
|  | ||||
|     const options: OptionType[] = municipios.map(m => ({ | ||||
|         value: m.id, | ||||
|         label: m.nombre | ||||
|     })); | ||||
|  | ||||
|     const handleChange = (selected: SingleValue<OptionType>) => { | ||||
|         if (selected) { | ||||
|             onMunicipioSelect(selected.value, selected.label); | ||||
|             setSelectedOption(null); // Resetea el selector para que muestre el placeholder de nuevo | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <div className="municipio-search-container"> | ||||
|             <Select | ||||
|                 options={options} | ||||
|                 onChange={handleChange} | ||||
|                 value={selectedOption} | ||||
|                 isLoading={isLoading} | ||||
|                 placeholder="Buscar municipio..." | ||||
|                 isClearable | ||||
|                 isSearchable | ||||
|                 styles={customSelectStyles} | ||||
|                 noOptionsMessage={() => 'No se encontraron municipios'} | ||||
|             /> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -12,7 +12,6 @@ const formatVotes = (num: number) => Math.round(num).toLocaleString('es-AR'); | ||||
| const SvgDefs = () => ( | ||||
|   <svg style={{ height: 0, width: 0, position: 'absolute' }}> | ||||
|     <defs> | ||||
|       {/* El gradiente ahora se define para que el color oscuro se mantenga en la segunda mitad del recorrido vertical */} | ||||
|       <linearGradient id="participationGradient" gradientTransform="rotate(90)"> | ||||
|         <stop offset="0%" stopColor="#e0f3ffff" /> | ||||
|         <stop offset="100%" stopColor="#007bff" /> | ||||
| @@ -40,13 +39,13 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP | ||||
|             value={estadoRecuento.participacionPorcentaje} | ||||
|             text={formatPercent(estadoRecuento.participacionPorcentaje)} | ||||
|             strokeWidth={12} | ||||
|             circleRatio={0.75} /* Se convierte en un arco de 270 grados */ | ||||
|             circleRatio={0.75} | ||||
|             styles={buildStyles({ | ||||
|               textColor: '#333', | ||||
|               pathColor: 'url(#participationGradient)', | ||||
|               trailColor: '#e9ecef', | ||||
|               textSize: '22px', | ||||
|               rotation: 0.625, /* Rota el inicio para que la apertura quede abajo */ | ||||
|               rotation: 0.625, | ||||
|             })} | ||||
|           /> | ||||
|           <span>Participación</span> | ||||
| @@ -56,13 +55,13 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP | ||||
|             value={estadoRecuento.mesasTotalizadasPorcentaje} | ||||
|             text={formatPercent(estadoRecuento.mesasTotalizadasPorcentaje)} | ||||
|             strokeWidth={12} | ||||
|             circleRatio={0.75} /* Se convierte en un arco de 270 grados */ | ||||
|             circleRatio={0.75} | ||||
|             styles={buildStyles({ | ||||
|               textColor: '#333', | ||||
|               pathColor: 'url(#scrutinizedGradient)', | ||||
|               trailColor: '#e9ecef', | ||||
|               textSize: '22px', | ||||
|               rotation: 0.625, /* Rota el inicio para que la apertura quede abajo */ | ||||
|               rotation: 0.625, | ||||
|             })} | ||||
|           /> | ||||
|           <span>Escrutado</span> | ||||
| @@ -76,7 +75,7 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP | ||||
|             className="partido-fila" | ||||
|             style={{ borderLeftColor: partido.color || '#ccc' }} | ||||
|           > | ||||
|             <div className="partido-logo"> | ||||
|             <div className="partido-logo" style={{ backgroundColor: partido.color || '#e9ecef' }}> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} /> | ||||
|             </div> | ||||
|             <div className="partido-main-content"> | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import { MiniMapaSvg } from './MiniMapaSvg'; | ||||
| import { ImageWithFallback } from '../../../../components/common/ImageWithFallback'; | ||||
| import { assetBaseUrl } from '../../../../apiService'; | ||||
|  | ||||
| // --- 1. AÑADIR LA PROP A AMBAS INTERFACES --- | ||||
| interface CategoriaDisplayProps { | ||||
|     categoria: CategoriaResumen; | ||||
|     mostrarBancas?: boolean; | ||||
| @@ -18,7 +17,6 @@ interface ProvinciaCardProps { | ||||
| const formatNumber = (num: number) => num.toLocaleString('es-AR'); | ||||
| const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`; | ||||
|  | ||||
| // --- 2. RECIBIR Y USAR LA PROP EN EL SUB-COMPONENTE --- | ||||
| const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) => { | ||||
|     return ( | ||||
|         <div className="categoria-bloque"> | ||||
| @@ -30,20 +28,26 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) = | ||||
|                     className="candidato-row" | ||||
|                     style={{ borderLeftColor: res.color || '#ccc' }} | ||||
|                 > | ||||
|                     <ImageWithFallback | ||||
|                         src={res.fotoUrl ?? undefined} | ||||
|                         fallbackSrc={`${assetBaseUrl}/default-avatar.png`} | ||||
|                         alt={res.nombreCandidato ?? res.nombreAgrupacion} | ||||
|                         className="candidato-foto" | ||||
|                     /> | ||||
|                     {/* --- INICIO DE LA MODIFICACIÓN --- */} | ||||
|                     <div className="candidato-foto-wrapper" style={{ backgroundColor: res.color || '#e9ecef' }}> | ||||
|                         <ImageWithFallback | ||||
|                             src={res.fotoUrl ?? undefined} | ||||
|                             fallbackSrc={`${assetBaseUrl}/default-avatar.png`} | ||||
|                             alt={res.nombreCandidato ?? res.nombreAgrupacion} | ||||
|                             className="candidato-foto" | ||||
|                         /> | ||||
|                     </div> | ||||
|                     {/* --- FIN DE LA MODIFICACIÓN --- */} | ||||
|  | ||||
|                     <div className="candidato-data"> | ||||
|                         {res.nombreCandidato && ( | ||||
|                             <span className="candidato-nombre">{res.nombreCandidato}</span> | ||||
|                         {res.nombreCandidato ? ( | ||||
|                             <> | ||||
|                                 <span className="candidato-nombre">{res.nombreCandidato}</span> | ||||
|                                 <span className="candidato-partido">{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span> | ||||
|                             </> | ||||
|                         ) : ( | ||||
|                             <span className="candidato-nombre">{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span> | ||||
|                         )} | ||||
|                         <span className={`candidato-partido ${!res.nombreCandidato ? 'main-title' : ''}`}> | ||||
|                             {res.nombreAgrupacion} | ||||
|                         </span> | ||||
|                         <div className="progress-bar-container"> | ||||
|                             <div className="progress-bar" style={{ width: `${res.porcentaje}%`, backgroundColor: res.color || '#ccc' }} /> | ||||
|                         </div> | ||||
| @@ -53,8 +57,6 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) = | ||||
|                         <span className="stats-votos">{formatNumber(res.votos)} votos</span> | ||||
|                     </div> | ||||
|  | ||||
|                     {/* --- 3. RENDERIZADO CONDICIONAL DEL CUADRO DE BANCAS --- */} | ||||
|                     {/* Este div solo se renderizará si mostrarBancas es true */} | ||||
|                     {mostrarBancas && ( | ||||
|                         <div className="stats-bancas"> | ||||
|                             +{res.bancasObtenidas} | ||||
| @@ -82,7 +84,6 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) = | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| // --- 4. RECIBIR Y PASAR LA PROP EN EL COMPONENTE PRINCIPAL --- | ||||
| export const ProvinciaCard = ({ data, mostrarBancas }: ProvinciaCardProps) => { | ||||
|     const colorGanador = data.categorias[0]?.resultados[0]?.color || '#d1d1d1'; | ||||
|  | ||||
| @@ -101,7 +102,7 @@ export const ProvinciaCard = ({ data, mostrarBancas }: ProvinciaCardProps) => { | ||||
|                     <CategoriaDisplay | ||||
|                         key={categoria.categoriaId} | ||||
|                         categoria={categoria} | ||||
|                         mostrarBancas={mostrarBancas} // Pasar la prop hacia abajo | ||||
|                         mostrarBancas={mostrarBancas} | ||||
|                     /> | ||||
|                 ))} | ||||
|             </div> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user