@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
|
@@ -13,11 +13,28 @@ import {
|
|
|
13
13
|
useState,
|
|
14
14
|
type ReactNode,
|
|
15
15
|
} from "react";
|
|
16
|
-
import { Spinner, StatePanel } from "@elabs-ai/components-ui";
|
|
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,15 +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
|
-
const
|
|
313
|
+
const { t } = useLocale();
|
|
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;
|
|
146
322
|
const [mapInstance, setMapInstance] = useState<MapLibreGL.Map | null>(null);
|
|
147
323
|
const [initFailed, setInitFailed] = useState(false);
|
|
148
324
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
@@ -157,6 +333,17 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
157
333
|
const onViewportChangeRef = useRef(onViewportChange);
|
|
158
334
|
onViewportChangeRef.current = onViewportChange;
|
|
159
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
|
+
|
|
343
|
+
// Read from the mount-only `styledata` handler below so a `projection` prop
|
|
344
|
+
// change after mount isn't reapplied with the value captured at mount time.
|
|
345
|
+
const projectionRef = useRef(projection);
|
|
346
|
+
|
|
160
347
|
const mapStyles = useMemo(() => {
|
|
161
348
|
// Explicit styles win. Otherwise `blank` opts into the transparent
|
|
162
349
|
// tile-less basemap; with neither, fall back to the Carto defaults.
|
|
@@ -172,6 +359,61 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
172
359
|
return defaultStyles;
|
|
173
360
|
}, [styles, blank]);
|
|
174
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
|
+
|
|
175
417
|
// Expose the map instance to the parent component.
|
|
176
418
|
useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);
|
|
177
419
|
|
|
@@ -182,10 +424,52 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
182
424
|
}
|
|
183
425
|
}, []);
|
|
184
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
|
+
|
|
185
457
|
// Initialize the map.
|
|
186
458
|
useEffect(() => {
|
|
187
459
|
if (!containerRef.current) return;
|
|
188
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
|
+
|
|
189
473
|
const initialStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
|
|
190
474
|
currentStyleRef.current = initialStyle;
|
|
191
475
|
|
|
@@ -204,8 +488,24 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
204
488
|
// `...props` below), or move to tiles licensed without the requirement via
|
|
205
489
|
// `styles` / `blank`.
|
|
206
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 } : {}),
|
|
207
504
|
...props,
|
|
208
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 : {}),
|
|
209
509
|
});
|
|
210
510
|
} catch {
|
|
211
511
|
// MapLibre throws at construction when WebGL is unavailable (headless
|
|
@@ -221,8 +521,8 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
221
521
|
// race conditions on setStyle without force-updating every layer.
|
|
222
522
|
styleTimeoutRef.current = setTimeout(() => {
|
|
223
523
|
setIsStyleLoaded(true);
|
|
224
|
-
if (
|
|
225
|
-
map.
|
|
524
|
+
if (projectionRef.current) {
|
|
525
|
+
applyProjection(map, projectionRef.current);
|
|
226
526
|
}
|
|
227
527
|
}, 100);
|
|
228
528
|
};
|
|
@@ -247,9 +547,21 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
247
547
|
onViewportChangeRef.current?.(getViewport(map));
|
|
248
548
|
};
|
|
249
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
|
+
|
|
250
561
|
map.on("load", loadHandler);
|
|
251
562
|
map.on("styledata", styleDataHandler);
|
|
252
563
|
map.on("move", handleMove);
|
|
564
|
+
map.on("movestart", handleMoveStart);
|
|
253
565
|
setMapInstance(map);
|
|
254
566
|
|
|
255
567
|
return () => {
|
|
@@ -257,6 +569,7 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
257
569
|
map.off("load", loadHandler);
|
|
258
570
|
map.off("styledata", styleDataHandler);
|
|
259
571
|
map.off("move", handleMove);
|
|
572
|
+
map.off("movestart", handleMoveStart);
|
|
260
573
|
map.remove();
|
|
261
574
|
setIsLoaded(false);
|
|
262
575
|
setIsStyleLoaded(false);
|
|
@@ -310,9 +623,120 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
310
623
|
|
|
311
624
|
// Sync projection when the prop changes after mount.
|
|
312
625
|
useEffect(() => {
|
|
313
|
-
if (!mapInstance || !isStyleLoaded || !
|
|
314
|
-
mapInstance
|
|
315
|
-
}, [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;
|
|
316
740
|
|
|
317
741
|
const contextValue = useMemo(
|
|
318
742
|
() => ({
|
|
@@ -320,8 +744,10 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
320
744
|
isLoaded: isLoaded && isStyleLoaded,
|
|
321
745
|
resolvedTheme,
|
|
322
746
|
themeKey,
|
|
747
|
+
plan: planCrs,
|
|
748
|
+
loading,
|
|
323
749
|
}),
|
|
324
|
-
[mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey],
|
|
750
|
+
[mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey, planCrs, loading],
|
|
325
751
|
);
|
|
326
752
|
|
|
327
753
|
if (initFailed) {
|
|
@@ -329,8 +755,8 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
329
755
|
<div className={cn("relative h-full w-full", className)}>
|
|
330
756
|
<StatePanel
|
|
331
757
|
kind="error"
|
|
332
|
-
title="
|
|
333
|
-
description="
|
|
758
|
+
title={t("maps.canvas.unavailableTitle")}
|
|
759
|
+
description={t("maps.canvas.unavailableDescription")}
|
|
334
760
|
className="h-full"
|
|
335
761
|
/>
|
|
336
762
|
</div>
|
|
@@ -339,11 +765,31 @@ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCa
|
|
|
339
765
|
|
|
340
766
|
return (
|
|
341
767
|
<MapContext.Provider value={contextValue}>
|
|
342
|
-
<
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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>
|
|
347
793
|
</MapContext.Provider>
|
|
348
794
|
);
|
|
349
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);
|