@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,186 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
planBoundsOfGeometry,
|
|
5
|
+
planBoundsUnion,
|
|
6
|
+
planGroupId,
|
|
7
|
+
planGroupsFromRegions,
|
|
8
|
+
planRegionCentre,
|
|
9
|
+
planRegionsFromGeoJSON,
|
|
10
|
+
type MapPlanRegion,
|
|
11
|
+
} from "./plan-regions";
|
|
12
|
+
|
|
13
|
+
const rooms: GeoJSON.FeatureCollection<
|
|
14
|
+
GeoJSON.Geometry,
|
|
15
|
+
{ id: string; name: string; status: string }
|
|
16
|
+
> = {
|
|
17
|
+
type: "FeatureCollection",
|
|
18
|
+
features: [
|
|
19
|
+
{
|
|
20
|
+
type: "Feature",
|
|
21
|
+
properties: { id: "studio", name: "Studio", status: "free" },
|
|
22
|
+
geometry: {
|
|
23
|
+
type: "Polygon",
|
|
24
|
+
coordinates: [
|
|
25
|
+
[
|
|
26
|
+
[10, 20],
|
|
27
|
+
[210, 20],
|
|
28
|
+
[210, 120],
|
|
29
|
+
[10, 120],
|
|
30
|
+
[10, 20],
|
|
31
|
+
],
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: "Feature",
|
|
37
|
+
properties: { id: "aisle", name: "Aisle", status: "free" },
|
|
38
|
+
geometry: {
|
|
39
|
+
type: "LineString",
|
|
40
|
+
coordinates: [
|
|
41
|
+
[0, 400],
|
|
42
|
+
[1000, 420],
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe("planBoundsOfGeometry", () => {
|
|
50
|
+
it("boxes a polygon, a line and a nested multi-polygon alike", () => {
|
|
51
|
+
expect(planBoundsOfGeometry(rooms.features[0]!.geometry)).toEqual({
|
|
52
|
+
x: 10,
|
|
53
|
+
y: 20,
|
|
54
|
+
width: 200,
|
|
55
|
+
height: 100,
|
|
56
|
+
});
|
|
57
|
+
expect(planBoundsOfGeometry(rooms.features[1]!.geometry)).toEqual({
|
|
58
|
+
x: 0,
|
|
59
|
+
y: 400,
|
|
60
|
+
width: 1000,
|
|
61
|
+
height: 20,
|
|
62
|
+
});
|
|
63
|
+
expect(
|
|
64
|
+
planBoundsOfGeometry({
|
|
65
|
+
type: "MultiPolygon",
|
|
66
|
+
coordinates: [
|
|
67
|
+
[
|
|
68
|
+
[
|
|
69
|
+
[0, 0],
|
|
70
|
+
[10, 0],
|
|
71
|
+
[10, 10],
|
|
72
|
+
[0, 0],
|
|
73
|
+
],
|
|
74
|
+
],
|
|
75
|
+
[
|
|
76
|
+
[
|
|
77
|
+
[90, 90],
|
|
78
|
+
[100, 90],
|
|
79
|
+
[100, 100],
|
|
80
|
+
[90, 90],
|
|
81
|
+
],
|
|
82
|
+
],
|
|
83
|
+
],
|
|
84
|
+
}),
|
|
85
|
+
).toEqual({ x: 0, y: 0, width: 100, height: 100 });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("boxes a geometry collection across its members", () => {
|
|
89
|
+
expect(
|
|
90
|
+
planBoundsOfGeometry({
|
|
91
|
+
type: "GeometryCollection",
|
|
92
|
+
geometries: [
|
|
93
|
+
{ type: "Point", coordinates: [5, 5] },
|
|
94
|
+
{ type: "Point", coordinates: [45, 25] },
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
).toEqual({ x: 5, y: 5, width: 40, height: 20 });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("planRegionsFromGeoJSON", () => {
|
|
102
|
+
it("derives one region per feature, from property names", () => {
|
|
103
|
+
const regions = planRegionsFromGeoJSON(rooms, { id: "id", label: "name" });
|
|
104
|
+
|
|
105
|
+
expect(regions.map((region) => region.id)).toEqual(["studio", "aisle"]);
|
|
106
|
+
expect(regions[0]!.label).toBe("Studio");
|
|
107
|
+
expect(regions[0]!.bounds).toEqual({ x: 10, y: 20, width: 200, height: 100 });
|
|
108
|
+
expect(regions[0]!.description).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("takes a function for anything computed", () => {
|
|
112
|
+
const regions = planRegionsFromGeoJSON(rooms, {
|
|
113
|
+
id: "id",
|
|
114
|
+
label: "name",
|
|
115
|
+
description: (properties) => `${properties.status} now`,
|
|
116
|
+
group: () => "level-3",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(regions[0]!.description).toBe("free now");
|
|
120
|
+
expect(regions[1]!.group).toBe("level-3");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("planRegionCentre", () => {
|
|
125
|
+
it("returns the middle of a box, in plan units", () => {
|
|
126
|
+
expect(planRegionCentre({ x: 10, y: 20, width: 200, height: 100 })).toEqual({
|
|
127
|
+
x: 110,
|
|
128
|
+
y: 70,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const SEATS: MapPlanRegion[] = [
|
|
134
|
+
{
|
|
135
|
+
id: "a-1",
|
|
136
|
+
label: "1",
|
|
137
|
+
group: "coach-a",
|
|
138
|
+
groupLabel: "Coach A",
|
|
139
|
+
bounds: { x: 0, y: 0, width: 50, height: 40 },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "a-2",
|
|
143
|
+
label: "2",
|
|
144
|
+
group: "coach-a",
|
|
145
|
+
groupLabel: "Coach A",
|
|
146
|
+
bounds: { x: 60, y: 10, width: 50, height: 40 },
|
|
147
|
+
},
|
|
148
|
+
{ id: "toilet", label: "Toilet", bounds: { x: 200, y: 0, width: 30, height: 60 } },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
describe("planBoundsUnion", () => {
|
|
152
|
+
it("is the smallest box holding all of them", () => {
|
|
153
|
+
expect(planBoundsUnion(SEATS.map((seat) => seat.bounds))).toEqual({
|
|
154
|
+
x: 0,
|
|
155
|
+
y: 0,
|
|
156
|
+
width: 230,
|
|
157
|
+
height: 60,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("is null for nothing at all", () => {
|
|
162
|
+
expect(planBoundsUnion([])).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("planGroupsFromRegions", () => {
|
|
167
|
+
it("bundles regions by their group, in first-seen order", () => {
|
|
168
|
+
const groups = planGroupsFromRegions(SEATS);
|
|
169
|
+
|
|
170
|
+
expect(groups.map((group) => group.key)).toEqual(["coach-a", "toilet"]);
|
|
171
|
+
expect(groups[0]!.members.map((member) => member.id)).toEqual(["a-1", "a-2"]);
|
|
172
|
+
expect(groups[0]!.region.label).toBe("Coach A");
|
|
173
|
+
expect(groups[0]!.region.id).toBe(planGroupId("coach-a"));
|
|
174
|
+
// The group's own box spans its members, so its button covers all of them.
|
|
175
|
+
expect(groups[0]!.region.bounds).toEqual({ x: 0, y: 0, width: 110, height: 50 });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("makes an ungrouped region a group of one, so nothing disappears", () => {
|
|
179
|
+
const groups = planGroupsFromRegions(SEATS);
|
|
180
|
+
|
|
181
|
+
const loner = groups.find((group) => group.key === "toilet")!;
|
|
182
|
+
expect(loner.members).toHaveLength(1);
|
|
183
|
+
expect(loner.region.label).toBe("toilet");
|
|
184
|
+
expect(loner.region.bounds).toEqual(SEATS[2]!.bounds);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { PlanPoint } from "./plan-crs";
|
|
2
|
+
|
|
3
|
+
/** An axis-aligned rectangle in PLAN units. */
|
|
4
|
+
export interface PlanRegionBounds {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One interactive area of a plan — a room, a machine cell, a seat. The overlay
|
|
13
|
+
* needs a box and a name, not the geometry: a room's shape is drawn by WebGL,
|
|
14
|
+
* while the keyboard proxy, the label and the readout all work from its box.
|
|
15
|
+
*/
|
|
16
|
+
export interface MapPlanRegion {
|
|
17
|
+
/** Stable id. Must match the feature id the shape layer promotes. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** The visible label, and the start of the button's accessible name. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Shown instead of `label` when the box is too small for the full one. */
|
|
22
|
+
shortLabel?: string;
|
|
23
|
+
/** Appended to the accessible name — a status word, a seat count, an owner. */
|
|
24
|
+
description?: string;
|
|
25
|
+
/** The region's box, in plan units. */
|
|
26
|
+
bounds: PlanRegionBounds;
|
|
27
|
+
/** Group id, for plans with more regions than a keyboard can walk (a carriage, a wing). */
|
|
28
|
+
group?: string;
|
|
29
|
+
/** The group's own label, used by group mode. */
|
|
30
|
+
groupLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The centre of a region's box, in plan units. */
|
|
34
|
+
export function planRegionCentre(bounds: PlanRegionBounds): PlanPoint {
|
|
35
|
+
return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function walkCoordinates(input: unknown, visit: (x: number, y: number) => void): void {
|
|
39
|
+
if (!Array.isArray(input)) return;
|
|
40
|
+
if (typeof input[0] === "number" && typeof input[1] === "number") {
|
|
41
|
+
visit(input[0], input[1]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
for (const entry of input) walkCoordinates(entry, visit);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The bounding box of any geometry, in the units its coordinates are written in. */
|
|
48
|
+
export function planBoundsOfGeometry(geometry: GeoJSON.Geometry): PlanRegionBounds | null {
|
|
49
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
50
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
51
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
52
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
53
|
+
|
|
54
|
+
const visit = (x: number, y: number) => {
|
|
55
|
+
if (x < minX) minX = x;
|
|
56
|
+
if (y < minY) minY = y;
|
|
57
|
+
if (x > maxX) maxX = x;
|
|
58
|
+
if (y > maxY) maxY = y;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (geometry.type === "GeometryCollection") {
|
|
62
|
+
for (const member of geometry.geometries) {
|
|
63
|
+
walkCoordinates((member as { coordinates?: unknown }).coordinates, visit);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
walkCoordinates(geometry.coordinates, visit);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return null;
|
|
70
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A field read either from a feature property or computed from the properties. */
|
|
74
|
+
export type PlanRegionField<P, T> = string | ((properties: P, index: number) => T);
|
|
75
|
+
|
|
76
|
+
export interface PlanRegionFields<P extends GeoJSON.GeoJsonProperties> {
|
|
77
|
+
/** Property name, or a function, giving each region's id. */
|
|
78
|
+
id: PlanRegionField<P, string>;
|
|
79
|
+
/** Property name, or a function, giving each region's visible label. */
|
|
80
|
+
label: PlanRegionField<P, string>;
|
|
81
|
+
/** Optional short label for a small box. */
|
|
82
|
+
shortLabel?: PlanRegionField<P, string | undefined>;
|
|
83
|
+
/** Optional extra words for the accessible name. */
|
|
84
|
+
description?: PlanRegionField<P, string | undefined>;
|
|
85
|
+
/** Optional group id. */
|
|
86
|
+
group?: PlanRegionField<P, string | undefined>;
|
|
87
|
+
/** Optional group label. */
|
|
88
|
+
groupLabel?: PlanRegionField<P, string | undefined>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function read<P extends GeoJSON.GeoJsonProperties, T>(
|
|
92
|
+
field: PlanRegionField<P, T> | undefined,
|
|
93
|
+
properties: P,
|
|
94
|
+
index: number,
|
|
95
|
+
): T | undefined {
|
|
96
|
+
if (field === undefined) return undefined;
|
|
97
|
+
if (typeof field === "function") return field(properties, index);
|
|
98
|
+
return (properties as Record<string, unknown> | null)?.[field] as T | undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Derive the overlay's regions from the same GeoJSON the shape layer draws, so
|
|
103
|
+
* the keyboard proxy can never drift out of step with the picture.
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* const regions = planRegionsFromGeoJSON(rooms, { id: "id", label: "name" });
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export function planRegionsFromGeoJSON<P extends GeoJSON.GeoJsonProperties>(
|
|
110
|
+
data: GeoJSON.FeatureCollection<GeoJSON.Geometry, P>,
|
|
111
|
+
fields: PlanRegionFields<P>,
|
|
112
|
+
): MapPlanRegion[] {
|
|
113
|
+
const regions: MapPlanRegion[] = [];
|
|
114
|
+
|
|
115
|
+
data.features.forEach((feature, index) => {
|
|
116
|
+
const bounds = planBoundsOfGeometry(feature.geometry);
|
|
117
|
+
if (!bounds) return;
|
|
118
|
+
const properties = feature.properties;
|
|
119
|
+
const id =
|
|
120
|
+
read(fields.id, properties, index) ?? (feature.id != null ? String(feature.id) : null);
|
|
121
|
+
if (id == null) return;
|
|
122
|
+
|
|
123
|
+
const shortLabel = read(fields.shortLabel, properties, index);
|
|
124
|
+
const description = read(fields.description, properties, index);
|
|
125
|
+
const group = read(fields.group, properties, index);
|
|
126
|
+
const groupLabel = read(fields.groupLabel, properties, index);
|
|
127
|
+
|
|
128
|
+
regions.push({
|
|
129
|
+
id: String(id),
|
|
130
|
+
label: String(read(fields.label, properties, index) ?? id),
|
|
131
|
+
...(shortLabel !== undefined ? { shortLabel: String(shortLabel) } : {}),
|
|
132
|
+
...(description !== undefined ? { description: String(description) } : {}),
|
|
133
|
+
...(group !== undefined ? { group: String(group) } : {}),
|
|
134
|
+
...(groupLabel !== undefined ? { groupLabel: String(groupLabel) } : {}),
|
|
135
|
+
bounds,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return regions;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The id the overlay gives a group's own button, namespaced away from region ids. */
|
|
143
|
+
export function planGroupId(group: string): string {
|
|
144
|
+
return `group:${group}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One group of regions, with the synthetic region that stands for the whole of it. */
|
|
148
|
+
export interface MapPlanGroup {
|
|
149
|
+
/** The group's key, as it appeared on its regions. */
|
|
150
|
+
key: string;
|
|
151
|
+
/** A region covering every member's box — what group mode puts a button on. */
|
|
152
|
+
region: MapPlanRegion;
|
|
153
|
+
/** The group's regions, in reading order. */
|
|
154
|
+
members: readonly MapPlanRegion[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The smallest box containing all of them. `null` for an empty list. */
|
|
158
|
+
export function planBoundsUnion(boxes: readonly PlanRegionBounds[]): PlanRegionBounds | null {
|
|
159
|
+
if (boxes.length === 0) return null;
|
|
160
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
161
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
162
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
163
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
164
|
+
|
|
165
|
+
for (const box of boxes) {
|
|
166
|
+
if (box.x < minX) minX = box.x;
|
|
167
|
+
if (box.y < minY) minY = box.y;
|
|
168
|
+
if (box.x + box.width > maxX) maxX = box.x + box.width;
|
|
169
|
+
if (box.y + box.height > maxY) maxY = box.y + box.height;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Bundle regions by their `group`, in first-seen order.
|
|
177
|
+
*
|
|
178
|
+
* A region with no `group` becomes a group of one, keyed by its own id: group
|
|
179
|
+
* mode then still shows everything, instead of quietly dropping whatever was
|
|
180
|
+
* left ungrouped.
|
|
181
|
+
*/
|
|
182
|
+
export function planGroupsFromRegions(regions: readonly MapPlanRegion[]): MapPlanGroup[] {
|
|
183
|
+
const byKey = new Map<string, MapPlanRegion[]>();
|
|
184
|
+
|
|
185
|
+
for (const region of regions) {
|
|
186
|
+
const key = region.group ?? region.id;
|
|
187
|
+
const members = byKey.get(key);
|
|
188
|
+
if (members) members.push(region);
|
|
189
|
+
else byKey.set(key, [region]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const groups: MapPlanGroup[] = [];
|
|
193
|
+
for (const [key, members] of byKey) {
|
|
194
|
+
const bounds = planBoundsUnion(members.map((member) => member.bounds));
|
|
195
|
+
if (!bounds) continue;
|
|
196
|
+
const first = members[0]!;
|
|
197
|
+
groups.push({
|
|
198
|
+
key,
|
|
199
|
+
region: {
|
|
200
|
+
id: planGroupId(key),
|
|
201
|
+
label: first.groupLabel ?? key,
|
|
202
|
+
bounds,
|
|
203
|
+
},
|
|
204
|
+
members,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return groups;
|
|
209
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PLAN_FILL_OPACITY,
|
|
5
|
+
PLAN_STATUSES,
|
|
6
|
+
PLAN_STATUS_ENCODING,
|
|
7
|
+
planStatusMatch,
|
|
8
|
+
} from "./plan-status";
|
|
9
|
+
|
|
10
|
+
describe("PLAN_STATUS_ENCODING", () => {
|
|
11
|
+
it("covers every status exactly once, in legend order", () => {
|
|
12
|
+
expect(PLAN_STATUSES).toEqual(["free", "occupied", "warning", "down"]);
|
|
13
|
+
expect(Object.keys(PLAN_STATUS_ENCODING).sort()).toEqual([...PLAN_STATUSES].sort());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("gives every status its own texture and its own outline style", () => {
|
|
17
|
+
const patterns = PLAN_STATUSES.map((status) => PLAN_STATUS_ENCODING[status].pattern);
|
|
18
|
+
const dashes = PLAN_STATUSES.map((status) => PLAN_STATUS_ENCODING[status].dash);
|
|
19
|
+
|
|
20
|
+
// Four statuses, four textures, four outline styles: in greyscale they still
|
|
21
|
+
// read apart, which is the whole point of the table (WCAG 1.4.1).
|
|
22
|
+
expect(new Set(patterns).size).toBe(PLAN_STATUSES.length);
|
|
23
|
+
expect(new Set(dashes).size).toBe(PLAN_STATUSES.length);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("carries a glyph only on the two states that must never be missed", () => {
|
|
27
|
+
expect(PLAN_STATUS_ENCODING.free.icon).toBeUndefined();
|
|
28
|
+
expect(PLAN_STATUS_ENCODING.occupied.icon).toBeUndefined();
|
|
29
|
+
expect(PLAN_STATUS_ENCODING.warning.icon).toBeDefined();
|
|
30
|
+
expect(PLAN_STATUS_ENCODING.down.icon).toBeDefined();
|
|
31
|
+
expect(PLAN_STATUS_ENCODING.warning.glyph).not.toBe(PLAN_STATUS_ENCODING.down.glyph);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("leaves the free status solid, so a dash always means something", () => {
|
|
35
|
+
expect(PLAN_STATUS_ENCODING.free.dashArray).toBeUndefined();
|
|
36
|
+
for (const status of ["occupied", "warning", "down"] as const) {
|
|
37
|
+
expect(PLAN_STATUS_ENCODING[status].dashArray?.length).toBeGreaterThan(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps the ink rung off the mark rung", () => {
|
|
42
|
+
for (const status of PLAN_STATUSES) {
|
|
43
|
+
const encoding = PLAN_STATUS_ENCODING[status];
|
|
44
|
+
expect(encoding.markClass.startsWith("bg-")).toBe(true);
|
|
45
|
+
// `-text` is the ink rung; a bare `text-<tone>` would be the 3:1 fill rung.
|
|
46
|
+
expect(
|
|
47
|
+
encoding.textClass.endsWith("-text") || encoding.textClass === "text-muted-foreground",
|
|
48
|
+
).toBe(true);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("cannot be edited by a surface that reads it", () => {
|
|
53
|
+
expect(Object.isFrozen(PLAN_STATUS_ENCODING)).toBe(true);
|
|
54
|
+
expect(Object.isFrozen(PLAN_FILL_OPACITY)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rises in opacity from rest through hover to selected", () => {
|
|
58
|
+
expect(PLAN_FILL_OPACITY.rest).toBeLessThan(PLAN_FILL_OPACITY.hover);
|
|
59
|
+
expect(PLAN_FILL_OPACITY.hover).toBeLessThan(PLAN_FILL_OPACITY.selected);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("planStatusMatch", () => {
|
|
64
|
+
it("builds one MapLibre match over the status property", () => {
|
|
65
|
+
const expression = planStatusMatch("state", (encoding) => encoding.dash, "solid");
|
|
66
|
+
|
|
67
|
+
expect(expression).toEqual([
|
|
68
|
+
"match",
|
|
69
|
+
["get", "state"],
|
|
70
|
+
"free",
|
|
71
|
+
"solid",
|
|
72
|
+
"occupied",
|
|
73
|
+
"dotted",
|
|
74
|
+
"warning",
|
|
75
|
+
"dashed",
|
|
76
|
+
"down",
|
|
77
|
+
"dot-dash",
|
|
78
|
+
"solid",
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("falls back to the occupied value when no fallback is given", () => {
|
|
83
|
+
const expression = planStatusMatch("status", (encoding) => encoding.pattern);
|
|
84
|
+
|
|
85
|
+
expect(expression.at(-1)).toBe(PLAN_STATUS_ENCODING.occupied.pattern);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status on a plan — free desk, busy machine, line in alarm — without leaning on
|
|
3
|
+
* colour (WCAG 1.4.1).
|
|
4
|
+
*
|
|
5
|
+
* The MapLibre style spec decides how the channels split, and the split is not a
|
|
6
|
+
* matter of taste: `fill-pattern` and `line-dasharray` accept `zoom` and
|
|
7
|
+
* `feature` expressions but **not `feature-state`**, while `fill-color`,
|
|
8
|
+
* `fill-opacity`, `line-color` and `line-width` accept all three. So:
|
|
9
|
+
*
|
|
10
|
+
* - **status** rides feature PROPERTIES — pattern and dash, which cannot read
|
|
11
|
+
* feature-state anyway;
|
|
12
|
+
* - **hover and selection** ride feature STATE — opacity, width and colour.
|
|
13
|
+
*
|
|
14
|
+
* The two can therefore never collide, and selection must never be expressed as
|
|
15
|
+
* a dash change: a selected free room would read as an occupied one.
|
|
16
|
+
*
|
|
17
|
+
* In greyscale each status still differs by four textures, four outline styles
|
|
18
|
+
* and, on the two states that must never be confused, a glyph — with the legend
|
|
19
|
+
* and the table repeating all of it as words.
|
|
20
|
+
*/
|
|
21
|
+
import { STATUS_TONE_ICONS } from "@elabs-ai/components-ui";
|
|
22
|
+
import type { LucideIcon } from "lucide-react";
|
|
23
|
+
|
|
24
|
+
/** What a region on a plan can be. */
|
|
25
|
+
export type PlanStatus = "free" | "occupied" | "warning" | "down";
|
|
26
|
+
|
|
27
|
+
/** Every {@link PlanStatus}, in legend order. */
|
|
28
|
+
export const PLAN_STATUSES: readonly PlanStatus[] = ["free", "occupied", "warning", "down"];
|
|
29
|
+
|
|
30
|
+
/** The generated hatch tile a status is textured with — see `lib/plan-patterns.ts`. */
|
|
31
|
+
export type PlanPatternKind = "none" | "diagonal" | "cross" | "dense";
|
|
32
|
+
|
|
33
|
+
/** The outline style a status is drawn with: the second non-colour channel. */
|
|
34
|
+
export type PlanDash = "solid" | "dotted" | "dashed" | "dot-dash";
|
|
35
|
+
|
|
36
|
+
/** MapLibre `line-dasharray` values, in line widths. `undefined` means solid. */
|
|
37
|
+
const DASH_ARRAYS: Record<PlanDash, number[] | undefined> = {
|
|
38
|
+
solid: undefined,
|
|
39
|
+
dotted: [1, 1.5],
|
|
40
|
+
dashed: [3, 2],
|
|
41
|
+
"dot-dash": [4, 1.5, 1, 1.5],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface PlanStatusEncoding {
|
|
45
|
+
/** The status tone, for the token rungs below. */
|
|
46
|
+
tone: "success" | "neutral" | "warning" | "destructive";
|
|
47
|
+
/** The hatch tile registered with `map.addImage`. */
|
|
48
|
+
pattern: PlanPatternKind;
|
|
49
|
+
/** The outline style. */
|
|
50
|
+
dash: PlanDash;
|
|
51
|
+
/** `line-dasharray` for the outline; `undefined` keeps it solid. */
|
|
52
|
+
dashArray: number[] | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* A glyph, on the two states that must never be confused with a working one.
|
|
55
|
+
* `undefined` elsewhere: a glyph on every neutral region is noise.
|
|
56
|
+
*/
|
|
57
|
+
icon: LucideIcon | undefined;
|
|
58
|
+
/** Stable glyph name, so a test can assert the four states read apart. */
|
|
59
|
+
glyph: "none" | "clock" | "alert";
|
|
60
|
+
/** The token the shape's own ink resolves from (the ≥3:1 mark rung). */
|
|
61
|
+
colorToken: string;
|
|
62
|
+
/** Mark (fill-rung) utility, for a legend swatch. */
|
|
63
|
+
markClass: string;
|
|
64
|
+
/** Ink (`-text` rung) utility, for coloured text on an ordinary surface. */
|
|
65
|
+
textClass: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The one table every plan surface reads status from — map paint, legend, table
|
|
70
|
+
* and readout alike. Frozen, so a surface cannot quietly drift from the others.
|
|
71
|
+
*/
|
|
72
|
+
export const PLAN_STATUS_ENCODING: Readonly<Record<PlanStatus, PlanStatusEncoding>> = Object.freeze(
|
|
73
|
+
{
|
|
74
|
+
free: {
|
|
75
|
+
tone: "success",
|
|
76
|
+
pattern: "none",
|
|
77
|
+
dash: "solid",
|
|
78
|
+
dashArray: DASH_ARRAYS.solid,
|
|
79
|
+
icon: undefined,
|
|
80
|
+
glyph: "none",
|
|
81
|
+
colorToken: "--success",
|
|
82
|
+
markClass: "bg-success",
|
|
83
|
+
textClass: "text-success-text",
|
|
84
|
+
},
|
|
85
|
+
occupied: {
|
|
86
|
+
tone: "neutral",
|
|
87
|
+
pattern: "diagonal",
|
|
88
|
+
dash: "dotted",
|
|
89
|
+
dashArray: DASH_ARRAYS.dotted,
|
|
90
|
+
icon: undefined,
|
|
91
|
+
glyph: "none",
|
|
92
|
+
colorToken: "--muted-foreground",
|
|
93
|
+
markClass: "bg-muted-foreground",
|
|
94
|
+
textClass: "text-muted-foreground",
|
|
95
|
+
},
|
|
96
|
+
warning: {
|
|
97
|
+
tone: "warning",
|
|
98
|
+
pattern: "cross",
|
|
99
|
+
dash: "dashed",
|
|
100
|
+
dashArray: DASH_ARRAYS.dashed,
|
|
101
|
+
icon: STATUS_TONE_ICONS.warning,
|
|
102
|
+
glyph: "clock",
|
|
103
|
+
colorToken: "--warning",
|
|
104
|
+
markClass: "bg-warning",
|
|
105
|
+
textClass: "text-warning-text",
|
|
106
|
+
},
|
|
107
|
+
down: {
|
|
108
|
+
tone: "destructive",
|
|
109
|
+
pattern: "dense",
|
|
110
|
+
dash: "dot-dash",
|
|
111
|
+
dashArray: DASH_ARRAYS["dot-dash"],
|
|
112
|
+
icon: STATUS_TONE_ICONS.destructive,
|
|
113
|
+
glyph: "alert",
|
|
114
|
+
colorToken: "--destructive",
|
|
115
|
+
markClass: "bg-destructive",
|
|
116
|
+
textClass: "text-destructive-text",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
/** Fill opacity per interaction state — the feature-state channel, in one place. */
|
|
122
|
+
export const PLAN_FILL_OPACITY = Object.freeze({
|
|
123
|
+
rest: 0.16,
|
|
124
|
+
hover: 0.28,
|
|
125
|
+
selected: 0.36,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A MapLibre `match` expression over a status property, so one paint value can
|
|
130
|
+
* serve every status without a layer per status.
|
|
131
|
+
*
|
|
132
|
+
* ```ts
|
|
133
|
+
* "line-dasharray": planStatusMatch("status", (encoding) => encoding.dashArray ?? [1, 0])
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export function planStatusMatch<T>(
|
|
137
|
+
property: string,
|
|
138
|
+
value: (encoding: PlanStatusEncoding, status: PlanStatus) => T,
|
|
139
|
+
fallback?: T,
|
|
140
|
+
): unknown[] {
|
|
141
|
+
const expression: unknown[] = ["match", ["get", property]];
|
|
142
|
+
for (const status of PLAN_STATUSES) {
|
|
143
|
+
expression.push(status, value(PLAN_STATUS_ENCODING[status], status));
|
|
144
|
+
}
|
|
145
|
+
expression.push(fallback ?? value(PLAN_STATUS_ENCODING.occupied, "occupied"));
|
|
146
|
+
return expression;
|
|
147
|
+
}
|