@elabs-ai/components-maps 4.2.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/chunk-2FPB53NG.js +36 -0
- package/dist/chunk-2FPB53NG.js.map +1 -0
- package/dist/chunk-2VVJRT66.js +37 -0
- package/dist/chunk-2VVJRT66.js.map +1 -0
- package/dist/chunk-3HXFONRP.js +2 -0
- package/dist/chunk-3HXFONRP.js.map +1 -0
- package/dist/chunk-3SJN3IYT.js +2 -0
- package/dist/chunk-3SJN3IYT.js.map +1 -0
- package/dist/chunk-4DANBD5B.js +55 -0
- package/dist/chunk-4DANBD5B.js.map +1 -0
- package/dist/chunk-5PDVG53B.js +2 -0
- package/dist/chunk-5PDVG53B.js.map +1 -0
- package/dist/chunk-643R7IJC.js +2 -0
- package/dist/chunk-643R7IJC.js.map +1 -0
- package/dist/chunk-B3ZDBQ6I.js +164 -0
- package/dist/chunk-B3ZDBQ6I.js.map +1 -0
- package/dist/chunk-BGCE3RR5.js +35 -0
- package/dist/chunk-BGCE3RR5.js.map +1 -0
- package/dist/chunk-C4VI37TD.js +485 -0
- package/dist/chunk-C4VI37TD.js.map +1 -0
- package/dist/chunk-CJJT6AEI.js +179 -0
- package/dist/chunk-CJJT6AEI.js.map +1 -0
- package/dist/chunk-D2RCPTRO.js +2 -0
- package/dist/chunk-D2RCPTRO.js.map +1 -0
- package/dist/chunk-DTV7JZVN.js +2 -0
- package/dist/chunk-DTV7JZVN.js.map +1 -0
- package/dist/chunk-E65Z3MSV.js +80 -0
- package/dist/chunk-E65Z3MSV.js.map +1 -0
- package/dist/chunk-E7VPNINZ.js +70 -0
- package/dist/chunk-E7VPNINZ.js.map +1 -0
- package/dist/chunk-FHV5IA6N.js +100 -0
- package/dist/chunk-FHV5IA6N.js.map +1 -0
- package/dist/chunk-FQ6V7BFZ.js +347 -0
- package/dist/chunk-FQ6V7BFZ.js.map +1 -0
- package/dist/chunk-G7JDXDJN.js +19 -0
- package/dist/chunk-G7JDXDJN.js.map +1 -0
- package/dist/chunk-GWULZOMW.js +21 -0
- package/dist/chunk-GWULZOMW.js.map +1 -0
- package/dist/chunk-GYDTTAVD.js +120 -0
- package/dist/chunk-GYDTTAVD.js.map +1 -0
- package/dist/chunk-HCOBWIM4.js +48 -0
- package/dist/chunk-HCOBWIM4.js.map +1 -0
- package/dist/chunk-IAHK5MVT.js +2 -0
- package/dist/chunk-IAHK5MVT.js.map +1 -0
- package/dist/chunk-JGJ6442I.js +235 -0
- package/dist/chunk-JGJ6442I.js.map +1 -0
- package/dist/chunk-JYFERXZT.js +18 -0
- package/dist/chunk-JYFERXZT.js.map +1 -0
- package/dist/chunk-LU2UEKFM.js +53 -0
- package/dist/chunk-LU2UEKFM.js.map +1 -0
- package/dist/chunk-LVYM6C5C.js +2 -0
- package/dist/chunk-LVYM6C5C.js.map +1 -0
- package/dist/chunk-MIMDP3EU.js +104 -0
- package/dist/chunk-MIMDP3EU.js.map +1 -0
- package/dist/chunk-NNBURJHY.js +2 -0
- package/dist/chunk-NNBURJHY.js.map +1 -0
- package/dist/chunk-NPUC3V37.js +2 -0
- package/dist/chunk-NPUC3V37.js.map +1 -0
- package/dist/chunk-OEVVNPN3.js +201 -0
- package/dist/chunk-OEVVNPN3.js.map +1 -0
- package/dist/chunk-PF3R2BXM.js +200 -0
- package/dist/chunk-PF3R2BXM.js.map +1 -0
- package/dist/chunk-Q2I5YODW.js +2 -0
- package/dist/chunk-Q2I5YODW.js.map +1 -0
- package/dist/chunk-QDWWP3N7.js +247 -0
- package/dist/chunk-QDWWP3N7.js.map +1 -0
- package/dist/chunk-QU33KPMP.js +2 -0
- package/dist/chunk-QU33KPMP.js.map +1 -0
- package/dist/chunk-RK4BUMHS.js +2 -0
- package/dist/chunk-RK4BUMHS.js.map +1 -0
- package/dist/chunk-RNSLG64F.js +26 -0
- package/dist/chunk-RNSLG64F.js.map +1 -0
- package/dist/chunk-RV673TB4.js +68 -0
- package/dist/chunk-RV673TB4.js.map +1 -0
- package/dist/chunk-S7N2WMVT.js +180 -0
- package/dist/chunk-S7N2WMVT.js.map +1 -0
- package/dist/chunk-T3Z6TCDD.js +2 -0
- package/dist/chunk-T3Z6TCDD.js.map +1 -0
- package/dist/chunk-T4TJTVVA.js +105 -0
- package/dist/chunk-T4TJTVVA.js.map +1 -0
- package/dist/chunk-UMOYRCDX.js +103 -0
- package/dist/chunk-UMOYRCDX.js.map +1 -0
- package/dist/chunk-V2REM6UT.js +185 -0
- package/dist/chunk-V2REM6UT.js.map +1 -0
- package/dist/chunk-VFBRFGID.js +89 -0
- package/dist/chunk-VFBRFGID.js.map +1 -0
- package/dist/chunk-W76NLFER.js +103 -0
- package/dist/chunk-W76NLFER.js.map +1 -0
- package/dist/chunk-WNU4IOGL.js +116 -0
- package/dist/chunk-WNU4IOGL.js.map +1 -0
- package/dist/chunk-WS4Z7ENS.js +494 -0
- package/dist/chunk-WS4Z7ENS.js.map +1 -0
- package/dist/chunk-XQT7YLJY.js +497 -0
- package/dist/chunk-XQT7YLJY.js.map +1 -0
- package/dist/chunk-Y44MEKJV.js +21 -0
- package/dist/chunk-Y44MEKJV.js.map +1 -0
- package/dist/chunk-Z4EPSOB4.js +53 -0
- package/dist/chunk-Z4EPSOB4.js.map +1 -0
- package/dist/chunk-Z5OPDBDC.js +2 -0
- package/dist/chunk-Z5OPDBDC.js.map +1 -0
- package/dist/index.d.ts +1241 -46
- package/dist/index.js +189 -1471
- package/dist/index.js.map +1 -1
- package/dist/lib/arc-math.js +8 -0
- package/dist/lib/arc-math.js.map +1 -0
- package/dist/lib/map-position.js +11 -0
- package/dist/lib/map-position.js.map +1 -0
- package/dist/lib/merge-hover-paint.js +10 -0
- package/dist/lib/merge-hover-paint.js.map +1 -0
- package/dist/lib/plan-crs.js +14 -0
- package/dist/lib/plan-crs.js.map +1 -0
- package/dist/lib/plan-patterns.js +14 -0
- package/dist/lib/plan-patterns.js.map +1 -0
- package/dist/lib/plan-regions.js +18 -0
- package/dist/lib/plan-regions.js.map +1 -0
- package/dist/lib/plan-status.js +14 -0
- package/dist/lib/plan-status.js.map +1 -0
- package/dist/lib/route-arrow.js +12 -0
- package/dist/lib/route-arrow.js.map +1 -0
- package/dist/lib/use-map-breakpoint.js +33 -0
- package/dist/lib/use-map-breakpoint.js.map +1 -0
- package/dist/lib/use-plan-projection.js +9 -0
- package/dist/lib/use-plan-projection.js.map +1 -0
- package/dist/lib/use-token-color.js +10 -0
- package/dist/lib/use-token-color.js.map +1 -0
- package/dist/lib/warn-once.js +10 -0
- package/dist/lib/warn-once.js.map +1 -0
- package/dist/map-annotation/anchor.js +14 -0
- package/dist/map-annotation/anchor.js.map +1 -0
- package/dist/map-annotation/index.js +21 -0
- package/dist/map-annotation/index.js.map +1 -0
- package/dist/map-annotation/map-annotation.js +18 -0
- package/dist/map-annotation/map-annotation.js.map +1 -0
- package/dist/map-arc/index.js +16 -0
- package/dist/map-arc/index.js.map +1 -0
- package/dist/map-arc/map-arc.js +13 -0
- package/dist/map-arc/map-arc.js.map +1 -0
- package/dist/map-canvas/index.css +11 -0
- package/dist/map-canvas/index.css.map +1 -0
- package/dist/map-canvas/index.js +20 -0
- package/dist/map-canvas/index.js.map +1 -0
- package/dist/map-canvas/map-canvas.css +11 -0
- package/dist/map-canvas/map-canvas.css.map +1 -0
- package/dist/map-canvas/map-canvas.js +17 -0
- package/dist/map-canvas/map-canvas.js.map +1 -0
- package/dist/map-canvas/map-context.js +11 -0
- package/dist/map-canvas/map-context.js.map +1 -0
- package/dist/map-canvas/use-resolved-basemap-theme.js +9 -0
- package/dist/map-canvas/use-resolved-basemap-theme.js.map +1 -0
- package/dist/map-cluster-layer/index.js +12 -0
- package/dist/map-cluster-layer/index.js.map +1 -0
- package/dist/map-cluster-layer/map-cluster-layer.js +12 -0
- package/dist/map-cluster-layer/map-cluster-layer.js.map +1 -0
- package/dist/map-controls/index.js +11 -0
- package/dist/map-controls/index.js.map +1 -0
- package/dist/map-controls/map-controls.js +11 -0
- package/dist/map-controls/map-controls.js.map +1 -0
- package/dist/map-geojson/index.js +13 -0
- package/dist/map-geojson/index.js.map +1 -0
- package/dist/map-geojson/map-geojson.js +13 -0
- package/dist/map-geojson/map-geojson.js.map +1 -0
- package/dist/map-inset/index.css +11 -0
- package/dist/map-inset/index.css.map +1 -0
- package/dist/map-inset/index.js +20 -0
- package/dist/map-inset/index.js.map +1 -0
- package/dist/map-inset/map-inset.css +11 -0
- package/dist/map-inset/map-inset.css.map +1 -0
- package/dist/map-inset/map-inset.js +20 -0
- package/dist/map-inset/map-inset.js.map +1 -0
- package/dist/map-legend/index.js +14 -0
- package/dist/map-legend/index.js.map +1 -0
- package/dist/map-legend/map-corner.js +10 -0
- package/dist/map-legend/map-corner.js.map +1 -0
- package/dist/map-legend/map-legend.js +11 -0
- package/dist/map-legend/map-legend.js.map +1 -0
- package/dist/map-marker/index.js +22 -0
- package/dist/map-marker/index.js.map +1 -0
- package/dist/map-marker/map-marker.js +22 -0
- package/dist/map-marker/map-marker.js.map +1 -0
- package/dist/map-north-arrow/index.js +11 -0
- package/dist/map-north-arrow/index.js.map +1 -0
- package/dist/map-north-arrow/map-north-arrow.js +11 -0
- package/dist/map-north-arrow/map-north-arrow.js.map +1 -0
- package/dist/map-plan/index.js +30 -0
- package/dist/map-plan/index.js.map +1 -0
- package/dist/map-plan/map-plan-legend.js +10 -0
- package/dist/map-plan/map-plan-legend.js.map +1 -0
- package/dist/map-plan/map-plan-overlay.js +13 -0
- package/dist/map-plan/map-plan-overlay.js.map +1 -0
- package/dist/map-plan/map-plan-status.js +9 -0
- package/dist/map-plan/map-plan-status.js.map +1 -0
- package/dist/map-plan/map-plan-table.js +10 -0
- package/dist/map-plan/map-plan-table.js.map +1 -0
- package/dist/map-plan/use-plan-selection.js +9 -0
- package/dist/map-plan/use-plan-selection.js.map +1 -0
- package/dist/map-plan-image/index.js +11 -0
- package/dist/map-plan-image/index.js.map +1 -0
- package/dist/map-plan-image/map-plan-image.js +11 -0
- package/dist/map-plan-image/map-plan-image.js.map +1 -0
- package/dist/map-popup/index.js +12 -0
- package/dist/map-popup/index.js.map +1 -0
- package/dist/map-popup/map-popup.js +12 -0
- package/dist/map-popup/map-popup.js.map +1 -0
- package/dist/map-route/index.js +12 -0
- package/dist/map-route/index.js.map +1 -0
- package/dist/map-route/map-route.js +12 -0
- package/dist/map-route/map-route.js.map +1 -0
- package/dist/map-scale-bar/index.js +18 -0
- package/dist/map-scale-bar/index.js.map +1 -0
- package/dist/map-scale-bar/map-scale-bar.js +18 -0
- package/dist/map-scale-bar/map-scale-bar.js.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +83 -0
- package/src/lib/arc-math.test.ts +19 -0
- package/src/lib/arc-math.ts +6 -1
- package/src/lib/map-position.ts +77 -0
- package/src/lib/merge-hover-paint.ts +44 -13
- package/src/lib/plan-crs.parity.test.ts +39 -0
- package/src/lib/plan-crs.test.ts +248 -0
- package/src/lib/plan-crs.ts +252 -0
- package/src/lib/plan-patterns.test.ts +255 -0
- package/src/lib/plan-patterns.ts +160 -0
- package/src/lib/plan-regions.test.ts +186 -0
- package/src/lib/plan-regions.ts +209 -0
- package/src/lib/plan-status.test.ts +87 -0
- package/src/lib/plan-status.ts +147 -0
- package/src/lib/route-arrow.test.ts +88 -0
- package/src/lib/route-arrow.ts +59 -0
- package/src/lib/use-map-breakpoint.test.ts +83 -0
- package/src/lib/use-map-breakpoint.ts +238 -0
- package/src/lib/use-plan-projection.ts +157 -0
- package/src/lib/warn-once.ts +21 -0
- package/src/map-annotation/anchor.ts +92 -0
- package/src/map-annotation/index.ts +15 -0
- package/src/map-annotation/map-annotation.stories.tsx +113 -0
- package/src/map-annotation/map-annotation.test.tsx +106 -0
- package/src/map-annotation/map-annotation.tsx +356 -0
- package/src/map-arc/map-arc.tsx +26 -5
- package/src/map-canvas/map-canvas.stories.tsx +211 -0
- package/src/map-canvas/map-canvas.test.tsx +389 -0
- package/src/map-canvas/map-canvas.tsx +457 -17
- package/src/map-canvas/map-context.ts +11 -0
- package/src/map-cluster-layer/map-cluster-layer.tsx +14 -1
- package/src/map-controls/map-controls.stories.tsx +16 -0
- package/src/map-controls/map-controls.test.tsx +145 -1
- package/src/map-controls/map-controls.tsx +57 -12
- package/src/map-geojson/map-geojson.stories.tsx +153 -3
- package/src/map-geojson/map-geojson.test.tsx +453 -0
- package/src/map-geojson/map-geojson.tsx +591 -51
- package/src/map-inset/index.ts +1 -0
- package/src/map-inset/map-inset.stories.tsx +61 -0
- package/src/map-inset/map-inset.test.tsx +117 -0
- package/src/map-inset/map-inset.tsx +264 -0
- package/src/map-legend/index.ts +10 -0
- package/src/map-legend/map-corner.ts +23 -0
- package/src/map-legend/map-legend.stories.tsx +171 -0
- package/src/map-legend/map-legend.test.tsx +136 -0
- package/src/map-legend/map-legend.tsx +264 -0
- package/src/map-marker/map-marker.stories.tsx +136 -4
- package/src/map-marker/map-marker.test.tsx +328 -3
- package/src/map-marker/map-marker.tsx +370 -33
- package/src/map-north-arrow/index.ts +1 -0
- package/src/map-north-arrow/map-north-arrow.stories.tsx +65 -0
- package/src/map-north-arrow/map-north-arrow.test.tsx +39 -0
- package/src/map-north-arrow/map-north-arrow.tsx +82 -0
- package/src/map-plan/index.ts +13 -0
- package/src/map-plan/map-plan-legend.test.tsx +61 -0
- package/src/map-plan/map-plan-legend.tsx +83 -0
- package/src/map-plan/map-plan-overlay.stories.tsx +260 -0
- package/src/map-plan/map-plan-overlay.test.tsx +302 -0
- package/src/map-plan/map-plan-overlay.tsx +531 -0
- package/src/map-plan/map-plan-status.test.tsx +71 -0
- package/src/map-plan/map-plan-status.tsx +77 -0
- package/src/map-plan/map-plan-table.test.tsx +86 -0
- package/src/map-plan/map-plan-table.tsx +153 -0
- package/src/map-plan/use-plan-selection.ts +78 -0
- package/src/map-plan-image/index.ts +1 -0
- package/src/map-plan-image/map-plan-image.stories.tsx +301 -0
- package/src/map-plan-image/map-plan-image.test.tsx +176 -0
- package/src/map-plan-image/map-plan-image.tsx +279 -0
- package/src/map-popup/map-popup.tsx +15 -10
- package/src/map-route/index.ts +1 -1
- package/src/map-route/map-route.test.tsx +216 -0
- package/src/map-route/map-route.tsx +105 -6
- package/src/map-scale-bar/index.ts +9 -0
- package/src/map-scale-bar/map-scale-bar.stories.tsx +63 -0
- package/src/map-scale-bar/map-scale-bar.test.tsx +65 -0
- package/src/map-scale-bar/map-scale-bar.tsx +145 -0
- package/src/test-utils/locator-fixture.ts +75 -0
- package/src/test-utils/maplibre-mock.ts +256 -19
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createRouteArrowImage, ROUTE_ARROW_SIZE, routeArrowImageId } from "./route-arrow";
|
|
4
|
+
|
|
5
|
+
interface Stroked {
|
|
6
|
+
points: [number, number][];
|
|
7
|
+
strokeStyle: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A 2D context that records the path instead of rasterizing it. */
|
|
11
|
+
function stubCanvasContext() {
|
|
12
|
+
const drawn: Stroked = { points: [], strokeStyle: "" };
|
|
13
|
+
|
|
14
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (kind: string) {
|
|
15
|
+
if (kind !== "2d") return null;
|
|
16
|
+
const context = {
|
|
17
|
+
set strokeStyle(value: string) {
|
|
18
|
+
drawn.strokeStyle = value;
|
|
19
|
+
},
|
|
20
|
+
get strokeStyle() {
|
|
21
|
+
return drawn.strokeStyle;
|
|
22
|
+
},
|
|
23
|
+
lineWidth: 0,
|
|
24
|
+
lineCap: "butt",
|
|
25
|
+
lineJoin: "miter",
|
|
26
|
+
clearRect: () => {},
|
|
27
|
+
beginPath: () => {},
|
|
28
|
+
moveTo: (x: number, y: number) => drawn.points.push([x, y]),
|
|
29
|
+
lineTo: (x: number, y: number) => drawn.points.push([x, y]),
|
|
30
|
+
stroke: () => {},
|
|
31
|
+
getImageData: (_x: number, _y: number, width: number, height: number) => ({
|
|
32
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
return context as unknown as CanvasRenderingContext2D;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return drawn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("routeArrowImageId", () => {
|
|
46
|
+
it("is one image per route, since the ink is per route", () => {
|
|
47
|
+
expect(routeArrowImageId("aisle")).toBe("route-arrow-aisle");
|
|
48
|
+
expect(routeArrowImageId("aisle")).not.toBe(routeArrowImageId("conveyor"));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("createRouteArrowImage", () => {
|
|
53
|
+
it("returns null with no 2D context, so the caller leaves the layer out", () => {
|
|
54
|
+
// jsdom, unstubbed: an arrow layer pointing at a missing image draws nothing
|
|
55
|
+
// and warns every frame, so the caller must be able to tell.
|
|
56
|
+
expect(createRouteArrowImage("#000")).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("draws a chevron pointing along +x, in the ink it was given", () => {
|
|
60
|
+
const drawn = stubCanvasContext();
|
|
61
|
+
|
|
62
|
+
const image = createRouteArrowImage("rgb(4, 5, 6)");
|
|
63
|
+
|
|
64
|
+
expect(image).toEqual({
|
|
65
|
+
width: ROUTE_ARROW_SIZE,
|
|
66
|
+
height: ROUTE_ARROW_SIZE,
|
|
67
|
+
data: new Uint8ClampedArray(ROUTE_ARROW_SIZE * ROUTE_ARROW_SIZE * 4),
|
|
68
|
+
});
|
|
69
|
+
expect(drawn.strokeStyle).toBe("rgb(4, 5, 6)");
|
|
70
|
+
|
|
71
|
+
// Three points: back-top, tip, back-bottom. The tip is the rightmost point
|
|
72
|
+
// and sits on the middle row — MapLibre aligns a line-placed symbol's x axis
|
|
73
|
+
// with the line, so +x is "forward".
|
|
74
|
+
expect(drawn.points).toHaveLength(3);
|
|
75
|
+
const [top, tip, bottom] = drawn.points as [
|
|
76
|
+
[number, number],
|
|
77
|
+
[number, number],
|
|
78
|
+
[number, number],
|
|
79
|
+
];
|
|
80
|
+
expect(tip[0]).toBeGreaterThan(top[0]);
|
|
81
|
+
expect(tip[0]).toBeGreaterThan(bottom[0]);
|
|
82
|
+
expect(tip[1]).toBeCloseTo(ROUTE_ARROW_SIZE / 2, 5);
|
|
83
|
+
expect(top[1]).toBeLessThan(tip[1]);
|
|
84
|
+
expect(bottom[1]).toBeGreaterThan(tip[1]);
|
|
85
|
+
// Symmetric about the middle row, so the chevron is not lopsided.
|
|
86
|
+
expect(top[1] + bottom[1]).toBeCloseTo(ROUTE_ARROW_SIZE, 5);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The direction chevron for `<MapRoute direction>`, drawn on a canvas and
|
|
3
|
+
* registered with `map.addImage`.
|
|
4
|
+
*
|
|
5
|
+
* An icon rather than a text glyph on purpose: a blank MapLibre style ships no
|
|
6
|
+
* glyph endpoint, so a `symbol` layer's `text-field` renders nothing at all —
|
|
7
|
+
* while `icon-image` needs no network and no sprite.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Tile edge, in px. Drawn at `pixelRatio: 2`, so it reads ~12 px on screen. */
|
|
11
|
+
export const ROUTE_ARROW_SIZE = 24;
|
|
12
|
+
|
|
13
|
+
/** Image id for one route's chevron. Per route, because the ink is per route. */
|
|
14
|
+
export function routeArrowImageId(routeId: string): string {
|
|
15
|
+
return `route-arrow-${routeId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A raw RGBA image MapLibre accepts, without depending on the DOM's `ImageData`. */
|
|
19
|
+
export interface RouteArrowImage {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
data: Uint8ClampedArray;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A chevron pointing along +x. MapLibre aligns a line-placed symbol's x axis
|
|
27
|
+
* with the line's direction, so +x is "forward"; `icon-rotate: 180` is backward.
|
|
28
|
+
*
|
|
29
|
+
* Returns `null` where there is no 2D context (jsdom) — the caller then leaves
|
|
30
|
+
* the arrow layer out rather than pointing it at a missing image, which
|
|
31
|
+
* MapLibre would draw as nothing while warning on every frame.
|
|
32
|
+
*/
|
|
33
|
+
export function createRouteArrowImage(ink: string): RouteArrowImage | null {
|
|
34
|
+
if (typeof document === "undefined") return null;
|
|
35
|
+
|
|
36
|
+
const canvas = document.createElement("canvas");
|
|
37
|
+
canvas.width = ROUTE_ARROW_SIZE;
|
|
38
|
+
canvas.height = ROUTE_ARROW_SIZE;
|
|
39
|
+
const context = canvas.getContext("2d");
|
|
40
|
+
if (!context) return null;
|
|
41
|
+
|
|
42
|
+
const size = ROUTE_ARROW_SIZE;
|
|
43
|
+
const inset = size * 0.22;
|
|
44
|
+
const mid = size / 2;
|
|
45
|
+
|
|
46
|
+
context.clearRect(0, 0, size, size);
|
|
47
|
+
context.strokeStyle = ink;
|
|
48
|
+
context.lineWidth = size * 0.16;
|
|
49
|
+
context.lineCap = "round";
|
|
50
|
+
context.lineJoin = "round";
|
|
51
|
+
context.beginPath();
|
|
52
|
+
context.moveTo(inset, inset);
|
|
53
|
+
context.lineTo(size - inset, mid);
|
|
54
|
+
context.lineTo(inset, size - inset);
|
|
55
|
+
context.stroke();
|
|
56
|
+
|
|
57
|
+
const image = context.getImageData(0, 0, size, size);
|
|
58
|
+
return { width: size, height: size, data: image.data };
|
|
59
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAP_HEIGHT,
|
|
5
|
+
MAP_BREAKPOINT_THRESHOLDS,
|
|
6
|
+
mapBreakpointForWidth,
|
|
7
|
+
resolveMapHeightStyle,
|
|
8
|
+
resolveMapResponsive,
|
|
9
|
+
} from "./use-map-breakpoint";
|
|
10
|
+
|
|
11
|
+
describe("map breakpoints (a documented copy of the charts tiers)", () => {
|
|
12
|
+
it("uses the charts thresholds: narrow < 480, medium < 768", () => {
|
|
13
|
+
expect(MAP_BREAKPOINT_THRESHOLDS).toEqual({ narrow: 480, medium: 768 });
|
|
14
|
+
expect(mapBreakpointForWidth(348)).toBe("narrow");
|
|
15
|
+
expect(mapBreakpointForWidth(479.5)).toBe("narrow");
|
|
16
|
+
expect(mapBreakpointForWidth(480)).toBe("medium");
|
|
17
|
+
expect(mapBreakpointForWidth(568)).toBe("medium");
|
|
18
|
+
expect(mapBreakpointForWidth(768)).toBe("wide");
|
|
19
|
+
expect(mapBreakpointForWidth(868)).toBe("wide");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("treats an unmeasured width as wide", () => {
|
|
23
|
+
expect(mapBreakpointForWidth(0)).toBe("wide");
|
|
24
|
+
expect(mapBreakpointForWidth(Number.NaN)).toBe("wide");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("resolves per-tier values desktop-first", () => {
|
|
28
|
+
const value = { base: "a", medium: "b", narrow: "c" };
|
|
29
|
+
expect(resolveMapResponsive(value, "wide")).toBe("a");
|
|
30
|
+
expect(resolveMapResponsive(value, "medium")).toBe("b");
|
|
31
|
+
expect(resolveMapResponsive(value, "narrow")).toBe("c");
|
|
32
|
+
expect(resolveMapResponsive({ base: true, narrow: false }, "medium")).toBe(true);
|
|
33
|
+
expect(resolveMapResponsive({ base: true, narrow: false }, "narrow")).toBe(false);
|
|
34
|
+
expect(resolveMapResponsive(false, "narrow")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("defaults the height to 1.6 : 1, square at narrow", () => {
|
|
38
|
+
expect(DEFAULT_MAP_HEIGHT).toEqual({ base: { aspect: 1.6 }, narrow: { aspect: 1 } });
|
|
39
|
+
expect(resolveMapHeightStyle(undefined, "wide")).toEqual({
|
|
40
|
+
aspectRatio: "1.6 / 1",
|
|
41
|
+
transitionProperty: "none",
|
|
42
|
+
});
|
|
43
|
+
expect(resolveMapHeightStyle(undefined, "narrow")).toEqual({
|
|
44
|
+
aspectRatio: "1 / 1",
|
|
45
|
+
transitionProperty: "none",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// The engine measures this box. A transitioned height/aspect only lands on
|
|
50
|
+
// the next animation frame — and the reduced-motion clamp gives EVERY element
|
|
51
|
+
// a 0.01 ms duration, so "no motion" is exactly where the map used to be
|
|
52
|
+
// measured mid-change and fit its bounds to a box that never existed.
|
|
53
|
+
it("never lets the box's geometry animate, whichever branch sets it", () => {
|
|
54
|
+
for (const style of [
|
|
55
|
+
resolveMapHeightStyle(undefined, "narrow"),
|
|
56
|
+
resolveMapHeightStyle(420, "narrow"),
|
|
57
|
+
resolveMapHeightStyle({ aspect: 2 }, "wide"),
|
|
58
|
+
]) {
|
|
59
|
+
expect(style.transitionProperty).toBe("none");
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("lets an explicit height win, per tier", () => {
|
|
64
|
+
expect(resolveMapHeightStyle(420, "narrow")).toEqual({
|
|
65
|
+
height: 420,
|
|
66
|
+
transitionProperty: "none",
|
|
67
|
+
});
|
|
68
|
+
expect(resolveMapHeightStyle({ base: { aspect: 2 }, narrow: 360 }, "wide")).toEqual({
|
|
69
|
+
height: "auto",
|
|
70
|
+
aspectRatio: "2 / 1",
|
|
71
|
+
transitionProperty: "none",
|
|
72
|
+
});
|
|
73
|
+
expect(resolveMapHeightStyle({ base: { aspect: 2 }, narrow: 360 }, "narrow")).toEqual({
|
|
74
|
+
height: 360,
|
|
75
|
+
transitionProperty: "none",
|
|
76
|
+
});
|
|
77
|
+
// An invalid height falls back to the default instead of collapsing.
|
|
78
|
+
expect(resolveMapHeightStyle(-5, "wide")).toEqual({
|
|
79
|
+
aspectRatio: "1.6 / 1",
|
|
80
|
+
transitionProperty: "none",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The map's responsive contract: container tiers, `MapResponsive<T>` and the
|
|
5
|
+
* frame context `<MapCanvas>` provides to its furniture (RM-125).
|
|
6
|
+
*
|
|
7
|
+
* A DELIBERATE COPY of `@elabs-ai/components-charts`' `chart-breakpoint.ts`
|
|
8
|
+
* (ADR 0039), with the same thresholds and the same desktop-first
|
|
9
|
+
* `{ base, medium?, narrow? }` shape. `maps` may not import `charts` (both are
|
|
10
|
+
* layer-2 leaves — `pnpm check --rule dep-direction`), so the few lines live
|
|
11
|
+
* here too. Change a threshold in both files or in neither.
|
|
12
|
+
*
|
|
13
|
+
* - Three container tiers, measured on the MAP's own width, never the
|
|
14
|
+
* viewport: `narrow` (< 480 px), `medium` (< 768 px), `wide`.
|
|
15
|
+
* - `MapResponsive<T>`: one value, or `{ base, medium?, narrow? }` — an
|
|
16
|
+
* override applies at its tier and every narrower tier that sets nothing.
|
|
17
|
+
* A plain value structurally matches `charts`' `Responsive<T>`, so one
|
|
18
|
+
* object can drive a chart and a map.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
type CSSProperties,
|
|
23
|
+
createContext,
|
|
24
|
+
useContext,
|
|
25
|
+
useEffect,
|
|
26
|
+
useLayoutEffect,
|
|
27
|
+
useState,
|
|
28
|
+
} from "react";
|
|
29
|
+
|
|
30
|
+
// ── Tiers ────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** A map container's width tier — the same three tiers as a chart's. */
|
|
33
|
+
export type MapBreakpoint = "narrow" | "medium" | "wide";
|
|
34
|
+
|
|
35
|
+
/** Every tier, narrowest first. */
|
|
36
|
+
export const MAP_BREAKPOINTS: readonly MapBreakpoint[] = ["narrow", "medium", "wide"];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A width (CSS px) below `narrow` is narrow; below `medium` is medium; anything
|
|
40
|
+
* else is wide. A boundary belongs to the wider tier (480 → medium). Same
|
|
41
|
+
* numbers as `CHART_BREAKPOINT_THRESHOLDS`.
|
|
42
|
+
*/
|
|
43
|
+
export const MAP_BREAKPOINT_THRESHOLDS = { narrow: 480, medium: 768 } as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The tier for a measured container width. `0` or a non-finite width means
|
|
47
|
+
* "not measured yet" (server render, first pass, `display: none`) and
|
|
48
|
+
* resolves to `wide`, so a first paint matches the pre-tier layout.
|
|
49
|
+
*/
|
|
50
|
+
export function mapBreakpointForWidth(width: number): MapBreakpoint {
|
|
51
|
+
if (!Number.isFinite(width) || width <= 0) return "wide";
|
|
52
|
+
if (width < MAP_BREAKPOINT_THRESHOLDS.narrow) return "narrow";
|
|
53
|
+
if (width < MAP_BREAKPOINT_THRESHOLDS.medium) return "medium";
|
|
54
|
+
return "wide";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── MapResponsive<T> ─────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-tier values, desktop-first: `base` is the WIDE value (the opposite of
|
|
61
|
+
* Tailwind's unprefixed, mobile-first class); overrides go down.
|
|
62
|
+
*/
|
|
63
|
+
export interface MapResponsiveByBreakpoint<T> {
|
|
64
|
+
/** The value at `wide`, and the fallback for every tier that sets nothing. */
|
|
65
|
+
base: T;
|
|
66
|
+
/** At `medium` — and at `narrow` too, unless `narrow` is set. */
|
|
67
|
+
medium?: T;
|
|
68
|
+
/** At `narrow` only. */
|
|
69
|
+
narrow?: T;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One value for every tier, or per-tier values. `T` must never be an object
|
|
74
|
+
* type with its own `base` key (that key marks the per-tier form). Read it
|
|
75
|
+
* through {@link resolveMapResponsive} / {@link useMapResponsive}.
|
|
76
|
+
*/
|
|
77
|
+
export type MapResponsive<T> = T | MapResponsiveByBreakpoint<T>;
|
|
78
|
+
|
|
79
|
+
/** True for the per-tier form: a plain object with its own `base` key. */
|
|
80
|
+
export function isMapResponsiveByBreakpoint<T>(
|
|
81
|
+
value: MapResponsive<T>,
|
|
82
|
+
): value is MapResponsiveByBreakpoint<T> {
|
|
83
|
+
return (
|
|
84
|
+
typeof value === "object" &&
|
|
85
|
+
value !== null &&
|
|
86
|
+
!Array.isArray(value) &&
|
|
87
|
+
Object.prototype.hasOwnProperty.call(value, "base")
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The value for `breakpoint`: `wide` → `base`; `medium` → `medium ?? base`;
|
|
93
|
+
* `narrow` → `narrow ?? medium ?? base`. "Not set" means `undefined`.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveMapResponsive<T>(value: MapResponsive<T>, breakpoint: MapBreakpoint): T {
|
|
96
|
+
if (!isMapResponsiveByBreakpoint(value)) return value as T;
|
|
97
|
+
if (breakpoint === "wide") return value.base;
|
|
98
|
+
if (breakpoint === "narrow" && value.narrow !== undefined) return value.narrow;
|
|
99
|
+
return value.medium !== undefined ? value.medium : value.base;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Height ───────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** CSS px, or the map's width ÷ height (`{ aspect: 2 }` = twice as wide as tall). */
|
|
105
|
+
export type MapHeight = number | { aspect: number };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The height a `<MapCanvas>` falls back to when its parent gives it none:
|
|
109
|
+
* 1.6 : 1, and square at `narrow` — an aspect-locked map at 380 px would
|
|
110
|
+
* otherwise shrink to a strip nobody can read.
|
|
111
|
+
*/
|
|
112
|
+
export const DEFAULT_MAP_HEIGHT: MapResponsiveByBreakpoint<MapHeight> = {
|
|
113
|
+
base: { aspect: 1.6 },
|
|
114
|
+
narrow: { aspect: 1 },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function validHeight(value: MapHeight | undefined): MapHeight | undefined {
|
|
118
|
+
if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
119
|
+
if (value && Number.isFinite(value.aspect) && value.aspect > 0) return value;
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The map box's geometry is LAYOUT THE ENGINE MEASURES, so it must never
|
|
125
|
+
* interpolate. `transition-property` defaults to `all`, and the house
|
|
126
|
+
* reduced-motion clamp sets `transition-duration: 0.01ms !important` on every
|
|
127
|
+
* element — which turns a height / aspect-ratio change into a (very short)
|
|
128
|
+
* transition that still lands one animation frame later. Under a busy WebGL
|
|
129
|
+
* first paint that frame arrived ~0.7 s late, so MapLibre measured the box
|
|
130
|
+
* mid-transition (348×218 on a box that ends up 348×348) and fitted `bounds`
|
|
131
|
+
* to a rectangle that never existed. `none` beats any stylesheet from the
|
|
132
|
+
* inline style, and the clamp only forces DURATIONS, never the property list.
|
|
133
|
+
*/
|
|
134
|
+
const NO_GEOMETRY_TRANSITION = { transitionProperty: "none" } as const;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The container style for a `height` at `breakpoint`. An explicit `height`
|
|
138
|
+
* sets the box outright (`height: auto` releases the `h-full` class for an
|
|
139
|
+
* aspect). Without one, only an `aspect-ratio` is set — CSS ignores it while
|
|
140
|
+
* the parent gives the map a definite height, so a map in a sized parent
|
|
141
|
+
* looks exactly as before; a map in an unsized parent stops collapsing to 0.
|
|
142
|
+
* Every branch pins {@link NO_GEOMETRY_TRANSITION} so the box resizes in the
|
|
143
|
+
* same frame the rule changes.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveMapHeightStyle(
|
|
146
|
+
height: MapResponsive<MapHeight> | undefined,
|
|
147
|
+
breakpoint: MapBreakpoint,
|
|
148
|
+
): CSSProperties {
|
|
149
|
+
const own =
|
|
150
|
+
height === undefined ? undefined : validHeight(resolveMapResponsive(height, breakpoint));
|
|
151
|
+
if (typeof own === "number") return { ...NO_GEOMETRY_TRANSITION, height: own };
|
|
152
|
+
if (own) return { ...NO_GEOMETRY_TRANSITION, height: "auto", aspectRatio: `${own.aspect} / 1` };
|
|
153
|
+
const fallback = resolveMapResponsive(DEFAULT_MAP_HEIGHT, breakpoint);
|
|
154
|
+
return typeof fallback === "number"
|
|
155
|
+
? { ...NO_GEOMETRY_TRANSITION }
|
|
156
|
+
: { ...NO_GEOMETRY_TRANSITION, aspectRatio: `${fallback.aspect} / 1` };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Measuring ────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Measures `node` with a `ResizeObserver` and returns its tier. Re-renders
|
|
163
|
+
* only when the TIER changes.
|
|
164
|
+
*/
|
|
165
|
+
export function useMeasuredMapBreakpoint(node: Element | null): MapBreakpoint {
|
|
166
|
+
const [breakpoint, setBreakpoint] = useState<MapBreakpoint>("wide");
|
|
167
|
+
// Layout effect: the first measure lands before paint, so a narrow map never
|
|
168
|
+
// flashes its wide furniture.
|
|
169
|
+
useLayoutEffect(() => {
|
|
170
|
+
if (!node) return undefined;
|
|
171
|
+
const update = (width: number) => {
|
|
172
|
+
const next = mapBreakpointForWidth(width);
|
|
173
|
+
setBreakpoint((prev) => (prev === next ? prev : next));
|
|
174
|
+
};
|
|
175
|
+
update(node.getBoundingClientRect().width);
|
|
176
|
+
if (typeof ResizeObserver === "undefined") return undefined;
|
|
177
|
+
const observer = new ResizeObserver((entries) => {
|
|
178
|
+
const entry = entries[entries.length - 1];
|
|
179
|
+
if (entry) update(entry.contentRect.width);
|
|
180
|
+
});
|
|
181
|
+
observer.observe(node);
|
|
182
|
+
return () => observer.disconnect();
|
|
183
|
+
}, [node]);
|
|
184
|
+
return breakpoint;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Frame context ────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/** Where furniture outside the map box goes: the strip above or below it. */
|
|
190
|
+
export type MapFrameSide = "above" | "below";
|
|
191
|
+
|
|
192
|
+
export interface MapFrameContextValue {
|
|
193
|
+
/** The map container's measured tier. */
|
|
194
|
+
breakpoint: MapBreakpoint;
|
|
195
|
+
/** `false` when the canvas is static (`<MapCanvas interactive={false}>`). */
|
|
196
|
+
interactive: boolean;
|
|
197
|
+
/** The strips around the map box; `null` until something asks for one. */
|
|
198
|
+
slots: Record<MapFrameSide, HTMLElement | null>;
|
|
199
|
+
/** Ask the canvas to render a strip; returns the release function. */
|
|
200
|
+
requestSlot: (side: MapFrameSide) => () => void;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const noopRelease = () => {};
|
|
204
|
+
|
|
205
|
+
const DEFAULT_FRAME: MapFrameContextValue = {
|
|
206
|
+
breakpoint: "wide",
|
|
207
|
+
interactive: true,
|
|
208
|
+
slots: { above: null, below: null },
|
|
209
|
+
requestSlot: () => noopRelease,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const MapFrameContext = createContext<MapFrameContextValue>(DEFAULT_FRAME);
|
|
213
|
+
|
|
214
|
+
/** The frame `<MapCanvas>` provides: tier, static flag, outside strips. */
|
|
215
|
+
export function useMapFrame(): MapFrameContextValue {
|
|
216
|
+
return useContext(MapFrameContext);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The enclosing map's tier (`wide` outside a `<MapCanvas>`). */
|
|
220
|
+
export function useMapBreakpoint(): MapBreakpoint {
|
|
221
|
+
return useContext(MapFrameContext).breakpoint;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `value` resolved at the enclosing map's tier. */
|
|
225
|
+
export function useMapResponsive<T>(value: MapResponsive<T>): T {
|
|
226
|
+
return resolveMapResponsive(value, useMapBreakpoint());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The strip above / below the map box to portal into, or `null` while it is
|
|
231
|
+
* not needed (`side` is `null`) or not mounted yet. Asking renders the strip;
|
|
232
|
+
* the canvas drops it again when nothing asks.
|
|
233
|
+
*/
|
|
234
|
+
export function useMapFrameSlot(side: MapFrameSide | null): HTMLElement | null {
|
|
235
|
+
const { slots, requestSlot } = useContext(MapFrameContext);
|
|
236
|
+
useEffect(() => (side ? requestSlot(side) : undefined), [side, requestSlot]);
|
|
237
|
+
return side ? slots[side] : null;
|
|
238
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type MapLibreGL from "maplibre-gl";
|
|
4
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
5
|
+
|
|
6
|
+
import type { PlanCrs } from "./plan-crs";
|
|
7
|
+
import type { MapPlanRegion } from "./plan-regions";
|
|
8
|
+
|
|
9
|
+
/** Where a region ended up on screen, in CSS pixels relative to the canvas. */
|
|
10
|
+
export interface PlanScreenBox {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UsePlanProjectionOptions {
|
|
18
|
+
map: MapLibreGL.Map | null;
|
|
19
|
+
plan: PlanCrs | null;
|
|
20
|
+
/**
|
|
21
|
+
* The regions to track. Keep this array MEMOIZED: it is an effect dependency,
|
|
22
|
+
* and a fresh array every render re-binds the map listeners every render.
|
|
23
|
+
*/
|
|
24
|
+
regions: readonly MapPlanRegion[];
|
|
25
|
+
/** Turn tracking off (while the map is still loading, say). */
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
/** Called after each frame's DOM writes, with the boxes that were written. */
|
|
28
|
+
onFrame?: (boxes: ReadonlyMap<string, PlanScreenBox>) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ONE projection pass for every plan overlay element, coalesced into a single
|
|
33
|
+
* `requestAnimationFrame` and writing straight to the DOM — no React state per
|
|
34
|
+
* frame, no per-element listener. A MapLibre `Marker` binds its own `move`
|
|
35
|
+
* listener and projects itself, so forty rooms would mean eighty listeners and
|
|
36
|
+
* forty uncoordinated writes; this is one listener set and one write pass.
|
|
37
|
+
*
|
|
38
|
+
* Each region is projected from its FOUR plan corners to a screen box, so the
|
|
39
|
+
* box stays right even where the projection is not a plain scale.
|
|
40
|
+
*/
|
|
41
|
+
export function usePlanProjection({
|
|
42
|
+
map,
|
|
43
|
+
plan,
|
|
44
|
+
regions,
|
|
45
|
+
enabled = true,
|
|
46
|
+
onFrame,
|
|
47
|
+
}: UsePlanProjectionOptions) {
|
|
48
|
+
const elementsRef = useRef(new Map<string, HTMLElement>());
|
|
49
|
+
const boxesRef = useRef(new Map<string, PlanScreenBox>());
|
|
50
|
+
const frameRef = useRef<number | null>(null);
|
|
51
|
+
const latestRef = useRef({ map, plan, regions, enabled, onFrame });
|
|
52
|
+
latestRef.current = { map, plan, regions, enabled, onFrame };
|
|
53
|
+
|
|
54
|
+
/** The whole write pass. Reads refs only, so it never needs re-creating. */
|
|
55
|
+
const write = useCallback(() => {
|
|
56
|
+
frameRef.current = null;
|
|
57
|
+
const {
|
|
58
|
+
map: currentMap,
|
|
59
|
+
plan: currentPlan,
|
|
60
|
+
regions: currentRegions,
|
|
61
|
+
enabled: isEnabled,
|
|
62
|
+
onFrame: notify,
|
|
63
|
+
} = latestRef.current;
|
|
64
|
+
if (!currentMap || !currentPlan || !isEnabled) return;
|
|
65
|
+
|
|
66
|
+
const boxes = boxesRef.current;
|
|
67
|
+
boxes.clear();
|
|
68
|
+
|
|
69
|
+
for (const region of currentRegions) {
|
|
70
|
+
const element = elementsRef.current.get(region.id);
|
|
71
|
+
const { x, y, width, height } = region.bounds;
|
|
72
|
+
const corners: [number, number][] = [
|
|
73
|
+
[x, y],
|
|
74
|
+
[x + width, y],
|
|
75
|
+
[x + width, y + height],
|
|
76
|
+
[x, y + height],
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
80
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
81
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
82
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
83
|
+
for (const [cornerX, cornerY] of corners) {
|
|
84
|
+
const point = currentMap.project(currentPlan.toLngLat({ x: cornerX, y: cornerY }));
|
|
85
|
+
if (point.x < minX) minX = point.x;
|
|
86
|
+
if (point.y < minY) minY = point.y;
|
|
87
|
+
if (point.x > maxX) maxX = point.x;
|
|
88
|
+
if (point.y > maxY) maxY = point.y;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const box: PlanScreenBox = {
|
|
92
|
+
x: minX,
|
|
93
|
+
y: minY,
|
|
94
|
+
width: Math.max(1, maxX - minX),
|
|
95
|
+
height: Math.max(1, maxY - minY),
|
|
96
|
+
};
|
|
97
|
+
boxes.set(region.id, box);
|
|
98
|
+
|
|
99
|
+
if (!element) continue;
|
|
100
|
+
// Physical `left`/`top`, deliberately: `map.project` returns screen
|
|
101
|
+
// pixels, which do not mirror in RTL — a logical inset would put every
|
|
102
|
+
// region on the wrong side of the canvas.
|
|
103
|
+
element.style.left = "0px";
|
|
104
|
+
element.style.top = "0px";
|
|
105
|
+
element.style.width = `${Math.round(box.width)}px`;
|
|
106
|
+
element.style.height = `${Math.round(box.height)}px`;
|
|
107
|
+
element.style.transform = `translate(${Math.round(box.x)}px, ${Math.round(box.y)}px)`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
notify?.(boxes);
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
/** Ask for a frame. Repeated calls inside one frame collapse into one pass. */
|
|
114
|
+
const refresh = useCallback(() => {
|
|
115
|
+
if (!latestRef.current.enabled) return;
|
|
116
|
+
if (frameRef.current != null) return;
|
|
117
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
118
|
+
write();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
frameRef.current = requestAnimationFrame(write);
|
|
122
|
+
}, [write]);
|
|
123
|
+
|
|
124
|
+
/** `ref` callback for a region's element. */
|
|
125
|
+
const register = useCallback(
|
|
126
|
+
(id: string, element: HTMLElement | null) => {
|
|
127
|
+
if (element) elementsRef.current.set(id, element);
|
|
128
|
+
else elementsRef.current.delete(id);
|
|
129
|
+
refresh();
|
|
130
|
+
},
|
|
131
|
+
[refresh],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!map || !plan || !enabled) return;
|
|
136
|
+
|
|
137
|
+
// Write once synchronously, so the first paint already has the geometry
|
|
138
|
+
// rather than a stack of boxes at the origin.
|
|
139
|
+
write();
|
|
140
|
+
|
|
141
|
+
map.on("move", refresh);
|
|
142
|
+
map.on("moveend", refresh);
|
|
143
|
+
map.on("resize", refresh);
|
|
144
|
+
|
|
145
|
+
return () => {
|
|
146
|
+
map.off("move", refresh);
|
|
147
|
+
map.off("moveend", refresh);
|
|
148
|
+
map.off("resize", refresh);
|
|
149
|
+
if (frameRef.current != null) {
|
|
150
|
+
cancelAnimationFrame(frameRef.current);
|
|
151
|
+
frameRef.current = null;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}, [map, plan, enabled, regions, refresh, write]);
|
|
155
|
+
|
|
156
|
+
return { register, refresh, boxes: boxesRef };
|
|
157
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only warnings for the silent failure modes a map wrapper cannot type away
|
|
3
|
+
* — a plan coordinate handed to a geographic canvas, a missing `promoteId`, a
|
|
4
|
+
* GeoJSON URL that cannot be transformed. Mirrors `warnChartOnce` in
|
|
5
|
+
* `@elabs-ai/components-charts` (kept local: `dep-direction` forbids the import).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const warned = new Set<string>();
|
|
9
|
+
|
|
10
|
+
/** `console.warn` once per `key` per page load, and never in production. */
|
|
11
|
+
export function warnMapOnce(key: string, message: string): void {
|
|
12
|
+
if (process.env.NODE_ENV === "production") return;
|
|
13
|
+
if (warned.has(key)) return;
|
|
14
|
+
warned.add(key);
|
|
15
|
+
console.warn(`[maps] ${message}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Test-only: forget which warnings have already fired. */
|
|
19
|
+
export function resetMapWarnings(): void {
|
|
20
|
+
warned.clear();
|
|
21
|
+
}
|