@swisper.ai/web-map 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +134 -0
  3. package/dist/base-map.d.ts +28 -0
  4. package/dist/base-map.d.ts.map +1 -0
  5. package/dist/constants.d.ts +9 -0
  6. package/dist/constants.d.ts.map +1 -0
  7. package/dist/index.css +2 -0
  8. package/dist/index.d.ts +21 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.es.js +283 -0
  11. package/dist/index.es.js.map +1 -0
  12. package/dist/load-map-lib.d.ts +13 -0
  13. package/dist/load-map-lib.d.ts.map +1 -0
  14. package/dist/map-context.d.ts +14 -0
  15. package/dist/map-context.d.ts.map +1 -0
  16. package/dist/map-marker.d.ts +26 -0
  17. package/dist/map-marker.d.ts.map +1 -0
  18. package/dist/map-pin.d.ts +28 -0
  19. package/dist/map-pin.d.ts.map +1 -0
  20. package/dist/map-user-location.d.ts +11 -0
  21. package/dist/map-user-location.d.ts.map +1 -0
  22. package/dist/map-view.d.ts +30 -0
  23. package/dist/map-view.d.ts.map +1 -0
  24. package/dist/maplibre-worker-source-DgoyX01U.js +4 -0
  25. package/dist/user-location-pin.d.ts +8 -0
  26. package/dist/user-location-pin.d.ts.map +1 -0
  27. package/dist/utils/get-bounds-from-points.d.ts +10 -0
  28. package/dist/utils/get-bounds-from-points.d.ts.map +1 -0
  29. package/dist/worker/maplibre-worker-source.d.ts +2 -0
  30. package/dist/worker/maplibre-worker-source.d.ts.map +1 -0
  31. package/package.json +100 -0
  32. package/src/__storybook__/fixtures.ts +19 -0
  33. package/src/__tests__/load-map-lib.spec.ts +57 -0
  34. package/src/__tests__/map-marker.spec.tsx +153 -0
  35. package/src/__tests__/map-pin.spec.tsx +82 -0
  36. package/src/__tests__/map-user-location.spec.tsx +48 -0
  37. package/src/__tests__/maplibre-worker-source.stub.ts +2 -0
  38. package/src/__tests__/setup.ts +13 -0
  39. package/src/base-map.json +89 -0
  40. package/src/base-map.stories.tsx +123 -0
  41. package/src/base-map.tsx +137 -0
  42. package/src/constants.ts +11 -0
  43. package/src/index.css +1 -0
  44. package/src/index.ts +32 -0
  45. package/src/load-map-lib.ts +25 -0
  46. package/src/map-context.tsx +25 -0
  47. package/src/map-marker.json +84 -0
  48. package/src/map-marker.stories.tsx +228 -0
  49. package/src/map-marker.tsx +97 -0
  50. package/src/map-pin.json +87 -0
  51. package/src/map-pin.stories.tsx +97 -0
  52. package/src/map-pin.tsx +174 -0
  53. package/src/map-user-location.json +70 -0
  54. package/src/map-user-location.stories.tsx +69 -0
  55. package/src/map-user-location.tsx +29 -0
  56. package/src/map-view.json +112 -0
  57. package/src/map-view.stories.tsx +174 -0
  58. package/src/map-view.tsx +86 -0
  59. package/src/user-location-pin.json +58 -0
  60. package/src/user-location-pin.stories.tsx +43 -0
  61. package/src/user-location-pin.tsx +27 -0
  62. package/src/utils/get-bounds-from-points.ts +39 -0
  63. package/src/worker/maplibre-worker-source.ts +2 -0
  64. package/src/worker/virtual-maplibre-worker-source.d.ts +5 -0
  65. package/tailwind.css +9 -0
