@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.
- package/LICENSE +202 -0
- package/README.md +134 -0
- package/dist/base-map.d.ts +28 -0
- package/dist/base-map.d.ts.map +1 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/index.css +2 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.es.js +283 -0
- package/dist/index.es.js.map +1 -0
- package/dist/load-map-lib.d.ts +13 -0
- package/dist/load-map-lib.d.ts.map +1 -0
- package/dist/map-context.d.ts +14 -0
- package/dist/map-context.d.ts.map +1 -0
- package/dist/map-marker.d.ts +26 -0
- package/dist/map-marker.d.ts.map +1 -0
- package/dist/map-pin.d.ts +28 -0
- package/dist/map-pin.d.ts.map +1 -0
- package/dist/map-user-location.d.ts +11 -0
- package/dist/map-user-location.d.ts.map +1 -0
- package/dist/map-view.d.ts +30 -0
- package/dist/map-view.d.ts.map +1 -0
- package/dist/maplibre-worker-source-DgoyX01U.js +4 -0
- package/dist/user-location-pin.d.ts +8 -0
- package/dist/user-location-pin.d.ts.map +1 -0
- package/dist/utils/get-bounds-from-points.d.ts +10 -0
- package/dist/utils/get-bounds-from-points.d.ts.map +1 -0
- package/dist/worker/maplibre-worker-source.d.ts +2 -0
- package/dist/worker/maplibre-worker-source.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/__storybook__/fixtures.ts +19 -0
- package/src/__tests__/load-map-lib.spec.ts +57 -0
- package/src/__tests__/map-marker.spec.tsx +153 -0
- package/src/__tests__/map-pin.spec.tsx +82 -0
- package/src/__tests__/map-user-location.spec.tsx +48 -0
- package/src/__tests__/maplibre-worker-source.stub.ts +2 -0
- package/src/__tests__/setup.ts +13 -0
- package/src/base-map.json +89 -0
- package/src/base-map.stories.tsx +123 -0
- package/src/base-map.tsx +137 -0
- package/src/constants.ts +11 -0
- package/src/index.css +1 -0
- package/src/index.ts +32 -0
- package/src/load-map-lib.ts +25 -0
- package/src/map-context.tsx +25 -0
- package/src/map-marker.json +84 -0
- package/src/map-marker.stories.tsx +228 -0
- package/src/map-marker.tsx +97 -0
- package/src/map-pin.json +87 -0
- package/src/map-pin.stories.tsx +97 -0
- package/src/map-pin.tsx +174 -0
- package/src/map-user-location.json +70 -0
- package/src/map-user-location.stories.tsx +69 -0
- package/src/map-user-location.tsx +29 -0
- package/src/map-view.json +112 -0
- package/src/map-view.stories.tsx +174 -0
- package/src/map-view.tsx +86 -0
- package/src/user-location-pin.json +58 -0
- package/src/user-location-pin.stories.tsx +43 -0
- package/src/user-location-pin.tsx +27 -0
- package/src/utils/get-bounds-from-points.ts +39 -0
- package/src/worker/maplibre-worker-source.ts +2 -0
- package/src/worker/virtual-maplibre-worker-source.d.ts +5 -0
- package/tailwind.css +9 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface UserLocationPinProps {
|
|
2
|
+
className?: string;
|
|
3
|
+
/** Tooltip and accessible name; pass a translated string. */
|
|
4
|
+
label?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Map marker for the user's own position: a blue dot with a white ring. */
|
|
7
|
+
export declare function UserLocationPin({ className, label }: UserLocationPinProps): import('react').JSX.Element;
|
|
8
|
+
//# sourceMappingURL=user-location-pin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-location-pin.d.ts","sourceRoot":"","sources":["../src/user-location-pin.tsx"],"names":[],"mappings":"AAEA,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,EAAE,SAAS,EAAE,KAAqB,EAAE,EAAE,oBAAoB,+BAiBzF"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type MapLngLat = [number, number];
|
|
2
|
+
export type MapBounds = [MapLngLat, MapLngLat];
|
|
3
|
+
/**
|
|
4
|
+
* Computes a southwest/northeast bounding box from lng/lat points.
|
|
5
|
+
* Returns null when there are no points.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getBoundsFromPoints(points: MapLngLat[]): MapBounds | null;
|
|
8
|
+
export declare function getBoundsCentroid(bounds: MapBounds): MapLngLat;
|
|
9
|
+
export declare function isDegenerateBounds(bounds: MapBounds): boolean;
|
|
10
|
+
//# sourceMappingURL=get-bounds-from-points.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"get-bounds-from-points.d.ts","sourceRoot":"","sources":["../../src/utils/get-bounds-from-points.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AACzC,MAAM,MAAM,SAAS,GAAG,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;AAE/C;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,SAAS,GAAG,IAAI,CAqBzE;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,CAG9D;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAG7D"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"maplibre-worker-source.d.ts","sourceRoot":"","sources":["../../src/worker/maplibre-worker-source.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@swisper.ai/web-map",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Interactive MapLibre maps with selectable markers for Swisper web apps",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"map",
|
|
8
|
+
"maplibre",
|
|
9
|
+
"design-system"
|
|
10
|
+
],
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"author": "Fintama AG",
|
|
13
|
+
"homepage": "https://fintama.github.io/fintama/",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.es.js",
|
|
16
|
+
"module": "./dist/index.es.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"source": "./src/index.ts",
|
|
19
|
+
"style": "./dist/index.css",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"source": "./src/index.ts",
|
|
24
|
+
"style": "./dist/index.css",
|
|
25
|
+
"import": "./dist/index.es.js",
|
|
26
|
+
"require": "./dist/index.es.js"
|
|
27
|
+
},
|
|
28
|
+
"./index.css": {
|
|
29
|
+
"style": "./dist/index.css",
|
|
30
|
+
"import": "./dist/index.css",
|
|
31
|
+
"require": "./dist/index.css"
|
|
32
|
+
},
|
|
33
|
+
"./tailwind.css": {
|
|
34
|
+
"style": "./tailwind.css",
|
|
35
|
+
"import": "./tailwind.css",
|
|
36
|
+
"require": "./tailwind.css"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src",
|
|
42
|
+
"tailwind.css"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@swisper.ai/icons": "0.0.2",
|
|
46
|
+
"@swisper.ai/tailwind-config": "0.0.2",
|
|
47
|
+
"@swisper.ai/ui": "0.0.2",
|
|
48
|
+
"@swisper.ai/web-core": "0.0.2",
|
|
49
|
+
"@vis.gl/react-maplibre": "^8.1.3",
|
|
50
|
+
"maplibre-gl": "^6.4.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@fintama/vite-config": "0.1.1",
|
|
54
|
+
"@fintama/vitest-config": "0.1.1",
|
|
55
|
+
"@storybook/react-vite": "10.6.0",
|
|
56
|
+
"@swisper.ai/int-eslint-config": "0.0.1",
|
|
57
|
+
"@swisper.ai/int-typescript-config": "0.0.1",
|
|
58
|
+
"@tailwindcss/cli": "4.3.3",
|
|
59
|
+
"@tailwindcss/vite": "4.3.3",
|
|
60
|
+
"@testing-library/dom": "10.4.1",
|
|
61
|
+
"@testing-library/react": "16.3.3",
|
|
62
|
+
"@types/node": "24.19.0",
|
|
63
|
+
"@types/react": "18.3.31",
|
|
64
|
+
"@types/react-dom": "18.3.7",
|
|
65
|
+
"@typescript/typescript6": "6.0.2",
|
|
66
|
+
"concurrently": "^9.1.2",
|
|
67
|
+
"eslint": "10.9.1",
|
|
68
|
+
"jsdom": "30.1.1",
|
|
69
|
+
"react": "18.3.1",
|
|
70
|
+
"react-dom": "18.3.1",
|
|
71
|
+
"rimraf": "^6.0.1",
|
|
72
|
+
"rollup-plugin-visualizer": "7.1.1",
|
|
73
|
+
"storybook": "10.6.0",
|
|
74
|
+
"tailwindcss": "4.3.3",
|
|
75
|
+
"typescript": "7.0.2",
|
|
76
|
+
"vite": "8.3.1",
|
|
77
|
+
"vite-plugin-dts": "5.1.1",
|
|
78
|
+
"vitest": "4.1.11"
|
|
79
|
+
},
|
|
80
|
+
"peerDependencies": {
|
|
81
|
+
"react": ">=18.0.0",
|
|
82
|
+
"react-dom": ">=18.0.0"
|
|
83
|
+
},
|
|
84
|
+
"publishConfig": {
|
|
85
|
+
"access": "public"
|
|
86
|
+
},
|
|
87
|
+
"scripts": {
|
|
88
|
+
"build": "pnpm run clean && pnpm run build:ts && pnpm run build:css",
|
|
89
|
+
"analyze": "ANALYZE=true vite build --mode production",
|
|
90
|
+
"build:ts": "vite build --mode production",
|
|
91
|
+
"build:css": "tailwindcss -i ./src/index.css -o ./dist/index.css --minify",
|
|
92
|
+
"clean": "rimraf dist",
|
|
93
|
+
"dev": "concurrently \"pnpm run dev:css\" \"pnpm run dev:ts\"",
|
|
94
|
+
"lint": "eslint . --max-warnings=0",
|
|
95
|
+
"test": "vitest run",
|
|
96
|
+
"typecheck": "tsc --noEmit",
|
|
97
|
+
"dev:ts": "vite build --mode development --watch",
|
|
98
|
+
"dev:css": "tailwindcss -i ./src/index.css -o ./dist/index.css --watch"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MapLngLat } from '../utils/get-bounds-from-points';
|
|
2
|
+
|
|
3
|
+
export interface StoryPlace {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
value: number;
|
|
7
|
+
lngLat: MapLngLat;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A few places around central Zurich, shared by the map stories. */
|
|
11
|
+
export const PLACES: StoryPlace[] = [
|
|
12
|
+
{ id: 'roma', label: 'Roma Pizza', value: 4.5, lngLat: [8.5417, 47.3769] },
|
|
13
|
+
{ id: 'kaiser', label: 'Kaiser Grill', value: 4.2, lngLat: [8.5305, 47.3717] },
|
|
14
|
+
{ id: 'lago', label: 'Lago Café', value: 4.8, lngLat: [8.5481, 47.3664] },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export const PLACE_POINTS: MapLngLat[] = PLACES.map((place) => place.lngLat);
|
|
18
|
+
|
|
19
|
+
export const USER_LOCATION = { longitude: 8.5391, latitude: 47.3723 };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* maplibre-gl v6 cannot find its own worker once a consumer bundles it, so
|
|
5
|
+
* `loadMapLib` hands it one built from the source this package ships. That source
|
|
6
|
+
* is a build-time virtual module; vitest.config.ts aliases it to a one-line stub.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
vi.unmock('@/load-map-lib');
|
|
10
|
+
|
|
11
|
+
const setWorkerUrl = vi.fn();
|
|
12
|
+
|
|
13
|
+
vi.mock('maplibre-gl', () => ({ setWorkerUrl }));
|
|
14
|
+
|
|
15
|
+
/** jsdom's Blob has no `text()`; its FileReader is the way in. */
|
|
16
|
+
function readBlob(blob: Blob): Promise<string> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const reader = new FileReader();
|
|
19
|
+
reader.onload = () => resolve(reader.result as string);
|
|
20
|
+
reader.readAsText(blob);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('loadMapLib', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
setWorkerUrl.mockClear();
|
|
28
|
+
URL.createObjectURL = vi.fn(() => 'blob:http://localhost/maplibre-worker');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('points maplibre at a blob URL of the shipped worker source', async () => {
|
|
36
|
+
const { loadMapLib } = await import('@/load-map-lib');
|
|
37
|
+
|
|
38
|
+
const lib = await loadMapLib();
|
|
39
|
+
|
|
40
|
+
expect(lib.setWorkerUrl).toBe(setWorkerUrl);
|
|
41
|
+
expect(setWorkerUrl).toHaveBeenCalledExactlyOnceWith('blob:http://localhost/maplibre-worker');
|
|
42
|
+
const [blob] = vi.mocked(URL.createObjectURL).mock.calls[0] as [Blob];
|
|
43
|
+
expect(blob.type).toBe('text/javascript');
|
|
44
|
+
expect(await readBlob(blob)).toBe('self.onmessage = () => {};');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('configures the worker once, however many maps ask', async () => {
|
|
48
|
+
const { loadMapLib } = await import('@/load-map-lib');
|
|
49
|
+
|
|
50
|
+
const [first, second] = await Promise.all([loadMapLib(), loadMapLib()]);
|
|
51
|
+
await loadMapLib();
|
|
52
|
+
|
|
53
|
+
expect(first).toBe(second);
|
|
54
|
+
expect(setWorkerUrl).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { useMapView } from '../map-context';
|
|
6
|
+
import { MapMarker, type MapMarkerState } from '../map-marker';
|
|
7
|
+
import { MapView, type MapViewProps } from '../map-view';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `MapMarker` is where the map's callbacks meet the DOM: selection, hover and
|
|
11
|
+
* the click that must not fall through to the map. The maplibre canvas is out
|
|
12
|
+
* of reach in jsdom, so `Map` and `Marker` are stood in by plain elements.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The `originalEvent` the Marker stub hands to `onClick`, as maplibre would. */
|
|
16
|
+
const originalEvent = { stopPropagation: vi.fn() };
|
|
17
|
+
|
|
18
|
+
vi.mock('@vis.gl/react-maplibre', () => ({
|
|
19
|
+
Map: forwardRef<unknown, { children?: ReactNode }>(function MapStub({ children }, _ref) {
|
|
20
|
+
return <div>{children}</div>;
|
|
21
|
+
}),
|
|
22
|
+
Marker: ({
|
|
23
|
+
children,
|
|
24
|
+
onClick,
|
|
25
|
+
}: {
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
onClick?: (e: { originalEvent: typeof originalEvent }) => void;
|
|
28
|
+
}) => (
|
|
29
|
+
// Stands in for maplibre's marker element, which owns the click in production.
|
|
30
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
31
|
+
<div onClick={() => onClick?.({ originalEvent })}>{children}</div>
|
|
32
|
+
),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
/** A map with nothing selected and spy callbacks. */
|
|
36
|
+
function viewProps(overrides: Partial<MapViewProps<number>> = {}): MapViewProps<number> {
|
|
37
|
+
return {
|
|
38
|
+
testId: 'map',
|
|
39
|
+
ariaLabel: 'Map',
|
|
40
|
+
fitPoints: [[8.54, 47.37]],
|
|
41
|
+
onSelect: vi.fn(),
|
|
42
|
+
onHover: vi.fn(),
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderMarkers(props: MapViewProps<number>, children?: ReactNode) {
|
|
48
|
+
return render(
|
|
49
|
+
<MapView {...props}>
|
|
50
|
+
{children ?? (
|
|
51
|
+
<>
|
|
52
|
+
<MapMarker id={0} longitude={8.54} latitude={47.37} label="Zurich">
|
|
53
|
+
<span>pin</span>
|
|
54
|
+
</MapMarker>
|
|
55
|
+
<MapMarker id={1} longitude={7.44} latitude={46.95} label="Bern">
|
|
56
|
+
<span>pin</span>
|
|
57
|
+
</MapMarker>
|
|
58
|
+
</>
|
|
59
|
+
)}
|
|
60
|
+
</MapView>,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
originalEvent.stopPropagation.mockClear();
|
|
66
|
+
// React logs the boundary-less throw itself; the guard test expects it.
|
|
67
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
cleanup();
|
|
72
|
+
vi.restoreAllMocks();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('callbacks', () => {
|
|
76
|
+
test('clicking a marker selects its id', () => {
|
|
77
|
+
const props = viewProps();
|
|
78
|
+
renderMarkers(props);
|
|
79
|
+
|
|
80
|
+
fireEvent.click(screen.getByRole('button', { name: 'Bern' }));
|
|
81
|
+
|
|
82
|
+
expect(props.onSelect).toHaveBeenCalledWith(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the click does not reach the map', () => {
|
|
86
|
+
// The map's own click handler deselects; a marker click that bubbles up
|
|
87
|
+
// would select and immediately deselect.
|
|
88
|
+
renderMarkers(viewProps());
|
|
89
|
+
|
|
90
|
+
fireEvent.click(screen.getByRole('button', { name: 'Zurich' }));
|
|
91
|
+
|
|
92
|
+
expect(originalEvent.stopPropagation).toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('hovering reports the id, leaving reports null', () => {
|
|
96
|
+
const props = viewProps();
|
|
97
|
+
renderMarkers(props);
|
|
98
|
+
const zurich = screen.getByRole('button', { name: 'Zurich' });
|
|
99
|
+
|
|
100
|
+
fireEvent.mouseEnter(zurich);
|
|
101
|
+
fireEvent.mouseLeave(zurich);
|
|
102
|
+
|
|
103
|
+
expect(props.onHover).toHaveBeenNthCalledWith(1, 0);
|
|
104
|
+
expect(props.onHover).toHaveBeenNthCalledWith(2, null);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('marker state', () => {
|
|
109
|
+
test('only the selected marker is pressed', () => {
|
|
110
|
+
renderMarkers(viewProps({ selectedId: 1 }));
|
|
111
|
+
|
|
112
|
+
expect(screen.getByRole('button', { name: 'Bern' }).getAttribute('aria-pressed')).toBe('true');
|
|
113
|
+
expect(screen.getByRole('button', { name: 'Zurich' }).getAttribute('aria-pressed')).toBe(
|
|
114
|
+
'false',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a render-function child receives the selection and hover state', () => {
|
|
119
|
+
const seen: Record<string, MapMarkerState> = {};
|
|
120
|
+
|
|
121
|
+
renderMarkers(
|
|
122
|
+
viewProps({ selectedId: 0, hoveredId: 1 }),
|
|
123
|
+
<>
|
|
124
|
+
<MapMarker id={0} longitude={8.54} latitude={47.37} label="Zurich">
|
|
125
|
+
{(state) => {
|
|
126
|
+
seen.zurich = state;
|
|
127
|
+
return <span>pin</span>;
|
|
128
|
+
}}
|
|
129
|
+
</MapMarker>
|
|
130
|
+
<MapMarker id={1} longitude={7.44} latitude={46.95} label="Bern">
|
|
131
|
+
{(state) => {
|
|
132
|
+
seen.bern = state;
|
|
133
|
+
return <span>pin</span>;
|
|
134
|
+
}}
|
|
135
|
+
</MapMarker>
|
|
136
|
+
</>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(seen.zurich).toEqual({ isSelected: true, isHovered: false });
|
|
140
|
+
expect(seen.bern).toEqual({ isSelected: false, isHovered: true });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('useMapView outside a MapView', () => {
|
|
145
|
+
test('throws a message naming the missing root', () => {
|
|
146
|
+
function Probe() {
|
|
147
|
+
useMapView();
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
expect(() => render(<Probe />)).toThrow('useMapView must be used within a MapView component');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { MapViewContext, type MapViewContextValue } from '../map-context';
|
|
6
|
+
import { MapMarker } from '../map-marker';
|
|
7
|
+
import { MapPin } from '../map-pin';
|
|
8
|
+
|
|
9
|
+
vi.mock('@vis.gl/react-maplibre', () => ({
|
|
10
|
+
Marker: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
/** A map root with nothing selected or hovered. */
|
|
14
|
+
function mapContext(overrides: Partial<MapViewContextValue> = {}): MapViewContextValue {
|
|
15
|
+
return {
|
|
16
|
+
mapRef: { current: null },
|
|
17
|
+
selectedId: null,
|
|
18
|
+
hoveredId: null,
|
|
19
|
+
flyToSelected: false,
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
cleanup();
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('content', () => {
|
|
30
|
+
test('shows both the value and the label', () => {
|
|
31
|
+
render(<MapPin label="Roma Pizza" value={4.5} />);
|
|
32
|
+
|
|
33
|
+
expect(screen.getByText('4.5')).not.toBeNull();
|
|
34
|
+
expect(screen.getByText('Roma Pizza')).not.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('treats an empty value as none', () => {
|
|
38
|
+
// A place without a rating comes through as '' as well as null; neither
|
|
39
|
+
// should leave an empty value slot beside the name.
|
|
40
|
+
const { container } = render(<MapPin label="Bluebird Cafe" value="" />);
|
|
41
|
+
|
|
42
|
+
expect(container.textContent).toBe('Bluebird Cafe');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('state', () => {
|
|
47
|
+
test('follows the enclosing marker', () => {
|
|
48
|
+
render(
|
|
49
|
+
<MapViewContext.Provider value={mapContext({ selectedId: 'a', hoveredId: 'b' })}>
|
|
50
|
+
<MapMarker id="a" longitude={0} latitude={0} label="A">
|
|
51
|
+
<MapPin label="A" data-testid="pin-a" />
|
|
52
|
+
</MapMarker>
|
|
53
|
+
<MapMarker id="b" longitude={0} latitude={0} label="B">
|
|
54
|
+
<MapPin label="B" data-testid="pin-b" />
|
|
55
|
+
</MapMarker>
|
|
56
|
+
</MapViewContext.Provider>,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(screen.getByTestId('pin-a').dataset.selected).toBe('true');
|
|
60
|
+
expect(screen.getByTestId('pin-a').dataset.hovered).toBeUndefined();
|
|
61
|
+
expect(screen.getByTestId('pin-b').dataset.hovered).toBe('true');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('explicit props win over the marker', () => {
|
|
65
|
+
// Lets a caller pin the look (e.g. in a legend) regardless of selection.
|
|
66
|
+
render(
|
|
67
|
+
<MapViewContext.Provider value={mapContext({ selectedId: 'a' })}>
|
|
68
|
+
<MapMarker id="a" longitude={0} latitude={0} label="A">
|
|
69
|
+
<MapPin label="A" isSelected={false} data-testid="pin-a" />
|
|
70
|
+
</MapMarker>
|
|
71
|
+
</MapViewContext.Provider>,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(screen.getByTestId('pin-a').dataset.selected).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('works outside a marker', () => {
|
|
78
|
+
render(<MapPin label="Standalone" isHovered />);
|
|
79
|
+
|
|
80
|
+
expect(screen.getByTestId('map-pin').dataset.hovered).toBe('true');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { TooltipProvider } from '@swisper.ai/ui';
|
|
6
|
+
|
|
7
|
+
import { MapUserLocation } from '../map-user-location';
|
|
8
|
+
|
|
9
|
+
vi.mock('@vis.gl/react-maplibre', () => ({
|
|
10
|
+
Marker: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const ZURICH = { longitude: 8.54, latitude: 47.37 };
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanup();
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('MapUserLocation', () => {
|
|
21
|
+
test('renders nothing while the position is unknown', () => {
|
|
22
|
+
// Geolocation resolves late or is denied; the map must not drop a dot at 0,0.
|
|
23
|
+
const { container } = render(<MapUserLocation coordinates={null} />);
|
|
24
|
+
|
|
25
|
+
expect(container.innerHTML).toBe('');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('shows the default dot once the position is known', () => {
|
|
29
|
+
render(
|
|
30
|
+
<TooltipProvider>
|
|
31
|
+
<MapUserLocation coordinates={ZURICH} />
|
|
32
|
+
</TooltipProvider>,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(screen.getByRole('img')).not.toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('renders a custom marker in place of the dot', () => {
|
|
39
|
+
render(
|
|
40
|
+
<MapUserLocation coordinates={ZURICH}>
|
|
41
|
+
<span>you</span>
|
|
42
|
+
</MapUserLocation>,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(screen.getByText('you')).not.toBeNull();
|
|
46
|
+
expect(screen.queryByRole('img')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { configure } from '@testing-library/react';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Under a parallel `turbo run test` a render can land seconds after the event that caused it,
|
|
5
|
+
// past the 1s default. A wait that holds returns at once, so only a real failure takes longer.
|
|
6
|
+
configure({ asyncUtilTimeout: 5_000 });
|
|
7
|
+
|
|
8
|
+
// Every `BaseMap` render asks for maplibre. jsdom can neither run it nor create
|
|
9
|
+
// the worker's blob URL, and the specs stub `Map` anyway, so hand them a promise
|
|
10
|
+
// that never settles. `load-map-lib.spec.ts` unmocks it to test the real one.
|
|
11
|
+
vi.mock('@/load-map-lib', () => ({
|
|
12
|
+
loadMapLib: () => new Promise(() => {}),
|
|
13
|
+
}));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../int-eslint-config/schemas/component.schema.json",
|
|
3
|
+
"component": "base-map",
|
|
4
|
+
"purpose": "The bare MapLibre map surface with loading and error states, framing and style URL, but no selection context.",
|
|
5
|
+
"useWhen": [
|
|
6
|
+
"Drawing custom sources, layers or plain markers that need no shared selection or hover",
|
|
7
|
+
"Building a new map component that manages its own view state through mapRef",
|
|
8
|
+
"Showing a map at a fixed centre and zoom, or framing explicit bounds"
|
|
9
|
+
],
|
|
10
|
+
"avoidWhen": [
|
|
11
|
+
"Showing selectable, hoverable places — use web-map/map-view with web-map/map-marker instead",
|
|
12
|
+
"Framing a list of points — web-map/map-view computes the bounds and initial centre from fitPoints for you"
|
|
13
|
+
],
|
|
14
|
+
"uxPrinciples": [
|
|
15
|
+
"Keep the map neutral while it loads or fails: tiles are hidden instead of half-rendered",
|
|
16
|
+
"An error replaces the map rather than overlaying an unusable one"
|
|
17
|
+
],
|
|
18
|
+
"accessibility": {
|
|
19
|
+
"required": [
|
|
20
|
+
"Pass an ariaLabel that says what the map shows",
|
|
21
|
+
"Give any interactive marker you draw inside it its own accessible name",
|
|
22
|
+
"Provide the map's information in a non-map form as well when it matters to the task"
|
|
23
|
+
],
|
|
24
|
+
"keyboard": [
|
|
25
|
+
"MapLibre's default keyboard handling applies when the map canvas has focus: arrow keys pan, + and - zoom",
|
|
26
|
+
"BaseMap adds no keyboard handling of its own"
|
|
27
|
+
],
|
|
28
|
+
"aria": [
|
|
29
|
+
"The root is role=application with aria-label=ariaLabel",
|
|
30
|
+
"aria-invalid is set on the root while hasError is true"
|
|
31
|
+
],
|
|
32
|
+
"focus": [
|
|
33
|
+
"BaseMap manages no focus; tab stops come from the map canvas and any interactive children"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"behaviour": {
|
|
37
|
+
"states": ["default", "loading", "error"],
|
|
38
|
+
"rules": [
|
|
39
|
+
"initialViewState is the centre and zoom the map opens at; it is read once on mount",
|
|
40
|
+
"bounds (south-west and north-east corners) is fitted on load and whenever a new bounds value is passed, with fitBoundsPadding px around it; a zero-area bounds centres and zooms to SINGLE_POINT_ZOOM instead",
|
|
41
|
+
"isLoading makes the map invisible over the plain background without a spinner; hasError also disables pointer input and shows errorLabel with an error icon over a red tint",
|
|
42
|
+
"mapStyleUrl defaults to OpenFreeMap Liberty; env vars are not read",
|
|
43
|
+
"mapRef exposes the underlying MapLibre map for imperative calls such as easeTo; without it BaseMap keeps its own ref",
|
|
44
|
+
"onLoad fires once the style has loaded, after the initial bounds fit",
|
|
45
|
+
"The MapLibre library and its worker are loaded lazily on first render and shared by every map",
|
|
46
|
+
"The root fills its parent (size-full) with rounded corners; the parent or className must give it a height"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"composition": {
|
|
50
|
+
"allowedParents": [],
|
|
51
|
+
"allowedChildren": [
|
|
52
|
+
"web-map/Source",
|
|
53
|
+
"web-map/Layer",
|
|
54
|
+
"web-map/Marker",
|
|
55
|
+
"web-map/map-user-location"
|
|
56
|
+
],
|
|
57
|
+
"avoidCombinations": [
|
|
58
|
+
"web-map/map-marker directly in a BaseMap — it throws without a web-map/map-view around it",
|
|
59
|
+
"BaseMap inside a web-map/map-view — MapView already renders its own BaseMap; nesting draws a second map"
|
|
60
|
+
],
|
|
61
|
+
"contexts": ["page section with an explicit height", "custom map component"]
|
|
62
|
+
},
|
|
63
|
+
"content": {
|
|
64
|
+
"labelRules": ["ariaLabel describes the content of the map, not the widget"],
|
|
65
|
+
"helperTextRules": [],
|
|
66
|
+
"errorRules": [
|
|
67
|
+
"errorLabel says the map could not load and what to do next, in one short sentence (default: 'Could not load map. Try again later.')",
|
|
68
|
+
"Pass errorLabel already translated; the package has no i18n dependency"
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"aiDecisionRules": [
|
|
72
|
+
{
|
|
73
|
+
"condition": "Markers must be selectable or hoverable",
|
|
74
|
+
"decision": "Use web-map/map-view instead of BaseMap",
|
|
75
|
+
"reason": "Selection and hover live in MapView's context; MapMarker cannot work without it"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"condition": "Custom code must move the camera (easeTo, fitBounds)",
|
|
79
|
+
"decision": "Pass a mapRef and call the MapLibre map from mapRef.current.getMap()",
|
|
80
|
+
"reason": "BaseMap has no camera props beyond initialViewState and bounds"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"condition": "bounds is derived from data",
|
|
84
|
+
"decision": "Memoise it",
|
|
85
|
+
"reason": "Every new bounds value re-fits the map and resets the user's pan and zoom"
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"relatedComponents": ["web-map/map-view", "web-map/map-user-location"]
|
|
89
|
+
}
|