@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,216 @@
|
|
|
1
|
+
import { cleanup, render, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
vi.mock("maplibre-gl", async () => {
|
|
5
|
+
const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
|
|
6
|
+
return createMaplibreMock();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
import { MockMap, resetMaplibreMock } from "../test-utils/maplibre-mock";
|
|
10
|
+
import { MapCanvas } from "../map-canvas";
|
|
11
|
+
import { createPlanCrs } from "../lib/plan-crs";
|
|
12
|
+
import { MapRoute } from "./map-route";
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
cleanup();
|
|
16
|
+
resetMaplibreMock();
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const PLAN = { width: 1000, height: 500 };
|
|
21
|
+
|
|
22
|
+
function lastMap() {
|
|
23
|
+
return MockMap.instances.at(-1)!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sourceData(map: MockMap) {
|
|
27
|
+
const source = [...map.sources.values()][0]!;
|
|
28
|
+
return source.setDataCalls.at(-1) as GeoJSON.Feature<GeoJSON.LineString> | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A 2D context stub, so the generated chevron exists in jsdom too. */
|
|
32
|
+
function stubCanvasContext() {
|
|
33
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (kind: string) {
|
|
34
|
+
if (kind !== "2d") return null;
|
|
35
|
+
return {
|
|
36
|
+
strokeStyle: "",
|
|
37
|
+
lineWidth: 0,
|
|
38
|
+
lineCap: "butt",
|
|
39
|
+
lineJoin: "miter",
|
|
40
|
+
clearRect: () => {},
|
|
41
|
+
beginPath: () => {},
|
|
42
|
+
moveTo: () => {},
|
|
43
|
+
lineTo: () => {},
|
|
44
|
+
stroke: () => {},
|
|
45
|
+
getImageData: (_x: number, _y: number, width: number, height: number) => ({
|
|
46
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
47
|
+
}),
|
|
48
|
+
} as unknown as CanvasRenderingContext2D;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("MapRoute", () => {
|
|
53
|
+
it("passes geographic coordinates through as they were given", async () => {
|
|
54
|
+
const coordinates: [number, number][] = [
|
|
55
|
+
[13.4, 52.5],
|
|
56
|
+
[2.35, 48.86],
|
|
57
|
+
];
|
|
58
|
+
render(
|
|
59
|
+
<MapCanvas>
|
|
60
|
+
<MapRoute coordinates={coordinates} />
|
|
61
|
+
</MapCanvas>,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(sourceData(lastMap())).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
expect(sourceData(lastMap())!.geometry.coordinates).toBe(coordinates);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("converts plan coordinates into the plan's own lng/lat", async () => {
|
|
71
|
+
const coordinates: [number, number][] = [
|
|
72
|
+
[0, 0],
|
|
73
|
+
[1000, 500],
|
|
74
|
+
];
|
|
75
|
+
render(
|
|
76
|
+
<MapCanvas plan={PLAN}>
|
|
77
|
+
<MapRoute coordinates={coordinates} />
|
|
78
|
+
</MapCanvas>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(sourceData(lastMap())).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const crs = createPlanCrs(PLAN);
|
|
86
|
+
expect(sourceData(lastMap())!.geometry.coordinates).toEqual([
|
|
87
|
+
crs.toLngLat({ x: 0, y: 0 }),
|
|
88
|
+
crs.toLngLat({ x: 1000, y: 500 }),
|
|
89
|
+
]);
|
|
90
|
+
// The caller's array is never mutated.
|
|
91
|
+
expect(coordinates[1]).toEqual([1000, 500]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("draws no arrows by default", async () => {
|
|
95
|
+
render(
|
|
96
|
+
<MapCanvas>
|
|
97
|
+
<MapRoute
|
|
98
|
+
coordinates={[
|
|
99
|
+
[0, 0],
|
|
100
|
+
[10, 10],
|
|
101
|
+
]}
|
|
102
|
+
/>
|
|
103
|
+
</MapCanvas>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(lastMap().layers.size).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
expect([...lastMap().layers.keys()].some((id) => id.startsWith("route-arrows-"))).toBe(false);
|
|
110
|
+
expect(lastMap().addImageCalls).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("registers the chevron and places it along the line when asked", async () => {
|
|
114
|
+
stubCanvasContext();
|
|
115
|
+
render(
|
|
116
|
+
<MapCanvas>
|
|
117
|
+
<MapRoute
|
|
118
|
+
id="aisle"
|
|
119
|
+
coordinates={[
|
|
120
|
+
[0, 0],
|
|
121
|
+
[10, 10],
|
|
122
|
+
]}
|
|
123
|
+
direction="forward"
|
|
124
|
+
/>
|
|
125
|
+
</MapCanvas>,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await waitFor(() => {
|
|
129
|
+
expect(lastMap().getLayer("route-arrows-aisle")).toBeDefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const [id, , options] = lastMap().addImageCalls.at(-1)!;
|
|
133
|
+
expect(id).toBe("route-arrow-aisle");
|
|
134
|
+
expect(options).toEqual({ pixelRatio: 2 });
|
|
135
|
+
|
|
136
|
+
const layer = lastMap().getLayer("route-arrows-aisle");
|
|
137
|
+
expect(layer.type).toBe("symbol");
|
|
138
|
+
expect(layer.layout).toMatchObject({
|
|
139
|
+
"symbol-placement": "line",
|
|
140
|
+
"icon-image": "route-arrow-aisle",
|
|
141
|
+
"icon-rotate": 0,
|
|
142
|
+
"icon-rotation-alignment": "map",
|
|
143
|
+
});
|
|
144
|
+
// An icon, never a `text-field`: a blank style has no glyph endpoint, so
|
|
145
|
+
// text on a symbol layer would silently render nothing.
|
|
146
|
+
expect(layer.layout["text-field"]).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("turns the chevron around for a backward route", async () => {
|
|
150
|
+
stubCanvasContext();
|
|
151
|
+
render(
|
|
152
|
+
<MapCanvas>
|
|
153
|
+
<MapRoute
|
|
154
|
+
id="belt"
|
|
155
|
+
coordinates={[
|
|
156
|
+
[0, 0],
|
|
157
|
+
[10, 10],
|
|
158
|
+
]}
|
|
159
|
+
direction="backward"
|
|
160
|
+
/>
|
|
161
|
+
</MapCanvas>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(lastMap().getLayer("route-arrows-belt")).toBeDefined();
|
|
166
|
+
});
|
|
167
|
+
expect(lastMap().getLayer("route-arrows-belt").layout["icon-rotate"]).toBe(180);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("leaves the arrow layer out when there is no canvas to draw it on", async () => {
|
|
171
|
+
// Unstubbed jsdom: no 2D context, so no image — and a layer pointing at a
|
|
172
|
+
// missing image would warn on every frame.
|
|
173
|
+
render(
|
|
174
|
+
<MapCanvas>
|
|
175
|
+
<MapRoute
|
|
176
|
+
id="aisle"
|
|
177
|
+
coordinates={[
|
|
178
|
+
[0, 0],
|
|
179
|
+
[10, 10],
|
|
180
|
+
]}
|
|
181
|
+
direction="forward"
|
|
182
|
+
/>
|
|
183
|
+
</MapCanvas>,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(lastMap().layers.size).toBeGreaterThan(0);
|
|
188
|
+
});
|
|
189
|
+
expect(lastMap().getLayer("route-arrows-aisle")).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("takes its arrow layer and image away again on unmount", async () => {
|
|
193
|
+
stubCanvasContext();
|
|
194
|
+
const { unmount } = render(
|
|
195
|
+
<MapCanvas>
|
|
196
|
+
<MapRoute
|
|
197
|
+
id="aisle"
|
|
198
|
+
coordinates={[
|
|
199
|
+
[0, 0],
|
|
200
|
+
[10, 10],
|
|
201
|
+
]}
|
|
202
|
+
direction="forward"
|
|
203
|
+
/>
|
|
204
|
+
</MapCanvas>,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(lastMap().getLayer("route-arrows-aisle")).toBeDefined();
|
|
209
|
+
});
|
|
210
|
+
const map = lastMap();
|
|
211
|
+
unmount();
|
|
212
|
+
|
|
213
|
+
expect(map.getLayer("route-arrows-aisle")).toBeUndefined();
|
|
214
|
+
expect(map.hasImage("route-arrow-aisle")).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type MapLibreGL from "maplibre-gl";
|
|
4
|
-
import { useEffect, useId } from "react";
|
|
4
|
+
import { useEffect, useId, useMemo } from "react";
|
|
5
5
|
|
|
6
6
|
import { useMap } from "../map-canvas/map-context";
|
|
7
7
|
import { useTokenColor } from "../lib/use-token-color";
|
|
8
|
+
import { createRouteArrowImage, routeArrowImageId } from "../lib/route-arrow";
|
|
9
|
+
|
|
10
|
+
/** Which way the route runs, drawn as chevrons along the line. */
|
|
11
|
+
export type MapRouteDirection = "none" | "forward" | "backward";
|
|
8
12
|
|
|
9
13
|
export interface MapRouteProps {
|
|
10
14
|
/** Optional unique identifier for the route layer. */
|
|
11
15
|
id?: string;
|
|
12
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Array of [longitude, latitude] coordinate pairs defining the route — or, in
|
|
18
|
+
* a `<MapCanvas plan>`, [x, y] pairs in plan units.
|
|
19
|
+
*/
|
|
13
20
|
coordinates: [number, number][];
|
|
14
21
|
/** Line color as a CSS color value. Defaults to the theme's `--primary` token. */
|
|
15
22
|
color?: string;
|
|
@@ -19,6 +26,14 @@ export interface MapRouteProps {
|
|
|
19
26
|
opacity?: number;
|
|
20
27
|
/** Dash pattern [dash length, gap length] for dashed lines. */
|
|
21
28
|
dashArray?: [number, number];
|
|
29
|
+
/**
|
|
30
|
+
* Draw chevrons along the line to show which way it runs (default: "none").
|
|
31
|
+
* A conveyor, a one-way aisle and a walking route all need this, and an arrow
|
|
32
|
+
* is a second channel that greyscale keeps.
|
|
33
|
+
*/
|
|
34
|
+
direction?: MapRouteDirection;
|
|
35
|
+
/** Distance between chevrons in px (default: 80). */
|
|
36
|
+
directionSpacing?: number;
|
|
22
37
|
/** Callback when the route line is clicked. */
|
|
23
38
|
onClick?: () => void;
|
|
24
39
|
/** Callback when the mouse enters the route line. */
|
|
@@ -37,20 +52,31 @@ export function MapRoute({
|
|
|
37
52
|
width = 3,
|
|
38
53
|
opacity = 0.8,
|
|
39
54
|
dashArray,
|
|
55
|
+
direction = "none",
|
|
56
|
+
directionSpacing = 80,
|
|
40
57
|
onClick,
|
|
41
58
|
onMouseEnter,
|
|
42
59
|
onMouseLeave,
|
|
43
60
|
interactive = true,
|
|
44
61
|
}: MapRouteProps) {
|
|
45
|
-
const { map, isLoaded } = useMap();
|
|
62
|
+
const { map, isLoaded, plan } = useMap();
|
|
46
63
|
const autoId = useId();
|
|
47
64
|
const id = propId ?? autoId;
|
|
48
65
|
const sourceId = `route-source-${id}`;
|
|
49
66
|
const layerId = `route-layer-${id}`;
|
|
67
|
+
const arrowLayerId = `route-arrows-${id}`;
|
|
68
|
+
const arrowImageId = routeArrowImageId(id);
|
|
50
69
|
|
|
51
70
|
const primary = useTokenColor("--primary");
|
|
52
71
|
const lineColor = color ?? primary;
|
|
53
72
|
|
|
73
|
+
// On a plan map the pairs are plan units; on a geographic one they are handed
|
|
74
|
+
// through untouched, identity included.
|
|
75
|
+
const resolvedCoordinates = useMemo(
|
|
76
|
+
() => (plan ? coordinates.map((point) => plan.toLngLat(point)) : coordinates),
|
|
77
|
+
[plan, coordinates],
|
|
78
|
+
);
|
|
79
|
+
|
|
54
80
|
// Add source and layer on mount.
|
|
55
81
|
useEffect(() => {
|
|
56
82
|
if (!isLoaded || !map) return;
|
|
@@ -90,17 +116,90 @@ export function MapRoute({
|
|
|
90
116
|
|
|
91
117
|
// When coordinates change, update the source data.
|
|
92
118
|
useEffect(() => {
|
|
93
|
-
if (!isLoaded || !map ||
|
|
119
|
+
if (!isLoaded || !map || resolvedCoordinates.length < 2) return;
|
|
94
120
|
|
|
95
121
|
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
|
|
96
122
|
if (source) {
|
|
97
123
|
source.setData({
|
|
98
124
|
type: "Feature",
|
|
99
125
|
properties: {},
|
|
100
|
-
geometry: { type: "LineString", coordinates },
|
|
126
|
+
geometry: { type: "LineString", coordinates: resolvedCoordinates },
|
|
101
127
|
});
|
|
102
128
|
}
|
|
103
|
-
}, [isLoaded, map,
|
|
129
|
+
}, [isLoaded, map, resolvedCoordinates, sourceId]);
|
|
130
|
+
|
|
131
|
+
// Direction chevrons: a generated icon, re-registered whenever the ink
|
|
132
|
+
// changes, with the layer added only once the image really exists.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!isLoaded || !map || direction === "none") return;
|
|
135
|
+
|
|
136
|
+
const image = createRouteArrowImage(lineColor);
|
|
137
|
+
if (!image) return;
|
|
138
|
+
|
|
139
|
+
let cancelled = false;
|
|
140
|
+
|
|
141
|
+
const register = () => {
|
|
142
|
+
try {
|
|
143
|
+
if (map.hasImage(arrowImageId)) {
|
|
144
|
+
map.updateImage(arrowImageId, image as unknown as ImageData);
|
|
145
|
+
} else {
|
|
146
|
+
map.addImage(arrowImageId, image as unknown as ImageData, { pixelRatio: 2 });
|
|
147
|
+
}
|
|
148
|
+
if (!map.getLayer(arrowLayerId)) {
|
|
149
|
+
map.addLayer({
|
|
150
|
+
id: arrowLayerId,
|
|
151
|
+
type: "symbol",
|
|
152
|
+
source: sourceId,
|
|
153
|
+
layout: {
|
|
154
|
+
"symbol-placement": "line",
|
|
155
|
+
"symbol-spacing": directionSpacing,
|
|
156
|
+
"icon-image": arrowImageId,
|
|
157
|
+
"icon-rotate": direction === "backward" ? 180 : 0,
|
|
158
|
+
"icon-rotation-alignment": "map",
|
|
159
|
+
"icon-allow-overlap": true,
|
|
160
|
+
"icon-ignore-placement": true,
|
|
161
|
+
},
|
|
162
|
+
paint: { "icon-opacity": opacity },
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
map.setLayoutProperty(arrowLayerId, "symbol-spacing", directionSpacing);
|
|
166
|
+
map.setLayoutProperty(arrowLayerId, "icon-rotate", direction === "backward" ? 180 : 0);
|
|
167
|
+
map.setPaintProperty(arrowLayerId, "icon-opacity", opacity);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// style mid-reload; `styledata` re-runs this
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
register();
|
|
175
|
+
// A theme swap calls setStyle, which drops every style image AND every layer
|
|
176
|
+
// this component added.
|
|
177
|
+
const onStyleData = () => {
|
|
178
|
+
if (!cancelled) register();
|
|
179
|
+
};
|
|
180
|
+
map.on("styledata", onStyleData);
|
|
181
|
+
|
|
182
|
+
return () => {
|
|
183
|
+
cancelled = true;
|
|
184
|
+
map.off("styledata", onStyleData);
|
|
185
|
+
try {
|
|
186
|
+
if (map.getLayer(arrowLayerId)) map.removeLayer(arrowLayerId);
|
|
187
|
+
if (map.hasImage(arrowImageId)) map.removeImage(arrowImageId);
|
|
188
|
+
} catch {
|
|
189
|
+
// style may be mid-reload
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}, [
|
|
193
|
+
isLoaded,
|
|
194
|
+
map,
|
|
195
|
+
direction,
|
|
196
|
+
directionSpacing,
|
|
197
|
+
lineColor,
|
|
198
|
+
opacity,
|
|
199
|
+
sourceId,
|
|
200
|
+
arrowLayerId,
|
|
201
|
+
arrowImageId,
|
|
202
|
+
]);
|
|
104
203
|
|
|
105
204
|
// Sync paint when styling (or the resolved theme color) changes.
|
|
106
205
|
useEffect(() => {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
3
|
+
|
|
4
|
+
import { MapCanvas } from "../map-canvas";
|
|
5
|
+
import { MapScaleBar } from "./map-scale-bar";
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: "Maps/MapScaleBar",
|
|
9
|
+
component: MapScaleBar,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
parameters: { layout: "fullscreen" },
|
|
12
|
+
} satisfies Meta<typeof MapScaleBar>;
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A kilometre bar at the bottom-left. Its length snaps to a round distance
|
|
18
|
+
* (1, 2, 5 × 10ⁿ) and is recomputed whenever the view or the map width
|
|
19
|
+
* changes — a narrow map gets a shorter bar.
|
|
20
|
+
*/
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
render: (args) => (
|
|
23
|
+
<div className="w-full p-4">
|
|
24
|
+
<MapCanvas interactive={false} center={[-78.2, 43.7]} zoom={6.5}>
|
|
25
|
+
<MapScaleBar {...args} />
|
|
26
|
+
</MapCanvas>
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
play: async ({ canvasElement }) => {
|
|
30
|
+
if (!canvasElement.querySelector(".maplibregl-canvas")) return;
|
|
31
|
+
const bar = await waitFor(() => {
|
|
32
|
+
const el = canvasElement.querySelector<HTMLElement>('[data-slot="map-scale-bar"]');
|
|
33
|
+
expect(el).not.toBeNull();
|
|
34
|
+
expect(Number(el!.dataset.distance)).toBeGreaterThan(0);
|
|
35
|
+
return el!;
|
|
36
|
+
});
|
|
37
|
+
await expect(bar).toHaveAttribute("data-unit", "kilometer");
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Miles, in the bottom-right corner. */
|
|
42
|
+
export const Miles: Story = {
|
|
43
|
+
args: { unit: "mi", position: "bottom-right" },
|
|
44
|
+
render: Default.render,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Zoomed in far enough that the bar switches to metres. */
|
|
48
|
+
export const Metres: Story = {
|
|
49
|
+
args: { position: "top-left" },
|
|
50
|
+
render: (args) => (
|
|
51
|
+
<div className="w-full p-4">
|
|
52
|
+
<MapCanvas interactive={false} center={[-79.38, 43.65]} zoom={15}>
|
|
53
|
+
<MapScaleBar {...args} />
|
|
54
|
+
</MapCanvas>
|
|
55
|
+
</div>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The last corner. */
|
|
60
|
+
export const TopRight: Story = {
|
|
61
|
+
args: { position: "top-right" },
|
|
62
|
+
render: Default.render,
|
|
63
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
vi.mock("maplibre-gl", async () => {
|
|
5
|
+
const { createMaplibreMock } = await import("../test-utils/maplibre-mock");
|
|
6
|
+
return createMaplibreMock();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
import { MockMap, resetMaplibreMock } from "../test-utils/maplibre-mock";
|
|
10
|
+
import { MapCanvas } from "../map-canvas";
|
|
11
|
+
import { MapScaleBar, computeMapScale, metresPerPixel, niceDistance } from "./map-scale-bar";
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup();
|
|
15
|
+
resetMaplibreMock();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("computeMapScale", () => {
|
|
19
|
+
it("measures Web Mercator metres per pixel", () => {
|
|
20
|
+
// 512 px world at zoom 0 on the equator.
|
|
21
|
+
expect(metresPerPixel(0, 0)).toBeCloseTo(40_075_016.686 / 512, 3);
|
|
22
|
+
expect(metresPerPixel(60, 1)).toBeCloseTo(40_075_016.686 / 512 / 4, 3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rounds to 1 / 2 / 5 × 10ⁿ", () => {
|
|
26
|
+
expect(niceDistance(87)).toBe(50);
|
|
27
|
+
expect(niceDistance(230)).toBe(200);
|
|
28
|
+
expect(niceDistance(1.4)).toBe(1);
|
|
29
|
+
expect(niceDistance(0)).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("recomputes the distance for a narrower bar and switches units", () => {
|
|
33
|
+
const wide = computeMapScale(43.6, 7, "km", 112);
|
|
34
|
+
const narrow = computeMapScale(43.6, 6.3, "km", 80);
|
|
35
|
+
expect(wide.unit).toBe("kilometer");
|
|
36
|
+
expect(narrow.value).not.toBe(wide.value);
|
|
37
|
+
expect(wide.px).toBeLessThanOrEqual(112);
|
|
38
|
+
expect(narrow.px).toBeLessThanOrEqual(80);
|
|
39
|
+
expect(computeMapScale(43.6, 16, "km", 100).unit).toBe("meter");
|
|
40
|
+
expect(computeMapScale(43.6, 7, "mi", 100).unit).toBe("mile");
|
|
41
|
+
expect(computeMapScale(43.6, 16, "mi", 100).unit).toBe("foot");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("MapScaleBar", () => {
|
|
46
|
+
it("renders a locale-formatted distance and updates as the map moves", async () => {
|
|
47
|
+
render(
|
|
48
|
+
<MapCanvas>
|
|
49
|
+
<MapScaleBar />
|
|
50
|
+
</MapCanvas>,
|
|
51
|
+
);
|
|
52
|
+
const bar = await screen.findByText(/km/);
|
|
53
|
+
const first = bar.textContent;
|
|
54
|
+
const map = MockMap.instances[0]!;
|
|
55
|
+
act(() => {
|
|
56
|
+
map.zoom = 4;
|
|
57
|
+
map.emit("move");
|
|
58
|
+
});
|
|
59
|
+
expect(screen.getByText(/km/).textContent).not.toBe(first);
|
|
60
|
+
expect(document.querySelector('[data-slot="map-scale-bar"]')).toHaveAttribute(
|
|
61
|
+
"data-unit",
|
|
62
|
+
"kilometer",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useEffect, useState, type HTMLAttributes } from "react";
|
|
4
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
5
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
|
+
|
|
7
|
+
import { useMap } from "../map-canvas/map-context";
|
|
8
|
+
import { useMapBreakpoint } from "../lib/use-map-breakpoint";
|
|
9
|
+
import { MAP_CORNER_CLASSES, type MapCorner } from "../map-legend/map-corner";
|
|
10
|
+
|
|
11
|
+
/** Metric (`km`, falling back to metres) or imperial (`mi`, falling back to feet). */
|
|
12
|
+
export type MapScaleUnit = "km" | "mi";
|
|
13
|
+
|
|
14
|
+
/** The Earth's equatorial circumference in metres (WGS 84), as MapLibre uses it. */
|
|
15
|
+
const EARTH_CIRCUMFERENCE_M = 40_075_016.686;
|
|
16
|
+
/** MapLibre's world size at zoom 0, in CSS px. */
|
|
17
|
+
const TILE_SIZE = 512;
|
|
18
|
+
const METRES_PER_MILE = 1609.344;
|
|
19
|
+
const METRES_PER_FOOT = 0.3048;
|
|
20
|
+
|
|
21
|
+
/** The longest the bar may draw, per tier (CSS px). */
|
|
22
|
+
const MAX_BAR_PX = { wide: 112, medium: 96, narrow: 80 } as const;
|
|
23
|
+
|
|
24
|
+
/** Metres one CSS px covers at `latitude` and `zoom` (Web Mercator). */
|
|
25
|
+
export function metresPerPixel(latitude: number, zoom: number): number {
|
|
26
|
+
return (EARTH_CIRCUMFERENCE_M * Math.cos((latitude * Math.PI) / 180)) / (TILE_SIZE * 2 ** zoom);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The largest 1 / 2 / 5 × 10ⁿ that is ≤ `max`. */
|
|
30
|
+
export function niceDistance(max: number): number {
|
|
31
|
+
if (!(max > 0) || !Number.isFinite(max)) return 0;
|
|
32
|
+
const magnitude = 10 ** Math.floor(Math.log10(max));
|
|
33
|
+
for (const step of [5, 2, 1]) {
|
|
34
|
+
if (step * magnitude <= max) return step * magnitude;
|
|
35
|
+
}
|
|
36
|
+
return magnitude;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MapScale {
|
|
40
|
+
/** The bar's length in CSS px. */
|
|
41
|
+
px: number;
|
|
42
|
+
/** The distance the bar stands for, in `unit`. */
|
|
43
|
+
value: number;
|
|
44
|
+
/** An `Intl.NumberFormat` unit identifier. */
|
|
45
|
+
unit: "kilometer" | "meter" | "mile" | "foot";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A round distance that fits in `maxPx` at this latitude and zoom, in `unit`
|
|
50
|
+
* — kilometres (metres below 1 km) or miles (feet below 1 mi).
|
|
51
|
+
*/
|
|
52
|
+
export function computeMapScale(
|
|
53
|
+
latitude: number,
|
|
54
|
+
zoom: number,
|
|
55
|
+
unit: MapScaleUnit,
|
|
56
|
+
maxPx: number,
|
|
57
|
+
): MapScale {
|
|
58
|
+
const perPx = metresPerPixel(latitude, zoom);
|
|
59
|
+
const maxMetres = perPx * maxPx;
|
|
60
|
+
const [big, small, bigM, smallM] =
|
|
61
|
+
unit === "mi"
|
|
62
|
+
? (["mile", "foot", METRES_PER_MILE, METRES_PER_FOOT] as const)
|
|
63
|
+
: (["kilometer", "meter", 1000, 1] as const);
|
|
64
|
+
const useBig = maxMetres >= bigM;
|
|
65
|
+
const perUnit = useBig ? bigM : smallM;
|
|
66
|
+
const value = niceDistance(maxMetres / perUnit);
|
|
67
|
+
const px = perPx > 0 ? (value * perUnit) / perPx : 0;
|
|
68
|
+
return { px: Math.max(0, px), value, unit: useBig ? big : small };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MapScaleBarProps extends HTMLAttributes<HTMLDivElement> {
|
|
72
|
+
/** `km` (default) or `mi`. */
|
|
73
|
+
unit?: MapScaleUnit;
|
|
74
|
+
/** Corner of the map (default `"bottom-left"`). */
|
|
75
|
+
position?: MapCorner;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A scale bar for the map's centre latitude — a round distance ("50 km") and
|
|
80
|
+
* a bar of that length, re-measured as the map moves or resizes. The bar gets
|
|
81
|
+
* shorter at `narrow`. Render inside `<MapCanvas>`.
|
|
82
|
+
*/
|
|
83
|
+
export const MapScaleBar = forwardRef<HTMLDivElement, MapScaleBarProps>(function MapScaleBar(
|
|
84
|
+
{ unit = "km", position = "bottom-left", className, ...props },
|
|
85
|
+
ref,
|
|
86
|
+
) {
|
|
87
|
+
const { map } = useMap();
|
|
88
|
+
const { formatNumber } = useLocale();
|
|
89
|
+
const maxPx = MAX_BAR_PX[useMapBreakpoint()];
|
|
90
|
+
const [scale, setScale] = useState<MapScale | null>(null);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!map) return undefined;
|
|
94
|
+
const update = () => {
|
|
95
|
+
const next = computeMapScale(map.getCenter().lat, map.getZoom(), unit, maxPx);
|
|
96
|
+
setScale((prev) =>
|
|
97
|
+
prev &&
|
|
98
|
+
prev.value === next.value &&
|
|
99
|
+
prev.unit === next.unit &&
|
|
100
|
+
Math.abs(prev.px - next.px) < 0.5
|
|
101
|
+
? prev
|
|
102
|
+
: next,
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
update();
|
|
106
|
+
map.on("move", update);
|
|
107
|
+
map.on("resize", update);
|
|
108
|
+
return () => {
|
|
109
|
+
map.off("move", update);
|
|
110
|
+
map.off("resize", update);
|
|
111
|
+
};
|
|
112
|
+
}, [map, unit, maxPx]);
|
|
113
|
+
|
|
114
|
+
if (!scale || scale.value <= 0) return null;
|
|
115
|
+
const label = formatNumber(scale.value, {
|
|
116
|
+
style: "unit",
|
|
117
|
+
unit: scale.unit,
|
|
118
|
+
unitDisplay: "short",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
ref={ref}
|
|
124
|
+
data-slot="map-scale-bar"
|
|
125
|
+
data-unit={scale.unit}
|
|
126
|
+
data-distance={scale.value}
|
|
127
|
+
className={cn(
|
|
128
|
+
"pointer-events-none absolute z-10 flex flex-col items-start gap-0.5 rounded-sm bg-background/80 px-1.5 py-1",
|
|
129
|
+
MAP_CORNER_CLASSES[position],
|
|
130
|
+
className,
|
|
131
|
+
)}
|
|
132
|
+
{...props}
|
|
133
|
+
>
|
|
134
|
+
<span data-slot="map-scale-bar-label" className="text-meta text-foreground tabular-nums">
|
|
135
|
+
{label}
|
|
136
|
+
</span>
|
|
137
|
+
<span
|
|
138
|
+
aria-hidden="true"
|
|
139
|
+
data-slot="map-scale-bar-bar"
|
|
140
|
+
className="block h-1.5 border-x-2 border-b-2 border-foreground"
|
|
141
|
+
style={{ width: Math.round(scale.px) }}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
});
|