A browser-only Lustre component for rendering GPS tracks with Leaflet

README.md

track_map #

A Lustre component for rendering GPS tracks with Leaflet.

track_map targets JavaScript and runs in the browser.

Installation #

gleam add track_map

Usage #

When using Lustre's development tools, add Leaflet to the generated page:

[tools.lustre.html]
stylesheets = [
  { href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" },
]
scripts = [
  { src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" },
]

Register the component before starting the Lustre app:

pub fn main() {
  let assert Ok(Nil) = track_map.register()

  let app = lustre.simple(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)

  Nil
}

Render a map directly from the app's view function:

fn view(model: Model) -> Element(Message) {
  html.main([], [
    track_map.component(model.samples),
  ])
}

That is the complete setup for a regular Lustre SPA; no server rendering or hydration is involved.

Convert application GPS records into Sample values at the browser boundary:

import gleam/list
import gleam/option.{Some}
import track_map

// Replace NavigationSample with the application's GPS record type.
fn map_samples(
  samples: List(NavigationSample),
) -> Result(List(track_map.Sample), track_map.SampleError) {
  samples
  |> list.try_map(fn(sample) {
    track_map.sample(
      latitude_e7: sample.latitude_e7,
      longitude_e7: sample.longitude_e7,
      timestamp_ms: Some(sample.timestamp_ms),
      speed_mm_s: Some(sample.speed_mm_s),
    )
  })
}

sample rejects out-of-range coordinates and negative speeds, so invalid data is handled before an element or Leaflet map is created.

Server-rendered documents #

track_map still runs in the browser. If the server generates the outer HTML document, include dependencies in its head instead of configuring tools.lustre.html:

html.head([], [
  track_map.dependencies(),
  // Other head elements.
])

The browser entrypoint still calls track_map.register() before lustre.start. The same view works whether the rest of the app starts from an empty mount point or hydrates server-rendered HTML.

Coordinates use 1e-7 degrees. Timestamps use Unix milliseconds and speeds use millimetres per second. Speed colors the route from blue (slow) to red (fast) relative to the supplied samples. Select any sample to inspect its recorded time and speed. ● marks a track's start and ■ marks its end. Dates follow the browser locale; the package adds no translatable UI copy.

Use component_unstyled when the host needs custom attributes or sizing. Unstyled components need a non-zero height; component provides a 320px minimum height.

With no samples, the component shows ∅.

Runtime errors #

Map setup and tile loading happen after the element is created. Applications can listen for those failures using on_error:

import lustre/attribute

pub type Message {
  MapFailed(track_map.MapError)
}

fn map(samples: List(track_map.Sample)) {
  track_map.component_unstyled(samples, [
    track_map.on_error(MapFailed),
    attribute.styles([
      #("display", "block"),
      #("width", "100%"),
      #("min-height", "320px"),
    ]),
  ])
}

MapError distinguishes LeafletUnavailable, TileLoadingFailed, and RenderingFailed. The component emits the error without adding an error message to the page; the application decides how to present or localise it.