Preparación Legislativas Nacionales 2025

This commit is contained in:
2025-09-17 11:31:17 -03:00
parent 64dc7ef440
commit 3a8f64bf85
94 changed files with 2471 additions and 195 deletions

View File

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

View File

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

View File

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

View File

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

View File

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