@elabs-ai/components-maps 4.1.0 → 5.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/README.md +1 -1
- package/dist/chunk-2FPB53NG.js +36 -0
- package/dist/chunk-2FPB53NG.js.map +1 -0
- package/dist/chunk-2VVJRT66.js +37 -0
- package/dist/chunk-2VVJRT66.js.map +1 -0
- package/dist/chunk-3HXFONRP.js +2 -0
- package/dist/chunk-3HXFONRP.js.map +1 -0
- package/dist/chunk-3SJN3IYT.js +2 -0
- package/dist/chunk-3SJN3IYT.js.map +1 -0
- package/dist/chunk-4DANBD5B.js +55 -0
- package/dist/chunk-4DANBD5B.js.map +1 -0
- package/dist/chunk-5PDVG53B.js +2 -0
- package/dist/chunk-5PDVG53B.js.map +1 -0
- package/dist/chunk-643R7IJC.js +2 -0
- package/dist/chunk-643R7IJC.js.map +1 -0
- package/dist/chunk-B3ZDBQ6I.js +164 -0
- package/dist/chunk-B3ZDBQ6I.js.map +1 -0
- package/dist/chunk-BGCE3RR5.js +35 -0
- package/dist/chunk-BGCE3RR5.js.map +1 -0
- package/dist/chunk-C4VI37TD.js +485 -0
- package/dist/chunk-C4VI37TD.js.map +1 -0
- package/dist/chunk-CJJT6AEI.js +179 -0
- package/dist/chunk-CJJT6AEI.js.map +1 -0
- package/dist/chunk-D2RCPTRO.js +2 -0
- package/dist/chunk-D2RCPTRO.js.map +1 -0
- package/dist/chunk-DTV7JZVN.js +2 -0
- package/dist/chunk-DTV7JZVN.js.map +1 -0
- package/dist/chunk-E65Z3MSV.js +80 -0
- package/dist/chunk-E65Z3MSV.js.map +1 -0
- package/dist/chunk-E7VPNINZ.js +70 -0
- package/dist/chunk-E7VPNINZ.js.map +1 -0
- package/dist/chunk-FHV5IA6N.js +100 -0
- package/dist/chunk-FHV5IA6N.js.map +1 -0
- package/dist/chunk-FQ6V7BFZ.js +347 -0
- package/dist/chunk-FQ6V7BFZ.js.map +1 -0
- package/dist/chunk-G7JDXDJN.js +19 -0
- package/dist/chunk-G7JDXDJN.js.map +1 -0
- package/dist/chunk-GWULZOMW.js +21 -0
- package/dist/chunk-GWULZOMW.js.map +1 -0
- package/dist/chunk-GYDTTAVD.js +120 -0
- package/dist/chunk-GYDTTAVD.js.map +1 -0
- package/dist/chunk-HCOBWIM4.js +48 -0
- package/dist/chunk-HCOBWIM4.js.map +1 -0
- package/dist/chunk-IAHK5MVT.js +2 -0
- package/dist/chunk-IAHK5MVT.js.map +1 -0
- package/dist/chunk-JGJ6442I.js +235 -0
- package/dist/chunk-JGJ6442I.js.map +1 -0
- package/dist/chunk-JYFERXZT.js +18 -0
- package/dist/chunk-JYFERXZT.js.map +1 -0
- package/dist/chunk-LU2UEKFM.js +53 -0
- package/dist/chunk-LU2UEKFM.js.map +1 -0
- package/dist/chunk-LVYM6C5C.js +2 -0
- package/dist/chunk-LVYM6C5C.js.map +1 -0
- package/dist/chunk-MIMDP3EU.js +104 -0
- package/dist/chunk-MIMDP3EU.js.map +1 -0
- package/dist/chunk-NNBURJHY.js +2 -0
- package/dist/chunk-NNBURJHY.js.map +1 -0
- package/dist/chunk-NPUC3V37.js +2 -0
- package/dist/chunk-NPUC3V37.js.map +1 -0
- package/dist/chunk-OEVVNPN3.js +201 -0
- package/dist/chunk-OEVVNPN3.js.map +1 -0
- package/dist/chunk-PF3R2BXM.js +200 -0
- package/dist/chunk-PF3R2BXM.js.map +1 -0
- package/dist/chunk-Q2I5YODW.js +2 -0
- package/dist/chunk-Q2I5YODW.js.map +1 -0
- package/dist/chunk-QDWWP3N7.js +247 -0
- package/dist/chunk-QDWWP3N7.js.map +1 -0
- package/dist/chunk-QU33KPMP.js +2 -0
- package/dist/chunk-QU33KPMP.js.map +1 -0
- package/dist/chunk-RK4BUMHS.js +2 -0
- package/dist/chunk-RK4BUMHS.js.map +1 -0
- package/dist/chunk-RNSLG64F.js +26 -0
- package/dist/chunk-RNSLG64F.js.map +1 -0
- package/dist/chunk-RV673TB4.js +68 -0
- package/dist/chunk-RV673TB4.js.map +1 -0
- package/dist/chunk-S7N2WMVT.js +180 -0
- package/dist/chunk-S7N2WMVT.js.map +1 -0
- package/dist/chunk-T3Z6TCDD.js +2 -0
- package/dist/chunk-T3Z6TCDD.js.map +1 -0
- package/dist/chunk-T4TJTVVA.js +105 -0
- package/dist/chunk-T4TJTVVA.js.map +1 -0
- package/dist/chunk-UMOYRCDX.js +103 -0
- package/dist/chunk-UMOYRCDX.js.map +1 -0
- package/dist/chunk-V2REM6UT.js +185 -0
- package/dist/chunk-V2REM6UT.js.map +1 -0
- package/dist/chunk-VFBRFGID.js +89 -0
- package/dist/chunk-VFBRFGID.js.map +1 -0
- package/dist/chunk-W76NLFER.js +103 -0
- package/dist/chunk-W76NLFER.js.map +1 -0
- package/dist/chunk-WNU4IOGL.js +116 -0
- package/dist/chunk-WNU4IOGL.js.map +1 -0
- package/dist/chunk-WS4Z7ENS.js +494 -0
- package/dist/chunk-WS4Z7ENS.js.map +1 -0
- package/dist/chunk-XQT7YLJY.js +497 -0
- package/dist/chunk-XQT7YLJY.js.map +1 -0
- package/dist/chunk-Y44MEKJV.js +21 -0
- package/dist/chunk-Y44MEKJV.js.map +1 -0
- package/dist/chunk-Z4EPSOB4.js +53 -0
- package/dist/chunk-Z4EPSOB4.js.map +1 -0
- package/dist/chunk-Z5OPDBDC.js +2 -0
- package/dist/chunk-Z5OPDBDC.js.map +1 -0
- package/dist/index.d.ts +1241 -46
- package/dist/index.js +189 -1456
- package/dist/index.js.map +1 -1
- package/dist/lib/arc-math.js +8 -0
- package/dist/lib/arc-math.js.map +1 -0
- package/dist/lib/map-position.js +11 -0
- package/dist/lib/map-position.js.map +1 -0
- package/dist/lib/merge-hover-paint.js +10 -0
- package/dist/lib/merge-hover-paint.js.map +1 -0
- package/dist/lib/plan-crs.js +14 -0
- package/dist/lib/plan-crs.js.map +1 -0
- package/dist/lib/plan-patterns.js +14 -0
- package/dist/lib/plan-patterns.js.map +1 -0
- package/dist/lib/plan-regions.js +18 -0
- package/dist/lib/plan-regions.js.map +1 -0
- package/dist/lib/plan-status.js +14 -0
- package/dist/lib/plan-status.js.map +1 -0
- package/dist/lib/route-arrow.js +12 -0
- package/dist/lib/route-arrow.js.map +1 -0
- package/dist/lib/use-map-breakpoint.js +33 -0
- package/dist/lib/use-map-breakpoint.js.map +1 -0
- package/dist/lib/use-plan-projection.js +9 -0
- package/dist/lib/use-plan-projection.js.map +1 -0
- package/dist/lib/use-token-color.js +10 -0
- package/dist/lib/use-token-color.js.map +1 -0
- package/dist/lib/warn-once.js +10 -0
- package/dist/lib/warn-once.js.map +1 -0
- package/dist/map-annotation/anchor.js +14 -0
- package/dist/map-annotation/anchor.js.map +1 -0
- package/dist/map-annotation/index.js +21 -0
- package/dist/map-annotation/index.js.map +1 -0
- package/dist/map-annotation/map-annotation.js +18 -0
- package/dist/map-annotation/map-annotation.js.map +1 -0
- package/dist/map-arc/index.js +16 -0
- package/dist/map-arc/index.js.map +1 -0
- package/dist/map-arc/map-arc.js +13 -0
- package/dist/map-arc/map-arc.js.map +1 -0
- package/dist/map-canvas/index.css +11 -0
- package/dist/map-canvas/index.css.map +1 -0
- package/dist/map-canvas/index.js +20 -0
- package/dist/map-canvas/index.js.map +1 -0
- package/dist/map-canvas/map-canvas.css +11 -0
- package/dist/map-canvas/map-canvas.css.map +1 -0
- package/dist/map-canvas/map-canvas.js +17 -0
- package/dist/map-canvas/map-canvas.js.map +1 -0
- package/dist/map-canvas/map-context.js +11 -0
- package/dist/map-canvas/map-context.js.map +1 -0
- package/dist/map-canvas/use-resolved-basemap-theme.js +9 -0
- package/dist/map-canvas/use-resolved-basemap-theme.js.map +1 -0
- package/dist/map-cluster-layer/index.js +12 -0
- package/dist/map-cluster-layer/index.js.map +1 -0
- package/dist/map-cluster-layer/map-cluster-layer.js +12 -0
- package/dist/map-cluster-layer/map-cluster-layer.js.map +1 -0
- package/dist/map-controls/index.js +11 -0
- package/dist/map-controls/index.js.map +1 -0
- package/dist/map-controls/map-controls.js +11 -0
- package/dist/map-controls/map-controls.js.map +1 -0
- package/dist/map-geojson/index.js +13 -0
- package/dist/map-geojson/index.js.map +1 -0
- package/dist/map-geojson/map-geojson.js +13 -0
- package/dist/map-geojson/map-geojson.js.map +1 -0
- package/dist/map-inset/index.css +11 -0
- package/dist/map-inset/index.css.map +1 -0
- package/dist/map-inset/index.js +20 -0
- package/dist/map-inset/index.js.map +1 -0
- package/dist/map-inset/map-inset.css +11 -0
- package/dist/map-inset/map-inset.css.map +1 -0
- package/dist/map-inset/map-inset.js +20 -0
- package/dist/map-inset/map-inset.js.map +1 -0
- package/dist/map-legend/index.js +14 -0
- package/dist/map-legend/index.js.map +1 -0
- package/dist/map-legend/map-corner.js +10 -0
- package/dist/map-legend/map-corner.js.map +1 -0
- package/dist/map-legend/map-legend.js +11 -0
- package/dist/map-legend/map-legend.js.map +1 -0
- package/dist/map-marker/index.js +22 -0
- package/dist/map-marker/index.js.map +1 -0
- package/dist/map-marker/map-marker.js +22 -0
- package/dist/map-marker/map-marker.js.map +1 -0
- package/dist/map-north-arrow/index.js +11 -0
- package/dist/map-north-arrow/index.js.map +1 -0
- package/dist/map-north-arrow/map-north-arrow.js +11 -0
- package/dist/map-north-arrow/map-north-arrow.js.map +1 -0
- package/dist/map-plan/index.js +30 -0
- package/dist/map-plan/index.js.map +1 -0
- package/dist/map-plan/map-plan-legend.js +10 -0
- package/dist/map-plan/map-plan-legend.js.map +1 -0
- package/dist/map-plan/map-plan-overlay.js +13 -0
- package/dist/map-plan/map-plan-overlay.js.map +1 -0
- package/dist/map-plan/map-plan-status.js +9 -0
- package/dist/map-plan/map-plan-status.js.map +1 -0
- package/dist/map-plan/map-plan-table.js +10 -0
- package/dist/map-plan/map-plan-table.js.map +1 -0
- package/dist/map-plan/use-plan-selection.js +9 -0
- package/dist/map-plan/use-plan-selection.js.map +1 -0
- package/dist/map-plan-image/index.js +11 -0
- package/dist/map-plan-image/index.js.map +1 -0
- package/dist/map-plan-image/map-plan-image.js +11 -0
- package/dist/map-plan-image/map-plan-image.js.map +1 -0
- package/dist/map-popup/index.js +12 -0
- package/dist/map-popup/index.js.map +1 -0
- package/dist/map-popup/map-popup.js +12 -0
- package/dist/map-popup/map-popup.js.map +1 -0
- package/dist/map-route/index.js +12 -0
- package/dist/map-route/index.js.map +1 -0
- package/dist/map-route/map-route.js +12 -0
- package/dist/map-route/map-route.js.map +1 -0
- package/dist/map-scale-bar/index.js +18 -0
- package/dist/map-scale-bar/index.js.map +1 -0
- package/dist/map-scale-bar/map-scale-bar.js +18 -0
- package/dist/map-scale-bar/map-scale-bar.js.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +83 -0
- package/src/lib/arc-math.test.ts +19 -0
- package/src/lib/arc-math.ts +6 -1
- package/src/lib/map-position.ts +77 -0
- package/src/lib/merge-hover-paint.ts +44 -13
- package/src/lib/plan-crs.parity.test.ts +39 -0
- package/src/lib/plan-crs.test.ts +248 -0
- package/src/lib/plan-crs.ts +252 -0
- package/src/lib/plan-patterns.test.ts +255 -0
- package/src/lib/plan-patterns.ts +160 -0
- package/src/lib/plan-regions.test.ts +186 -0
- package/src/lib/plan-regions.ts +209 -0
- package/src/lib/plan-status.test.ts +87 -0
- package/src/lib/plan-status.ts +147 -0
- package/src/lib/route-arrow.test.ts +88 -0
- package/src/lib/route-arrow.ts +59 -0
- package/src/lib/use-map-breakpoint.test.ts +83 -0
- package/src/lib/use-map-breakpoint.ts +238 -0
- package/src/lib/use-plan-projection.ts +157 -0
- package/src/lib/warn-once.ts +21 -0
- package/src/map-annotation/anchor.ts +92 -0
- package/src/map-annotation/index.ts +15 -0
- package/src/map-annotation/map-annotation.stories.tsx +113 -0
- package/src/map-annotation/map-annotation.test.tsx +106 -0
- package/src/map-annotation/map-annotation.tsx +356 -0
- package/src/map-arc/map-arc.tsx +26 -5
- package/src/map-canvas/map-canvas.stories.tsx +211 -0
- package/src/map-canvas/map-canvas.test.tsx +421 -1
- package/src/map-canvas/map-canvas.tsx +466 -20
- package/src/map-canvas/map-context.ts +11 -0
- package/src/map-cluster-layer/map-cluster-layer.tsx +14 -1
- package/src/map-controls/map-controls.stories.tsx +16 -0
- package/src/map-controls/map-controls.test.tsx +145 -1
- package/src/map-controls/map-controls.tsx +57 -12
- package/src/map-geojson/map-geojson.stories.tsx +153 -3
- package/src/map-geojson/map-geojson.test.tsx +453 -0
- package/src/map-geojson/map-geojson.tsx +591 -51
- package/src/map-inset/index.ts +1 -0
- package/src/map-inset/map-inset.stories.tsx +61 -0
- package/src/map-inset/map-inset.test.tsx +117 -0
- package/src/map-inset/map-inset.tsx +264 -0
- package/src/map-legend/index.ts +10 -0
- package/src/map-legend/map-corner.ts +23 -0
- package/src/map-legend/map-legend.stories.tsx +171 -0
- package/src/map-legend/map-legend.test.tsx +136 -0
- package/src/map-legend/map-legend.tsx +264 -0
- package/src/map-marker/map-marker.stories.tsx +136 -4
- package/src/map-marker/map-marker.test.tsx +383 -3
- package/src/map-marker/map-marker.tsx +414 -44
- package/src/map-north-arrow/index.ts +1 -0
- package/src/map-north-arrow/map-north-arrow.stories.tsx +65 -0
- package/src/map-north-arrow/map-north-arrow.test.tsx +39 -0
- package/src/map-north-arrow/map-north-arrow.tsx +82 -0
- package/src/map-plan/index.ts +13 -0
- package/src/map-plan/map-plan-legend.test.tsx +61 -0
- package/src/map-plan/map-plan-legend.tsx +83 -0
- package/src/map-plan/map-plan-overlay.stories.tsx +260 -0
- package/src/map-plan/map-plan-overlay.test.tsx +302 -0
- package/src/map-plan/map-plan-overlay.tsx +531 -0
- package/src/map-plan/map-plan-status.test.tsx +71 -0
- package/src/map-plan/map-plan-status.tsx +77 -0
- package/src/map-plan/map-plan-table.test.tsx +86 -0
- package/src/map-plan/map-plan-table.tsx +153 -0
- package/src/map-plan/use-plan-selection.ts +78 -0
- package/src/map-plan-image/index.ts +1 -0
- package/src/map-plan-image/map-plan-image.stories.tsx +301 -0
- package/src/map-plan-image/map-plan-image.test.tsx +176 -0
- package/src/map-plan-image/map-plan-image.tsx +279 -0
- package/src/map-popup/map-popup.tsx +18 -11
- package/src/map-route/index.ts +1 -1
- package/src/map-route/map-route.test.tsx +216 -0
- package/src/map-route/map-route.tsx +105 -6
- package/src/map-scale-bar/index.ts +9 -0
- package/src/map-scale-bar/map-scale-bar.stories.tsx +63 -0
- package/src/map-scale-bar/map-scale-bar.test.tsx +65 -0
- package/src/map-scale-bar/map-scale-bar.tsx +145 -0
- package/src/test-utils/locator-fixture.ts +75 -0
- package/src/test-utils/maplibre-mock.ts +267 -20
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import MapLibreGL, { type MarkerOptions, type PopupOptions } from "maplibre-gl";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
use,
|
|
7
|
+
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from "react";
|
|
5
14
|
import { createPortal } from "react-dom";
|
|
6
15
|
import { X } from "lucide-react";
|
|
7
16
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
17
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
8
18
|
|
|
9
19
|
import { useMap } from "../map-canvas/map-context";
|
|
20
|
+
import {
|
|
21
|
+
type MapResponsive,
|
|
22
|
+
useMapBreakpoint,
|
|
23
|
+
resolveMapResponsive,
|
|
24
|
+
} from "../lib/use-map-breakpoint";
|
|
25
|
+
import {
|
|
26
|
+
type MapLabelAnchor,
|
|
27
|
+
clampShift,
|
|
28
|
+
mapAnchorGeometry,
|
|
29
|
+
mapAnchorTransform,
|
|
30
|
+
} from "../map-annotation/anchor";
|
|
31
|
+
import { resolveMapPosition, toPlanPoint, type MapPosition } from "../lib/map-position";
|
|
32
|
+
import type { PlanPoint } from "../lib/plan-crs";
|
|
10
33
|
|
|
11
34
|
type MarkerContextValue = {
|
|
12
35
|
marker: MapLibreGL.Marker;
|
|
@@ -23,35 +46,68 @@ function useMarkerContext() {
|
|
|
23
46
|
return context;
|
|
24
47
|
}
|
|
25
48
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
|
|
49
|
+
/**
|
|
50
|
+
* A marker's own text label — the locator-map label: one of eight positions,
|
|
51
|
+
* optionally on a box, optionally pushed out on a callout line.
|
|
52
|
+
*/
|
|
53
|
+
export interface MapMarkerLabelSpec {
|
|
54
|
+
/** The label text. */
|
|
55
|
+
text: string;
|
|
56
|
+
/** Where the label sits relative to the point (default `"top"`). */
|
|
57
|
+
position?: MapLabelAnchor;
|
|
58
|
+
/** Set the label on a background box so it reads over a busy basemap. */
|
|
59
|
+
box?: boolean;
|
|
60
|
+
/** Push the label further out and draw a callout line back to the point. */
|
|
61
|
+
callout?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type MapMarkerProps = MapPosition & {
|
|
65
|
+
/**
|
|
66
|
+
* Marker sub-components (MapMarkerContent, MapMarkerPopup, MapMarkerTooltip,
|
|
67
|
+
* MapMarkerLabel). Optional when `label` alone marks the place — an inline
|
|
68
|
+
* area label, for example.
|
|
69
|
+
*/
|
|
70
|
+
children?: ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* Whether the marker shows at a tier (default `true` everywhere):
|
|
73
|
+
* `{ base: true, narrow: false }` drops it from a phone-width map. The
|
|
74
|
+
* The editorial advice applies — duplicate a marker with a shorter label and
|
|
75
|
+
* the opposite `showAt` rather than cramming one label into both.
|
|
76
|
+
*/
|
|
77
|
+
showAt?: MapResponsive<boolean>;
|
|
78
|
+
/** A text label drawn with the marker (see {@link MapMarkerLabelSpec}). */
|
|
79
|
+
label?: MapMarkerLabelSpec;
|
|
33
80
|
/** Callback when the marker is clicked. */
|
|
34
81
|
onClick?: (e: MouseEvent) => void;
|
|
35
82
|
/** Callback when the mouse enters the marker. */
|
|
36
83
|
onMouseEnter?: (e: MouseEvent) => void;
|
|
37
84
|
/** Callback when the mouse leaves the marker. */
|
|
38
85
|
onMouseLeave?: (e: MouseEvent) => void;
|
|
39
|
-
/** Callback when a drag starts (requires `draggable`). */
|
|
40
|
-
onDragStart?: (lngLat: { lng: number; lat: number }) => void;
|
|
41
|
-
/** Callback during a drag (requires `draggable`). */
|
|
42
|
-
onDrag?: (lngLat: { lng: number; lat: number }) => void;
|
|
43
|
-
/** Callback when a drag ends (requires `draggable`). */
|
|
44
|
-
onDragEnd?: (lngLat: { lng: number; lat: number }) => void;
|
|
86
|
+
/** Callback when a drag starts (requires `draggable`). The plan point is `null` off a plan. */
|
|
87
|
+
onDragStart?: (lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => void;
|
|
88
|
+
/** Callback during a drag (requires `draggable`). The plan point is `null` off a plan. */
|
|
89
|
+
onDrag?: (lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => void;
|
|
90
|
+
/** Callback when a drag ends (requires `draggable`). The plan point is `null` off a plan. */
|
|
91
|
+
onDragEnd?: (lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => void;
|
|
92
|
+
/**
|
|
93
|
+
* The accessible name of a `draggable` marker — say what it moves ("Depot
|
|
94
|
+
* location"). Defaults to the locale seam's generic name. Ignored when the
|
|
95
|
+
* marker is not draggable.
|
|
96
|
+
*/
|
|
97
|
+
dragLabel?: string;
|
|
45
98
|
} & Omit<MarkerOptions, "element">;
|
|
46
99
|
|
|
47
100
|
/**
|
|
48
|
-
* A marker anchored at a lng/lat
|
|
49
|
-
* `MapMarkerContent` (the visual),
|
|
50
|
-
* on click) and `MapMarkerTooltip`
|
|
101
|
+
* A marker anchored at a lng/lat — or, on a canvas with a `plan` extent, at a
|
|
102
|
+
* plan `x`/`y`. Compose the pieces you need: `MapMarkerContent` (the visual),
|
|
103
|
+
* `MapMarkerLabel`, `MapMarkerPopup` (opens on click) and `MapMarkerTooltip`
|
|
104
|
+
* (shows on hover).
|
|
51
105
|
*/
|
|
52
106
|
export function MapMarker({
|
|
53
|
-
longitude,
|
|
54
|
-
latitude,
|
|
107
|
+
longitude: longitudeProp,
|
|
108
|
+
latitude: latitudeProp,
|
|
109
|
+
x,
|
|
110
|
+
y,
|
|
55
111
|
children,
|
|
56
112
|
onClick,
|
|
57
113
|
onMouseEnter,
|
|
@@ -60,9 +116,25 @@ export function MapMarker({
|
|
|
60
116
|
onDrag,
|
|
61
117
|
onDragEnd,
|
|
62
118
|
draggable = false,
|
|
119
|
+
dragLabel,
|
|
120
|
+
showAt = true,
|
|
121
|
+
label,
|
|
63
122
|
...markerOptions
|
|
64
123
|
}: MapMarkerProps) {
|
|
65
|
-
const { map } = useMap();
|
|
124
|
+
const { map, plan } = useMap();
|
|
125
|
+
const { t } = useLocale();
|
|
126
|
+
const visible = resolveMapResponsive(showAt, useMapBreakpoint());
|
|
127
|
+
const [marker, setMarker] = useState<MapLibreGL.Marker | null>(null);
|
|
128
|
+
|
|
129
|
+
// One position prop, two coordinate systems, two scalars out — so every sync
|
|
130
|
+
// effect below keeps primitive dependencies.
|
|
131
|
+
const [longitude, latitude] = resolveMapPosition(
|
|
132
|
+
{ longitude: longitudeProp, latitude: latitudeProp, x, y },
|
|
133
|
+
plan,
|
|
134
|
+
"MapMarker",
|
|
135
|
+
);
|
|
136
|
+
const planRef = useRef(plan);
|
|
137
|
+
planRef.current = plan;
|
|
66
138
|
|
|
67
139
|
const callbacksRef = useRef({
|
|
68
140
|
onClick,
|
|
@@ -81,12 +153,39 @@ export function MapMarker({
|
|
|
81
153
|
onDragEnd,
|
|
82
154
|
};
|
|
83
155
|
|
|
84
|
-
const
|
|
156
|
+
const { anchor, className, offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
|
|
157
|
+
|
|
158
|
+
// `longitude`/`latitude`/`draggable` at construction time only — the sync
|
|
159
|
+
// effect below corrects them on the very next commit, and are read from a
|
|
160
|
+
// ref here so they don't force a rebuild on every position change (see the
|
|
161
|
+
// `anchor`/`className` note below for what SHOULD force one).
|
|
162
|
+
const initialRef = useRef({ longitude, latitude, draggable });
|
|
163
|
+
initialRef.current = { longitude, latitude, draggable };
|
|
164
|
+
|
|
165
|
+
// Builds (and tears down) the actual MapLibre `Marker` instance. This is a
|
|
166
|
+
// real `useEffect`, never `useMemo`: `useMemo`'s initializer runs during
|
|
167
|
+
// RENDER — including on the server, where `document` doesn't exist — and
|
|
168
|
+
// React offers no guarantee it runs only once (it may discard and recompute
|
|
169
|
+
// a memoized value), which would silently leak marker instances/listeners.
|
|
170
|
+
//
|
|
171
|
+
// `anchor` and `className` have no MapLibre setter (`Marker` bakes both into
|
|
172
|
+
// its constructor), so they are the only options that must REBUILD the
|
|
173
|
+
// instance; every other option (position, draggable, offset, rotation,
|
|
174
|
+
// alignment) has a live setter and is kept in sync by the effect further
|
|
175
|
+
// below without ever recreating the marker.
|
|
176
|
+
useEffect(() => {
|
|
85
177
|
const markerInstance = new MapLibreGL.Marker({
|
|
86
|
-
|
|
178
|
+
anchor,
|
|
179
|
+
className,
|
|
87
180
|
element: document.createElement("div"),
|
|
88
|
-
draggable,
|
|
89
|
-
}).setLngLat([longitude, latitude]);
|
|
181
|
+
draggable: initialRef.current.draggable,
|
|
182
|
+
}).setLngLat([initialRef.current.longitude, initialRef.current.latitude]);
|
|
183
|
+
|
|
184
|
+
// c-10: MapLibre gives a marker a tab stop of its own (a popup, a
|
|
185
|
+
// keyboard drag), and a raw element shows the BROWSER's focus ring, which
|
|
186
|
+
// does not follow the theme. The house indicator is a class, so the
|
|
187
|
+
// element wears the same `focus-ring` every other control does.
|
|
188
|
+
markerInstance.getElement()?.classList.add("focus-ring");
|
|
90
189
|
|
|
91
190
|
const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
|
|
92
191
|
const handleMouseEnter = (e: MouseEvent) => callbacksRef.current.onMouseEnter?.(e);
|
|
@@ -98,39 +197,50 @@ export function MapMarker({
|
|
|
98
197
|
|
|
99
198
|
const handleDragStart = () => {
|
|
100
199
|
const lngLat = markerInstance.getLngLat();
|
|
101
|
-
callbacksRef.current.onDragStart?.(
|
|
200
|
+
callbacksRef.current.onDragStart?.(
|
|
201
|
+
{ lng: lngLat.lng, lat: lngLat.lat },
|
|
202
|
+
toPlanPoint(planRef.current, lngLat),
|
|
203
|
+
);
|
|
102
204
|
};
|
|
103
205
|
const handleDrag = () => {
|
|
104
206
|
const lngLat = markerInstance.getLngLat();
|
|
105
|
-
callbacksRef.current.onDrag?.(
|
|
207
|
+
callbacksRef.current.onDrag?.(
|
|
208
|
+
{ lng: lngLat.lng, lat: lngLat.lat },
|
|
209
|
+
toPlanPoint(planRef.current, lngLat),
|
|
210
|
+
);
|
|
106
211
|
};
|
|
107
212
|
const handleDragEnd = () => {
|
|
108
213
|
const lngLat = markerInstance.getLngLat();
|
|
109
|
-
callbacksRef.current.onDragEnd?.(
|
|
214
|
+
callbacksRef.current.onDragEnd?.(
|
|
215
|
+
{ lng: lngLat.lng, lat: lngLat.lat },
|
|
216
|
+
toPlanPoint(planRef.current, lngLat),
|
|
217
|
+
);
|
|
110
218
|
};
|
|
111
219
|
|
|
112
220
|
markerInstance.on("dragstart", handleDragStart);
|
|
113
221
|
markerInstance.on("drag", handleDrag);
|
|
114
222
|
markerInstance.on("dragend", handleDragEnd);
|
|
115
223
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}, []);
|
|
224
|
+
setMarker(markerInstance);
|
|
225
|
+
}, [anchor, className]);
|
|
119
226
|
|
|
227
|
+
// Attaches/detaches the CURRENT marker instance to the map — its own effect
|
|
228
|
+
// so both a `map` change and an `anchor`/`className`-driven rebuild above
|
|
229
|
+
// are handled the same way, without a double `remove()`.
|
|
230
|
+
// A marker hidden at this tier (`showAt`) is simply not on the map.
|
|
120
231
|
useEffect(() => {
|
|
121
|
-
if (!map) return;
|
|
232
|
+
if (!map || !marker || !visible) return;
|
|
122
233
|
|
|
123
234
|
marker.addTo(map);
|
|
124
235
|
|
|
125
236
|
return () => {
|
|
126
237
|
marker.remove();
|
|
127
238
|
};
|
|
128
|
-
|
|
129
|
-
}, [map]);
|
|
130
|
-
|
|
131
|
-
const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
|
|
239
|
+
}, [map, marker, visible]);
|
|
132
240
|
|
|
133
241
|
useEffect(() => {
|
|
242
|
+
if (!marker) return;
|
|
243
|
+
|
|
134
244
|
const current = marker.getLngLat();
|
|
135
245
|
if (current.lng !== longitude || current.lat !== latitude) {
|
|
136
246
|
marker.setLngLat([longitude, latitude]);
|
|
@@ -160,7 +270,238 @@ export function MapMarker({
|
|
|
160
270
|
}
|
|
161
271
|
}, [marker, longitude, latitude, draggable, offset, rotation, rotationAlignment, pitchAlignment]);
|
|
162
272
|
|
|
163
|
-
|
|
273
|
+
// c-4 (WCAG 2.1.1): `marker.setDraggable(true)` buys a MOUSE affordance and
|
|
274
|
+
// nothing else — MapLibre gives a marker element a tab stop and a key
|
|
275
|
+
// handler only from inside `setPopup`, so a popup-less draggable marker is
|
|
276
|
+
// not focusable and answers no key. The drag gets its keyboard equivalent
|
|
277
|
+
// here: a real tab stop, a name that says what moves, and arrow keys that
|
|
278
|
+
// step the marker in PIXELS (Shift = a coarser step) through the same
|
|
279
|
+
// project/unproject round trip the pointer drag uses, firing the identical
|
|
280
|
+
// dragstart/drag/dragend payloads.
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const element = marker?.getElement();
|
|
283
|
+
if (!(draggable && marker && map && element)) return;
|
|
284
|
+
|
|
285
|
+
const hadTabIndex = element.getAttribute("tabindex");
|
|
286
|
+
const hadRole = element.getAttribute("role");
|
|
287
|
+
const hadLabel = element.getAttribute("aria-label");
|
|
288
|
+
if (hadTabIndex === null) element.setAttribute("tabindex", "0");
|
|
289
|
+
if (hadRole === null) element.setAttribute("role", "button");
|
|
290
|
+
// MapLibre's own name is the generic "Map marker"; it says neither what
|
|
291
|
+
// this one moves nor that it can be moved at all. A draggable marker is
|
|
292
|
+
// named for its drag, and the original name goes back on cleanup.
|
|
293
|
+
element.setAttribute("aria-label", dragLabel ?? t("maps.marker.drag"));
|
|
294
|
+
|
|
295
|
+
let dragging = false;
|
|
296
|
+
const position = () => {
|
|
297
|
+
const lngLat = marker.getLngLat();
|
|
298
|
+
return { lng: lngLat.lng, lat: lngLat.lat };
|
|
299
|
+
};
|
|
300
|
+
// The keyboard path reports the plan point exactly as the pointer path
|
|
301
|
+
// does, so a plan consumer cannot tell the two apart.
|
|
302
|
+
const planPoint = () => toPlanPoint(planRef.current, marker.getLngLat());
|
|
303
|
+
const settle = () => {
|
|
304
|
+
if (!dragging) return;
|
|
305
|
+
dragging = false;
|
|
306
|
+
callbacksRef.current.onDragEnd?.(position(), planPoint());
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
310
|
+
const step = event.shiftKey ? KEYBOARD_DRAG_STEP_COARSE_PX : KEYBOARD_DRAG_STEP_PX;
|
|
311
|
+
const delta = KEYBOARD_DRAG_DELTAS[event.key];
|
|
312
|
+
if (!delta) return;
|
|
313
|
+
event.preventDefault();
|
|
314
|
+
// MapLibre appends the marker element straight into
|
|
315
|
+
// `map.getCanvasContainer()` — the SAME element its own
|
|
316
|
+
// `HandlerManager` listens on for `keydown` (bubble phase, no
|
|
317
|
+
// `defaultPrevented` check). Without this, every arrow key we handle
|
|
318
|
+
// here also reaches MapLibre's built-in keyboard pan handler, which
|
|
319
|
+
// fires an `easeTo` that moves the CAMERA by 100px the opposite way
|
|
320
|
+
// (its `offset` is negated) and — since that `easeTo` isn't marked
|
|
321
|
+
// `essential` — collapses to an instant jump under reduced motion but
|
|
322
|
+
// animates over 300ms otherwise, so repeated presses compose
|
|
323
|
+
// differently per motion setting. Stopping propagation keeps the key
|
|
324
|
+
// entirely inside this handler: only `marker.setLngLat` moves, the
|
|
325
|
+
// camera never does.
|
|
326
|
+
event.stopPropagation();
|
|
327
|
+
if (!dragging) {
|
|
328
|
+
dragging = true;
|
|
329
|
+
callbacksRef.current.onDragStart?.(position(), planPoint());
|
|
330
|
+
}
|
|
331
|
+
const point = map.project(marker.getLngLat());
|
|
332
|
+
const next = map.unproject([point.x + delta[0] * step, point.y + delta[1] * step]);
|
|
333
|
+
marker.setLngLat(next);
|
|
334
|
+
callbacksRef.current.onDrag?.(position(), planPoint());
|
|
335
|
+
};
|
|
336
|
+
const handleKeyUp = (event: KeyboardEvent) => {
|
|
337
|
+
if (KEYBOARD_DRAG_DELTAS[event.key]) settle();
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
element.addEventListener("keydown", handleKeyDown);
|
|
341
|
+
element.addEventListener("keyup", handleKeyUp);
|
|
342
|
+
element.addEventListener("blur", settle);
|
|
343
|
+
|
|
344
|
+
return () => {
|
|
345
|
+
element.removeEventListener("keydown", handleKeyDown);
|
|
346
|
+
element.removeEventListener("keyup", handleKeyUp);
|
|
347
|
+
element.removeEventListener("blur", settle);
|
|
348
|
+
if (hadTabIndex === null) element.removeAttribute("tabindex");
|
|
349
|
+
if (hadRole === null) element.removeAttribute("role");
|
|
350
|
+
if (hadLabel === null) element.removeAttribute("aria-label");
|
|
351
|
+
else element.setAttribute("aria-label", hadLabel);
|
|
352
|
+
};
|
|
353
|
+
}, [draggable, dragLabel, map, marker, t]);
|
|
354
|
+
|
|
355
|
+
// No marker yet (first client tick after mount, or the moment an
|
|
356
|
+
// `anchor`/`className` rebuild is in flight) — render nothing rather than
|
|
357
|
+
// a sub-component (`MapMarkerContent` et al.) reaching into a null marker,
|
|
358
|
+
// and nothing at all on the server.
|
|
359
|
+
if (!marker) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<MarkerContext.Provider value={{ marker, map }}>
|
|
365
|
+
{children}
|
|
366
|
+
{label && <MarkerOwnLabel label={label} />}
|
|
367
|
+
</MarkerContext.Provider>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** One arrow-key press of a keyboard drag, in screen px (c-4). */
|
|
372
|
+
const KEYBOARD_DRAG_STEP_PX = 8;
|
|
373
|
+
/** The same press with Shift held — a coarser step across the map. */
|
|
374
|
+
const KEYBOARD_DRAG_STEP_COARSE_PX = 40;
|
|
375
|
+
/** Arrow key → screen-space direction. `y` grows downward, as `map.project` does. */
|
|
376
|
+
const KEYBOARD_DRAG_DELTAS: Record<string, [number, number] | undefined> = {
|
|
377
|
+
ArrowLeft: [-1, 0],
|
|
378
|
+
ArrowRight: [1, 0],
|
|
379
|
+
ArrowUp: [0, -1],
|
|
380
|
+
ArrowDown: [0, 1],
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const LABEL_GAP = 10;
|
|
384
|
+
const CALLOUT_DISTANCE = 28;
|
|
385
|
+
/** How close to the map's edge a clamped label may sit, in px. */
|
|
386
|
+
const LABEL_EDGE_PAD = 4;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Keep a marker's label inside the map box (c-7 / c-8).
|
|
390
|
+
*
|
|
391
|
+
* A label is a pure offset from its marker, so at the edge of a static map it
|
|
392
|
+
* was painted half outside the box and clipped — "bottom-righ", "…urg Gate" —
|
|
393
|
+
* with no way to pan it back into view. This is the pass `MapAnnotation`
|
|
394
|
+
* already makes: measure the painted box, clamp it into the map's rectangle
|
|
395
|
+
* with `clampShift`, and write the correction to the CSS `translate` property,
|
|
396
|
+
* which composes with the label's own `transform` instead of replacing it.
|
|
397
|
+
*
|
|
398
|
+
* A label whose own point has left the box is hidden rather than pinned to the
|
|
399
|
+
* edge: a name parked at the border beside no marker claims a place the reader
|
|
400
|
+
* cannot see. Skipped while the map box measures 0 (jsdom, first paint).
|
|
401
|
+
*/
|
|
402
|
+
function useLabelInsideMap(
|
|
403
|
+
element: HTMLElement | null,
|
|
404
|
+
map: MapLibreGL.Map | null,
|
|
405
|
+
marker: MapLibreGL.Marker,
|
|
406
|
+
onShift?: (sx: number, sy: number) => void,
|
|
407
|
+
) {
|
|
408
|
+
const onShiftRef = useRef(onShift);
|
|
409
|
+
onShiftRef.current = onShift;
|
|
410
|
+
|
|
411
|
+
useLayoutEffect(() => {
|
|
412
|
+
if (!element || !map) return undefined;
|
|
413
|
+
const container = map.getContainer();
|
|
414
|
+
|
|
415
|
+
const update = () => {
|
|
416
|
+
const frame = container.getBoundingClientRect();
|
|
417
|
+
if (frame.width === 0 || frame.height === 0) return;
|
|
418
|
+
|
|
419
|
+
const point = map.project(marker.getLngLat());
|
|
420
|
+
const inside =
|
|
421
|
+
point.x >= 0 && point.y >= 0 && point.x <= frame.width && point.y <= frame.height;
|
|
422
|
+
element.style.visibility = inside ? "" : "hidden";
|
|
423
|
+
if (!inside) return;
|
|
424
|
+
|
|
425
|
+
// Measure the label where it would sit unshifted, then re-apply.
|
|
426
|
+
element.style.translate = "";
|
|
427
|
+
const box = element.getBoundingClientRect();
|
|
428
|
+
const { sx, sy } = clampShift(
|
|
429
|
+
box.left - frame.left,
|
|
430
|
+
box.top - frame.top,
|
|
431
|
+
box.width,
|
|
432
|
+
box.height,
|
|
433
|
+
frame.width,
|
|
434
|
+
frame.height,
|
|
435
|
+
LABEL_EDGE_PAD,
|
|
436
|
+
);
|
|
437
|
+
element.style.translate = sx === 0 && sy === 0 ? "" : `${sx}px ${sy}px`;
|
|
438
|
+
onShiftRef.current?.(sx, sy);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
update();
|
|
442
|
+
map.on("move", update);
|
|
443
|
+
map.on("resize", update);
|
|
444
|
+
const observer =
|
|
445
|
+
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => update());
|
|
446
|
+
observer?.observe(element);
|
|
447
|
+
return () => {
|
|
448
|
+
map.off("move", update);
|
|
449
|
+
map.off("resize", update);
|
|
450
|
+
observer?.disconnect();
|
|
451
|
+
};
|
|
452
|
+
}, [element, map, marker]);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The `label` prop, portaled into the marker element and centred on the point. */
|
|
456
|
+
function MarkerOwnLabel({ label }: { label: MapMarkerLabelSpec }) {
|
|
457
|
+
const { marker, map } = useMarkerContext();
|
|
458
|
+
const { text, position = "top", box = false, callout = false } = label;
|
|
459
|
+
const distance = callout ? CALLOUT_DISTANCE : LABEL_GAP;
|
|
460
|
+
const { dx, dy } = mapAnchorGeometry(position, distance);
|
|
461
|
+
const [labelEl, setLabelEl] = useState<HTMLDivElement | null>(null);
|
|
462
|
+
const lineRef = useRef<SVGLineElement | null>(null);
|
|
463
|
+
|
|
464
|
+
// A clamped label pulls its callout line's end along with it, so the line
|
|
465
|
+
// still meets the box it points at.
|
|
466
|
+
useLabelInsideMap(labelEl, map, marker, (sx, sy) => {
|
|
467
|
+
const line = lineRef.current;
|
|
468
|
+
if (!line) return;
|
|
469
|
+
line.setAttribute("x2", String(dx + sx));
|
|
470
|
+
line.setAttribute("y2", String(dy + sy));
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
return createPortal(
|
|
474
|
+
<>
|
|
475
|
+
{callout && (
|
|
476
|
+
<svg
|
|
477
|
+
aria-hidden="true"
|
|
478
|
+
data-slot="map-marker-callout"
|
|
479
|
+
className="pointer-events-none absolute overflow-visible text-foreground"
|
|
480
|
+
// The marker element is centred on the point; draw from its centre.
|
|
481
|
+
style={{ left: "50%", top: "50%" }}
|
|
482
|
+
width={1}
|
|
483
|
+
height={1}
|
|
484
|
+
>
|
|
485
|
+
<line ref={lineRef} x1={0} y1={0} x2={dx} y2={dy} stroke="currentColor" strokeWidth={1} />
|
|
486
|
+
</svg>
|
|
487
|
+
)}
|
|
488
|
+
<div
|
|
489
|
+
ref={setLabelEl}
|
|
490
|
+
data-slot="map-marker-label"
|
|
491
|
+
data-position={position}
|
|
492
|
+
className={cn(
|
|
493
|
+
"pointer-events-none absolute whitespace-nowrap text-meta font-medium text-foreground",
|
|
494
|
+
box && "rounded-sm bg-background/90 px-1 py-px",
|
|
495
|
+
)}
|
|
496
|
+
// Geographic placement: physical offsets from the point in every
|
|
497
|
+
// writing direction (east stays east), like MapLibre's own anchors.
|
|
498
|
+
style={{ left: "50%", top: "50%", transform: mapAnchorTransform(position, distance) }}
|
|
499
|
+
>
|
|
500
|
+
{text}
|
|
501
|
+
</div>
|
|
502
|
+
</>,
|
|
503
|
+
marker.getElement(),
|
|
504
|
+
);
|
|
164
505
|
}
|
|
165
506
|
|
|
166
507
|
export interface MapMarkerContentProps {
|
|
@@ -189,11 +530,12 @@ function DefaultMarkerIcon() {
|
|
|
189
530
|
}
|
|
190
531
|
|
|
191
532
|
function PopupCloseButton({ onClick }: { onClick: () => void }) {
|
|
533
|
+
const { t } = useLocale();
|
|
192
534
|
return (
|
|
193
535
|
<button
|
|
194
536
|
type="button"
|
|
195
537
|
onClick={onClick}
|
|
196
|
-
aria-label="
|
|
538
|
+
aria-label={t("maps.popup.close")}
|
|
197
539
|
className="absolute top-1 right-1 z-10 inline-flex size-5 items-center justify-center rounded-sm text-foreground transition-colors duration-fast hover:bg-muted focus-ring-inset"
|
|
198
540
|
>
|
|
199
541
|
<X className="size-3.5" aria-hidden="true" />
|
|
@@ -297,17 +639,36 @@ export function MapMarkerTooltip({ children, className, ...popupOptions }: MapMa
|
|
|
297
639
|
|
|
298
640
|
tooltip.setDOMContent(container);
|
|
299
641
|
|
|
300
|
-
const
|
|
642
|
+
const show = () => {
|
|
301
643
|
tooltip.setLngLat(marker.getLngLat()).addTo(map);
|
|
302
644
|
};
|
|
303
|
-
const
|
|
645
|
+
const hide = () => tooltip.remove();
|
|
646
|
+
// WCAG 1.4.13: a tooltip that only answers a pointer is unreachable, and
|
|
647
|
+
// one that cannot be dismissed without moving the pointer traps the view.
|
|
648
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
649
|
+
if (event.key === "Escape") hide();
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const element = marker.getElement();
|
|
653
|
+
// MapLibre only makes a marker focusable inside `setPopup`, so a
|
|
654
|
+
// tooltip-only marker has to ask for the tab stop itself. The original
|
|
655
|
+
// value is put back on cleanup, so a marker that had one keeps it.
|
|
656
|
+
const hadTabIndex = element?.getAttribute("tabindex") ?? null;
|
|
657
|
+
if (element && hadTabIndex === null) element.setAttribute("tabindex", "0");
|
|
304
658
|
|
|
305
|
-
|
|
306
|
-
|
|
659
|
+
element?.addEventListener("mouseenter", show);
|
|
660
|
+
element?.addEventListener("mouseleave", hide);
|
|
661
|
+
element?.addEventListener("focus", show);
|
|
662
|
+
element?.addEventListener("blur", hide);
|
|
663
|
+
element?.addEventListener("keydown", handleKeyDown);
|
|
307
664
|
|
|
308
665
|
return () => {
|
|
309
|
-
|
|
310
|
-
|
|
666
|
+
element?.removeEventListener("mouseenter", show);
|
|
667
|
+
element?.removeEventListener("mouseleave", hide);
|
|
668
|
+
element?.removeEventListener("focus", show);
|
|
669
|
+
element?.removeEventListener("blur", hide);
|
|
670
|
+
element?.removeEventListener("keydown", handleKeyDown);
|
|
671
|
+
if (element && hadTabIndex === null) element.removeAttribute("tabindex");
|
|
311
672
|
tooltip.remove();
|
|
312
673
|
};
|
|
313
674
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- tooltip/marker/container are stable
|
|
@@ -351,14 +712,23 @@ export interface MapMarkerLabelProps {
|
|
|
351
712
|
* sibling composition rendering the label against the map container instead).
|
|
352
713
|
*/
|
|
353
714
|
export function MapMarkerLabel({ children, className, position = "top" }: MapMarkerLabelProps) {
|
|
354
|
-
const { marker } = useMarkerContext();
|
|
715
|
+
const { marker, map } = useMarkerContext();
|
|
716
|
+
const [labelEl, setLabelEl] = useState<HTMLDivElement | null>(null);
|
|
355
717
|
const positionClasses = {
|
|
356
718
|
top: "bottom-full mb-1",
|
|
357
719
|
bottom: "top-full mt-1",
|
|
358
720
|
};
|
|
359
721
|
|
|
722
|
+
// Same edge clamp as the `label` prop's own label (c-7 / c-8): a composed
|
|
723
|
+
// label is a plain offset from the marker too, so at the map's edge it was
|
|
724
|
+
// painted outside the box and clipped.
|
|
725
|
+
useLabelInsideMap(labelEl, map, marker);
|
|
726
|
+
|
|
360
727
|
return createPortal(
|
|
361
728
|
<div
|
|
729
|
+
ref={setLabelEl}
|
|
730
|
+
data-slot="map-marker-label"
|
|
731
|
+
data-position={position}
|
|
362
732
|
className={cn(
|
|
363
733
|
"absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
|
|
364
734
|
"text-meta font-medium text-foreground",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MapNorthArrow, type MapNorthArrowProps } from "./map-north-arrow";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
3
|
+
|
|
4
|
+
import { MapCanvas } from "../map-canvas";
|
|
5
|
+
import { MapNorthArrow } from "./map-north-arrow";
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: "Maps/MapNorthArrow",
|
|
9
|
+
component: MapNorthArrow,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
parameters: { layout: "fullscreen" },
|
|
12
|
+
} satisfies Meta<typeof MapNorthArrow>;
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A rotated map (bearing 30°) shows the arrow, turned to point at true north.
|
|
18
|
+
* On a north-up map the arrow stays hidden — it would only state the default.
|
|
19
|
+
*/
|
|
20
|
+
export const Default: Story = {
|
|
21
|
+
render: (args) => (
|
|
22
|
+
<div className="w-full p-4">
|
|
23
|
+
<MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={9} bearing={30}>
|
|
24
|
+
<MapNorthArrow {...args} />
|
|
25
|
+
</MapCanvas>
|
|
26
|
+
</div>
|
|
27
|
+
),
|
|
28
|
+
play: async ({ canvasElement }) => {
|
|
29
|
+
if (!canvasElement.querySelector(".maplibregl-canvas")) return;
|
|
30
|
+
await waitFor(() => {
|
|
31
|
+
const arrow = canvasElement.querySelector<HTMLElement>('[data-slot="map-north-arrow"]');
|
|
32
|
+
expect(arrow).not.toBeNull();
|
|
33
|
+
expect(arrow!.hidden).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** North-up: nothing to show. */
|
|
39
|
+
export const NorthUp: Story = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<div className="w-full p-4">
|
|
42
|
+
<MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={9}>
|
|
43
|
+
<MapNorthArrow />
|
|
44
|
+
</MapCanvas>
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** In another corner, with a translated name. */
|
|
50
|
+
export const Positioned: Story = {
|
|
51
|
+
args: { position: "bottom-right", label: "Norden" },
|
|
52
|
+
render: Default.render,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The remaining corners. */
|
|
56
|
+
export const OtherCorners: Story = {
|
|
57
|
+
render: () => (
|
|
58
|
+
<div className="w-full p-4">
|
|
59
|
+
<MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={9} bearing={-45}>
|
|
60
|
+
<MapNorthArrow position="top-right" />
|
|
61
|
+
<MapNorthArrow position="bottom-left" />
|
|
62
|
+
</MapCanvas>
|
|
63
|
+
</div>
|
|
64
|
+
),
|
|
65
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { act, cleanup, render, screen, 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 { MockMap, resetMaplibreMock } from "../test-utils/maplibre-mock";
|
|
10
|
+
import { MapCanvas } from "../map-canvas";
|
|
11
|
+
import { MapNorthArrow } from "./map-north-arrow";
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup();
|
|
15
|
+
resetMaplibreMock();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("MapNorthArrow", () => {
|
|
19
|
+
it("stays away on a north-up map and appears once the map rotates", async () => {
|
|
20
|
+
render(
|
|
21
|
+
<MapCanvas>
|
|
22
|
+
<MapNorthArrow />
|
|
23
|
+
</MapCanvas>,
|
|
24
|
+
);
|
|
25
|
+
const arrow = await waitFor(() => {
|
|
26
|
+
const el = document.querySelector('[data-slot="map-north-arrow"]');
|
|
27
|
+
expect(el).not.toBeNull();
|
|
28
|
+
return el!;
|
|
29
|
+
});
|
|
30
|
+
expect(arrow).toHaveAttribute("hidden");
|
|
31
|
+
act(() => {
|
|
32
|
+
MockMap.instances[0]!.bearing = 30;
|
|
33
|
+
MockMap.instances[0]!.emit("rotate");
|
|
34
|
+
});
|
|
35
|
+
expect(arrow).not.toHaveAttribute("hidden");
|
|
36
|
+
expect(screen.getByRole("img", { name: "North" })).toBeVisible();
|
|
37
|
+
expect(arrow.querySelector("svg")!.style.transform).toBe("rotate(-30deg)");
|
|
38
|
+
});
|
|
39
|
+
});
|