Feat Widgets 2030
This commit is contained in:
		| @@ -30,61 +30,59 @@ 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' }; | ||||
| // --- CORRECCIÓN 1: Mover NOMBRES_SECCIONES aquí para que sea global al archivo --- | ||||
| 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 MIN_ZOOM = 1; | ||||
| const MAX_ZOOM = 5; | ||||
| const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -600], [1100, 300]]; | ||||
| const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -1000], [1100, 800]]; | ||||
| 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 }) => { | ||||
|   const seccionId = seccion ? ROMAN_TO_SECCION_ID[seccion.properties.seccion] : null; | ||||
|     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, | ||||
|   }); | ||||
|     const { data: resultadosDetalle, isLoading, error } = useQuery<ResultadoDetalleSeccion[]>({ | ||||
|         queryKey: ['detalleSeccion', seccionId, categoriaId], | ||||
|         queryFn: () => getDetalleSeccion(seccionId!, categoriaId), | ||||
|         enabled: !!seccionId, | ||||
|     }); | ||||
|  | ||||
|     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>; | ||||
|  | ||||
|     const nombreSeccionLegible = NOMBRES_SECCIONES[seccion.properties.seccion] || "Sección Desconocida"; | ||||
|  | ||||
|   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> | ||||
|         <div className="detalle-content"> | ||||
|         <button className="reset-button-panel" onClick={onReset}>← VOLVER</button> | ||||
|         <h3>{nombreSeccionLegible}</h3> | ||||
|         <ul className="resultados-lista"> | ||||
|             {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}%`, backgroundColor: r.color || DEFAULT_MAP_COLOR }}></div> | ||||
|                 </div> | ||||
|             </li> | ||||
|             ))} | ||||
|         </ul> | ||||
|         </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>; | ||||
|  | ||||
|   const nombreSeccionLegible = NOMBRES_SECCIONES[seccion.properties.seccion] || "Sección Desconocida"; | ||||
|  | ||||
|   return ( | ||||
|     <div className="detalle-content"> | ||||
|       <button className="reset-button-panel" onClick={onReset}>← VOLVER</button> | ||||
|       <h3>{nombreSeccionLegible}</h3> | ||||
|       <ul className="resultados-lista"> | ||||
|         {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"> | ||||
|               {/* --- CORRECCIÓN 2: Usar el color de la API --- */} | ||||
|               <div className="progress-fill" style={{ width: `${r.porcentaje}%`, backgroundColor: r.color || DEFAULT_MAP_COLOR }}></div> | ||||
|             </div> | ||||
|           </li> | ||||
|         ))} | ||||
|       </ul> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| // --- Componente de Controles del Mapa --- | ||||
| @@ -100,86 +98,89 @@ const MapaBsAsSecciones = () => { | ||||
|   const [selectedCategoriaId, setSelectedCategoriaId] = useState<number>(6); | ||||
|   const [clickedSeccion, setClickedSeccion] = useState<SeccionGeography | null>(null); | ||||
|   const [tooltipContent, setTooltipContent] = useState(''); | ||||
|   const [isPanning, setIsPanning] = useState(false); | ||||
|  | ||||
|   const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({ | ||||
|     queryKey: ['mapaGeoDataSecciones'], | ||||
|     queryFn: async () => (await axios.get('./secciones-electorales-pba.topojson')).data, | ||||
|   }); | ||||
|     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: 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 { 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>(); | ||||
|     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]); | ||||
|         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 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) { | ||||
|     const handleGeographyClick = useCallback((geo: SeccionGeography) => { | ||||
|         if (clickedSeccion?.rsmKey === geo.rsmKey) { | ||||
|         handleReset(); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|     if (newPosition.zoom < position.zoom && clickedSeccion !== null) { | ||||
|       setClickedSeccion(null); | ||||
|     } | ||||
|     setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom }); | ||||
|   }; | ||||
|         } else { | ||||
|         const centroid = geoCentroid(geo as any) as PointTuple; | ||||
|         setPosition({ center: centroid, zoom: 2 }); | ||||
|         setClickedSeccion(geo); | ||||
|         } | ||||
|     }, [clickedSeccion, handleReset]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset(); | ||||
|     window.addEventListener('keydown', handleKeyDown); | ||||
|     return () => window.removeEventListener('keydown', handleKeyDown); | ||||
|   }, [handleReset]); | ||||
|     const handleMoveEnd = (newPosition: { coordinates: PointTuple; zoom: number }) => { | ||||
|         if (newPosition.zoom <= MIN_ZOOM) { | ||||
|         if (position.zoom > MIN_ZOOM || clickedSeccion !== null) { | ||||
|             handleReset(); | ||||
|         } | ||||
|         return; | ||||
|         } | ||||
|         if (newPosition.zoom < position.zoom && clickedSeccion !== null) { | ||||
|         setClickedSeccion(null); | ||||
|         } | ||||
|         setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom }); | ||||
|     }; | ||||
|  | ||||
|   const getSectionFillColor = (seccionRomana: string) => { | ||||
|     return resultadosPorSeccionRomana.get(seccionRomana)?.colorGanador || DEFAULT_MAP_COLOR; | ||||
|   }; | ||||
|     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) })); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|   const handleZoomIn = () => { | ||||
|     if (position.zoom < MAX_ZOOM) { | ||||
|       setPosition(p => ({ ...p, zoom: Math.min(p.zoom * 1.5, MAX_ZOOM) })); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className="mapa-wrapper"> | ||||
| @@ -195,11 +196,15 @@ const MapaBsAsSecciones = () => { | ||||
|             <ZoomableGroup | ||||
|               center={position.center} | ||||
|               zoom={position.zoom} | ||||
|               onMoveEnd={handleMoveEnd} | ||||
|               onMoveEnd={(newPosition: { coordinates: PointTuple; zoom: number }) => { | ||||
|                 setIsPanning(false); | ||||
|                 handleMoveEnd(newPosition); | ||||
|               }} | ||||
|               minZoom={MIN_ZOOM} | ||||
|               maxZoom={MAX_ZOOM} | ||||
|               translateExtent={TRANSLATE_EXTENT} | ||||
|               style={{ transition: "transform 400ms ease-in-out" }} | ||||
|               className={isPanning ? 'panning' : ''} | ||||
|               onMoveStart={() => setIsPanning(true)} | ||||
|               filterZoomEvent={(e: WheelEvent) => { | ||||
|                 if (e.deltaY > 0) { | ||||
|                   handleReset(); | ||||
| @@ -228,7 +233,6 @@ const MapaBsAsSecciones = () => { | ||||
|                           onClick={isClickable ? () => handleGeographyClick(geo) : undefined} | ||||
|                           onMouseEnter={() => { | ||||
|                             if (isClickable) { | ||||
|                               // --- CORRECCIÓN 3: Tooltip con nombre de sección --- | ||||
|                               const nombreSeccionLegible = NOMBRES_SECCIONES[geo.properties.seccion] || "Sección Desconocida"; | ||||
|                               setTooltipContent(`${nombreSeccionLegible}: ${nombreGanador}`); | ||||
|                             } | ||||
| @@ -272,32 +276,32 @@ const MapaBsAsSecciones = () => { | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| // --- Sub-componente para la Leyenda --- | ||||
| // --- Sub-componente para la Leyenda (sin cambios) --- | ||||
| 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 | ||||
|     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 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> | ||||
|     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> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default MapaBsAsSecciones; | ||||
		Reference in New Issue
	
	Block a user