@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 ADDED
@@ -0,0 +1,62 @@
1
+ # @reventlessdev/reventless-map
2
+
3
+ The **geo view mode** for Reventless AutoUI, built on the framework-agnostic
4
+ [`@reventlessdev/rescript-maplibre`](../../rescript/maplibre) bindings:
5
+
6
+ - `ReventlessMap.MapMode.init()` — registers the `"map"` view mode (clustered
7
+ point layer per source, accent colors, click → drill detail). Offered
8
+ automatically wherever a read model's schema resolves a numeric
9
+ `lat`/`lng` pair (see `AutoSemantics`), both as a list toggle and as a
10
+ per-plugin "Map" canvas page.
11
+ - `ReventlessMap.GeoInput` — click-to-place picker for the geo semantic, with an
12
+ optional provider-agnostic `geocoder` adapter (`search: string =>
13
+ promise<array<{label, lat, lng}>>`; the AWS Location implementation lives
14
+ in reventless-aws).
15
+ - `ReventlessMap.MapView` — the underlying map component the `"map"` mode renders.
16
+
17
+ The maplibre-gl bindings themselves live in the sibling
18
+ `@reventlessdev/rescript-maplibre` package (`Maplibre.MaplibreBindings`), which
19
+ carries no Reventless dependency and can be reused on its own.
20
+
21
+ This package is an **optional add-on**: reventless-ui never imports it, so
22
+ apps without geo data pay no bundle cost.
23
+
24
+ ## Setup
25
+
26
+ ```bash
27
+ pnpm add @reventlessdev/reventless-map maplibre-gl
28
+ ```
29
+
30
+ `rescript.json`:
31
+
32
+ ```json
33
+ "dependencies": ["@reventlessdev/reventless-map"]
34
+ ```
35
+
36
+ App entry (once, before the first auto page renders):
37
+
38
+ ```rescript
39
+ // maplibre-gl's stylesheet — import it once in your app entry (JS/Vite):
40
+ // import "maplibre-gl/dist/maplibre-gl.css"
41
+ ReventlessMap.MapMode.init(~mapStyle="https://your-tiles.example/style.json", ())
42
+ ```
43
+
44
+ The default style is MapLibre's demo tiles — development only; pass a real
45
+ style URL (tiles + glyphs) in production.
46
+
47
+ ## Building in this repo
48
+
49
+ `pnpm run build` here uses `rescript-legacy`: the ReScript v12 build
50
+ orchestrator still panics (UTF-8 underline bug) when invoked from a
51
+ downstream package that walks into reventless-ui's bs-dependencies. Build
52
+ reventless-ui first (`rescript build` from `reventless/ui`), then this
53
+ package.
54
+
55
+ ## Stories
56
+
57
+ Storybook lives in `reventless/ui`, but this package sits **downstream** of
58
+ reventless-ui, so its stories compile without the build cycle that a story
59
+ placed inside ui would create. Author map stories under `stories/` here; ui's
60
+ Storybook picks them up via a glob that also scans `../../map/stories`, and ui
61
+ carries maplibre-gl + this package as **dev**-only dependencies so the bundler
62
+ resolves them (the published ui surface stays maplibre-free).
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@reventlessdev/reventless-map",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Geo view mode (MapView, MapMode, GeoInput) for Reventless AutoUI, built on @reventlessdev/rescript-maplibre. Optional add-on: non-geo apps never load it.",
5
+ "license": "MIT",
6
+ "files": [
7
+ "src/**/*.res",
8
+ "src/**/*.res.mjs",
9
+ "rescript.json"
10
+ ],
11
+ "scripts": {
12
+ "build": "rescript-legacy build",
13
+ "start": "rescript-legacy build -w",
14
+ "clean": "rescript-legacy clean",
15
+ "test": "echo \"no test specified\""
16
+ },
17
+ "devDependencies": {
18
+ "@rescript/react": "^0.13.1",
19
+ "@reventlessdev/rescript-maplibre": "0.1.0-alpha.3",
20
+ "@reventlessdev/reventless-ui": "3.0.0-alpha.40",
21
+ "maplibre-gl": "^5.6.0",
22
+ "react": "^18.3.1",
23
+ "react-dom": "^18.3.1",
24
+ "rescript": "^12.3.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@rescript/react": "^0.13.1",
28
+ "@reventlessdev/rescript-maplibre": ">=0.1.0-alpha.2",
29
+ "@reventlessdev/reventless-ui": ">=3.0.0-alpha.37",
30
+ "maplibre-gl": ">=4.0.0",
31
+ "react": "^18.3.1",
32
+ "rescript": "^12.3.0"
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org",
36
+ "access": "public"
37
+ },
38
+ "gitHead": "ee9a72b1a1f76e7fdeab58922e4fb653a1b20cec"
39
+ }
package/rescript.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@reventlessdev/reventless-map",
3
+ "namespace": "ReventlessMap",
4
+ "jsx": { "version": 4 },
5
+ "sources": [
6
+ { "dir": "src", "subdirs": true },
7
+ { "dir": "stories", "subdirs": true, "type": "dev" }
8
+ ],
9
+ "package-specs": { "module": "esmodule", "in-source": true },
10
+ "suffix": ".res.mjs",
11
+ "warnings": { "number": "-3" },
12
+ "dependencies": ["@rescript/react", "@reventlessdev/reventless-ui", "@reventlessdev/rescript-maplibre"]
13
+ }
@@ -0,0 +1,177 @@
1
+ // Phase 5 — click-to-place input for the geo semantic: click or drag the
2
+ // marker to pick a (lat, lng); optional geocoding search through a
3
+ // provider-agnostic adapter (the AWS Location implementation belongs to
4
+ // reventless-aws — separate core-repo plan).
5
+ //
6
+ // SchemaForm doesn't yet expose pluggable field inputs, so command forms
7
+ // can't auto-adopt this; apps use it in custom forms today. The SchemaForm
8
+ // input registry is noted as follow-up in the plan.
9
+
10
+ open Maplibre.MaplibreBindings
11
+
12
+ type geocodeResult = {
13
+ label: string,
14
+ lat: float,
15
+ lng: float,
16
+ }
17
+
18
+ // Provider-agnostic geocoder adapter.
19
+ type geocoder = {search: string => promise<array<geocodeResult>>}
20
+
21
+ @react.component
22
+ let make = (
23
+ ~lat: option<float>=?,
24
+ ~lng: option<float>=?,
25
+ ~onChange: ((float, float)) => unit, // (lat, lng)
26
+ ~mapStyle: string="https://demotiles.maplibre.org/style.json",
27
+ ~geocoder: option<geocoder>=?,
28
+ ) => {
29
+ let containerRef = React.useRef(Js.Nullable.null)
30
+ let mapRef: React.ref<option<map>> = React.useRef(None)
31
+ let markerRef: React.ref<option<marker>> = React.useRef(None)
32
+ let (query, setQuery) = React.useState(() => "")
33
+ let (results, setResults) = React.useState(() => [])
34
+
35
+ let placeMarker = (map: map, ~lng: float, ~lat: float): unit =>
36
+ switch markerRef.current {
37
+ | Some(m) => {
38
+ let _ = m->setLngLat((lng, lat))
39
+ }
40
+ | None => {
41
+ let m = makeMarker({draggable: true})
42
+ let _ = m->setLngLat((lng, lat))
43
+ let _ = m->addTo(map)
44
+ m->onMarker("dragend", () => {
45
+ let pos = m->getLngLat
46
+ onChange((pos.lat, pos.lng))
47
+ })
48
+ markerRef.current = Some(m)
49
+ }
50
+ }
51
+
52
+ React.useEffect0(() => {
53
+ switch containerRef.current->Js.Nullable.toOption {
54
+ | None => None
55
+ | Some(el) =>
56
+ let center = switch (lng, lat) {
57
+ | (Some(lngV), Some(latV)) => (lngV, latV)
58
+ | _ => (0.0, 20.0)
59
+ }
60
+ let map = makeMap({
61
+ container: el,
62
+ style: mapStyle,
63
+ center,
64
+ zoom: lat->Option.isSome ? 12.0 : 1.5,
65
+ })
66
+ let _ = map->addControl(makeNavigationControl())
67
+ map->onMouse("click", e => {
68
+ placeMarker(map, ~lng=e.lngLat.lng, ~lat=e.lngLat.lat)
69
+ onChange((e.lngLat.lat, e.lngLat.lng))
70
+ })
71
+ switch (lng, lat) {
72
+ | (Some(lngV), Some(latV)) =>
73
+ map->on("load", () => placeMarker(map, ~lng=lngV, ~lat=latV))
74
+ | _ => ()
75
+ }
76
+ mapRef.current = Some(map)
77
+ Some(
78
+ () => {
79
+ mapRef.current = None
80
+ markerRef.current = None
81
+ map->remove
82
+ },
83
+ )
84
+ }
85
+ })
86
+
87
+ let pick = (r: geocodeResult): unit =>
88
+ switch mapRef.current {
89
+ | Some(map) => {
90
+ map->flyTo({center: (r.lng, r.lat), zoom: 13.0})
91
+ placeMarker(map, ~lng=r.lng, ~lat=r.lat)
92
+ onChange((r.lat, r.lng))
93
+ setResults(_ => [])
94
+ }
95
+ | None => ()
96
+ }
97
+
98
+ let runSearch = (): unit =>
99
+ switch geocoder {
100
+ | Some(g) if query != "" =>
101
+ let _p =
102
+ g.search(query)
103
+ ->Promise.then(rs => {
104
+ setResults(_ => rs)
105
+ Promise.resolve()
106
+ })
107
+ ->Promise.catch(_ => Promise.resolve())
108
+ ()
109
+ | _ => ()
110
+ }
111
+
112
+ <div style={ReactDOM.Style.make(~display="flex", ~flexDirection="column", ~gap="6px", ())}>
113
+ {switch geocoder {
114
+ | None => React.null
115
+ | Some(_) =>
116
+ <div style={ReactDOM.Style.make(~display="flex", ~gap="6px", ())}>
117
+ <input
118
+ type_="text"
119
+ placeholder="Search address…"
120
+ value=query
121
+ onChange={e => setQuery(ReactEvent.Form.target(e)["value"])}
122
+ onKeyDown={e =>
123
+ if ReactEvent.Keyboard.key(e) == "Enter" {
124
+ ReactEvent.Keyboard.preventDefault(e)
125
+ runSearch()
126
+ }}
127
+ style={ReactDOM.Style.make(~flex="1", ())}
128
+ />
129
+ <button type_="button" onClick={_ => runSearch()}> {React.string("Search")} </button>
130
+ </div>
131
+ }}
132
+ {Array.length(results) > 0
133
+ ? <ul style={ReactDOM.Style.make(~margin="0", ~paddingLeft="16px", ())}>
134
+ {results
135
+ ->Array.map(r =>
136
+ <li key=r.label>
137
+ <button
138
+ type_="button"
139
+ style={ReactDOM.Style.make(
140
+ ~background="none",
141
+ ~border="none",
142
+ ~padding="0",
143
+ ~cursor="pointer",
144
+ ~textDecoration="underline",
145
+ (),
146
+ )}
147
+ onClick={_ => pick(r)}>
148
+ {React.string(r.label)}
149
+ </button>
150
+ </li>
151
+ )
152
+ ->React.array}
153
+ </ul>
154
+ : React.null}
155
+ <div
156
+ ref={ReactDOM.Ref.domRef(containerRef)}
157
+ style={ReactDOM.Style.make(
158
+ ~height="280px",
159
+ ~borderRadius="8px",
160
+ ~overflow="hidden",
161
+ (),
162
+ )}
163
+ />
164
+ {switch (lat, lng) {
165
+ | (Some(latV), Some(lngV)) =>
166
+ <span style={ReactDOM.Style.make(~fontSize="12px", ~opacity="0.7", ())}>
167
+ {React.string(
168
+ `Selected: ${Float.toString(latV)}, ${Float.toString(lngV)} (click map or drag marker to change)`,
169
+ )}
170
+ </span>
171
+ | _ =>
172
+ <span style={ReactDOM.Style.make(~fontSize="12px", ~opacity="0.7", ())}>
173
+ {React.string("Click the map to place a location")}
174
+ </span>
175
+ }}
176
+ </div>
177
+ }
@@ -0,0 +1,216 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as React from "react";
4
+ import * as MaplibreGl from "maplibre-gl";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
7
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
8
+ import * as JsxRuntime from "react/jsx-runtime";
9
+
10
+ function GeoInput(props) {
11
+ let geocoder = props.geocoder;
12
+ let __mapStyle = props.mapStyle;
13
+ let onChange = props.onChange;
14
+ let lng = props.lng;
15
+ let lat = props.lat;
16
+ let __mapStyle_value = __mapStyle !== undefined ? __mapStyle : "https://demotiles.maplibre.org/style.json";
17
+ let containerRef = React.useRef(null);
18
+ let mapRef = React.useRef(undefined);
19
+ let markerRef = React.useRef(undefined);
20
+ let match = React.useState(() => "");
21
+ let setQuery = match[1];
22
+ let query = match[0];
23
+ let match$1 = React.useState(() => []);
24
+ let setResults = match$1[1];
25
+ let results = match$1[0];
26
+ let placeMarker = (map, lng, lat) => {
27
+ let m = markerRef.current;
28
+ if (m !== undefined) {
29
+ Primitive_option.valFromOption(m).setLngLat([
30
+ lng,
31
+ lat
32
+ ]);
33
+ return;
34
+ }
35
+ let m$1 = new MaplibreGl.Marker({
36
+ draggable: true
37
+ });
38
+ m$1.setLngLat([
39
+ lng,
40
+ lat
41
+ ]);
42
+ m$1.addTo(map);
43
+ m$1.on("dragend", () => {
44
+ let pos = m$1.getLngLat();
45
+ onChange([
46
+ pos.lat,
47
+ pos.lng
48
+ ]);
49
+ });
50
+ markerRef.current = Primitive_option.some(m$1);
51
+ };
52
+ React.useEffect(() => {
53
+ let el = containerRef.current;
54
+ if (el == null) {
55
+ return;
56
+ }
57
+ let center = lng !== undefined ? (
58
+ lat !== undefined ? [
59
+ lng,
60
+ lat
61
+ ] : [
62
+ 0.0,
63
+ 20.0
64
+ ]
65
+ ) : [
66
+ 0.0,
67
+ 20.0
68
+ ];
69
+ let map = new MaplibreGl.Map({
70
+ container: el,
71
+ style: __mapStyle_value,
72
+ center: center,
73
+ zoom: Stdlib_Option.isSome(lat) ? 12.0 : 1.5
74
+ });
75
+ map.addControl(new MaplibreGl.NavigationControl());
76
+ map.on("click", e => {
77
+ placeMarker(map, e.lngLat.lng, e.lngLat.lat);
78
+ onChange([
79
+ e.lngLat.lat,
80
+ e.lngLat.lng
81
+ ]);
82
+ });
83
+ if (lng !== undefined && lat !== undefined) {
84
+ map.on("load", () => placeMarker(map, lng, lat));
85
+ }
86
+ mapRef.current = Primitive_option.some(map);
87
+ return () => {
88
+ mapRef.current = undefined;
89
+ markerRef.current = undefined;
90
+ map.remove();
91
+ };
92
+ }, []);
93
+ let runSearch = () => {
94
+ if (geocoder !== undefined && query !== "") {
95
+ Stdlib_Promise.$$catch(geocoder.search(query).then(rs => {
96
+ setResults(param => rs);
97
+ return Promise.resolve();
98
+ }), param => Promise.resolve());
99
+ return;
100
+ }
101
+ };
102
+ let tmp;
103
+ let exit = 0;
104
+ if (lat !== undefined && lng !== undefined) {
105
+ tmp = JsxRuntime.jsx("span", {
106
+ children: `Selected: ` + lat.toString() + `, ` + lng.toString() + ` (click map or drag marker to change)`,
107
+ style: {
108
+ fontSize: "12px",
109
+ opacity: "0.7"
110
+ }
111
+ });
112
+ } else {
113
+ exit = 1;
114
+ }
115
+ if (exit === 1) {
116
+ tmp = JsxRuntime.jsx("span", {
117
+ children: "Click the map to place a location",
118
+ style: {
119
+ fontSize: "12px",
120
+ opacity: "0.7"
121
+ }
122
+ });
123
+ }
124
+ return JsxRuntime.jsxs("div", {
125
+ children: [
126
+ geocoder !== undefined ? JsxRuntime.jsxs("div", {
127
+ children: [
128
+ JsxRuntime.jsx("input", {
129
+ style: {
130
+ flex: "1"
131
+ },
132
+ placeholder: "Search address…",
133
+ type: "text",
134
+ value: query,
135
+ onKeyDown: e => {
136
+ if (e.key === "Enter") {
137
+ e.preventDefault();
138
+ return runSearch();
139
+ }
140
+ },
141
+ onChange: e => setQuery(e.target.value)
142
+ }),
143
+ JsxRuntime.jsx("button", {
144
+ children: "Search",
145
+ type: "button",
146
+ onClick: param => runSearch()
147
+ })
148
+ ],
149
+ style: {
150
+ display: "flex",
151
+ gap: "6px"
152
+ }
153
+ }) : null,
154
+ results.length !== 0 ? JsxRuntime.jsx("ul", {
155
+ children: results.map(r => JsxRuntime.jsx("li", {
156
+ children: JsxRuntime.jsx("button", {
157
+ children: r.label,
158
+ style: {
159
+ background: "none",
160
+ border: "none",
161
+ cursor: "pointer",
162
+ padding: "0",
163
+ textDecoration: "underline"
164
+ },
165
+ type: "button",
166
+ onClick: param => {
167
+ let map = mapRef.current;
168
+ if (map === undefined) {
169
+ return;
170
+ }
171
+ let map$1 = Primitive_option.valFromOption(map);
172
+ map$1.flyTo({
173
+ center: [
174
+ r.lng,
175
+ r.lat
176
+ ],
177
+ zoom: 13.0
178
+ });
179
+ placeMarker(map$1, r.lng, r.lat);
180
+ onChange([
181
+ r.lat,
182
+ r.lng
183
+ ]);
184
+ setResults(param => []);
185
+ }
186
+ })
187
+ }, r.label)),
188
+ style: {
189
+ margin: "0",
190
+ paddingLeft: "16px"
191
+ }
192
+ }) : null,
193
+ JsxRuntime.jsx("div", {
194
+ ref: Primitive_option.some(containerRef),
195
+ style: {
196
+ height: "280px",
197
+ overflow: "hidden",
198
+ borderRadius: "8px"
199
+ }
200
+ }),
201
+ tmp
202
+ ],
203
+ style: {
204
+ display: "flex",
205
+ flexDirection: "column",
206
+ gap: "6px"
207
+ }
208
+ });
209
+ }
210
+
211
+ let make = GeoInput;
212
+
213
+ export {
214
+ make,
215
+ }
216
+ /* react Not a pure module */
@@ -0,0 +1,41 @@
1
+ // Constructs a `GeoInput.geocoder` from a deploy-time endpoint URL. AWS
2
+ // Location Service needs SigV4-signed requests, so a browser can't call it
3
+ // directly; the deployment exposes a thin proxy endpoint (a Lambda / API
4
+ // route, owned by reventless-aws) that the shell reads from config.json and
5
+ // hands here. The proxy takes a `?q=` query string and returns a JSON array
6
+ // of `{label, lat, lng}` — exactly `GeoInput.geocodeResult`.
7
+ //
8
+ // Kept endpoint-shaped (not SDK-shaped) on purpose: the UI never holds AWS
9
+ // credentials, and swapping the geocoding provider is a server-side change
10
+ // with no UI rebuild.
11
+
12
+ @val external fetch: string => promise<{..}> = "fetch"
13
+ @val @scope("global") external encodeURIComponent: string => string = "encodeURIComponent"
14
+
15
+ let decodeResult = (json: Js.Json.t): option<GeoInput.geocodeResult> =>
16
+ json
17
+ ->Js.Json.decodeObject
18
+ ->Option.flatMap(o =>
19
+ switch (
20
+ Js.Dict.get(o, "label")->Option.flatMap(Js.Json.decodeString),
21
+ Js.Dict.get(o, "lat")->Option.flatMap(Js.Json.decodeNumber),
22
+ Js.Dict.get(o, "lng")->Option.flatMap(Js.Json.decodeNumber),
23
+ ) {
24
+ | (Some(label), Some(lat), Some(lng)) => Some({GeoInput.label, lat, lng})
25
+ | _ => None
26
+ }
27
+ )
28
+
29
+ let decodeResults = (json: Js.Json.t): array<GeoInput.geocodeResult> =>
30
+ json->Js.Json.decodeArray->Option.getOr([])->Array.filterMap(decodeResult)
31
+
32
+ // Any failure (network, non-2xx, malformed body) resolves to `[]` — the map
33
+ // input degrades to click-to-place rather than throwing into the form.
34
+ let fromEndpoint = (endpoint: string): GeoInput.geocoder => {
35
+ search: query =>
36
+ fetch(`${endpoint}?q=${encodeURIComponent(query)}`)
37
+ ->Promise.then(res =>
38
+ res["ok"] ? res["json"]()->Promise.thenResolve(decodeResults) : Promise.resolve([])
39
+ )
40
+ ->Promise.catch(_ => Promise.resolve([])),
41
+ }
@@ -0,0 +1,45 @@
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_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
8
+
9
+ function decodeResult(json) {
10
+ return Stdlib_Option.flatMap(Js_json.decodeObject(json), o => {
11
+ let match = Stdlib_Option.flatMap(Js_dict.get(o, "label"), Js_json.decodeString);
12
+ let match$1 = Stdlib_Option.flatMap(Js_dict.get(o, "lat"), Js_json.decodeNumber);
13
+ let match$2 = Stdlib_Option.flatMap(Js_dict.get(o, "lng"), Js_json.decodeNumber);
14
+ if (match !== undefined && match$1 !== undefined && match$2 !== undefined) {
15
+ return {
16
+ label: match,
17
+ lat: match$1,
18
+ lng: match$2
19
+ };
20
+ }
21
+ });
22
+ }
23
+
24
+ function decodeResults(json) {
25
+ return Stdlib_Array.filterMap(Stdlib_Option.getOr(Js_json.decodeArray(json), []), decodeResult);
26
+ }
27
+
28
+ function fromEndpoint(endpoint) {
29
+ return {
30
+ search: query => Stdlib_Promise.$$catch(fetch(endpoint + `?q=` + global.encodeURIComponent(query)).then(res => {
31
+ if (res.ok) {
32
+ return res.json().then(decodeResults);
33
+ } else {
34
+ return Promise.resolve([]);
35
+ }
36
+ }), param => Promise.resolve([]))
37
+ };
38
+ }
39
+
40
+ export {
41
+ decodeResult,
42
+ decodeResults,
43
+ fromEndpoint,
44
+ }
45
+ /* No side effect */