@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,97 @@
1
+ import { Marker } from '@vis.gl/react-maplibre';
2
+ import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react';
3
+
4
+ import { cn } from '@swisper.ai/ui';
5
+
6
+ import { useMapView, type MapItemId } from './map-context';
7
+
8
+ export interface MapMarkerState {
9
+ isSelected: boolean;
10
+ isHovered: boolean;
11
+ }
12
+
13
+ const MapMarkerContext = createContext<MapMarkerState | null>(null);
14
+
15
+ /** Selection state of the enclosing `MapMarker`, or null outside of one. */
16
+ export function useMapMarker() {
17
+ return useContext(MapMarkerContext);
18
+ }
19
+
20
+ export interface MapMarkerProps {
21
+ id: MapItemId;
22
+ longitude: number;
23
+ latitude: number;
24
+ /** Accessible name of the marker button. */
25
+ label: string;
26
+ anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right';
27
+ /** Ease to this marker when selected; defaults to the `MapView`'s `flyToSelected`. */
28
+ flyToOnSelect?: boolean;
29
+ className?: string;
30
+ children: ReactNode | ((state: MapMarkerState) => ReactNode);
31
+ }
32
+
33
+ function getMarkerZIndex({ isHovered, isSelected }: MapMarkerState) {
34
+ if (isHovered) return 3;
35
+ if (isSelected) return 2;
36
+ return 1;
37
+ }
38
+
39
+ /**
40
+ * A selectable, hoverable point on a `MapView`. Renders any pin as its child —
41
+ * `MapPin` by default, or a render function receiving the marker state.
42
+ */
43
+ export function MapMarker({
44
+ id,
45
+ longitude,
46
+ latitude,
47
+ label,
48
+ anchor = 'bottom',
49
+ flyToOnSelect,
50
+ className,
51
+ children,
52
+ }: MapMarkerProps) {
53
+ const { mapRef, selectedId, hoveredId, flyToSelected, onSelect, onHover } = useMapView();
54
+
55
+ const isSelected = id === selectedId;
56
+ const isHovered = id === hoveredId;
57
+ const state = useMemo(() => ({ isSelected, isHovered }), [isSelected, isHovered]);
58
+
59
+ // Fly to the selected pin so it's always in view when chosen from outside the map.
60
+ const shouldFlyTo = flyToOnSelect ?? flyToSelected;
61
+
62
+ useEffect(() => {
63
+ if (!isSelected || !shouldFlyTo) return;
64
+ const map = mapRef.current?.getMap();
65
+ if (!map) return;
66
+ map.easeTo({ center: [longitude, latitude], duration: 400 });
67
+ }, [isSelected, shouldFlyTo, longitude, latitude, mapRef]);
68
+
69
+ return (
70
+ <Marker
71
+ longitude={longitude}
72
+ latitude={latitude}
73
+ anchor={anchor}
74
+ style={{ zIndex: getMarkerZIndex(state) }}
75
+ onClick={(e) => {
76
+ // Prevent the map's click handler from firing and deselecting.
77
+ e.originalEvent.stopPropagation();
78
+ onSelect?.(id);
79
+ }}
80
+ >
81
+ <button
82
+ type="button"
83
+ aria-label={label}
84
+ aria-pressed={isSelected}
85
+ data-selected={isSelected || undefined}
86
+ data-hovered={isHovered || undefined}
87
+ onMouseEnter={() => onHover?.(id)}
88
+ onMouseLeave={() => onHover?.(null)}
89
+ className={cn('cursor-pointer transition-[filter] duration-150 ease-out', className)}
90
+ >
91
+ <MapMarkerContext.Provider value={state}>
92
+ {typeof children === 'function' ? children(state) : children}
93
+ </MapMarkerContext.Provider>
94
+ </button>
95
+ </Marker>
96
+ );
97
+ }
@@ -0,0 +1,87 @@
1
+ {
2
+ "$schema": "../../int-eslint-config/schemas/component.schema.json",
3
+ "component": "map-pin",
4
+ "purpose": "The default callout pin for a map marker: a pill showing a short value that expands to value and place name on hover or selection.",
5
+ "useWhen": [
6
+ "Marking a place on a web-map/map-view with a short value the user compares at a glance, such as a rating or a price",
7
+ "Marking named places where the name should appear only on hover or selection to keep the map uncluttered",
8
+ "Showing a pin outside a map (a legend or a key) in a fixed state"
9
+ ],
10
+ "avoidWhen": [
11
+ "Marking the user's own position — use web-map/user-location-pin instead",
12
+ "Points that need no text, such as dense clusters — draw a custom pin through web-map/map-marker's render function instead",
13
+ "Showing long text or actions about a place — show it in a list, card or panel beside the map instead"
14
+ ],
15
+ "uxPrinciples": [
16
+ "The resting pin shows only what users compare across places (the value); the name appears when the user shows interest",
17
+ "Selected is the strongest state: dark pill and primary-coloured text, so the chosen place stands out from hovered ones"
18
+ ],
19
+ "accessibility": {
20
+ "required": [
21
+ "Inside a web-map/map-marker the marker button carries the accessible name; keep the marker's label and the pin's label the same",
22
+ "Used outside a marker, the pin has no role or name of its own; give it an accessible context (e.g. a legend caption)"
23
+ ],
24
+ "keyboard": [
25
+ "None on the pin; it is not focusable. Keyboard interaction belongs to the enclosing marker button"
26
+ ],
27
+ "aria": [
28
+ "No role; the divider and the caret are aria-hidden",
29
+ "data-selected and data-hovered are set on the root for styling"
30
+ ],
31
+ "focus": ["The pin is never a tab stop"]
32
+ },
33
+ "behaviour": {
34
+ "states": ["default", "hovered", "selected"],
35
+ "rules": [
36
+ "isSelected and isHovered default to the enclosing web-map/map-marker's state; passing either explicitly overrides the marker",
37
+ "With a value, the resting pin shows the value only and expands to value | label on hover or selection; without a value (undefined, null or an empty string) the label is always shown",
38
+ "The label is truncated past 84px",
39
+ "The label's expand animation is turned off under prefers-reduced-motion",
40
+ "The palette is a set of --map-pin-* CSS custom properties on the root; override them through className to restyle the pin (e.g. [--map-pin-bg:#fff])",
41
+ "classNames targets single parts: root, pill, value, divider, label and caret"
42
+ ]
43
+ },
44
+ "composition": {
45
+ "allowedParents": ["web-map/map-marker"],
46
+ "allowedChildren": ["label (place name)", "value (short text such as a rating or price)"],
47
+ "avoidCombinations": [
48
+ "Interactive content in value — the pin sits inside the marker's button",
49
+ "A web-map/map-marker anchored at center around a MapPin — the caret points at the coordinate only with the default bottom anchor"
50
+ ],
51
+ "contexts": ["map overlay", "map legend"]
52
+ },
53
+ "content": {
54
+ "labelRules": [
55
+ "label is the place name, kept short; anything past 84px is truncated",
56
+ "value is a short lead the user compares across pins, e.g. a rating (4.5) or a price (CHF 180), never a sentence"
57
+ ],
58
+ "helperTextRules": [],
59
+ "errorRules": []
60
+ },
61
+ "aiDecisionRules": [
62
+ {
63
+ "condition": "The pin is inside a web-map/map-marker",
64
+ "decision": "Do not pass isSelected or isHovered",
65
+ "reason": "The pin reads the marker's state; explicit props would freeze it"
66
+ },
67
+ {
68
+ "condition": "The pin must match a different surface or brand",
69
+ "decision": "Override the --map-pin-* custom properties through className",
70
+ "reason": "The pin's colours are defined as those variables, so every state follows the override"
71
+ },
72
+ {
73
+ "condition": "Places have no comparable value",
74
+ "decision": "Omit value",
75
+ "reason": "The pin then shows the label at rest, so it is never an empty pill"
76
+ }
77
+ ],
78
+ "relatedComponents": ["web-map/map-marker", "web-map/map-view", "web-map/user-location-pin"],
79
+ "tokenReferences": {
80
+ "source": "packages/design-system/src/tailwind.css",
81
+ "categories": [
82
+ "--color-primary-main (selected text and divider)",
83
+ "--map-pin-* component custom properties"
84
+ ],
85
+ "rule": "Restyle through the --map-pin-* properties; the defaults are fixed Figma values, and only the selected state reads a design token."
86
+ }
87
+ }
@@ -0,0 +1,97 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { MapPin } from './map-pin';
4
+
5
+ const meta: Meta<typeof MapPin> = {
6
+ title: 'Map/MapPin',
7
+ component: MapPin,
8
+ tags: ['autodocs'],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="max-w-[420px] mx-auto flex items-center justify-center bg-[#f8f4f1] p-32 rounded-lg">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ args: {
17
+ label: 'Roma Pizza',
18
+ value: 4.5,
19
+ isSelected: false,
20
+ isHovered: false,
21
+ 'data-testid': 'map-pin',
22
+ },
23
+ argTypes: {
24
+ label: {
25
+ control: 'text',
26
+ description: 'Place name, shown on hover or selection, or always when there is no `value`.',
27
+ },
28
+ value: {
29
+ control: 'text',
30
+ description:
31
+ 'Short lead text always shown in the pill, e.g. a rating (`4.5`) or a price (`CHF 180`).',
32
+ },
33
+ isSelected: {
34
+ control: 'boolean',
35
+ description:
36
+ "Selected style: dark pill, primary text, label shown. Defaults to the enclosing `MapMarker`'s state.",
37
+ table: { defaultValue: { summary: 'false' } },
38
+ },
39
+ isHovered: {
40
+ control: 'boolean',
41
+ description:
42
+ "Hover style with the label expanded. Defaults to the enclosing `MapMarker`'s state.",
43
+ table: { defaultValue: { summary: 'false' } },
44
+ },
45
+ classNames: {
46
+ control: 'object',
47
+ description:
48
+ 'Extra classes per part: `root`, `pill`, `value`, `divider`, `label` and `caret`.',
49
+ },
50
+ 'data-testid': {
51
+ control: 'text',
52
+ description: 'Sets `data-testid` on the root.',
53
+ table: { defaultValue: { summary: 'map-pin' } },
54
+ },
55
+ className: {
56
+ control: 'text',
57
+ description:
58
+ 'Merged onto the root. Override the palette with CSS vars, e.g. `[--map-pin-bg:#fff]`.',
59
+ },
60
+ },
61
+ };
62
+
63
+ export default meta;
64
+
65
+ type Story = StoryObj<typeof MapPin>;
66
+
67
+ export const Default: Story = {};
68
+
69
+ export const Hovered: Story = {
70
+ args: { isHovered: true },
71
+ };
72
+
73
+ export const Selected: Story = {
74
+ args: { isSelected: true },
75
+ };
76
+
77
+ /** No value — the label is always shown. */
78
+ export const LabelOnly: Story = {
79
+ args: { value: undefined },
80
+ };
81
+
82
+ /** Palette overridden through the pin's CSS custom properties. */
83
+ export const Restyled: Story = {
84
+ args: {
85
+ isSelected: true,
86
+ value: 'CHF 180',
87
+ className: [
88
+ '[--map-pin-bg:#ffffff]',
89
+ '[--map-pin-bg-selected:#ffffff]',
90
+ '[--map-pin-overlay-selected:transparent]',
91
+ '[--map-pin-fg:#252931]',
92
+ '[--map-pin-fg-expanded:#252931]',
93
+ '[--map-pin-fg-selected:#0D212B]',
94
+ '[--map-pin-divider-selected:#BBC3CC]',
95
+ ].join(' '),
96
+ },
97
+ };
@@ -0,0 +1,174 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { Typography, cn } from '@swisper.ai/ui';
4
+
5
+ import { useMapMarker } from './map-marker';
6
+
7
+ /**
8
+ * Pin palette as CSS custom properties so a caller can restyle it with a class,
9
+ * e.g. `className="[--map-pin-bg:#fff] [--map-pin-fg:#000]"`.
10
+ * Defaults are the Figma PlacePointer values (brand/secondary/700, selected
11
+ * Pill #0D212B + 12% secondary overlay, hover 6% overlay).
12
+ */
13
+ const PIN_PALETTE = cn(
14
+ '[--map-pin-bg:#252931]',
15
+ '[--map-pin-bg-selected:#0D212B]',
16
+ '[--map-pin-overlay-hover:rgba(187,195,204,0.06)]',
17
+ '[--map-pin-overlay-selected:rgba(187,195,204,0.12)]',
18
+ '[--map-pin-fg:#BBC3CC]',
19
+ '[--map-pin-fg-expanded:#E4E8EB]',
20
+ '[--map-pin-fg-selected:var(--color-primary-main)]',
21
+ '[--map-pin-divider:rgba(187,195,204,0.6)]',
22
+ '[--map-pin-divider-selected:color-mix(in_srgb,var(--color-primary-main)_40%,transparent)]',
23
+ );
24
+
25
+ function overlay(color: string, base: string) {
26
+ return `linear-gradient(${color}, ${color}), ${base}`;
27
+ }
28
+
29
+ export interface MapPinClassNames {
30
+ root?: string;
31
+ pill?: string;
32
+ value?: string;
33
+ divider?: string;
34
+ label?: string;
35
+ caret?: string;
36
+ }
37
+
38
+ export interface MapPinProps {
39
+ /** Shown on hover / selection, or always when there is no `value`. */
40
+ label: string;
41
+ /** Short lead text always shown in the pill, e.g. a rating or a price. */
42
+ value?: ReactNode;
43
+ /** Defaults to the enclosing `MapMarker`'s state. */
44
+ isSelected?: boolean;
45
+ /** Defaults to the enclosing `MapMarker`'s state. */
46
+ isHovered?: boolean;
47
+ className?: string;
48
+ classNames?: MapPinClassNames;
49
+ 'data-testid'?: string;
50
+ }
51
+
52
+ /**
53
+ * Map callout pin: `value`-only by default; expands to `value | label` on
54
+ * hover/selection (Figma PlacePointer Default / Hover / selected Pill).
55
+ */
56
+ export function MapPin({
57
+ label,
58
+ value,
59
+ isSelected: isSelectedProp,
60
+ isHovered: isHoveredProp,
61
+ className,
62
+ classNames,
63
+ 'data-testid': dataTestId = 'map-pin',
64
+ }: MapPinProps) {
65
+ const marker = useMapMarker();
66
+ const isSelected = isSelectedProp ?? marker?.isSelected ?? false;
67
+ const isHovered = isHoveredProp ?? marker?.isHovered ?? false;
68
+
69
+ const isExpanded = isSelected || isHovered;
70
+ const hasValue = value != null && value !== '';
71
+ const showLabel = isExpanded || !hasValue;
72
+
73
+ const pillBg = isSelected
74
+ ? overlay('var(--map-pin-overlay-selected)', 'var(--map-pin-bg-selected)')
75
+ : isHovered
76
+ ? overlay('var(--map-pin-overlay-hover)', 'var(--map-pin-bg)')
77
+ : 'var(--map-pin-bg)';
78
+
79
+ const textClassName = isSelected
80
+ ? 'text-[color:var(--map-pin-fg-selected)]'
81
+ : isExpanded
82
+ ? 'text-[color:var(--map-pin-fg-expanded)]'
83
+ : 'text-[color:var(--map-pin-fg)]';
84
+
85
+ return (
86
+ <div
87
+ data-testid={dataTestId}
88
+ data-selected={isSelected || undefined}
89
+ data-hovered={isHovered || undefined}
90
+ className={cn(
91
+ PIN_PALETTE,
92
+ 'flex flex-col items-center isolate drop-shadow-[0px_1px_2px_rgba(228,232,235,0.3)]',
93
+ className,
94
+ classNames?.root,
95
+ )}
96
+ >
97
+ <div
98
+ className={cn(
99
+ 'relative z-2 flex h-[20px] w-full items-center overflow-hidden rounded-[8px] px-4 -mb-1.25',
100
+ classNames?.pill,
101
+ )}
102
+ style={{ background: pillBg }}
103
+ >
104
+ <div className="flex items-center px-4">
105
+ {hasValue && (
106
+ <Typography
107
+ variant="caption"
108
+ className={cn(
109
+ 'shrink-0 whitespace-nowrap leading-[1.3]',
110
+ textClassName,
111
+ classNames?.value,
112
+ )}
113
+ >
114
+ {value}
115
+ </Typography>
116
+ )}
117
+
118
+ {/* 0fr → 1fr animates to the label's intrinsic width; the inner
119
+ `w-max` keeps it from reflowing while the column collapses. */}
120
+ <div
121
+ className={cn(
122
+ 'grid self-stretch transition-[grid-template-columns] duration-200 ease-out motion-reduce:transition-none',
123
+ showLabel ? 'grid-cols-[1fr]' : 'grid-cols-[0fr]',
124
+ )}
125
+ >
126
+ <div className="flex min-w-0 items-center overflow-hidden">
127
+ <div className={cn('flex w-max items-center gap-4', hasValue && 'pl-4')}>
128
+ {hasValue && (
129
+ <span
130
+ aria-hidden="true"
131
+ className={cn(
132
+ 'w-px self-stretch shrink-0',
133
+ isSelected
134
+ ? 'bg-[color:var(--map-pin-divider-selected)]'
135
+ : 'bg-[color:var(--map-pin-divider)]',
136
+ classNames?.divider,
137
+ )}
138
+ />
139
+ )}
140
+
141
+ <Typography
142
+ variant="caption"
143
+ className={cn(
144
+ 'max-w-[84px] truncate whitespace-nowrap leading-[1.3] transition-opacity duration-200 ease-out motion-reduce:transition-none',
145
+ showLabel ? 'opacity-100' : 'opacity-0',
146
+ textClassName,
147
+ classNames?.label,
148
+ )}
149
+ >
150
+ {label}
151
+ </Typography>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ <div className="relative z-1 flex w-full items-center justify-center" aria-hidden="true">
159
+ <svg
160
+ width={8}
161
+ height={8}
162
+ viewBox="0 0 8 8"
163
+ fill="none"
164
+ className={cn('size-8 shrink-0', classNames?.caret)}
165
+ >
166
+ <path
167
+ d="M4 8L0 0H8L4 8Z"
168
+ fill={isSelected ? 'var(--map-pin-bg-selected)' : 'var(--map-pin-bg)'}
169
+ />
170
+ </svg>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "$schema": "../../int-eslint-config/schemas/component.schema.json",
3
+ "component": "map-user-location",
4
+ "purpose": "Draws the user's own position on a map once it is known, with UserLocationPin as the default marker.",
5
+ "useWhen": [
6
+ "Showing where the user is relative to the places on a web-map/map-view",
7
+ "The position comes from navigator.geolocation or another source that may resolve late or not at all"
8
+ ],
9
+ "avoidWhen": [
10
+ "Marking a place the user can select — use web-map/map-marker instead",
11
+ "Drawing the dot outside a map — use web-map/user-location-pin instead"
12
+ ],
13
+ "uxPrinciples": [
14
+ "Show nothing rather than a wrong position: no dot is drawn until coordinates are known",
15
+ "The user's position is a reference, not a choice: it is not selectable and does not move the map"
16
+ ],
17
+ "accessibility": {
18
+ "required": [
19
+ "Pass a translated label for the default pin; it is the dot's accessible name and tooltip",
20
+ "A custom marker passed as children needs its own accessible name",
21
+ "The default pin needs a ui/TooltipProvider above the map"
22
+ ],
23
+ "keyboard": ["None; the marker is not focusable"],
24
+ "aria": ["The default pin is role=img with aria-label=label"],
25
+ "focus": ["No tab stop"]
26
+ },
27
+ "behaviour": {
28
+ "states": ["unknown (renders nothing)", "known"],
29
+ "rules": [
30
+ "coordinates null or undefined renders nothing; any longitude/latitude pair draws the marker centred on it",
31
+ "children replaces the default web-map/user-location-pin; label is then ignored",
32
+ "It stacks at the same level as hovered and selected markers",
33
+ "It reads no MapView context, so it also works inside a web-map/base-map",
34
+ "It does not add the position to the MapView's fitPoints; include it there if the map should frame it"
35
+ ]
36
+ },
37
+ "composition": {
38
+ "allowedParents": ["web-map/map-view", "web-map/base-map"],
39
+ "allowedChildren": ["web-map/user-location-pin", "custom marker element"],
40
+ "avoidCombinations": [
41
+ "An interactive custom marker — the user's position is not a selectable place; use web-map/map-marker for those"
42
+ ],
43
+ "contexts": ["map overlay"]
44
+ },
45
+ "content": {
46
+ "labelRules": [
47
+ "label names the dot from the user's point of view, e.g. 'My location' (the default); pass it translated"
48
+ ],
49
+ "helperTextRules": [],
50
+ "errorRules": []
51
+ },
52
+ "aiDecisionRules": [
53
+ {
54
+ "condition": "Geolocation is pending or denied",
55
+ "decision": "Pass null and keep rendering the component",
56
+ "reason": "It renders nothing until coordinates arrive, so no conditional is needed and no dot lands at 0,0"
57
+ },
58
+ {
59
+ "condition": "The map should frame the user together with the places",
60
+ "decision": "Add the user's [longitude, latitude] to the MapView's fitPoints",
61
+ "reason": "MapUserLocation draws the dot but does not affect framing"
62
+ }
63
+ ],
64
+ "relatedComponents": [
65
+ "web-map/user-location-pin",
66
+ "web-map/map-view",
67
+ "web-map/map-marker",
68
+ "ui/tooltip"
69
+ ]
70
+ }
@@ -0,0 +1,69 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { TooltipProvider } from '@swisper.ai/ui';
4
+
5
+ import { USER_LOCATION } from './__storybook__/fixtures';
6
+ import { MapUserLocation } from './map-user-location';
7
+ import { MapView } from './map-view';
8
+
9
+ const meta: Meta<typeof MapUserLocation> = {
10
+ title: 'Map/MapUserLocation',
11
+ component: MapUserLocation,
12
+ tags: ['autodocs'],
13
+ decorators: [
14
+ (Story) => (
15
+ <TooltipProvider>
16
+ <div className="h-[500px] w-[700px]">
17
+ <MapView
18
+ testId="map-user-location-story"
19
+ ariaLabel="Your location"
20
+ fitPoints={[[USER_LOCATION.longitude, USER_LOCATION.latitude]]}
21
+ >
22
+ <Story />
23
+ </MapView>
24
+ </div>
25
+ </TooltipProvider>
26
+ ),
27
+ ],
28
+ args: {
29
+ coordinates: USER_LOCATION,
30
+ label: 'My location',
31
+ },
32
+ argTypes: {
33
+ coordinates: {
34
+ control: 'object',
35
+ description:
36
+ "The user's `longitude` and `latitude`, e.g. from `navigator.geolocation`. Nothing is drawn while it is `null` or `undefined`.",
37
+ },
38
+ label: {
39
+ control: 'text',
40
+ description:
41
+ 'Tooltip and accessible name of the default `UserLocationPin`; ignored when `children` is set. Pass a translated string.',
42
+ table: { defaultValue: { summary: 'My location' } },
43
+ },
44
+ children: {
45
+ control: false,
46
+ description: 'Custom marker drawn at the position instead of the default `UserLocationPin`.',
47
+ },
48
+ },
49
+ };
50
+
51
+ export default meta;
52
+
53
+ type Story = StoryObj<typeof MapUserLocation>;
54
+
55
+ export const Default: Story = {};
56
+
57
+ /** Until the position is known (`null` / `undefined`) nothing is drawn. */
58
+ export const Unknown: Story = {
59
+ args: { coordinates: null },
60
+ };
61
+
62
+ /** Any element passed as `children` replaces the default `UserLocationPin`. */
63
+ export const CustomMarker: Story = {
64
+ args: {
65
+ children: (
66
+ <span className="block size-24 rounded-full border-2 border-white bg-[#EB0000]/70 shadow" />
67
+ ),
68
+ },
69
+ };
@@ -0,0 +1,29 @@
1
+ import { Marker } from '@vis.gl/react-maplibre';
2
+ import type { ReactNode } from 'react';
3
+
4
+ import { UserLocationPin } from './user-location-pin';
5
+
6
+ export interface MapUserLocationProps {
7
+ coordinates: Pick<GeolocationCoordinates, 'longitude' | 'latitude'> | null | undefined;
8
+ /** Custom marker; defaults to `UserLocationPin`. */
9
+ children?: ReactNode;
10
+ /** Label of the default `UserLocationPin`; ignored when `children` is set. */
11
+ label?: string;
12
+ }
13
+
14
+ /** The user's own position on a `MapView`; renders nothing until it is known. */
15
+ export function MapUserLocation({ coordinates, children, label }: MapUserLocationProps) {
16
+ if (!coordinates) return null;
17
+
18
+ return (
19
+ <Marker
20
+ longitude={coordinates.longitude}
21
+ latitude={coordinates.latitude}
22
+ anchor="center"
23
+ // At the same level as the hovered and selected pins.
24
+ style={{ zIndex: 3 }}
25
+ >
26
+ {children ?? <UserLocationPin label={label} />}
27
+ </Marker>
28
+ );
29
+ }