@elabs-ai/components-maps 4.1.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 -1456
- 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 +421 -1
- package/src/map-canvas/map-canvas.tsx +466 -20
- 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 +383 -3
- package/src/map-marker/map-marker.tsx +414 -44
- 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 +18 -11
- 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 +267 -20
|
@@ -4,8 +4,15 @@ import type MapLibreGL from "maplibre-gl";
|
|
|
4
4
|
import { useEffect, useId, useMemo, useRef } from "react";
|
|
5
5
|
|
|
6
6
|
import { useMap } from "../map-canvas/map-context";
|
|
7
|
-
import {
|
|
7
|
+
import { mergeFeatureStatePaint } from "../lib/merge-hover-paint";
|
|
8
|
+
import type { PlanPoint } from "../lib/plan-crs";
|
|
8
9
|
import { useTokenColor } from "../lib/use-token-color";
|
|
10
|
+
import { warnMapOnce } from "../lib/warn-once";
|
|
11
|
+
|
|
12
|
+
/** Invisible line width, in px, that makes a hairline outline or a wall touchable. */
|
|
13
|
+
const DEFAULT_HIT_WIDTH = 16;
|
|
14
|
+
/** Half-size, in px, of the box a plan click is allowed to miss by. */
|
|
15
|
+
const DEFAULT_PLAN_PICK_PADDING = 8;
|
|
9
16
|
|
|
10
17
|
export type MapGeoJSONData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
|
|
11
18
|
| GeoJSON.FeatureCollection<GeoJSON.Geometry, P>
|
|
@@ -16,6 +23,31 @@ export type MapGeoJSONData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
|
|
|
16
23
|
export type MapFillPaint = NonNullable<MapLibreGL.FillLayerSpecification["paint"]>;
|
|
17
24
|
export type MapLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
|
18
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A hatch drawn over the fill — the locator-map "area marker" look. `width` is
|
|
28
|
+
* a stripe's thickness and `gap` the space between stripes, both in CSS px
|
|
29
|
+
* measured across the 45° stripes. Stripes take the fill colour (a plain
|
|
30
|
+
* `fill-color`; an expression falls back to the `--foreground` token).
|
|
31
|
+
*/
|
|
32
|
+
export interface MapGeoJSONPattern {
|
|
33
|
+
kind: "stripes";
|
|
34
|
+
/** Stripe thickness in px (default 2). */
|
|
35
|
+
width?: number;
|
|
36
|
+
/** Space between stripes in px (default 4). */
|
|
37
|
+
gap?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A soft glow around the area's edge — a second, blurred line layer under the
|
|
42
|
+
* fill, in the fill colour. `width` is how far the glow reaches out, in px.
|
|
43
|
+
*/
|
|
44
|
+
export interface MapGeoJSONVignette {
|
|
45
|
+
/** Glow reach in px (default 12). */
|
|
46
|
+
width?: number;
|
|
47
|
+
/** Glow opacity, 0–1 (default 0.35). */
|
|
48
|
+
opacity?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
/** A rendered feature with strongly-typed `properties`. */
|
|
20
52
|
export type MapGeoJSONFeature<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
|
|
21
53
|
Omit<MapLibreGL.MapGeoJSONFeature, "properties"> & { properties: P };
|
|
@@ -28,18 +60,33 @@ export type MapGeoJSONEvent<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJso
|
|
|
28
60
|
longitude: number;
|
|
29
61
|
/** Latitude of the cursor at the time of the event. */
|
|
30
62
|
latitude: number;
|
|
31
|
-
/**
|
|
32
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The cursor in PLAN units, when the canvas declares a `plan` extent —
|
|
65
|
+
* otherwise `null`. What a plan consumer actually wants: metres along the
|
|
66
|
+
* hall, not a synthesized longitude.
|
|
67
|
+
*/
|
|
68
|
+
plan: PlanPoint | null;
|
|
69
|
+
/**
|
|
70
|
+
* The underlying MapLibre mouse event for advanced use cases. `null` when
|
|
71
|
+
* the feature was reached from the KEYBOARD rather than a pointer — there is
|
|
72
|
+
* no mouse event behind that (c-4/c-2, WCAG 2.1.1).
|
|
73
|
+
*/
|
|
74
|
+
originalEvent: MapLibreGL.MapLayerMouseEvent | null;
|
|
33
75
|
};
|
|
34
76
|
|
|
35
77
|
export type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
|
|
36
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it
|
|
80
|
+
* from. On a canvas with a `plan` extent the coordinates are PLAN units —
|
|
81
|
+
* `[x, y]` in the plan's own drawing units — and are converted for you.
|
|
82
|
+
*/
|
|
37
83
|
data: MapGeoJSONData<P>;
|
|
38
84
|
/** Optional unique identifier prefix for the source/layers. Auto-generated if not provided. */
|
|
39
85
|
id?: string;
|
|
40
86
|
/**
|
|
41
|
-
* Feature property to promote to the feature `id`. Required for hover
|
|
42
|
-
* feature-state (`fillHoverPaint`) and stable
|
|
87
|
+
* Feature property to promote to the feature `id`. Required for hover and
|
|
88
|
+
* selected feature-state (`fillHoverPaint`, `selectedId`) and stable
|
|
89
|
+
* `onHover`/`onClick` payloads.
|
|
43
90
|
*/
|
|
44
91
|
promoteId?: string;
|
|
45
92
|
/**
|
|
@@ -61,12 +108,66 @@ export type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJso
|
|
|
61
108
|
* as a `case` expression keyed on hover feature-state. Requires `promoteId`.
|
|
62
109
|
*/
|
|
63
110
|
fillHoverPaint?: MapFillPaint;
|
|
111
|
+
/**
|
|
112
|
+
* The fill layer's opacity, 0–1 — shorthand for `fillPaint["fill-opacity"]`
|
|
113
|
+
* (which wins when both are set). Default 1, or 0.25 with a `pattern`, so
|
|
114
|
+
* the stripes read over a tint of the same colour.
|
|
115
|
+
*/
|
|
116
|
+
fillOpacity?: number;
|
|
117
|
+
/** Hatch the areas with stripes (see {@link MapGeoJSONPattern}). */
|
|
118
|
+
pattern?: MapGeoJSONPattern;
|
|
119
|
+
/** Glow around the areas' edges (see {@link MapGeoJSONVignette}). */
|
|
120
|
+
vignette?: MapGeoJSONVignette;
|
|
121
|
+
/** The outline equivalent of `fillHoverPaint`. Requires `promoteId`. */
|
|
122
|
+
lineHoverPaint?: MapLinePaint;
|
|
123
|
+
/**
|
|
124
|
+
* Paint merged onto the fill layer for the feature(s) named by `selectedId`.
|
|
125
|
+
* Applied outside hover, so a selected feature still looks selected while the
|
|
126
|
+
* cursor is over it. Requires `promoteId`.
|
|
127
|
+
*/
|
|
128
|
+
fillSelectedPaint?: MapFillPaint;
|
|
129
|
+
/** The outline equivalent of `fillSelectedPaint`. Requires `promoteId`. */
|
|
130
|
+
lineSelectedPaint?: MapLinePaint;
|
|
131
|
+
/**
|
|
132
|
+
* The selected feature id(s) — the promoted `promoteId` values. Selection is
|
|
133
|
+
* the consumer's state; this only paints it.
|
|
134
|
+
*/
|
|
135
|
+
selectedId?: string | number | readonly (string | number)[] | null;
|
|
136
|
+
/**
|
|
137
|
+
* Take over the highlight: when set, the hover paint follows THIS id instead
|
|
138
|
+
* of the pointer. That is how `MapPlanOverlay` makes a keyboard-focused room
|
|
139
|
+
* light up exactly as a hovered one does — one channel, one appearance.
|
|
140
|
+
*/
|
|
141
|
+
hoveredId?: string | number | null;
|
|
64
142
|
/** Callback when a feature is clicked. */
|
|
65
143
|
onClick?: (e: MapGeoJSONEvent<P>) => void;
|
|
66
144
|
/** Callback fired when the hovered feature changes; `null` when the cursor leaves. */
|
|
67
145
|
onHover?: (e: MapGeoJSONEvent<P> | null) => void;
|
|
68
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Whether features respond to mouse events. Defaults to `false` on a
|
|
148
|
+
* geographic canvas and `true` on a plan canvas, where the shapes ARE the
|
|
149
|
+
* subject rather than a backdrop.
|
|
150
|
+
*/
|
|
69
151
|
interactive?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* The text of a feature in the keyboard list an `interactive` map renders —
|
|
154
|
+
* one visually-hidden button per feature, so the values a pointer reveals
|
|
155
|
+
* on hover are reachable by Tab and readable by a screen reader (WCAG 2.1.1
|
|
156
|
+
* and 1.3.1). Say what the region is AND what it is worth — the same
|
|
157
|
+
* sentence a hover readout prints. Defaults to the promoted id.
|
|
158
|
+
*/
|
|
159
|
+
featureLabel?: (feature: MapGeoJSONFeature<P>, index: number) => string;
|
|
160
|
+
/**
|
|
161
|
+
* How far, in px, a click may miss a shape and still land on it. Defaults to
|
|
162
|
+
* 8 px on a plan (so a 20 px seat is a 36 px touch target) and 0 elsewhere.
|
|
163
|
+
* An exact hit always wins over a padded one.
|
|
164
|
+
*/
|
|
165
|
+
pickPadding?: number;
|
|
166
|
+
/**
|
|
167
|
+
* Width, in px, of the invisible line that makes an outline-only shape
|
|
168
|
+
* touchable (default 16). Only used when `fillPaint` is `false`.
|
|
169
|
+
*/
|
|
170
|
+
hitWidth?: number;
|
|
70
171
|
/** Optional MapLibre layer id to insert the layers before (z-order control). */
|
|
71
172
|
beforeId?: string;
|
|
72
173
|
};
|
|
@@ -76,6 +177,16 @@ export type MapGeoJSONProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJso
|
|
|
76
177
|
* `MapRoute` / `MapArc` — drop it inside `<MapCanvas>` (typically with `blank`)
|
|
77
178
|
* for choropleths and region/data maps. For full control over expressions and
|
|
78
179
|
* multiple layers, manage layers directly via `useMap()` instead.
|
|
180
|
+
*
|
|
181
|
+
* On a canvas that declares a plan extent it is also the shape layer of a
|
|
182
|
+
* custom (non-geographic) map: rooms, machine cells, aisles and seats written
|
|
183
|
+
* in the plan's own units.
|
|
184
|
+
*
|
|
185
|
+
* ```tsx
|
|
186
|
+
* <MapCanvas blank plan={{ width: 2400, height: 1600, unit: "cm" }}>
|
|
187
|
+
* <MapGeoJSON data={roomsInCentimetres} promoteId="id" selectedId={roomId} />
|
|
188
|
+
* </MapCanvas>
|
|
189
|
+
* ```
|
|
79
190
|
*/
|
|
80
191
|
export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
|
|
81
192
|
data,
|
|
@@ -84,40 +195,116 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
|
|
|
84
195
|
fillPaint,
|
|
85
196
|
linePaint,
|
|
86
197
|
fillHoverPaint,
|
|
198
|
+
fillOpacity,
|
|
199
|
+
pattern,
|
|
200
|
+
vignette,
|
|
201
|
+
lineHoverPaint,
|
|
202
|
+
fillSelectedPaint,
|
|
203
|
+
lineSelectedPaint,
|
|
204
|
+
selectedId,
|
|
205
|
+
hoveredId,
|
|
87
206
|
onClick,
|
|
88
207
|
onHover,
|
|
89
|
-
interactive
|
|
208
|
+
interactive: interactiveProp,
|
|
209
|
+
featureLabel,
|
|
210
|
+
pickPadding,
|
|
211
|
+
hitWidth = DEFAULT_HIT_WIDTH,
|
|
90
212
|
beforeId,
|
|
91
213
|
}: MapGeoJSONProps<P>) {
|
|
92
|
-
const { map, isLoaded } = useMap();
|
|
214
|
+
const { map, isLoaded, plan } = useMap();
|
|
93
215
|
const autoId = useId();
|
|
94
216
|
const id = propId ?? autoId;
|
|
95
217
|
const sourceId = `geojson-source-${id}`;
|
|
96
218
|
const fillLayerId = `geojson-fill-${id}`;
|
|
97
219
|
const lineLayerId = `geojson-line-${id}`;
|
|
220
|
+
const patternLayerId = `geojson-pattern-${id}`;
|
|
221
|
+
const vignetteLayerId = `geojson-vignette-${id}`;
|
|
222
|
+
const patternImageId = `geojson-pattern-image-${id}`;
|
|
223
|
+
const hitLayerId = `geojson-hit-${id}`;
|
|
224
|
+
|
|
225
|
+
const interactive = interactiveProp ?? plan != null;
|
|
226
|
+
const pickPad = pickPadding ?? (plan ? DEFAULT_PLAN_PICK_PADDING : 0);
|
|
98
227
|
|
|
99
228
|
// Theme-driven neutral defaults: landmass = the mid-neutral `--border` rung,
|
|
100
229
|
// separators = the page surface. Both re-resolve on theme change.
|
|
101
230
|
const defaultFill = useTokenColor("--border");
|
|
102
231
|
const defaultLine = useTokenColor("--background");
|
|
232
|
+
const defaultInk = useTokenColor("--foreground");
|
|
103
233
|
|
|
104
234
|
const showFill = fillPaint !== false;
|
|
105
235
|
const showLine = linePaint !== false;
|
|
236
|
+
// Without a fill there is nothing to click: a hairline outline is a 1 px
|
|
237
|
+
// target and a wall or conveyor has no interior at all.
|
|
238
|
+
const showHit = interactive && !showFill;
|
|
239
|
+
|
|
240
|
+
// Plan units in, Mercator out. A URL cannot be converted — the fetch happens
|
|
241
|
+
// inside the engine — so say so once instead of drawing the plan in the
|
|
242
|
+
// Atlantic.
|
|
243
|
+
if (plan && typeof data === "string") {
|
|
244
|
+
warnMapOnce(
|
|
245
|
+
"geojson-plan-url",
|
|
246
|
+
"<MapGeoJSON data={url}> cannot be converted to plan units. Fetch the GeoJSON yourself and pass the object, or pass coordinates already in lng/lat.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (interactive && !promoteId) {
|
|
250
|
+
warnMapOnce(
|
|
251
|
+
"geojson-promote-id",
|
|
252
|
+
"<MapGeoJSON interactive> needs `promoteId` to tell features apart: without it hover and selection paint nothing.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const resolvedData = useMemo(
|
|
257
|
+
() => (plan && typeof data !== "string" ? plan.toGeoJSON(data) : data),
|
|
258
|
+
[plan, data],
|
|
259
|
+
);
|
|
106
260
|
|
|
261
|
+
const baseOpacity = fillOpacity ?? (pattern ? 0.25 : undefined);
|
|
107
262
|
const mergedFillPaint = useMemo(
|
|
108
|
-
() =>
|
|
109
|
-
|
|
263
|
+
() =>
|
|
264
|
+
mergeFeatureStatePaint(
|
|
265
|
+
{
|
|
266
|
+
"fill-color": defaultFill,
|
|
267
|
+
...(baseOpacity !== undefined ? { "fill-opacity": baseOpacity } : {}),
|
|
268
|
+
...(fillPaint || {}),
|
|
269
|
+
},
|
|
270
|
+
{ hover: fillHoverPaint, selected: fillSelectedPaint },
|
|
271
|
+
),
|
|
272
|
+
[defaultFill, baseOpacity, fillPaint, fillHoverPaint, fillSelectedPaint],
|
|
110
273
|
);
|
|
274
|
+
// The colour stripes and the glow take: the fill's own plain colour, or ink.
|
|
275
|
+
const configuredFill = fillPaint ? fillPaint["fill-color"] : undefined;
|
|
276
|
+
const areaColor =
|
|
277
|
+
configuredFill === undefined
|
|
278
|
+
? defaultFill
|
|
279
|
+
: typeof configuredFill === "string"
|
|
280
|
+
? configuredFill
|
|
281
|
+
: defaultInk;
|
|
282
|
+
const stripeWidth = Math.max(1, pattern?.width ?? 2);
|
|
283
|
+
const stripeGap = Math.max(0, pattern?.gap ?? 4);
|
|
284
|
+
const vignetteWidth = Math.max(0, vignette?.width ?? 12);
|
|
285
|
+
const vignetteOpacity = Math.min(1, Math.max(0, vignette?.opacity ?? 0.35));
|
|
286
|
+
const hasPattern = pattern !== undefined;
|
|
287
|
+
const hasVignette = vignette !== undefined;
|
|
288
|
+
// The tile drawn last; a new one is drawn only when colour or size change.
|
|
289
|
+
const patternDrawnRef = useRef<string | null>(null);
|
|
111
290
|
const mergedLinePaint = useMemo(
|
|
112
|
-
() =>
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
291
|
+
() =>
|
|
292
|
+
mergeFeatureStatePaint(
|
|
293
|
+
{
|
|
294
|
+
"line-color": defaultLine,
|
|
295
|
+
"line-width": 0.5,
|
|
296
|
+
...(linePaint || {}),
|
|
297
|
+
},
|
|
298
|
+
{ hover: lineHoverPaint, selected: lineSelectedPaint },
|
|
299
|
+
),
|
|
300
|
+
[defaultLine, linePaint, lineHoverPaint, lineSelectedPaint],
|
|
118
301
|
);
|
|
119
|
-
const latestRef = useRef({ onClick, onHover });
|
|
120
|
-
latestRef.current = { onClick, onHover };
|
|
302
|
+
const latestRef = useRef({ onClick, onHover, plan });
|
|
303
|
+
latestRef.current = { onClick, onHover, plan };
|
|
304
|
+
// c-2: the pointer path owns the hover feature-state inside its effect; the
|
|
305
|
+
// keyboard list below drives the SAME highlight through this handle, so a
|
|
306
|
+
// focused region lights up exactly as a hovered one does.
|
|
307
|
+
const setHoverRef = useRef<((next: string | number | null) => void) | null>(null);
|
|
121
308
|
|
|
122
309
|
// Add source on mount.
|
|
123
310
|
useEffect(() => {
|
|
@@ -125,14 +312,18 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
|
|
|
125
312
|
|
|
126
313
|
map.addSource(sourceId, {
|
|
127
314
|
type: "geojson",
|
|
128
|
-
data,
|
|
315
|
+
data: resolvedData as never,
|
|
129
316
|
...(promoteId ? { promoteId } : {}),
|
|
130
317
|
});
|
|
131
318
|
|
|
132
319
|
return () => {
|
|
133
320
|
try {
|
|
321
|
+
if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId);
|
|
134
322
|
if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
|
|
323
|
+
if (map.getLayer(patternLayerId)) map.removeLayer(patternLayerId);
|
|
135
324
|
if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
|
|
325
|
+
if (map.getLayer(vignetteLayerId)) map.removeLayer(vignetteLayerId);
|
|
326
|
+
if (map.hasImage(patternImageId)) map.removeImage(patternImageId);
|
|
136
327
|
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
137
328
|
} catch {
|
|
138
329
|
// style may be mid-reload
|
|
@@ -145,8 +336,8 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
|
|
|
145
336
|
useEffect(() => {
|
|
146
337
|
if (!isLoaded || !map) return;
|
|
147
338
|
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource | undefined;
|
|
148
|
-
source?.setData(
|
|
149
|
-
}, [isLoaded, map,
|
|
339
|
+
source?.setData(resolvedData as never);
|
|
340
|
+
}, [isLoaded, map, resolvedData, sourceId]);
|
|
150
341
|
|
|
151
342
|
// Sync layers and paint when visibility or styling changes.
|
|
152
343
|
useEffect(() => {
|
|
@@ -183,6 +374,21 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
|
|
|
183
374
|
map.removeLayer(lineLayerId);
|
|
184
375
|
}
|
|
185
376
|
|
|
377
|
+
// Invisible, but still picked: MapLibre hit-tests geometry, not opacity.
|
|
378
|
+
if (showHit && !map.getLayer(hitLayerId)) {
|
|
379
|
+
map.addLayer(
|
|
380
|
+
{
|
|
381
|
+
id: hitLayerId,
|
|
382
|
+
type: "line",
|
|
383
|
+
source: sourceId,
|
|
384
|
+
paint: { "line-color": defaultLine, "line-opacity": 0, "line-width": hitWidth },
|
|
385
|
+
},
|
|
386
|
+
beforeId,
|
|
387
|
+
);
|
|
388
|
+
} else if (!showHit && map.getLayer(hitLayerId)) {
|
|
389
|
+
map.removeLayer(hitLayerId);
|
|
390
|
+
}
|
|
391
|
+
|
|
186
392
|
if (showFill && map.getLayer(fillLayerId)) {
|
|
187
393
|
for (const [key, value] of Object.entries(mergedFillPaint)) {
|
|
188
394
|
map.setPaintProperty(fillLayerId, key as keyof MapFillPaint, value as never);
|
|
@@ -193,51 +399,181 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
|
|
|
193
399
|
map.setPaintProperty(lineLayerId, key as keyof MapLinePaint, value as never);
|
|
194
400
|
}
|
|
195
401
|
}
|
|
402
|
+
|
|
403
|
+
// Vignette: a blurred line UNDER the fill, so the glow reads outside it.
|
|
404
|
+
const vignettePaint: MapLinePaint = {
|
|
405
|
+
"line-color": areaColor,
|
|
406
|
+
"line-width": vignetteWidth * 2,
|
|
407
|
+
"line-blur": vignetteWidth,
|
|
408
|
+
"line-opacity": vignetteOpacity,
|
|
409
|
+
};
|
|
410
|
+
if (hasVignette && !map.getLayer(vignetteLayerId)) {
|
|
411
|
+
const before = map.getLayer(fillLayerId)
|
|
412
|
+
? fillLayerId
|
|
413
|
+
: map.getLayer(lineLayerId)
|
|
414
|
+
? lineLayerId
|
|
415
|
+
: beforeId;
|
|
416
|
+
map.addLayer(
|
|
417
|
+
{ id: vignetteLayerId, type: "line", source: sourceId, paint: vignettePaint },
|
|
418
|
+
before,
|
|
419
|
+
);
|
|
420
|
+
} else if (hasVignette) {
|
|
421
|
+
for (const [key, value] of Object.entries(vignettePaint)) {
|
|
422
|
+
map.setPaintProperty(vignetteLayerId, key as keyof MapLinePaint, value as never);
|
|
423
|
+
}
|
|
424
|
+
} else if (map.getLayer(vignetteLayerId)) {
|
|
425
|
+
map.removeLayer(vignetteLayerId);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Pattern: a stripe tile drawn in the area colour, on its own fill layer
|
|
429
|
+
// between the fill and the outline. Re-drawn when the colour or size changes.
|
|
430
|
+
if (hasPattern) {
|
|
431
|
+
const drawKey = `${areaColor}|${stripeWidth}|${stripeGap}`;
|
|
432
|
+
if (patternDrawnRef.current !== drawKey || !map.hasImage(patternImageId)) {
|
|
433
|
+
const image = stripeImage(areaColor, stripeWidth, stripeGap);
|
|
434
|
+
if (image) {
|
|
435
|
+
if (map.hasImage(patternImageId)) map.removeImage(patternImageId);
|
|
436
|
+
map.addImage(patternImageId, image.data, { pixelRatio: image.pixelRatio });
|
|
437
|
+
patternDrawnRef.current = drawKey;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (map.hasImage(patternImageId)) {
|
|
441
|
+
if (!map.getLayer(patternLayerId)) {
|
|
442
|
+
map.addLayer(
|
|
443
|
+
{
|
|
444
|
+
id: patternLayerId,
|
|
445
|
+
type: "fill",
|
|
446
|
+
source: sourceId,
|
|
447
|
+
paint: { "fill-pattern": patternImageId },
|
|
448
|
+
},
|
|
449
|
+
map.getLayer(lineLayerId) ? lineLayerId : beforeId,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
if (map.getLayer(patternLayerId)) map.removeLayer(patternLayerId);
|
|
455
|
+
if (map.hasImage(patternImageId)) map.removeImage(patternImageId);
|
|
456
|
+
patternDrawnRef.current = null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (showHit && map.getLayer(hitLayerId)) {
|
|
460
|
+
map.setPaintProperty(hitLayerId, "line-width", hitWidth);
|
|
461
|
+
}
|
|
196
462
|
}, [
|
|
463
|
+
areaColor,
|
|
464
|
+
hasPattern,
|
|
465
|
+
stripeWidth,
|
|
466
|
+
stripeGap,
|
|
467
|
+
hasVignette,
|
|
468
|
+
vignetteWidth,
|
|
469
|
+
vignetteOpacity,
|
|
470
|
+
patternLayerId,
|
|
471
|
+
vignetteLayerId,
|
|
472
|
+
patternImageId,
|
|
197
473
|
isLoaded,
|
|
198
474
|
map,
|
|
199
475
|
sourceId,
|
|
200
476
|
fillLayerId,
|
|
201
477
|
lineLayerId,
|
|
478
|
+
hitLayerId,
|
|
202
479
|
showFill,
|
|
203
480
|
showLine,
|
|
481
|
+
showHit,
|
|
482
|
+
hitWidth,
|
|
483
|
+
defaultLine,
|
|
204
484
|
mergedFillPaint,
|
|
205
485
|
mergedLinePaint,
|
|
206
486
|
beforeId,
|
|
207
487
|
]);
|
|
208
488
|
|
|
209
|
-
//
|
|
489
|
+
// Selection is the consumer's state; mirror it into feature-state and clear
|
|
490
|
+
// whatever fell out of the set, so paint never sticks to a deselected shape.
|
|
491
|
+
const selectedKey = useMemo(() => {
|
|
492
|
+
if (selectedId == null) return "";
|
|
493
|
+
return (Array.isArray(selectedId) ? selectedId : [selectedId]).join("");
|
|
494
|
+
}, [selectedId]);
|
|
495
|
+
const appliedSelectionRef = useRef<(string | number)[]>([]);
|
|
210
496
|
useEffect(() => {
|
|
211
|
-
if (!isLoaded || !map || !
|
|
497
|
+
if (!isLoaded || !map || !map.getSource(sourceId)) return;
|
|
498
|
+
const next =
|
|
499
|
+
selectedId == null
|
|
500
|
+
? []
|
|
501
|
+
: ((Array.isArray(selectedId) ? [...selectedId] : [selectedId]) as (string | number)[]);
|
|
212
502
|
|
|
213
|
-
|
|
503
|
+
for (const previous of appliedSelectionRef.current) {
|
|
504
|
+
if (!next.includes(previous)) {
|
|
505
|
+
map.setFeatureState({ source: sourceId, id: previous }, { selected: false });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
for (const current of next) {
|
|
509
|
+
map.setFeatureState({ source: sourceId, id: current }, { selected: true });
|
|
510
|
+
}
|
|
511
|
+
appliedSelectionRef.current = next;
|
|
512
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the ids themselves so an inline array literal doesn't re-run this
|
|
513
|
+
}, [isLoaded, map, sourceId, selectedKey]);
|
|
514
|
+
|
|
515
|
+
// A controlled highlight: the overlay's keyboard focus and the pointer must
|
|
516
|
+
// produce the same appearance, so when `hoveredId` is given the pointer stops
|
|
517
|
+
// writing hover state and this effect owns it instead.
|
|
518
|
+
const hoverIsControlled = hoveredId !== undefined;
|
|
519
|
+
const appliedHoverRef = useRef<string | number | null>(null);
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
if (!hoverIsControlled || !isLoaded || !map || !map.getSource(sourceId)) return;
|
|
522
|
+
const previous = appliedHoverRef.current;
|
|
523
|
+
if (previous != null && previous !== hoveredId) {
|
|
524
|
+
map.setFeatureState({ source: sourceId, id: previous }, { hover: false });
|
|
525
|
+
}
|
|
526
|
+
if (hoveredId != null) {
|
|
527
|
+
map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: true });
|
|
528
|
+
}
|
|
529
|
+
appliedHoverRef.current = hoveredId ?? null;
|
|
530
|
+
}, [hoverIsControlled, hoveredId, isLoaded, map, sourceId]);
|
|
531
|
+
|
|
532
|
+
// Interaction handlers, bound to whichever layer carries the shape: the fill,
|
|
533
|
+
// or the invisible hit line when there is no fill.
|
|
534
|
+
const pickLayerId = showFill ? fillLayerId : hitLayerId;
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
if (!isLoaded || !map || !interactive) return;
|
|
537
|
+
if (!showFill && !showHit) return;
|
|
538
|
+
|
|
539
|
+
let pointerHoverId: string | number | null = null;
|
|
214
540
|
|
|
215
541
|
const setHover = (next: string | number | null) => {
|
|
216
|
-
if (
|
|
542
|
+
if (hoverIsControlled) return;
|
|
543
|
+
if (next === pointerHoverId) return;
|
|
217
544
|
const sourceExists = !!map.getSource(sourceId);
|
|
218
|
-
if (
|
|
219
|
-
map.setFeatureState({ source: sourceId, id:
|
|
545
|
+
if (pointerHoverId != null && sourceExists) {
|
|
546
|
+
map.setFeatureState({ source: sourceId, id: pointerHoverId }, { hover: false });
|
|
220
547
|
}
|
|
221
|
-
|
|
548
|
+
pointerHoverId = next;
|
|
222
549
|
if (next != null && sourceExists) {
|
|
223
550
|
map.setFeatureState({ source: sourceId, id: next }, { hover: true });
|
|
224
551
|
}
|
|
225
552
|
};
|
|
226
553
|
|
|
554
|
+
const payload = (
|
|
555
|
+
feature: MapLibreGL.MapGeoJSONFeature,
|
|
556
|
+
e: MapLibreGL.MapLayerMouseEvent,
|
|
557
|
+
): MapGeoJSONEvent<P> => {
|
|
558
|
+
const planCrs = latestRef.current.plan;
|
|
559
|
+
return {
|
|
560
|
+
feature: feature as unknown as MapGeoJSONFeature<P>,
|
|
561
|
+
longitude: e.lngLat.lng,
|
|
562
|
+
latitude: e.lngLat.lat,
|
|
563
|
+
plan: planCrs ? planCrs.toPlan([e.lngLat.lng, e.lngLat.lat]) : null,
|
|
564
|
+
originalEvent: e,
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
|
|
227
568
|
const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
228
569
|
const feature = e.features?.[0];
|
|
229
570
|
if (!feature) return;
|
|
230
571
|
map.getCanvas().style.cursor = "pointer";
|
|
231
572
|
|
|
232
573
|
const featureId = feature.id;
|
|
233
|
-
if (featureId ===
|
|
574
|
+
if (featureId === pointerHoverId) return;
|
|
234
575
|
setHover(featureId ?? null);
|
|
235
|
-
latestRef.current.onHover?.(
|
|
236
|
-
feature: feature as unknown as MapGeoJSONFeature<P>,
|
|
237
|
-
longitude: e.lngLat.lng,
|
|
238
|
-
latitude: e.lngLat.lat,
|
|
239
|
-
originalEvent: e,
|
|
240
|
-
});
|
|
576
|
+
latestRef.current.onHover?.(payload(feature, e));
|
|
241
577
|
};
|
|
242
578
|
|
|
243
579
|
const handleMouseLeave = () => {
|
|
@@ -246,29 +582,233 @@ export function MapGeoJSON<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJson
|
|
|
246
582
|
latestRef.current.onHover?.(null);
|
|
247
583
|
};
|
|
248
584
|
|
|
585
|
+
/**
|
|
586
|
+
* An exact hit always wins; only when nothing is under the pointer does the
|
|
587
|
+
* padded box get a say. On a plan that turns a 20 px seat into a target a
|
|
588
|
+
* fingertip can actually land on.
|
|
589
|
+
*/
|
|
590
|
+
const pickPadded = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
591
|
+
const exact = map.queryRenderedFeatures(e.point, { layers: [pickLayerId] });
|
|
592
|
+
if (exact.length > 0) return exact[0];
|
|
593
|
+
const { x, y } = e.point;
|
|
594
|
+
const box = [
|
|
595
|
+
[x - pickPad, y - pickPad],
|
|
596
|
+
[x + pickPad, y + pickPad],
|
|
597
|
+
] as unknown as Parameters<typeof map.queryRenderedFeatures>[0];
|
|
598
|
+
return map.queryRenderedFeatures(box, { layers: [pickLayerId] })[0];
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
setHoverRef.current = setHover;
|
|
602
|
+
|
|
249
603
|
const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
250
|
-
const feature = e.features?.[0];
|
|
604
|
+
const feature = pickPad > 0 ? pickPadded(e) : e.features?.[0];
|
|
251
605
|
if (!feature) return;
|
|
252
|
-
latestRef.current.onClick?.(
|
|
253
|
-
feature: feature as unknown as MapGeoJSONFeature<P>,
|
|
254
|
-
longitude: e.lngLat.lng,
|
|
255
|
-
latitude: e.lngLat.lat,
|
|
256
|
-
originalEvent: e,
|
|
257
|
-
});
|
|
606
|
+
latestRef.current.onClick?.(payload(feature, e));
|
|
258
607
|
};
|
|
259
608
|
|
|
260
|
-
map.on("mousemove",
|
|
261
|
-
map.on("mouseleave",
|
|
262
|
-
|
|
609
|
+
map.on("mousemove", pickLayerId, handleMouseMove);
|
|
610
|
+
map.on("mouseleave", pickLayerId, handleMouseLeave);
|
|
611
|
+
// A padded click must hear about the misses too, so it listens on the map.
|
|
612
|
+
if (pickPad > 0) {
|
|
613
|
+
map.on("click", handleClick);
|
|
614
|
+
} else {
|
|
615
|
+
map.on("click", pickLayerId, handleClick);
|
|
616
|
+
}
|
|
263
617
|
|
|
264
618
|
return () => {
|
|
265
|
-
map.off("mousemove",
|
|
266
|
-
map.off("mouseleave",
|
|
267
|
-
|
|
619
|
+
map.off("mousemove", pickLayerId, handleMouseMove);
|
|
620
|
+
map.off("mouseleave", pickLayerId, handleMouseLeave);
|
|
621
|
+
if (pickPad > 0) {
|
|
622
|
+
map.off("click", handleClick);
|
|
623
|
+
} else {
|
|
624
|
+
map.off("click", pickLayerId, handleClick);
|
|
625
|
+
}
|
|
268
626
|
setHover(null);
|
|
627
|
+
setHoverRef.current = null;
|
|
269
628
|
map.getCanvas().style.cursor = "";
|
|
270
629
|
};
|
|
271
|
-
}, [
|
|
630
|
+
}, [
|
|
631
|
+
isLoaded,
|
|
632
|
+
map,
|
|
633
|
+
pickLayerId,
|
|
634
|
+
sourceId,
|
|
635
|
+
interactive,
|
|
636
|
+
showFill,
|
|
637
|
+
showHit,
|
|
638
|
+
pickPad,
|
|
639
|
+
hoverIsControlled,
|
|
640
|
+
]);
|
|
641
|
+
|
|
642
|
+
// c-2 (WCAG 2.1.1, 1.3.1): the fill layer lives in WebGL, so an interactive
|
|
643
|
+
// choropleth had no DOM at all — its values were reachable by mouse only,
|
|
644
|
+
// with no tab stop, no text alternative and nothing for a screen reader to
|
|
645
|
+
// read. One visually-hidden button per feature gives every region a tab stop
|
|
646
|
+
// whose name IS its value; focusing it lights the region and fires the same
|
|
647
|
+
// `onHover` the pointer does, so a host readout follows the keyboard for
|
|
648
|
+
// free. Hidden, not absent: the map itself is the picture.
|
|
649
|
+
const keyboardFeatures = useMemo(
|
|
650
|
+
() => (interactive && showFill ? listKeyboardFeatures<P>(data, promoteId) : []),
|
|
651
|
+
[interactive, showFill, data, promoteId],
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
const reach = (entry: KeyboardFeature<P>) => {
|
|
655
|
+
setHoverRef.current?.(entry.id ?? null);
|
|
656
|
+
latestRef.current.onHover?.({
|
|
657
|
+
feature: entry.feature,
|
|
658
|
+
longitude: entry.longitude,
|
|
659
|
+
latitude: entry.latitude,
|
|
660
|
+
// A plan map's keyboard path is `MapPlanOverlay`'s job, so this list
|
|
661
|
+
// never has plan units to hand back.
|
|
662
|
+
plan: null,
|
|
663
|
+
originalEvent: null,
|
|
664
|
+
});
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
if (keyboardFeatures.length === 0) {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<div className="sr-only" data-slot="map-geojson-keyboard-list">
|
|
673
|
+
<ul>
|
|
674
|
+
{keyboardFeatures.map((entry, index) => (
|
|
675
|
+
<li key={entry.key}>
|
|
676
|
+
<button
|
|
677
|
+
onBlur={() => {
|
|
678
|
+
setHoverRef.current?.(null);
|
|
679
|
+
latestRef.current.onHover?.(null);
|
|
680
|
+
}}
|
|
681
|
+
onClick={() =>
|
|
682
|
+
latestRef.current.onClick?.({
|
|
683
|
+
feature: entry.feature,
|
|
684
|
+
longitude: entry.longitude,
|
|
685
|
+
latitude: entry.latitude,
|
|
686
|
+
plan: null,
|
|
687
|
+
originalEvent: null,
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
onFocus={() => reach(entry)}
|
|
691
|
+
type="button"
|
|
692
|
+
>
|
|
693
|
+
{featureLabel?.(entry.feature, index) ?? entry.fallbackLabel}
|
|
694
|
+
</button>
|
|
695
|
+
</li>
|
|
696
|
+
))}
|
|
697
|
+
</ul>
|
|
698
|
+
</div>
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** One row of the keyboard list: a feature, where it sits and what it is called. */
|
|
703
|
+
interface KeyboardFeature<P extends GeoJSON.GeoJsonProperties> {
|
|
704
|
+
key: string;
|
|
705
|
+
id: string | number | undefined;
|
|
706
|
+
feature: MapGeoJSONFeature<P>;
|
|
707
|
+
longitude: number;
|
|
708
|
+
latitude: number;
|
|
709
|
+
fallbackLabel: string;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Every coordinate in a geometry, flattened — enough for a bounding box. */
|
|
713
|
+
function geometryPositions(geometry: GeoJSON.Geometry): GeoJSON.Position[] {
|
|
714
|
+
if (geometry.type === "GeometryCollection") {
|
|
715
|
+
return geometry.geometries.flatMap(geometryPositions);
|
|
716
|
+
}
|
|
717
|
+
if (geometry.type === "Point") {
|
|
718
|
+
return [geometry.coordinates];
|
|
719
|
+
}
|
|
720
|
+
// Every remaining geometry nests Positions 1–3 arrays deep.
|
|
721
|
+
const flatten = (value: unknown): GeoJSON.Position[] =>
|
|
722
|
+
Array.isArray(value) && typeof value[0] === "number"
|
|
723
|
+
? [value as GeoJSON.Position]
|
|
724
|
+
: Array.isArray(value)
|
|
725
|
+
? value.flatMap(flatten)
|
|
726
|
+
: [];
|
|
727
|
+
return flatten(geometry.coordinates);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** The centre of a geometry's bounding box — where a keyboard "hover" lands. */
|
|
731
|
+
function geometryCentre(geometry: GeoJSON.Geometry): [number, number] {
|
|
732
|
+
const positions = geometryPositions(geometry);
|
|
733
|
+
if (positions.length === 0) return [0, 0];
|
|
734
|
+
let west = Number.POSITIVE_INFINITY;
|
|
735
|
+
let east = Number.NEGATIVE_INFINITY;
|
|
736
|
+
let south = Number.POSITIVE_INFINITY;
|
|
737
|
+
let north = Number.NEGATIVE_INFINITY;
|
|
738
|
+
for (const [lng, lat] of positions) {
|
|
739
|
+
west = Math.min(west, lng ?? 0);
|
|
740
|
+
east = Math.max(east, lng ?? 0);
|
|
741
|
+
south = Math.min(south, lat ?? 0);
|
|
742
|
+
north = Math.max(north, lat ?? 0);
|
|
743
|
+
}
|
|
744
|
+
return [(west + east) / 2, (south + north) / 2];
|
|
745
|
+
}
|
|
272
746
|
|
|
273
|
-
|
|
747
|
+
/**
|
|
748
|
+
* The features a keyboard list can offer. A `data` URL is fetched by MapLibre
|
|
749
|
+
* itself and never reaches React, and a bare Geometry carries no properties to
|
|
750
|
+
* read — both give an empty list rather than a row that says nothing.
|
|
751
|
+
*/
|
|
752
|
+
function listKeyboardFeatures<P extends GeoJSON.GeoJsonProperties>(
|
|
753
|
+
data: MapGeoJSONData<P>,
|
|
754
|
+
promoteId: string | undefined,
|
|
755
|
+
): KeyboardFeature<P>[] {
|
|
756
|
+
if (typeof data === "string") return [];
|
|
757
|
+
const features =
|
|
758
|
+
data.type === "FeatureCollection" ? data.features : data.type === "Feature" ? [data] : [];
|
|
759
|
+
return features.map((feature, index) => {
|
|
760
|
+
const promoted = promoteId ? feature.properties?.[promoteId] : undefined;
|
|
761
|
+
const id =
|
|
762
|
+
typeof promoted === "string" || typeof promoted === "number"
|
|
763
|
+
? promoted
|
|
764
|
+
: typeof feature.id === "string" || typeof feature.id === "number"
|
|
765
|
+
? feature.id
|
|
766
|
+
: undefined;
|
|
767
|
+
const [longitude, latitude] = geometryCentre(feature.geometry);
|
|
768
|
+
return {
|
|
769
|
+
key: `${id ?? index}`,
|
|
770
|
+
id,
|
|
771
|
+
feature: feature as unknown as MapGeoJSONFeature<P>,
|
|
772
|
+
longitude,
|
|
773
|
+
latitude,
|
|
774
|
+
fallbackLabel: `${id ?? index + 1}`,
|
|
775
|
+
};
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* One seamless tile of 45° stripes, `width` thick with `gap` between them
|
|
781
|
+
* (both measured across the stripes), in `color` — any CSS colour, so a
|
|
782
|
+
* resolved token works as-is. `null` where no 2D canvas exists (tests, SSR).
|
|
783
|
+
*/
|
|
784
|
+
function stripeImage(
|
|
785
|
+
color: string,
|
|
786
|
+
width: number,
|
|
787
|
+
gap: number,
|
|
788
|
+
): { data: ImageData; pixelRatio: number } | null {
|
|
789
|
+
if (typeof document === "undefined") return null;
|
|
790
|
+
const pixelRatio = Math.max(1, Math.round(globalThis.devicePixelRatio || 1));
|
|
791
|
+
// A 45° stripe period of (width + gap) across the stripes is √2 × that along x.
|
|
792
|
+
const size = Math.max(2, Math.round((width + gap) * Math.SQRT2 * pixelRatio));
|
|
793
|
+
const canvas = document.createElement("canvas");
|
|
794
|
+
canvas.width = size;
|
|
795
|
+
canvas.height = size;
|
|
796
|
+
let context: CanvasRenderingContext2D | null = null;
|
|
797
|
+
try {
|
|
798
|
+
context = canvas.getContext("2d");
|
|
799
|
+
} catch {
|
|
800
|
+
context = null;
|
|
801
|
+
}
|
|
802
|
+
if (!context) return null;
|
|
803
|
+
context.strokeStyle = color;
|
|
804
|
+
context.lineWidth = width * pixelRatio;
|
|
805
|
+
context.lineCap = "square";
|
|
806
|
+
context.beginPath();
|
|
807
|
+
// x + y = k·size for k = 0, 1, 2 covers the tile, corners included.
|
|
808
|
+
for (const k of [0, 1, 2]) {
|
|
809
|
+
context.moveTo(k * size - size, size);
|
|
810
|
+
context.lineTo(k * size, 0);
|
|
811
|
+
}
|
|
812
|
+
context.stroke();
|
|
813
|
+
return { data: context.getImageData(0, 0, size, size), pixelRatio };
|
|
274
814
|
}
|