@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,112 @@
1
+ {
2
+ "$schema": "../../int-eslint-config/schemas/component.schema.json",
3
+ "component": "map-view",
4
+ "purpose": "Interactive map root that frames a set of points and shares controlled selection and hover state with the MapMarkers drawn inside it.",
5
+ "useWhen": [
6
+ "Showing a set of places (search results, venues, offices) as selectable pins on a map",
7
+ "A map is paired with a list or cards and selecting in one must highlight the other",
8
+ "The map should frame all given points automatically on load and when the points change"
9
+ ],
10
+ "avoidWhen": [
11
+ "Drawing only custom layers, sources or plain markers with no selection — use web-map/base-map instead",
12
+ "Showing a single static address with no interaction — a written address or a link to an external map is lighter, since the map engine is loaded on first render",
13
+ "Listing places where location is not the deciding information — use ui/list instead"
14
+ ],
15
+ "uxPrinciples": [
16
+ "Selection is one shared state: the map, the pins and any list beside it read the same selectedId, so choosing a place anywhere highlights it everywhere",
17
+ "A place chosen outside the map is eased into view, so the user never selects something they cannot see",
18
+ "Frame the points the user cares about; don't open on an arbitrary world view when there is data"
19
+ ],
20
+ "accessibility": {
21
+ "required": [
22
+ "Pass an ariaLabel that says what the map shows (e.g. 'Restaurants near you'), not just 'Map'",
23
+ "Give every MapMarker a label, since it is the accessible name of the marker button",
24
+ "Offer the same places in a non-map form (a list or cards) alongside, because panning and zooming the map is not usable by every user"
25
+ ],
26
+ "keyboard": [
27
+ "Tab moves through the marker buttons; Enter or Space selects the focused marker",
28
+ "MapLibre's default keyboard handling applies when the map canvas has focus: arrow keys pan, + and - zoom"
29
+ ],
30
+ "aria": [
31
+ "The root is role=application with aria-label=ariaLabel",
32
+ "aria-invalid is set on the root while hasError is true",
33
+ "Each MapMarker renders a button with aria-pressed reflecting selection"
34
+ ],
35
+ "focus": [
36
+ "Marker buttons are the tab stops inside the map; the map does not move focus on selection",
37
+ "Hover state is driven by pointer enter/leave only, not by keyboard focus"
38
+ ]
39
+ },
40
+ "behaviour": {
41
+ "states": ["default", "loading", "error", "empty (no fitPoints)"],
42
+ "rules": [
43
+ "fitPoints is framed on load and every time a new array is passed; one point zooms to SINGLE_POINT_ZOOM, an empty array shows the whole world",
44
+ "fitPoints is compared by identity: memoise it, or every re-render (e.g. from a selection or hover change) re-frames the map and undoes the user's pan and zoom",
45
+ "selectedId and hoveredId are controlled: MapView only reports clicks and pointer enter/leave through onSelect and onHover; the parent stores the id and passes it back",
46
+ "Clicking a marker does not reach the map, and MapView never clears the selection on a map click; deselecting is up to the parent",
47
+ "flyToSelected=true eases the map to a marker when it becomes selected; a MapMarker's flyToOnSelect overrides it per marker",
48
+ "isLoading hides the map tiles behind the plain background without showing a spinner; hasError hides the map, disables pointer input and shows errorLabel over an error tint",
49
+ "MapView fills its parent (size-full): the parent or className must give it an explicit height",
50
+ "The ids passed to MapMarker are generic (string or number); onSelect and onHover only ever return ids the caller passed in"
51
+ ]
52
+ },
53
+ "composition": {
54
+ "allowedParents": [],
55
+ "allowedChildren": [
56
+ "web-map/map-marker",
57
+ "web-map/map-user-location",
58
+ "web-map/Source",
59
+ "web-map/Layer",
60
+ "web-map/Marker",
61
+ "custom children that read web-map/useMapView"
62
+ ],
63
+ "avoidCombinations": [
64
+ "web-map/map-marker outside a MapView — it throws, because it reads the MapView context",
65
+ "A MapView without a sized parent — it collapses to zero height"
66
+ ],
67
+ "contexts": [
68
+ "page section with an explicit height",
69
+ "split view beside a results list or cards",
70
+ "detail panel"
71
+ ]
72
+ },
73
+ "content": {
74
+ "labelRules": [
75
+ "ariaLabel describes the content of the map ('Restaurants near you', 'Office locations'), not the widget"
76
+ ],
77
+ "helperTextRules": [],
78
+ "errorRules": [
79
+ "errorLabel says the map could not load and what to do next, in one short sentence (default: 'Could not load map. Try again later.')",
80
+ "Pass errorLabel already translated; the package has no i18n dependency"
81
+ ]
82
+ },
83
+ "aiDecisionRules": [
84
+ {
85
+ "condition": "The places on the map are also listed beside it",
86
+ "decision": "Keep selectedId and hoveredId in the shared parent and pass them to both the MapView and the list",
87
+ "reason": "Both views stay in sync and a selection from the list eases the marker into view"
88
+ },
89
+ {
90
+ "condition": "fitPoints is derived from data (places.map(...))",
91
+ "decision": "Wrap it in useMemo keyed on the data",
92
+ "reason": "A new array on every render re-frames the map and snaps it back while the user pans"
93
+ },
94
+ {
95
+ "condition": "The map only needs custom sources, layers or markers without selection",
96
+ "decision": "Use web-map/base-map with initialViewState and bounds",
97
+ "reason": "MapView's selection context is unused weight, and MapMarker would not be needed"
98
+ },
99
+ {
100
+ "condition": "The map style must come from configuration",
101
+ "decision": "Pass mapStyleUrl explicitly from the app",
102
+ "reason": "The library does not read env vars such as VITE_MAP_STYLE_URL; without the prop it uses OpenFreeMap Liberty"
103
+ }
104
+ ],
105
+ "relatedComponents": [
106
+ "web-map/base-map",
107
+ "web-map/map-marker",
108
+ "web-map/map-pin",
109
+ "web-map/map-user-location",
110
+ "ui/list"
111
+ ]
112
+ }
@@ -0,0 +1,174 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+
4
+ import { TooltipProvider } from '@swisper.ai/ui';
5
+
6
+ import { PLACE_POINTS, PLACES, USER_LOCATION } from './__storybook__/fixtures';
7
+ import { DEFAULT_FIT_BOUNDS_PADDING, DEFAULT_MAP_STYLE_URL } from './constants';
8
+ import { MapMarker } from './map-marker';
9
+ import { MapPin } from './map-pin';
10
+ import { MapUserLocation } from './map-user-location';
11
+ import { MapView, type MapViewProps } from './map-view';
12
+
13
+ function MapViewDemo(args: Partial<MapViewProps<string>>) {
14
+ const [selectedId, setSelectedId] = useState<string | null>(null);
15
+ const [hoveredId, setHoveredId] = useState<string | null>(null);
16
+
17
+ return (
18
+ <MapView<string>
19
+ testId="map-view-story"
20
+ ariaLabel="Restaurants near you"
21
+ fitPoints={PLACE_POINTS}
22
+ selectedId={selectedId}
23
+ hoveredId={hoveredId}
24
+ onSelect={setSelectedId}
25
+ onHover={setHoveredId}
26
+ {...args}
27
+ >
28
+ {PLACES.map((place) => (
29
+ <MapMarker
30
+ key={place.id}
31
+ id={place.id}
32
+ longitude={place.lngLat[0]}
33
+ latitude={place.lngLat[1]}
34
+ label={place.label}
35
+ >
36
+ <MapPin label={place.label} value={place.value} />
37
+ </MapMarker>
38
+ ))}
39
+ <MapUserLocation coordinates={USER_LOCATION} />
40
+ </MapView>
41
+ );
42
+ }
43
+
44
+ const meta: Meta<typeof MapViewDemo> = {
45
+ title: 'Map/MapView',
46
+ component: MapViewDemo,
47
+ tags: ['autodocs'],
48
+ decorators: [
49
+ (Story) => (
50
+ <TooltipProvider>
51
+ <div className="h-[600px] w-[800px]">
52
+ <Story />
53
+ </div>
54
+ </TooltipProvider>
55
+ ),
56
+ ],
57
+ // `selectedId`, `hoveredId`, `onSelect` and `onHover` stay out of meta args: the demo owns them
58
+ // in state, and an arg would override it and break selecting a pin. Callbacks are logged through
59
+ // their `action` argTypes (`storybook/test` is not a dependency here).
60
+ args: {
61
+ testId: 'map-view-story',
62
+ ariaLabel: 'Restaurants near you',
63
+ fitPoints: PLACE_POINTS,
64
+ flyToSelected: true,
65
+ fitBoundsPadding: DEFAULT_FIT_BOUNDS_PADDING,
66
+ isLoading: false,
67
+ hasError: false,
68
+ errorLabel: 'Could not load map. Try again later.',
69
+ },
70
+ argTypes: {
71
+ fitPoints: {
72
+ control: 'object',
73
+ description:
74
+ '`[lng, lat]` points the map frames on load and whenever they change; empty shows the whole world.',
75
+ },
76
+ selectedId: {
77
+ control: false,
78
+ description:
79
+ "Id of the selected `MapMarker`, controlled by the parent. Owned by the demo's state here.",
80
+ table: { defaultValue: { summary: 'null' } },
81
+ },
82
+ hoveredId: {
83
+ control: false,
84
+ description:
85
+ "Id of the hovered `MapMarker`, controlled by the parent. Owned by the demo's state here.",
86
+ table: { defaultValue: { summary: 'null' } },
87
+ },
88
+ onSelect: {
89
+ control: false,
90
+ description:
91
+ "Called with a marker's id when it is clicked; store it and pass it back as `selectedId`.",
92
+ },
93
+ onHover: {
94
+ control: false,
95
+ description:
96
+ "Called with a marker's id on pointer enter and `null` on leave; pass it back as `hoveredId`.",
97
+ },
98
+ flyToSelected: {
99
+ control: 'boolean',
100
+ description:
101
+ 'Eases to a marker when it becomes selected, so one chosen from outside the map is never off screen.',
102
+ table: { defaultValue: { summary: 'true' } },
103
+ },
104
+ testId: {
105
+ control: 'text',
106
+ description: 'Sets `data-testid` on the map root.',
107
+ },
108
+ ariaLabel: {
109
+ control: 'text',
110
+ description: 'Accessible name of the map region; describe what it shows.',
111
+ },
112
+ isLoading: {
113
+ control: 'boolean',
114
+ description: 'Hides the map tiles behind the background while its data is loading.',
115
+ table: { defaultValue: { summary: 'false' } },
116
+ },
117
+ hasError: {
118
+ control: 'boolean',
119
+ description: 'Hides the map, disables interaction and shows `errorLabel`.',
120
+ table: { defaultValue: { summary: 'false' } },
121
+ },
122
+ errorLabel: {
123
+ control: 'text',
124
+ description: 'Message shown when `hasError` is set; pass a translated string.',
125
+ table: { defaultValue: { summary: 'Could not load map. Try again later.' } },
126
+ },
127
+ fitBoundsPadding: {
128
+ control: 'number',
129
+ description: 'Padding in px kept around `fitPoints` when framing them.',
130
+ table: { defaultValue: { summary: String(DEFAULT_FIT_BOUNDS_PADDING) } },
131
+ },
132
+ mapStyleUrl: {
133
+ control: 'text',
134
+ description: 'Style JSON URL; defaults to OpenFreeMap Liberty.',
135
+ table: { defaultValue: { summary: DEFAULT_MAP_STYLE_URL } },
136
+ },
137
+ children: {
138
+ control: false,
139
+ description: 'The `MapMarker`s and `MapUserLocation` drawn on the map.',
140
+ },
141
+ className: {
142
+ control: 'text',
143
+ description: 'Extra classes merged onto the root.',
144
+ },
145
+ onLoad: {
146
+ action: 'loaded',
147
+ description: 'Called once the map style has loaded and `fitPoints` are framed.',
148
+ },
149
+ },
150
+ };
151
+
152
+ export default meta;
153
+
154
+ type Story = StoryObj<typeof MapViewDemo>;
155
+
156
+ export const Default: Story = {};
157
+
158
+ export const Loading: Story = {
159
+ args: { isLoading: true },
160
+ };
161
+
162
+ export const WithError: Story = {
163
+ args: { hasError: true },
164
+ };
165
+
166
+ /** A translated error message passed through `errorLabel`. */
167
+ export const CustomErrorLabel: Story = {
168
+ args: { hasError: true, errorLabel: 'Karte konnte nicht geladen werden.' },
169
+ };
170
+
171
+ /** With no `fitPoints` there is nothing to frame, so the map shows the whole world. */
172
+ export const EmptyPoints: Story = {
173
+ args: { fitPoints: [] },
174
+ };
@@ -0,0 +1,86 @@
1
+ import type { MapRef } from '@vis.gl/react-maplibre';
2
+ import { useMemo, useRef, type ReactNode } from 'react';
3
+
4
+ import { BaseMap } from './base-map';
5
+ import { MapViewContext, type MapItemId, type MapViewContextValue } from './map-context';
6
+ import {
7
+ getBoundsCentroid,
8
+ getBoundsFromPoints,
9
+ type MapLngLat,
10
+ } from './utils/get-bounds-from-points';
11
+
12
+ export interface MapViewProps<TId extends MapItemId = MapItemId> {
13
+ /** Points the map frames on load and whenever they change. */
14
+ fitPoints: MapLngLat[];
15
+ selectedId?: TId | null;
16
+ hoveredId?: TId | null;
17
+ onSelect?: (id: TId) => void;
18
+ onHover?: (id: TId | null) => void;
19
+ /** Ease to a marker when it becomes selected, so it is never off screen. */
20
+ flyToSelected?: boolean;
21
+ children?: ReactNode;
22
+ testId: string;
23
+ ariaLabel: string;
24
+ className?: string;
25
+ hasError?: boolean;
26
+ isLoading?: boolean;
27
+ fitBoundsPadding?: number;
28
+ mapStyleUrl?: string;
29
+ onLoad?: () => void;
30
+ /** Shown when `hasError` is set; pass a translated string. */
31
+ errorLabel?: string;
32
+ }
33
+
34
+ /**
35
+ * Interactive map root: frames `fitPoints` and shares selection / hover state
36
+ * with the `MapMarker`s composed inside it.
37
+ */
38
+ export function MapView<TId extends MapItemId = MapItemId>({
39
+ fitPoints,
40
+ selectedId = null,
41
+ hoveredId = null,
42
+ onSelect,
43
+ onHover,
44
+ flyToSelected = true,
45
+ children,
46
+ ...baseMapProps
47
+ }: MapViewProps<TId>) {
48
+ const mapRef = useRef<MapRef>(null);
49
+
50
+ const bounds = useMemo(() => getBoundsFromPoints(fitPoints), [fitPoints]);
51
+
52
+ const initialViewState = useMemo(() => {
53
+ if (bounds) {
54
+ // With a bounds fit called on load/change we still need an initial
55
+ // center or MapGL defaults to null-island. Use the geographic centroid.
56
+ return { center: getBoundsCentroid(bounds), zoom: 12 };
57
+ }
58
+ return { center: [0, 0] as MapLngLat, zoom: 2 };
59
+ }, [bounds]);
60
+
61
+ const value = useMemo<MapViewContextValue>(
62
+ () => ({
63
+ mapRef,
64
+ selectedId,
65
+ hoveredId,
66
+ flyToSelected,
67
+ // Markers deal in the widened id type; the caller only ever gets back ids it passed in.
68
+ onSelect: onSelect as MapViewContextValue['onSelect'],
69
+ onHover: onHover as MapViewContextValue['onHover'],
70
+ }),
71
+ [selectedId, hoveredId, flyToSelected, onSelect, onHover],
72
+ );
73
+
74
+ return (
75
+ <MapViewContext.Provider value={value}>
76
+ <BaseMap
77
+ {...baseMapProps}
78
+ initialViewState={initialViewState}
79
+ bounds={bounds}
80
+ mapRef={mapRef}
81
+ >
82
+ {children}
83
+ </BaseMap>
84
+ </MapViewContext.Provider>
85
+ );
86
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "../../int-eslint-config/schemas/component.schema.json",
3
+ "component": "user-location-pin",
4
+ "purpose": "The marker for the user's own position: a blue dot with a white ring, with a tooltip and accessible name.",
5
+ "useWhen": [
6
+ "Rendering the default user-position marker, which web-map/map-user-location does for you",
7
+ "Showing the user-position dot outside a map, such as in a map legend"
8
+ ],
9
+ "avoidWhen": [
10
+ "Placing it on a map directly — wrap it in web-map/map-user-location, which positions it and hides it while the position is unknown",
11
+ "Marking places — use web-map/map-pin inside web-map/map-marker instead"
12
+ ],
13
+ "uxPrinciples": ["One distinct look for 'you are here' that is never confused with a place pin"],
14
+ "accessibility": {
15
+ "required": [
16
+ "Pass a translated label; it is both the accessible name and the tooltip text",
17
+ "Render it under a ui/TooltipProvider; the tooltip requires one"
18
+ ],
19
+ "keyboard": ["None; the dot is not focusable, so its tooltip opens on pointer hover only"],
20
+ "aria": ["role=img with aria-label=label"],
21
+ "focus": ["No tab stop"]
22
+ },
23
+ "behaviour": {
24
+ "states": ["default", "tooltip open on hover"],
25
+ "rules": [
26
+ "Hovering the dot shows label in a ui/tooltip",
27
+ "className is merged onto the dot, so it can change its size or colour"
28
+ ]
29
+ },
30
+ "composition": {
31
+ "allowedParents": ["web-map/map-user-location"],
32
+ "allowedChildren": [],
33
+ "avoidCombinations": [
34
+ "Inside a web-map/map-marker — the user's position is not a selectable place"
35
+ ],
36
+ "contexts": ["map overlay", "map legend"]
37
+ },
38
+ "content": {
39
+ "labelRules": [
40
+ "label names the dot from the user's point of view, e.g. 'My location' (the default); pass it translated"
41
+ ],
42
+ "helperTextRules": [],
43
+ "errorRules": []
44
+ },
45
+ "aiDecisionRules": [
46
+ {
47
+ "condition": "The user's position should appear on a map",
48
+ "decision": "Render web-map/map-user-location with the coordinates rather than this pin directly",
49
+ "reason": "MapUserLocation positions the dot and renders nothing until the position is known"
50
+ },
51
+ {
52
+ "condition": "The app has no ui/TooltipProvider above the map",
53
+ "decision": "Add one",
54
+ "reason": "The pin's tooltip needs a provider"
55
+ }
56
+ ],
57
+ "relatedComponents": ["web-map/map-user-location", "web-map/map-pin", "ui/tooltip"]
58
+ }
@@ -0,0 +1,43 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { UserLocationPin } from './user-location-pin';
4
+
5
+ const meta: Meta<typeof UserLocationPin> = {
6
+ title: 'Map/UserLocationPin',
7
+ component: UserLocationPin,
8
+ tags: ['autodocs'],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="max-w-[420px] mx-auto flex items-center justify-center bg-[#252931] p-32 rounded-lg">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ args: {
17
+ className: '',
18
+ label: 'My location',
19
+ },
20
+ argTypes: {
21
+ label: {
22
+ control: 'text',
23
+ description: 'Tooltip and accessible name of the dot; pass a translated string.',
24
+ table: { defaultValue: { summary: 'My location' } },
25
+ },
26
+ className: {
27
+ control: 'text',
28
+ description: 'Extra classes merged onto the dot — use to override its size or colour.',
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+
35
+ type Story = StoryObj<typeof UserLocationPin>;
36
+
37
+ export const Default: Story = {};
38
+
39
+ export const Playground: Story = {
40
+ args: {
41
+ className: 'size-16',
42
+ },
43
+ };
@@ -0,0 +1,27 @@
1
+ import { Tooltip, cn } from '@swisper.ai/ui';
2
+
3
+ export interface UserLocationPinProps {
4
+ className?: string;
5
+ /** Tooltip and accessible name; pass a translated string. */
6
+ label?: string;
7
+ }
8
+
9
+ /** Map marker for the user's own position: a blue dot with a white ring. */
10
+ export function UserLocationPin({ className, label = 'My location' }: UserLocationPinProps) {
11
+ return (
12
+ <Tooltip tooltip={label}>
13
+ <div
14
+ data-testid="user-location-pin"
15
+ // The dot is a bare `div`, so without a label it is invisible to
16
+ // screen readers — the tooltip only covers pointer users.
17
+ role="img"
18
+ aria-label={label}
19
+ className={cn(
20
+ 'size-3.5 rounded-full bg-blue-500 ring-2 ring-white',
21
+ 'shadow-[0px_1px_3px_rgba(0,0,0,0.35)]',
22
+ className,
23
+ )}
24
+ />
25
+ </Tooltip>
26
+ );
27
+ }
@@ -0,0 +1,39 @@
1
+ export type MapLngLat = [number, number];
2
+ export type MapBounds = [MapLngLat, MapLngLat];
3
+
4
+ /**
5
+ * Computes a southwest/northeast bounding box from lng/lat points.
6
+ * Returns null when there are no points.
7
+ */
8
+ export function getBoundsFromPoints(points: MapLngLat[]): MapBounds | null {
9
+ if (points.length === 0) {
10
+ return null;
11
+ }
12
+
13
+ let minLng = points[0][0];
14
+ let minLat = points[0][1];
15
+ let maxLng = points[0][0];
16
+ let maxLat = points[0][1];
17
+
18
+ for (const [lng, lat] of points) {
19
+ minLng = Math.min(minLng, lng);
20
+ minLat = Math.min(minLat, lat);
21
+ maxLng = Math.max(maxLng, lng);
22
+ maxLat = Math.max(maxLat, lat);
23
+ }
24
+
25
+ return [
26
+ [minLng, minLat],
27
+ [maxLng, maxLat],
28
+ ];
29
+ }
30
+
31
+ export function getBoundsCentroid(bounds: MapBounds): MapLngLat {
32
+ const [[minLng, minLat], [maxLng, maxLat]] = bounds;
33
+ return [(minLng + maxLng) / 2, (minLat + maxLat) / 2];
34
+ }
35
+
36
+ export function isDegenerateBounds(bounds: MapBounds): boolean {
37
+ const [[minLng, minLat], [maxLng, maxLat]] = bounds;
38
+ return minLng === maxLng && minLat === maxLat;
39
+ }
@@ -0,0 +1,2 @@
1
+ // The string is produced at build time by `vite-plugin-maplibre-worker.ts`.
2
+ export { default } from 'virtual:maplibre-worker-source';
@@ -0,0 +1,5 @@
1
+ declare module 'virtual:maplibre-worker-source' {
2
+ /** maplibre's worker as one self-contained script. */
3
+ const source: string;
4
+ export default source;
5
+ }
package/tailwind.css ADDED
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Tailwind v4 sources for apps that use @swisper.ai/web-map:
3
+ *
4
+ * @import "@swisper.ai/web-map/tailwind.css";
5
+ *
6
+ * Tailwind skips node_modules by default, so without this the package's class names
7
+ * are never generated and its components render without layout or colors.
8
+ */
9
+ @source "./dist";