Feat Widgets Controles y Estilos

This commit is contained in:
2025-10-03 13:26:20 -03:00
parent 1719e79723
commit 64d45a7a39
17 changed files with 544 additions and 278 deletions

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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">

View File

@@ -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>