From 4c0583845a6d4a67a5f4738f2c9bb6050a171b4e Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 11:51:16 +0100 Subject: [PATCH] fix(map): keep the selected place findable on the map Selecting a place no longer spiderfies a city-sized cluster: the reveal only fans open bubbles of up to 12 stops, or stacks whose stops all share one coordinate, so a zoomed-out trip with hundreds of places stays usable (#2990). The selected pin now carries a white border inside a pulsing blue ring on both renderers (static under reduced motion), and the map measures the place card instead of assuming a fixed 320px, so the selection pans the pin above even a tall card on portrait tablets and eases it back out when the card grows over it later (#2991). Closes #2990 Closes #2991 --- client/scripts/eslint-baseline.json | 4 +- client/scripts/size-baseline.json | 6 +- client/scripts/strict-baseline.json | 2 +- client/scripts/theme-disable-baseline.json | 1 + client/src/components/Map/MapView.test.tsx | 48 +++++++++- client/src/components/Map/MapView.tsx | 61 +++---------- client/src/components/Map/MapViewGL.tsx | 42 +++------ .../components/Map/SelectionController.tsx | 79 ++++++++++++++++ client/src/components/Map/centredMapCard.ts | 11 +++ .../components/Map/mapChromePadding.test.ts | 43 +++++++++ client/src/components/Map/mapChromePadding.ts | 47 ++++++++++ client/src/components/Map/mapViewContract.ts | 5 ++ .../src/components/Map/markerCluster.test.ts | 42 ++++++++- client/src/components/Map/markerCluster.ts | 41 ++++++++- client/src/components/Map/markerLook.test.ts | 27 ++++++ client/src/components/Map/markerLook.ts | 23 +++++ client/src/components/Map/selectedMarker.css | 28 ++++++ .../Map/useSelectionCameraGL.test.ts | 79 ++++++++++++++++ .../components/Map/useSelectionCameraGL.ts | 89 +++++++++++++++++++ .../src/components/Planner/PlaceInspector.tsx | 11 +-- .../src/components/Roadtrip/serviceMarker.ts | 8 +- client/src/hooks/useReportedHeight.test.tsx | 68 ++++++++++++++ client/src/hooks/useReportedHeight.ts | 42 +++++++++ client/src/pages/TripPlannerPage.tsx | 4 +- client/src/pages/tripPlanner/PlanMapStage.tsx | 5 +- .../pages/tripPlanner/useTripPlannerPage.ts | 4 + 26 files changed, 713 insertions(+), 107 deletions(-) create mode 100644 client/src/components/Map/SelectionController.tsx create mode 100644 client/src/components/Map/mapChromePadding.test.ts create mode 100644 client/src/components/Map/mapChromePadding.ts create mode 100644 client/src/components/Map/markerLook.test.ts create mode 100644 client/src/components/Map/selectedMarker.css create mode 100644 client/src/components/Map/useSelectionCameraGL.test.ts create mode 100644 client/src/components/Map/useSelectionCameraGL.ts create mode 100644 client/src/hooks/useReportedHeight.test.tsx create mode 100644 client/src/hooks/useReportedHeight.ts diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 7527cfd3a..f92a01edf 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -11,7 +11,7 @@ "react-hooks/refs": 553, "react-hooks/set-state-in-effect": 193, "@typescript-eslint/no-floating-promises": 164, - "react-hooks/exhaustive-deps": 116, + "react-hooks/exhaustive-deps": 115, "@typescript-eslint/no-unused-vars": 124, "react-hooks/immutability": 28, "max-lines-per-function": 130, @@ -31,7 +31,7 @@ }, "suppressed": { "@typescript-eslint/no-explicit-any": 38, - "react-hooks/exhaustive-deps": 46 + "react-hooks/exhaustive-deps": 45 }, "tests": { "@typescript-eslint/no-explicit-any": 489, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 166113985..09b4e489f 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -5,14 +5,14 @@ "src/components/Admin/AdminPluginsPanel.tsx": 1266, "src/components/Budget/CostsPanel.test.tsx": 2730, "src/components/Budget/CostsPanel.tsx": 1798, - "src/components/Map/MapView.tsx": 1255, + "src/components/Map/MapView.tsx": 1214, "src/components/Map/MapViewGL.test.tsx": 2919, - "src/components/Map/MapViewGL.tsx": 2198, + "src/components/Map/MapViewGL.tsx": 2183, "src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.tsx": 2929, "src/components/Planner/PlaceFormModal.test.tsx": 2836, - "src/components/Planner/PlaceInspector.tsx": 1290, + "src/components/Planner/PlaceInspector.tsx": 1287, "src/components/Studio/TravelElements.tsx": 1559, "src/components/Studio/countryShapes.ts": 2595, "src/index.css": 1636, diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index d6b35e74f..ca7932ca1 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -38,7 +38,7 @@ "src/components/Journey/journeyGeo.ts": 4, "src/components/Layout/Navbar.test.tsx": 1, "src/components/Map/MapView.test.tsx": 2, - "src/components/Map/MapView.tsx": 8, + "src/components/Map/MapView.tsx": 7, "src/components/Map/MapViewGL.tsx": 1, "src/components/Map/ReservationOverlay.test.tsx": 1, "src/components/Map/transitGeometry.ts": 1, diff --git a/client/scripts/theme-disable-baseline.json b/client/scripts/theme-disable-baseline.json index 87f672eb0..8fae04402 100644 --- a/client/scripts/theme-disable-baseline.json +++ b/client/scripts/theme-disable-baseline.json @@ -19,6 +19,7 @@ "components/Journey/journeyPhotoLayer.ts": 2, "components/Map/DawarichTrailLayer.tsx": 1, "components/Map/dawarichTrail.ts": 10, + "components/Map/markerLook.ts": 1, "components/Map/useDawarichTrailGL.ts": 1, "components/Packing/PackingBringersCard.tsx": 1, "components/Planner/DayReorderPopup.tsx": 1, diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index e9a9e679b..49aed5572 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { describe, it, expect, vi, afterEach } from 'vitest' +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import { render, screen } from '../../../tests/helpers/render' import { act, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -85,7 +85,7 @@ const clusterMock = vi.hoisted(() => { group: { getVisibleParent(marker: unknown) { clusterMock.asked.push(marker) - return clusterMock.stacked ? { spiderfy } : marker + return clusterMock.stacked ? { spiderfy, getChildCount: () => 2 } : marker }, }, } @@ -1283,6 +1283,50 @@ describe('MapView selection panning (#921)', () => { } }) + describe('a place card that grows over the selected pin (#2991)', () => { + const projectable = mapMock as unknown as Record + // The flat projection of the mock puts lat 0.7 / lng 0.5 at (500, 700) in a 1000x1200 map. + const covered = buildMapPlace({ id: 21, lat: 0.7, lng: 0.5 }) + const clear = buildMapPlace({ id: 22, lat: 0.2, lng: 0.5 }) + beforeEach(() => { + projectable.project = vi.fn(() => ({ add: vi.fn(() => ({ x: 0, y: 0 })) })) + projectable.unproject = vi.fn(() => [1, 1]) + projectable.getSize = vi.fn(() => ({ x: 1000, y: 1200 })) + }) + afterEach(() => { + delete projectable.project + delete projectable.unproject + delete projectable.getSize + }) + + it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => { + const places = [covered] + const { rerender } = render() + mapMock.panTo.mockClear() + + rerender() + expect(mapMock.panTo).toHaveBeenCalledTimes(1) + }) + + it('FE-COMP-MAPVIEW-091: leaves a pin that is still in the open alone', () => { + const places = [clear] + const { rerender } = render() + mapMock.panTo.mockClear() + + rerender() + expect(mapMock.panTo).not.toHaveBeenCalled() + }) + + it('FE-COMP-MAPVIEW-092: pans only once when the selection and the card arrive together', () => { + const places = [covered] + const { rerender } = render() + mapMock.panTo.mockClear() + + rerender() + expect(mapMock.panTo).toHaveBeenCalledTimes(1) + }) + }) + it('FE-COMP-MAPVIEW-052: falls back to a plain pan when the map cannot project', () => { const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })] const { rerender } = render() diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 8644dab70..194fbd70c 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -49,7 +49,8 @@ import { PluginMapLayers } from './MapPluginLayers' import { useTransportRoutes } from '../../hooks/useTransportRoutes' import { visibleRouteReservations } from '../../utils/reservationRoutes' import { safeHexColor } from '../../utils/safeColor' -import { placeMarkerLook } from './markerLook' +import { placeMarkerFrame, placeMarkerLook } from './markerLook' +import { bottomChromePadding } from './mapChromePadding' import { escapeHtml } from '@trek/shared' import type { Day, Place, Reservation, RouteVia } from '../../types' import type { MapHoverInfo } from './mapHover' @@ -152,10 +153,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) { const { size, borderWidth } = look // Allow-listed, not escaped: the value lands in style="…" of a divIcon, where // escaping stops the attribute breakout but still permits a CSS url(). - const borderColor = isSelected ? '#111827' : safeHexColor(place.category_color, 'white') - const shadow = isSelected - ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)' - : '0 2px 8px rgba(0,0,0,0.22)' + const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, safeHexColor(place.category_color, 'white')) const bgColor = safeHexColor(place.category_color, '#6b7280') // Number badges (bottom-right), or the rating where there are none: a numbered stop @@ -187,7 +185,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) { width:${size}px;height:${size}px; cursor:pointer;position:relative; "> -