Feat Widgets 0209

This commit is contained in:
2025-09-02 09:48:46 -03:00
parent 12860f2406
commit 271a86b632
15 changed files with 671 additions and 166 deletions

View File

@@ -6,6 +6,7 @@ import MapaBsAs from './components/MapaBsAs'
import { TickerWidget } from './components/TickerWidget'
import { TelegramaWidget } from './components/TelegramaWidget'
import { ConcejalesWidget } from './components/ConcejalesWidget'
import MapaBsAsSecciones from './components/MapaBsAsSecciones'
function App() {
return (
@@ -17,6 +18,7 @@ function App() {
<CongresoWidget />
<BancasWidget />
<MapaBsAs />
<MapaBsAsSecciones />
<TelegramaWidget />
</main>
</>

View File

@@ -60,6 +60,13 @@ export interface ConfiguracionPublica {
// ... otras claves públicas que pueda añadir en el futuro
}
export interface ResultadoDetalleSeccion {
id: string; // ID de la agrupación para la key
nombre: string;
votos: number;
porcentaje: number;
}
export const getResumenProvincial = async (): Promise<CategoriaResumen[]> => {
const response = await apiClient.get('/resultados/provincia/02');
return response.data;
@@ -129,4 +136,9 @@ export const getConfiguracionPublica = async (): Promise<ConfiguracionPublica> =
export const getResultadosConcejales = async (seccionId: string): Promise<ResultadoTicker[]> => {
const response = await apiClient.get(`/resultados/concejales/${seccionId}`);
return response.data;
};
export const getDetalleSeccion = async (seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => {
const response = await apiClient.get(`/resultados/seccion/${seccionId}?categoriaId=${categoriaId}`);
return response.data;
};

View File

@@ -1,5 +1,5 @@
// src/components/ConcejalesWidget.tsx
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getSeccionesElectorales, getResultadosConcejales, getConfiguracionPublica } from '../apiService';
import type { MunicipioSimple, ResultadoTicker } from '../types/types';
@@ -20,7 +20,9 @@ export const ConcejalesWidget = () => {
});
// Calculamos la cantidad a mostrar desde la configuración
const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10) + 1;
const cantidadAMostrar = useMemo(() => {
return parseInt(configData?.TickerResultadosCantidad || '5', 10) + 1;
}, [configData]);
useEffect(() => {
getSeccionesElectorales().then(seccionesData => {
@@ -53,7 +55,7 @@ export const ConcejalesWidget = () => {
if (resultados && resultados.length > cantidadAMostrar) {
const topParties = resultados.slice(0, cantidadAMostrar - 1);
const otherParties = resultados.slice(cantidadAMostrar - 1);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.votosPorcentaje || 0), 0);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.votosPorcentaje, 0);
const otrosEntry: ResultadoTicker = {
id: `otros-concejales-${seccionActualId}`,
@@ -73,7 +75,7 @@ export const ConcejalesWidget = () => {
return (
<div className="ticker-card" style={{ gridColumn: '1 / -1' }}>
<div className="ticker-header">
<h3>CONCEJALES - LA PLATA</h3>
<h3>CONCEJALES POR SECCIÓN ELECTORAL</h3>
<select value={seccionActualId} onChange={e => setSeccionActualId(e.target.value)} disabled={secciones.length === 0}>
{secciones.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}
</select>

View File

@@ -76,7 +76,7 @@
min-height: 0;
background-color: var(--background-panel-color);
border-radius: 8px;
padding: 1.5rem;
padding: 1rem;
border: none;
}
@@ -194,4 +194,52 @@
/* Un tamaño de fuente legible en móviles. */
font-size: 1em;
}
}
/* --- ESTILOS PARA EL SELECTOR DE CATEGORÍA --- */
.mapa-categoria-selector {
display: flex;
margin-bottom: 1.5rem;
}
.mapa-categoria-combobox {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1em;
font-weight: 500;
color: var(--text-color);
background-color: #f8f9fa;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
appearance: none;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%230073e6%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.4-12.9z%22%2F%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 0.8em;
transition: all 0.2s ease-in-out;
}
.mapa-categoria-combobox:hover {
border-color: var(--primary-accent-color);
background-color: #e9ecef;
}
.mapa-categoria-combobox:focus {
outline: none;
border-color: var(--primary-accent-color);
box-shadow: 0 0 0 2px rgba(0, 115, 230, 0.25);
}
/* --- ESTILOS PARA SECCIONES NO CLICLEABLES --- */
.rsm-geography.no-results {
pointer-events: none; /* Ignora todos los eventos del ratón (click, hover, etc.) */
cursor: default; /* Muestra el cursor por defecto en lugar de la mano */
}
/* Opcional pero recomendado: modificar la regla :hover para que no afecte a las secciones no clicleables */
.rsm-geography:not(.no-results):hover {
stroke: var(--primary-accent-color);
stroke-width: 1.5px;
filter: brightness(1.05);
}

View File

@@ -1,6 +1,5 @@
// src/components/MapaBsAs.tsx
import { useState, useMemo, useCallback, useEffect } from 'react';
import type { MouseEvent } from 'react';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
import { Tooltip } from 'react-tooltip';
import { useQuery } from '@tanstack/react-query';
@@ -24,7 +23,7 @@ interface ResultadoDetalladoMunicipio {
ultimaActualizacion: string;
porcentajeEscrutado: number;
porcentajeParticipacion: number;
resultados: { nombre: string; votos: number; porcentaje: number }[];
resultados: { id: string; nombre: string; votos: number; porcentaje: number }[];
votosAdicionales: { enBlanco: number; nulos: number; recurridos: number };
}
@@ -33,11 +32,13 @@ interface Agrupacion {
nombre: string;
}
interface Categoria {
id: number;
nombre: string;
}
interface PartidoProperties {
id: string;
departamento: string;
cabecera: string;
provincia: string;
}
type PartidoGeography = Feature<Geometry, PartidoProperties> & { rsmKey: string };
@@ -50,52 +51,57 @@ const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -600], [1
const INITIAL_POSITION = { center: [-60.5, -37.2] as PointTuple, zoom: MIN_ZOOM };
const DEFAULT_MAP_COLOR = '#E0E0E0';
const CATEGORIAS: Categoria[] = [
{ id: 5, nombre: 'Senadores' },
{ id: 6, nombre: 'Diputados' },
{ id: 7, nombre: 'Concejales' }
];
// --- Componente Principal ---
const MapaBsAs = () => {
const [position, setPosition] = useState(INITIAL_POSITION);
const [selectedAmbitoId, setSelectedAmbitoId] = useState<number | null>(null);
const [selectedCategoriaId, setSelectedCategoriaId] = useState<number>(6);
const [tooltipContent, setTooltipContent] = useState('');
const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapa[]>({
queryKey: ['mapaResultados'],
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa`)).data,
queryKey: ['mapaResultadosPorMunicipio', selectedCategoriaId],
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa-por-municipio?categoriaId=${selectedCategoriaId}`)).data,
});
const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({
queryKey: ['mapaGeoData'],
queryFn: async () => (await axios.get('/partidos-bsas.topojson')).data,
});
const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({
queryKey: ['catalogoAgrupaciones'],
queryFn: async () => (await axios.get(`${API_BASE_URL}/Catalogos/agrupaciones`)).data,
});
// --- SU SOLUCIÓN CORRECTA INTEGRADA ---
const { nombresAgrupaciones, resultadosPorDepartamento } = useMemo<{
nombresAgrupaciones: Map<string, string>;
resultadosPorDepartamento: Map<string, ResultadoMapa>;
}>(() => {
const nombresMap = new Map<string, string>();
const resultadosMap = new Map<string, ResultadoMapa>();
if (agrupacionesData) {
agrupacionesData.forEach((agrupacion) => {
nombresMap.set(agrupacion.id, agrupacion.nombre);
});
}
if (resultadosData) {
resultadosData.forEach(r => resultadosMap.set(r.departamentoNombre.toUpperCase(), r));
resultadosData.forEach(r => {
if (r.departamentoNombre) {
resultadosMap.set(r.departamentoNombre.toUpperCase(), r)
}
});
}
return {
nombresAgrupaciones: nombresMap,
resultadosPorDepartamento: resultadosMap
};
return { nombresAgrupaciones: nombresMap, resultadosPorDepartamento: resultadosMap };
}, [agrupacionesData, resultadosData]);
const isLoading = isLoadingResultados || isLoadingAgrupaciones || isLoadingGeo;
// ... (el resto del componente no necesita cambios)
const handleReset = useCallback(() => {
setSelectedAmbitoId(null);
setPosition(INITIAL_POSITION);
@@ -142,75 +148,104 @@ const MapaBsAs = () => {
const getPartyFillColor = (departamentoNombre: string) => {
const resultado = resultadosPorDepartamento.get(departamentoNombre.toUpperCase());
if (!resultado || !resultado.colorGanador) {
return DEFAULT_MAP_COLOR;
}
return resultado.colorGanador;
return resultado?.colorGanador || DEFAULT_MAP_COLOR;
};
const handleMouseEnter = (e: MouseEvent<SVGPathElement>) => {
const path = e.target as SVGPathElement;
if (path.parentNode) {
path.parentNode.appendChild(path);
}
};
// --- Helper de Renderizado ---
const renderGeography = (geo: PartidoGeography, isSelectedGeo: boolean = false) => {
const departamentoNombre = geo.properties.departamento.toUpperCase();
const resultado = resultadosPorDepartamento.get(departamentoNombre);
const isClickable = !!resultado;
const isSelected = isSelectedGeo || (selectedAmbitoId !== null && selectedAmbitoId === resultado?.ambitoId);
const isFaded = !isSelectedGeo && selectedAmbitoId !== null && !isSelected;
const nombreAgrupacionGanadora = resultado ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) : 'Sin datos';
if (isLoading) return <div className="loading-container">Cargando datos del mapa...</div>;
return (
<Geography
key={geo.rsmKey + (isSelectedGeo ? '-selected' : '')}
geography={geo}
data-tooltip-id="partido-tooltip"
data-tooltip-content={`${geo.properties.departamento}: ${nombreAgrupacionGanadora}`}
className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''} ${!isClickable ? 'no-results' : ''}`}
fill={getPartyFillColor(geo.properties.departamento)}
onClick={isClickable ? () => handleGeographyClick(geo) : undefined}
onMouseEnter={() => setTooltipContent(`${geo.properties.departamento}: ${nombreAgrupacionGanadora}`)}
onMouseLeave={() => setTooltipContent("")}
/>
);
};
return (
<div className="mapa-wrapper">
<div className="mapa-container">
<ComposableMap projection="geoMercator" projectionConfig={{ scale: 4400, center: [-60.5, -37.2] }} className="rsm-svg">
<ZoomableGroup
center={position.center}
zoom={position.zoom}
onMoveEnd={handleMoveEnd}
style={{ transition: "transform 400ms ease-in-out" }}
translateExtent={TRANSLATE_EXTENT}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
filterZoomEvent={(e: WheelEvent) => {
if (e.deltaY > 0) {
handleReset();
} else if (e.deltaY < 0) {
handleZoomIn();
}
return true;
}}
{isLoading ? <div className="spinner"></div> : (
<ComposableMap
key={selectedCategoriaId}
projection="geoMercator"
projectionConfig={{ scale: 4400, center: [-60.5, -37.2] }}
className="rsm-svg"
data-tooltip-id="partido-tooltip"
>
{geoData && (
<Geographies geography={geoData}>
{({ geographies }: { geographies: PartidoGeography[] }) =>
geographies.map((geo) => {
const departamentoNombre = geo.properties.departamento.toUpperCase();
const resultado = resultadosPorDepartamento.get(departamentoNombre);
const isSelected = resultado ? selectedAmbitoId === resultado.ambitoId : false;
const isFaded = selectedAmbitoId !== null && !isSelected;
const nombreAgrupacionGanadora = resultado ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) : 'Sin datos';
<ZoomableGroup
center={position.center}
zoom={position.zoom}
onMoveEnd={handleMoveEnd}
style={{ transition: "transform 400ms ease-in-out" }}
translateExtent={TRANSLATE_EXTENT}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
filterZoomEvent={(e: WheelEvent) => {
if (e.deltaY > 0) {
handleReset();
} else if (e.deltaY < 0) {
handleZoomIn();
}
return true;
}}
>
{geoData && (
<Geographies geography={geoData}>
{({ geographies }: { geographies: PartidoGeography[] }) => {
const selectedGeo = selectedAmbitoId
? geographies.find(geo => {
const resultado = resultadosPorDepartamento.get(geo.properties.departamento.toUpperCase());
return resultado?.ambitoId === selectedAmbitoId;
})
: null;
return (
<Geography
key={geo.rsmKey}
geography={geo}
data-tooltip-id="partido-tooltip"
data-tooltip-content={`${geo.properties.departamento}: ${nombreAgrupacionGanadora}`}
className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''}`}
fill={getPartyFillColor(geo.properties.departamento)}
onClick={() => handleGeographyClick(geo)}
onMouseEnter={handleMouseEnter}
/>
<>
{geographies.map(geo => (!selectedGeo || geo.rsmKey !== selectedGeo.rsmKey) ? renderGeography(geo) : null)}
{selectedGeo && renderGeography(selectedGeo, true)}
</>
);
})
}
</Geographies>
)}
</ZoomableGroup>
</ComposableMap>
<Tooltip id="partido-tooltip" variant="light" />
}}
</Geographies>
)}
</ZoomableGroup>
</ComposableMap>
)}
<Tooltip id="partido-tooltip" content={tooltipContent} />
{selectedAmbitoId !== null && <ControlesMapa onReset={handleReset} />}
</div>
<div className="info-panel">
<DetalleMunicipio ambitoId={selectedAmbitoId} onReset={handleReset} />
<div className="mapa-categoria-selector">
<select
className="mapa-categoria-combobox"
value={selectedCategoriaId}
onChange={(e) => {
setSelectedCategoriaId(Number(e.target.value));
handleReset();
}}
>
{CATEGORIAS.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.nombre}
</option>
))}
</select>
</div>
<DetalleMunicipio ambitoId={selectedAmbitoId} onReset={handleReset} categoriaId={selectedCategoriaId} />
<Legend resultados={resultadosPorDepartamento} nombresAgrupaciones={nombresAgrupaciones} />
</div>
</div>
@@ -224,10 +259,10 @@ const ControlesMapa = ({ onReset }: { onReset: () => void }) => (
</div>
);
const DetalleMunicipio = ({ ambitoId, onReset }: { ambitoId: number | null; onReset: () => void }) => {
const DetalleMunicipio = ({ ambitoId, onReset, categoriaId }: { ambitoId: number | null; onReset: () => void; categoriaId: number; }) => {
const { data, isLoading, error } = useQuery<ResultadoDetalladoMunicipio>({
queryKey: ['municipioDetalle', ambitoId],
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/municipio/${ambitoId}`)).data,
queryKey: ['municipioDetalle', ambitoId, categoriaId],
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/municipio/${ambitoId}?categoriaId=${categoriaId}`)).data,
enabled: !!ambitoId,
});

