From fbf90ec696979e618a472ac9c7810e960d966a99 Mon Sep 17 00:00:00 2001 From: Rutansh Suthar Date: Tue, 23 Jun 2026 10:04:54 -0400 Subject: [PATCH 1/2] feat: replace floating map cards with cards pinned to sidebar --- src/features/map/EntityMap.tsx | 192 +++++++++--------------- src/features/map/MapCentroids.tsx | 49 ++++-- src/features/map/MapSidebar.tsx | 66 ++++++++ src/features/map/MapTerritories.tsx | 18 ++- src/features/params/getParamsFromURL.ts | 2 +- 5 files changed, 186 insertions(+), 141 deletions(-) create mode 100644 src/features/map/MapSidebar.tsx diff --git a/src/features/map/EntityMap.tsx b/src/features/map/EntityMap.tsx index bf4453bdf..8f6a79937 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,98 @@ 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 ad72212ec..47279cdd9 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 000000000..4e39dc1c9 --- /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 0249ab4ee..70211a7c4 100644 --- a/src/features/map/MapTerritories.tsx +++ b/src/features/map/MapTerritories.tsx @@ -13,13 +13,15 @@ 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; }; const MapTerritories: React.FC = ({ drawableEntities, coloringFunctions: { colorBy, getColor }, - openCard, + pinCard, + hoveredId, }) => { const svgContainerRef = useRef(null); const [svgLoaded, setSvgLoaded] = useState(false); @@ -61,8 +63,14 @@ const MapTerritories: React.FC = ({ } 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]); const buildOnMouseEnter = useCallback( (territory: TerritoryData, element: SVGElement) => (ev: MouseEvent) => { @@ -96,7 +104,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 +122,7 @@ const MapTerritories: React.FC = ({ }); return () => cleanupListeners.forEach((cleanup) => cleanup()); - }, [buildOnMouseEnter, buildOnMouseLeave, openCard, territories, svgLoaded]); + }, [buildOnMouseEnter, buildOnMouseLeave, pinCard, territories, svgLoaded]); return (
Date: Fri, 26 Jun 2026 10:42:00 -0400 Subject: [PATCH 2/2] fix: use theme relevant border colors for pinned map elements --- src/features/map/EntityMap.tsx | 3 ++- src/features/map/MapCentroids.tsx | 2 +- src/features/map/MapSidebar.tsx | 2 +- src/features/map/MapTerritories.tsx | 12 +++++++++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/features/map/EntityMap.tsx b/src/features/map/EntityMap.tsx index 8f6a79937..72cbfd8c8 100644 --- a/src/features/map/EntityMap.tsx +++ b/src/features/map/EntityMap.tsx @@ -95,7 +95,7 @@ const EntityMap: React.FC = ({ entities, maxWidth = 2000 }) => {
= ({ entities, maxWidth = 2000 }) => { pinCard={pinCard} coloringFunctions={coloringFunctions} hoveredId={hoveredId} + pinnedIds={pinned} /> )} diff --git a/src/features/map/MapCentroids.tsx b/src/features/map/MapCentroids.tsx index 47279cdd9..e31aad122 100644 --- a/src/features/map/MapCentroids.tsx +++ b/src/features/map/MapCentroids.tsx @@ -164,7 +164,7 @@ const Circle: React.FC = ({ fill={color ?? 'transparent'} stroke={ isPinned - ? 'black' + ? 'var(--color-text)' : isHovered ? 'var(--color-button-primary)' : color == null diff --git a/src/features/map/MapSidebar.tsx b/src/features/map/MapSidebar.tsx index 4e39dc1c9..5485e72e4 100644 --- a/src/features/map/MapSidebar.tsx +++ b/src/features/map/MapSidebar.tsx @@ -28,7 +28,7 @@ const MapSidebar: React.FC = ({ width: '300px', height: '100%', overflowY: 'auto', - borderRight: '1px solid var(--border-color)', + borderRight: '1px solid var(--color-text-secondary)', display: 'flex', flexDirection: 'column', gap: '1em', diff --git a/src/features/map/MapTerritories.tsx b/src/features/map/MapTerritories.tsx index 70211a7c4..4600b394e 100644 --- a/src/features/map/MapTerritories.tsx +++ b/src/features/map/MapTerritories.tsx @@ -15,6 +15,7 @@ type Props = { coloringFunctions: ColoringFunctions; pinCard: (obj: DrawableData) => void; hoveredId?: string | null; + pinnedIds?: string[]; }; const MapTerritories: React.FC = ({ @@ -22,6 +23,7 @@ const MapTerritories: React.FC = ({ coloringFunctions: { colorBy, getColor }, pinCard, hoveredId, + pinnedIds = [], }) => { const svgContainerRef = useRef(null); const [svgLoaded, setSvgLoaded] = useState(false); @@ -58,6 +60,14 @@ 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'; } @@ -70,7 +80,7 @@ const MapTerritories: React.FC = ({ element.style.opacity = '1'; } }); - }, [territories, getColor, isTerritoryInList, colorBy, svgLoaded, hoveredId]); + }, [territories, getColor, isTerritoryInList, colorBy, svgLoaded, hoveredId, pinnedIds]); const buildOnMouseEnter = useCallback( (territory: TerritoryData, element: SVGElement) => (ev: MouseEvent) => {