@reventlessdev/reventless-map 0.1.0-alpha.1

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.
@@ -0,0 +1,107 @@
1
+ // Pure source → geo data mapping for MapView. Kept free of maplibre and
2
+ // React so the shapes are testable and reusable (e.g. by a future
3
+ // heatmap/route layer).
4
+
5
+ type point = {
6
+ rowKey: string,
7
+ title: string,
8
+ lng: float,
9
+ lat: float,
10
+ }
11
+
12
+ // Accents may be CSS custom-property references (var(--…)) from the shared
13
+ // canvas palette — those work in DOM styles but NOT on the WebGL canvas, so
14
+ // map layers substitute a literal palette by source index.
15
+ let literalPalette = ["#3b6ea5", "#7c9c5e", "#b0743c", "#7466a3", "#4d8a8a", "#a35f74"]
16
+
17
+ let layerColor = (source: ReventlessUi.AutoViewMode.source, idx: int): string =>
18
+ switch source.accent {
19
+ | Some(a) if !String.startsWith(a, "var(") => a
20
+ | _ => literalPalette->Array.getUnsafe(mod(idx, Array.length(literalPalette)))
21
+ }
22
+
23
+ let numberAt = (dict: Js.Dict.t<Js.Json.t>, field: string): option<float> =>
24
+ switch Js.Dict.get(dict, field) {
25
+ | None => None
26
+ | Some(v) =>
27
+ switch v->Js.Json.decodeNumber {
28
+ | Some(_) as n => n
29
+ // Tolerate stringly-typed coordinates from loose projections.
30
+ | None => v->Js.Json.decodeString->Option.flatMap(Float.fromString)
31
+ }
32
+ }
33
+
34
+ let pointsOf = (source: ReventlessUi.AutoViewMode.source): array<point> =>
35
+ switch source.semantics.geo {
36
+ | None => []
37
+ | Some(geo) =>
38
+ ReventlessUi.AutoModes.prepareRows(source)->Array.filterMap(row => {
39
+ switch (numberAt(row.dict, geo.lngField), numberAt(row.dict, geo.latField)) {
40
+ | (Some(lng), Some(lat)) =>
41
+ let title =
42
+ source.semantics.labelField
43
+ ->Option.flatMap(f => Js.Dict.get(row.dict, f)->Option.flatMap(Js.Json.decodeString))
44
+ ->Option.getOr(row.key)
45
+ Some({rowKey: row.key, title, lng, lat})
46
+ | _ => None
47
+ }
48
+ })
49
+ }
50
+
51
+ // GeoJSON FeatureCollection with rowKey/title properties — what the click
52
+ // handler reads back off the feature.
53
+ let featureCollection = (points: array<point>): Js.Json.t =>
54
+ Js.Json.object_(
55
+ Js.Dict.fromArray([
56
+ ("type", Js.Json.string("FeatureCollection")),
57
+ (
58
+ "features",
59
+ Js.Json.array(
60
+ points->Array.map(p =>
61
+ Js.Json.object_(
62
+ Js.Dict.fromArray([
63
+ ("type", Js.Json.string("Feature")),
64
+ (
65
+ "geometry",
66
+ Js.Json.object_(
67
+ Js.Dict.fromArray([
68
+ ("type", Js.Json.string("Point")),
69
+ (
70
+ "coordinates",
71
+ Js.Json.array([Js.Json.number(p.lng), Js.Json.number(p.lat)]),
72
+ ),
73
+ ]),
74
+ ),
75
+ ),
76
+ (
77
+ "properties",
78
+ Js.Json.object_(
79
+ Js.Dict.fromArray([
80
+ ("rowKey", Js.Json.string(p.rowKey)),
81
+ ("title", Js.Json.string(p.title)),
82
+ ]),
83
+ ),
84
+ ),
85
+ ]),
86
+ )
87
+ ),
88
+ ),
89
+ ),
90
+ ]),
91
+ )
92
+
93
+ // ((west, south), (east, north)) over every source's points.
94
+ let bounds = (allPoints: array<point>): option<((float, float), (float, float))> =>
95
+ switch allPoints {
96
+ | [] => None
97
+ | points => {
98
+ let first = points->Array.getUnsafe(0)
99
+ let init = ((first.lng, first.lat), (first.lng, first.lat))
100
+ Some(
101
+ points->Array.reduce(init, (((w, s), (e, n)), p) => (
102
+ (Js.Math.min_float(w, p.lng), Js.Math.min_float(s, p.lat)),
103
+ (Js.Math.max_float(e, p.lng), Js.Math.max_float(n, p.lat)),
104
+ )),
105
+ )
106
+ }
107
+ }
@@ -0,0 +1,159 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js";
4
+ import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Float from "@rescript/runtime/lib/es6/Stdlib_Float.js";
7
+ import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
8
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
9
+ import * as AutoModes$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/AutoModes.res.mjs";
10
+
11
+ let literalPalette = [
12
+ "#3b6ea5",
13
+ "#7c9c5e",
14
+ "#b0743c",
15
+ "#7466a3",
16
+ "#4d8a8a",
17
+ "#a35f74"
18
+ ];
19
+
20
+ function layerColor(source, idx) {
21
+ let a = source.accent;
22
+ if (a !== undefined) {
23
+ if (a.startsWith("var(")) {
24
+ return literalPalette[Primitive_int.mod_(idx, literalPalette.length)];
25
+ } else {
26
+ return a;
27
+ }
28
+ } else {
29
+ return literalPalette[Primitive_int.mod_(idx, literalPalette.length)];
30
+ }
31
+ }
32
+
33
+ function numberAt(dict, field) {
34
+ let v = Js_dict.get(dict, field);
35
+ if (v === undefined) {
36
+ return;
37
+ }
38
+ let n = Js_json.decodeNumber(v);
39
+ if (n !== undefined) {
40
+ return n;
41
+ } else {
42
+ return Stdlib_Option.flatMap(Js_json.decodeString(v), Stdlib_Float.fromString);
43
+ }
44
+ }
45
+
46
+ function pointsOf(source) {
47
+ let geo = source.semantics.geo;
48
+ if (geo !== undefined) {
49
+ return Stdlib_Array.filterMap(AutoModes$ReventlessUi.prepareRows(source), row => {
50
+ let match = numberAt(row.dict, geo.lngField);
51
+ let match$1 = numberAt(row.dict, geo.latField);
52
+ if (match === undefined) {
53
+ return;
54
+ }
55
+ if (match$1 === undefined) {
56
+ return;
57
+ }
58
+ let title = Stdlib_Option.getOr(Stdlib_Option.flatMap(source.semantics.labelField, f => Stdlib_Option.flatMap(Js_dict.get(row.dict, f), Js_json.decodeString)), row.key);
59
+ return {
60
+ rowKey: row.key,
61
+ title: title,
62
+ lng: match,
63
+ lat: match$1
64
+ };
65
+ });
66
+ } else {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ function featureCollection(points) {
72
+ return Js_dict.fromArray([
73
+ [
74
+ "type",
75
+ "FeatureCollection"
76
+ ],
77
+ [
78
+ "features",
79
+ points.map(p => Js_dict.fromArray([
80
+ [
81
+ "type",
82
+ "Feature"
83
+ ],
84
+ [
85
+ "geometry",
86
+ Js_dict.fromArray([
87
+ [
88
+ "type",
89
+ "Point"
90
+ ],
91
+ [
92
+ "coordinates",
93
+ [
94
+ p.lng,
95
+ p.lat
96
+ ]
97
+ ]
98
+ ])
99
+ ],
100
+ [
101
+ "properties",
102
+ Js_dict.fromArray([
103
+ [
104
+ "rowKey",
105
+ p.rowKey
106
+ ],
107
+ [
108
+ "title",
109
+ p.title
110
+ ]
111
+ ])
112
+ ]
113
+ ]))
114
+ ]
115
+ ]);
116
+ }
117
+
118
+ function bounds(allPoints) {
119
+ if (allPoints.length === 0) {
120
+ return;
121
+ }
122
+ let first = allPoints[0];
123
+ let init_0 = [
124
+ first.lng,
125
+ first.lat
126
+ ];
127
+ let init_1 = [
128
+ first.lng,
129
+ first.lat
130
+ ];
131
+ let init = [
132
+ init_0,
133
+ init_1
134
+ ];
135
+ return Stdlib_Array.reduce(allPoints, init, (param, p) => {
136
+ let match = param[1];
137
+ let match$1 = param[0];
138
+ return [
139
+ [
140
+ Math.min(match$1[0], p.lng),
141
+ Math.min(match$1[1], p.lat)
142
+ ],
143
+ [
144
+ Math.max(match[0], p.lng),
145
+ Math.max(match[1], p.lat)
146
+ ]
147
+ ];
148
+ });
149
+ }
150
+
151
+ export {
152
+ literalPalette,
153
+ layerColor,
154
+ numberAt,
155
+ pointsOf,
156
+ featureCollection,
157
+ bounds,
158
+ }
159
+ /* AutoModes-ReventlessUi Not a pure module */
@@ -0,0 +1,109 @@
1
+ // Phase 5 — registers the "map" view mode with reventless-ui's registry.
2
+ // Registration is an explicit opt-in (MapMode.init() from the host app),
3
+ // never a module side effect of reventless-ui: apps without geo data never
4
+ // load this package or maplibre-gl.
5
+
6
+ let defaultStyle = "https://demotiles.maplibre.org/style.json"
7
+
8
+ let styleRef = ref(defaultStyle)
9
+
10
+ // The geocoder handed to the geo-point form input (address search → coords).
11
+ // None ⇒ the input still works as a click-to-place map, just without search.
12
+ let geocoderRef: ref<option<GeoInput.geocoder>> = ref(None)
13
+
14
+ let mapIcon =
15
+ <span ariaHidden=true style={ReactDOM.Style.make(~fontSize="0.9em", ~lineHeight="1", ())}>
16
+ {React.string(`◎`)}
17
+ </span>
18
+
19
+ // ─── geo-point form input (SchemaForm pluggable-input registry) ──────────────
20
+ //
21
+ // A `geo-point` field carries a whole `{lat, lng}` object; GeoInput owns the
22
+ // JSON encode/decode of that object into the field's string formState value,
23
+ // so the submit-time coercion (which parses object-typed fields back) round-
24
+ // trips it. Registered by init() alongside the map view mode — one opt-in
25
+ // lights up both the map display and the map-backed command input.
26
+
27
+ let encodePoint = (~lat: float, ~lng: float): string =>
28
+ Js.Json.stringifyAny({"lat": lat, "lng": lng})->Option.getOr("")
29
+
30
+ let decodePoint = (value: string): option<(float, float)> =>
31
+ if value == "" {
32
+ None
33
+ } else {
34
+ switch Js.Json.parseExn(value)->Js.Json.decodeObject {
35
+ | Some(o) =>
36
+ switch (
37
+ Js.Dict.get(o, "lat")->Option.flatMap(Js.Json.decodeNumber),
38
+ Js.Dict.get(o, "lng")->Option.flatMap(Js.Json.decodeNumber),
39
+ ) {
40
+ | (Some(lat), Some(lng)) => Some((lat, lng))
41
+ | _ => None
42
+ }
43
+ | None => None
44
+ | exception _ => None
45
+ }
46
+ }
47
+
48
+ let geoPointInput = (
49
+ ~key as _,
50
+ ~value,
51
+ ~onChange,
52
+ ~onBlur as _,
53
+ ~required as _,
54
+ ~hasError as _,
55
+ ) => {
56
+ let (lat, lng) = switch decodePoint(value) {
57
+ | Some((la, ln)) => (Some(la), Some(ln))
58
+ | None => (None, None)
59
+ }
60
+ <GeoInput
61
+ ?lat
62
+ ?lng
63
+ onChange={((la, ln)) => onChange(encodePoint(~lat=la, ~lng=ln))}
64
+ mapStyle=styleRef.contents
65
+ geocoder=?geocoderRef.contents
66
+ />
67
+ }
68
+
69
+ // Non-empty values must decode to a full (lat, lng); empty is valid (an
70
+ // optional, not-yet-picked location).
71
+ let geoPointValidate = (raw: string): option<string> =>
72
+ raw == "" || decodePoint(raw)->Option.isSome ? None : Some("Pick a location on the map")
73
+
74
+ let registerGeoInput = (): unit =>
75
+ ReventlessUi.SemanticRenderers.register(
76
+ ReventlessUi.AutoSemantics.GeoPoint,
77
+ {
78
+ cell: (~key as _, ~value as _) => None,
79
+ detail: (~key as _, ~value as _) => None,
80
+ input: geoPointInput,
81
+ validateInput: geoPointValidate,
82
+ },
83
+ )
84
+
85
+ // Call once at app startup, before the first auto page renders. `mapStyle`
86
+ // should point at a full MapLibre style JSON (tiles + glyphs); the default
87
+ // demo style is for development only. `geocoder` (optional) powers address
88
+ // search in the geo-point command input; without it the input is still a
89
+ // click-to-place map.
90
+ let init = (~mapStyle: option<string>=?, ~geocoder: option<GeoInput.geocoder>=?, ()): unit => {
91
+ mapStyle->Option.forEach(s => styleRef := s)
92
+ geocoder->Option.forEach(g => geocoderRef := Some(g))
93
+ registerGeoInput()
94
+ ReventlessUi.AutoViewMode.register({
95
+ id: "map",
96
+ label: "Map",
97
+ icon: mapIcon,
98
+ // Canvas-composable: a "Map" canvas page layers every geo-capable read
99
+ // model of a plugin (one clustered layer each, plan Axis C1).
100
+ arity: Multi({min: 1}),
101
+ // Capable when the schema resolves a complete numeric lat/lng pair.
102
+ isCapable: schema => ReventlessUi.AutoSemantics.resolve(schema).geo->Option.isSome,
103
+ render: sources => <MapView sources mapStyle=styleRef.contents />,
104
+ // Keep in sync with the `optionalModes` entry in ui's ComponentManifest —
105
+ // this registration only exists at app runtime, so the manifest can't
106
+ // read it from the registry.
107
+ capability: "geo — a numeric lat/lng field pair",
108
+ })
109
+ }
@@ -0,0 +1,134 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js";
4
+ import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as JsxRuntime from "react/jsx-runtime";
7
+ import * as MapView$ReventlessMap from "./MapView.res.mjs";
8
+ import * as GeoInput$ReventlessMap from "./GeoInput.res.mjs";
9
+ import * as AutoViewMode$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/AutoViewMode.res.mjs";
10
+ import * as AutoSemantics$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/AutoSemantics.res.mjs";
11
+ import * as SemanticRenderers$ReventlessUi from "@reventlessdev/reventless-ui/src/auto/SemanticRenderers.res.mjs";
12
+
13
+ let defaultStyle = "https://demotiles.maplibre.org/style.json";
14
+
15
+ let styleRef = {
16
+ contents: defaultStyle
17
+ };
18
+
19
+ let geocoderRef = {
20
+ contents: undefined
21
+ };
22
+
23
+ let mapIcon = JsxRuntime.jsx("span", {
24
+ children: `◎`,
25
+ "aria-hidden": true,
26
+ style: {
27
+ fontSize: "0.9em",
28
+ lineHeight: "1"
29
+ }
30
+ });
31
+
32
+ function encodePoint(lat, lng) {
33
+ return Stdlib_Option.getOr(JSON.stringify({
34
+ lat: lat,
35
+ lng: lng
36
+ }), "");
37
+ }
38
+
39
+ function decodePoint(value) {
40
+ if (value === "") {
41
+ return;
42
+ }
43
+ let o;
44
+ try {
45
+ o = Js_json.decodeObject(JSON.parse(value));
46
+ } catch (exn) {
47
+ return;
48
+ }
49
+ if (o === undefined) {
50
+ return;
51
+ }
52
+ let match = Stdlib_Option.flatMap(Js_dict.get(o, "lat"), Js_json.decodeNumber);
53
+ let match$1 = Stdlib_Option.flatMap(Js_dict.get(o, "lng"), Js_json.decodeNumber);
54
+ if (match !== undefined && match$1 !== undefined) {
55
+ return [
56
+ match,
57
+ match$1
58
+ ];
59
+ }
60
+ }
61
+
62
+ function geoPointInput(param, value, onChange, param$1, param$2, param$3) {
63
+ let match = decodePoint(value);
64
+ let match$1 = match !== undefined ? [
65
+ match[0],
66
+ match[1]
67
+ ] : [
68
+ undefined,
69
+ undefined
70
+ ];
71
+ return JsxRuntime.jsx(GeoInput$ReventlessMap.make, {
72
+ lat: match$1[0],
73
+ lng: match$1[1],
74
+ onChange: param => onChange(encodePoint(param[0], param[1])),
75
+ mapStyle: styleRef.contents,
76
+ geocoder: geocoderRef.contents
77
+ });
78
+ }
79
+
80
+ function geoPointValidate(raw) {
81
+ if (raw === "" || Stdlib_Option.isSome(decodePoint(raw))) {
82
+ return;
83
+ } else {
84
+ return "Pick a location on the map";
85
+ }
86
+ }
87
+
88
+ function registerGeoInput() {
89
+ SemanticRenderers$ReventlessUi.register("GeoPoint", {
90
+ cell: (param, param$1) => {},
91
+ detail: (param, param$1) => {},
92
+ input: geoPointInput,
93
+ validateInput: geoPointValidate
94
+ });
95
+ }
96
+
97
+ function init(mapStyle, geocoder, param) {
98
+ Stdlib_Option.forEach(mapStyle, s => {
99
+ styleRef.contents = s;
100
+ });
101
+ Stdlib_Option.forEach(geocoder, g => {
102
+ geocoderRef.contents = g;
103
+ });
104
+ registerGeoInput();
105
+ AutoViewMode$ReventlessUi.register({
106
+ id: "map",
107
+ label: "Map",
108
+ icon: mapIcon,
109
+ arity: {
110
+ TAG: "Multi",
111
+ min: 1
112
+ },
113
+ isCapable: schema => Stdlib_Option.isSome(AutoSemantics$ReventlessUi.resolve(schema, undefined).geo),
114
+ render: sources => JsxRuntime.jsx(MapView$ReventlessMap.make, {
115
+ sources: sources,
116
+ mapStyle: styleRef.contents
117
+ }),
118
+ capability: "geo — a numeric lat/lng field pair"
119
+ });
120
+ }
121
+
122
+ export {
123
+ defaultStyle,
124
+ styleRef,
125
+ geocoderRef,
126
+ mapIcon,
127
+ encodePoint,
128
+ decodePoint,
129
+ geoPointInput,
130
+ geoPointValidate,
131
+ registerGeoInput,
132
+ init,
133
+ }
134
+ /* mapIcon Not a pure module */
@@ -0,0 +1,182 @@
1
+ // Phase 5 — the map view mode: one clustered point layer per source, accent
2
+ // colors, click → the source's drill detail. Registered by MapMode.init()
3
+ // (never by reventless-ui itself), so non-geo apps pay no bundle cost.
4
+ //
5
+ // The style URL must point at a full MapLibre style (tiles + glyphs); the
6
+ // default demo style is fine for development, real apps pass their own via
7
+ // MapMode.init(~mapStyle=…). Remember to import maplibre-gl's CSS once in
8
+ // the app (see README).
9
+
10
+ open Maplibre.MaplibreBindings
11
+
12
+ let sourceId = (idx: int): string => "autoui-src-" ++ Int.toString(idx)
13
+ let clusterLayerId = (idx: int): string => "autoui-clusters-" ++ Int.toString(idx)
14
+ let pointLayerId = (idx: int): string => "autoui-points-" ++ Int.toString(idx)
15
+
16
+ // Layer/spec JSON is built from trusted internal values only (indices +
17
+ // palette colors), so template-parsing is safe and beats hand-rolling
18
+ // nested Js.Dict pyramids.
19
+ let json = Js.Json.parseExn
20
+
21
+ let clusterSourceSpec = (data: Js.Json.t): Js.Json.t => {
22
+ let spec = Js.Dict.fromArray([
23
+ ("type", Js.Json.string("geojson")),
24
+ ("data", data),
25
+ ("cluster", Js.Json.boolean(true)),
26
+ ("clusterRadius", Js.Json.number(40.0)),
27
+ ("clusterMaxZoom", Js.Json.number(14.0)),
28
+ ])
29
+ Js.Json.object_(spec)
30
+ }
31
+
32
+ let clusterLayerSpec = (idx: int, color: string): Js.Json.t =>
33
+ json(`{
34
+ "id": "${clusterLayerId(idx)}",
35
+ "type": "circle",
36
+ "source": "${sourceId(idx)}",
37
+ "filter": ["has", "point_count"],
38
+ "paint": {
39
+ "circle-color": "${color}",
40
+ "circle-opacity": 0.6,
41
+ "circle-radius": ["step", ["get", "point_count"], 12, 10, 16, 50, 22]
42
+ }
43
+ }`)
44
+
45
+ let pointLayerSpec = (idx: int, color: string): Js.Json.t =>
46
+ json(`{
47
+ "id": "${pointLayerId(idx)}",
48
+ "type": "circle",
49
+ "source": "${sourceId(idx)}",
50
+ "filter": ["!", ["has", "point_count"]],
51
+ "paint": {
52
+ "circle-color": "${color}",
53
+ "circle-radius": 6,
54
+ "circle-stroke-width": 1.5,
55
+ "circle-stroke-color": "#ffffff"
56
+ }
57
+ }`)
58
+
59
+ let featureRowKey = (e: mapMouseEvent): option<string> =>
60
+ e.features
61
+ ->Js.Nullable.toOption
62
+ ->Option.flatMap(fs => fs->Array.get(0))
63
+ ->Option.flatMap(f =>
64
+ f.properties
65
+ ->Js.Json.decodeObject
66
+ ->Option.flatMap(o => Js.Dict.get(o, "rowKey"))
67
+ ->Option.flatMap(Js.Json.decodeString)
68
+ )
69
+
70
+ @react.component
71
+ let make = (~sources: array<ReventlessUi.AutoViewMode.source>, ~mapStyle: string) => {
72
+ let containerRef = React.useRef(Js.Nullable.null)
73
+ let mapRef: React.ref<option<map>> = React.useRef(None)
74
+ let (loaded, setLoaded) = React.useState(() => false)
75
+ // Click handlers register once per layer but must see the CURRENT sources
76
+ // (the canvas re-renders with fresh rows / hidden layers).
77
+ let sourcesRef = React.useRef(sources)
78
+ sourcesRef.current = sources
79
+ let didFit = React.useRef(false)
80
+ let installedSources: React.ref<array<string>> = React.useRef([])
81
+
82
+ React.useEffect0(() => {
83
+ switch containerRef.current->Js.Nullable.toOption {
84
+ | None => None
85
+ | Some(el) =>
86
+ let map = makeMap({container: el, style: mapStyle, center: (0.0, 20.0), zoom: 1.5})
87
+ let _ = map->addControl(makeNavigationControl())
88
+ map->on("load", () => setLoaded(_ => true))
89
+ mapRef.current = Some(map)
90
+ Some(
91
+ () => {
92
+ mapRef.current = None
93
+ map->remove
94
+ },
95
+ )
96
+ }
97
+ })
98
+
99
+ let openRowByKey = (idx: int, rowKey: string): unit =>
100
+ sourcesRef.current
101
+ ->Array.get(idx)
102
+ ->Option.forEach(source =>
103
+ ReventlessUi.AutoModes.prepareRows(source)
104
+ ->Array.find(r => r.key == rowKey)
105
+ ->Option.forEach(r => source.onOpenRow(~rowId=r.key, ~rowData=r.json))
106
+ )
107
+
108
+ // Sync layers whenever sources change: first sighting of a source index
109
+ // installs source + layers + handlers; afterwards only the data updates.
110
+ React.useEffect2(() => {
111
+ switch (mapRef.current, loaded) {
112
+ | (Some(map), true) =>
113
+ sources->Array.forEachWithIndex((source, idx) => {
114
+ let points = MapData.pointsOf(source)
115
+ let data = MapData.featureCollection(points)
116
+ let id = sourceId(idx)
117
+ switch map->getSource(id) {
118
+ | Some(geo) => geo->setData(data)
119
+ | None =>
120
+ if !(installedSources.current->Array.includes(id)) {
121
+ Array.push(installedSources.current, id)
122
+ map->addSource(id, clusterSourceSpec(data))
123
+ let color = MapData.layerColor(source, idx)
124
+ map->addLayer(clusterLayerSpec(idx, color))
125
+ map->addLayer(pointLayerSpec(idx, color))
126
+ map->onLayer("click", pointLayerId(idx), e =>
127
+ featureRowKey(e)->Option.forEach(k => openRowByKey(idx, k))
128
+ )
129
+ map->onLayer("click", clusterLayerId(idx), e =>
130
+ map->flyTo({center: (e.lngLat.lng, e.lngLat.lat), zoom: map->getZoom +. 2.0})
131
+ )
132
+ }
133
+ }
134
+ })
135
+ // Fit once, when the first non-empty data lands.
136
+ if !didFit.current {
137
+ let allPoints = sources->Array.flatMap(MapData.pointsOf)
138
+ switch MapData.bounds(allPoints) {
139
+ | Some(b) =>
140
+ didFit.current = true
141
+ map->fitBounds(b, {padding: 48, maxZoom: 12.0, duration: 0})
142
+ | None => ()
143
+ }
144
+ }
145
+ | _ => ()
146
+ }
147
+ None
148
+ }, (sources, loaded))
149
+
150
+ let total = sources->Array.reduce(0, (n, s) => n + Array.length(s.rows))
151
+
152
+ <div
153
+ style={ReactDOM.Style.make(~position="relative", ~width="100%", ~height="520px", ())}>
154
+ <div
155
+ ref={ReactDOM.Ref.domRef(containerRef)}
156
+ style={ReactDOM.Style.make(
157
+ ~position="absolute",
158
+ ~top="0",
159
+ ~bottom="0",
160
+ ~left="0",
161
+ ~right="0",
162
+ ~borderRadius="8px",
163
+ ~overflow="hidden",
164
+ (),
165
+ )}
166
+ />
167
+ <span
168
+ style={ReactDOM.Style.make(
169
+ ~position="absolute",
170
+ ~bottom="4px",
171
+ ~left="8px",
172
+ ~fontSize="11px",
173
+ ~opacity="0.7",
174
+ ~background="rgba(255,255,255,0.8)",
175
+ ~padding="1px 6px",
176
+ ~borderRadius="4px",
177
+ (),
178
+ )}>
179
+ {React.string(`Showing the ${Int.toString(total)} loaded item(s)`)}
180
+ </span>
181
+ </div>
182
+ }