@@ -0,0 +1,123 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { PLACE_POINTS, PLACES } from './__storybook__/fixtures';
4
+ import { BaseMap } from './base-map';
5
+ import { DEFAULT_FIT_BOUNDS_PADDING, DEFAULT_MAP_STYLE_URL } from './constants';
6
+ import { getBoundsCentroid, getBoundsFromPoints } from './utils/get-bounds-from-points';
7
+
8
+ const PLACE_BOUNDS = getBoundsFromPoints(PLACE_POINTS);
9
+
10
+ const meta: Meta<typeof BaseMap> = {
11
+ title: 'Map/BaseMap',
12
+ component: BaseMap,
13
+ tags: ['autodocs'],
14
+ decorators: [
15
+ (Story) => (
16
+ <div className="h-[500px] w-[700px]">
17
+ <Story />
18
+ </div>
19
+ ),
20
+ ],
21
+ args: {
22
+ testId: 'base-map-story',
23
+ ariaLabel: 'Map',
24
+ initialViewState: {
25
+ center: PLACE_BOUNDS ? getBoundsCentroid(PLACE_BOUNDS) : null,
26
+ zoom: 12,
27
+ },
28
+ isLoading: false,
29
+ hasError: false,
30
+ bounds: null,
31
+ fitBoundsPadding: DEFAULT_FIT_BOUNDS_PADDING,
32
+ errorLabel: 'Could not load map. Try again later.',
33
+ },
34
+ // Callbacks are logged through their `action` argTypes (`storybook/test` is not a dependency here).
35
+ argTypes: {
36
+ testId: {
37
+ control: 'text',
38
+ description: 'Sets `data-testid` on the map root.',
39
+ },
40
+ ariaLabel: {
41
+ control: 'text',
42
+ description:
43
+ 'Accessible name of the map region; describe what it shows, e.g. `Restaurants near you`.',
44
+ },
45
+ initialViewState: {
46
+ control: 'object',
47
+ description:
48
+ 'Centre (`[lng, lat]`) and zoom the map opens at, before any `bounds` fit. Read once on mount.',
49
+ },
50
+ bounds: {
51
+ control: 'object',
52
+ description:
53
+ 'South-west and north-east corners to frame on load and whenever they change. A single point zooms to `SINGLE_POINT_ZOOM`.',
54
+ table: { defaultValue: { summary: 'null' } },
55
+ },
56
+ fitBoundsPadding: {
57
+ control: 'number',
58
+ description: 'Padding in px kept around `bounds` when fitting them.',
59
+ table: { defaultValue: { summary: String(DEFAULT_FIT_BOUNDS_PADDING) } },
60
+ },
61
+ isLoading: {
62
+ control: 'boolean',
63
+ description: 'Hides the map tiles behind the background while data for it is loading.',
64
+ table: { defaultValue: { summary: 'false' } },
65
+ },
66
+ hasError: {
67
+ control: 'boolean',
68
+ description: 'Hides the map, disables interaction and shows `errorLabel` over an error tint.',
69
+ table: { defaultValue: { summary: 'false' } },
70
+ },
71
+ errorLabel: {
72
+ control: 'text',
73
+ description: 'Message shown when `hasError` is set; pass a translated string.',
74
+ table: { defaultValue: { summary: 'Could not load map. Try again later.' } },
75
+ },
76
+ mapStyleUrl: {
77
+ control: 'text',
78
+ description: 'Style JSON URL; defaults to OpenFreeMap Liberty.',
79
+ table: { defaultValue: { summary: DEFAULT_MAP_STYLE_URL } },
80
+ },
81
+ mapRef: {
82
+ control: false,
83
+ description: 'Ref to the underlying MapLibre map, for imperative calls like `easeTo`.',
84
+ },
85
+ children: {
86
+ control: false,
87
+ description: 'Map layers and markers rendered inside the map.',
88
+ },
89
+ className: {
90
+ control: 'text',
91
+ description: 'Extra classes merged onto the root.',
92
+ },
93
+ onLoad: {
94
+ action: 'loaded',
95
+ description: 'Called once the map style has loaded (after any initial `bounds` fit).',
96
+ },
97
+ },
98
+ };
99
+
100
+ export default meta;
101
+
102
+ type Story = StoryObj<typeof BaseMap>;
103
+
104
+ /** Centred on `initialViewState`, with no bounds to fit. */
105
+ export const Default: Story = {};
106
+
107
+ /** Frames every point in `bounds` on load. */
108
+ export const FitBounds: Story = {
109
+ args: { bounds: PLACE_BOUNDS },
110
+ };
111
+
112
+ /** A single point has no area to fit, so the map zooms in to `SINGLE_POINT_ZOOM`. */
113
+ export const SinglePoint: Story = {
114
+ args: { bounds: [PLACES[0].lngLat, PLACES[0].lngLat] },
115
+ };
116
+
117
+ export const Loading: Story = {
118
+ args: { isLoading: true },
119
+ };
120
+
121
+ export const WithError: Story = {
122
+ args: { hasError: true, errorLabel: 'Could not load map. Try again later.' },
123
+ };
@@ -0,0 +1,137 @@
1
+ import { Map as MapGL } from '@vis.gl/react-maplibre';
2
+ import type { MapRef } from '@vis.gl/react-maplibre';
3
+ import type { Map as MapLibreMap } from 'maplibre-gl';
4
+ import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
5
+
6
+ import { ErrorIcon } from '@swisper.ai/icons';
7
+ import { Typography } from '@swisper.ai/ui';
8
+ import { cn } from '@swisper.ai/web-core';
9
+
10
+ import { DEFAULT_FIT_BOUNDS_PADDING, resolveMapStyleUrl, SINGLE_POINT_ZOOM } from './constants';
11
+ import { loadMapLib } from './load-map-lib';
12
+ import { isDegenerateBounds, type MapBounds, type MapLngLat } from './utils/get-bounds-from-points';
13
+
14
+ import 'maplibre-gl/dist/maplibre-gl.css';
15
+
16
+ interface BaseMapProps {
17
+ children?: ReactNode;
18
+ className?: string;
19
+ testId: string;
20
+ ariaLabel: string;
21
+ hasError?: boolean;
22
+ isLoading?: boolean;
23
+ initialViewState: { center: MapLngLat | null; zoom: number };
24
+ bounds?: MapBounds | null;
25
+ fitBoundsPadding?: number;
26
+ mapStyleUrl?: string;
27
+ mapRef?: RefObject<MapRef>;
28
+ onLoad?: () => void;
29
+ /**
30
+ * Shown when `hasError` is set. A prop rather than a translation lookup, so the
31
+ * package does not tie consumers to one i18n library or namespace.
32
+ */
33
+ errorLabel?: string;
34
+ }
35
+
36
+ function fitMapToBounds(map: MapLibreMap, bounds: MapBounds, padding: number) {
37
+ if (isDegenerateBounds(bounds)) {
38
+ const [[lng, lat]] = bounds;
39
+ map.setCenter([lng, lat]);
40
+ map.setZoom(SINGLE_POINT_ZOOM);
41
+ return;
42
+ }
43
+
44
+ map.fitBounds(bounds, { padding, duration: 0 });
45
+ }
46
+
47
+ export function BaseMap({
48
+ children,
49
+ className,
50
+ testId,
51
+ ariaLabel,
52
+ hasError,
53
+ isLoading,
54
+ initialViewState,
55
+ bounds = null,
56
+ fitBoundsPadding = DEFAULT_FIT_BOUNDS_PADDING,
57
+ mapStyleUrl,
58
+ mapRef: externalMapRef,
59
+ onLoad,
60
+ errorLabel = 'Could not load map. Try again later.',
61
+ }: BaseMapProps) {
62
+ const internalMapRef = useRef<MapRef>(null);
63
+ const mapRef = externalMapRef ?? internalMapRef;
64
+ const resolvedMapStyleUrl = resolveMapStyleUrl(mapStyleUrl);
65
+
66
+ useEffect(() => {
67
+ if (!bounds) {
68
+ return;
69
+ }
70
+
71
+ const map = mapRef.current?.getMap();
72
+ if (!map) {
73
+ return;
74
+ }
75
+
76
+ fitMapToBounds(map, bounds, fitBoundsPadding);
77
+ }, [bounds, fitBoundsPadding, mapRef]);
78
+
79
+ return (
80
+ <div
81
+ data-testid={testId}
82
+ data-rounded={true}
83
+ data-loading={isLoading}
84
+ aria-invalid={hasError}
85
+ data-error={hasError}
86
+ className={cn(
87
+ 'relative size-full',
88
+ 'bg-[#f8f4f1]',
89
+ 'data-[error=true]:bg-[#EB0000]/10',
90
+ 'data-[rounded=true]:rounded-lg overflow-hidden',
91
+ className,
92
+ )}
93
+ role="application"
94
+ aria-label={ariaLabel}
95
+ >
96
+ {hasError && <BaseMapErrorLayer label={errorLabel} />}
97
+
98
+ <MapGL
99
+ ref={mapRef}
100
+ mapLib={loadMapLib()}
101
+ initialViewState={initialViewState}
102
+ mapStyle={resolvedMapStyleUrl}
103
+ style={{
104
+ width: '100%',
105
+ height: '100%',
106
+ opacity: isLoading || hasError ? 0 : 1,
107
+ pointerEvents: hasError ? 'none' : 'auto',
108
+ }}
109
+ attributionControl={false}
110
+ onLoad={() => {
111
+ if (bounds) {
112
+ const map = mapRef.current?.getMap();
113
+ if (map) {
114
+ fitMapToBounds(map, bounds, fitBoundsPadding);
115
+ }
116
+ }
117
+ onLoad?.();
118
+ }}
119
+ >
120
+ {children}
121
+ </MapGL>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ function BaseMapErrorLayer({ label }: { label: string }) {
127
+ return (
128
+ <div className="absolute inset-0 flex items-center justify-center">
129
+ <div className="flex flex-col items-center gap-8">
130
+ <ErrorIcon fontSize="large" />
131
+ <Typography variant="body-sm" className="text-text-error">
132
+ {label}
133
+ </Typography>
134
+ </div>
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,11 @@
1
+ export const DEFAULT_MAP_STYLE_URL = 'https://tiles.openfreemap.org/styles/liberty';
2
+ export const DEFAULT_FIT_BOUNDS_PADDING = 48;
3
+ export const SINGLE_POINT_ZOOM = 15;
4
+
5
+ /**
6
+ * No `import.meta.env` fallback: Vite replaces it when this library is built, so a
7
+ * consumer's `VITE_MAP_STYLE_URL` would never be read. Pass `mapStyleUrl` instead.
8
+ */
9
+ export function resolveMapStyleUrl(mapStyleUrl?: string): string {
10
+ return mapStyleUrl ?? DEFAULT_MAP_STYLE_URL;
11
+ }
package/src/index.css ADDED
@@ -0,0 +1 @@
1
+ @import '@swisper.ai/tailwind-config/tailwind.css';
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export { MapView } from './map-view';
2
+ export { MapMarker, useMapMarker } from './map-marker';
3
+ export { MapPin } from './map-pin';
4
+ export { MapUserLocation } from './map-user-location';
5
+ export { UserLocationPin } from './user-location-pin';
6
+ export { useMapView } from './map-context';
7
+ export { BaseMap } from './base-map';
8
+ export { loadMapLib } from './load-map-lib';
9
+ // The primitives for drawing inside a `BaseMap`, re-exported so consumers compose
10
+ // against this package's copy of react-maplibre — and so read the same map context.
11
+ export { Layer, Marker, Source, useMap } from '@vis.gl/react-maplibre';
12
+ export {
13
+ DEFAULT_FIT_BOUNDS_PADDING,
14
+ DEFAULT_MAP_STYLE_URL,
15
+ SINGLE_POINT_ZOOM,
16
+ resolveMapStyleUrl,
17
+ } from './constants';
18
+ export {
19
+ getBoundsCentroid,
20
+ getBoundsFromPoints,
21
+ isDegenerateBounds,
22
+ } from './utils/get-bounds-from-points';
23
+
24
+ export type { MapViewProps } from './map-view';
25
+ export type { MapMarkerProps, MapMarkerState } from './map-marker';
26
+ export type { MapPinProps, MapPinClassNames } from './map-pin';
27
+ export type { MapUserLocationProps } from './map-user-location';
28
+ export type { UserLocationPinProps } from './user-location-pin';
29
+ export type { MapItemId, MapViewContextValue } from './map-context';
30
+ export type { MapBounds, MapLngLat } from './utils/get-bounds-from-points';
31
+ export type { LayerProps, MapRef, MarkerProps, SourceProps } from '@vis.gl/react-maplibre';
32
+ export type { ExpressionSpecification } from 'maplibre-gl';
@@ -0,0 +1,25 @@
1
+ import type * as maplibre from 'maplibre-gl';
2
+
3
+ type MapLib = typeof maplibre;
4
+
5
+ let mapLib: Promise<MapLib> | undefined;
6
+
7
+ /**
8
+ * maplibre-gl, with its worker pointed at a blob URL built from the source this
9
+ * package ships (see `vite-plugin-maplibre-worker.ts`). Pass it to `<Map mapLib>`.
10
+ *
11
+ * Both halves are dynamic imports, so the ~500 kB worker source is fetched with
12
+ * the first map rather than with every import of the package. Memoised: every
13
+ * map shares one worker URL.
14
+ */
15
+ export function loadMapLib(): Promise<MapLib> {
16
+ mapLib ??= Promise.all([import('maplibre-gl'), import('./worker/maplibre-worker-source')]).then(
17
+ ([lib, { default: workerSource }]) => {
18
+ const blob = new Blob([workerSource], { type: 'text/javascript' });
19
+ lib.setWorkerUrl(URL.createObjectURL(blob));
20
+ return lib;
21
+ },
22
+ );
23
+
24
+ return mapLib;
25
+ }
@@ -0,0 +1,25 @@
1
+ import type { MapRef } from '@vis.gl/react-maplibre';
2
+ import { createContext, useContext, type RefObject } from 'react';
3
+
4
+ export type MapItemId = string | number;
5
+
6
+ export interface MapViewContextValue {
7
+ mapRef: RefObject<MapRef>;
8
+ selectedId: MapItemId | null;
9
+ hoveredId: MapItemId | null;
10
+ flyToSelected: boolean;
11
+ onSelect?: (id: MapItemId) => void;
12
+ onHover?: (id: MapItemId | null) => void;
13
+ }
14
+
15
+ export const MapViewContext = createContext<MapViewContextValue | null>(null);
16
+
17
+ export function useMapView() {
18
+ const context = useContext(MapViewContext);
19
+
20
+ if (!context) {
21
+ throw new Error('useMapView must be used within a MapView component');
22
+ }
23
+
24
+ return context;
25
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "../../int-eslint-config/schemas/component.schema.json",
3
+ "component": "map-marker",
4
+ "purpose": "A selectable, hoverable point on a MapView that wraps its pin in a button and passes the marker's selection and hover state down to it.",
5
+ "useWhen": [
6
+ "Placing a place, result or entity on a web-map/map-view that the user can select",
7
+ "A pin must change look when its place is selected or hovered, in the map or in a list beside it"
8
+ ],
9
+ "avoidWhen": [
10
+ "Showing the user's own position — use web-map/map-user-location instead",
11
+ "Drawing a non-interactive marker or a marker on a web-map/base-map — use web-map/Marker instead",
12
+ "Drawing many hundreds of points — use a web-map/Source with a web-map/Layer instead of one DOM marker each"
13
+ ],
14
+ "uxPrinciples": [
15
+ "A marker reflects shared state: it is selected because the parent says so, whether the click came from the map or from a list",
16
+ "Hovered and selected markers rise above the others so the active one is never hidden under a neighbour"
17
+ ],
18
+ "accessibility": {
19
+ "required": [
20
+ "label is required and becomes the button's accessible name; use the place's name",
21
+ "Do not put interactive elements inside the marker; it is already a button"
22
+ ],
23
+ "keyboard": ["Tab focuses the marker button; Enter or Space selects it, the same as a click"],
24
+ "aria": [
25
+ "Renders a button with aria-label=label and aria-pressed reflecting selection",
26
+ "data-selected and data-hovered are set on the button for styling"
27
+ ],
28
+ "focus": [
29
+ "Each marker is one tab stop, in DOM order",
30
+ "Focus does not set hover state; hover follows the pointer only"
31
+ ]
32
+ },
33
+ "behaviour": {
34
+ "states": ["default", "hovered", "selected"],
35
+ "rules": [
36
+ "Clicking the marker calls the MapView's onSelect with its id and stops the click from reaching the map",
37
+ "Pointer enter calls onHover with its id and pointer leave calls it with null; the marker is hovered or selected only when the MapView's hoveredId or selectedId equals its id",
38
+ "When the marker becomes selected it eases the map to its coordinate if flyToOnSelect is true, or, when flyToOnSelect is unset, if the MapView's flyToSelected is true",
39
+ "anchor sets which side of the marker sits on the coordinate: bottom for callout pins like MapPin, center for dots",
40
+ "children is either an element, which reads the state through useMapMarker (MapPin does this), or a render function that receives { isSelected, isHovered }",
41
+ "Stacking: a hovered marker is above a selected one, which is above the rest",
42
+ "Must be rendered inside a web-map/map-view; outside one it throws"
43
+ ]
44
+ },
45
+ "composition": {
46
+ "allowedParents": ["web-map/map-view"],
47
+ "allowedChildren": [
48
+ "web-map/map-pin",
49
+ "render function receiving { isSelected, isHovered }",
50
+ "custom non-interactive pin element"
51
+ ],
52
+ "avoidCombinations": [
53
+ "Buttons or links inside the marker — nested interactive elements inside its button",
54
+ "MapMarker inside a web-map/base-map without a web-map/map-view — it throws"
55
+ ],
56
+ "contexts": ["map overlay"]
57
+ },
58
+ "content": {
59
+ "labelRules": [
60
+ "label is the place's name as the user knows it ('Roma Pizza'), the same text as the pin and any list entry",
61
+ "Pass label already translated"
62
+ ],
63
+ "helperTextRules": [],
64
+ "errorRules": []
65
+ },
66
+ "aiDecisionRules": [
67
+ {
68
+ "condition": "The default callout pin fits the design",
69
+ "decision": "Render web-map/map-pin as the child with the same label and an optional value",
70
+ "reason": "MapPin picks up the marker's state through context and needs no extra props"
71
+ },
72
+ {
73
+ "condition": "A custom pin (dot, icon, avatar) is needed",
74
+ "decision": "Pass a render function as children and draw the pin from { isSelected, isHovered }; set anchor to center for round pins",
75
+ "reason": "The marker owns the button and state; the child only draws"
76
+ },
77
+ {
78
+ "condition": "One marker should not move the map when selected (e.g. it is already in view or is a reference point)",
79
+ "decision": "Set flyToOnSelect={false} on that marker",
80
+ "reason": "It overrides the MapView's flyToSelected for that marker only"
81
+ }
82
+ ],
83
+ "relatedComponents": ["web-map/map-view", "web-map/map-pin", "web-map/map-user-location"]
84
+ }
@@ -0,0 +1,228 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState, type ReactNode } from 'react';
3
+
4
+ import { TooltipProvider, cn } from '@swisper.ai/ui';
5
+
6
+ import { PLACE_POINTS, PLACES } from './__storybook__/fixtures';
7
+ import { MapMarker, type MapMarkerProps, type MapMarkerState } from './map-marker';
8
+ import { MapPin } from './map-pin';
9
+ import { MapView } from './map-view';
10
+
11
+ type Anchor = NonNullable<MapMarkerProps['anchor']>;
12
+
13
+ interface HarnessState {
14
+ selectedId: string | null;
15
+ setSelectedId: (id: string | null) => void;
16
+ }
17
+
18
+ interface MarkerHarnessProps {
19
+ children: ReactNode;
20
+ /** Rendered beside the map with the selection, e.g. a list that selects from outside. */
21
+ sidebar?: (state: HarnessState) => ReactNode;
22
+ }
23
+
24
+ /** `MapMarker` needs a `MapView` around it; this one owns selection and hover. */
25
+ function MarkerHarness({ children, sidebar }: MarkerHarnessProps) {
26
+ const [selectedId, setSelectedId] = useState<string | null>(null);
27
+ const [hoveredId, setHoveredId] = useState<string | null>(null);
28
+
29
+ return (
30
+ <div className="flex size-full gap-16">
31
+ {sidebar?.({ selectedId, setSelectedId })}
32
+ <div className="h-full flex-1">
33
+ <MapView<string>
34
+ testId="map-marker-story"
35
+ ariaLabel="Restaurants near you"
36
+ fitPoints={PLACE_POINTS}
37
+ selectedId={selectedId}
38
+ hoveredId={hoveredId}
39
+ onSelect={setSelectedId}
40
+ onHover={setHoveredId}
41
+ >
42
+ {children}
43
+ </MapView>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ /** `MapMarker`'s props, so it can be the meta's `component`, plus the harness's own args. */
50
+ interface MapMarkerStoryArgs extends MapMarkerProps {
51
+ anchor: Anchor;
52
+ /** Only used by `SelectedFromOutside`: whether the first marker eases into view. */
53
+ flyToFirst: boolean;
54
+ }
55
+
56
+ const meta: Meta<MapMarkerStoryArgs> = {
57
+ title: 'Map/MapMarker',
58
+ component: MapMarker,
59
+ tags: ['autodocs'],
60
+ decorators: [
61
+ (Story) => (
62
+ <TooltipProvider>
63
+ <div className="h-[600px] w-[900px]">
64
+ <Story />
65
+ </div>
66
+ </TooltipProvider>
67
+ ),
68
+ ],
69
+ args: {
70
+ anchor: 'bottom',
71
+ flyToFirst: true,
72
+ },
73
+ argTypes: {
74
+ anchor: {
75
+ control: 'inline-radio',
76
+ options: ['center', 'top', 'bottom', 'left', 'right'] satisfies Anchor[],
77
+ description:
78
+ 'Which side of the marker sits on its coordinate: `bottom` for callout pins like `MapPin`, `center` for dots.',
79
+ table: { defaultValue: { summary: 'bottom' } },
80
+ },
81
+ flyToFirst: {
82
+ table: { disable: true },
83
+ description:
84
+ "Passed as the first marker's `flyToOnSelect`: whether selecting it eases the map to it, overriding the `MapView`'s `flyToSelected`.",
85
+ },
86
+ },
87
+ };
88
+
89
+ export default meta;
90
+
91
+ type Story = StoryObj<MapMarkerStoryArgs>;
92
+
93
+ /** `MapPin` children pick up the marker state. Hover or click a pin to see it. */
94
+ export const Default: Story = {
95
+ render: ({ anchor }) => (
96
+ <MarkerHarness>
97
+ {PLACES.map((place) => (
98
+ <MapMarker
99
+ key={place.id}
100
+ id={place.id}
101
+ longitude={place.lngLat[0]}
102
+ latitude={place.lngLat[1]}
103
+ label={place.label}
104
+ anchor={anchor}
105
+ >
106
+ <MapPin label={place.label} value={place.value} />
107
+ </MapMarker>
108
+ ))}
109
+ </MarkerHarness>
110
+ ),
111
+ };
112
+
113
+ function DotPin({ isSelected, isHovered }: MapMarkerState) {
114
+ return (
115
+ <span
116
+ className={cn(
117
+ 'block size-16 rounded-full bg-[#252931] ring-2 ring-white transition-transform duration-150',
118
+ isHovered && 'scale-125',
119
+ isSelected && 'scale-150 bg-primary-main',
120
+ )}
121
+ />
122
+ );
123
+ }
124
+
125
+ /** Children as a render function get `{ isSelected, isHovered }` to draw any pin. */
126
+ export const RenderFunction: Story = {
127
+ args: { anchor: 'center' },
128
+ render: ({ anchor }) => (
129
+ <MarkerHarness>
130
+ {PLACES.map((place) => (
131
+ <MapMarker
132
+ key={place.id}
133
+ id={place.id}
134
+ longitude={place.lngLat[0]}
135
+ latitude={place.lngLat[1]}
136
+ label={place.label}
137
+ anchor={anchor}
138
+ >
139
+ {(state) => <DotPin {...state} />}
140
+ </MapMarker>
141
+ ))}
142
+ </MarkerHarness>
143
+ ),
144
+ };
145
+
146
+ /**
147
+ * Selecting from a list outside the map eases to the marker. Pan away first to
148
+ * see it. Turn off `flyToFirst` to see the per-marker `flyToOnSelect` override.
149
+ */
150
+ export const SelectedFromOutside: Story = {
151
+ argTypes: { flyToFirst: { table: { disable: false }, control: 'boolean' } },
152
+ render: ({ anchor, flyToFirst }) => (
153
+ <MarkerHarness
154
+ sidebar={({ selectedId, setSelectedId }) => (
155
+ <ul className="flex w-[200px] flex-col gap-4">
156
+ {PLACES.map((place) => (
157
+ <li key={place.id}>
158
+ <button
159
+ type="button"
160
+ aria-pressed={place.id === selectedId}
161
+ onClick={() => setSelectedId(place.id === selectedId ? null : place.id)}
162
+ className={cn(
163
+ 'w-full cursor-pointer rounded-md px-12 py-8 text-left',
164
+ place.id === selectedId ? 'bg-primary-main/20' : 'hover:bg-white/5',
165
+ )}
166
+ >
167
+ {place.label}
168
+ </button>
169
+ </li>
170
+ ))}
171
+ </ul>
172
+ )}
173
+ >
174
+ {PLACES.map((place, index) => (
175
+ <MapMarker
176
+ key={place.id}
177
+ id={place.id}
178
+ longitude={place.lngLat[0]}
179
+ latitude={place.lngLat[1]}
180
+ label={place.label}
181
+ anchor={anchor}
182
+ flyToOnSelect={index === 0 ? flyToFirst : undefined}
183
+ >
184
+ <MapPin label={place.label} value={place.value} />
185
+ </MapMarker>
186
+ ))}
187
+ </MarkerHarness>
188
+ ),
189
+ };
190
+
191
+ const ANCHORS: Anchor[] = ['center', 'top', 'bottom', 'left', 'right'];
192
+
193
+ /** One marker per `anchor` at the same point, so each shows which side sits on it. */
194
+ export const Anchors: Story = {
195
+ argTypes: { anchor: { table: { disable: true } } },
196
+ render: () => {
197
+ const [longitude, latitude] = PLACES[0].lngLat;
198
+ return (
199
+ <MarkerHarness>
200
+ <>
201
+ {ANCHORS.map((anchor) => (
202
+ <MapMarker
203
+ key={anchor}
204
+ id={anchor}
205
+ longitude={longitude}
206
+ latitude={latitude}
207
+ label={`Anchor ${anchor}`}
208
+ anchor={anchor}
209
+ flyToOnSelect={false}
210
+ >
211
+ <MapPin label={anchor} />
212
+ </MapMarker>
213
+ ))}
214
+ <MapMarker
215
+ id="point"
216
+ longitude={longitude}
217
+ latitude={latitude}
218
+ label="Anchor point"
219
+ anchor="center"
220
+ flyToOnSelect={false}
221
+ >
222
+ <span className="block size-8 rounded-full bg-[#EB0000]" />
223
+ </MapMarker>
224
+ </>
225
+ </MarkerHarness>
226
+ );
227
+ },
228
+ };