@elabs-ai/components-maps 4.0.0
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 +21 -0
- package/README.md +121 -0
- package/dist/index.css +11 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +442 -0
- package/dist/index.js +1475 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
- package/src/index.ts +37 -0
- package/src/lib/arc-math.test.ts +41 -0
- package/src/lib/arc-math.ts +45 -0
- package/src/lib/merge-hover-paint.test.ts +36 -0
- package/src/lib/merge-hover-paint.ts +21 -0
- package/src/lib/use-token-color.ts +23 -0
- package/src/map-arc/index.ts +9 -0
- package/src/map-arc/map-arc.stories.tsx +58 -0
- package/src/map-arc/map-arc.tsx +294 -0
- package/src/map-canvas/index.ts +8 -0
- package/src/map-canvas/map-canvas-webgl-fallback.test.tsx +30 -0
- package/src/map-canvas/map-canvas.stories.tsx +54 -0
- package/src/map-canvas/map-canvas.test.tsx +93 -0
- package/src/map-canvas/map-canvas.tsx +349 -0
- package/src/map-canvas/map-context.ts +32 -0
- package/src/map-canvas/maps.css +15 -0
- package/src/map-canvas/use-resolved-basemap-theme.ts +77 -0
- package/src/map-cluster-layer/index.ts +1 -0
- package/src/map-cluster-layer/map-cluster-layer.stories.tsx +56 -0
- package/src/map-cluster-layer/map-cluster-layer.tsx +292 -0
- package/src/map-controls/index.ts +1 -0
- package/src/map-controls/map-controls.stories.tsx +43 -0
- package/src/map-controls/map-controls.test.tsx +56 -0
- package/src/map-controls/map-controls.tsx +220 -0
- package/src/map-geojson/index.ts +9 -0
- package/src/map-geojson/map-geojson.stories.tsx +124 -0
- package/src/map-geojson/map-geojson.tsx +274 -0
- package/src/map-marker/index.ts +12 -0
- package/src/map-marker/map-marker.stories.tsx +71 -0
- package/src/map-marker/map-marker.test.tsx +81 -0
- package/src/map-marker/map-marker.tsx +373 -0
- package/src/map-popup/index.ts +1 -0
- package/src/map-popup/map-popup.tsx +113 -0
- package/src/map-route/index.ts +1 -0
- package/src/map-route/map-route.stories.tsx +56 -0
- package/src/map-route/map-route.tsx +143 -0
- package/src/test-utils/maplibre-mock.ts +249 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type MapLibreGL from "maplibre-gl";
|
|
4
|
+
import { useEffect, useId, useMemo, useRef } from "react";
|
|
5
|
+
|
|
6
|
+
import { useMap } from "../map-canvas/map-context";
|
|
7
|
+
import { buildArcCoordinates } from "../lib/arc-math";
|
|
8
|
+
import { mergeHoverPaint } from "../lib/merge-hover-paint";
|
|
9
|
+
import { useTokenColor } from "../lib/use-token-color";
|
|
10
|
+
|
|
11
|
+
/** A single arc to render inside `<MapArc data={...}>`. */
|
|
12
|
+
export type MapArcDatum = {
|
|
13
|
+
/** Unique identifier for this arc. Required for hover state tracking and event payloads. */
|
|
14
|
+
id: string | number;
|
|
15
|
+
/** Start coordinate as [longitude, latitude]. */
|
|
16
|
+
from: [number, number];
|
|
17
|
+
/** End coordinate as [longitude, latitude]. */
|
|
18
|
+
to: [number, number];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Event payload passed to MapArc interaction callbacks. */
|
|
22
|
+
export type MapArcEvent<T extends MapArcDatum = MapArcDatum> = {
|
|
23
|
+
/** The arc datum that was hovered or clicked. */
|
|
24
|
+
arc: T;
|
|
25
|
+
/** Longitude of the cursor at the time of the event. */
|
|
26
|
+
longitude: number;
|
|
27
|
+
/** Latitude of the cursor at the time of the event. */
|
|
28
|
+
latitude: number;
|
|
29
|
+
/** The underlying MapLibre mouse event for advanced use cases. */
|
|
30
|
+
originalEvent: MapLibreGL.MapMouseEvent;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
|
34
|
+
export type MapArcLineLayout = NonNullable<MapLibreGL.LineLayerSpecification["layout"]>;
|
|
35
|
+
|
|
36
|
+
export type MapArcProps<T extends MapArcDatum = MapArcDatum> = {
|
|
37
|
+
/** Array of arcs to render. Each arc must have a unique `id`. */
|
|
38
|
+
data: T[];
|
|
39
|
+
/** Optional unique identifier prefix for the arc source/layers. Auto-generated if not provided. */
|
|
40
|
+
id?: string;
|
|
41
|
+
/**
|
|
42
|
+
* How far each arc bows away from a straight line. `0` renders straight
|
|
43
|
+
* lines; higher values bend further; negative values bend to the opposite
|
|
44
|
+
* side. Arcs cross the antimeridian via the shorter direction. (default: 0.2)
|
|
45
|
+
*/
|
|
46
|
+
curvature?: number;
|
|
47
|
+
/** Number of samples used to render each curve. Higher = smoother. (default: 64) */
|
|
48
|
+
samples?: number;
|
|
49
|
+
/**
|
|
50
|
+
* MapLibre paint properties for the arc layer. Merged on top of theme-aware
|
|
51
|
+
* defaults (`line-color` = the `--primary` token, `line-width: 2`,
|
|
52
|
+
* `line-opacity: 0.85`). Any value can be a MapLibre expression for
|
|
53
|
+
* per-feature styling; every field on each arc datum (besides `from`/`to`)
|
|
54
|
+
* is exposed via `["get", ...]`.
|
|
55
|
+
*/
|
|
56
|
+
paint?: MapArcLinePaint;
|
|
57
|
+
/** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */
|
|
58
|
+
layout?: MapArcLineLayout;
|
|
59
|
+
/**
|
|
60
|
+
* Paint properties applied to the arc currently under the cursor. Each key
|
|
61
|
+
* is merged into `paint` as a `case` expression keyed on per-feature hover
|
|
62
|
+
* state, so only the hovered arc changes appearance.
|
|
63
|
+
*/
|
|
64
|
+
hoverPaint?: MapArcLinePaint;
|
|
65
|
+
/** Callback when an arc is clicked. */
|
|
66
|
+
onClick?: (e: MapArcEvent<T>) => void;
|
|
67
|
+
/**
|
|
68
|
+
* Callback fired when the hovered arc changes. Receives the cursor's
|
|
69
|
+
* lng/lat at the moment of entry, and `null` when the cursor leaves the
|
|
70
|
+
* last hovered arc.
|
|
71
|
+
*/
|
|
72
|
+
onHover?: (e: MapArcEvent<T> | null) => void;
|
|
73
|
+
/** Whether arcs respond to mouse events (default: true). */
|
|
74
|
+
interactive?: boolean;
|
|
75
|
+
/** Optional MapLibre layer id to insert the arc layers before (z-order control). */
|
|
76
|
+
beforeId?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const DEFAULT_ARC_CURVATURE = 0.2;
|
|
80
|
+
const DEFAULT_ARC_SAMPLES = 64;
|
|
81
|
+
const ARC_HIT_MIN_WIDTH = 12;
|
|
82
|
+
const ARC_HIT_PADDING = 6;
|
|
83
|
+
|
|
84
|
+
const DEFAULT_ARC_LAYOUT: MapArcLineLayout = {
|
|
85
|
+
"line-join": "round",
|
|
86
|
+
"line-cap": "round",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Curved great-circle-style arcs between coordinate pairs (flight paths,
|
|
91
|
+
* network links). Pairs well with `<MapCanvas blank projection={{ type: "globe" }}>`.
|
|
92
|
+
*/
|
|
93
|
+
export function MapArc<T extends MapArcDatum = MapArcDatum>({
|
|
94
|
+
data,
|
|
95
|
+
id: propId,
|
|
96
|
+
curvature = DEFAULT_ARC_CURVATURE,
|
|
97
|
+
samples = DEFAULT_ARC_SAMPLES,
|
|
98
|
+
paint,
|
|
99
|
+
layout,
|
|
100
|
+
hoverPaint,
|
|
101
|
+
onClick,
|
|
102
|
+
onHover,
|
|
103
|
+
interactive = true,
|
|
104
|
+
beforeId,
|
|
105
|
+
}: MapArcProps<T>) {
|
|
106
|
+
const { map, isLoaded } = useMap();
|
|
107
|
+
const autoId = useId();
|
|
108
|
+
const id = propId ?? autoId;
|
|
109
|
+
const sourceId = `arc-source-${id}`;
|
|
110
|
+
const layerId = `arc-layer-${id}`;
|
|
111
|
+
const hitLayerId = `arc-hit-layer-${id}`;
|
|
112
|
+
|
|
113
|
+
const primary = useTokenColor("--primary");
|
|
114
|
+
|
|
115
|
+
const mergedPaint = useMemo(
|
|
116
|
+
() =>
|
|
117
|
+
mergeHoverPaint(
|
|
118
|
+
{ "line-color": primary, "line-width": 2, "line-opacity": 0.85, ...paint },
|
|
119
|
+
hoverPaint,
|
|
120
|
+
),
|
|
121
|
+
[primary, paint, hoverPaint],
|
|
122
|
+
);
|
|
123
|
+
const mergedLayout = useMemo(() => ({ ...DEFAULT_ARC_LAYOUT, ...layout }), [layout]);
|
|
124
|
+
|
|
125
|
+
const hitWidth = useMemo(() => {
|
|
126
|
+
const w = paint?.["line-width"] ?? 2;
|
|
127
|
+
const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH;
|
|
128
|
+
return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
|
|
129
|
+
}, [paint]);
|
|
130
|
+
|
|
131
|
+
const geoJSON = useMemo<GeoJSON.FeatureCollection<GeoJSON.LineString>>(
|
|
132
|
+
() => ({
|
|
133
|
+
type: "FeatureCollection",
|
|
134
|
+
features: data.map((arc) => {
|
|
135
|
+
const { from, to, ...properties } = arc;
|
|
136
|
+
return {
|
|
137
|
+
type: "Feature",
|
|
138
|
+
properties,
|
|
139
|
+
geometry: {
|
|
140
|
+
type: "LineString",
|
|
141
|
+
coordinates: buildArcCoordinates(from, to, curvature, samples),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
}),
|
|
146
|
+
[data, curvature, samples],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const latestRef = useRef({ data, onClick, onHover });
|
|
150
|
+
latestRef.current = { data, onClick, onHover };
|
|
151
|
+
|
|
152
|
+
// Add source and layers on mount. The invisible hit layer widens the
|
|
153
|
+
// pointer target so thin arcs stay hoverable/clickable.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!isLoaded || !map) return;
|
|
156
|
+
|
|
157
|
+
map.addSource(sourceId, {
|
|
158
|
+
type: "geojson",
|
|
159
|
+
data: geoJSON,
|
|
160
|
+
promoteId: "id",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
map.addLayer(
|
|
164
|
+
{
|
|
165
|
+
id: hitLayerId,
|
|
166
|
+
type: "line",
|
|
167
|
+
source: sourceId,
|
|
168
|
+
layout: DEFAULT_ARC_LAYOUT,
|
|
169
|
+
paint: {
|
|
170
|
+
"line-color": "transparent",
|
|
171
|
+
"line-width": hitWidth,
|
|
172
|
+
"line-opacity": 1,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
beforeId,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
map.addLayer(
|
|
179
|
+
{
|
|
180
|
+
id: layerId,
|
|
181
|
+
type: "line",
|
|
182
|
+
source: sourceId,
|
|
183
|
+
layout: mergedLayout,
|
|
184
|
+
paint: mergedPaint,
|
|
185
|
+
},
|
|
186
|
+
beforeId,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return () => {
|
|
190
|
+
try {
|
|
191
|
+
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
192
|
+
if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId);
|
|
193
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
194
|
+
} catch {
|
|
195
|
+
// style may be mid-reload
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- source/layers created once per map; data + paint are synced by the effects below
|
|
199
|
+
}, [isLoaded, map]);
|
|
200
|
+
|
|
201
|
+
// Sync features when data / curvature / samples change.
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (!isLoaded || !map) return;
|
|
204
|
+
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
|
|
205
|
+
source?.setData(geoJSON);
|
|
206
|
+
}, [isLoaded, map, geoJSON, sourceId]);
|
|
207
|
+
|
|
208
|
+
// Sync paint/layout when they change.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!isLoaded || !map || !map.getLayer(layerId)) return;
|
|
211
|
+
for (const [key, value] of Object.entries(mergedPaint)) {
|
|
212
|
+
map.setPaintProperty(layerId, key as keyof MapArcLinePaint, value as never);
|
|
213
|
+
}
|
|
214
|
+
for (const [key, value] of Object.entries(mergedLayout)) {
|
|
215
|
+
map.setLayoutProperty(layerId, key as keyof MapArcLineLayout, value as never);
|
|
216
|
+
}
|
|
217
|
+
if (map.getLayer(hitLayerId)) {
|
|
218
|
+
map.setPaintProperty(hitLayerId, "line-width", hitWidth);
|
|
219
|
+
}
|
|
220
|
+
}, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]);
|
|
221
|
+
|
|
222
|
+
// Interaction handlers (bound to the hit layer).
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (!isLoaded || !map || !interactive) return;
|
|
225
|
+
|
|
226
|
+
let hoveredId: string | number | null = null;
|
|
227
|
+
|
|
228
|
+
const setHover = (next: string | number | null) => {
|
|
229
|
+
if (next === hoveredId) return;
|
|
230
|
+
const sourceExists = !!map.getSource(sourceId);
|
|
231
|
+
if (hoveredId != null && sourceExists) {
|
|
232
|
+
map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false });
|
|
233
|
+
}
|
|
234
|
+
hoveredId = next;
|
|
235
|
+
if (next != null && sourceExists) {
|
|
236
|
+
map.setFeatureState({ source: sourceId, id: next }, { hover: true });
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const findArc = (featureId: string | number | undefined) =>
|
|
241
|
+
featureId == null
|
|
242
|
+
? undefined
|
|
243
|
+
: latestRef.current.data.find((arc) => String(arc.id) === String(featureId));
|
|
244
|
+
|
|
245
|
+
const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
246
|
+
const featureId = e.features?.[0]?.id as string | number | undefined;
|
|
247
|
+
if (featureId == null || featureId === hoveredId) return;
|
|
248
|
+
|
|
249
|
+
setHover(featureId);
|
|
250
|
+
map.getCanvas().style.cursor = "pointer";
|
|
251
|
+
|
|
252
|
+
const arc = findArc(featureId);
|
|
253
|
+
if (arc) {
|
|
254
|
+
latestRef.current.onHover?.({
|
|
255
|
+
arc: arc as T,
|
|
256
|
+
longitude: e.lngLat.lng,
|
|
257
|
+
latitude: e.lngLat.lat,
|
|
258
|
+
originalEvent: e,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const handleMouseLeave = () => {
|
|
264
|
+
setHover(null);
|
|
265
|
+
map.getCanvas().style.cursor = "";
|
|
266
|
+
latestRef.current.onHover?.(null);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
270
|
+
const arc = findArc(e.features?.[0]?.id as string | number | undefined);
|
|
271
|
+
if (!arc) return;
|
|
272
|
+
latestRef.current.onClick?.({
|
|
273
|
+
arc: arc as T,
|
|
274
|
+
longitude: e.lngLat.lng,
|
|
275
|
+
latitude: e.lngLat.lat,
|
|
276
|
+
originalEvent: e,
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
map.on("mousemove", hitLayerId, handleMouseMove);
|
|
281
|
+
map.on("mouseleave", hitLayerId, handleMouseLeave);
|
|
282
|
+
map.on("click", hitLayerId, handleClick);
|
|
283
|
+
|
|
284
|
+
return () => {
|
|
285
|
+
map.off("mousemove", hitLayerId, handleMouseMove);
|
|
286
|
+
map.off("mouseleave", hitLayerId, handleMouseLeave);
|
|
287
|
+
map.off("click", hitLayerId, handleClick);
|
|
288
|
+
setHover(null);
|
|
289
|
+
map.getCanvas().style.cursor = "";
|
|
290
|
+
};
|
|
291
|
+
}, [isLoaded, map, hitLayerId, sourceId, interactive]);
|
|
292
|
+
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// MapLibre throws at construction when WebGL is unavailable — the canvas must
|
|
5
|
+
// degrade to a quiet panel, not an unhandled render error (smoke-test contract).
|
|
6
|
+
vi.mock("maplibre-gl", () => {
|
|
7
|
+
class ThrowingMap {
|
|
8
|
+
constructor() {
|
|
9
|
+
throw new Error("Failed to initialize WebGL");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return { default: { Map: ThrowingMap }, Map: ThrowingMap };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
import { MapCanvas } from "./map-canvas";
|
|
16
|
+
|
|
17
|
+
afterEach(cleanup);
|
|
18
|
+
|
|
19
|
+
describe("MapCanvas without WebGL", () => {
|
|
20
|
+
it("renders the unavailable panel instead of throwing", () => {
|
|
21
|
+
render(
|
|
22
|
+
<MapCanvas className="my-map">
|
|
23
|
+
<div data-testid="child" />
|
|
24
|
+
</MapCanvas>,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByText("Map unavailable")).toBeInTheDocument();
|
|
27
|
+
// Children (which need the map context) are not rendered in this state.
|
|
28
|
+
expect(screen.queryByTestId("child")).not.toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
|
|
3
|
+
import { MapCanvas } from "./map-canvas";
|
|
4
|
+
import { MapControls } from "../map-controls";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Maps/MapCanvas",
|
|
8
|
+
component: MapCanvas,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
parameters: { layout: "fullscreen" },
|
|
11
|
+
} satisfies Meta<typeof MapCanvas>;
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<div className="h-[480px]">
|
|
18
|
+
<MapCanvas center={[13.405, 52.52]} zoom={11}>
|
|
19
|
+
<MapControls />
|
|
20
|
+
</MapCanvas>
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The `loading` prop shows an accessible overlay while the app fetches map data. */
|
|
26
|
+
export const Loading: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<div className="h-[480px]">
|
|
29
|
+
<MapCanvas center={[13.405, 52.52]} zoom={11} loading />
|
|
30
|
+
</div>
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Pin the basemap flavor regardless of the active brand theme. */
|
|
35
|
+
export const ForcedDarkBasemap: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div className="h-[480px]">
|
|
38
|
+
<MapCanvas center={[-74.006, 40.7128]} zoom={10} theme="dark">
|
|
39
|
+
<MapControls />
|
|
40
|
+
</MapCanvas>
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A globe projection — pairs well with `blank` + data layers for data-viz. */
|
|
46
|
+
export const Globe: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<div className="h-[480px]">
|
|
49
|
+
<MapCanvas center={[10, 30]} zoom={1.5} projection={{ type: "globe" }}>
|
|
50
|
+
<MapControls showCompass />
|
|
51
|
+
</MapCanvas>
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// maplibre-gl requires WebGL — mock the engine and assert the brand wrapper's
|
|
5
|
+
// own output. Real rendering + a11y are covered by Storybook story tests.
|
|
6
|
+
vi.mock("maplibre-gl", async () => {
|
|
7
|
+
const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
|
|
8
|
+
return createMaplibreMock();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
import { MockMap, resetMaplibreMock } from "../test-utils/maplibre-mock";
|
|
12
|
+
import { MapCanvas } from "./map-canvas";
|
|
13
|
+
import { useMap } from "./map-context";
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanup();
|
|
17
|
+
resetMaplibreMock();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("MapCanvas", () => {
|
|
21
|
+
it("renders the container and applies custom className", () => {
|
|
22
|
+
const { container } = render(<MapCanvas className="my-map" />);
|
|
23
|
+
expect(container.firstChild).toHaveClass("my-map");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("creates a MapLibre map on mount and removes it on unmount", () => {
|
|
27
|
+
const { unmount } = render(<MapCanvas />);
|
|
28
|
+
expect(MockMap.instances).toHaveLength(1);
|
|
29
|
+
unmount();
|
|
30
|
+
expect(MockMap.instances[0]!.removed).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// A deliberate maintainer default for internal use — see
|
|
34
|
+
// .claude/rules/map-components.md. The rule carries the constraint that the
|
|
35
|
+
// Carto/OSM basemap legally requires the credit for PUBLIC surfaces; these two
|
|
36
|
+
// tests exist so the default can't be flipped, or the override broken, silently.
|
|
37
|
+
it("disables the MapLibre attribution control by default", () => {
|
|
38
|
+
render(<MapCanvas />);
|
|
39
|
+
expect(MockMap.instances[0]!.options.attributionControl).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("lets a consumer re-enable the attribution control for a public surface", () => {
|
|
43
|
+
render(<MapCanvas attributionControl={{ compact: true }} />);
|
|
44
|
+
expect(MockMap.instances[0]!.options.attributionControl).toEqual({ compact: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders children once the map instance exists", async () => {
|
|
48
|
+
render(
|
|
49
|
+
<MapCanvas>
|
|
50
|
+
<div data-testid="child">child</div>
|
|
51
|
+
</MapCanvas>,
|
|
52
|
+
);
|
|
53
|
+
expect(await screen.findByTestId("child")).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("shows an accessible loading overlay while loading", () => {
|
|
57
|
+
render(<MapCanvas loading />);
|
|
58
|
+
expect(screen.getByRole("status", { name: "Loading map" })).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("hides the loading overlay once loaded and not loading", async () => {
|
|
62
|
+
render(<MapCanvas loading={false} />);
|
|
63
|
+
// The mock fires "load" synchronously, so the overlay must be gone.
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("exposes the map instance through context", async () => {
|
|
70
|
+
let seen: unknown = null;
|
|
71
|
+
function Probe() {
|
|
72
|
+
const { map } = useMap();
|
|
73
|
+
seen = map;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
render(
|
|
77
|
+
<MapCanvas>
|
|
78
|
+
<Probe />
|
|
79
|
+
</MapCanvas>,
|
|
80
|
+
);
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(seen).toBe(MockMap.instances[0]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("useMap throws outside of MapCanvas", () => {
|
|
87
|
+
function Bare() {
|
|
88
|
+
useMap();
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
expect(() => render(<Bare />)).toThrow(/within a <MapCanvas>/);
|
|
92
|
+
});
|
|
93
|
+
});
|