@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.
- package/README.md +62 -0
- package/package.json +39 -0
- package/rescript.json +13 -0
- package/src/GeoInput.res +177 -0
- package/src/GeoInput.res.mjs +216 -0
- package/src/Geocoder.res +41 -0
- package/src/Geocoder.res.mjs +45 -0
- package/src/MapData.res +107 -0
- package/src/MapData.res.mjs +159 -0
- package/src/MapMode.res +109 -0
- package/src/MapMode.res.mjs +134 -0
- package/src/MapView.res +182 -0
- package/src/MapView.res.mjs +221 -0
package/src/MapData.res
ADDED
|
@@ -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 */
|
package/src/MapMode.res
ADDED
|
@@ -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 */
|
package/src/MapView.res
ADDED
|
@@ -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
|
+
}
|