@elabs-ai/components-maps 4.2.0 → 5.1.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 -1471
- 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 +389 -0
- package/src/map-canvas/map-canvas.tsx +457 -17
- 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 +328 -3
- package/src/map-marker/map-marker.tsx +370 -33
- 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 +15 -10
- 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 +256 -19
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eight label anchors a map label or annotation can sit at, relative to
|
|
3
|
+
* its point. Compass-style and PHYSICAL on purpose: east of Toronto is east in
|
|
4
|
+
* every writing direction, so a map label never mirrors in RTL.
|
|
5
|
+
*/
|
|
6
|
+
export type MapLabelAnchor =
|
|
7
|
+
| "top"
|
|
8
|
+
| "bottom"
|
|
9
|
+
| "left"
|
|
10
|
+
| "right"
|
|
11
|
+
| "top-left"
|
|
12
|
+
| "top-right"
|
|
13
|
+
| "bottom-left"
|
|
14
|
+
| "bottom-right";
|
|
15
|
+
|
|
16
|
+
/** Every anchor, clockwise from `top` (for stories and validation). */
|
|
17
|
+
export const MAP_LABEL_ANCHORS: readonly MapLabelAnchor[] = [
|
|
18
|
+
"top",
|
|
19
|
+
"top-right",
|
|
20
|
+
"right",
|
|
21
|
+
"bottom-right",
|
|
22
|
+
"bottom",
|
|
23
|
+
"bottom-left",
|
|
24
|
+
"left",
|
|
25
|
+
"top-left",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where a label box sits for `anchor`, `distance` px away from its point:
|
|
30
|
+
* `dx` / `dy` is the attach point (the box's nearest edge or corner) relative
|
|
31
|
+
* to the point, and `fx` / `fy` the box's own offset as a fraction of its
|
|
32
|
+
* width / height (`0`, `-0.5` or `-1`), so the box grows away from the point.
|
|
33
|
+
*/
|
|
34
|
+
export interface MapAnchorGeometry {
|
|
35
|
+
dx: number;
|
|
36
|
+
dy: number;
|
|
37
|
+
fx: number;
|
|
38
|
+
fy: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DIAGONAL = Math.SQRT1_2;
|
|
42
|
+
|
|
43
|
+
export function mapAnchorGeometry(anchor: MapLabelAnchor, distance: number): MapAnchorGeometry {
|
|
44
|
+
const d = Math.max(0, distance);
|
|
45
|
+
const k = d * DIAGONAL;
|
|
46
|
+
switch (anchor) {
|
|
47
|
+
case "top":
|
|
48
|
+
return { dx: 0, dy: -d, fx: -0.5, fy: -1 };
|
|
49
|
+
case "bottom":
|
|
50
|
+
return { dx: 0, dy: d, fx: -0.5, fy: 0 };
|
|
51
|
+
case "left":
|
|
52
|
+
return { dx: -d, dy: 0, fx: -1, fy: -0.5 };
|
|
53
|
+
case "right":
|
|
54
|
+
return { dx: d, dy: 0, fx: 0, fy: -0.5 };
|
|
55
|
+
case "top-left":
|
|
56
|
+
return { dx: -k, dy: -k, fx: -1, fy: -1 };
|
|
57
|
+
case "top-right":
|
|
58
|
+
return { dx: k, dy: -k, fx: 0, fy: -1 };
|
|
59
|
+
case "bottom-left":
|
|
60
|
+
return { dx: -k, dy: k, fx: -1, fy: 0 };
|
|
61
|
+
case "bottom-right":
|
|
62
|
+
return { dx: k, dy: k, fx: 0, fy: 0 };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The CSS transform that puts a label box at `anchor`, `distance` px from its point. */
|
|
67
|
+
export function mapAnchorTransform(anchor: MapLabelAnchor, distance: number): string {
|
|
68
|
+
const { dx, dy, fx, fy } = mapAnchorGeometry(anchor, distance);
|
|
69
|
+
return `translate(calc(${fx * 100}% + ${dx}px), calc(${fy * 100}% + ${dy}px))`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The shift that keeps a `width` × `height` box whose top-left corner would
|
|
74
|
+
* land at (`left`, `top`) inside a `boundsWidth` × `boundsHeight` container,
|
|
75
|
+
* `pad` px from every edge. A box larger than the container pins to the
|
|
76
|
+
* start / top edge.
|
|
77
|
+
*/
|
|
78
|
+
export function clampShift(
|
|
79
|
+
left: number,
|
|
80
|
+
top: number,
|
|
81
|
+
width: number,
|
|
82
|
+
height: number,
|
|
83
|
+
boundsWidth: number,
|
|
84
|
+
boundsHeight: number,
|
|
85
|
+
pad = 4,
|
|
86
|
+
): { sx: number; sy: number } {
|
|
87
|
+
const maxLeft = Math.max(pad, boundsWidth - width - pad);
|
|
88
|
+
const maxTop = Math.max(pad, boundsHeight - height - pad);
|
|
89
|
+
const clampedLeft = Math.min(maxLeft, Math.max(pad, left));
|
|
90
|
+
const clampedTop = Math.min(maxTop, Math.max(pad, top));
|
|
91
|
+
return { sx: clampedLeft - left, sy: clampedTop - top };
|
|
92
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
MapAnnotation,
|
|
3
|
+
MapAnnotationKey,
|
|
4
|
+
type MapAnnotationProps,
|
|
5
|
+
type MapAnnotationConnector,
|
|
6
|
+
type MapAnnotationKeyProps,
|
|
7
|
+
type MapAnnotationKeyRow,
|
|
8
|
+
} from "./map-annotation";
|
|
9
|
+
export {
|
|
10
|
+
MAP_LABEL_ANCHORS,
|
|
11
|
+
mapAnchorGeometry,
|
|
12
|
+
mapAnchorTransform,
|
|
13
|
+
type MapLabelAnchor,
|
|
14
|
+
type MapAnchorGeometry,
|
|
15
|
+
} from "./anchor";
|
|
@@ -0,0 +1,113 @@
|
|
|
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 { MapMarker, MapMarkerContent } from "../map-marker";
|
|
6
|
+
import { LOCATOR_BOUNDS, LOCATOR_PLACES } from "../test-utils/locator-fixture";
|
|
7
|
+
import { MAP_LABEL_ANCHORS } from "./anchor";
|
|
8
|
+
import { MapAnnotation } from "./map-annotation";
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
title: "Maps/MapAnnotation",
|
|
12
|
+
component: MapAnnotation,
|
|
13
|
+
tags: ["autodocs"],
|
|
14
|
+
parameters: { layout: "fullscreen" },
|
|
15
|
+
} satisfies Meta<typeof MapAnnotation>;
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj<typeof meta>;
|
|
18
|
+
|
|
19
|
+
const NOTES = [
|
|
20
|
+
{ id: "toronto", at: LOCATOR_PLACES.toronto, text: "Toronto sits on the north shore." },
|
|
21
|
+
{ id: "lake", at: LOCATOR_PLACES.lakeLabel, text: "The lake is 311 km long." },
|
|
22
|
+
{ id: "kingston", at: LOCATOR_PLACES.kingston, text: "Kingston marks the outlet." },
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Three notes with leader lines. From `medium` up each is painted beside its
|
|
27
|
+
* point; at `narrow` (under 480 px of map width) the text would cover the
|
|
28
|
+
* data, so each point gets a number and the notes move to a numbered key
|
|
29
|
+
* under the map, in reading order.
|
|
30
|
+
*/
|
|
31
|
+
export const Default: Story = {
|
|
32
|
+
args: { longitude: 0, latitude: 0, text: "" },
|
|
33
|
+
render: () => (
|
|
34
|
+
<div className="w-full p-4">
|
|
35
|
+
<MapCanvas interactive={false} bounds={LOCATOR_BOUNDS} fitBoundsOptions={{ padding: 48 }}>
|
|
36
|
+
{NOTES.map((note, index) => (
|
|
37
|
+
<MapAnnotation
|
|
38
|
+
key={note.id}
|
|
39
|
+
{...note.at}
|
|
40
|
+
text={note.text}
|
|
41
|
+
anchor={index === 1 ? "bottom-right" : "top-right"}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
</MapCanvas>
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
play: async ({ canvasElement }) => {
|
|
48
|
+
const map = canvasElement.querySelector<HTMLElement>('[data-slot="map-canvas"]');
|
|
49
|
+
if (!map || !canvasElement.querySelector(".maplibregl-canvas")) return;
|
|
50
|
+
await waitFor(() =>
|
|
51
|
+
expect(canvasElement.querySelectorAll('[data-slot="map-annotation"]')).toHaveLength(3),
|
|
52
|
+
);
|
|
53
|
+
if (map.getAttribute("data-map-breakpoint") === "narrow") {
|
|
54
|
+
const key = await waitFor(() => {
|
|
55
|
+
const el = canvasElement.querySelector('[data-slot="map-annotation-key"]');
|
|
56
|
+
expect(el).not.toBeNull();
|
|
57
|
+
return el!;
|
|
58
|
+
});
|
|
59
|
+
await expect(key.querySelectorAll('[data-slot="map-annotation-key-item"]')).toHaveLength(3);
|
|
60
|
+
await expect(map.contains(key)).toBe(false);
|
|
61
|
+
} else {
|
|
62
|
+
await expect(canvasElement.querySelector('[data-slot="map-annotation-key"]')).toBeNull();
|
|
63
|
+
await expect(
|
|
64
|
+
canvasElement.querySelectorAll('[data-slot="map-annotation-connector"]'),
|
|
65
|
+
).toHaveLength(3);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every anchor around one point, with arrowheads; a note whose `showAt` says
|
|
72
|
+
* `narrow: false` stays off small maps entirely instead of joining the key.
|
|
73
|
+
*/
|
|
74
|
+
export const AnchorsAndArrows: Story = {
|
|
75
|
+
args: { longitude: 0, latitude: 0, text: "" },
|
|
76
|
+
render: () => (
|
|
77
|
+
<div className="w-full p-4">
|
|
78
|
+
<MapCanvas interactive={false} center={[-78.2, 43.7]} zoom={6.4}>
|
|
79
|
+
<MapMarker longitude={-78.2} latitude={43.7}>
|
|
80
|
+
<MapMarkerContent />
|
|
81
|
+
</MapMarker>
|
|
82
|
+
{MAP_LABEL_ANCHORS.map((anchor) => (
|
|
83
|
+
<MapAnnotation
|
|
84
|
+
key={anchor}
|
|
85
|
+
longitude={-78.2}
|
|
86
|
+
latitude={43.7}
|
|
87
|
+
text={anchor}
|
|
88
|
+
anchor={anchor}
|
|
89
|
+
connector={{ arrow: true }}
|
|
90
|
+
showAt={{ base: true, narrow: anchor.includes("-") ? false : true }}
|
|
91
|
+
/>
|
|
92
|
+
))}
|
|
93
|
+
</MapCanvas>
|
|
94
|
+
</div>
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** A note with no leader line — the text sits right at its point. */
|
|
99
|
+
export const WithoutConnector: Story = {
|
|
100
|
+
args: { longitude: 0, latitude: 0, text: "" },
|
|
101
|
+
render: () => (
|
|
102
|
+
<div className="w-full p-4">
|
|
103
|
+
<MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={8}>
|
|
104
|
+
<MapAnnotation
|
|
105
|
+
{...LOCATOR_PLACES.toronto}
|
|
106
|
+
text="Downtown Toronto"
|
|
107
|
+
anchor="right"
|
|
108
|
+
connector={false}
|
|
109
|
+
/>
|
|
110
|
+
</MapCanvas>
|
|
111
|
+
</div>
|
|
112
|
+
),
|
|
113
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } 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 { resetMaplibreMock } from "../test-utils/maplibre-mock";
|
|
10
|
+
import { MapCanvas } from "../map-canvas";
|
|
11
|
+
import { MapAnnotation } from "./map-annotation";
|
|
12
|
+
import { clampShift, mapAnchorGeometry } from "./anchor";
|
|
13
|
+
|
|
14
|
+
let rectSpy: MockInstance | null = null;
|
|
15
|
+
function atWidth(width: number) {
|
|
16
|
+
rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
|
17
|
+
width,
|
|
18
|
+
height: 0,
|
|
19
|
+
top: 0,
|
|
20
|
+
left: 0,
|
|
21
|
+
right: width,
|
|
22
|
+
bottom: 0,
|
|
23
|
+
x: 0,
|
|
24
|
+
y: 0,
|
|
25
|
+
toJSON: () => ({}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
rectSpy = null;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
rectSpy?.mockRestore();
|
|
35
|
+
cleanup();
|
|
36
|
+
resetMaplibreMock();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function ThreeNotes() {
|
|
40
|
+
return (
|
|
41
|
+
<MapCanvas>
|
|
42
|
+
<MapAnnotation longitude={-79.4} latitude={43.7} text="Toronto sits on the north shore." />
|
|
43
|
+
<MapAnnotation
|
|
44
|
+
longitude={-78.9}
|
|
45
|
+
latitude={43.3}
|
|
46
|
+
text="Only on wide maps."
|
|
47
|
+
showAt={{ base: true, narrow: false }}
|
|
48
|
+
/>
|
|
49
|
+
<MapAnnotation longitude={-77.5} latitude={43.6} text="The lake is 311 km long." />
|
|
50
|
+
<MapAnnotation longitude={-76.4} latitude={44.2} text="Kingston marks the outlet." />
|
|
51
|
+
</MapCanvas>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("MapAnnotation", () => {
|
|
56
|
+
it("paints its text with a leader line at wide, and no key", async () => {
|
|
57
|
+
atWidth(868);
|
|
58
|
+
render(<ThreeNotes />);
|
|
59
|
+
expect(await screen.findByText("Toronto sits on the north shore.")).toBeInTheDocument();
|
|
60
|
+
expect(document.querySelectorAll('[data-slot="map-annotation-connector"]')).toHaveLength(4);
|
|
61
|
+
expect(document.querySelector('[data-slot="map-annotation-key"]')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("numbers the shown notes in reading order and keys them under the map at narrow", async () => {
|
|
65
|
+
atWidth(348);
|
|
66
|
+
render(<ThreeNotes />);
|
|
67
|
+
const key = await waitFor(() => {
|
|
68
|
+
const el = document.querySelector<HTMLElement>('[data-slot="map-annotation-key"]');
|
|
69
|
+
expect(el).not.toBeNull();
|
|
70
|
+
return el!;
|
|
71
|
+
});
|
|
72
|
+
const rows = within(key).getAllByRole("listitem");
|
|
73
|
+
expect(rows).toHaveLength(3);
|
|
74
|
+
expect(rows.map((row) => row.textContent)).toEqual([
|
|
75
|
+
"1Toronto sits on the north shore.",
|
|
76
|
+
"2The lake is 311 km long.",
|
|
77
|
+
"3Kingston marks the outlet.",
|
|
78
|
+
]);
|
|
79
|
+
// The key sits OUTSIDE the map box, after it.
|
|
80
|
+
const below = document.querySelector('[data-slot="map-canvas-below"]')!;
|
|
81
|
+
expect(below.contains(key)).toBe(true);
|
|
82
|
+
const map = document.querySelector('[data-slot="map-canvas"]')!;
|
|
83
|
+
expect(map.contains(key)).toBe(false);
|
|
84
|
+
// On the map: three numbered markers, the hidden note gone.
|
|
85
|
+
const markers = map.querySelectorAll('[data-slot="map-annotation-number"]');
|
|
86
|
+
expect([...markers].map((m) => m.textContent)).toEqual(["1", "2", "3"]);
|
|
87
|
+
expect(map.querySelectorAll('[data-display="hidden"]')).toHaveLength(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("annotation geometry", () => {
|
|
92
|
+
it("sets the box on the far side of its anchor", () => {
|
|
93
|
+
expect(mapAnchorGeometry("top", 10)).toEqual({ dx: 0, dy: -10, fx: -0.5, fy: -1 });
|
|
94
|
+
expect(mapAnchorGeometry("right", 10)).toEqual({ dx: 10, dy: 0, fx: 0, fy: -0.5 });
|
|
95
|
+
const diagonal = mapAnchorGeometry("bottom-left", 10);
|
|
96
|
+
expect(diagonal.dx).toBeCloseTo(-7.071, 3);
|
|
97
|
+
expect(diagonal.dy).toBeCloseTo(7.071, 3);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("clamps a box inside the map on both axes", () => {
|
|
101
|
+
expect(clampShift(330, -20, 60, 30, 348, 348)).toEqual({ sx: -46, sy: 24 });
|
|
102
|
+
expect(clampShift(10, 10, 60, 30, 348, 348)).toEqual({ sx: 0, sy: 0 });
|
|
103
|
+
// Wider than the map: pinned to the start edge.
|
|
104
|
+
expect(clampShift(-50, 10, 500, 30, 348, 348)).toEqual({ sx: 54, sy: 0 });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useId,
|
|
9
|
+
useLayoutEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
type HTMLAttributes,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from "react";
|
|
16
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
17
|
+
|
|
18
|
+
import { useMap } from "../map-canvas/map-context";
|
|
19
|
+
import {
|
|
20
|
+
type MapBreakpoint,
|
|
21
|
+
type MapResponsive,
|
|
22
|
+
useMapBreakpoint,
|
|
23
|
+
resolveMapResponsive,
|
|
24
|
+
} from "../lib/use-map-breakpoint";
|
|
25
|
+
import { type MapLabelAnchor, clampShift, mapAnchorGeometry } from "./anchor";
|
|
26
|
+
|
|
27
|
+
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
28
|
+
//
|
|
29
|
+
// Every `<MapAnnotation>` in a canvas registers here, so the canvas can number
|
|
30
|
+
// them and render ONE key under the map at `narrow`. Numbers follow reading
|
|
31
|
+
// order — the annotations' DOM order, which is their JSX order — and skip an
|
|
32
|
+
// annotation hidden at the current tier (the RM-111 rule `charts` uses).
|
|
33
|
+
|
|
34
|
+
interface AnnotationEntry {
|
|
35
|
+
id: string;
|
|
36
|
+
el: HTMLElement;
|
|
37
|
+
text: ReactNode;
|
|
38
|
+
hidden: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One row of the narrow key. */
|
|
42
|
+
export interface MapAnnotationKeyRow {
|
|
43
|
+
id: string;
|
|
44
|
+
number: number;
|
|
45
|
+
text: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MapAnnotationRegistryValue {
|
|
49
|
+
register: (entry: AnnotationEntry) => () => void;
|
|
50
|
+
/** The number an annotation shows at this tier, or `undefined` when it paints its text. */
|
|
51
|
+
numberOf: (id: string) => number | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const NO_ROWS: MapAnnotationKeyRow[] = [];
|
|
55
|
+
|
|
56
|
+
const MapAnnotationRegistryContext = createContext<MapAnnotationRegistryValue | null>(null);
|
|
57
|
+
|
|
58
|
+
function byDocumentOrder(a: AnnotationEntry, b: AnnotationEntry): number {
|
|
59
|
+
if (a.el === b.el) return 0;
|
|
60
|
+
return a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Internal: the annotation registry a `<MapCanvas>` owns. Returns the context
|
|
65
|
+
* value to provide and the key rows to render under the map (empty unless the
|
|
66
|
+
* tier is `narrow`).
|
|
67
|
+
*/
|
|
68
|
+
export function useMapAnnotationRegistry(breakpoint: MapBreakpoint): {
|
|
69
|
+
value: MapAnnotationRegistryValue;
|
|
70
|
+
rows: MapAnnotationKeyRow[];
|
|
71
|
+
} {
|
|
72
|
+
const [entries, setEntries] = useState<ReadonlyMap<string, AnnotationEntry>>(() => new Map());
|
|
73
|
+
|
|
74
|
+
const register = useCallback((entry: AnnotationEntry) => {
|
|
75
|
+
setEntries((prev) => {
|
|
76
|
+
const current = prev.get(entry.id);
|
|
77
|
+
if (
|
|
78
|
+
current &&
|
|
79
|
+
current.el === entry.el &&
|
|
80
|
+
current.text === entry.text &&
|
|
81
|
+
current.hidden === entry.hidden
|
|
82
|
+
) {
|
|
83
|
+
return prev;
|
|
84
|
+
}
|
|
85
|
+
const next = new Map(prev);
|
|
86
|
+
next.set(entry.id, entry);
|
|
87
|
+
return next;
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
setEntries((prev) => {
|
|
91
|
+
if (!prev.has(entry.id)) return prev;
|
|
92
|
+
const next = new Map(prev);
|
|
93
|
+
next.delete(entry.id);
|
|
94
|
+
return next;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const rows = useMemo<MapAnnotationKeyRow[]>(() => {
|
|
100
|
+
if (breakpoint !== "narrow") return NO_ROWS;
|
|
101
|
+
return [...entries.values()]
|
|
102
|
+
.filter((entry) => !entry.hidden && entry.el.isConnected)
|
|
103
|
+
.sort(byDocumentOrder)
|
|
104
|
+
.map((entry, index) => ({ id: entry.id, number: index + 1, text: entry.text }));
|
|
105
|
+
}, [entries, breakpoint]);
|
|
106
|
+
|
|
107
|
+
const value = useMemo<MapAnnotationRegistryValue>(() => {
|
|
108
|
+
const numbers = new Map(rows.map((row) => [row.id, row.number]));
|
|
109
|
+
return { register, numberOf: (id) => numbers.get(id) };
|
|
110
|
+
}, [register, rows]);
|
|
111
|
+
|
|
112
|
+
return { value, rows };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Internal: provides the registry `useMapAnnotationRegistry` built. */
|
|
116
|
+
export const MapAnnotationRegistryProvider = MapAnnotationRegistryContext.Provider;
|
|
117
|
+
|
|
118
|
+
// ── Key ──────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export interface MapAnnotationKeyProps extends HTMLAttributes<HTMLOListElement> {
|
|
121
|
+
/** The rows, in reading order. */
|
|
122
|
+
rows: readonly MapAnnotationKeyRow[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The numbered list `<MapCanvas>` renders under the map at `narrow`, where each
|
|
127
|
+
* `<MapAnnotation>` shows only its number on the map: "1 Lake Ontario, …".
|
|
128
|
+
* NOT `aria-hidden` — at `narrow` this list is the only place the text lives.
|
|
129
|
+
*/
|
|
130
|
+
export const MapAnnotationKey = forwardRef<HTMLOListElement, MapAnnotationKeyProps>(
|
|
131
|
+
function MapAnnotationKey({ rows, className, ...props }, ref) {
|
|
132
|
+
return (
|
|
133
|
+
<ol
|
|
134
|
+
ref={ref}
|
|
135
|
+
data-slot="map-annotation-key"
|
|
136
|
+
data-count={rows.length}
|
|
137
|
+
className={cn("m-0 flex list-none flex-col gap-1 p-0", className)}
|
|
138
|
+
{...props}
|
|
139
|
+
>
|
|
140
|
+
{rows.map((row) => (
|
|
141
|
+
<li
|
|
142
|
+
key={row.id}
|
|
143
|
+
data-slot="map-annotation-key-item"
|
|
144
|
+
className="flex items-start gap-1.5 text-caption text-foreground"
|
|
145
|
+
>
|
|
146
|
+
<NumberBadge number={row.number} />
|
|
147
|
+
<span className="min-w-0 break-words">{row.text}</span>
|
|
148
|
+
</li>
|
|
149
|
+
))}
|
|
150
|
+
</ol>
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
function NumberBadge({ number, className }: { number: number; className?: string }) {
|
|
156
|
+
return (
|
|
157
|
+
<span
|
|
158
|
+
aria-hidden="true"
|
|
159
|
+
data-slot="map-annotation-number"
|
|
160
|
+
className={cn(
|
|
161
|
+
"inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-foreground text-meta font-semibold text-background tabular-nums",
|
|
162
|
+
className,
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{number}
|
|
166
|
+
</span>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Annotation ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/** A leader line from the point to the text; `arrow` puts an arrowhead at the point. */
|
|
173
|
+
export interface MapAnnotationConnector {
|
|
174
|
+
arrow?: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface MapAnnotationProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
178
|
+
/** Longitude of the point the note is about. */
|
|
179
|
+
longitude: number;
|
|
180
|
+
/** Latitude of the point the note is about. */
|
|
181
|
+
latitude: number;
|
|
182
|
+
/** The note. Plain text reads best; it moves into the key at `narrow`. */
|
|
183
|
+
text: ReactNode;
|
|
184
|
+
/** Where the text sits relative to the point (default `"top-right"`). */
|
|
185
|
+
anchor?: MapLabelAnchor;
|
|
186
|
+
/**
|
|
187
|
+
* The leader line from the point to the text (default: a plain line). Pass
|
|
188
|
+
* `false` to set the text right beside the point with no line.
|
|
189
|
+
*/
|
|
190
|
+
connector?: MapAnnotationConnector | false;
|
|
191
|
+
/**
|
|
192
|
+
* Whether the note shows at a tier (default `true` everywhere). A hidden note
|
|
193
|
+
* takes no number, so the key stays gap-free.
|
|
194
|
+
*/
|
|
195
|
+
showAt?: MapResponsive<boolean>;
|
|
196
|
+
/** Paint order among annotations: a higher one paints on top (0–8, default 0). */
|
|
197
|
+
priority?: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const LEADER_DISTANCE = 28;
|
|
201
|
+
const PLAIN_DISTANCE = 6;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A text note pinned to a lon / lat with a leader line — an HTML overlay that
|
|
205
|
+
* follows the map. At `narrow` the text gives way to a numbered marker and
|
|
206
|
+
* `<MapCanvas>` lists the notes, in reading order, in a key under the map.
|
|
207
|
+
* The text box clamps inside the map on both axes. Render inside `<MapCanvas>`.
|
|
208
|
+
*/
|
|
209
|
+
export const MapAnnotation = forwardRef<HTMLDivElement, MapAnnotationProps>(function MapAnnotation(
|
|
210
|
+
{
|
|
211
|
+
longitude,
|
|
212
|
+
latitude,
|
|
213
|
+
text,
|
|
214
|
+
anchor = "top-right",
|
|
215
|
+
connector = {},
|
|
216
|
+
showAt = true,
|
|
217
|
+
priority = 0,
|
|
218
|
+
className,
|
|
219
|
+
style,
|
|
220
|
+
...props
|
|
221
|
+
},
|
|
222
|
+
forwardedRef,
|
|
223
|
+
) {
|
|
224
|
+
const { map } = useMap();
|
|
225
|
+
const registry = useContext(MapAnnotationRegistryContext);
|
|
226
|
+
const breakpoint = useMapBreakpoint();
|
|
227
|
+
const id = useId();
|
|
228
|
+
const [root, setRoot] = useState<HTMLDivElement | null>(null);
|
|
229
|
+
const boxRef = useRef<HTMLDivElement>(null);
|
|
230
|
+
const lineRef = useRef<SVGLineElement>(null);
|
|
231
|
+
|
|
232
|
+
const hidden = !resolveMapResponsive(showAt, breakpoint);
|
|
233
|
+
const number = registry?.numberOf(id);
|
|
234
|
+
const keyed = number !== undefined;
|
|
235
|
+
|
|
236
|
+
const setRefs = useCallback(
|
|
237
|
+
(node: HTMLDivElement | null) => {
|
|
238
|
+
setRoot(node);
|
|
239
|
+
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
240
|
+
else if (forwardedRef) forwardedRef.current = node;
|
|
241
|
+
},
|
|
242
|
+
[forwardedRef],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// Depend on the stable `register`, never the registry value: that changes
|
|
246
|
+
// with every registration and would re-register in a loop.
|
|
247
|
+
const register = registry?.register;
|
|
248
|
+
useLayoutEffect(() => {
|
|
249
|
+
if (!register || !root) return undefined;
|
|
250
|
+
return register({ id, el: root, text, hidden });
|
|
251
|
+
}, [register, id, root, text, hidden]);
|
|
252
|
+
|
|
253
|
+
const distance = connector === false ? PLAIN_DISTANCE : LEADER_DISTANCE;
|
|
254
|
+
|
|
255
|
+
// Follow the map. Transforms are written straight to the DOM on every move
|
|
256
|
+
// (no React render per frame); the text box is measured and clamped inside
|
|
257
|
+
// the map, and the leader line's end moves with it.
|
|
258
|
+
useLayoutEffect(() => {
|
|
259
|
+
if (!map || !root || hidden) return undefined;
|
|
260
|
+
const container = map.getContainer();
|
|
261
|
+
const { dx, dy, fx, fy } = mapAnchorGeometry(anchor, distance);
|
|
262
|
+
const update = () => {
|
|
263
|
+
const point = map.project([longitude, latitude]);
|
|
264
|
+
const width = container.clientWidth;
|
|
265
|
+
const height = container.clientHeight;
|
|
266
|
+
const inside = point.x >= 0 && point.y >= 0 && point.x <= width && point.y <= height;
|
|
267
|
+
root.style.visibility = inside ? "" : "hidden";
|
|
268
|
+
root.style.transform = `translate(${point.x}px, ${point.y}px)`;
|
|
269
|
+
const box = boxRef.current;
|
|
270
|
+
if (!box) return;
|
|
271
|
+
const boxWidth = box.offsetWidth;
|
|
272
|
+
const boxHeight = box.offsetHeight;
|
|
273
|
+
const left = point.x + dx + fx * boxWidth;
|
|
274
|
+
const top = point.y + dy + fy * boxHeight;
|
|
275
|
+
const { sx, sy } = clampShift(left, top, boxWidth, boxHeight, width, height);
|
|
276
|
+
box.style.transform = `translate(${dx + sx + fx * boxWidth}px, ${dy + sy + fy * boxHeight}px)`;
|
|
277
|
+
const line = lineRef.current;
|
|
278
|
+
if (line) {
|
|
279
|
+
line.setAttribute("x2", String(dx + sx));
|
|
280
|
+
line.setAttribute("y2", String(dy + sy));
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
update();
|
|
284
|
+
map.on("move", update);
|
|
285
|
+
map.on("resize", update);
|
|
286
|
+
return () => {
|
|
287
|
+
map.off("move", update);
|
|
288
|
+
map.off("resize", update);
|
|
289
|
+
};
|
|
290
|
+
}, [map, root, hidden, keyed, anchor, distance, longitude, latitude, text]);
|
|
291
|
+
|
|
292
|
+
const arrowId = `${id}-arrow`;
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
ref={setRefs}
|
|
297
|
+
data-slot="map-annotation"
|
|
298
|
+
data-display={hidden ? "hidden" : keyed ? "keyed" : "painted"}
|
|
299
|
+
hidden={hidden}
|
|
300
|
+
className={cn("pointer-events-none absolute", className)}
|
|
301
|
+
// Geographic overlay: a projected pixel offset from the map's top-left
|
|
302
|
+
// corner, physical in every writing direction (like MapLibre's markers).
|
|
303
|
+
style={{ left: 0, top: 0, zIndex: 1 + Math.max(0, Math.min(8, priority)), ...style }}
|
|
304
|
+
{...props}
|
|
305
|
+
>
|
|
306
|
+
{keyed ? (
|
|
307
|
+
<NumberBadge number={number} className="absolute -translate-x-1/2 -translate-y-1/2" />
|
|
308
|
+
) : (
|
|
309
|
+
<>
|
|
310
|
+
{connector !== false && (
|
|
311
|
+
<svg
|
|
312
|
+
aria-hidden="true"
|
|
313
|
+
data-slot="map-annotation-connector"
|
|
314
|
+
className="absolute overflow-visible text-foreground"
|
|
315
|
+
width={1}
|
|
316
|
+
height={1}
|
|
317
|
+
>
|
|
318
|
+
{connector.arrow && (
|
|
319
|
+
<defs>
|
|
320
|
+
<marker
|
|
321
|
+
id={arrowId}
|
|
322
|
+
viewBox="0 0 8 8"
|
|
323
|
+
refX={0}
|
|
324
|
+
refY={4}
|
|
325
|
+
markerWidth={8}
|
|
326
|
+
markerHeight={8}
|
|
327
|
+
orient="auto"
|
|
328
|
+
>
|
|
329
|
+
<path d="M8 0 L0 4 L8 8 Z" fill="currentColor" />
|
|
330
|
+
</marker>
|
|
331
|
+
</defs>
|
|
332
|
+
)}
|
|
333
|
+
<line
|
|
334
|
+
ref={lineRef}
|
|
335
|
+
x1={0}
|
|
336
|
+
y1={0}
|
|
337
|
+
x2={0}
|
|
338
|
+
y2={0}
|
|
339
|
+
stroke="currentColor"
|
|
340
|
+
strokeWidth={1}
|
|
341
|
+
markerStart={connector.arrow ? `url(#${arrowId})` : undefined}
|
|
342
|
+
/>
|
|
343
|
+
</svg>
|
|
344
|
+
)}
|
|
345
|
+
<div
|
|
346
|
+
ref={boxRef}
|
|
347
|
+
data-slot="map-annotation-text"
|
|
348
|
+
className="absolute w-max max-w-48 rounded-sm bg-background/85 px-1 text-caption text-foreground"
|
|
349
|
+
>
|
|
350
|
+
{text}
|
|
351
|
+
</div>
|
|
352
|
+
</>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
});
|