@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
|
@@ -16,8 +16,25 @@ import {
|
|
|
16
16
|
import { Spinner, StatePanel, useLocale } from "@elabs-ai/components-ui";
|
|
17
17
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
18
18
|
|
|
19
|
+
import { createPlanCrs, type PlanCrs, type PlanExtent } from "../lib/plan-crs";
|
|
20
|
+
import { warnMapOnce } from "../lib/warn-once";
|
|
19
21
|
import { MapContext, type BasemapTheme } from "./map-context";
|
|
20
22
|
import { useResolvedBasemapTheme } from "./use-resolved-basemap-theme";
|
|
23
|
+
import {
|
|
24
|
+
MapFrameContext,
|
|
25
|
+
type MapFrameContextValue,
|
|
26
|
+
type MapFrameSide,
|
|
27
|
+
type MapHeight,
|
|
28
|
+
type MapResponsive,
|
|
29
|
+
mapBreakpointForWidth,
|
|
30
|
+
resolveMapHeightStyle,
|
|
31
|
+
useMeasuredMapBreakpoint,
|
|
32
|
+
} from "../lib/use-map-breakpoint";
|
|
33
|
+
import {
|
|
34
|
+
MapAnnotationKey,
|
|
35
|
+
MapAnnotationRegistryProvider,
|
|
36
|
+
useMapAnnotationRegistry,
|
|
37
|
+
} from "../map-annotation/map-annotation";
|
|
21
38
|
|
|
22
39
|
/**
|
|
23
40
|
* Default basemaps: Carto's free light/dark GL styles. These serve ODbL-licensed
|
|
@@ -33,6 +50,9 @@ const defaultStyles = {
|
|
|
33
50
|
// data visualizations (choropleths, arcs, dot maps) where you draw your own
|
|
34
51
|
// layers and don't need a street basemap: `<MapCanvas blank>`. The transparent
|
|
35
52
|
// background lets the themed container show through.
|
|
53
|
+
/** Breathing room, in CSS pixels, between a fitted plan and the viewport edge. */
|
|
54
|
+
const PLAN_FIT_PADDING = 24;
|
|
55
|
+
|
|
36
56
|
const blankMapStyle: MapLibreGL.StyleSpecification = {
|
|
37
57
|
version: 8,
|
|
38
58
|
sources: {},
|
|
@@ -59,6 +79,93 @@ export interface MapViewport {
|
|
|
59
79
|
|
|
60
80
|
export type MapStyleOption = string | MapLibreGL.StyleSpecification;
|
|
61
81
|
|
|
82
|
+
/** `"mercator"` / `"globe"`, or a full MapLibre projection spec. */
|
|
83
|
+
export type MapProjectionOption = "mercator" | "globe" | MapLibreGL.ProjectionSpecification;
|
|
84
|
+
|
|
85
|
+
/** The gesture handlers static mode switches off (tooltips and hover keep working). */
|
|
86
|
+
const GESTURE_HANDLERS = [
|
|
87
|
+
"scrollZoom",
|
|
88
|
+
"boxZoom",
|
|
89
|
+
"dragRotate",
|
|
90
|
+
"dragPan",
|
|
91
|
+
"keyboard",
|
|
92
|
+
"doubleClickZoom",
|
|
93
|
+
"touchZoomRotate",
|
|
94
|
+
"touchPitch",
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
type GestureHandlerKey = (typeof GESTURE_HANDLERS)[number];
|
|
98
|
+
|
|
99
|
+
const STATIC_HANDLER_OPTIONS = Object.fromEntries(
|
|
100
|
+
GESTURE_HANDLERS.map((key) => [key, false]),
|
|
101
|
+
) as Record<GestureHandlerKey, false>;
|
|
102
|
+
|
|
103
|
+
let warnedProjection = false;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Apply a projection when this MapLibre build supports switching one
|
|
107
|
+
* (`setProjection`, MapLibre 5+); otherwise leave the map as it is and say so
|
|
108
|
+
* once — a globe is an extra, never a requirement.
|
|
109
|
+
*/
|
|
110
|
+
function applyProjection(map: MapLibreGL.Map, projection: MapLibreGL.ProjectionSpecification) {
|
|
111
|
+
if (typeof map.setProjection !== "function") {
|
|
112
|
+
if (!warnedProjection) {
|
|
113
|
+
warnedProjection = true;
|
|
114
|
+
console.warn(
|
|
115
|
+
"[@elabs-ai/components-maps] This MapLibre build cannot switch projections; `projection` is ignored.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
map.setProjection(projection);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Hide (or restore) the basemap's OWN text labels — every symbol layer that
|
|
125
|
+
* draws a `text-field` (c-6 / c-11).
|
|
126
|
+
*
|
|
127
|
+
* A basemap's labels are drawn for a full-size map. Inside a 96 px inset they
|
|
128
|
+
* are sliced mid-word by the frame ("EUROP", "AMERIC") and louder than the
|
|
129
|
+
* globe they caption; on a locator they print a place name the editorial
|
|
130
|
+
* marker names again 16 px away. Both are the locator recipe's answer:
|
|
131
|
+
* the basemap draws the ground, the map's own labels do the naming.
|
|
132
|
+
*
|
|
133
|
+
* `hidden` records what THIS call turned off, so restoring never reveals a
|
|
134
|
+
* layer the style itself shipped hidden.
|
|
135
|
+
*/
|
|
136
|
+
function applyBasemapLabels(map: MapLibreGL.Map, visible: boolean, hidden: Set<string>) {
|
|
137
|
+
let style: MapLibreGL.StyleSpecification | undefined;
|
|
138
|
+
try {
|
|
139
|
+
style = map.getStyle?.();
|
|
140
|
+
} catch {
|
|
141
|
+
// style mid-reload
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!style?.layers) return;
|
|
145
|
+
if (visible) {
|
|
146
|
+
for (const id of hidden) {
|
|
147
|
+
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", "visible");
|
|
148
|
+
}
|
|
149
|
+
hidden.clear();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const layer of style.layers) {
|
|
153
|
+
if (layer.type !== "symbol") continue;
|
|
154
|
+
if (!layer.layout || !("text-field" in layer.layout)) continue;
|
|
155
|
+
if (hidden.has(layer.id)) continue;
|
|
156
|
+
if (map.getLayoutProperty?.(layer.id, "visibility") === "none") continue;
|
|
157
|
+
map.setLayoutProperty(layer.id, "visibility", "none");
|
|
158
|
+
hidden.add(layer.id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A box's measured size as a comparable key, or `null` while it has none. */
|
|
163
|
+
function boxSize(node: HTMLElement): string | null {
|
|
164
|
+
const { width, height } = node.getBoundingClientRect();
|
|
165
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
166
|
+
return `${Math.round(width)}×${Math.round(height)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
62
169
|
/** The imperative handle exposed by `<MapCanvas ref>`: the MapLibre map itself. */
|
|
63
170
|
export type MapCanvasRef = MapLibreGL.Map;
|
|
64
171
|
|
|
@@ -84,8 +191,37 @@ export type MapCanvasProps = {
|
|
|
84
191
|
* visualizations. Ignored when an explicit `styles` prop is provided.
|
|
85
192
|
*/
|
|
86
193
|
blank?: boolean;
|
|
87
|
-
/**
|
|
88
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Draw the basemap's own place labels. `false` leaves the ground and hides
|
|
196
|
+
* every text label the basemap style carries, so the map's own labels
|
|
197
|
+
* (`MapMarker`'s `label`, `MapAnnotation`) are the only naming on it — the
|
|
198
|
+
* locator recipe, and the default inside `MapInset`, where a full-size
|
|
199
|
+
* label is sliced by the frame. Default `true`.
|
|
200
|
+
*/
|
|
201
|
+
basemapLabels?: boolean;
|
|
202
|
+
/**
|
|
203
|
+
* Map projection: `"mercator"` (MapLibre's default) or `"globe"` for a 3D
|
|
204
|
+
* globe view, or a full MapLibre projection spec. Feature-detected: a
|
|
205
|
+
* MapLibre build that cannot switch projections ignores it (with a console
|
|
206
|
+
* note).
|
|
207
|
+
*/
|
|
208
|
+
projection?: MapProjectionOption;
|
|
209
|
+
/**
|
|
210
|
+
* `false` makes the map STATIC — the editorial / locator default: no zoom,
|
|
211
|
+
* pan, rotate or keyboard handlers, the default cursor and no tab stop
|
|
212
|
+
* (leave `<MapControls>` out of a static map). Hover and click still reach
|
|
213
|
+
* layers and markers, so tooltips keep working. The viewport can still be set in code
|
|
214
|
+
* (`viewport`, the ref). Default `true`.
|
|
215
|
+
*/
|
|
216
|
+
interactive?: boolean;
|
|
217
|
+
/**
|
|
218
|
+
* The map's height: CSS px, or `{ aspect }` (width ÷ height), optionally per
|
|
219
|
+
* tier — `{ base: { aspect: 1.6 }, narrow: { aspect: 1 } }`. Tiers are
|
|
220
|
+
* measured on the map's own width (`narrow` < 480 px, `medium` < 768 px).
|
|
221
|
+
* Unset, the map fills its parent as before; a parent with no height of its
|
|
222
|
+
* own gets `DEFAULT_MAP_HEIGHT` (1.6 : 1, square at `narrow`) instead of 0.
|
|
223
|
+
*/
|
|
224
|
+
height?: MapResponsive<MapHeight>;
|
|
89
225
|
/**
|
|
90
226
|
* Controlled viewport. When provided together with `onViewportChange`, the
|
|
91
227
|
* map becomes controlled and the viewport is driven by this prop.
|
|
@@ -99,12 +235,34 @@ export type MapCanvasProps = {
|
|
|
99
235
|
onViewportChange?: (viewport: MapViewport) => void;
|
|
100
236
|
/** Show a loading overlay on the map (e.g. while the app fetches map data). */
|
|
101
237
|
loading?: boolean;
|
|
102
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Turn the canvas into a CUSTOM (non-geographic) plan map: a floor plan, a
|
|
240
|
+
* factory layout, a train carriage, a rack elevation. Pass the plan's extent
|
|
241
|
+
* (`{ width, height }` in the plan's own units) or a `createPlanCrs(…)`
|
|
242
|
+
* result, and every layer inside then speaks PLAN coordinates instead of
|
|
243
|
+
* lng/lat — shapes, routes, markers and popups alike.
|
|
244
|
+
*
|
|
245
|
+
* The plan is fitted on mount, pan and zoom are clamped to its extent, and
|
|
246
|
+
* rotation and pitch are off (a rotated floor plan is unreadable). Pair it
|
|
247
|
+
* with `blank` and, for a picture under the shapes, `<MapPlanImage>`.
|
|
248
|
+
*
|
|
249
|
+
* The plan coordinate system is synthesized on Web Mercator, so it is not a
|
|
250
|
+
* georeference: distances belong to the plan, and a scale bar would lie.
|
|
251
|
+
*/
|
|
252
|
+
plan?: PlanExtent | PlanCrs;
|
|
253
|
+
/**
|
|
254
|
+
* Fit a plan map to its extent on mount and whenever the extent changes
|
|
255
|
+
* (default: true). Ignored on a geographic map, and skipped when the camera
|
|
256
|
+
* is driven through `viewport`.
|
|
257
|
+
*/
|
|
258
|
+
fitPlan?: boolean;
|
|
259
|
+
} & Omit<MapLibreGL.MapOptions, "container" | "style" | "interactive">;
|
|
103
260
|
|
|
104
261
|
function MapLoadingOverlay() {
|
|
262
|
+
const { t } = useLocale();
|
|
105
263
|
return (
|
|
106
264
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-xs">
|
|
107
|
-
<Spinner label="
|
|
265
|
+
<Spinner label={t("maps.canvas.loading")} className="size-5" />
|
|
108
266
|
</div>
|
|
109
267
|
);
|
|
110
268
|
}
|
|
@@ -134,16 +292,33 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
134
292
|
theme: themeProp,
|
|
135
293
|
styles,
|
|
136
294
|
blank = false,
|
|
137
|
-
|
|
295
|
+
basemapLabels = true,
|
|
296
|
+
projection: projectionProp,
|
|
138
297
|
viewport,
|
|
139
298
|
onViewportChange,
|
|
140
299
|
loading = false,
|
|
300
|
+
interactive,
|
|
301
|
+
height,
|
|
302
|
+
plan: planProp,
|
|
303
|
+
fitPlan = true,
|
|
304
|
+
// Pulled out of `...props` so the constructor and the live plan-limit effect
|
|
305
|
+
// below resolve them the same way.
|
|
306
|
+
minZoom,
|
|
307
|
+
maxZoom,
|
|
308
|
+
maxBounds,
|
|
141
309
|
...props
|
|
142
310
|
},
|
|
143
311
|
ref,
|
|
144
312
|
) {
|
|
145
313
|
const { t } = useLocale();
|
|
146
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
314
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
315
|
+
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(null);
|
|
316
|
+
const setContainer = useCallback((node: HTMLDivElement | null) => {
|
|
317
|
+
containerRef.current = node;
|
|
318
|
+
setContainerNode(node);
|
|
319
|
+
}, []);
|
|
320
|
+
const breakpoint = useMeasuredMapBreakpoint(containerNode);
|
|
321
|
+
const isStatic = interactive === false;
|
|
147
322
|
const [mapInstance, setMapInstance] = useState<MapLibreGL.Map | null>(null);
|
|
148
323
|
const [initFailed, setInitFailed] = useState(false);
|
|
149
324
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
@@ -158,10 +333,16 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
158
333
|
const onViewportChangeRef = useRef(onViewportChange);
|
|
159
334
|
onViewportChangeRef.current = onViewportChange;
|
|
160
335
|
|
|
336
|
+
// A string shorthand becomes a stable spec object; a spec object passes
|
|
337
|
+
// through as given.
|
|
338
|
+
const projection = useMemo<MapLibreGL.ProjectionSpecification | undefined>(
|
|
339
|
+
() => (typeof projectionProp === "string" ? { type: projectionProp } : projectionProp),
|
|
340
|
+
[projectionProp],
|
|
341
|
+
);
|
|
342
|
+
|
|
161
343
|
// Read from the mount-only `styledata` handler below so a `projection` prop
|
|
162
344
|
// change after mount isn't reapplied with the value captured at mount time.
|
|
163
345
|
const projectionRef = useRef(projection);
|
|
164
|
-
projectionRef.current = projection;
|
|
165
346
|
|
|
166
347
|
const mapStyles = useMemo(() => {
|
|
167
348
|
// Explicit styles win. Otherwise `blank` opts into the transparent
|
|
@@ -178,6 +359,61 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
178
359
|
return defaultStyles;
|
|
179
360
|
}, [styles, blank]);
|
|
180
361
|
|
|
362
|
+
// Normalize `plan` from PRIMITIVES, so an inline `plan={{ width, height }}`
|
|
363
|
+
// literal keeps a stable coordinate system instead of re-running every layer
|
|
364
|
+
// effect on each render. A caller who passes their own memoized `PlanCrs`
|
|
365
|
+
// keeps that identity untouched.
|
|
366
|
+
const planIsCrs = typeof (planProp as PlanCrs | undefined)?.toLngLat === "function";
|
|
367
|
+
const planExtent = planIsCrs ? undefined : (planProp as PlanExtent | undefined);
|
|
368
|
+
const planWidth = planExtent?.width;
|
|
369
|
+
const planHeight = planExtent?.height;
|
|
370
|
+
const planOrigin = planExtent?.origin ?? "top-left";
|
|
371
|
+
const planUnit = planExtent?.unit ?? "px";
|
|
372
|
+
const derivedPlan = useMemo(
|
|
373
|
+
() =>
|
|
374
|
+
planWidth !== undefined && planHeight !== undefined
|
|
375
|
+
? createPlanCrs({
|
|
376
|
+
width: planWidth,
|
|
377
|
+
height: planHeight,
|
|
378
|
+
origin: planOrigin,
|
|
379
|
+
unit: planUnit,
|
|
380
|
+
})
|
|
381
|
+
: null,
|
|
382
|
+
[planWidth, planHeight, planOrigin, planUnit],
|
|
383
|
+
);
|
|
384
|
+
const planCrs = planIsCrs ? ((planProp as PlanCrs) ?? null) : derivedPlan;
|
|
385
|
+
|
|
386
|
+
// A plan is a flat drawing: the globe would bend it, so refuse the pairing
|
|
387
|
+
// rather than render something the consumer cannot trust.
|
|
388
|
+
const projectionIsGlobe = !!projection && projection.type !== "mercator";
|
|
389
|
+
const effectiveProjection = planCrs && projectionIsGlobe ? undefined : projection;
|
|
390
|
+
projectionRef.current = effectiveProjection;
|
|
391
|
+
if (planCrs && projectionIsGlobe) {
|
|
392
|
+
warnMapOnce(
|
|
393
|
+
"plan-projection",
|
|
394
|
+
`Ignored projection "${projection?.type}" on a plan map: a plan is flat, and only the mercator projection keeps it undistorted.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Camera options a plan needs. Merged UNDER `...props`, so an explicit prop
|
|
399
|
+
// always wins.
|
|
400
|
+
const planOptions = useMemo(() => {
|
|
401
|
+
if (!planCrs) return null;
|
|
402
|
+
return {
|
|
403
|
+
minZoom: planCrs.minZoom,
|
|
404
|
+
maxZoom: planCrs.maxZoom,
|
|
405
|
+
maxBounds: planCrs.maxBounds() as MapLibreGL.LngLatBoundsLike,
|
|
406
|
+
// A plan is 2D: rotation and tilt only make it harder to read.
|
|
407
|
+
dragRotate: false,
|
|
408
|
+
pitchWithRotate: false,
|
|
409
|
+
touchPitch: false,
|
|
410
|
+
bearing: 0,
|
|
411
|
+
pitch: 0,
|
|
412
|
+
// A plan is usually embedded in a page, so don't swallow the wheel.
|
|
413
|
+
cooperativeGestures: true,
|
|
414
|
+
};
|
|
415
|
+
}, [planCrs]);
|
|
416
|
+
|
|
181
417
|
// Expose the map instance to the parent component.
|
|
182
418
|
useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);
|
|
183
419
|
|
|
@@ -188,10 +424,52 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
188
424
|
}
|
|
189
425
|
}, []);
|
|
190
426
|
|
|
427
|
+
const heightRef = useRef(height);
|
|
428
|
+
heightRef.current = height;
|
|
429
|
+
|
|
430
|
+
// ── Keeping a bounds-framed map framed ─────────────────────────────────────
|
|
431
|
+
// MapLibre fits `bounds` ONCE, against the box it measures at construction.
|
|
432
|
+
// A box that settles later — a responsive height resolving after the first
|
|
433
|
+
// paint, a side panel opening, a tier flip — leaves the viewport fitted to a
|
|
434
|
+
// rectangle that no longer exists (a 348×218 fit kept on a 348×348 box put
|
|
435
|
+
// North America on a Lake Ontario locator). So: re-fit whenever the box
|
|
436
|
+
// changes size, until someone moves the map themselves.
|
|
437
|
+
const { bounds, fitBoundsOptions } = props;
|
|
438
|
+
const refitRef = useRef({ bounds, fitBoundsOptions });
|
|
439
|
+
refitRef.current = { bounds, fitBoundsOptions };
|
|
440
|
+
const isControlledRef = useRef(isControlled);
|
|
441
|
+
isControlledRef.current = isControlled;
|
|
442
|
+
/** `true` once a gesture (or the controlled `viewport`) owns the viewport. */
|
|
443
|
+
const userMovedRef = useRef(false);
|
|
444
|
+
/** The box size the current viewport was fitted against. */
|
|
445
|
+
const fittedSizeRef = useRef<string | null>(null);
|
|
446
|
+
|
|
447
|
+
const syncBox = useCallback((map: MapLibreGL.Map, node: HTMLElement) => {
|
|
448
|
+
const size = boxSize(node);
|
|
449
|
+
if (!size || size === fittedSizeRef.current) return;
|
|
450
|
+
fittedSizeRef.current = size;
|
|
451
|
+
map.resize();
|
|
452
|
+
const { bounds: currentBounds, fitBoundsOptions: currentOptions } = refitRef.current;
|
|
453
|
+
if (!currentBounds || userMovedRef.current || isControlledRef.current) return;
|
|
454
|
+
map.fitBounds(currentBounds, { ...currentOptions, duration: 0 });
|
|
455
|
+
}, []);
|
|
456
|
+
|
|
191
457
|
// Initialize the map.
|
|
192
458
|
useEffect(() => {
|
|
193
459
|
if (!containerRef.current) return;
|
|
194
460
|
|
|
461
|
+
// The tier is measured in a layout effect whose re-render lands AFTER this
|
|
462
|
+
// effect, so the box may still carry the wide-tier height here. Size it for
|
|
463
|
+
// its real tier first: MapLibre sizes its canvas (and fits `bounds`) from
|
|
464
|
+
// the box at construction, and drops the first resize it observes.
|
|
465
|
+
Object.assign(
|
|
466
|
+
containerRef.current.style,
|
|
467
|
+
resolveMapHeightStyle(
|
|
468
|
+
heightRef.current,
|
|
469
|
+
mapBreakpointForWidth(containerRef.current.getBoundingClientRect().width),
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
|
|
195
473
|
const initialStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
|
|
196
474
|
currentStyleRef.current = initialStyle;
|
|
197
475
|
|
|
@@ -210,8 +488,24 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
210
488
|
// `...props` below), or move to tiles licensed without the requirement via
|
|
211
489
|
// `styles` / `blank`.
|
|
212
490
|
attributionControl: false,
|
|
491
|
+
...(planOptions ?? {}),
|
|
492
|
+
// `bounds` fits the plan in the very first frame, so a plan map never
|
|
493
|
+
// flashes at lng/lat 0,0 before the fit effect below runs. Skipped when
|
|
494
|
+
// the caller drives the camera themselves.
|
|
495
|
+
...(planOptions && !viewport?.center
|
|
496
|
+
? {
|
|
497
|
+
bounds: planCrs!.bounds as MapLibreGL.LngLatBoundsLike,
|
|
498
|
+
fitBoundsOptions: { padding: PLAN_FIT_PADDING, animate: false },
|
|
499
|
+
}
|
|
500
|
+
: {}),
|
|
501
|
+
...(minZoom !== undefined ? { minZoom } : {}),
|
|
502
|
+
...(maxZoom !== undefined ? { maxZoom } : {}),
|
|
503
|
+
...(maxBounds !== undefined ? { maxBounds } : {}),
|
|
213
504
|
...props,
|
|
214
505
|
...viewport,
|
|
506
|
+
// Static mode: gestures off, but MapLibre's own `interactive` stays on —
|
|
507
|
+
// it would detach EVERY listener, tooltips and hover included.
|
|
508
|
+
...(isStatic ? STATIC_HANDLER_OPTIONS : {}),
|
|
215
509
|
});
|
|
216
510
|
} catch {
|
|
217
511
|
// MapLibre throws at construction when WebGL is unavailable (headless
|
|
@@ -228,7 +522,7 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
228
522
|
styleTimeoutRef.current = setTimeout(() => {
|
|
229
523
|
setIsStyleLoaded(true);
|
|
230
524
|
if (projectionRef.current) {
|
|
231
|
-
map
|
|
525
|
+
applyProjection(map, projectionRef.current);
|
|
232
526
|
}
|
|
233
527
|
}, 100);
|
|
234
528
|
};
|
|
@@ -253,9 +547,21 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
253
547
|
onViewportChangeRef.current?.(getViewport(map));
|
|
254
548
|
};
|
|
255
549
|
|
|
550
|
+
// A gesture carries the DOM event that caused it; our own `fitBounds` /
|
|
551
|
+
// `jumpTo` does not. Once a person has moved the map, a later box change
|
|
552
|
+
// resizes the canvas but must never yank the view back to `bounds`.
|
|
553
|
+
const handleMoveStart = (event?: { originalEvent?: unknown }) => {
|
|
554
|
+
if (event?.originalEvent) userMovedRef.current = true;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// The size the constructor fitted `bounds` against — the baseline every
|
|
558
|
+
// later box change is compared to.
|
|
559
|
+
fittedSizeRef.current = boxSize(containerRef.current);
|
|
560
|
+
|
|
256
561
|
map.on("load", loadHandler);
|
|
257
562
|
map.on("styledata", styleDataHandler);
|
|
258
563
|
map.on("move", handleMove);
|
|
564
|
+
map.on("movestart", handleMoveStart);
|
|
259
565
|
setMapInstance(map);
|
|
260
566
|
|
|
261
567
|
return () => {
|
|
@@ -263,6 +569,7 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
263
569
|
map.off("load", loadHandler);
|
|
264
570
|
map.off("styledata", styleDataHandler);
|
|
265
571
|
map.off("move", handleMove);
|
|
572
|
+
map.off("movestart", handleMoveStart);
|
|
266
573
|
map.remove();
|
|
267
574
|
setIsLoaded(false);
|
|
268
575
|
setIsStyleLoaded(false);
|
|
@@ -316,9 +623,120 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
316
623
|
|
|
317
624
|
// Sync projection when the prop changes after mount.
|
|
318
625
|
useEffect(() => {
|
|
319
|
-
if (!mapInstance || !isStyleLoaded || !
|
|
320
|
-
mapInstance
|
|
321
|
-
}, [mapInstance, isStyleLoaded,
|
|
626
|
+
if (!mapInstance || !isStyleLoaded || !effectiveProjection) return;
|
|
627
|
+
applyProjection(mapInstance, effectiveProjection);
|
|
628
|
+
}, [mapInstance, isStyleLoaded, effectiveProjection]);
|
|
629
|
+
|
|
630
|
+
// Keep a plan map's camera limits live — unlike the geographic options above,
|
|
631
|
+
// which are init-time. A plan's extent is often only known once its image has
|
|
632
|
+
// decoded, and a multi-floor building can change it after mount.
|
|
633
|
+
useEffect(() => {
|
|
634
|
+
if (!mapInstance || !planCrs) return;
|
|
635
|
+
mapInstance.setMinZoom(minZoom ?? planCrs.minZoom);
|
|
636
|
+
mapInstance.setMaxZoom(maxZoom ?? planCrs.maxZoom);
|
|
637
|
+
mapInstance.setMaxBounds((maxBounds ?? planCrs.maxBounds()) as MapLibreGL.LngLatBoundsLike);
|
|
638
|
+
}, [mapInstance, planCrs, minZoom, maxZoom, maxBounds]);
|
|
639
|
+
|
|
640
|
+
// Fit the plan once per extent. Keyed on the extent rather than on the object
|
|
641
|
+
// so a floor swap at the same size does not yank the user's view.
|
|
642
|
+
const fittedExtentRef = useRef<string | null>(null);
|
|
643
|
+
useEffect(() => {
|
|
644
|
+
if (!mapInstance || !isLoaded || !planCrs || !fitPlan) return;
|
|
645
|
+
if (isControlled || viewport?.center) return;
|
|
646
|
+
|
|
647
|
+
const { width, height, origin } = planCrs.extent;
|
|
648
|
+
const extentKey = `${width}x${height}:${origin}`;
|
|
649
|
+
if (fittedExtentRef.current === extentKey) return;
|
|
650
|
+
fittedExtentRef.current = extentKey;
|
|
651
|
+
|
|
652
|
+
mapInstance.fitBounds(planCrs.bounds as MapLibreGL.LngLatBoundsLike, {
|
|
653
|
+
padding: PLAN_FIT_PADDING,
|
|
654
|
+
animate: false,
|
|
655
|
+
});
|
|
656
|
+
}, [mapInstance, isLoaded, planCrs, fitPlan, isControlled, viewport?.center]);
|
|
657
|
+
|
|
658
|
+
// Basemap labels on / off (c-6, c-11). Re-runs after every style load, so a
|
|
659
|
+
// theme flip (which swaps the whole style) does not bring the labels back.
|
|
660
|
+
const hiddenLabelLayersRef = useRef<Set<string>>(new Set());
|
|
661
|
+
useEffect(() => {
|
|
662
|
+
if (!mapInstance || !isStyleLoaded) return;
|
|
663
|
+
applyBasemapLabels(mapInstance, basemapLabels, hiddenLabelLayersRef.current);
|
|
664
|
+
}, [mapInstance, isStyleLoaded, basemapLabels, themeKey]);
|
|
665
|
+
|
|
666
|
+
// Static mode on / off after mount. Untouched until the map is first made
|
|
667
|
+
// static, so an interactive map keeps MapLibre's own setup exactly.
|
|
668
|
+
const handlerOptionsRef = useRef<Partial<Record<GestureHandlerKey, unknown>>>(props);
|
|
669
|
+
handlerOptionsRef.current = props;
|
|
670
|
+
const wasStaticRef = useRef(false);
|
|
671
|
+
useEffect(() => {
|
|
672
|
+
if (!mapInstance) return;
|
|
673
|
+
if (!isStatic && !wasStaticRef.current) return;
|
|
674
|
+
wasStaticRef.current = true;
|
|
675
|
+
for (const key of GESTURE_HANDLERS) {
|
|
676
|
+
const handler = mapInstance[key] as { enable?: () => void; disable?: () => void } | undefined;
|
|
677
|
+
if (isStatic) handler?.disable?.();
|
|
678
|
+
else if (handlerOptionsRef.current[key] !== false) handler?.enable?.();
|
|
679
|
+
}
|
|
680
|
+
// MapLibre's grab cursor hangs off this class; a static map keeps the
|
|
681
|
+
// default arrow (a layer's hover still sets its own pointer on the canvas).
|
|
682
|
+
mapInstance.getCanvasContainer().classList.toggle("maplibregl-interactive", !isStatic);
|
|
683
|
+
mapInstance.getCanvas().tabIndex = isStatic ? -1 : 0;
|
|
684
|
+
}, [mapInstance, isStatic]);
|
|
685
|
+
|
|
686
|
+
// c-10: the canvas is a tab stop MapLibre owns, so before this it painted
|
|
687
|
+
// the BROWSER's default focus ring (`1px auto rgb(0, 95, 204)` in light,
|
|
688
|
+
// `rgb(153, 200, 255)` in dark) — visible, but not the theme's. The house
|
|
689
|
+
// indicator is a utility class, so the element simply wears it.
|
|
690
|
+
useEffect(() => {
|
|
691
|
+
if (!mapInstance) return;
|
|
692
|
+
mapInstance.getCanvas()?.classList.add("focus-ring");
|
|
693
|
+
}, [mapInstance]);
|
|
694
|
+
|
|
695
|
+
// Watch the box: any size change resizes the canvas and re-fits `bounds`.
|
|
696
|
+
useEffect(() => {
|
|
697
|
+
if (!mapInstance || !containerNode) return undefined;
|
|
698
|
+
const sync = () => syncBox(mapInstance, containerNode);
|
|
699
|
+
sync();
|
|
700
|
+
if (typeof ResizeObserver === "undefined") return undefined;
|
|
701
|
+
const observer = new ResizeObserver(sync);
|
|
702
|
+
observer.observe(containerNode);
|
|
703
|
+
return () => observer.disconnect();
|
|
704
|
+
}, [mapInstance, containerNode, syncBox]);
|
|
705
|
+
|
|
706
|
+
// A tier change can swap the box's height (the default goes square at
|
|
707
|
+
// `narrow`); sync at commit rather than wait on the observer's next frame.
|
|
708
|
+
const heightStyle = resolveMapHeightStyle(height, breakpoint);
|
|
709
|
+
const heightKey = `${heightStyle.height ?? ""}|${heightStyle.aspectRatio ?? ""}`;
|
|
710
|
+
const appliedHeightKeyRef = useRef(heightKey);
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
if (!mapInstance || !containerNode || heightKey === appliedHeightKeyRef.current) return;
|
|
713
|
+
appliedHeightKeyRef.current = heightKey;
|
|
714
|
+
syncBox(mapInstance, containerNode);
|
|
715
|
+
}, [mapInstance, containerNode, heightKey, syncBox]);
|
|
716
|
+
|
|
717
|
+
// Furniture outside the map box (legends `above` / `below`, the narrow
|
|
718
|
+
// annotation key) portals into strips rendered only while something asks.
|
|
719
|
+
const [slotRequests, setSlotRequests] = useState<Record<MapFrameSide, number>>({
|
|
720
|
+
above: 0,
|
|
721
|
+
below: 0,
|
|
722
|
+
});
|
|
723
|
+
const [aboveEl, setAboveEl] = useState<HTMLDivElement | null>(null);
|
|
724
|
+
const [belowEl, setBelowEl] = useState<HTMLDivElement | null>(null);
|
|
725
|
+
const requestSlot = useCallback((side: MapFrameSide) => {
|
|
726
|
+
setSlotRequests((prev) => ({ ...prev, [side]: prev[side] + 1 }));
|
|
727
|
+
return () => setSlotRequests((prev) => ({ ...prev, [side]: Math.max(0, prev[side] - 1) }));
|
|
728
|
+
}, []);
|
|
729
|
+
const frameValue = useMemo<MapFrameContextValue>(
|
|
730
|
+
() => ({
|
|
731
|
+
breakpoint,
|
|
732
|
+
interactive: !isStatic,
|
|
733
|
+
slots: { above: aboveEl, below: belowEl },
|
|
734
|
+
requestSlot,
|
|
735
|
+
}),
|
|
736
|
+
[breakpoint, isStatic, aboveEl, belowEl, requestSlot],
|
|
737
|
+
);
|
|
738
|
+
const annotations = useMapAnnotationRegistry(breakpoint);
|
|
739
|
+
const showBelow = slotRequests.below > 0 || annotations.rows.length > 0;
|
|
322
740
|
|
|
323
741
|
const contextValue = useMemo(
|
|
324
742
|
() => ({
|
|
@@ -326,8 +744,10 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
326
744
|
isLoaded: isLoaded && isStyleLoaded,
|
|
327
745
|
resolvedTheme,
|
|
328
746
|
themeKey,
|
|
747
|
+
plan: planCrs,
|
|
748
|
+
loading,
|
|
329
749
|
}),
|
|
330
|
-
[mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey],
|
|
750
|
+
[mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey, planCrs, loading],
|
|
331
751
|
);
|
|
332
752
|
|
|
333
753
|
if (initFailed) {
|
|
@@ -345,11 +765,31 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
345
765
|
|
|
346
766
|
return (
|
|
347
767
|
<MapContext.Provider value={contextValue}>
|
|
348
|
-
<
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
768
|
+
<MapFrameContext.Provider value={frameValue}>
|
|
769
|
+
<MapAnnotationRegistryProvider value={annotations.value}>
|
|
770
|
+
{slotRequests.above > 0 && (
|
|
771
|
+
<div ref={setAboveEl} data-slot="map-canvas-above" className="pb-2" />
|
|
772
|
+
)}
|
|
773
|
+
<div
|
|
774
|
+
ref={setContainer}
|
|
775
|
+
data-slot="map-canvas"
|
|
776
|
+
data-map-breakpoint={breakpoint}
|
|
777
|
+
data-interactive={isStatic ? "false" : undefined}
|
|
778
|
+
className={cn("relative h-full w-full", className)}
|
|
779
|
+
style={heightStyle}
|
|
780
|
+
>
|
|
781
|
+
{(!isLoaded || loading) && <MapLoadingOverlay />}
|
|
782
|
+
{/* SSR-safe: children render only when the map exists on the client. */}
|
|
783
|
+
{mapInstance && children}
|
|
784
|
+
</div>
|
|
785
|
+
{showBelow && (
|
|
786
|
+
<div data-slot="map-canvas-below" className="flex flex-col gap-2 pt-2">
|
|
787
|
+
<div ref={setBelowEl} data-slot="map-canvas-below-furniture" className="contents" />
|
|
788
|
+
{annotations.rows.length > 0 && <MapAnnotationKey rows={annotations.rows} />}
|
|
789
|
+
</div>
|
|
790
|
+
)}
|
|
791
|
+
</MapAnnotationRegistryProvider>
|
|
792
|
+
</MapFrameContext.Provider>
|
|
353
793
|
</MapContext.Provider>
|
|
354
794
|
);
|
|
355
795
|
});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import type MapLibreGL from "maplibre-gl";
|
|
4
4
|
import { createContext, use } from "react";
|
|
5
5
|
|
|
6
|
+
import type { PlanCrs } from "../lib/plan-crs";
|
|
7
|
+
|
|
6
8
|
/** Light-or-dark flavor of the active basemap (derived from the brand theme). */
|
|
7
9
|
export type BasemapTheme = "light" | "dark";
|
|
8
10
|
|
|
@@ -18,6 +20,15 @@ export interface MapContextValue {
|
|
|
18
20
|
* as a dependency key to re-resolve semantic token colors for WebGL paint.
|
|
19
21
|
*/
|
|
20
22
|
themeKey: string;
|
|
23
|
+
/**
|
|
24
|
+
* The plan coordinate system when this canvas is a CUSTOM (non-geographic)
|
|
25
|
+
* plan map — a floor plan, factory layout or carriage — and `null` for an
|
|
26
|
+
* ordinary geographic map. Layer components read it to convert the plan
|
|
27
|
+
* coordinates they are handed, and to report plan coordinates back.
|
|
28
|
+
*/
|
|
29
|
+
plan: PlanCrs | null;
|
|
30
|
+
/** True while the consumer has asked for a loading overlay over the map. */
|
|
31
|
+
loading: boolean;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
export const MapContext = createContext<MapContextValue | null>(null);
|
|
@@ -5,6 +5,7 @@ import { useEffect, useId, useMemo } from "react";
|
|
|
5
5
|
|
|
6
6
|
import { useMap } from "../map-canvas/map-context";
|
|
7
7
|
import { useTokenColor } from "../lib/use-token-color";
|
|
8
|
+
import { warnMapOnce } from "../lib/warn-once";
|
|
8
9
|
|
|
9
10
|
export type MapClusterLayerProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
|
|
10
11
|
{
|
|
@@ -38,6 +39,11 @@ const DEFAULT_CLUSTER_THRESHOLDS: [number, number] = [100, 750];
|
|
|
38
39
|
* Clustered point rendering for large point datasets. Cluster circles step
|
|
39
40
|
* through the status tokens (success → warning → destructive) as the point
|
|
40
41
|
* count grows; strokes and count labels use the page surface for contrast.
|
|
42
|
+
*
|
|
43
|
+
* NOT for a plan map. The count inside each circle is a `symbol` `text-field`,
|
|
44
|
+
* and a blank style — which every plan uses — ships no glyph endpoint, so the
|
|
45
|
+
* counts would silently render as nothing at all. Cluster plan points yourself
|
|
46
|
+
* and draw the groups with `MapGeoJSON`, or put the count in the DOM overlay.
|
|
41
47
|
*/
|
|
42
48
|
export function MapClusterLayer<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
|
|
43
49
|
data,
|
|
@@ -49,13 +55,20 @@ export function MapClusterLayer<P extends GeoJSON.GeoJsonProperties = GeoJSON.Ge
|
|
|
49
55
|
onPointClick,
|
|
50
56
|
onClusterClick,
|
|
51
57
|
}: MapClusterLayerProps<P>) {
|
|
52
|
-
const { map, isLoaded } = useMap();
|
|
58
|
+
const { map, isLoaded, plan } = useMap();
|
|
53
59
|
const id = useId();
|
|
54
60
|
const sourceId = `cluster-source-${id}`;
|
|
55
61
|
const clusterLayerId = `clusters-${id}`;
|
|
56
62
|
const clusterCountLayerId = `cluster-count-${id}`;
|
|
57
63
|
const unclusteredLayerId = `unclustered-point-${id}`;
|
|
58
64
|
|
|
65
|
+
if (plan) {
|
|
66
|
+
warnMapOnce(
|
|
67
|
+
"cluster-layer-plan",
|
|
68
|
+
"<MapClusterLayer> is not for a plan map: its count label is a symbol text-field, and a plan's blank style has no glyph endpoint, so the counts render as nothing.",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
const success = useTokenColor("--success");
|
|
60
73
|
const warning = useTokenColor("--warning");
|
|
61
74
|
const destructive = useTokenColor("--destructive");
|