@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/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
|
+
}
|
package/src/GeoInput.res
ADDED
|
@@ -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 */
|
package/src/Geocoder.res
ADDED
|
@@ -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 */
|