View File

@@ -0,0 +1,308 @@
// src/components/MapaBsAsSecciones.tsx
import { useState, useMemo, useCallback, useEffect } from 'react';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
import { Tooltip } from 'react-tooltip';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { geoCentroid } from 'd3-geo';
import { getDetalleSeccion } from '../apiService';
import type { ResultadoDetalleSeccion } from '../apiService';
import './MapaBsAs.css';
// --- Interfaces y Tipos ---
type PointTuple = [number, number];
interface ResultadoMapaSeccion {
seccionId: string;
seccionNombre: string;
agrupacionGanadoraId: string | null;
colorGanador: string | null;
}
interface Agrupacion { id: string; nombre: string; }
interface Categoria { id: number; nombre: string; }
type SeccionGeography = {
rsmKey: string;
properties: { seccion: string; fna: string; };
};
// --- Constantes ---
const API_BASE_URL = 'http://localhost:5217/api';
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' };
const MIN_ZOOM = 1;
const MAX_ZOOM = 5;
const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -600], [1100, 300]];
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 }) => {
// Obtenemos el ID numérico de la sección a partir de su número romano para llamar a la API
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, // La query solo se ejecuta si hay una sección seleccionada
});
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 (!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>;
// --- LÓGICA DE NOMBRE DE SECCIÓN ---
// Mapeo de número romano a nombre legible. Se puede mejorar en el futuro.
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 nombreSeccionLegible = NOMBRES_SECCIONES[seccion.properties.seccion] || "Sección Desconocida";
return (
<div className="detalle-content">
<button className="reset-button-panel" onClick={onReset}> VOLVER</button>
{/* Mostramos el nombre legible de la sección */}
<h3>{nombreSeccionLegible}</h3>
<ul className="resultados-lista">
{/* Mapeamos los resultados obtenidos de la API */}
{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}%` }}></div>
</div>
</li>
))}
</ul>
</div>
);
};
// --- Componente de Controles del Mapa ---
const ControlesMapa = ({ onReset }: { onReset: () => void }) => (
<div className="map-controls">
<button onClick={onReset}> VOLVER</button>
</div>
);
// --- Componente Principal ---
const MapaBsAsSecciones = () => {
const [position, setPosition] = useState(INITIAL_POSITION);
const [selectedCategoriaId, setSelectedCategoriaId] = useState<number>(6);
const [clickedSeccion, setClickedSeccion] = useState<SeccionGeography | null>(null);
const [tooltipContent, setTooltipContent] = useState('');
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: 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>();
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 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) {
handleReset();
}
return;
}
// Si el usuario hace zoom out, deseleccionamos la sección para volver a la vista general
if (newPosition.zoom < position.zoom && clickedSeccion !== null) {
setClickedSeccion(null);
}
setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom });
};
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) }));
}
};
return (
<div className="mapa-wrapper">
<div className="mapa-container">
{isLoading ? <div className="spinner"></div> : (
<ComposableMap
key={selectedCategoriaId}
projection="geoMercator"
projectionConfig={{ scale: 4400, center: [-60.5, -37.2] }}
className="rsm-svg"
data-tooltip-id="seccion-tooltip"
>
<ZoomableGroup
center={position.center}
zoom={position.zoom}
onMoveEnd={handleMoveEnd}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
translateExtent={TRANSLATE_EXTENT}
style={{ transition: "transform 400ms ease-in-out" }}
filterZoomEvent={(e: WheelEvent) => {
if (e.deltaY > 0) {
handleReset();
} else if (e.deltaY < 0) {
handleZoomIn();
}
return true;
}}
>
{geoData && (
<Geographies geography={geoData}>
{({ geographies }: { geographies: SeccionGeography[] }) =>
geographies.map((geo) => {
const seccionRomana = geo.properties.seccion;
const resultado = resultadosPorSeccionRomana.get(seccionRomana);
const nombreGanador = resultado?.agrupacionGanadoraId ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) : 'Sin datos';
const isSelected = clickedSeccion?.rsmKey === geo.rsmKey;
const isFaded = clickedSeccion && !isSelected;
const isClickable = !!resultado;
return (
<Geography
key={geo.rsmKey + (isSelected ? '-selected' : '')}
geography={geo as any}
data-tooltip-id="seccion-tooltip"
data-tooltip-content={`${geo.properties.fna}: ${nombreGanador}`}
onClick={isClickable ? () => handleGeographyClick(geo) : undefined}
onMouseEnter={() => {
if (isClickable) {
setTooltipContent(`${geo.properties.fna}: ${nombreGanador}`);
}
}}
onMouseLeave={() => setTooltipContent("")}
className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''} ${!isClickable ? 'no-results' : ''}`}
fill={getSectionFillColor(seccionRomana)}
/>
);
})
}
</Geographies>
)}
</ZoomableGroup>
</ComposableMap>
)}
{/* El botón de volver ahora está aquí, en el componente principal */}
{clickedSeccion && <ControlesMapa onReset={handleReset} />}
<Tooltip id="seccion-tooltip" content={tooltipContent} />
</div>
<div className="info-panel">
<div className="mapa-categoria-selector">
<select
className="mapa-categoria-combobox"
value={selectedCategoriaId}
onChange={(e) => {
setSelectedCategoriaId(Number(e.target.value));
handleReset();
}}
>
{CATEGORIAS.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.nombre}
</option>
))}
</select>
</div>
<DetalleSeccion seccion={clickedSeccion} categoriaId={selectedCategoriaId} onReset={handleReset} />
<LegendSecciones resultados={resultadosPorSeccionRomana} nombresAgrupaciones={nombresAgrupaciones} />
</div>
</div>
);
};
// --- Sub-componente para la Leyenda ---
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
});
}
});
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>
</div>
))}
</div>
);
};
export default MapaBsAsSecciones;

View File

@@ -4,6 +4,7 @@ import { getResumenProvincial, getConfiguracionPublica } from '../apiService';
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
import { ImageWithFallback } from './ImageWithFallback';
import './TickerWidget.css';
import { useMemo } from 'react';
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
@@ -20,7 +21,9 @@ export const TickerWidget = () => {
staleTime: 0,
});
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10) + 1;
const cantidadAMostrar = useMemo(() => {
return parseInt(configData?.TickerResultadosCantidad || '5', 10) + 1;
}, [configData]);
if (isLoading) return <div className="ticker-wrapper loading">Cargando resumen...</div>;
if (error || !categorias) return <div className="ticker-wrapper error">No hay datos disponibles.</div>;