@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 @@
|
|
|
1
|
+
export { MapInset, type MapInsetProps, type MapInsetKind } from "./map-inset";
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { LOCATOR_BOUNDS } from "../test-utils/locator-fixture";
|
|
6
|
+
import { MapInset } from "./map-inset";
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: "Maps/MapInset",
|
|
10
|
+
component: MapInset,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
parameters: { layout: "fullscreen" },
|
|
13
|
+
} satisfies Meta<typeof MapInset>;
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A globe inset marks where the main map sits in the world. It is a second,
|
|
19
|
+
* static map; the main view shows as a box (or a dot when the view is too
|
|
20
|
+
* small to draw). It shrinks at `narrow` and never takes more than 40 % of
|
|
21
|
+
* the map width.
|
|
22
|
+
*/
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
render: (args) => (
|
|
25
|
+
<div className="w-full p-4">
|
|
26
|
+
<MapCanvas
|
|
27
|
+
interactive={false}
|
|
28
|
+
bounds={LOCATOR_BOUNDS}
|
|
29
|
+
fitBoundsOptions={{ padding: { top: 152, right: 24, bottom: 24, left: 24 } }}
|
|
30
|
+
>
|
|
31
|
+
<MapInset {...args} />
|
|
32
|
+
</MapCanvas>
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
play: async ({ canvasElement }) => {
|
|
36
|
+
if (!canvasElement.querySelector(".maplibregl-canvas")) return;
|
|
37
|
+
await waitFor(() =>
|
|
38
|
+
expect(
|
|
39
|
+
canvasElement.querySelectorAll('[data-slot="map-inset"] .maplibregl-canvas'),
|
|
40
|
+
).toHaveLength(1),
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A zoomed-out flat map of the region instead of a globe, bottom-left. */
|
|
46
|
+
export const Region: Story = {
|
|
47
|
+
args: { kind: "region", position: "bottom-left" },
|
|
48
|
+
render: Default.render,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The remaining corners, at a fixed size. */
|
|
52
|
+
export const Corners: Story = {
|
|
53
|
+
render: () => (
|
|
54
|
+
<div className="w-full p-4">
|
|
55
|
+
<MapCanvas interactive={false} center={[-78.2, 43.7]} zoom={6}>
|
|
56
|
+
<MapInset position="top-left" size={96} />
|
|
57
|
+
<MapInset kind="region" position="bottom-right" size={96} />
|
|
58
|
+
</MapCanvas>
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { act, 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 { MapInset } from "./map-inset";
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup();
|
|
15
|
+
resetMaplibreMock();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("MapInset", () => {
|
|
19
|
+
it("renders a second, static globe canvas that marks the main view", async () => {
|
|
20
|
+
render(
|
|
21
|
+
<MapCanvas>
|
|
22
|
+
<MapInset />
|
|
23
|
+
</MapCanvas>,
|
|
24
|
+
);
|
|
25
|
+
await waitFor(() => expect(MockMap.instances).toHaveLength(2));
|
|
26
|
+
const inset = MockMap.instances[1]!;
|
|
27
|
+
expect(inset.options.dragPan).toBe(false);
|
|
28
|
+
expect(inset.options.scrollZoom).toBe(false);
|
|
29
|
+
await waitFor(() =>
|
|
30
|
+
expect([...inset.layers.keys()].some((id) => id.includes("map-inset-box"))).toBe(true),
|
|
31
|
+
);
|
|
32
|
+
const wrapper = document.querySelector<HTMLElement>('[data-slot="map-inset"]')!;
|
|
33
|
+
expect(wrapper).toHaveAttribute("aria-hidden", "true");
|
|
34
|
+
expect(wrapper).toHaveAttribute("data-kind", "globe");
|
|
35
|
+
expect(wrapper.style.maxWidth).toBe("40%");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("uses a zoomed-out mercator map for a region inset", async () => {
|
|
39
|
+
render(
|
|
40
|
+
<MapCanvas zoom={7}>
|
|
41
|
+
<MapInset kind="region" />
|
|
42
|
+
</MapCanvas>,
|
|
43
|
+
);
|
|
44
|
+
await waitFor(() => expect(MockMap.instances).toHaveLength(2));
|
|
45
|
+
expect(MockMap.instances[1]!.options.projection).toBeUndefined();
|
|
46
|
+
expect(document.querySelector('[data-slot="map-inset"]')).toHaveAttribute(
|
|
47
|
+
"data-kind",
|
|
48
|
+
"region",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("MapInset — a locator shape, not a second map (c-6)", () => {
|
|
54
|
+
/** The globe zoom the inset asked for on its last update. */
|
|
55
|
+
function lastGlobeZoom(inset: MockMap): number | undefined {
|
|
56
|
+
const call = [...inset.jumpToCalls].reverse().find((c) => typeof c?.zoom === "number");
|
|
57
|
+
return call?.zoom as number | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it("fills the frame with the globe at every latitude", async () => {
|
|
61
|
+
render(
|
|
62
|
+
<MapCanvas>
|
|
63
|
+
<MapInset kind="globe" />
|
|
64
|
+
</MapCanvas>,
|
|
65
|
+
);
|
|
66
|
+
await waitFor(() => expect(MockMap.instances).toHaveLength(2));
|
|
67
|
+
const main = MockMap.instances[0]!;
|
|
68
|
+
const inset = MockMap.instances[1]!;
|
|
69
|
+
// jsdom measures nothing: give the inset box the 96 px it has at `narrow`.
|
|
70
|
+
Object.defineProperty(inset.getContainer(), "clientWidth", { value: 96, configurable: true });
|
|
71
|
+
act(() => inset.emit("resize"));
|
|
72
|
+
|
|
73
|
+
/** Move the MAIN map and wait for the inset to re-frame its globe. */
|
|
74
|
+
async function globeZoomAt(lng: number, lat: number) {
|
|
75
|
+
const before = inset.jumpToCalls.length;
|
|
76
|
+
main.center = { lng, lat };
|
|
77
|
+
act(() => main.emit("moveend"));
|
|
78
|
+
await waitFor(() => expect(inset.jumpToCalls.length).toBeGreaterThan(before));
|
|
79
|
+
return lastGlobeZoom(inset);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const atToronto = await globeZoomAt(-79.38, 43.65);
|
|
83
|
+
const atEquator = await globeZoomAt(9.4, 0); // the Gulf of Guinea
|
|
84
|
+
|
|
85
|
+
// Before the fix the zoom carried a Mercator cos(latitude) factor, so the
|
|
86
|
+
// same globe came out log2(cos 43.65°) = 0.47 zoom levels smaller over
|
|
87
|
+
// Toronto than over the equator.
|
|
88
|
+
expect(atToronto).toBeCloseTo(Math.log2((0.9 * 96 * Math.PI) / 512), 6);
|
|
89
|
+
expect(atToronto).toBeCloseTo(atEquator!, 6);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("drops the basemap's own labels inside the frame by default", async () => {
|
|
93
|
+
const { rerender } = render(
|
|
94
|
+
<MapCanvas>
|
|
95
|
+
<MapInset kind="globe" basemapLabels />
|
|
96
|
+
</MapCanvas>,
|
|
97
|
+
);
|
|
98
|
+
await waitFor(() => expect(MockMap.instances).toHaveLength(2));
|
|
99
|
+
const inset = MockMap.instances[1]!;
|
|
100
|
+
inset.addLayer({
|
|
101
|
+
id: "place-continent",
|
|
102
|
+
type: "symbol",
|
|
103
|
+
layout: { "text-field": ["get", "name"] },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
rerender(
|
|
107
|
+
<MapCanvas>
|
|
108
|
+
<MapInset kind="globe" />
|
|
109
|
+
</MapCanvas>,
|
|
110
|
+
);
|
|
111
|
+
await waitFor(() =>
|
|
112
|
+
expect(inset.getLayoutProperty("place-continent", "visibility")).toBe("none"),
|
|
113
|
+
);
|
|
114
|
+
// The MAIN map keeps its own labels — the default is the inset's alone.
|
|
115
|
+
expect(MockMap.instances[0]!.layoutProperties.size).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useEffect, useId, useState, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
|
|
6
|
+
import { MapCanvas, type MapCanvasProps } from "../map-canvas/map-canvas";
|
|
7
|
+
import { useMap } from "../map-canvas/map-context";
|
|
8
|
+
import { type MapResponsive, useMapResponsive } from "../lib/use-map-breakpoint";
|
|
9
|
+
import { useTokenColor } from "../lib/use-token-color";
|
|
10
|
+
import { MAP_CORNER_CLASSES, type MapCorner } from "../map-legend/map-corner";
|
|
11
|
+
|
|
12
|
+
/** A globe with the main view marked, or a zoomed-out regional map around it. */
|
|
13
|
+
export type MapInsetKind = "globe" | "region";
|
|
14
|
+
|
|
15
|
+
export interface MapInsetProps extends HTMLAttributes<HTMLDivElement> {
|
|
16
|
+
/** Default `"globe"`. */
|
|
17
|
+
kind?: MapInsetKind;
|
|
18
|
+
/** Corner of the main map (default `"top-right"`). */
|
|
19
|
+
position?: MapCorner;
|
|
20
|
+
/**
|
|
21
|
+
* Side length in CSS px, optionally per tier (default
|
|
22
|
+
* `{ base: 128, narrow: 88 }`). Never more than 40 % of the main map's width.
|
|
23
|
+
*/
|
|
24
|
+
size?: MapResponsive<number>;
|
|
25
|
+
/** Basemap styles for the inset (default: the main map's defaults). */
|
|
26
|
+
styles?: MapCanvasProps["styles"];
|
|
27
|
+
/** A tile-less inset — pair it with your own layers via `styles`. */
|
|
28
|
+
blank?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Draw the basemap's own place labels inside the inset. Default `false`
|
|
31
|
+
* (c-6): a basemap's labels are sized for a full map, so in an 88–128 px
|
|
32
|
+
* frame they are sliced mid-word ("EUROP", "AMERIC") and read louder than
|
|
33
|
+
* the globe they caption. An inset is a locator SHAPE, not a second map.
|
|
34
|
+
*/
|
|
35
|
+
basemapLabels?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How many zoom levels a `region` inset sits below the main map. */
|
|
39
|
+
const REGION_ZOOM_OUT = 4;
|
|
40
|
+
/** Below this many px across, the main view is marked as a dot, not a box. */
|
|
41
|
+
const MIN_BOX_PX = 6;
|
|
42
|
+
|
|
43
|
+
interface MainView {
|
|
44
|
+
center: [number, number];
|
|
45
|
+
zoom: number;
|
|
46
|
+
bounds: [west: number, south: number, east: number, north: number];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readView(map: NonNullable<ReturnType<typeof useMap>["map"]>): MainView {
|
|
50
|
+
const center = map.getCenter();
|
|
51
|
+
const bounds = map.getBounds();
|
|
52
|
+
return {
|
|
53
|
+
center: [center.lng, center.lat],
|
|
54
|
+
zoom: map.getZoom(),
|
|
55
|
+
bounds: [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The zoom at which a globe fills ~90 % of a `width`-px box.
|
|
61
|
+
*
|
|
62
|
+
* Latitude-independent on purpose (c-6): the old `cos(latitude)` factor is
|
|
63
|
+
* Mercator's scale correction, and a globe projection does not stretch with
|
|
64
|
+
* latitude — it only made the sphere shrink the further the main map sat from
|
|
65
|
+
* the equator (a Toronto locator's globe came out ~28 % narrower than the same
|
|
66
|
+
* globe over the Gulf of Guinea).
|
|
67
|
+
*/
|
|
68
|
+
function globeZoom(width: number): number {
|
|
69
|
+
const scale = (0.9 * width * Math.PI) / 512;
|
|
70
|
+
return Math.log2(Math.max(scale, 1e-3));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Inside the inset canvas: follows the main view and marks its extent. */
|
|
74
|
+
function InsetFollower({ view, kind }: { view: MainView; kind: MapInsetKind }) {
|
|
75
|
+
const { map, isLoaded } = useMap();
|
|
76
|
+
const id = useId();
|
|
77
|
+
const sourceId = `map-inset-view-${id}`;
|
|
78
|
+
const lineId = `map-inset-box-${id}`;
|
|
79
|
+
const fillId = `map-inset-fill-${id}`;
|
|
80
|
+
const dotId = `map-inset-dot-${id}`;
|
|
81
|
+
const accent = useTokenColor("--primary");
|
|
82
|
+
const surface = useTokenColor("--background");
|
|
83
|
+
|
|
84
|
+
// The inset's own box is responsive: `size` resolves at the MAIN map's tier,
|
|
85
|
+
// which is measured one render after the inset canvas is built, so a narrow
|
|
86
|
+
// inset is created 128 px wide and settles at 88. A globe is zoomed to fill
|
|
87
|
+
// its box, so that zoom has to be recomputed when the box changes.
|
|
88
|
+
const [boxWidth, setBoxWidth] = useState(0);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!map) return undefined;
|
|
91
|
+
const update = () => setBoxWidth(map.getContainer().clientWidth);
|
|
92
|
+
update();
|
|
93
|
+
map.on("resize", update);
|
|
94
|
+
return () => {
|
|
95
|
+
map.off("resize", update);
|
|
96
|
+
};
|
|
97
|
+
}, [map]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!map || !isLoaded) return undefined;
|
|
101
|
+
map.addSource(sourceId, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
|
|
102
|
+
map.addLayer({
|
|
103
|
+
id: fillId,
|
|
104
|
+
type: "fill",
|
|
105
|
+
source: sourceId,
|
|
106
|
+
filter: ["==", ["geometry-type"], "Polygon"],
|
|
107
|
+
paint: { "fill-color": accent, "fill-opacity": 0.2 },
|
|
108
|
+
});
|
|
109
|
+
map.addLayer({
|
|
110
|
+
id: lineId,
|
|
111
|
+
type: "line",
|
|
112
|
+
source: sourceId,
|
|
113
|
+
filter: ["==", ["geometry-type"], "Polygon"],
|
|
114
|
+
paint: { "line-color": accent, "line-width": 1.5 },
|
|
115
|
+
});
|
|
116
|
+
map.addLayer({
|
|
117
|
+
id: dotId,
|
|
118
|
+
type: "circle",
|
|
119
|
+
source: sourceId,
|
|
120
|
+
filter: ["==", ["geometry-type"], "Point"],
|
|
121
|
+
paint: {
|
|
122
|
+
"circle-color": accent,
|
|
123
|
+
"circle-radius": 3.5,
|
|
124
|
+
"circle-stroke-color": surface,
|
|
125
|
+
"circle-stroke-width": 1,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return () => {
|
|
129
|
+
try {
|
|
130
|
+
for (const layer of [dotId, lineId, fillId])
|
|
131
|
+
if (map.getLayer(layer)) map.removeLayer(layer);
|
|
132
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
133
|
+
} catch {
|
|
134
|
+
// style may be mid-reload
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- layers created once per load; colours synced below
|
|
138
|
+
}, [map, isLoaded]);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!map || !isLoaded) return;
|
|
142
|
+
if (map.getLayer(fillId)) map.setPaintProperty(fillId, "fill-color", accent);
|
|
143
|
+
if (map.getLayer(lineId)) map.setPaintProperty(lineId, "line-color", accent);
|
|
144
|
+
if (map.getLayer(dotId)) {
|
|
145
|
+
map.setPaintProperty(dotId, "circle-color", accent);
|
|
146
|
+
map.setPaintProperty(dotId, "circle-stroke-color", surface);
|
|
147
|
+
}
|
|
148
|
+
}, [map, isLoaded, accent, surface, fillId, lineId, dotId]);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!map || !isLoaded) return;
|
|
152
|
+
const width = boxWidth || map.getContainer().clientWidth;
|
|
153
|
+
const zoom = kind === "globe" ? globeZoom(width) : Math.max(0, view.zoom - REGION_ZOOM_OUT);
|
|
154
|
+
map.jumpTo({ center: view.center, zoom });
|
|
155
|
+
|
|
156
|
+
const [west, south, east, north] = view.bounds;
|
|
157
|
+
const a = map.project([west, north]);
|
|
158
|
+
const b = map.project([east, south]);
|
|
159
|
+
const small = Math.abs(b.x - a.x) < MIN_BOX_PX && Math.abs(b.y - a.y) < MIN_BOX_PX;
|
|
160
|
+
const feature: GeoJSON.Feature = small
|
|
161
|
+
? { type: "Feature", properties: {}, geometry: { type: "Point", coordinates: view.center } }
|
|
162
|
+
: {
|
|
163
|
+
type: "Feature",
|
|
164
|
+
properties: {},
|
|
165
|
+
geometry: {
|
|
166
|
+
type: "Polygon",
|
|
167
|
+
coordinates: [
|
|
168
|
+
[
|
|
169
|
+
[west, south],
|
|
170
|
+
[east, south],
|
|
171
|
+
[east, north],
|
|
172
|
+
[west, north],
|
|
173
|
+
[west, south],
|
|
174
|
+
],
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const source = map.getSource(sourceId) as { setData?: (data: unknown) => void } | undefined;
|
|
179
|
+
source?.setData?.({ type: "FeatureCollection", features: [feature] });
|
|
180
|
+
}, [map, isLoaded, kind, view, sourceId, boxWidth]);
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* MapInset — a small locator map in a corner of the main map: a globe
|
|
187
|
+
* (`kind="globe"`) or a zoomed-out regional map (`kind="region"`), with the
|
|
188
|
+
* main map's current view marked (a box, or a dot when the box would be too
|
|
189
|
+
* small to see). It is a second, static `<MapCanvas>` that follows the main
|
|
190
|
+
* view; decorative, so hidden from assistive tech and never a tab stop.
|
|
191
|
+
* Render inside `<MapCanvas>`.
|
|
192
|
+
*/
|
|
193
|
+
export const MapInset = forwardRef<HTMLDivElement, MapInsetProps>(function MapInset(
|
|
194
|
+
{
|
|
195
|
+
kind = "globe",
|
|
196
|
+
position = "top-right",
|
|
197
|
+
size: sizeProp = { base: 128, narrow: 88 },
|
|
198
|
+
styles,
|
|
199
|
+
blank,
|
|
200
|
+
basemapLabels = false,
|
|
201
|
+
className,
|
|
202
|
+
style,
|
|
203
|
+
...props
|
|
204
|
+
},
|
|
205
|
+
ref,
|
|
206
|
+
) {
|
|
207
|
+
const { map } = useMap();
|
|
208
|
+
const size = Math.max(0, useMapResponsive(sizeProp));
|
|
209
|
+
const [view, setView] = useState<MainView | null>(() => (map ? readView(map) : null));
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!map) return undefined;
|
|
213
|
+
const update = () => setView(readView(map));
|
|
214
|
+
update();
|
|
215
|
+
map.on("moveend", update);
|
|
216
|
+
map.on("resize", update);
|
|
217
|
+
return () => {
|
|
218
|
+
map.off("moveend", update);
|
|
219
|
+
map.off("resize", update);
|
|
220
|
+
};
|
|
221
|
+
}, [map]);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div
|
|
225
|
+
ref={ref}
|
|
226
|
+
aria-hidden="true"
|
|
227
|
+
data-slot="map-inset"
|
|
228
|
+
data-kind={kind}
|
|
229
|
+
className={cn(
|
|
230
|
+
"pointer-events-none absolute z-10 overflow-hidden bg-background shadow-ring-sm",
|
|
231
|
+
kind === "globe" ? "rounded-full" : "rounded-md",
|
|
232
|
+
MAP_CORNER_CLASSES[position],
|
|
233
|
+
className,
|
|
234
|
+
)}
|
|
235
|
+
// `transitionProperty: none` for the same reason `<MapCanvas>` pins it: a
|
|
236
|
+
// transitioned width (the reduced-motion clamp gives EVERY element a
|
|
237
|
+
// 0.01 ms duration) only lands on the next animation frame, and the
|
|
238
|
+
// engine inside measures this box.
|
|
239
|
+
style={{
|
|
240
|
+
width: size,
|
|
241
|
+
maxWidth: "40%",
|
|
242
|
+
aspectRatio: "1 / 1",
|
|
243
|
+
transitionProperty: "none",
|
|
244
|
+
...style,
|
|
245
|
+
}}
|
|
246
|
+
{...props}
|
|
247
|
+
>
|
|
248
|
+
{view && (
|
|
249
|
+
<MapCanvas
|
|
250
|
+
interactive={false}
|
|
251
|
+
center={view.center}
|
|
252
|
+
zoom={kind === "globe" ? 0 : Math.max(0, view.zoom - REGION_ZOOM_OUT)}
|
|
253
|
+
minZoom={-2}
|
|
254
|
+
projection={kind === "globe" ? "globe" : undefined}
|
|
255
|
+
styles={styles}
|
|
256
|
+
blank={blank}
|
|
257
|
+
basemapLabels={basemapLabels}
|
|
258
|
+
>
|
|
259
|
+
<InsetFollower view={view} kind={kind} />
|
|
260
|
+
</MapCanvas>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four corners map furniture (legend, scale bar, north arrow, inset) can
|
|
3
|
+
* sit in, inside the map box. Chrome, not geography: the corners are LOGICAL
|
|
4
|
+
* (`start` / `end`), so "top-left" is the reading-start corner and mirrors in
|
|
5
|
+
* RTL like the rest of the page.
|
|
6
|
+
*/
|
|
7
|
+
export type MapCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
8
|
+
|
|
9
|
+
/** Every corner, clockwise from top-left (for stories and validation). */
|
|
10
|
+
export const MAP_CORNERS: readonly MapCorner[] = [
|
|
11
|
+
"top-left",
|
|
12
|
+
"top-right",
|
|
13
|
+
"bottom-right",
|
|
14
|
+
"bottom-left",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** The inset classes for each corner. */
|
|
18
|
+
export const MAP_CORNER_CLASSES: Record<MapCorner, string> = {
|
|
19
|
+
"top-left": "top-2 start-2",
|
|
20
|
+
"top-right": "top-2 end-2",
|
|
21
|
+
"bottom-left": "bottom-2 start-2",
|
|
22
|
+
"bottom-right": "bottom-2 end-2",
|
|
23
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, waitFor } from "storybook/test";
|
|
4
|
+
|
|
5
|
+
import { MapCanvas } from "../map-canvas";
|
|
6
|
+
import { LOCATOR_BOUNDS } from "../test-utils/locator-fixture";
|
|
7
|
+
import { MAP_CORNERS } from "./map-corner";
|
|
8
|
+
import { MapLegend, type MapLegendItem } from "./map-legend";
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
title: "Maps/MapLegend",
|
|
12
|
+
component: MapLegend,
|
|
13
|
+
tags: ["autodocs"],
|
|
14
|
+
parameters: { layout: "fullscreen" },
|
|
15
|
+
} satisfies Meta<typeof MapLegend>;
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj<typeof meta>;
|
|
18
|
+
|
|
19
|
+
const ITEMS: MapLegendItem[] = [
|
|
20
|
+
{ id: "lake", label: "Lake Ontario", color: "var(--chart-1)", shape: "square" },
|
|
21
|
+
{ id: "gta", label: "Greater Toronto Area", color: "var(--chart-2)", shape: "square" },
|
|
22
|
+
// Four rows, four colours (c-12): `var(--primary)` aliases `var(--chart-1)`
|
|
23
|
+
// in every shipped theme, so a "City" circle painted with it was the same
|
|
24
|
+
// colour as the "Lake Ontario" square — a distinction the marks can't carry.
|
|
25
|
+
{ id: "city", label: "City", color: "var(--chart-4)", shape: "circle" },
|
|
26
|
+
{ id: "ferry", label: "Ferry route", color: "var(--chart-3)", shape: "line" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const RENT_INDEX = [62, 71, 74, 80, 86, 91, 97, 104, 112, 118, 125, 131, 140];
|
|
30
|
+
|
|
31
|
+
function Frame({ children }: { children: ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="w-full p-4">
|
|
34
|
+
<MapCanvas interactive={false} bounds={LOCATOR_BOUNDS} fitBoundsOptions={{ padding: 32 }}>
|
|
35
|
+
{children}
|
|
36
|
+
</MapCanvas>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A key for markers and areas, laid out as a list over the top-left corner. */
|
|
42
|
+
export const Default: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<Frame>
|
|
45
|
+
<MapLegend title="Legend" position="top-left" items={ITEMS} />
|
|
46
|
+
</Frame>
|
|
47
|
+
),
|
|
48
|
+
play: async ({ canvasElement }) => {
|
|
49
|
+
// c-12: four rows, four colours. `var(--primary)` aliases `var(--chart-1)`
|
|
50
|
+
// in every shipped theme, so the "City" circle and the "Lake Ontario"
|
|
51
|
+
// square were painted the same — a distinction the marks could not carry,
|
|
52
|
+
// with only a 10 px shape to tell them apart.
|
|
53
|
+
const swatches = await waitFor(() => {
|
|
54
|
+
const found = [
|
|
55
|
+
...canvasElement.querySelectorAll<HTMLElement>('[data-slot="map-legend-swatch"]'),
|
|
56
|
+
];
|
|
57
|
+
expect(found).toHaveLength(ITEMS.length);
|
|
58
|
+
return found;
|
|
59
|
+
});
|
|
60
|
+
const colors = swatches.map((swatch) => getComputedStyle(swatch).backgroundColor);
|
|
61
|
+
await expect(new Set(colors).size).toBe(ITEMS.length);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The same key as a grid, under the map — for keys wider than a corner. */
|
|
66
|
+
export const GridBelow: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<Frame>
|
|
69
|
+
<MapLegend title="Legend" position="below" layout="grid" items={ITEMS} />
|
|
70
|
+
</Frame>
|
|
71
|
+
),
|
|
72
|
+
play: async ({ canvasElement }) => {
|
|
73
|
+
// c-5: `auto-fill` held 7 tracks of 113.7 px on the 868 px strip for these
|
|
74
|
+
// four keys, so "Greater Toronto Area" truncated (scrollWidth 123 vs
|
|
75
|
+
// clientWidth 98) with three EMPTY tracks beside it. `auto-fit` collapses
|
|
76
|
+
// the empty tracks, so the strip never carries more tracks than keys.
|
|
77
|
+
const list = await waitFor(() => {
|
|
78
|
+
const found = canvasElement.querySelector<HTMLElement>('[data-slot="map-legend-items"]');
|
|
79
|
+
expect(found).not.toBeNull();
|
|
80
|
+
return found!;
|
|
81
|
+
});
|
|
82
|
+
const tracks = getComputedStyle(list)
|
|
83
|
+
.gridTemplateColumns.split(" ")
|
|
84
|
+
.map((value) => Number.parseFloat(value));
|
|
85
|
+
const filled = tracks.filter((width) => width > 0);
|
|
86
|
+
await expect(filled.length).toBeLessThanOrEqual(ITEMS.length);
|
|
87
|
+
|
|
88
|
+
// On a strip wide enough to hold every key in one row, none of them
|
|
89
|
+
// truncates any more.
|
|
90
|
+
if (filled.length === ITEMS.length) {
|
|
91
|
+
for (const item of canvasElement.querySelectorAll('[data-slot="map-legend-item"]')) {
|
|
92
|
+
const label = item.querySelector("span:not([data-slot])") as HTMLElement;
|
|
93
|
+
await expect(label.scrollWidth).toBeLessThanOrEqual(label.clientWidth);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Above the map, as a list. */
|
|
100
|
+
export const Above: Story = {
|
|
101
|
+
render: () => (
|
|
102
|
+
<Frame>
|
|
103
|
+
<MapLegend title="Legend" position="above" layout="list" items={ITEMS} />
|
|
104
|
+
</Frame>
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A stepped ramp from the shared colour-scale helper: every swatch is a
|
|
110
|
+
* `var(--chart-seq-N)` reference, so the ramp follows the theme.
|
|
111
|
+
*/
|
|
112
|
+
export const SteppedRamp: Story = {
|
|
113
|
+
render: () => (
|
|
114
|
+
<Frame>
|
|
115
|
+
<MapLegend
|
|
116
|
+
title="Rent index (Toronto = 100)"
|
|
117
|
+
position="bottom-right"
|
|
118
|
+
scale={{ values: RENT_INDEX, spec: { type: "stepped", steps: 5 } }}
|
|
119
|
+
/>
|
|
120
|
+
</Frame>
|
|
121
|
+
),
|
|
122
|
+
play: async ({ canvasElement }) => {
|
|
123
|
+
if (!canvasElement.querySelector(".maplibregl-canvas")) return;
|
|
124
|
+
const steps = await waitFor(() => {
|
|
125
|
+
const found = canvasElement.querySelectorAll<HTMLElement>('[data-slot="map-legend-step"]');
|
|
126
|
+
expect(found).toHaveLength(5);
|
|
127
|
+
return found;
|
|
128
|
+
});
|
|
129
|
+
for (const step of steps) {
|
|
130
|
+
await expect(step.getAttribute("style") ?? "").toMatch(/var\(--chart-seq-\d\)/);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** A continuous ramp, drawn as a gradient of token references. */
|
|
136
|
+
export const ContinuousRamp: Story = {
|
|
137
|
+
render: () => (
|
|
138
|
+
<Frame>
|
|
139
|
+
<MapLegend
|
|
140
|
+
title="Rent index"
|
|
141
|
+
position="bottom-left"
|
|
142
|
+
scale={{ values: RENT_INDEX, spec: { type: "continuous" } }}
|
|
143
|
+
formatValue={(value) => `${value}`}
|
|
144
|
+
/>
|
|
145
|
+
</Frame>
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Every corner a key can sit in. */
|
|
150
|
+
export const Corners: Story = {
|
|
151
|
+
render: () => (
|
|
152
|
+
<Frame>
|
|
153
|
+
{MAP_CORNERS.map((corner) => (
|
|
154
|
+
<MapLegend
|
|
155
|
+
key={corner}
|
|
156
|
+
position={corner}
|
|
157
|
+
items={[{ id: corner, label: corner, color: "var(--chart-1)" }]}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</Frame>
|
|
161
|
+
),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Over a corner on wide maps; under the map at `narrow`, where it would cover data. */
|
|
165
|
+
export const ResponsivePosition: Story = {
|
|
166
|
+
render: () => (
|
|
167
|
+
<Frame>
|
|
168
|
+
<MapLegend title="Legend" position={{ base: "top-right", narrow: "below" }} items={ITEMS} />
|
|
169
|
+
</Frame>
|
|
170
|
+
),
|
|
171
|
+
};
|