@elabs-ai/components-maps 4.2.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 -1471
- package/dist/index.js.map +1 -1
- package/dist/lib/arc-math.js +8 -0
- package/dist/lib/arc-math.js.map +1 -0
- package/dist/lib/map-position.js +11 -0
- package/dist/lib/map-position.js.map +1 -0
- package/dist/lib/merge-hover-paint.js +10 -0
- package/dist/lib/merge-hover-paint.js.map +1 -0
- package/dist/lib/plan-crs.js +14 -0
- package/dist/lib/plan-crs.js.map +1 -0
- package/dist/lib/plan-patterns.js +14 -0
- package/dist/lib/plan-patterns.js.map +1 -0
- package/dist/lib/plan-regions.js +18 -0
- package/dist/lib/plan-regions.js.map +1 -0
- package/dist/lib/plan-status.js +14 -0
- package/dist/lib/plan-status.js.map +1 -0
- package/dist/lib/route-arrow.js +12 -0
- package/dist/lib/route-arrow.js.map +1 -0
- package/dist/lib/use-map-breakpoint.js +33 -0
- package/dist/lib/use-map-breakpoint.js.map +1 -0
- package/dist/lib/use-plan-projection.js +9 -0
- package/dist/lib/use-plan-projection.js.map +1 -0
- package/dist/lib/use-token-color.js +10 -0
- package/dist/lib/use-token-color.js.map +1 -0
- package/dist/lib/warn-once.js +10 -0
- package/dist/lib/warn-once.js.map +1 -0
- package/dist/map-annotation/anchor.js +14 -0
- package/dist/map-annotation/anchor.js.map +1 -0
- package/dist/map-annotation/index.js +21 -0
- package/dist/map-annotation/index.js.map +1 -0
- package/dist/map-annotation/map-annotation.js +18 -0
- package/dist/map-annotation/map-annotation.js.map +1 -0
- package/dist/map-arc/index.js +16 -0
- package/dist/map-arc/index.js.map +1 -0
- package/dist/map-arc/map-arc.js +13 -0
- package/dist/map-arc/map-arc.js.map +1 -0
- package/dist/map-canvas/index.css +11 -0
- package/dist/map-canvas/index.css.map +1 -0
- package/dist/map-canvas/index.js +20 -0
- package/dist/map-canvas/index.js.map +1 -0
- package/dist/map-canvas/map-canvas.css +11 -0
- package/dist/map-canvas/map-canvas.css.map +1 -0
- package/dist/map-canvas/map-canvas.js +17 -0
- package/dist/map-canvas/map-canvas.js.map +1 -0
- package/dist/map-canvas/map-context.js +11 -0
- package/dist/map-canvas/map-context.js.map +1 -0
- package/dist/map-canvas/use-resolved-basemap-theme.js +9 -0
- package/dist/map-canvas/use-resolved-basemap-theme.js.map +1 -0
- package/dist/map-cluster-layer/index.js +12 -0
- package/dist/map-cluster-layer/index.js.map +1 -0
- package/dist/map-cluster-layer/map-cluster-layer.js +12 -0
- package/dist/map-cluster-layer/map-cluster-layer.js.map +1 -0
- package/dist/map-controls/index.js +11 -0
- package/dist/map-controls/index.js.map +1 -0
- package/dist/map-controls/map-controls.js +11 -0
- package/dist/map-controls/map-controls.js.map +1 -0
- package/dist/map-geojson/index.js +13 -0
- package/dist/map-geojson/index.js.map +1 -0
- package/dist/map-geojson/map-geojson.js +13 -0
- package/dist/map-geojson/map-geojson.js.map +1 -0
- package/dist/map-inset/index.css +11 -0
- package/dist/map-inset/index.css.map +1 -0
- package/dist/map-inset/index.js +20 -0
- package/dist/map-inset/index.js.map +1 -0
- package/dist/map-inset/map-inset.css +11 -0
- package/dist/map-inset/map-inset.css.map +1 -0
- package/dist/map-inset/map-inset.js +20 -0
- package/dist/map-inset/map-inset.js.map +1 -0
- package/dist/map-legend/index.js +14 -0
- package/dist/map-legend/index.js.map +1 -0
- package/dist/map-legend/map-corner.js +10 -0
- package/dist/map-legend/map-corner.js.map +1 -0
- package/dist/map-legend/map-legend.js +11 -0
- package/dist/map-legend/map-legend.js.map +1 -0
- package/dist/map-marker/index.js +22 -0
- package/dist/map-marker/index.js.map +1 -0
- package/dist/map-marker/map-marker.js +22 -0
- package/dist/map-marker/map-marker.js.map +1 -0
- package/dist/map-north-arrow/index.js +11 -0
- package/dist/map-north-arrow/index.js.map +1 -0
- package/dist/map-north-arrow/map-north-arrow.js +11 -0
- package/dist/map-north-arrow/map-north-arrow.js.map +1 -0
- package/dist/map-plan/index.js +30 -0
- package/dist/map-plan/index.js.map +1 -0
- package/dist/map-plan/map-plan-legend.js +10 -0
- package/dist/map-plan/map-plan-legend.js.map +1 -0
- package/dist/map-plan/map-plan-overlay.js +13 -0
- package/dist/map-plan/map-plan-overlay.js.map +1 -0
- package/dist/map-plan/map-plan-status.js +9 -0
- package/dist/map-plan/map-plan-status.js.map +1 -0
- package/dist/map-plan/map-plan-table.js +10 -0
- package/dist/map-plan/map-plan-table.js.map +1 -0
- package/dist/map-plan/use-plan-selection.js +9 -0
- package/dist/map-plan/use-plan-selection.js.map +1 -0
- package/dist/map-plan-image/index.js +11 -0
- package/dist/map-plan-image/index.js.map +1 -0
- package/dist/map-plan-image/map-plan-image.js +11 -0
- package/dist/map-plan-image/map-plan-image.js.map +1 -0
- package/dist/map-popup/index.js +12 -0
- package/dist/map-popup/index.js.map +1 -0
- package/dist/map-popup/map-popup.js +12 -0
- package/dist/map-popup/map-popup.js.map +1 -0
- package/dist/map-route/index.js +12 -0
- package/dist/map-route/index.js.map +1 -0
- package/dist/map-route/map-route.js +12 -0
- package/dist/map-route/map-route.js.map +1 -0
- package/dist/map-scale-bar/index.js +18 -0
- package/dist/map-scale-bar/index.js.map +1 -0
- package/dist/map-scale-bar/map-scale-bar.js +18 -0
- package/dist/map-scale-bar/map-scale-bar.js.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +83 -0
- package/src/lib/arc-math.test.ts +19 -0
- package/src/lib/arc-math.ts +6 -1
- package/src/lib/map-position.ts +77 -0
- package/src/lib/merge-hover-paint.ts +44 -13
- package/src/lib/plan-crs.parity.test.ts +39 -0
- package/src/lib/plan-crs.test.ts +248 -0
- package/src/lib/plan-crs.ts +252 -0
- package/src/lib/plan-patterns.test.ts +255 -0
- package/src/lib/plan-patterns.ts +160 -0
- package/src/lib/plan-regions.test.ts +186 -0
- package/src/lib/plan-regions.ts +209 -0
- package/src/lib/plan-status.test.ts +87 -0
- package/src/lib/plan-status.ts +147 -0
- package/src/lib/route-arrow.test.ts +88 -0
- package/src/lib/route-arrow.ts +59 -0
- package/src/lib/use-map-breakpoint.test.ts +83 -0
- package/src/lib/use-map-breakpoint.ts +238 -0
- package/src/lib/use-plan-projection.ts +157 -0
- package/src/lib/warn-once.ts +21 -0
- package/src/map-annotation/anchor.ts +92 -0
- package/src/map-annotation/index.ts +15 -0
- package/src/map-annotation/map-annotation.stories.tsx +113 -0
- package/src/map-annotation/map-annotation.test.tsx +106 -0
- package/src/map-annotation/map-annotation.tsx +356 -0
- package/src/map-arc/map-arc.tsx +26 -5
- package/src/map-canvas/map-canvas.stories.tsx +211 -0
- package/src/map-canvas/map-canvas.test.tsx +389 -0
- package/src/map-canvas/map-canvas.tsx +457 -17
- package/src/map-canvas/map-context.ts +11 -0
- package/src/map-cluster-layer/map-cluster-layer.tsx +14 -1
- package/src/map-controls/map-controls.stories.tsx +16 -0
- package/src/map-controls/map-controls.test.tsx +145 -1
- package/src/map-controls/map-controls.tsx +57 -12
- package/src/map-geojson/map-geojson.stories.tsx +153 -3
- package/src/map-geojson/map-geojson.test.tsx +453 -0
- package/src/map-geojson/map-geojson.tsx +591 -51
- package/src/map-inset/index.ts +1 -0
- package/src/map-inset/map-inset.stories.tsx +61 -0
- package/src/map-inset/map-inset.test.tsx +117 -0
- package/src/map-inset/map-inset.tsx +264 -0
- package/src/map-legend/index.ts +10 -0
- package/src/map-legend/map-corner.ts +23 -0
- package/src/map-legend/map-legend.stories.tsx +171 -0
- package/src/map-legend/map-legend.test.tsx +136 -0
- package/src/map-legend/map-legend.tsx +264 -0
- package/src/map-marker/map-marker.stories.tsx +136 -4
- package/src/map-marker/map-marker.test.tsx +328 -3
- package/src/map-marker/map-marker.tsx +370 -33
- package/src/map-north-arrow/index.ts +1 -0
- package/src/map-north-arrow/map-north-arrow.stories.tsx +65 -0
- package/src/map-north-arrow/map-north-arrow.test.tsx +39 -0
- package/src/map-north-arrow/map-north-arrow.tsx +82 -0
- package/src/map-plan/index.ts +13 -0
- package/src/map-plan/map-plan-legend.test.tsx +61 -0
- package/src/map-plan/map-plan-legend.tsx +83 -0
- package/src/map-plan/map-plan-overlay.stories.tsx +260 -0
- package/src/map-plan/map-plan-overlay.test.tsx +302 -0
- package/src/map-plan/map-plan-overlay.tsx +531 -0
- package/src/map-plan/map-plan-status.test.tsx +71 -0
- package/src/map-plan/map-plan-status.tsx +77 -0
- package/src/map-plan/map-plan-table.test.tsx +86 -0
- package/src/map-plan/map-plan-table.tsx +153 -0
- package/src/map-plan/use-plan-selection.ts +78 -0
- package/src/map-plan-image/index.ts +1 -0
- package/src/map-plan-image/map-plan-image.stories.tsx +301 -0
- package/src/map-plan-image/map-plan-image.test.tsx +176 -0
- package/src/map-plan-image/map-plan-image.tsx +279 -0
- package/src/map-popup/map-popup.tsx +15 -10
- package/src/map-route/index.ts +1 -1
- package/src/map-route/map-route.test.tsx +216 -0
- package/src/map-route/map-route.tsx +105 -6
- package/src/map-scale-bar/index.ts +9 -0
- package/src/map-scale-bar/map-scale-bar.stories.tsx +63 -0
- package/src/map-scale-bar/map-scale-bar.test.tsx +65 -0
- package/src/map-scale-bar/map-scale-bar.tsx +145 -0
- package/src/test-utils/locator-fixture.ts +75 -0
- package/src/test-utils/maplibre-mock.ts +256 -19
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
usePlanProjection
|
|
4
|
+
} from "./chunk-MIMDP3EU.js";
|
|
5
|
+
import {
|
|
6
|
+
useMap
|
|
7
|
+
} from "./chunk-JYFERXZT.js";
|
|
8
|
+
import {
|
|
9
|
+
warnMapOnce
|
|
10
|
+
} from "./chunk-G7JDXDJN.js";
|
|
11
|
+
import {
|
|
12
|
+
planGroupsFromRegions,
|
|
13
|
+
planRegionCentre
|
|
14
|
+
} from "./chunk-WNU4IOGL.js";
|
|
15
|
+
|
|
16
|
+
// src/map-plan/map-plan-overlay.tsx
|
|
17
|
+
import {
|
|
18
|
+
useCallback,
|
|
19
|
+
useEffect,
|
|
20
|
+
useLayoutEffect,
|
|
21
|
+
useMemo,
|
|
22
|
+
useRef,
|
|
23
|
+
useState
|
|
24
|
+
} from "react";
|
|
25
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
26
|
+
import { useReducedMotion } from "@elabs-ai/components-tokens";
|
|
27
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
28
|
+
var REGION_WARN_CEILING = 500;
|
|
29
|
+
var GROUP_MEMBER_CEILING = 64;
|
|
30
|
+
var LABEL_MIN_WIDTH = 44;
|
|
31
|
+
var LABEL_MIN_HEIGHT = 14;
|
|
32
|
+
var TRAVEL_DURATION_MS = 260;
|
|
33
|
+
function orderIndex(regions, id) {
|
|
34
|
+
return id == null ? -1 : regions.findIndex((region) => region.id === id);
|
|
35
|
+
}
|
|
36
|
+
var DIRECTION_VECTORS = {
|
|
37
|
+
end: [1, 0],
|
|
38
|
+
start: [-1, 0],
|
|
39
|
+
up: [0, -1],
|
|
40
|
+
down: [0, 1]
|
|
41
|
+
};
|
|
42
|
+
function nearestInDirection(regions, fromId, direction, flipY) {
|
|
43
|
+
const from = regions.find((region) => region.id === fromId);
|
|
44
|
+
if (!from) return null;
|
|
45
|
+
const centre = (region) => {
|
|
46
|
+
const point = planRegionCentre(region.bounds);
|
|
47
|
+
return { x: point.x, y: flipY ? -point.y : point.y };
|
|
48
|
+
};
|
|
49
|
+
const origin = centre(from);
|
|
50
|
+
const [dirX, dirY] = DIRECTION_VECTORS[direction];
|
|
51
|
+
let best = null;
|
|
52
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
53
|
+
for (const region of regions) {
|
|
54
|
+
if (region.id === fromId) continue;
|
|
55
|
+
const target = centre(region);
|
|
56
|
+
const dx = target.x - origin.x;
|
|
57
|
+
const dy = target.y - origin.y;
|
|
58
|
+
const along = dx * dirX + dy * dirY;
|
|
59
|
+
if (along <= 0) continue;
|
|
60
|
+
const distance = Math.hypot(dx, dy);
|
|
61
|
+
if (along / distance < Math.SQRT1_2) continue;
|
|
62
|
+
if (distance < bestDistance) {
|
|
63
|
+
bestDistance = distance;
|
|
64
|
+
best = region;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (best) return best;
|
|
68
|
+
const index = orderIndex(regions, fromId);
|
|
69
|
+
const step = direction === "end" || direction === "down" ? 1 : -1;
|
|
70
|
+
return regions[index + step] ?? null;
|
|
71
|
+
}
|
|
72
|
+
function MapPlanOverlay({
|
|
73
|
+
regions,
|
|
74
|
+
label,
|
|
75
|
+
selectedId,
|
|
76
|
+
onSelect,
|
|
77
|
+
onActiveChange,
|
|
78
|
+
mode = "regions",
|
|
79
|
+
describeGroup,
|
|
80
|
+
showLabels = true,
|
|
81
|
+
labelMinZoom,
|
|
82
|
+
capturePointer = true,
|
|
83
|
+
className
|
|
84
|
+
}) {
|
|
85
|
+
const { map, plan, loading } = useMap();
|
|
86
|
+
const reducedMotion = useReducedMotion();
|
|
87
|
+
const [activeId, setActiveId] = useState(null);
|
|
88
|
+
const [openGroupKey, setOpenGroupKey] = useState(null);
|
|
89
|
+
const buttonsRef = useRef(/* @__PURE__ */ new Map());
|
|
90
|
+
const labelsRef = useRef(/* @__PURE__ */ new Map());
|
|
91
|
+
const pendingFocusRef = useRef(null);
|
|
92
|
+
const selectedIds = useMemo(() => {
|
|
93
|
+
if (selectedId == null) return [];
|
|
94
|
+
return Array.isArray(selectedId) ? [...selectedId] : [selectedId];
|
|
95
|
+
}, [selectedId]);
|
|
96
|
+
const groups = useMemo(
|
|
97
|
+
() => mode === "groups" ? planGroupsFromRegions(regions) : [],
|
|
98
|
+
[mode, regions]
|
|
99
|
+
);
|
|
100
|
+
const openGroup = useMemo(
|
|
101
|
+
() => groups.find((group) => group.key === openGroupKey) ?? null,
|
|
102
|
+
[groups, openGroupKey]
|
|
103
|
+
);
|
|
104
|
+
const visibleRegions = useMemo(() => {
|
|
105
|
+
if (mode !== "groups") return regions;
|
|
106
|
+
if (openGroup) return openGroup.members;
|
|
107
|
+
return groups.map((group) => {
|
|
108
|
+
const description = describeGroup?.(group);
|
|
109
|
+
return description ? { ...group.region, description } : group.region;
|
|
110
|
+
});
|
|
111
|
+
}, [describeGroup, groups, mode, openGroup, regions]);
|
|
112
|
+
const groupByRegionId = useMemo(() => {
|
|
113
|
+
const index = /* @__PURE__ */ new Map();
|
|
114
|
+
for (const group of groups) index.set(group.region.id, group);
|
|
115
|
+
return index;
|
|
116
|
+
}, [groups]);
|
|
117
|
+
if (!plan) {
|
|
118
|
+
warnMapOnce(
|
|
119
|
+
"plan-overlay-no-plan",
|
|
120
|
+
"<MapPlanOverlay> needs a plan coordinate system: set `plan` on <MapCanvas>."
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (mode === "regions" && regions.length > REGION_WARN_CEILING) {
|
|
124
|
+
warnMapOnce(
|
|
125
|
+
"plan-overlay-region-count",
|
|
126
|
+
`<MapPlanOverlay> has ${regions.length} regions. Past ~${REGION_WARN_CEILING} a keyboard walk stops being a usable way to reach one of them \u2014 set mode="groups" instead.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const biggestGroup = groups.reduce((most, group) => Math.max(most, group.members.length), 0);
|
|
130
|
+
if (biggestGroup > GROUP_MEMBER_CEILING) {
|
|
131
|
+
warnMapOnce(
|
|
132
|
+
"plan-overlay-group-size",
|
|
133
|
+
`<MapPlanOverlay> has a group of ${biggestGroup} regions. Past ~${GROUP_MEMBER_CEILING} members a group is no easier to walk than the whole plan was \u2014 split it.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const callbacksRef = useRef({ onSelect, onActiveChange });
|
|
137
|
+
callbacksRef.current = { onSelect, onActiveChange };
|
|
138
|
+
const regionById = useMemo(() => {
|
|
139
|
+
const index = /* @__PURE__ */ new Map();
|
|
140
|
+
for (const region of visibleRegions) index.set(region.id, region);
|
|
141
|
+
return index;
|
|
142
|
+
}, [visibleRegions]);
|
|
143
|
+
const handleFrame = useCallback(
|
|
144
|
+
(boxes) => {
|
|
145
|
+
if (!showLabels) return;
|
|
146
|
+
const zoom = map?.getZoom?.();
|
|
147
|
+
const belowZoom = labelMinZoom !== void 0 && zoom !== void 0 && zoom < labelMinZoom;
|
|
148
|
+
for (const [id, box] of boxes) {
|
|
149
|
+
const element = labelsRef.current.get(id);
|
|
150
|
+
if (!element) continue;
|
|
151
|
+
const region = regionById.get(id);
|
|
152
|
+
const fits = !belowZoom && box.width >= LABEL_MIN_WIDTH && box.height >= LABEL_MIN_HEIGHT;
|
|
153
|
+
element.style.visibility = fits ? "visible" : "hidden";
|
|
154
|
+
if (!fits || !region) continue;
|
|
155
|
+
const short = region.shortLabel ?? region.label;
|
|
156
|
+
const roomFor = Math.floor(box.width / 7);
|
|
157
|
+
element.textContent = region.label.length <= roomFor ? region.label : short;
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
[labelMinZoom, map, regionById, showLabels]
|
|
161
|
+
);
|
|
162
|
+
const { register, refresh } = usePlanProjection({
|
|
163
|
+
map,
|
|
164
|
+
plan,
|
|
165
|
+
regions: visibleRegions,
|
|
166
|
+
enabled: !!plan,
|
|
167
|
+
onFrame: handleFrame
|
|
168
|
+
});
|
|
169
|
+
useLayoutEffect(() => {
|
|
170
|
+
const id = pendingFocusRef.current;
|
|
171
|
+
if (id == null) return;
|
|
172
|
+
pendingFocusRef.current = null;
|
|
173
|
+
buttonsRef.current.get(id)?.focus();
|
|
174
|
+
});
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
refresh();
|
|
177
|
+
}, [refresh, showLabels, labelMinZoom, visibleRegions]);
|
|
178
|
+
const setActive = useCallback(
|
|
179
|
+
(id) => {
|
|
180
|
+
setActiveId(id);
|
|
181
|
+
callbacksRef.current.onActiveChange?.(id, id ? regionById.get(id) ?? null : null);
|
|
182
|
+
},
|
|
183
|
+
[regionById]
|
|
184
|
+
);
|
|
185
|
+
const focusRegion = useCallback(
|
|
186
|
+
(region) => {
|
|
187
|
+
if (!region) return;
|
|
188
|
+
setActive(region.id);
|
|
189
|
+
buttonsRef.current.get(region.id)?.focus();
|
|
190
|
+
if (!map || !plan) return;
|
|
191
|
+
const canvas = map.getCanvas?.();
|
|
192
|
+
const width = canvas?.clientWidth ?? 0;
|
|
193
|
+
const height = canvas?.clientHeight ?? 0;
|
|
194
|
+
const centre = planRegionCentre(region.bounds);
|
|
195
|
+
const point = map.project(plan.toLngLat(centre));
|
|
196
|
+
const outside = point.x < 0 || point.y < 0 || point.x > width || point.y > height;
|
|
197
|
+
if (!outside) return;
|
|
198
|
+
map.easeTo({
|
|
199
|
+
center: plan.toLngLat(centre),
|
|
200
|
+
duration: reducedMotion ? 0 : TRAVEL_DURATION_MS
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
[map, plan, reducedMotion, setActive]
|
|
204
|
+
);
|
|
205
|
+
const flipY = plan?.extent.origin === "bottom-left";
|
|
206
|
+
const enterGroup = useCallback(
|
|
207
|
+
(group) => {
|
|
208
|
+
const first = group.members[0];
|
|
209
|
+
setOpenGroupKey(group.key);
|
|
210
|
+
if (!first) return;
|
|
211
|
+
pendingFocusRef.current = first.id;
|
|
212
|
+
setActive(first.id);
|
|
213
|
+
},
|
|
214
|
+
[setActive]
|
|
215
|
+
);
|
|
216
|
+
const leaveGroup = useCallback(() => {
|
|
217
|
+
const key = openGroupKey;
|
|
218
|
+
if (key == null) return false;
|
|
219
|
+
setOpenGroupKey(null);
|
|
220
|
+
const groupRegionId = groups.find((group) => group.key === key)?.region.id;
|
|
221
|
+
if (groupRegionId) {
|
|
222
|
+
pendingFocusRef.current = groupRegionId;
|
|
223
|
+
setActive(groupRegionId);
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}, [groups, openGroupKey, setActive]);
|
|
227
|
+
const activate = useCallback(
|
|
228
|
+
(region) => {
|
|
229
|
+
const group = groupByRegionId.get(region.id);
|
|
230
|
+
if (group) {
|
|
231
|
+
enterGroup(group);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
callbacksRef.current.onSelect?.(region.id, region);
|
|
235
|
+
},
|
|
236
|
+
[enterGroup, groupByRegionId]
|
|
237
|
+
);
|
|
238
|
+
const stepGroup = useCallback(
|
|
239
|
+
(step, fromRegionId) => {
|
|
240
|
+
if (groups.length === 0) return;
|
|
241
|
+
const fromKey = openGroupKey ?? groups.find((group) => group.region.id === fromRegionId)?.key ?? groups[0].key;
|
|
242
|
+
const index = groups.findIndex((group) => group.key === fromKey);
|
|
243
|
+
const next = groups[index + step];
|
|
244
|
+
if (!next) return;
|
|
245
|
+
if (openGroupKey != null) enterGroup(next);
|
|
246
|
+
else focusRegion(next.region);
|
|
247
|
+
},
|
|
248
|
+
[enterGroup, focusRegion, groups, openGroupKey]
|
|
249
|
+
);
|
|
250
|
+
const handleKeyDown = useCallback(
|
|
251
|
+
(event, region) => {
|
|
252
|
+
const direction = event.key === "ArrowRight" ? "end" : event.key === "ArrowLeft" ? "start" : event.key === "ArrowUp" ? "up" : event.key === "ArrowDown" ? "down" : null;
|
|
253
|
+
if (direction) {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
focusRegion(nearestInDirection(visibleRegions, region.id, direction, !!flipY));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (event.key === "Home" || event.key === "End") {
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
focusRegion((event.key === "Home" ? visibleRegions[0] : visibleRegions.at(-1)) ?? null);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (event.key === "PageUp" || event.key === "PageDown") {
|
|
264
|
+
if (groups.length === 0) return;
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
stepGroup(event.key === "PageDown" ? 1 : -1, region.id);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (event.key === "Escape") {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
if (leaveGroup()) return;
|
|
272
|
+
callbacksRef.current.onSelect?.(null, null);
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
[flipY, focusRegion, groups.length, leaveGroup, stepGroup, visibleRegions]
|
|
276
|
+
);
|
|
277
|
+
const tabStopId = activeId ?? visibleRegions.find((region) => selectedIds.includes(region.id))?.id ?? visibleRegions[0]?.id ?? null;
|
|
278
|
+
return /* @__PURE__ */ jsx(
|
|
279
|
+
"div",
|
|
280
|
+
{
|
|
281
|
+
"data-slot": "map-plan-overlay",
|
|
282
|
+
className: cn("pointer-events-none absolute inset-0", loading && "invisible", className),
|
|
283
|
+
children: /* @__PURE__ */ jsx("ul", { "data-slot": "map-plan-overlay-list", role: "list", "aria-label": label, className: "contents", children: visibleRegions.map((region) => {
|
|
284
|
+
const isGroup = groupByRegionId.has(region.id);
|
|
285
|
+
const isSelected = !isGroup && selectedIds.includes(region.id);
|
|
286
|
+
const accessibleName = region.description ? `${region.label}, ${region.description}` : region.label;
|
|
287
|
+
return /* @__PURE__ */ jsxs(
|
|
288
|
+
"li",
|
|
289
|
+
{
|
|
290
|
+
ref: (element) => register(region.id, element),
|
|
291
|
+
className: "pointer-events-none absolute",
|
|
292
|
+
children: [
|
|
293
|
+
/* @__PURE__ */ jsx(
|
|
294
|
+
"button",
|
|
295
|
+
{
|
|
296
|
+
type: "button",
|
|
297
|
+
ref: (element) => {
|
|
298
|
+
if (element) buttonsRef.current.set(region.id, element);
|
|
299
|
+
else buttonsRef.current.delete(region.id);
|
|
300
|
+
},
|
|
301
|
+
"data-slot": "map-plan-overlay-region",
|
|
302
|
+
"data-selected": isSelected || void 0,
|
|
303
|
+
"data-group": isGroup || void 0,
|
|
304
|
+
"aria-pressed": isGroup ? void 0 : isSelected,
|
|
305
|
+
tabIndex: region.id === tabStopId ? 0 : -1,
|
|
306
|
+
className: cn(
|
|
307
|
+
"absolute inset-0 rounded-sm focus-ring",
|
|
308
|
+
capturePointer ? "pointer-events-auto" : "pointer-events-none"
|
|
309
|
+
),
|
|
310
|
+
onFocus: () => setActive(region.id),
|
|
311
|
+
onBlur: () => setActive(null),
|
|
312
|
+
onPointerEnter: () => setActive(region.id),
|
|
313
|
+
onPointerLeave: () => setActive(null),
|
|
314
|
+
onClick: () => activate(region),
|
|
315
|
+
onKeyDown: (event) => handleKeyDown(event, region),
|
|
316
|
+
children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: accessibleName })
|
|
317
|
+
}
|
|
318
|
+
),
|
|
319
|
+
showLabels && /* @__PURE__ */ jsx(
|
|
320
|
+
"span",
|
|
321
|
+
{
|
|
322
|
+
ref: (element) => {
|
|
323
|
+
if (element) labelsRef.current.set(region.id, element);
|
|
324
|
+
else labelsRef.current.delete(region.id);
|
|
325
|
+
},
|
|
326
|
+
"data-slot": "map-plan-overlay-label",
|
|
327
|
+
"aria-hidden": "true",
|
|
328
|
+
className: cn(
|
|
329
|
+
"pointer-events-none absolute inset-0 m-auto grid h-fit w-fit max-w-full",
|
|
330
|
+
"place-items-center truncate rounded-sm bg-card px-1 text-meta text-foreground"
|
|
331
|
+
),
|
|
332
|
+
children: region.label
|
|
333
|
+
}
|
|
334
|
+
)
|
|
335
|
+
]
|
|
336
|
+
},
|
|
337
|
+
region.id
|
|
338
|
+
);
|
|
339
|
+
}) })
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export {
|
|
345
|
+
MapPlanOverlay
|
|
346
|
+
};
|
|
347
|
+
//# sourceMappingURL=chunk-FQ6V7BFZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/map-plan/map-plan-overlay.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type KeyboardEvent as ReactKeyboardEvent,\n} from \"react\";\nimport { cn } from \"@elabs-ai/components-ui/lib/cn\";\nimport { useReducedMotion } from \"@elabs-ai/components-tokens\";\n\nimport { useMap } from \"../map-canvas/map-context\";\nimport {\n planGroupsFromRegions,\n planRegionCentre,\n type MapPlanGroup,\n type MapPlanRegion,\n} from \"../lib/plan-regions\";\nimport { usePlanProjection, type PlanScreenBox } from \"../lib/use-plan-projection\";\nimport { warnMapOnce } from \"../lib/warn-once\";\n\n/** Beyond this many regions a keyboard walk stops being usable — see `MapPlanOverlay`. */\nconst REGION_WARN_CEILING = 500;\n/** Beyond this many members, a group is no better to walk than the whole plan was. */\nconst GROUP_MEMBER_CEILING = 64;\n/** A label needs at least this much room, in px, before it is worth drawing. */\nconst LABEL_MIN_WIDTH = 44;\nconst LABEL_MIN_HEIGHT = 14;\n/** How long the camera takes to bring an off-screen region into view. */\nconst TRAVEL_DURATION_MS = 260;\n\n/** How the overlay lays its buttons out. */\nexport type MapPlanOverlayMode = \"regions\" | \"groups\";\n\nexport type MapPlanOverlayProps = {\n /**\n * The interactive regions, in reading order. Keep the array MEMOIZED — it\n * drives the projection pass.\n */\n regions: readonly MapPlanRegion[];\n /**\n * Accessible name for the set of regions (\"Rooms on level 3\"). Supplied by the\n * consumer so it can be localized.\n */\n label: string;\n /** The selected region id(s) — the same value handed to `<MapGeoJSON selectedId>`. */\n selectedId?: string | readonly string[] | null;\n /** Fired when a region is activated by click, `Enter` or `Space`; `null` on `Escape`. */\n onSelect?: (id: string | null, region: MapPlanRegion | null) => void;\n /**\n * Fired when the pointer or keyboard focus moves to another region. Feed it to\n * `<MapGeoJSON hoveredId>` so the shape and the proxy highlight together.\n */\n onActiveChange?: (id: string | null, region: MapPlanRegion | null) => void;\n /**\n * `\"regions\"` (default) puts one button on every region. `\"groups\"` puts one\n * button on each `group` instead, and `Enter` descends into its members — the\n * answer for a plan with more regions than a keyboard can reasonably walk (a\n * train with 192 seats), where the first question is always \"which coach?\".\n */\n mode?: MapPlanOverlayMode;\n /**\n * The accessible description of a group's button — \"64 seats\", localized by the\n * consumer. Group mode only.\n */\n describeGroup?: (group: MapPlanGroup) => string;\n /** Draw each region's label in the overlay (default true). */\n showLabels?: boolean;\n /** Hide the labels below this zoom, when a fitted plan is too small to read. */\n labelMinZoom?: number;\n /**\n * Let the regions take the pointer as well as the keyboard (default true).\n * With `false` the overlay is keyboard-only and clicks reach the canvas, where\n * `<MapGeoJSON onClick>` picks the shape itself — the right choice for\n * interlocking, non-rectangular shapes whose boxes overlap.\n */\n capturePointer?: boolean;\n /** Extra classes for the overlay root. */\n className?: string;\n};\n\n/** Reading-order index, for the fallback when no region lies in the arrow's cone. */\nfunction orderIndex(regions: readonly MapPlanRegion[], id: string | null) {\n return id == null ? -1 : regions.findIndex((region) => region.id === id);\n}\n\ntype Direction = \"start\" | \"end\" | \"up\" | \"down\";\n\nconst DIRECTION_VECTORS: Record<Direction, [number, number]> = {\n end: [1, 0],\n start: [-1, 0],\n up: [0, -1],\n down: [0, 1],\n};\n\n/**\n * The nearest region centre inside a ±45° cone — how a sighted user reads an\n * arrow key on a plan. Falls back to reading order, so an arrow always moves.\n */\nfunction nearestInDirection(\n regions: readonly MapPlanRegion[],\n fromId: string,\n direction: Direction,\n flipY: boolean,\n): MapPlanRegion | null {\n const from = regions.find((region) => region.id === fromId);\n if (!from) return null;\n\n const centre = (region: MapPlanRegion) => {\n const point = planRegionCentre(region.bounds);\n return { x: point.x, y: flipY ? -point.y : point.y };\n };\n const origin = centre(from);\n const [dirX, dirY] = DIRECTION_VECTORS[direction];\n\n let best: MapPlanRegion | null = null;\n let bestDistance = Number.POSITIVE_INFINITY;\n\n for (const region of regions) {\n if (region.id === fromId) continue;\n const target = centre(region);\n const dx = target.x - origin.x;\n const dy = target.y - origin.y;\n const along = dx * dirX + dy * dirY;\n if (along <= 0) continue;\n // cos(45°) — inside the cone when the along-axis share dominates.\n const distance = Math.hypot(dx, dy);\n if (along / distance < Math.SQRT1_2) continue;\n if (distance < bestDistance) {\n bestDistance = distance;\n best = region;\n }\n }\n\n if (best) return best;\n\n const index = orderIndex(regions, fromId);\n const step = direction === \"end\" || direction === \"down\" ? 1 : -1;\n return regions[index + step] ?? null;\n}\n\n/**\n * The keyboard and label layer of a plan. MapLibre draws every shape into one\n * WebGL canvas, so a room is not a DOM element and cannot be focused, named or\n * given a pressed state. This overlay puts one real `<button aria-pressed>` over\n * each region — the shapes stay in WebGL, the semantics come back to the DOM.\n *\n * ```tsx\n * <MapCanvas blank plan={{ width: 2400, height: 1600, unit: \"cm\" }}>\n * <MapGeoJSON data={rooms} promoteId=\"id\" selectedId={selection.selectedIds} hoveredId={active} />\n * <MapPlanOverlay\n * regions={regions}\n * label={t(\"floor.rooms\")}\n * selectedId={selection.selectedIds}\n * onSelect={(id) => selection.select(id)}\n * onActiveChange={setActive}\n * />\n * </MapCanvas>\n * ```\n *\n * Roughly 250 regions stay comfortable and 500 is the hard ceiling: past that a\n * `Tab`-and-arrow walk is no longer a usable way to reach one seat, and the\n * regions want `mode=\"groups\"` instead — one button per coach or wing, `Enter`\n * to go in, `Escape` to come back out, `PageUp`/`PageDown` to change group at\n * either level.\n */\nexport function MapPlanOverlay({\n regions,\n label,\n selectedId,\n onSelect,\n onActiveChange,\n mode = \"regions\",\n describeGroup,\n showLabels = true,\n labelMinZoom,\n capturePointer = true,\n className,\n}: MapPlanOverlayProps) {\n const { map, plan, loading } = useMap();\n const reducedMotion = useReducedMotion();\n const [activeId, setActiveId] = useState<string | null>(null);\n /** Group mode only: which group the keyboard is inside, if any. */\n const [openGroupKey, setOpenGroupKey] = useState<string | null>(null);\n\n const buttonsRef = useRef(new Map<string, HTMLButtonElement>());\n const labelsRef = useRef(new Map<string, HTMLElement>());\n /**\n * A button to focus once it exists. Changing group level swaps the whole set of\n * buttons, so the target is not in the DOM when the key is pressed — and a\n * `requestAnimationFrame` would drop the focus on the floor whenever the frame\n * lands after the next keystroke.\n */\n const pendingFocusRef = useRef<string | null>(null);\n\n const selectedIds = useMemo(() => {\n if (selectedId == null) return [] as string[];\n return Array.isArray(selectedId) ? [...selectedId] : [selectedId as string];\n }, [selectedId]);\n\n const groups = useMemo(\n () => (mode === \"groups\" ? planGroupsFromRegions(regions) : []),\n [mode, regions],\n );\n const openGroup = useMemo(\n () => groups.find((group) => group.key === openGroupKey) ?? null,\n [groups, openGroupKey],\n );\n\n /**\n * What actually gets a button: every region, the groups, or one group's members.\n * Everything downstream — the projection pass, the arrow keys, the roving tab\n * stop — works from this one list.\n */\n const visibleRegions = useMemo<readonly MapPlanRegion[]>(() => {\n if (mode !== \"groups\") return regions;\n if (openGroup) return openGroup.members;\n return groups.map((group) => {\n const description = describeGroup?.(group);\n return description ? { ...group.region, description } : group.region;\n });\n }, [describeGroup, groups, mode, openGroup, regions]);\n\n const groupByRegionId = useMemo(() => {\n const index = new Map<string, MapPlanGroup>();\n for (const group of groups) index.set(group.region.id, group);\n return index;\n }, [groups]);\n\n if (!plan) {\n warnMapOnce(\n \"plan-overlay-no-plan\",\n \"<MapPlanOverlay> needs a plan coordinate system: set `plan` on <MapCanvas>.\",\n );\n }\n if (mode === \"regions\" && regions.length > REGION_WARN_CEILING) {\n warnMapOnce(\n \"plan-overlay-region-count\",\n `<MapPlanOverlay> has ${regions.length} regions. Past ~${REGION_WARN_CEILING} a keyboard walk stops being a usable way to reach one of them — set mode=\"groups\" instead.`,\n );\n }\n const biggestGroup = groups.reduce((most, group) => Math.max(most, group.members.length), 0);\n if (biggestGroup > GROUP_MEMBER_CEILING) {\n warnMapOnce(\n \"plan-overlay-group-size\",\n `<MapPlanOverlay> has a group of ${biggestGroup} regions. Past ~${GROUP_MEMBER_CEILING} members a group is no easier to walk than the whole plan was — split it.`,\n );\n }\n\n const callbacksRef = useRef({ onSelect, onActiveChange });\n callbacksRef.current = { onSelect, onActiveChange };\n\n const regionById = useMemo(() => {\n const index = new Map<string, MapPlanRegion>();\n for (const region of visibleRegions) index.set(region.id, region);\n return index;\n }, [visibleRegions]);\n\n /**\n * Labels are sized in the same pass that places the boxes: a label that cannot\n * fit its region is hidden rather than allowed to spill over the shape next\n * door, and it shrinks to `shortLabel` before it disappears.\n */\n const handleFrame = useCallback(\n (boxes: ReadonlyMap<string, PlanScreenBox>) => {\n if (!showLabels) return;\n const zoom = map?.getZoom?.();\n const belowZoom = labelMinZoom !== undefined && zoom !== undefined && zoom < labelMinZoom;\n\n for (const [id, box] of boxes) {\n const element = labelsRef.current.get(id);\n if (!element) continue;\n const region = regionById.get(id);\n const fits = !belowZoom && box.width >= LABEL_MIN_WIDTH && box.height >= LABEL_MIN_HEIGHT;\n element.style.visibility = fits ? \"visible\" : \"hidden\";\n if (!fits || !region) continue;\n const short = region.shortLabel ?? region.label;\n // A rough per-character estimate is enough here: it only decides which\n // of two strings to draw, and a wrong guess still truncates cleanly.\n const roomFor = Math.floor(box.width / 7);\n element.textContent = region.label.length <= roomFor ? region.label : short;\n }\n },\n [labelMinZoom, map, regionById, showLabels],\n );\n\n const { register, refresh } = usePlanProjection({\n map,\n plan,\n regions: visibleRegions,\n enabled: !!plan,\n onFrame: handleFrame,\n });\n\n // Focus whatever a level change asked for, in the commit that created it.\n useLayoutEffect(() => {\n const id = pendingFocusRef.current;\n if (id == null) return;\n pendingFocusRef.current = null;\n buttonsRef.current.get(id)?.focus();\n });\n\n // Re-run the pass when the label rules change, so a zoom threshold or a\n // switched-off label takes effect without waiting for the next map move.\n useEffect(() => {\n refresh();\n }, [refresh, showLabels, labelMinZoom, visibleRegions]);\n\n const setActive = useCallback(\n (id: string | null) => {\n setActiveId(id);\n callbacksRef.current.onActiveChange?.(id, id ? (regionById.get(id) ?? null) : null);\n },\n [regionById],\n );\n\n /** Move focus, and bring the region into view when it sits off-screen. */\n const focusRegion = useCallback(\n (region: MapPlanRegion | null) => {\n if (!region) return;\n setActive(region.id);\n buttonsRef.current.get(region.id)?.focus();\n\n if (!map || !plan) return;\n const canvas = map.getCanvas?.();\n const width = canvas?.clientWidth ?? 0;\n const height = canvas?.clientHeight ?? 0;\n const centre = planRegionCentre(region.bounds);\n const point = map.project(plan.toLngLat(centre));\n const outside = point.x < 0 || point.y < 0 || point.x > width || point.y > height;\n if (!outside) return;\n\n map.easeTo({\n center: plan.toLngLat(centre),\n duration: reducedMotion ? 0 : TRAVEL_DURATION_MS,\n });\n },\n [map, plan, reducedMotion, setActive],\n );\n\n const flipY = plan?.extent.origin === \"bottom-left\";\n\n /** Open a group and put focus on its first member. */\n const enterGroup = useCallback(\n (group: MapPlanGroup) => {\n const first = group.members[0];\n setOpenGroupKey(group.key);\n if (!first) return;\n pendingFocusRef.current = first.id;\n setActive(first.id);\n },\n [setActive],\n );\n\n /** Leave the open group and put focus back on the group's own button. */\n const leaveGroup = useCallback(() => {\n const key = openGroupKey;\n if (key == null) return false;\n setOpenGroupKey(null);\n const groupRegionId = groups.find((group) => group.key === key)?.region.id;\n if (groupRegionId) {\n pendingFocusRef.current = groupRegionId;\n setActive(groupRegionId);\n }\n return true;\n }, [groups, openGroupKey, setActive]);\n\n /**\n * A button press means \"go in\" on a group and \"select\" on a region — the one\n * place the two levels of group mode differ.\n */\n const activate = useCallback(\n (region: MapPlanRegion) => {\n const group = groupByRegionId.get(region.id);\n if (group) {\n enterGroup(group);\n return;\n }\n callbacksRef.current.onSelect?.(region.id, region);\n },\n [enterGroup, groupByRegionId],\n );\n\n /** `PageUp`/`PageDown`: the group before or after this one, at either level. */\n const stepGroup = useCallback(\n (step: 1 | -1, fromRegionId: string) => {\n if (groups.length === 0) return;\n const fromKey =\n openGroupKey ??\n groups.find((group) => group.region.id === fromRegionId)?.key ??\n groups[0]!.key;\n const index = groups.findIndex((group) => group.key === fromKey);\n const next = groups[index + step];\n if (!next) return;\n if (openGroupKey != null) enterGroup(next);\n else focusRegion(next.region);\n },\n [enterGroup, focusRegion, groups, openGroupKey],\n );\n\n const handleKeyDown = useCallback(\n (event: ReactKeyboardEvent<HTMLButtonElement>, region: MapPlanRegion) => {\n const direction: Direction | null =\n event.key === \"ArrowRight\"\n ? \"end\"\n : event.key === \"ArrowLeft\"\n ? \"start\"\n : event.key === \"ArrowUp\"\n ? \"up\"\n : event.key === \"ArrowDown\"\n ? \"down\"\n : null;\n\n if (direction) {\n event.preventDefault();\n focusRegion(nearestInDirection(visibleRegions, region.id, direction, !!flipY));\n return;\n }\n\n if (event.key === \"Home\" || event.key === \"End\") {\n event.preventDefault();\n focusRegion((event.key === \"Home\" ? visibleRegions[0] : visibleRegions.at(-1)) ?? null);\n return;\n }\n\n if (event.key === \"PageUp\" || event.key === \"PageDown\") {\n if (groups.length === 0) return;\n event.preventDefault();\n stepGroup(event.key === \"PageDown\" ? 1 : -1, region.id);\n return;\n }\n\n if (event.key === \"Escape\") {\n event.preventDefault();\n // Inside a group, Escape steps back OUT of it; otherwise it clears the\n // selection. One key, the level it is on.\n if (leaveGroup()) return;\n callbacksRef.current.onSelect?.(null, null);\n }\n },\n [flipY, focusRegion, groups.length, leaveGroup, stepGroup, visibleRegions],\n );\n\n // Roving tabindex: exactly one stop into the set, then arrows inside it.\n const tabStopId =\n activeId ??\n visibleRegions.find((region) => selectedIds.includes(region.id))?.id ??\n visibleRegions[0]?.id ??\n null;\n\n return (\n <div\n data-slot=\"map-plan-overlay\"\n // The overlay sits over the canvas but under the loading veil, so a\n // keyboard user never lands on a button hidden behind it.\n className={cn(\"pointer-events-none absolute inset-0\", loading && \"invisible\", className)}\n >\n {/* `role=\"list\"` is explicit: `display: contents` drops list semantics in\n some engines, and the set needs its name to survive. */}\n <ul data-slot=\"map-plan-overlay-list\" role=\"list\" aria-label={label} className=\"contents\">\n {visibleRegions.map((region) => {\n const isGroup = groupByRegionId.has(region.id);\n const isSelected = !isGroup && selectedIds.includes(region.id);\n const accessibleName = region.description\n ? `${region.label}, ${region.description}`\n : region.label;\n\n return (\n <li\n key={region.id}\n ref={(element) => register(region.id, element)}\n // Placed by the projection pass; `absolute` with no inset so the\n // transform it writes is the only source of position.\n className=\"pointer-events-none absolute\"\n >\n <button\n type=\"button\"\n ref={(element) => {\n if (element) buttonsRef.current.set(region.id, element);\n else buttonsRef.current.delete(region.id);\n }}\n data-slot=\"map-plan-overlay-region\"\n data-selected={isSelected || undefined}\n data-group={isGroup || undefined}\n // A group's button is not a toggle: pressing it replaces the\n // group buttons with its members, and `aria-expanded` would then\n // describe a control that is no longer on screen.\n aria-pressed={isGroup ? undefined : isSelected}\n tabIndex={region.id === tabStopId ? 0 : -1}\n className={cn(\n \"absolute inset-0 rounded-sm focus-ring\",\n capturePointer ? \"pointer-events-auto\" : \"pointer-events-none\",\n )}\n onFocus={() => setActive(region.id)}\n onBlur={() => setActive(null)}\n onPointerEnter={() => setActive(region.id)}\n onPointerLeave={() => setActive(null)}\n onClick={() => activate(region)}\n onKeyDown={(event) => handleKeyDown(event, region)}\n >\n <span className=\"sr-only\">{accessibleName}</span>\n </button>\n {showLabels && (\n <span\n ref={(element) => {\n if (element) labelsRef.current.set(region.id, element);\n else labelsRef.current.delete(region.id);\n }}\n data-slot=\"map-plan-overlay-label\"\n // Aria-hidden and a SIBLING of the button: the button already\n // says the label as the start of its accessible name (WCAG\n // 2.5.3), and nesting it would say it twice.\n aria-hidden=\"true\"\n className={cn(\n \"pointer-events-none absolute inset-0 m-auto grid h-fit w-fit max-w-full\",\n \"place-items-center truncate rounded-sm bg-card px-1 text-meta text-foreground\",\n )}\n >\n {region.label}\n </span>\n )}\n </li>\n );\n })}\n </ul>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/lib/warn-once.ts
|
|
4
|
+
var warned = /* @__PURE__ */ new Set();
|
|
5
|
+
function warnMapOnce(key, message) {
|
|
6
|
+
if (process.env.NODE_ENV === "production") return;
|
|
7
|
+
if (warned.has(key)) return;
|
|
8
|
+
warned.add(key);
|
|
9
|
+
console.warn(`[maps] ${message}`);
|
|
10
|
+
}
|
|
11
|
+
function resetMapWarnings() {
|
|
12
|
+
warned.clear();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
warnMapOnce,
|
|
17
|
+
resetMapWarnings
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=chunk-G7JDXDJN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/lib/warn-once.ts"],"sourcesContent":["/**\n * Dev-only warnings for the silent failure modes a map wrapper cannot type away\n * — a plan coordinate handed to a geographic canvas, a missing `promoteId`, a\n * GeoJSON URL that cannot be transformed. Mirrors `warnChartOnce` in\n * `@elabs-ai/components-charts` (kept local: `dep-direction` forbids the import).\n */\n\nconst warned = new Set<string>();\n\n/** `console.warn` once per `key` per page load, and never in production. */\nexport function warnMapOnce(key: string, message: string): void {\n if (process.env.NODE_ENV === \"production\") return;\n if (warned.has(key)) return;\n warned.add(key);\n console.warn(`[maps] ${message}`);\n}\n\n/** Test-only: forget which warnings have already fired. */\nexport function resetMapWarnings(): void {\n warned.clear();\n}\n"],"mappings":";;;AAOA,IAAM,SAAS,oBAAI,IAAY;AAGxB,SAAS,YAAY,KAAa,SAAuB;AAC9D,MAAI,QAAQ,IAAI,aAAa,aAAc;AAC3C,MAAI,OAAO,IAAI,GAAG,EAAG;AACrB,SAAO,IAAI,GAAG;AACd,UAAQ,KAAK,UAAU,OAAO,EAAE;AAClC;AAGO,SAAS,mBAAyB;AACvC,SAAO,MAAM;AACf;","names":[]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/map-legend/map-corner.ts
|
|
4
|
+
var MAP_CORNERS = [
|
|
5
|
+
"top-left",
|
|
6
|
+
"top-right",
|
|
7
|
+
"bottom-right",
|
|
8
|
+
"bottom-left"
|
|
9
|
+
];
|
|
10
|
+
var MAP_CORNER_CLASSES = {
|
|
11
|
+
"top-left": "top-2 start-2",
|
|
12
|
+
"top-right": "top-2 end-2",
|
|
13
|
+
"bottom-left": "bottom-2 start-2",
|
|
14
|
+
"bottom-right": "bottom-2 end-2"
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
MAP_CORNERS,
|
|
19
|
+
MAP_CORNER_CLASSES
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=chunk-GWULZOMW.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/map-legend/map-corner.ts"],"sourcesContent":["/**\n * The four corners map furniture (legend, scale bar, north arrow, inset) can\n * sit in, inside the map box. Chrome, not geography: the corners are LOGICAL\n * (`start` / `end`), so \"top-left\" is the reading-start corner and mirrors in\n * RTL like the rest of the page.\n */\nexport type MapCorner = \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n\n/** Every corner, clockwise from top-left (for stories and validation). */\nexport const MAP_CORNERS: readonly MapCorner[] = [\n \"top-left\",\n \"top-right\",\n \"bottom-right\",\n \"bottom-left\",\n];\n\n/** The inset classes for each corner. */\nexport const MAP_CORNER_CLASSES: Record<MapCorner, string> = {\n \"top-left\": \"top-2 start-2\",\n \"top-right\": \"top-2 end-2\",\n \"bottom-left\": \"bottom-2 start-2\",\n \"bottom-right\": \"bottom-2 end-2\",\n};\n"],"mappings":";;;AASO,IAAM,cAAoC;AAAA,EAC/C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,IAAM,qBAAgD;AAAA,EAC3D,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,eAAe;AAAA,EACf,gBAAgB;AAClB;","names":[]}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/lib/plan-crs.ts
|
|
4
|
+
var PLAN_MERCATOR_SPAN = 0.5;
|
|
5
|
+
var MERCATOR_TILE_SIZE = 512;
|
|
6
|
+
var RAD_PER_DEG = Math.PI / 180;
|
|
7
|
+
var DEG_PER_RAD = 180 / Math.PI;
|
|
8
|
+
function mercatorFromLngLat(lng, lat) {
|
|
9
|
+
const x = (180 + lng) / 360;
|
|
10
|
+
const y = (180 - DEG_PER_RAD * Math.log(Math.tan(Math.PI / 4 + lat * RAD_PER_DEG / 2))) / 360;
|
|
11
|
+
return [x, y];
|
|
12
|
+
}
|
|
13
|
+
function lngLatFromMercator(x, y) {
|
|
14
|
+
const lng = x * 360 - 180;
|
|
15
|
+
const y2 = 180 - y * 360;
|
|
16
|
+
const lat = 360 / Math.PI * (Math.atan(Math.exp(y2 * RAD_PER_DEG)) - Math.PI / 4);
|
|
17
|
+
return [lng, lat];
|
|
18
|
+
}
|
|
19
|
+
function asPlanPoint(point) {
|
|
20
|
+
return Array.isArray(point) ? { x: point[0], y: point[1] } : point;
|
|
21
|
+
}
|
|
22
|
+
function createPlanCrs(extent) {
|
|
23
|
+
const { width, height, origin = "top-left", unit = "px" } = extent;
|
|
24
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`createPlanCrs: width and height must be finite positive numbers (received ${String(width)} x ${String(height)})`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const resolved = { width, height, origin, unit };
|
|
30
|
+
const scale = PLAN_MERCATOR_SPAN / Math.max(width, height);
|
|
31
|
+
const x0 = 0.5 - width * scale / 2;
|
|
32
|
+
const y0 = 0.5 - height * scale / 2;
|
|
33
|
+
const planYToMercatorY = (y) => y0 + (origin === "top-left" ? y : height - y) * scale;
|
|
34
|
+
const mercatorYToPlanY = (my) => {
|
|
35
|
+
const y = (my - y0) / scale;
|
|
36
|
+
return origin === "top-left" ? y : height - y;
|
|
37
|
+
};
|
|
38
|
+
const toLngLat = (point) => {
|
|
39
|
+
const { x, y } = asPlanPoint(point);
|
|
40
|
+
return lngLatFromMercator(x0 + x * scale, planYToMercatorY(y));
|
|
41
|
+
};
|
|
42
|
+
const toPlan = (lngLat) => {
|
|
43
|
+
const [lng, lat] = Array.isArray(lngLat) ? lngLat : [lngLat.lng, lngLat.lat];
|
|
44
|
+
const [mx, my] = mercatorFromLngLat(lng, lat);
|
|
45
|
+
return { x: (mx - x0) / scale, y: mercatorYToPlanY(my) };
|
|
46
|
+
};
|
|
47
|
+
const topLeft = toLngLat({ x: 0, y: 0 });
|
|
48
|
+
const bottomRight = toLngLat({ x: width, y: height });
|
|
49
|
+
const west = topLeft[0];
|
|
50
|
+
const east = bottomRight[0];
|
|
51
|
+
const north = Math.max(topLeft[1], bottomRight[1]);
|
|
52
|
+
const south = Math.min(topLeft[1], bottomRight[1]);
|
|
53
|
+
const bounds = [
|
|
54
|
+
[west, south],
|
|
55
|
+
[east, north]
|
|
56
|
+
];
|
|
57
|
+
const imageCoordinates = [
|
|
58
|
+
toLngLat({ x: 0, y: 0 }),
|
|
59
|
+
toLngLat({ x: width, y: 0 }),
|
|
60
|
+
toLngLat({ x: width, y: height }),
|
|
61
|
+
toLngLat({ x: 0, y: height })
|
|
62
|
+
];
|
|
63
|
+
const zoomForLongSidePixels = (px) => Math.log2(px / (PLAN_MERCATOR_SPAN * MERCATOR_TILE_SIZE));
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
extent: resolved,
|
|
66
|
+
scale,
|
|
67
|
+
toLngLat,
|
|
68
|
+
toPlan,
|
|
69
|
+
toGeoJSON: (data) => convertGeoJSON(data, toLngLat),
|
|
70
|
+
bounds,
|
|
71
|
+
maxBounds(padFraction = 0.1) {
|
|
72
|
+
const pad = Math.max(width, height) * padFraction;
|
|
73
|
+
const outerTopLeft = toLngLat({ x: -pad, y: -pad });
|
|
74
|
+
const outerBottomRight = toLngLat({ x: width + pad, y: height + pad });
|
|
75
|
+
return [
|
|
76
|
+
[outerTopLeft[0], Math.min(outerTopLeft[1], outerBottomRight[1])],
|
|
77
|
+
[outerBottomRight[0], Math.max(outerTopLeft[1], outerBottomRight[1])]
|
|
78
|
+
];
|
|
79
|
+
},
|
|
80
|
+
imageCoordinates,
|
|
81
|
+
zoomForLongSidePixels,
|
|
82
|
+
minZoom: zoomForLongSidePixels(128),
|
|
83
|
+
maxZoom: zoomForLongSidePixels(32768),
|
|
84
|
+
distance: (a, b) => Math.hypot(b.x - a.x, b.y - a.y)
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function convertPositions(coordinates, convert) {
|
|
88
|
+
if (!Array.isArray(coordinates)) return coordinates;
|
|
89
|
+
if (typeof coordinates[0] === "number" && typeof coordinates[1] === "number") {
|
|
90
|
+
return convert([coordinates[0], coordinates[1]]);
|
|
91
|
+
}
|
|
92
|
+
return coordinates.map((entry) => convertPositions(entry, convert));
|
|
93
|
+
}
|
|
94
|
+
function convertGeoJSON(data, convert) {
|
|
95
|
+
if (!data || typeof data !== "object") return data;
|
|
96
|
+
const input = data;
|
|
97
|
+
const { bbox: _bbox, ...rest } = input;
|
|
98
|
+
const output = { ...rest };
|
|
99
|
+
if (Array.isArray(input.features)) {
|
|
100
|
+
output.features = input.features.map((feature) => convertGeoJSON(feature, convert));
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(input.geometries)) {
|
|
103
|
+
output.geometries = input.geometries.map((geometry) => convertGeoJSON(geometry, convert));
|
|
104
|
+
}
|
|
105
|
+
if (input.geometry && typeof input.geometry === "object") {
|
|
106
|
+
output.geometry = convertGeoJSON(input.geometry, convert);
|
|
107
|
+
}
|
|
108
|
+
if (input.coordinates !== void 0) {
|
|
109
|
+
output.coordinates = convertPositions(input.coordinates, convert);
|
|
110
|
+
}
|
|
111
|
+
return output;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export {
|
|
115
|
+
PLAN_MERCATOR_SPAN,
|
|
116
|
+
mercatorFromLngLat,
|
|
117
|
+
lngLatFromMercator,
|
|
118
|
+
createPlanCrs
|
|
119
|
+
};
|
|
120
|
+
//# sourceMappingURL=chunk-GYDTTAVD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/lib/plan-crs.ts"],"sourcesContent":["/**\n * A coordinate system for a CUSTOM, non-geographic plan — a floor plan, a\n * factory layout, a train carriage, a rack elevation. Kept engine-free so it can\n * be unit-tested (same convention as `arc-math.ts`).\n *\n * MapLibre GL JS renders Web Mercator and nothing else: it has no custom CRS and\n * none is planned, so a plan coordinate system has to be SYNTHESIZED on top of\n * Mercator. The same convention as Leaflet's `CRS.Simple` (origin top-left, one\n * map unit = one pixel) and Qlik Sense's image background layer (corner\n * coordinates in the image's own pixels, projection \"undefined\").\n *\n * The plan is mapped LINEARLY INTO NORMALIZED MERCATOR, not linearly into\n * degrees. Normalized Mercator is what the screen shows, so one scale factor for\n * both axes keeps the plan's aspect ratio exact at every zoom and every plan\n * size. Mapping linearly into degrees — the naive reading of \"undefined degrees\"\n * — stretches the plan vertically by Mercator's cos(lat) factor: a 1600 x 900\n * plan comes out at aspect 1.996 instead of 1.778, 12% too wide.\n *\n * What this is NOT: a georeference. Mercator's y is non-linear in latitude, so\n * lengths measured on the Earth are meaningless here. Distances belong to the\n * plan (`distance()` returns plan units); never put a scale bar on a plan map,\n * and never combine a plan with the globe projection.\n */\n\n/**\n * How much of normalized Mercator the plan's LONG side spans. Normalizing on the\n * long side caps the footprint whatever the aspect ratio: the tallest possible\n * plan reaches +-66.5 deg latitude, comfortably inside Mercator's +-85.05 limit.\n */\nexport const PLAN_MERCATOR_SPAN = 0.5;\n\n/** MapLibre's world is this many CSS pixels across at zoom 0. */\nconst MERCATOR_TILE_SIZE = 512;\n\n/** A point in plan units. */\nexport interface PlanPoint {\n x: number;\n y: number;\n}\n\n/** The plan's own extent and units — everything the coordinate system needs. */\nexport interface PlanExtent {\n /** Plan width in plan units (image pixels, millimetres, metres — your choice). */\n width: number;\n /** Plan height in plan units. */\n height: number;\n /**\n * Where plan (0, 0) sits. `\"top-left\"` (the default) matches an image and\n * Leaflet's `CRS.Simple`; `\"bottom-left\"` matches y-up CAD/DXF exports.\n */\n origin?: \"top-left\" | \"bottom-left\";\n /** Unit name, for labels and readouts only — the maths never reads it. */\n unit?: string;\n}\n\n/** The four image-source corners MapLibre wants: top-left, top-right, bottom-right, bottom-left. */\nexport type PlanImageCoordinates = [\n [number, number],\n [number, number],\n [number, number],\n [number, number],\n];\n\n/** A two-way transform between plan units and lng/lat, plus everything derived from it. */\nexport interface PlanCrs {\n /** The extent this was built from, with defaults applied. */\n readonly extent: Required<PlanExtent>;\n /** Normalized Mercator units per plan unit. */\n readonly scale: number;\n /** Plan point -> `[lng, lat]`. */\n toLngLat(point: PlanPoint | [number, number]): [number, number];\n /** `[lng, lat]` -> plan point. */\n toPlan(lngLat: [number, number] | { lng: number; lat: number }): PlanPoint;\n /** Deep-convert GeoJSON authored in plan units into lng/lat GeoJSON. */\n toGeoJSON<T>(data: T): T;\n /** The plan's bounds as `[[west, south], [east, north]]`. */\n readonly bounds: [[number, number], [number, number]];\n /** Bounds grown by `padFraction` of the long side — a pan limit that still shows the edges. */\n maxBounds(padFraction?: number): [[number, number], [number, number]];\n /** The plan's four corners, ready for a MapLibre `image` or `canvas` source. */\n readonly imageCoordinates: PlanImageCoordinates;\n /** The zoom at which the plan's long side covers `px` CSS pixels. */\n zoomForLongSidePixels(px: number): number;\n /** Zoom at which the plan's long side is 128 px — small but still findable. */\n readonly minZoom: number;\n /** Zoom at which the plan's long side is 32768 px — a desk, a machine, a seat. */\n readonly maxZoom: number;\n /** Straight-line distance between two plan points, in plan units. */\n distance(a: PlanPoint, b: PlanPoint): number;\n}\n\nconst RAD_PER_DEG = Math.PI / 180;\nconst DEG_PER_RAD = 180 / Math.PI;\n\n/**\n * `[lng, lat]` -> normalized Mercator `[x, y]`, both in `[0, 1]`, with `y = 0` at\n * the NORTH-WEST corner. The Web Mercator spec formulas, implemented here so the\n * module stays engine-free; a parity test pins them to MapLibre's own\n * `MercatorCoordinate`.\n */\nexport function mercatorFromLngLat(lng: number, lat: number): [number, number] {\n const x = (180 + lng) / 360;\n const y = (180 - DEG_PER_RAD * Math.log(Math.tan(Math.PI / 4 + (lat * RAD_PER_DEG) / 2))) / 360;\n return [x, y];\n}\n\n/** Normalized Mercator `[x, y]` -> `[lng, lat]`. The inverse of `mercatorFromLngLat`. */\nexport function lngLatFromMercator(x: number, y: number): [number, number] {\n const lng = x * 360 - 180;\n const y2 = 180 - y * 360;\n const lat = (360 / Math.PI) * (Math.atan(Math.exp(y2 * RAD_PER_DEG)) - Math.PI / 4);\n return [lng, lat];\n}\n\nfunction asPlanPoint(point: PlanPoint | [number, number]): PlanPoint {\n return Array.isArray(point) ? { x: point[0], y: point[1] } : point;\n}\n\n/**\n * Build a plan coordinate system from the plan's extent.\n *\n * ```tsx\n * const plan = createPlanCrs({ width: 2400, height: 1600, unit: \"cm\" });\n * <MapCanvas blank plan={plan}>\n * <MapPlanImage src={floorPlanPng} />\n * <MapGeoJSON data={roomsInPlanUnits} promoteId=\"id\" />\n * </MapCanvas>\n * ```\n *\n * Throws when the extent is not a pair of finite, positive numbers — a NaN\n * coordinate system is unfixable three layers downstream.\n */\nexport function createPlanCrs(extent: PlanExtent): PlanCrs {\n const { width, height, origin = \"top-left\", unit = \"px\" } = extent;\n\n if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {\n throw new Error(\n `createPlanCrs: width and height must be finite positive numbers (received ${String(width)} x ${String(height)})`,\n );\n }\n\n const resolved: Required<PlanExtent> = { width, height, origin, unit };\n const scale = PLAN_MERCATOR_SPAN / Math.max(width, height);\n // Centre the plan on normalized Mercator (0.5, 0.5), i.e. on lng/lat [0, 0].\n const x0 = 0.5 - (width * scale) / 2;\n const y0 = 0.5 - (height * scale) / 2;\n\n // Mercator y already increases southward, so a top-left plan origin needs no\n // flip. Only a y-up plan does.\n const planYToMercatorY = (y: number) => y0 + (origin === \"top-left\" ? y : height - y) * scale;\n const mercatorYToPlanY = (my: number) => {\n const y = (my - y0) / scale;\n return origin === \"top-left\" ? y : height - y;\n };\n\n const toLngLat = (point: PlanPoint | [number, number]): [number, number] => {\n const { x, y } = asPlanPoint(point);\n return lngLatFromMercator(x0 + x * scale, planYToMercatorY(y));\n };\n\n const toPlan = (lngLat: [number, number] | { lng: number; lat: number }): PlanPoint => {\n const [lng, lat] = Array.isArray(lngLat) ? lngLat : [lngLat.lng, lngLat.lat];\n const [mx, my] = mercatorFromLngLat(lng, lat);\n return { x: (mx - x0) / scale, y: mercatorYToPlanY(my) };\n };\n\n const topLeft = toLngLat({ x: 0, y: 0 });\n const bottomRight = toLngLat({ x: width, y: height });\n // Plan y grows downward, so the plan's y=0 edge is the NORTHERN one.\n const west = topLeft[0];\n const east = bottomRight[0];\n const north = Math.max(topLeft[1], bottomRight[1]);\n const south = Math.min(topLeft[1], bottomRight[1]);\n\n const bounds: [[number, number], [number, number]] = [\n [west, south],\n [east, north],\n ];\n\n const imageCoordinates: PlanImageCoordinates = [\n toLngLat({ x: 0, y: 0 }),\n toLngLat({ x: width, y: 0 }),\n toLngLat({ x: width, y: height }),\n toLngLat({ x: 0, y: height }),\n ];\n\n const zoomForLongSidePixels = (px: number) =>\n Math.log2(px / (PLAN_MERCATOR_SPAN * MERCATOR_TILE_SIZE));\n\n return Object.freeze({\n extent: resolved,\n scale,\n toLngLat,\n toPlan,\n toGeoJSON: <T>(data: T): T => convertGeoJSON(data, toLngLat),\n bounds,\n maxBounds(padFraction = 0.1): [[number, number], [number, number]] {\n const pad = Math.max(width, height) * padFraction;\n const outerTopLeft = toLngLat({ x: -pad, y: -pad });\n const outerBottomRight = toLngLat({ x: width + pad, y: height + pad });\n return [\n [outerTopLeft[0], Math.min(outerTopLeft[1], outerBottomRight[1])],\n [outerBottomRight[0], Math.max(outerTopLeft[1], outerBottomRight[1])],\n ];\n },\n imageCoordinates,\n zoomForLongSidePixels,\n minZoom: zoomForLongSidePixels(128),\n maxZoom: zoomForLongSidePixels(32768),\n distance: (a: PlanPoint, b: PlanPoint) => Math.hypot(b.x - a.x, b.y - a.y),\n });\n}\n\ntype Convert = (point: PlanPoint | [number, number]) => [number, number];\n\nfunction convertPositions(coordinates: unknown, convert: Convert): unknown {\n if (!Array.isArray(coordinates)) return coordinates;\n // A position is a flat [x, y(, z)] — convert it and drop any third element,\n // which would be a plan-unit altitude MapLibre cannot interpret.\n if (typeof coordinates[0] === \"number\" && typeof coordinates[1] === \"number\") {\n return convert([coordinates[0], coordinates[1]]);\n }\n return coordinates.map((entry) => convertPositions(entry, convert));\n}\n\n/**\n * Deep-convert every position in a GeoJSON value from plan units to lng/lat.\n * `bbox` is STRIPPED rather than converted: MapLibre trusts a declared bbox, and\n * a plan-unit one would put the geometry in the wrong place.\n */\nfunction convertGeoJSON<T>(data: T, convert: Convert): T {\n if (!data || typeof data !== \"object\") return data;\n\n const input = data as Record<string, unknown>;\n const { bbox: _bbox, ...rest } = input;\n const output: Record<string, unknown> = { ...rest };\n\n if (Array.isArray(input.features)) {\n output.features = input.features.map((feature) => convertGeoJSON(feature, convert));\n }\n if (Array.isArray(input.geometries)) {\n output.geometries = input.geometries.map((geometry) => convertGeoJSON(geometry, convert));\n }\n if (input.geometry && typeof input.geometry === \"object\") {\n output.geometry = convertGeoJSON(input.geometry, convert);\n }\n if (input.coordinates !== undefined) {\n output.coordinates = convertPositions(input.coordinates, convert);\n }\n\n return output as T;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/map-plan/use-plan-selection.ts
|
|
4
|
+
import { useCallback, useMemo, useState } from "react";
|
|
5
|
+
function usePlanSelection({
|
|
6
|
+
multiple = false,
|
|
7
|
+
initialSelectedIds,
|
|
8
|
+
onChange
|
|
9
|
+
} = {}) {
|
|
10
|
+
const [selectedIds, setSelectedIds] = useState(() => [...initialSelectedIds ?? []]);
|
|
11
|
+
const commit = useCallback(
|
|
12
|
+
(next) => {
|
|
13
|
+
setSelectedIds(next);
|
|
14
|
+
onChange?.(next);
|
|
15
|
+
},
|
|
16
|
+
[onChange]
|
|
17
|
+
);
|
|
18
|
+
const select = useCallback(
|
|
19
|
+
(id) => {
|
|
20
|
+
if (id === null) {
|
|
21
|
+
commit([]);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
setSelectedIds((current) => {
|
|
25
|
+
const next = current.includes(id) ? current.filter((entry) => entry !== id) : multiple ? [...current, id] : [id];
|
|
26
|
+
onChange?.(next);
|
|
27
|
+
return next;
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
[commit, multiple, onChange]
|
|
31
|
+
);
|
|
32
|
+
const clear = useCallback(() => commit([]), [commit]);
|
|
33
|
+
return useMemo(
|
|
34
|
+
() => ({
|
|
35
|
+
selectedId: selectedIds.at(-1) ?? null,
|
|
36
|
+
selectedIds,
|
|
37
|
+
isSelected: (id) => selectedIds.includes(id),
|
|
38
|
+
select,
|
|
39
|
+
clear
|
|
40
|
+
}),
|
|
41
|
+
[selectedIds, select, clear]
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
usePlanSelection
|
|
47
|
+
};
|
|
48
|
+
//# sourceMappingURL=chunk-HCOBWIM4.js.map
|