MapInstance.res

  1module WaypointMarker = {
  2  @jsx.component
  3  let make = (~id: string, ~lon: float, ~lat: float, ~label: string, ~error: array<string>) => {
  4    <>
  5      <Maplibre.Marker
  6        key={id}
  7        longitude={lon}
  8        latitude={lat}
  9        anchor="center"
 10        opacity={1.0}
 11        opacityWhenCovered={1.0}
 12      >
 13        <Icons.Waypoint
 14          size={24}
 15          color={error->Array.length == 0 ? "var(--color-blue)" : "var(--color-red)"}
 16          strokeWidth={3}
 17          className="rounded-full bg-[#FFFFFF66]"
 18        />
 19      </Maplibre.Marker>
 20
 21      // hide labels when zoomed out at world level
 22      {State.zoom->Preact.Signal.get > 3.0
 23        ? <Maplibre.Marker
 24            key={id ++ "-text"}
 25            longitude={lon}
 26            latitude={lat}
 27            anchor="top-left"
 28            offset=(6, 6)
 29            color="#333333"
 30            opacity={1.0}
 31            opacityWhenCovered={1.0}
 32          >
 33            <div className="bg-[#333333] text-white rounded-sm px-1"> {label->Preact.string} </div>
 34          </Maplibre.Marker>
 35        : Preact.null}
 36    </>
 37  }
 38}
 39
 40// container for the distance popup, created outside to maintain state across renders
 41let popup: Preact.Signal.t<option<Jsx.element>> = Preact.Signal.make(None)
 42let initialRender = Preact.Signal.make(true)
 43
 44@jsx.component
 45let make = (~doc: string) => {
 46  let waypoints = State.getOrCreateWaypointsSignal(doc)
 47  let cmap = Preact.useRef(Nullable.null)
 48
 49  // Helper to safely fly to a point when map is available
 50  let flyToPoint = (point: (float, float), padding: option<(int, int)>) => {
 51    switch (cmap.current->Nullable.toOption, padding) {
 52    | (Some(map), Some(padding)) => {
 53        let (bottom, left) = padding
 54        map->Maplibre.flyTo({
 55          center: point,
 56          padding: {"bottom": bottom, "left": left, "top": 0, "right": 0},
 57        })
 58      }
 59    | (Some(map), _) => map->Maplibre.flyTo({center: point})
 60    | (None, _) => ()
 61    }
 62  }
 63
 64  // Extract route hashes from waypoints for rendering
 65  let routeHashes = Route.extractRouteHashes(waypoints->Preact.Signal.get)
 66  let currentWaypoints = waypoints->Preact.Signal.get
 67
 68  <Maplibre.Map
 69    id="map"
 70    cursor={#crosshair}
 71    initialViewState={
 72      latitude: 27.70972,
 73      longitude: 83.85382,
 74      zoom: 8.0,
 75    }
 76    onContextMenu={evt => ContextMenu.show->Preact.Signal.set(Some(evt))}
 77    onMoveEnd={event => {
 78      // update map state for other components read-only view
 79      State.center->Preact.Signal.set((event.viewState.longitude, event.viewState.latitude))
 80      State.zoom->Preact.Signal.set(event.viewState.zoom)
 81      Log.debug(
 82        "move",
 83        ~kv={
 84          "lat": event.viewState.latitude->Float.toFixed(~digits=6),
 85          "lng": event.viewState.longitude->Float.toFixed(~digits=6),
 86          "zoom": event.viewState.zoom->Float.toFixed(~digits=1),
 87        },
 88      )
 89    }}
 90    onLoad={event => {
 91      // on initial render or reload, focus the whole route around the waypoints bbox
 92      if initialRender->Preact.Signal.get && currentWaypoints->Array.length > 0 {
 93        let map = event.target
 94        let lats = currentWaypoints->Array.map(p => {
 95          let (_, lat) = p.point
 96          lat
 97        })
 98        let lons = currentWaypoints->Array.map(p => {
 99          let (lon, _) = p.point
100          lon
101        })
102        let bbox = (lons->Math.minMany, lats->Math.minMany, lons->Math.maxMany, lats->Math.maxMany)
103        let isSinglePoint = currentWaypoints->Array.length == 1
104        if Window.isMobile->Preact.Signal.get {
105          // small screens, fit it above the half-height sheet
106          switch (Window.getSize(), isSinglePoint) {
107          | (Some(size), false) =>
108            map->Maplibre.fitBoundsWithOptions(
109              bbox,
110              {padding: {"bottom": size.height / 2 + 50, "left": 50, "top": 50, "right": 50}},
111            )
112          | (Some(size), true) =>
113            currentWaypoints
114            ->Array.get(0)
115            ->Option.map(wp => flyToPoint(wp.point, Some((size.height / 2, 0))))
116            ->Option.getOr()
117          | (None, _) => map->Maplibre.fitBounds(bbox)
118          }
119        } else {
120          // large screens, estimate it about 1/4 width, could be partially covered by the sheet
121          switch (Window.getSize(), isSinglePoint) {
122          | (Some(size), false) =>
123            map->Maplibre.fitBoundsWithOptions(
124              bbox,
125              {padding: {"bottom": 50, "left": size.width / 4, "top": 50, "right": 50}},
126            )
127          | (Some(size), true) =>
128            currentWaypoints
129            ->Array.get(0)
130            ->Option.map(wp => flyToPoint(wp.point, Some((0, size.width / 4))))
131            ->Option.getOr()
132          | (None, _) => map->Maplibre.fitBounds(bbox)
133          }
134        }
135      }
136      // prevent controlled movement after initial render
137      initialRender->Preact.Signal.set(false)
138
139      // set initial map center and zoom
140      event.target
141      ->Maplibre.getCenter
142      ->((center: Maplibre.center) => {State.center->Preact.Signal.set((center.lng, center.lat))})
143      State.zoom->Preact.Signal.set(event.target->Maplibre.getZoom)
144
145      // set map at top level for use in calling map methods
146      cmap.current = event.target->Nullable.make
147      ()
148    }}
149    onMouseLeave={_event => popup->Preact.Signal.set(None)}
150    onMouseEnter={event => MapEvents.handleDistance(~event, ~popup, ~waypoints=currentWaypoints)}
151    //onMouseDown={event => MapEvents.handleDistance(~event, ~popup, ~waypoints=currentWaypoints)}
152    interactiveLayerIds={routeHashes->Array.map(hash => `route-layer-${hash}`)}
153    //mapStyle=Maplibre.defaultStyle
154    mapStyle=Style.rtwStyle
155  >
156    // Render route sources and layers with URL-based GeoJSON
157    // Service worker handles caching with CacheFirst strategy
158    {routeHashes
159    ->Array.map(hash => {
160      <Preact.jsxFragment key={`route-fragment-${hash}`}>
161        <Maplibre.Source
162          id={`route-${hash}`} type_="geojson" data={Route.getGeoJSONUrl(hash)->Obj.magic}
163        />
164        <Maplibre.Layer
165          id={`route-layer-${hash}`}
166          type_="line"
167          source={`route-${hash}`}
168          paint={
169            let paintDict = Dict.make()
170            paintDict->Dict.set("line-color", JSON.Encode.string("#205ea6"))
171            paintDict->Dict.set("line-width", JSON.Encode.float(8.0))
172            paintDict->JSON.Encode.object
173          }
174        />
175      </Preact.jsxFragment>
176    })
177    ->Preact.array}
178
179    // Render waypoint markers on top
180    {switch currentWaypoints {
181    | [] => <> </>
182    | [wp1] => {
183        // one point = initial departure point => center map for better planning
184        // add some padding about 25% screen size based on form factor to move the
185        // centered point off the panel edge in worst case dimensions
186        let (lon, lat) = wp1.point
187        if Window.isMobile->Preact.Signal.get {
188          switch Window.getSize() {
189          | Some(size) => flyToPoint(wp1.point, Some((size.height / 4, 0))) // bottom padding on mobile
190          | None => flyToPoint(wp1.point, None)
191          }
192        } else {
193          switch Window.getSize() {
194          | Some(size) => flyToPoint(wp1.point, Some((0, size.width / 4))) // left padding
195          | None => flyToPoint(wp1.point, None)
196          }
197        }
198        <WaypointMarker
199          lon={lon} lat={lat} id={wp1.id} label={wp1.label} error={wp1.error->Option.getOr([])}
200        />
201      }
202    // multiple points, remain uncontrolled, expect user to adjust
203    // initial render relies on server setting the initData for view state to bbox
204    | wp =>
205      wp
206      ->Array.map((wp: Waypoint.attributes) => {
207        let (lon, lat) = wp.point
208        <WaypointMarker
209          lon={lon} lat={lat} id={wp.id} label={wp.label} error={wp.error->Option.getOr([])}
210        />
211      })
212      ->Preact.array
213    }}
214    {popup->Preact.Signal.get->Preact.option}
215    <ContextMenu doc={doc} />
216  </Maplibre.Map>
217}