@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,292 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type MapLibreGL from "maplibre-gl";
|
|
4
|
+
import { useEffect, useId, useMemo } from "react";
|
|
5
|
+
|
|
6
|
+
import { useMap } from "../map-canvas/map-context";
|
|
7
|
+
import { useTokenColor } from "../lib/use-token-color";
|
|
8
|
+
|
|
9
|
+
export type MapClusterLayerProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
|
|
10
|
+
{
|
|
11
|
+
/** GeoJSON FeatureCollection data or a URL to fetch GeoJSON from. */
|
|
12
|
+
data: string | GeoJSON.FeatureCollection<GeoJSON.Point, P>;
|
|
13
|
+
/** Maximum zoom level to cluster points on (default: 14). */
|
|
14
|
+
clusterMaxZoom?: number;
|
|
15
|
+
/** Radius of each cluster when clustering points, in pixels (default: 50). */
|
|
16
|
+
clusterRadius?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Colors for cluster circles: [small, medium, large] based on point count.
|
|
19
|
+
* Defaults to the theme's `--success`/`--warning`/`--destructive` tokens.
|
|
20
|
+
*/
|
|
21
|
+
clusterColors?: [string, string, string];
|
|
22
|
+
/** Point-count thresholds for the color/size steps: [medium, large] (default: [100, 750]). */
|
|
23
|
+
clusterThresholds?: [number, number];
|
|
24
|
+
/** Color for unclustered individual points. Defaults to the theme's `--primary` token. */
|
|
25
|
+
pointColor?: string;
|
|
26
|
+
/** Callback when an unclustered point is clicked. */
|
|
27
|
+
onPointClick?: (
|
|
28
|
+
feature: GeoJSON.Feature<GeoJSON.Point, P>,
|
|
29
|
+
coordinates: [number, number],
|
|
30
|
+
) => void;
|
|
31
|
+
/** Callback when a cluster is clicked. If not provided, zooms into the cluster. */
|
|
32
|
+
onClusterClick?: (clusterId: number, coordinates: [number, number], pointCount: number) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const DEFAULT_CLUSTER_THRESHOLDS: [number, number] = [100, 750];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Clustered point rendering for large point datasets. Cluster circles step
|
|
39
|
+
* through the status tokens (success → warning → destructive) as the point
|
|
40
|
+
* count grows; strokes and count labels use the page surface for contrast.
|
|
41
|
+
*/
|
|
42
|
+
export function MapClusterLayer<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
|
|
43
|
+
data,
|
|
44
|
+
clusterMaxZoom = 14,
|
|
45
|
+
clusterRadius = 50,
|
|
46
|
+
clusterColors,
|
|
47
|
+
clusterThresholds = DEFAULT_CLUSTER_THRESHOLDS,
|
|
48
|
+
pointColor,
|
|
49
|
+
onPointClick,
|
|
50
|
+
onClusterClick,
|
|
51
|
+
}: MapClusterLayerProps<P>) {
|
|
52
|
+
const { map, isLoaded } = useMap();
|
|
53
|
+
const id = useId();
|
|
54
|
+
const sourceId = `cluster-source-${id}`;
|
|
55
|
+
const clusterLayerId = `clusters-${id}`;
|
|
56
|
+
const clusterCountLayerId = `cluster-count-${id}`;
|
|
57
|
+
const unclusteredLayerId = `unclustered-point-${id}`;
|
|
58
|
+
|
|
59
|
+
const success = useTokenColor("--success");
|
|
60
|
+
const warning = useTokenColor("--warning");
|
|
61
|
+
const destructive = useTokenColor("--destructive");
|
|
62
|
+
const primary = useTokenColor("--primary");
|
|
63
|
+
const surface = useTokenColor("--background");
|
|
64
|
+
|
|
65
|
+
const resolvedClusterColors = useMemo<[string, string, string]>(
|
|
66
|
+
() => clusterColors ?? [success, warning, destructive],
|
|
67
|
+
[clusterColors, success, warning, destructive],
|
|
68
|
+
);
|
|
69
|
+
const resolvedPointColor = pointColor ?? primary;
|
|
70
|
+
|
|
71
|
+
// Add source and layers on mount.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!isLoaded || !map) return;
|
|
74
|
+
|
|
75
|
+
map.addSource(sourceId, {
|
|
76
|
+
type: "geojson",
|
|
77
|
+
data,
|
|
78
|
+
cluster: true,
|
|
79
|
+
clusterMaxZoom,
|
|
80
|
+
clusterRadius,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
map.addLayer({
|
|
84
|
+
id: clusterLayerId,
|
|
85
|
+
type: "circle",
|
|
86
|
+
source: sourceId,
|
|
87
|
+
filter: ["has", "point_count"],
|
|
88
|
+
paint: {
|
|
89
|
+
"circle-color": [
|
|
90
|
+
"step",
|
|
91
|
+
["get", "point_count"],
|
|
92
|
+
resolvedClusterColors[0],
|
|
93
|
+
clusterThresholds[0],
|
|
94
|
+
resolvedClusterColors[1],
|
|
95
|
+
clusterThresholds[1],
|
|
96
|
+
resolvedClusterColors[2],
|
|
97
|
+
],
|
|
98
|
+
"circle-radius": [
|
|
99
|
+
"step",
|
|
100
|
+
["get", "point_count"],
|
|
101
|
+
20,
|
|
102
|
+
clusterThresholds[0],
|
|
103
|
+
30,
|
|
104
|
+
clusterThresholds[1],
|
|
105
|
+
40,
|
|
106
|
+
],
|
|
107
|
+
"circle-stroke-width": 1,
|
|
108
|
+
"circle-stroke-color": surface,
|
|
109
|
+
"circle-opacity": 0.85,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
map.addLayer({
|
|
114
|
+
id: clusterCountLayerId,
|
|
115
|
+
type: "symbol",
|
|
116
|
+
source: sourceId,
|
|
117
|
+
filter: ["has", "point_count"],
|
|
118
|
+
layout: {
|
|
119
|
+
"text-field": "{point_count_abbreviated}",
|
|
120
|
+
"text-font": ["Open Sans"],
|
|
121
|
+
"text-size": 12,
|
|
122
|
+
},
|
|
123
|
+
paint: {
|
|
124
|
+
"text-color": surface,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
map.addLayer({
|
|
129
|
+
id: unclusteredLayerId,
|
|
130
|
+
type: "circle",
|
|
131
|
+
source: sourceId,
|
|
132
|
+
filter: ["!", ["has", "point_count"]],
|
|
133
|
+
paint: {
|
|
134
|
+
"circle-color": resolvedPointColor,
|
|
135
|
+
"circle-radius": 5,
|
|
136
|
+
"circle-stroke-width": 2,
|
|
137
|
+
"circle-stroke-color": surface,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
try {
|
|
143
|
+
if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
|
|
144
|
+
if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
|
|
145
|
+
if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
|
|
146
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
147
|
+
} catch {
|
|
148
|
+
// style may be mid-reload
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- source/layers created once per map; data + paint are synced by the effects below
|
|
152
|
+
}, [isLoaded, map, sourceId]);
|
|
153
|
+
|
|
154
|
+
// Update source data when the data prop changes (only for non-URL data).
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!isLoaded || !map || typeof data === "string") return;
|
|
157
|
+
|
|
158
|
+
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
|
|
159
|
+
if (source) {
|
|
160
|
+
source.setData(data);
|
|
161
|
+
}
|
|
162
|
+
}, [isLoaded, map, data, sourceId]);
|
|
163
|
+
|
|
164
|
+
// Sync layer styles when props (or the resolved theme colors) change.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!isLoaded || !map) return;
|
|
167
|
+
|
|
168
|
+
if (map.getLayer(clusterLayerId)) {
|
|
169
|
+
map.setPaintProperty(clusterLayerId, "circle-color", [
|
|
170
|
+
"step",
|
|
171
|
+
["get", "point_count"],
|
|
172
|
+
resolvedClusterColors[0],
|
|
173
|
+
clusterThresholds[0],
|
|
174
|
+
resolvedClusterColors[1],
|
|
175
|
+
clusterThresholds[1],
|
|
176
|
+
resolvedClusterColors[2],
|
|
177
|
+
]);
|
|
178
|
+
map.setPaintProperty(clusterLayerId, "circle-radius", [
|
|
179
|
+
"step",
|
|
180
|
+
["get", "point_count"],
|
|
181
|
+
20,
|
|
182
|
+
clusterThresholds[0],
|
|
183
|
+
30,
|
|
184
|
+
clusterThresholds[1],
|
|
185
|
+
40,
|
|
186
|
+
]);
|
|
187
|
+
map.setPaintProperty(clusterLayerId, "circle-stroke-color", surface);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (map.getLayer(clusterCountLayerId)) {
|
|
191
|
+
map.setPaintProperty(clusterCountLayerId, "text-color", surface);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (map.getLayer(unclusteredLayerId)) {
|
|
195
|
+
map.setPaintProperty(unclusteredLayerId, "circle-color", resolvedPointColor);
|
|
196
|
+
map.setPaintProperty(unclusteredLayerId, "circle-stroke-color", surface);
|
|
197
|
+
}
|
|
198
|
+
}, [
|
|
199
|
+
isLoaded,
|
|
200
|
+
map,
|
|
201
|
+
clusterLayerId,
|
|
202
|
+
clusterCountLayerId,
|
|
203
|
+
unclusteredLayerId,
|
|
204
|
+
resolvedClusterColors,
|
|
205
|
+
clusterThresholds,
|
|
206
|
+
resolvedPointColor,
|
|
207
|
+
surface,
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
// Handle click events.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!isLoaded || !map) return;
|
|
213
|
+
|
|
214
|
+
// Cluster click handler — zoom into the cluster by default.
|
|
215
|
+
const handleClusterClick = async (e: MapLibreGL.MapMouseEvent) => {
|
|
216
|
+
const features = map.queryRenderedFeatures(e.point, {
|
|
217
|
+
layers: [clusterLayerId],
|
|
218
|
+
});
|
|
219
|
+
const feature = features[0];
|
|
220
|
+
if (!feature) return;
|
|
221
|
+
const clusterId = feature.properties?.cluster_id as number;
|
|
222
|
+
const pointCount = feature.properties?.point_count as number;
|
|
223
|
+
const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [number, number];
|
|
224
|
+
|
|
225
|
+
if (onClusterClick) {
|
|
226
|
+
onClusterClick(clusterId, coordinates, pointCount);
|
|
227
|
+
} else {
|
|
228
|
+
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
|
|
229
|
+
const zoom = await source.getClusterExpansionZoom(clusterId);
|
|
230
|
+
map.easeTo({
|
|
231
|
+
center: coordinates,
|
|
232
|
+
zoom,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Unclustered point click handler.
|
|
238
|
+
const handlePointClick = (
|
|
239
|
+
e: MapLibreGL.MapMouseEvent & {
|
|
240
|
+
features?: MapLibreGL.MapGeoJSONFeature[];
|
|
241
|
+
},
|
|
242
|
+
) => {
|
|
243
|
+
const feature = e.features?.[0];
|
|
244
|
+
if (!onPointClick || !feature) return;
|
|
245
|
+
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [
|
|
246
|
+
number,
|
|
247
|
+
number,
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
// Handle world copies.
|
|
251
|
+
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
|
|
252
|
+
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onPointClick(feature as unknown as GeoJSON.Feature<GeoJSON.Point, P>, coordinates);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Cursor style handlers.
|
|
259
|
+
const handleMouseEnterCluster = () => {
|
|
260
|
+
map.getCanvas().style.cursor = "pointer";
|
|
261
|
+
};
|
|
262
|
+
const handleMouseLeaveCluster = () => {
|
|
263
|
+
map.getCanvas().style.cursor = "";
|
|
264
|
+
};
|
|
265
|
+
const handleMouseEnterPoint = () => {
|
|
266
|
+
if (onPointClick) {
|
|
267
|
+
map.getCanvas().style.cursor = "pointer";
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const handleMouseLeavePoint = () => {
|
|
271
|
+
map.getCanvas().style.cursor = "";
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
map.on("click", clusterLayerId, handleClusterClick);
|
|
275
|
+
map.on("click", unclusteredLayerId, handlePointClick);
|
|
276
|
+
map.on("mouseenter", clusterLayerId, handleMouseEnterCluster);
|
|
277
|
+
map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster);
|
|
278
|
+
map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
|
|
279
|
+
map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
|
|
280
|
+
|
|
281
|
+
return () => {
|
|
282
|
+
map.off("click", clusterLayerId, handleClusterClick);
|
|
283
|
+
map.off("click", unclusteredLayerId, handlePointClick);
|
|
284
|
+
map.off("mouseenter", clusterLayerId, handleMouseEnterCluster);
|
|
285
|
+
map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster);
|
|
286
|
+
map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
|
|
287
|
+
map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
|
|
288
|
+
};
|
|
289
|
+
}, [isLoaded, map, clusterLayerId, unclusteredLayerId, sourceId, onClusterClick, onPointClick]);
|
|
290
|
+
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MapControls, type MapControlsProps } from "./map-controls";
|
|
@@ -0,0 +1,43 @@
|
|
|
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/MapControls",
|
|
8
|
+
component: MapControls,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
parameters: { layout: "fullscreen" },
|
|
11
|
+
} satisfies Meta<typeof MapControls>;
|
|
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={[2.3522, 48.8566]} zoom={11}>
|
|
19
|
+
<MapControls />
|
|
20
|
+
</MapCanvas>
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const AllControls: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<div className="h-[480px]">
|
|
28
|
+
<MapCanvas center={[2.3522, 48.8566]} zoom={11} pitch={30} bearing={-20}>
|
|
29
|
+
<MapControls showZoom showCompass showLocate showFullscreen />
|
|
30
|
+
</MapCanvas>
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const TopLeft: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div className="h-[480px]">
|
|
38
|
+
<MapCanvas center={[2.3522, 48.8566]} zoom={11}>
|
|
39
|
+
<MapControls position="top-left" showCompass />
|
|
40
|
+
</MapCanvas>
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
vi.mock("maplibre-gl", async () => {
|
|
6
|
+
const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
|
|
7
|
+
return createMaplibreMock();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
import { MockMap, resetMaplibreMock } from "../test-utils/maplibre-mock";
|
|
11
|
+
import { MapCanvas } from "../map-canvas";
|
|
12
|
+
import { MapControls } from "./map-controls";
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
cleanup();
|
|
16
|
+
resetMaplibreMock();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("MapControls", () => {
|
|
20
|
+
it("renders zoom buttons by default with accessible names", async () => {
|
|
21
|
+
render(
|
|
22
|
+
<MapCanvas>
|
|
23
|
+
<MapControls />
|
|
24
|
+
</MapCanvas>,
|
|
25
|
+
);
|
|
26
|
+
expect(await screen.findByRole("button", { name: "Zoom in" })).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByRole("button", { name: "Zoom out" })).toBeInTheDocument();
|
|
28
|
+
expect(
|
|
29
|
+
screen.queryByRole("button", { name: "Reset bearing to north" }),
|
|
30
|
+
).not.toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders compass, locate and fullscreen controls when enabled", async () => {
|
|
34
|
+
render(
|
|
35
|
+
<MapCanvas>
|
|
36
|
+
<MapControls showCompass showLocate showFullscreen />
|
|
37
|
+
</MapCanvas>,
|
|
38
|
+
);
|
|
39
|
+
expect(
|
|
40
|
+
await screen.findByRole("button", { name: "Reset bearing to north" }),
|
|
41
|
+
).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByRole("button", { name: "Find my location" })).toBeInTheDocument();
|
|
43
|
+
expect(screen.getByRole("button", { name: "Toggle fullscreen" })).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("zooms the map when zoom in is clicked", async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
render(
|
|
49
|
+
<MapCanvas>
|
|
50
|
+
<MapControls />
|
|
51
|
+
</MapCanvas>,
|
|
52
|
+
);
|
|
53
|
+
await user.click(await screen.findByRole("button", { name: "Zoom in" }));
|
|
54
|
+
expect(MockMap.instances[0]!.zoomToCalls.length).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { Locate, Maximize, Minus, Plus } from "lucide-react";
|
|
5
|
+
import { Spinner } from "@elabs-ai/components-ui";
|
|
6
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
7
|
+
|
|
8
|
+
import { useMap } from "../map-canvas/map-context";
|
|
9
|
+
|
|
10
|
+
export interface MapControlsProps {
|
|
11
|
+
/** Position of the controls on the map (default: "bottom-right"). */
|
|
12
|
+
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
13
|
+
/** Show zoom in/out buttons (default: true). */
|
|
14
|
+
showZoom?: boolean;
|
|
15
|
+
/** Show a compass button to reset bearing/pitch (default: false). */
|
|
16
|
+
showCompass?: boolean;
|
|
17
|
+
/** Show a locate button to fly to the user's location (default: false). */
|
|
18
|
+
showLocate?: boolean;
|
|
19
|
+
/** Show a fullscreen toggle button (default: false). */
|
|
20
|
+
showFullscreen?: boolean;
|
|
21
|
+
/** Additional CSS classes for the controls container. */
|
|
22
|
+
className?: string;
|
|
23
|
+
/** Callback with user coordinates when located. */
|
|
24
|
+
onLocate?: (coords: { longitude: number; latitude: number }) => void;
|
|
25
|
+
/** Callback when geolocation fails or is denied. */
|
|
26
|
+
onLocateError?: (error: GeolocationPositionError) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// bottom-right sits above MapLibre's attribution control — keep it visible.
|
|
30
|
+
const positionClasses = {
|
|
31
|
+
"top-left": "top-2 left-2",
|
|
32
|
+
"top-right": "top-2 right-2",
|
|
33
|
+
"bottom-left": "bottom-2 left-2",
|
|
34
|
+
"bottom-right": "bottom-10 right-2",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function ControlGroup({ children }: { children: ReactNode }) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-col divide-y overflow-hidden rounded-md bg-surface-elevated shadow-ring-sm">
|
|
40
|
+
{children}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ControlButton({
|
|
46
|
+
onClick,
|
|
47
|
+
label,
|
|
48
|
+
children,
|
|
49
|
+
disabled = false,
|
|
50
|
+
}: {
|
|
51
|
+
onClick: () => void;
|
|
52
|
+
label: string;
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
onClick={onClick}
|
|
59
|
+
aria-label={label}
|
|
60
|
+
type="button"
|
|
61
|
+
className={cn(
|
|
62
|
+
"flex size-8 items-center justify-center text-foreground transition-colors duration-fast",
|
|
63
|
+
"hover:bg-surface-muted",
|
|
64
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
|
65
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
66
|
+
)}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Branded zoom / compass / locate / fullscreen controls. Render inside `<MapCanvas>`. */
|
|
75
|
+
export function MapControls({
|
|
76
|
+
position = "bottom-right",
|
|
77
|
+
showZoom = true,
|
|
78
|
+
showCompass = false,
|
|
79
|
+
showLocate = false,
|
|
80
|
+
showFullscreen = false,
|
|
81
|
+
className,
|
|
82
|
+
onLocate,
|
|
83
|
+
onLocateError,
|
|
84
|
+
}: MapControlsProps) {
|
|
85
|
+
const { map } = useMap();
|
|
86
|
+
const [waitingForLocation, setWaitingForLocation] = useState(false);
|
|
87
|
+
|
|
88
|
+
const handleZoomIn = useCallback(() => {
|
|
89
|
+
map?.zoomTo(map.getZoom() + 1, { duration: 300 });
|
|
90
|
+
}, [map]);
|
|
91
|
+
|
|
92
|
+
const handleZoomOut = useCallback(() => {
|
|
93
|
+
map?.zoomTo(map.getZoom() - 1, { duration: 300 });
|
|
94
|
+
}, [map]);
|
|
95
|
+
|
|
96
|
+
const handleResetBearing = useCallback(() => {
|
|
97
|
+
map?.resetNorthPitch({ duration: 300 });
|
|
98
|
+
}, [map]);
|
|
99
|
+
|
|
100
|
+
const handleLocate = useCallback(() => {
|
|
101
|
+
if (!("geolocation" in navigator)) return;
|
|
102
|
+
setWaitingForLocation(true);
|
|
103
|
+
navigator.geolocation.getCurrentPosition(
|
|
104
|
+
(pos) => {
|
|
105
|
+
const coords = {
|
|
106
|
+
longitude: pos.coords.longitude,
|
|
107
|
+
latitude: pos.coords.latitude,
|
|
108
|
+
};
|
|
109
|
+
map?.flyTo({
|
|
110
|
+
center: [coords.longitude, coords.latitude],
|
|
111
|
+
zoom: 14,
|
|
112
|
+
duration: 1500,
|
|
113
|
+
});
|
|
114
|
+
onLocate?.(coords);
|
|
115
|
+
setWaitingForLocation(false);
|
|
116
|
+
},
|
|
117
|
+
(error) => {
|
|
118
|
+
onLocateError?.(error);
|
|
119
|
+
setWaitingForLocation(false);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
}, [map, onLocate, onLocateError]);
|
|
123
|
+
|
|
124
|
+
const handleFullscreen = useCallback(() => {
|
|
125
|
+
const container = map?.getContainer();
|
|
126
|
+
if (!container) return;
|
|
127
|
+
if (document.fullscreenElement) {
|
|
128
|
+
void document.exitFullscreen();
|
|
129
|
+
} else {
|
|
130
|
+
void container.requestFullscreen();
|
|
131
|
+
}
|
|
132
|
+
}, [map]);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
className={cn("absolute z-10 flex flex-col gap-1.5", positionClasses[position], className)}
|
|
137
|
+
>
|
|
138
|
+
{showZoom && (
|
|
139
|
+
<ControlGroup>
|
|
140
|
+
<ControlButton onClick={handleZoomIn} label="Zoom in">
|
|
141
|
+
<Plus className="size-4" aria-hidden="true" />
|
|
142
|
+
</ControlButton>
|
|
143
|
+
<ControlButton onClick={handleZoomOut} label="Zoom out">
|
|
144
|
+
<Minus className="size-4" aria-hidden="true" />
|
|
145
|
+
</ControlButton>
|
|
146
|
+
</ControlGroup>
|
|
147
|
+
)}
|
|
148
|
+
{showCompass && (
|
|
149
|
+
<ControlGroup>
|
|
150
|
+
<CompassButton onClick={handleResetBearing} />
|
|
151
|
+
</ControlGroup>
|
|
152
|
+
)}
|
|
153
|
+
{showLocate && (
|
|
154
|
+
<ControlGroup>
|
|
155
|
+
<ControlButton
|
|
156
|
+
onClick={handleLocate}
|
|
157
|
+
label="Find my location"
|
|
158
|
+
disabled={waitingForLocation}
|
|
159
|
+
>
|
|
160
|
+
{waitingForLocation ? (
|
|
161
|
+
<Spinner label="Locating" className="size-4 text-foreground" />
|
|
162
|
+
) : (
|
|
163
|
+
<Locate className="size-4" aria-hidden="true" />
|
|
164
|
+
)}
|
|
165
|
+
</ControlButton>
|
|
166
|
+
</ControlGroup>
|
|
167
|
+
)}
|
|
168
|
+
{showFullscreen && (
|
|
169
|
+
<ControlGroup>
|
|
170
|
+
<ControlButton onClick={handleFullscreen} label="Toggle fullscreen">
|
|
171
|
+
<Maximize className="size-4" aria-hidden="true" />
|
|
172
|
+
</ControlButton>
|
|
173
|
+
</ControlGroup>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function CompassButton({ onClick }: { onClick: () => void }) {
|
|
180
|
+
const { map } = useMap();
|
|
181
|
+
const compassRef = useRef<SVGSVGElement>(null);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!map || !compassRef.current) return;
|
|
185
|
+
|
|
186
|
+
const compass = compassRef.current;
|
|
187
|
+
|
|
188
|
+
const updateRotation = () => {
|
|
189
|
+
const bearing = map.getBearing();
|
|
190
|
+
const pitch = map.getPitch();
|
|
191
|
+
compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
map.on("rotate", updateRotation);
|
|
195
|
+
map.on("pitch", updateRotation);
|
|
196
|
+
updateRotation();
|
|
197
|
+
|
|
198
|
+
return () => {
|
|
199
|
+
map.off("rotate", updateRotation);
|
|
200
|
+
map.off("pitch", updateRotation);
|
|
201
|
+
};
|
|
202
|
+
}, [map]);
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<ControlButton onClick={onClick} label="Reset bearing to north">
|
|
206
|
+
<svg
|
|
207
|
+
ref={compassRef}
|
|
208
|
+
viewBox="0 0 24 24"
|
|
209
|
+
aria-hidden="true"
|
|
210
|
+
className="size-5 transition-transform duration-fast"
|
|
211
|
+
style={{ transformStyle: "preserve-3d" }}
|
|
212
|
+
>
|
|
213
|
+
<path d="M12 2L16 12H12V2Z" className="fill-destructive" />
|
|
214
|
+
<path d="M12 2L8 12H12V2Z" className="fill-destructive/50" />
|
|
215
|
+
<path d="M12 22L16 12H12V22Z" className="fill-muted-foreground/60" />
|
|
216
|
+
<path d="M12 22L8 12H12V22Z" className="fill-muted-foreground/30" />
|
|
217
|
+
</svg>
|
|
218
|
+
</ControlButton>
|
|
219
|
+
);
|
|
220
|
+
}
|