diff --git a/src/features/map/EntityMap.tsx b/src/features/map/EntityMap.tsx index bf4453bd..72cbfd8c 100644 --- a/src/features/map/EntityMap.tsx +++ b/src/features/map/EntityMap.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { ObjectType } from '@features/params/PageParamTypes'; import usePageParams from '@features/params/usePageParams'; @@ -14,15 +14,9 @@ import { ObjectData } from '@entities/types/DataTypes'; import { uniqueBy } from '@shared/lib/setUtils'; import DrawableData from './DrawableData'; -import { getRobinsonCoordinatesShifted } from './getRobinsonCoordinates'; -import MapCard from './MapCard'; import MapCentroids from './MapCentroids'; -import { - MAP_ASPECT_RATIO, - MAP_INTERNAL_WIDTH, - MAP_ROBINSON_X_SCALE, - MAP_ROBINSON_Y_SCALE, -} from './MapConsts'; +import { MAP_ASPECT_RATIO, MAP_INTERNAL_WIDTH } from './MapConsts'; +import MapSidebar from './MapSidebar'; import MapTerritories from './MapTerritories'; import useMapZoom from './UseMapZoom'; import ZoomControls from './ZoomControls'; @@ -32,17 +26,12 @@ type Props = { maxWidth?: number; }; -type FloatingCard = { - id: string; - entity: DrawableData; - x: number; - y: number; -}; - const EntityMap: React.FC = ({ entities, maxWidth = 2000 }) => { const mapHeight = MAP_INTERNAL_WIDTH / MAP_ASPECT_RATIO; const { pageBrightness } = usePageParams().brightness; + const [hoveredId, setHoveredId] = useState(null); + const [zoomFactor, setZoomFactor] = useState(1); const { containerRef, contentRef, zoomIn, zoomOut, resetTransform } = useMapZoom({ @@ -52,14 +41,6 @@ const EntityMap: React.FC = ({ entities, maxWidth = 2000 }) => { }); const { colorBy, objectType, pinned, updatePageParams } = usePageParams(); - const [mapScale, setMapScale] = useState(1); - - const updateMapScale = useCallback(() => { - const rect = contentRef.current?.getBoundingClientRect(); - if (!rect) return; - - setMapScale(rect.width / MAP_INTERNAL_WIDTH); - }, [contentRef]); const drawableEntities = useMemo(() => { if (objectType === ObjectType.Language) { @@ -83,135 +64,99 @@ const EntityMap: React.FC = ({ entities, maxWidth = 2000 }) => { const coloringFunctions = useColors({ objects: drawableEntities }); - // Floating cards are derived from the pinned page param so they can be fully restored from the - // URL after a refresh. Each card is positioned at its entity's Robinson centroid. - const floatingCards = useMemo(() => { + const pinnedEntities = useMemo(() => { const drawableById = new Map(drawableEntities.map((entity) => [entity.ID, entity])); return pinned - .map((id) => { - const entity = drawableById.get(id); - if (entity == null || entity.latitude == null || entity.longitude == null) return undefined; - - const { x: robinsonX, y: robinsonY } = getRobinsonCoordinatesShifted(entity); - return { - id, - entity, - x: MAP_INTERNAL_WIDTH / 2 + robinsonX * MAP_ROBINSON_X_SCALE, - y: mapHeight / 2 - robinsonY * MAP_ROBINSON_Y_SCALE, - }; - }) - .filter((card): card is FloatingCard => card != null); - }, [pinned, drawableEntities, mapHeight]); - - const openCard = useCallback( + .map((id) => drawableById.get(id)) + .filter((entity): entity is DrawableData => entity != null); + }, [pinned, drawableEntities]); + + const pinCard = useCallback( (entity: DrawableData) => { - updateMapScale(); - if (pinned.includes(entity.ID)) return; - updatePageParams({ pinned: [...pinned, entity.ID] }); + if (pinned.includes(entity.ID)) { + updatePageParams({ pinned: pinned.filter((id) => id !== entity.ID) }); + } else { + updatePageParams({ pinned: [...pinned, entity.ID] }); + } }, - [pinned, updateMapScale, updatePageParams], + [pinned, updatePageParams], ); - const closeCard = useCallback( + const unpinCard = useCallback( (id: string) => { updatePageParams({ pinned: pinned.filter((pin) => pin !== id) }); }, [pinned, updatePageParams], ); - const handleZoomIn = useCallback(() => { - zoomIn(); - requestAnimationFrame(updateMapScale); - }, [zoomIn, updateMapScale]); - - const handleZoomOut = useCallback(() => { - zoomOut(); - requestAnimationFrame(updateMapScale); - }, [zoomOut, updateMapScale]); - - const handleResetTransform = useCallback(() => { - resetTransform(); - requestAnimationFrame(updateMapScale); - }, [resetTransform, updateMapScale]); - - // Restore the map scale on mount so URL-restored cards are sized correctly before any zoom. - useEffect(() => { - requestAnimationFrame(updateMapScale); - }, [updateMapScale]); - return (
- +
updatePageParams({ pinned: [] })} > + +
- World map - - {objectType !== ObjectType.Language && ( - - )} - - - {floatingCards.map((card) => ( -
+ World map e.stopPropagation()} - > - closeCard(card.id)} + /> + + {objectType !== ObjectType.Language && ( + -
- ))} + )} + + +
diff --git a/src/features/map/MapCentroids.tsx b/src/features/map/MapCentroids.tsx index ad72212e..e31aad12 100644 --- a/src/features/map/MapCentroids.tsx +++ b/src/features/map/MapCentroids.tsx @@ -17,18 +17,22 @@ import './map.css'; type Props = { drawableEntities: DrawableData[]; - openCard: (obj: DrawableData, x: number, y: number) => void; + pinCard: (obj: DrawableData) => void; scalar: number; zoomFactor: number; coloringFunctions: ColoringFunctions; + hoveredId?: string | null; + pinnedIds?: string[]; }; const MapCentroids: React.FC = ({ drawableEntities, - openCard, + pinCard, scalar, zoomFactor, coloringFunctions: { getColor, colorBy }, + hoveredId, + pinnedIds = [], }) => { const { scaleBy, objectType } = usePageParams(); const { getCurrentEntities } = usePagination(); @@ -81,9 +85,11 @@ const MapCentroids: React.FC = ({ object={obj} scale={scalar * getScale(obj)} zoomFactor={zoomFactor} - openCard={openCard} + pinCard={pinCard} onMouseEnter={buildOnMouseEnter(obj)} onMouseLeave={onMouseLeaveTriggeringElement} + isHovered={hoveredId === obj.ID} + isPinned={pinnedIds.includes(obj.ID)} /> ))} @@ -95,9 +101,11 @@ type NodeProps = { object: DrawableData; scale: number; zoomFactor: number; - openCard: (obj: DrawableData, x: number, y: number) => void; + pinCard: (obj: DrawableData) => void; onMouseEnter: (e: React.MouseEvent) => void; onMouseLeave: () => void; + isHovered?: boolean; + isPinned?: boolean; }; const ObjectNode: React.FC = ({ @@ -105,9 +113,11 @@ const ObjectNode: React.FC = ({ color, scale, zoomFactor, - openCard, + pinCard, onMouseEnter, onMouseLeave, + isHovered, + isPinned, }) => { if (object.type !== ObjectType.Language && object.type !== ObjectType.Territory) return null; if (object.latitude == null || object.longitude == null) return null; @@ -126,9 +136,11 @@ const ObjectNode: React.FC = ({ object={object} scale={scale} zoomFactor={zoomFactor} - openCard={openCard} + pinCard={pinCard} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + isHovered={isHovered} + isPinned={isPinned} /> )} @@ -140,18 +152,33 @@ const Circle: React.FC = ({ color, object, scale, - openCard, + pinCard, onMouseEnter, onMouseLeave, + isHovered, + isPinned, }) => ( { e.stopPropagation(); - openCard(object, e.clientX, e.clientY); + pinCard(object); }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} diff --git a/src/features/map/MapSidebar.tsx b/src/features/map/MapSidebar.tsx new file mode 100644 index 00000000..5485e72e --- /dev/null +++ b/src/features/map/MapSidebar.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { ObjectType } from '@features/params/PageParamTypes'; + +import DrawableData from './DrawableData'; +import MapCard from './MapCard'; + +type Props = { + pinnedEntities: DrawableData[]; + objectType: ObjectType; + onClose: (id: string) => void; + hoveredId: string | null; + setHoveredId: (id: string | null) => void; +}; + +const MapSidebar: React.FC = ({ + pinnedEntities, + objectType, + onClose, + hoveredId, + setHoveredId, +}) => { + if (pinnedEntities.length === 0) return null; + + return ( +
+

+ Selected {objectType === ObjectType.Language ? 'Languages' : 'Territories'} +

+ {pinnedEntities.map((entity) => ( +
setHoveredId(entity.ID)} + onMouseLeave={() => setHoveredId(null)} + style={{ + transition: 'transform 0.15s ease-in-out', + transform: hoveredId === entity.ID ? 'scale(1.02)' : 'scale(1)', + }} + > + { + setHoveredId(null); + onClose(entity.ID); + }} + /> +
+ ))} +
+ ); +}; + +export default MapSidebar; diff --git a/src/features/map/MapTerritories.tsx b/src/features/map/MapTerritories.tsx index 0249ab4e..4600b394 100644 --- a/src/features/map/MapTerritories.tsx +++ b/src/features/map/MapTerritories.tsx @@ -13,13 +13,17 @@ import DrawableData from './DrawableData'; type Props = { drawableEntities: DrawableData[]; coloringFunctions: ColoringFunctions; - openCard: (obj: DrawableData, x: number, y: number) => void; + pinCard: (obj: DrawableData) => void; + hoveredId?: string | null; + pinnedIds?: string[]; }; const MapTerritories: React.FC = ({ drawableEntities, coloringFunctions: { colorBy, getColor }, - openCard, + pinCard, + hoveredId, + pinnedIds = [], }) => { const svgContainerRef = useRef(null); const [svgLoaded, setSvgLoaded] = useState(false); @@ -56,13 +60,27 @@ const MapTerritories: React.FC = ({ } else { element.style.fill = 'var(--color-button-primary)'; } + + if (pinnedIds.includes(territory.ID)) { + element.style.stroke = 'var(--color-text)'; + element.style.strokeWidth = '2'; + } else { + element.style.stroke = ''; + element.style.strokeWidth = ''; + } } else { element.style.fill = '#bcbcbc'; } element.style.cursor = 'pointer'; + + if (hoveredId === territory.ID) { + element.style.opacity = '0.7'; + } else { + element.style.opacity = '1'; + } }); - }, [territories, getColor, isTerritoryInList, colorBy, svgLoaded]); + }, [territories, getColor, isTerritoryInList, colorBy, svgLoaded, hoveredId, pinnedIds]); const buildOnMouseEnter = useCallback( (territory: TerritoryData, element: SVGElement) => (ev: MouseEvent) => { @@ -96,7 +114,7 @@ const MapTerritories: React.FC = ({ forEachTerritory((territory, element) => { const handleClick = (ev: MouseEvent) => { ev.stopPropagation(); - openCard(territory, ev.clientX, ev.clientY); + pinCard(territory); }; const handleMouseEnter = buildOnMouseEnter(territory, element); @@ -114,7 +132,7 @@ const MapTerritories: React.FC = ({ }); return () => cleanupListeners.forEach((cleanup) => cleanup()); - }, [buildOnMouseEnter, buildOnMouseLeave, openCard, territories, svgLoaded]); + }, [buildOnMouseEnter, buildOnMouseLeave, pinCard, territories, svgLoaded]); return (