@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,124 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { resolveTokenColor } from "@elabs-ai/components-tokens";
|
|
4
|
+
|
|
5
|
+
import { MapCanvas } from "../map-canvas";
|
|
6
|
+
import { MapGeoJSON } from "./map-geojson";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Theme-reactive token resolution for CONSUMER-side paint (outside the map
|
|
10
|
+
* context). A one-shot `resolveTokenColor` at render time can read the `:root`
|
|
11
|
+
* fallback: Storybook's theme decorator stamps `data-theme` after the first
|
|
12
|
+
* render (the sweep caught the blue `:root` primary instead of the active
|
|
13
|
+
* theme's own).
|
|
14
|
+
* Re-resolve whenever the theme attribute changes — the same contract the
|
|
15
|
+
* package's internal `useTokenColor` follows.
|
|
16
|
+
*/
|
|
17
|
+
function useThemedTokenColor(name: string): string {
|
|
18
|
+
const [color, setColor] = useState("#000000");
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const update = () => setColor(resolveTokenColor(name));
|
|
21
|
+
update();
|
|
22
|
+
const observer = new MutationObserver(update);
|
|
23
|
+
observer.observe(document.documentElement, {
|
|
24
|
+
attributes: true,
|
|
25
|
+
attributeFilter: ["data-theme", "class"],
|
|
26
|
+
});
|
|
27
|
+
return () => observer.disconnect();
|
|
28
|
+
}, [name]);
|
|
29
|
+
return color;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type RegionProps = { name: string; value: number };
|
|
33
|
+
|
|
34
|
+
// A synthetic "regions" grid (offline-friendly stand-in for country shapes).
|
|
35
|
+
function makeRegions(): GeoJSON.FeatureCollection<GeoJSON.Polygon, RegionProps> {
|
|
36
|
+
const features: GeoJSON.Feature<GeoJSON.Polygon, RegionProps>[] = [];
|
|
37
|
+
let i = 0;
|
|
38
|
+
for (let row = 0; row < 4; row += 1) {
|
|
39
|
+
for (let col = 0; col < 6; col += 1) {
|
|
40
|
+
const x = -12 + col * 8;
|
|
41
|
+
const y = 38 + row * 5;
|
|
42
|
+
i += 1;
|
|
43
|
+
features.push({
|
|
44
|
+
type: "Feature",
|
|
45
|
+
id: `r${i}`,
|
|
46
|
+
properties: { name: `Region ${i}`, value: ((i * 37) % 100) / 100 },
|
|
47
|
+
geometry: {
|
|
48
|
+
type: "Polygon",
|
|
49
|
+
coordinates: [
|
|
50
|
+
[
|
|
51
|
+
[x, y],
|
|
52
|
+
[x + 7.4, y],
|
|
53
|
+
[x + 7.4, y + 4.4],
|
|
54
|
+
[x, y + 4.4],
|
|
55
|
+
[x, y],
|
|
56
|
+
],
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { type: "FeatureCollection", features };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const regions = makeRegions();
|
|
66
|
+
|
|
67
|
+
const meta = {
|
|
68
|
+
title: "Maps/MapGeoJSON",
|
|
69
|
+
component: MapGeoJSON,
|
|
70
|
+
tags: ["autodocs"],
|
|
71
|
+
parameters: { layout: "fullscreen" },
|
|
72
|
+
} satisfies Meta<typeof MapGeoJSON>;
|
|
73
|
+
export default meta;
|
|
74
|
+
type Story = StoryObj<typeof meta>;
|
|
75
|
+
|
|
76
|
+
/** Neutral fills + hairline outlines from the theme tokens, on a blank canvas. */
|
|
77
|
+
export const Default: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<div className="h-[480px]">
|
|
80
|
+
<MapCanvas blank center={[10, 48]} zoom={3.2}>
|
|
81
|
+
<MapGeoJSON data={regions} />
|
|
82
|
+
</MapCanvas>
|
|
83
|
+
</div>
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function ChoroplethDemo() {
|
|
88
|
+
const [hovered, setHovered] = useState<RegionProps | null>(null);
|
|
89
|
+
// WebGL paint can't read CSS variables — resolve the token, re-resolving on
|
|
90
|
+
// theme change (the same mechanism the package uses for its own defaults).
|
|
91
|
+
const primary = useThemedTokenColor("--primary");
|
|
92
|
+
return (
|
|
93
|
+
<div className="relative h-[480px]">
|
|
94
|
+
<MapCanvas blank center={[10, 48]} zoom={3.2}>
|
|
95
|
+
<MapGeoJSON<RegionProps>
|
|
96
|
+
data={regions}
|
|
97
|
+
promoteId="name"
|
|
98
|
+
interactive
|
|
99
|
+
fillPaint={{
|
|
100
|
+
"fill-color": primary,
|
|
101
|
+
"fill-opacity": [
|
|
102
|
+
"interpolate",
|
|
103
|
+
["linear"],
|
|
104
|
+
["get", "value"],
|
|
105
|
+
0,
|
|
106
|
+
0.15,
|
|
107
|
+
1,
|
|
108
|
+
0.95,
|
|
109
|
+
] as never,
|
|
110
|
+
}}
|
|
111
|
+
fillHoverPaint={{ "fill-opacity": 1 }}
|
|
112
|
+
onHover={(e) => setHovered(e?.feature.properties ?? null)}
|
|
113
|
+
/>
|
|
114
|
+
</MapCanvas>
|
|
115
|
+
<div className="absolute top-2 left-2 rounded-md border bg-card px-3 py-2 text-caption text-card-foreground shadow-sm">
|
|
116
|
+
{hovered ? `${hovered.name}: ${(hovered.value * 100).toFixed(0)}%` : "Hover a region…"}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const InteractiveHover: Story = {
|
|
123
|
+
render: () => <ChoroplethDemo />,
|
|
124
|
+
};
|
|
@@ -0,0 +1,274 @@
|
|
|
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 { mergeHoverPaint } from "../lib/merge-hover-paint";
|
|
8
|
+
import { useTokenColor } from "../lib/use-token-color";
|
|
9
|
+
|
|
10
|
+
export type MapGeoJSONData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
|
|
11
|
+
| GeoJSON.FeatureCollection<GeoJSON.Geometry, P>
|
|
12
|
+
| GeoJSON.Feature<GeoJSON.Geometry, P>
|
|
13
|
+
| GeoJSON.Geometry
|
|
14
|
+
| string;
|
|
15
|
+
|
|
16
|
+
export type MapFillPaint = NonNullable<MapLibreGL.FillLayerSpecification["paint"]>;
|
|
17
|
+
export type MapLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
|
18
|
+
|
|
19
|
+
/** A rendered feature with strongly-typed `properties`. */
|
|
20
|
+
export type MapGeoJSONFeature<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
|
|
21
|
+
Omit<MapLibreGL.MapGeoJSONFeature, "properties"> & { properties: P };
|
|
22
|
+
|
|
23
|
+
/** Event payload passed to MapGeoJSON interaction callbacks. */
|
|
24
|
+
export type MapGeoJSONEvent<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
|
|
25
|
+
/** The feature under the cursor, with its typed GeoJSON properties. */
|
|
26
|
+
feature: MapGeoJSONFeature<P>;
|
|
27
|
+
/** Longitude of the cursor at the time of the event. */
|
|
28
|
+
longitude: number;
|
|
29
|
+
/** Latitude of the cursor at the time of the event. */
|
|
30
|
+
latitude: number;
|
|
31
|
+
/** The underlying MapLibre mouse event for advanced use cases. */
|
|
32
|
+
originalEvent: MapLibreGL.MapLayerMouseEvent;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
|
|
36
|
+
/** GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it from. */
|
|
37
|
+
data: MapGeoJSONData<P>;
|
|
38
|
+
/** Optional unique identifier prefix for the source/layers. Auto-generated if not provided. */
|
|
39
|
+
id?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Feature property to promote to the feature `id`. Required for hover
|
|
42
|
+
* feature-state (`fillHoverPaint`) and stable `onHover`/`onClick` payloads.
|
|
43
|
+
*/
|
|
44
|
+
promoteId?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Paint for the polygon fill layer. Merged on top of a theme-aware neutral
|
|
47
|
+
* default (the `--border` token — a mid neutral that reads on the page
|
|
48
|
+
* surface in every theme). Pass `false` to omit the fill layer entirely
|
|
49
|
+
* (e.g. outlines only).
|
|
50
|
+
*/
|
|
51
|
+
fillPaint?: MapFillPaint | false;
|
|
52
|
+
/**
|
|
53
|
+
* Paint for the outline layer. Merged on top of a hairline default
|
|
54
|
+
* (`line-color` = the `--background` token, `line-width` = 0.5) for thin
|
|
55
|
+
* separators. Override `line-color` if your container differs, or pass
|
|
56
|
+
* `false` to omit the layer.
|
|
57
|
+
*/
|
|
58
|
+
linePaint?: MapLinePaint | false;
|
|
59
|
+
/**
|
|
60
|
+
* Paint merged onto the fill layer for the feature under the cursor, applied
|
|
61
|
+
* as a `case` expression keyed on hover feature-state. Requires `promoteId`.
|
|
62
|
+
*/
|
|
63
|
+
fillHoverPaint?: MapFillPaint;
|
|
64
|
+
/** Callback when a feature is clicked. */
|
|
65
|
+
onClick?: (e: MapGeoJSONEvent<P>) => void;
|
|
66
|
+
/** Callback fired when the hovered feature changes; `null` when the cursor leaves. */
|
|
67
|
+
onHover?: (e: MapGeoJSONEvent<P> | null) => void;
|
|
68
|
+
/** Whether features respond to mouse events (default: false). */
|
|
69
|
+
interactive?: boolean;
|
|
70
|
+
/** Optional MapLibre layer id to insert the layers before (z-order control). */
|
|
71
|
+
beforeId?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Renders arbitrary GeoJSON as fill + outline layers on the map. Composes like
|
|
76
|
+
* `MapRoute` / `MapArc` — drop it inside `<MapCanvas>` (typically with `blank`)
|
|
77
|
+
* for choropleths and region/data maps. For full control over expressions and
|
|
78
|
+
* multiple layers, manage layers directly via `useMap()` instead.
|
|
79
|
+
*/
|
|
80
|
+
export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
|
|
81
|
+
data,
|
|
82
|
+
id: propId,
|
|
83
|
+
promoteId,
|
|
84
|
+
fillPaint,
|
|
85
|
+
linePaint,
|
|
86
|
+
fillHoverPaint,
|
|
87
|
+
onClick,
|
|
88
|
+
onHover,
|
|
89
|
+
interactive = false,
|
|
90
|
+
beforeId,
|
|
91
|
+
}: MapGeoJSONProps<P>) {
|
|
92
|
+
const { map, isLoaded } = useMap();
|
|
93
|
+
const autoId = useId();
|
|
94
|
+
const id = propId ?? autoId;
|
|
95
|
+
const sourceId = `geojson-source-${id}`;
|
|
96
|
+
const fillLayerId = `geojson-fill-${id}`;
|
|
97
|
+
const lineLayerId = `geojson-line-${id}`;
|
|
98
|
+
|
|
99
|
+
// Theme-driven neutral defaults: landmass = the mid-neutral `--border` rung,
|
|
100
|
+
// separators = the page surface. Both re-resolve on theme change.
|
|
101
|
+
const defaultFill = useTokenColor("--border");
|
|
102
|
+
const defaultLine = useTokenColor("--background");
|
|
103
|
+
|
|
104
|
+
const showFill = fillPaint !== false;
|
|
105
|
+
const showLine = linePaint !== false;
|
|
106
|
+
|
|
107
|
+
const mergedFillPaint = useMemo(
|
|
108
|
+
() => mergeHoverPaint({ "fill-color": defaultFill, ...(fillPaint || {}) }, fillHoverPaint),
|
|
109
|
+
[defaultFill, fillPaint, fillHoverPaint],
|
|
110
|
+
);
|
|
111
|
+
const mergedLinePaint = useMemo(
|
|
112
|
+
() => ({
|
|
113
|
+
"line-color": defaultLine,
|
|
114
|
+
"line-width": 0.5,
|
|
115
|
+
...(linePaint || {}),
|
|
116
|
+
}),
|
|
117
|
+
[defaultLine, linePaint],
|
|
118
|
+
);
|
|
119
|
+
const latestRef = useRef({ onClick, onHover });
|
|
120
|
+
latestRef.current = { onClick, onHover };
|
|
121
|
+
|
|
122
|
+
// Add source on mount.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!isLoaded || !map) return;
|
|
125
|
+
|
|
126
|
+
map.addSource(sourceId, {
|
|
127
|
+
type: "geojson",
|
|
128
|
+
data,
|
|
129
|
+
...(promoteId ? { promoteId } : {}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return () => {
|
|
133
|
+
try {
|
|
134
|
+
if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
|
|
135
|
+
if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
|
|
136
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
137
|
+
} catch {
|
|
138
|
+
// style may be mid-reload
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- source created once per map; data/layers are synced by the effects below
|
|
142
|
+
}, [isLoaded, map]);
|
|
143
|
+
|
|
144
|
+
// Sync data when it changes.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!isLoaded || !map) return;
|
|
147
|
+
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
|
|
148
|
+
source?.setData(data as never);
|
|
149
|
+
}, [isLoaded, map, data, sourceId]);
|
|
150
|
+
|
|
151
|
+
// Sync layers and paint when visibility or styling changes.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!isLoaded || !map) return;
|
|
154
|
+
|
|
155
|
+
const source = map.getSource(sourceId);
|
|
156
|
+
if (!source) return;
|
|
157
|
+
|
|
158
|
+
if (showFill && !map.getLayer(fillLayerId)) {
|
|
159
|
+
map.addLayer(
|
|
160
|
+
{
|
|
161
|
+
id: fillLayerId,
|
|
162
|
+
type: "fill",
|
|
163
|
+
source: sourceId,
|
|
164
|
+
paint: mergedFillPaint,
|
|
165
|
+
},
|
|
166
|
+
beforeId,
|
|
167
|
+
);
|
|
168
|
+
} else if (!showFill && map.getLayer(fillLayerId)) {
|
|
169
|
+
map.removeLayer(fillLayerId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (showLine && !map.getLayer(lineLayerId)) {
|
|
173
|
+
map.addLayer(
|
|
174
|
+
{
|
|
175
|
+
id: lineLayerId,
|
|
176
|
+
type: "line",
|
|
177
|
+
source: sourceId,
|
|
178
|
+
paint: mergedLinePaint,
|
|
179
|
+
},
|
|
180
|
+
beforeId,
|
|
181
|
+
);
|
|
182
|
+
} else if (!showLine && map.getLayer(lineLayerId)) {
|
|
183
|
+
map.removeLayer(lineLayerId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (showFill && map.getLayer(fillLayerId)) {
|
|
187
|
+
for (const [key, value] of Object.entries(mergedFillPaint)) {
|
|
188
|
+
map.setPaintProperty(fillLayerId, key as keyof MapFillPaint, value as never);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (showLine && map.getLayer(lineLayerId)) {
|
|
192
|
+
for (const [key, value] of Object.entries(mergedLinePaint)) {
|
|
193
|
+
map.setPaintProperty(lineLayerId, key as keyof MapLinePaint, value as never);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}, [
|
|
197
|
+
isLoaded,
|
|
198
|
+
map,
|
|
199
|
+
sourceId,
|
|
200
|
+
fillLayerId,
|
|
201
|
+
lineLayerId,
|
|
202
|
+
showFill,
|
|
203
|
+
showLine,
|
|
204
|
+
mergedFillPaint,
|
|
205
|
+
mergedLinePaint,
|
|
206
|
+
beforeId,
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
// Interaction handlers (bound to the fill layer).
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (!isLoaded || !map || !interactive || !showFill) return;
|
|
212
|
+
|
|
213
|
+
let hoveredId: string | number | null = null;
|
|
214
|
+
|
|
215
|
+
const setHover = (next: string | number | null) => {
|
|
216
|
+
if (next === hoveredId) return;
|
|
217
|
+
const sourceExists = !!map.getSource(sourceId);
|
|
218
|
+
if (hoveredId != null && sourceExists) {
|
|
219
|
+
map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false });
|
|
220
|
+
}
|
|
221
|
+
hoveredId = next;
|
|
222
|
+
if (next != null && sourceExists) {
|
|
223
|
+
map.setFeatureState({ source: sourceId, id: next }, { hover: true });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
228
|
+
const feature = e.features?.[0];
|
|
229
|
+
if (!feature) return;
|
|
230
|
+
map.getCanvas().style.cursor = "pointer";
|
|
231
|
+
|
|
232
|
+
const featureId = feature.id;
|
|
233
|
+
if (featureId === hoveredId) return;
|
|
234
|
+
setHover(featureId ?? null);
|
|
235
|
+
latestRef.current.onHover?.({
|
|
236
|
+
feature: feature as unknown as MapGeoJSONFeature<P>,
|
|
237
|
+
longitude: e.lngLat.lng,
|
|
238
|
+
latitude: e.lngLat.lat,
|
|
239
|
+
originalEvent: e,
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const handleMouseLeave = () => {
|
|
244
|
+
setHover(null);
|
|
245
|
+
map.getCanvas().style.cursor = "";
|
|
246
|
+
latestRef.current.onHover?.(null);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
250
|
+
const feature = e.features?.[0];
|
|
251
|
+
if (!feature) return;
|
|
252
|
+
latestRef.current.onClick?.({
|
|
253
|
+
feature: feature as unknown as MapGeoJSONFeature<P>,
|
|
254
|
+
longitude: e.lngLat.lng,
|
|
255
|
+
latitude: e.lngLat.lat,
|
|
256
|
+
originalEvent: e,
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
map.on("mousemove", fillLayerId, handleMouseMove);
|
|
261
|
+
map.on("mouseleave", fillLayerId, handleMouseLeave);
|
|
262
|
+
map.on("click", fillLayerId, handleClick);
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
map.off("mousemove", fillLayerId, handleMouseMove);
|
|
266
|
+
map.off("mouseleave", fillLayerId, handleMouseLeave);
|
|
267
|
+
map.off("click", fillLayerId, handleClick);
|
|
268
|
+
setHover(null);
|
|
269
|
+
map.getCanvas().style.cursor = "";
|
|
270
|
+
};
|
|
271
|
+
}, [isLoaded, map, fillLayerId, sourceId, interactive, showFill]);
|
|
272
|
+
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
MapMarker,
|
|
3
|
+
MapMarkerContent,
|
|
4
|
+
MapMarkerPopup,
|
|
5
|
+
MapMarkerTooltip,
|
|
6
|
+
MapMarkerLabel,
|
|
7
|
+
type MapMarkerProps,
|
|
8
|
+
type MapMarkerContentProps,
|
|
9
|
+
type MapMarkerPopupProps,
|
|
10
|
+
type MapMarkerTooltipProps,
|
|
11
|
+
type MapMarkerLabelProps,
|
|
12
|
+
} from "./map-marker";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { MapPin } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { MapCanvas } from "../map-canvas";
|
|
5
|
+
import {
|
|
6
|
+
MapMarker,
|
|
7
|
+
MapMarkerContent,
|
|
8
|
+
MapMarkerLabel,
|
|
9
|
+
MapMarkerPopup,
|
|
10
|
+
MapMarkerTooltip,
|
|
11
|
+
} from "./map-marker";
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
title: "Maps/MapMarker",
|
|
15
|
+
component: MapMarker,
|
|
16
|
+
tags: ["autodocs"],
|
|
17
|
+
parameters: { layout: "fullscreen" },
|
|
18
|
+
} satisfies Meta<typeof MapMarker>;
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
render: () => (
|
|
24
|
+
<div className="h-[480px]">
|
|
25
|
+
<MapCanvas center={[13.405, 52.52]} zoom={11}>
|
|
26
|
+
<MapMarker longitude={13.405} latitude={52.52}>
|
|
27
|
+
<MapMarkerContent />
|
|
28
|
+
<MapMarkerLabel>Berlin</MapMarkerLabel>
|
|
29
|
+
</MapMarker>
|
|
30
|
+
</MapCanvas>
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const WithPopupAndTooltip: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div className="h-[480px]">
|
|
38
|
+
<MapCanvas center={[13.41, 52.52]} zoom={12}>
|
|
39
|
+
<MapMarker longitude={13.405} latitude={52.52}>
|
|
40
|
+
<MapMarkerContent />
|
|
41
|
+
<MapMarkerTooltip>Click for details</MapMarkerTooltip>
|
|
42
|
+
<MapMarkerPopup closeButton>
|
|
43
|
+
<p className="text-body font-medium">Alexanderplatz</p>
|
|
44
|
+
<p className="text-caption text-muted-foreground">
|
|
45
|
+
Public square in the Mitte district.
|
|
46
|
+
</p>
|
|
47
|
+
</MapMarkerPopup>
|
|
48
|
+
</MapMarker>
|
|
49
|
+
<MapMarker longitude={13.377} latitude={52.516}>
|
|
50
|
+
<MapMarkerContent>
|
|
51
|
+
<MapPin className="size-6 text-primary" aria-hidden="true" />
|
|
52
|
+
</MapMarkerContent>
|
|
53
|
+
<MapMarkerLabel position="bottom">Brandenburg Gate</MapMarkerLabel>
|
|
54
|
+
</MapMarker>
|
|
55
|
+
</MapCanvas>
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const Draggable: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<div className="h-[480px]">
|
|
63
|
+
<MapCanvas center={[13.405, 52.52]} zoom={11}>
|
|
64
|
+
<MapMarker longitude={13.405} latitude={52.52} draggable>
|
|
65
|
+
<MapMarkerContent />
|
|
66
|
+
<MapMarkerLabel>Drag me</MapMarkerLabel>
|
|
67
|
+
</MapMarker>
|
|
68
|
+
</MapCanvas>
|
|
69
|
+
</div>
|
|
70
|
+
),
|
|
71
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { cleanup, render, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
vi.mock("maplibre-gl", async () => {
|
|
5
|
+
const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
|
|
6
|
+
return createMaplibreMock();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
import { MockMarker, resetMaplibreMock } from "../test-utils/maplibre-mock";
|
|
10
|
+
import { MapCanvas } from "../map-canvas";
|
|
11
|
+
import { MapMarker, MapMarkerContent, MapMarkerLabel } from "./map-marker";
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup();
|
|
15
|
+
resetMaplibreMock();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("MapMarker", () => {
|
|
19
|
+
it("adds the marker to the map at the given position", async () => {
|
|
20
|
+
render(
|
|
21
|
+
<MapCanvas>
|
|
22
|
+
<MapMarker longitude={13.4} latitude={52.52}>
|
|
23
|
+
<MapMarkerContent />
|
|
24
|
+
</MapMarker>
|
|
25
|
+
</MapCanvas>,
|
|
26
|
+
);
|
|
27
|
+
await waitFor(() => {
|
|
28
|
+
expect(MockMarker.instances).toHaveLength(1);
|
|
29
|
+
expect(MockMarker.instances[0]!.addedTo).not.toBeNull();
|
|
30
|
+
});
|
|
31
|
+
expect(MockMarker.instances[0]!.lngLat).toEqual({ lng: 13.4, lat: 52.52 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("portals content into the marker element (default icon)", async () => {
|
|
35
|
+
render(
|
|
36
|
+
<MapCanvas>
|
|
37
|
+
<MapMarker longitude={0} latitude={0}>
|
|
38
|
+
<MapMarkerContent />
|
|
39
|
+
</MapMarker>
|
|
40
|
+
</MapCanvas>,
|
|
41
|
+
);
|
|
42
|
+
await waitFor(() => {
|
|
43
|
+
expect(MockMarker.instances[0]!.element.querySelector(".bg-primary")).not.toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders custom content and labels", async () => {
|
|
48
|
+
render(
|
|
49
|
+
<MapCanvas>
|
|
50
|
+
<MapMarker longitude={0} latitude={0}>
|
|
51
|
+
<MapMarkerContent>
|
|
52
|
+
<span data-testid="pin">pin</span>
|
|
53
|
+
</MapMarkerContent>
|
|
54
|
+
<MapMarkerLabel>Berlin</MapMarkerLabel>
|
|
55
|
+
</MapMarker>
|
|
56
|
+
</MapCanvas>,
|
|
57
|
+
);
|
|
58
|
+
await waitFor(() => {
|
|
59
|
+
const el = MockMarker.instances[0]!.element;
|
|
60
|
+
expect(el.querySelector("[data-testid='pin']")).not.toBeNull();
|
|
61
|
+
// The label anchors to the marker element even when composed as a
|
|
62
|
+
// SIBLING of MapMarkerContent (it portals itself).
|
|
63
|
+
expect(el.textContent).toContain("Berlin");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("removes the marker on unmount", async () => {
|
|
68
|
+
const { unmount } = render(
|
|
69
|
+
<MapCanvas>
|
|
70
|
+
<MapMarker longitude={0} latitude={0}>
|
|
71
|
+
<MapMarkerContent />
|
|
72
|
+
</MapMarker>
|
|
73
|
+
</MapCanvas>,
|
|
74
|
+
);
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(MockMarker.instances[0]!.addedTo).not.toBeNull();
|
|
77
|
+
});
|
|
78
|
+
unmount();
|
|
79
|
+
expect(MockMarker.instances[0]!.addedTo).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|