diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index d363a1b75..6e36b8e36 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -4,6 +4,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup' import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag' +import { CLUSTER_OPTIONS, createClusterIcon } from './markerCluster' import RoadtripViaMarkers from './RoadtripViaMarkers' import HazardLayers from './HazardLayers' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' @@ -876,16 +877,6 @@ export const MapView = memo(function MapView({ } }, [placeIds, placesPhotosEnabled]) - const clusterIconCreateFunction = useCallback((cluster) => { - const count = cluster.getChildCount() - const size = count < 10 ? 36 : count < 50 ? 42 : 48 - return L.divIcon({ - html: `
${count}
`, - className: 'marker-cluster-wrapper', - iconSize: L.point(size, size), - }) - }, []) - const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0 // Drag a marker onto a day (#891). Pointer-driven, so it is off wherever // HTML5 drag does not exist — and the day plan is not on screen there anyway. @@ -1077,18 +1068,7 @@ export const MapView = memo(function MapView({ - + {markers} diff --git a/client/src/components/Map/markerCluster.ts b/client/src/components/Map/markerCluster.ts new file mode 100644 index 000000000..a80a69fd2 --- /dev/null +++ b/client/src/components/Map/markerCluster.ts @@ -0,0 +1,34 @@ +import L from 'leaflet' + +/** + * The cluster group the planner and the public share page both draw places with, + * so a shared link clusters a trip the way the planner does (#2343). + */ +export const CLUSTER_OPTIONS = { + chunkedLoading: true, + chunkInterval: 30, + chunkDelay: 0, + // Pixels at the current zoom, so ordinary stops still come apart by zooming in. + maxClusterRadius: 20, + disableClusteringAtZoom: 9, + spiderfyOnMaxZoom: true, + showCoverageOnHover: false, + zoomToBoundsOnClick: true, + animate: false, +} + +/** The part of a Leaflet cluster this factory reads. */ +export interface ClusterLike { + getChildCount: () => number +} + +/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */ +export function createClusterIcon(cluster: ClusterLike) { + const count = cluster.getChildCount() + const size = count < 10 ? 36 : count < 50 ? 42 : 48 + return L.divIcon({ + html: `
${count}
`, + className: 'marker-cluster-wrapper', + iconSize: L.point(size, size), + }) +} diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 2ecfe7d83..47ba4f53f 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -24,6 +24,13 @@ vi.mock('react-leaflet', () => ({ }), })); +// The real cluster group needs the map context the stubbed MapContainer lacks (#2343). +vi.mock('react-leaflet-cluster', () => ({ + default: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})); + // The basemap is a MapLibre style now, and the real component reaches for // maplibre-gl through a dynamic import. The page test only cares that it is the // thing being rendered. diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index f0e0d384b..9d541a984 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -18,6 +18,11 @@ import { import { createElement, useEffect, useRef } from 'react'; import { renderIconMarkup } from '../utils/iconMarkup'; import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet'; +import MarkerClusterGroup from 'react-leaflet-cluster'; +// MapView brings these sheets for the planner, and this page never mounts it. +import 'leaflet.markercluster/dist/MarkerCluster.css'; +import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; +import { CLUSTER_OPTIONS, createClusterIcon } from '../components/Map/markerCluster'; import { getCategoryIcon } from '../components/shared/categoryIcons'; import PublicLanguagePicker from '../components/shared/PublicLanguagePicker'; import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults'; @@ -457,11 +462,14 @@ export default function SharedTripPage() { interactive={false} /> )} - {mapPlaces.map((p: any) => ( - - {p.name} - - ))} + {/* Clustered like the planner's map, so nearby stops stay tappable (#2343). */} + + {mapPlaces.map((p: any) => ( + + {p.name} + + ))} + diff --git a/client/tests/unit/pages/SharedTripPage.map.test.tsx b/client/tests/unit/pages/SharedTripPage.map.test.tsx new file mode 100644 index 000000000..9b7f9f238 --- /dev/null +++ b/client/tests/unit/pages/SharedTripPage.map.test.tsx @@ -0,0 +1,130 @@ +/** + * The share page clusters its markers like the planner (#2343). The data hook and + * Leaflet are stubbed: under test is the page's wiring, not Leaflet's rendering. + */ +import React from 'react'; +import L from 'leaflet'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, within } from '../../helpers/render'; +import { CLUSTER_OPTIONS, createClusterIcon } from '../../../src/components/Map/markerCluster'; +import SharedTripPage from '../../../src/pages/SharedTripPage'; + +const mocks = vi.hoisted(() => ({ + hook: {} as Record, + clusterProps: [] as Array>, +})); + +vi.mock('../../../src/pages/sharedTrip/useSharedTrip', () => ({ + useSharedTrip: () => mocks.hook, +})); + +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: { children?: React.ReactNode }) =>
{children}
, + TileLayer: () => null, + Polyline: () => null, + Tooltip: ({ children }: { children?: React.ReactNode }) => {children}, + Marker: ({ children }: { children?: React.ReactNode }) =>
{children}
, + useMap: () => ({ fitBounds: vi.fn(), on: vi.fn(), off: vi.fn() }), +})); + +vi.mock('react-leaflet-cluster', () => ({ + default: (props: { children?: React.ReactNode }) => { + mocks.clusterProps.push(props as unknown as Record); + return
{props.children}
; + }, +})); + +// Its effect dynamically imports maplibre-gl, which has no business in jsdom. +vi.mock('../../../src/components/Map/VectorBasemap', () => ({ default: () => null })); + +const place = (id: number, name: string, lat: number, lng: number) => ({ + id, + name, + lat, + lng, + category_color: '#6366f1', + category_icon: 'map-pin', +}); + +function payload(places: Array>) { + return { + trip: { id: 1, title: 'Autumn', start_date: '2026-09-25', end_date: '2026-09-27', currency: 'EUR' }, + days: [{ id: 5, day_number: 1, date: '2026-09-25', title: 'Day one' }], + assignments: {}, + dayNotes: {}, + places, + reservations: [], + accommodations: [], + packing: [], + budget: [], + categories: [], + permissions: { share_map: true, share_bookings: true, share_packing: true, share_budget: true }, + collab: null, + cartoApiKey: null, + }; +} + +function hookState(data: unknown) { + return { + data, + error: false, + base: 'EUR', + convert: (n: number) => n, + selectedDay: null, + setSelectedDay: vi.fn(), + activeTab: 'plan', + setActiveTab: vi.fn(), + showLangPicker: false, + setShowLangPicker: vi.fn(), + }; +} + +beforeEach(() => { + mocks.clusterProps.length = 0; +}); + +describe('the map on a public share page', () => { + it('FE-PAGE-SHARED-MAP-001: every stop is handed to a cluster group, not the raw map', () => { + mocks.hook = hookState( + payload([place(11, 'The Bund', 31.24, 121.49), place(12, 'Peace Hotel', 31.2397, 121.4903)]), + ); + render(); + + const cluster = screen.getByTestId('cluster-group'); + expect(within(cluster).getAllByTestId('marker')).toHaveLength(2); + // And nothing escaped it: the map holds no bare markers of its own. + expect(within(screen.getByTestId('map')).getAllByTestId('marker')).toHaveLength(2); + }); + + it('FE-PAGE-SHARED-MAP-002: the group is built from the shared options, so both maps cluster alike', () => { + mocks.hook = hookState(payload([place(11, 'The Bund', 31.24, 121.49)])); + render(); + + expect(mocks.clusterProps).toHaveLength(1); + const props = mocks.clusterProps[0]; + for (const [key, value] of Object.entries(CLUSTER_OPTIONS)) { + expect(props[key], key + ' must reach the cluster group').toBe(value); + } + expect(typeof props.iconCreateFunction).toBe('function'); + }); + + it('FE-PAGE-SHARED-MAP-003: the bubble shows the count, in the size bucket that count earns', () => { + const small = createClusterIcon({ getChildCount: () => 7 }); + const medium = createClusterIcon({ getChildCount: () => 30 }); + const large = createClusterIcon({ getChildCount: () => 60 }); + + expect(small.options.className).toBe('marker-cluster-wrapper'); + expect(small.options.html).toContain('7'); + expect([small, medium, large].map((icon) => icon.options.iconSize)).toEqual([L.point(36, 36), L.point(42, 42), L.point(48, 48)]); + expect(small.options.html).toContain('width:36px;height:36px'); + }); + + it('FE-PAGE-SHARED-MAP-004: a place without coordinates is left out of the map, as before', () => { + mocks.hook = hookState( + payload([place(11, 'The Bund', 31.24, 121.49), { id: 12, name: 'Geocoding failed' } as never]), + ); + render(); + + expect(within(screen.getByTestId('cluster-group')).getAllByTestId('marker')).toHaveLength(1); + }); +});