Esta refactorización modifica la forma en que los widgets manejan sus estilos para prevenir conflictos con los CSS de los sitios anfitriones donde se incrustan. Se ha migrado el sistema de estilos de CSS global a CSS Modules para todos los componentes principales y sus hijos, asegurando que todas las clases sean únicas y estén aisladas. Cambios principales: - Se actualizan los componentes .tsx para importar y usar los módulos de estilos (`import styles from ...`). - Se renombran los archivos `.css` a `.module.css`. - Se añade una regla en cada módulo para proteger la `font-family` y el `box-sizing` del widget, evitando que sean sobreescritos por estilos externos. - Se ajustan los selectores para librerías de terceros (react-select, react-simple-maps) usando `:global()` para mantener la compatibilidad. - Se mueven las variables CSS de `:root` a las clases principales de cada widget para evitar colisiones en el scope global. Como resultado, los widgets (`HomeCarouselWidget`, `PanelNacionalWidget`, `ResultadosNacionalesCardsWidget`, `CongresoNacionalWidget`) son ahora más robustos, portátiles y visualmente consistentes en cualquier entorno.
338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
// 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';
|
|
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
|
import { Tooltip } from 'react-tooltip';
|
|
import { geoCentroid } from 'd3-geo';
|
|
import { feature } from 'topojson-client';
|
|
import { API_BASE_URL, assetBaseUrl } from '../../../../apiService';
|
|
import type { ResultadoMapaDto, AmbitoGeography } from '../../../../types/types';
|
|
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';
|
|
// 1. Importamos el archivo de estilos como un módulo CSS
|
|
import styles from '../PanelNacional.module.css';
|
|
|
|
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 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;
|
|
const MIN_LUPA_SIZE_PX = 100;
|
|
const MAX_LUPA_SIZE_PX = 180;
|
|
|
|
interface MapaNacionalProps {
|
|
eleccionId: number;
|
|
categoriaId: number;
|
|
nivel: 'pais' | 'provincia' | 'municipio';
|
|
nombreAmbito: string;
|
|
nombreProvinciaActiva: string | undefined | null;
|
|
provinciaDistritoId: string | null;
|
|
onAmbitoSelect: (ambitoId: string, nivel: 'provincia' | 'municipio', nombre: string) => void;
|
|
onVolver: () => void;
|
|
isMobileView: boolean;
|
|
}
|
|
|
|
const desktopProjectionConfig = { scale: 700, center: [-65, -40] as [number, number] };
|
|
const mobileProjectionConfig = { scale: 1000, center: [-64, -43] as [number, number] };
|
|
const mobileSmallProjectionConfig = { scale: 750, center: [-64, -45] 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,
|
|
center: isMobileView ? mobileProjectionConfig.center : desktopProjectionConfig.center as PointTuple
|
|
});
|
|
const [isPanning, setIsPanning] = useState(false);
|
|
const initialProvincePositionRef = useRef<ViewConfig | null>(null);
|
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const lupaRef = useRef<HTMLDivElement | null>(null);
|
|
const cabaPathRef = useRef<SVGPathElement | null>(null);
|
|
const isAnimatingRef = useRef(false);
|
|
const initialLoadRef = useRef(true);
|
|
|
|
const [lupaStyle, setLupaStyle] = useState<React.CSSProperties>({ opacity: 0 });
|
|
|
|
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),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (nivel === 'pais') {
|
|
const currentMobileConfig = isMobileSmall ? mobileSmallProjectionConfig : mobileProjectionConfig;
|
|
const currentMobileZoom = isMobileSmall ? 1.4 : 1.5;
|
|
|
|
setPosition({
|
|
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: ViewConfig | undefined;
|
|
|
|
if (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: isMobileView ? 8 : 7, center: centroid as PointTuple };
|
|
}
|
|
}
|
|
|
|
if (provinceConfig) {
|
|
setPosition(provinceConfig);
|
|
initialProvincePositionRef.current = provinceConfig;
|
|
}
|
|
}
|
|
}, [nivel, nombreAmbito, geoDataNacional, isMobileView, isMobileSmall]);
|
|
|
|
const resultadosNacionalesPorNombre = new Map<string, ResultadoMapaDto>(mapaDataNacional.map(d => [normalizarTexto(d.ambitoNombre), d]));
|
|
const nombreMunicipioSeleccionado = nivel === 'municipio' ? nombreAmbito : null;
|
|
const handleCalculatedCenter = useCallback((center: PointTuple, zoom: number) => { setPosition({ center, zoom }); }, []);
|
|
|
|
useEffect(() => {
|
|
const updateLupaPosition = () => {
|
|
if (nivel === 'pais' && cabaPathRef.current && containerRef.current) {
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
if (containerRect.width === 0) return;
|
|
|
|
const cabaRect = cabaPathRef.current.getBoundingClientRect();
|
|
const cabaCenterX = (cabaRect.left - containerRect.left) + cabaRect.width / 2;
|
|
const cabaCenterY = (cabaRect.top - containerRect.top) + cabaRect.height / 2;
|
|
|
|
const calculatedSize = containerRect.width * LUPA_SIZE_RATIO;
|
|
const newLupaSize = Math.max(MIN_LUPA_SIZE_PX, Math.min(calculatedSize, MAX_LUPA_SIZE_PX));
|
|
|
|
const horizontalOffset = newLupaSize * 0.5;
|
|
const verticalOffset = newLupaSize * 0.2;
|
|
|
|
setLupaStyle({
|
|
position: 'absolute',
|
|
top: `${cabaCenterY - verticalOffset}px`,
|
|
left: `${cabaCenterX + horizontalOffset}px`,
|
|
width: `${newLupaSize}px`,
|
|
opacity: 1,
|
|
});
|
|
} else {
|
|
setLupaStyle({ opacity: 0, pointerEvents: 'none' });
|
|
}
|
|
};
|
|
|
|
isAnimatingRef.current = true;
|
|
const handleResize = () => { if (!isAnimatingRef.current) updateLupaPosition(); };
|
|
const resizeObserver = new ResizeObserver(handleResize);
|
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
|
|
|
let timerId: NodeJS.Timeout;
|
|
if (initialLoadRef.current && nivel === 'pais') {
|
|
timerId = setTimeout(() => {
|
|
updateLupaPosition();
|
|
isAnimatingRef.current = false;
|
|
}, 0);
|
|
initialLoadRef.current = false;
|
|
} else {
|
|
timerId = setTimeout(() => {
|
|
updateLupaPosition();
|
|
isAnimatingRef.current = false;
|
|
}, 800);
|
|
}
|
|
|
|
return () => {
|
|
if (containerRef.current) resizeObserver.unobserve(containerRef.current);
|
|
clearTimeout(timerId);
|
|
isAnimatingRef.current = false;
|
|
};
|
|
}, [position, nivel]);
|
|
|
|
const panEnabled =
|
|
nivel === 'provincia' &&
|
|
initialProvincePositionRef.current !== null &&
|
|
position.zoom > initialProvincePositionRef.current.zoom &&
|
|
!nombreMunicipioSeleccionado;
|
|
|
|
const handleZoomIn = () => {
|
|
if (!panEnabled && initialProvincePositionRef.current) {
|
|
const newZoom = position.zoom * 1.8;
|
|
if (newZoom > initialProvincePositionRef.current.zoom) {
|
|
toast.success('Desplazamiento Habilitado', {
|
|
icon: '🖐️',
|
|
style: { background: '#32e5f1ff', color: 'white' },
|
|
duration: 1000,
|
|
});
|
|
}
|
|
}
|
|
setPosition(prev => ({ ...prev, zoom: Math.min(prev.zoom * 1.8, 100) }));
|
|
};
|
|
|
|
const handleZoomOut = () => {
|
|
if (panEnabled && initialProvincePositionRef.current) {
|
|
const newZoom = position.zoom / 1.8;
|
|
if (newZoom <= initialProvincePositionRef.current.zoom) {
|
|
toast.error('Desplazamiento Deshabilitado', {
|
|
icon: '🔒',
|
|
style: { background: '#32e5f1ff', color: 'white' },
|
|
duration: 1000,
|
|
});
|
|
}
|
|
}
|
|
setPosition(prev => {
|
|
const newZoom = Math.max(prev.zoom / 1.8, 1);
|
|
const initialPos = initialProvincePositionRef.current;
|
|
if (initialPos && newZoom <= initialPos.zoom) return initialPos;
|
|
return { ...prev, zoom: newZoom };
|
|
});
|
|
};
|
|
|
|
const handleMoveEnd = (newPosition: { coordinates: PointTuple, zoom: number }) => {
|
|
setPosition(prev => ({ ...prev, center: newPosition.coordinates }));
|
|
setIsPanning(false);
|
|
};
|
|
|
|
const filterInteractionEvents = (event: any) => {
|
|
if (event.sourceEvent && event.sourceEvent.type === 'wheel') return false;
|
|
return panEnabled;
|
|
};
|
|
|
|
const showZoomControls = nivel === 'provincia';
|
|
const isZoomOutDisabled =
|
|
(nivel === 'provincia' && initialProvincePositionRef.current && position.zoom <= initialProvincePositionRef.current.zoom) ||
|
|
(nivel === 'pais' && position.zoom <= (isMobileView ? 1.4 : 1.05));
|
|
|
|
// 2. Todas las props 'className' ahora usan el objeto 'styles'
|
|
const mapContainerClasses = `${styles.mapaComponenteContainer} ${panEnabled ? styles.mapPannable : styles.mapLocked}`;
|
|
|
|
return (
|
|
<div className={mapContainerClasses} ref={containerRef}>
|
|
{showZoomControls && (
|
|
<div className={styles.zoomControlsContainer}>
|
|
<button onClick={handleZoomIn} className={styles.zoomBtn} title="Acercar">
|
|
<span className={styles.zoomIconWrapper}><BiZoomIn /></span>
|
|
</button>
|
|
<button
|
|
onClick={handleZoomOut}
|
|
className={`${styles.zoomBtn} ${isZoomOutDisabled ? styles.disabled : ''}`}
|
|
title="Alejar"
|
|
disabled={isZoomOutDisabled}
|
|
>
|
|
<span className={styles.zoomIconWrapper}><BiZoomOut /></span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{nivel !== 'pais' && <button onClick={onVolver} className={styles.mapaVolverBtn}>← Volver</button>}
|
|
|
|
<div className={styles.mapaRenderArea}>
|
|
<ComposableMap
|
|
projection="geoMercator"
|
|
projectionConfig={isMobileSmall ? mobileSmallProjectionConfig : (isMobileView ? mobileProjectionConfig : desktopProjectionConfig)}
|
|
style={{ width: "100%", height: "100%" }}
|
|
>
|
|
<ZoomableGroup
|
|
center={position.center}
|
|
zoom={position.zoom}
|
|
onMoveStart={() => setIsPanning(true)}
|
|
onMoveEnd={handleMoveEnd}
|
|
filterZoomEvent={filterInteractionEvents}
|
|
className={isPanning ? styles.panning : ''}
|
|
>
|
|
<Geographies geography={geoDataNacional}>
|
|
{({ geographies }: { geographies: AmbitoGeography[] }) => geographies.map((geo) => {
|
|
const nombreNormalizado = normalizarTexto(geo.properties.nombre);
|
|
const esCABA = nombreNormalizado === 'CIUDAD AUTONOMA DE BUENOS AIRES';
|
|
const resultado = resultadosNacionalesPorNombre.get(nombreNormalizado);
|
|
const esProvinciaActiva = provinciaDistritoId && resultado?.ambitoId === provinciaDistritoId;
|
|
|
|
return (
|
|
<Geography
|
|
key={geo.rsmKey}
|
|
geography={geo}
|
|
ref={esCABA ? cabaPathRef : undefined}
|
|
// 3. Las clases de react-simple-maps ahora deben ser globales o no funcionarán
|
|
// Como el CSS module ya las define con :global(), no necesitamos hacer nada aquí.
|
|
className={`rsm-geography ${nivel !== 'pais' ? 'rsm-geography-faded' : ''}`}
|
|
style={{ visibility: esCABA ? 'hidden' : (esProvinciaActiva ? 'hidden' : 'visible') }}
|
|
fill={nivel === 'pais' ? (resultado?.colorGanador || DEFAULT_MAP_COLOR) : FADED_BACKGROUND_COLOR}
|
|
onClick={() => !esCABA && resultado && onAmbitoSelect(resultado.ambitoId, 'provincia', resultado.ambitoNombre)}
|
|
data-tooltip-id="mapa-tooltip"
|
|
data-tooltip-content={geo.properties.nombre}
|
|
/>
|
|
);
|
|
})}
|
|
</Geographies>
|
|
|
|
{provinciaDistritoId && nombreProvinciaActiva && (
|
|
<Suspense fallback={null}>
|
|
<MapaProvincial
|
|
eleccionId={eleccionId}
|
|
categoriaId={categoriaId}
|
|
distritoId={provinciaDistritoId}
|
|
nombreProvincia={nombreProvinciaActiva}
|
|
nombreMunicipioSeleccionado={nombreMunicipioSeleccionado}
|
|
onMunicipioSelect={(ambitoId, nombre) => onAmbitoSelect(ambitoId, 'municipio', nombre)}
|
|
onCalculatedCenter={handleCalculatedCenter}
|
|
nivel={nivel as 'provincia' | 'municipio'}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</ZoomableGroup>
|
|
</ComposableMap>
|
|
</div>
|
|
|
|
{nivel === 'pais' && (
|
|
<div id="caba-lupa-anchor" className={styles.cabaMagnifierContainer} style={lupaStyle} ref={lupaRef}>
|
|
{(() => {
|
|
const resultadoCABA = resultadosNacionalesPorNombre.get("CIUDAD AUTONOMA DE BUENOS AIRES");
|
|
const fillColor = resultadoCABA?.colorGanador || DEFAULT_MAP_COLOR;
|
|
const handleClick = () => {
|
|
if (resultadoCABA) {
|
|
onAmbitoSelect(resultadoCABA.ambitoId, 'provincia', resultadoCABA.ambitoNombre);
|
|
}
|
|
};
|
|
return <CabaLupa fillColor={fillColor} onClick={handleClick} />;
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
<Tooltip id="mapa-tooltip" key={nivel} />
|
|
</div>
|
|
);
|
|
}; |