MapLibre events come back as ordinary Hologram actions.

Handler syntax

Event props accept the same shapes Hologram's own longhand event bindings do:

# Bare action name, dispatched to the page.
on_click={:parcel_clicked}

# Longhand, when the handler lives on a component rather than the page.
on_click={%{action: :parcel_clicked, target: "sidebar"}}

# With static params merged into the payload.
on_click={%{action: :parcel_clicked, target: "sidebar", params: %{layer: "parcels"}}}

A keyword list works wherever a map does. An unset handler attaches no MapLibre listener at all, so an event prop can be driven from state:

on_click={if @selectable do :parcel_clicked end}

Targets

target is a component cid, "page", or "layout". It defaults to "page".

That default differs from Hologram's own, which is the closest enclosing stateful component. HoloMap's children are deliberately stateless and have no cid, so there is nothing to fall back to, and silently targeting the map container would send the action to HoloMap.Map rather than to your handler.

If your handler lives on a stateful component, name it:

<HoloMap.Layer.Fill id="parcels" source="parcels"
                    on_click={%{action: :picked, target: "sidebar"}} />

Params

Whatever you supply in params is merged under the runtime payload, so the data MapLibre reports always wins over a static value with the same key. Keys arrive in your handler as atoms, as they do for any Hologram action.

Payloads

Map events

on_load, on_click, on_dbl_click, on_context_menu, on_mouse_move, on_move_start, on_move_end, on_zoom_end, on_rotate_end, on_pitch_end, on_idle, on_error.

KeyTypeNotes
center[lng, lat]Camera centre after the event
zoomnumber
bearingnumber
pitchnumber
bounds[[w, s], [e, n]]The visible extent
lng_lat[lng, lat]Pointer events only
point[x, y]Pointer events only, in pixels
def action(:map_moved, params, component) do
  put_state(component, :bounds, params.bounds)
end

Layer events

on_click, on_dbl_click, on_context_menu, on_mouse_enter, on_mouse_leave, on_mouse_move, on_mouse_down, on_mouse_up, on_touch_start, on_touch_end.

KeyTypeNotes
layerstringThe layer's id
lng_lat[lng, lat]
point[x, y]In pixels
featureslistThe features under the pointer

Each feature carries id, source, source_layer, properties and geometry. MapLibre's own feature objects hold back-references to their source and tile which cannot cross into Elixir, so only the actionable parts are carried over.

Every key is an atom, all the way down

Hologram converts JavaScript object keys to atoms when an action's params cross the boundary, and it does so recursively. A feature therefore arrives like this:

%{
  id: 0,
  source: "cities",
  source_layer: nil,
  properties: %{name: "Santo Domingo", population: 2_908_000},
  geometry: %{type: "Point", coordinates: [-69.93, 18.48]}
}

Your own GeoJSON property names are part of that: a property written as "parcel_id" in the data is matched as %{parcel_id: id}. Matching with string keys silently fails. The clause simply does not match, and a case with a catch-all falls through without an error.

def action(:parcel_clicked, params, component) do
  case params.features do
    [%{properties: %{parcel_id: id}} | _rest] -> put_state(component, :selected, id)
    [] -> put_state(component, :selected, nil)
  end
end

features is a list because layers overlap; it is empty for a click on empty space when the event is bound to the map rather than a layer. Match on it rather than assuming a head.

on_open, on_close. Neither carries a payload.

on_close fires for every close, including the ones MapLibre performs without asking: the close button, a click on the map (close_on_click is on by default) and a camera move. Handle it by clearing whatever state opened the popup. Otherwise the component still believes the popup is open, and clicking the same feature again re-renders nothing.

def action(:selection_cleared, _params, component) do
  put_state(component, :selected, nil)
end

Marker events

on_click, on_drag_start, on_drag, on_drag_end.

KeyTypeNotes
lng_lat[lng, lat]For drag events, where the marker has been moved to
def action(:pin_moved, params, component) do
  [lng, lat] = params.lng_lat
  put_state(component, :coords, {lng, lat})
end

on_drag fires on every pointer move, and writing the position back into state from it is safe: the value going back is the one MapLibre just reported, so the setLngLat the reconciler performs is a no-op and the marker stays locked to the cursor. Prefer on_drag_end when only the final position matters, since there is no reason to re-render on every frame for a value nothing reads until the drag ends.

Hover styling

The usual pattern needs feature ids, which GeoJSON features often lack. Set generate_id on the source, or point promote_id at a property that is already unique:

<HoloMap.Source.GeoJSON id="parcels" data={@parcels} generate_id />

<HoloMap.Layer.Fill
  id="parcels-fill"
  source="parcels"
  paint={%{fill_opacity: ["case", ["boolean", ["feature-state", "hover"], false], 0.8, 0.35]}}
  on_mouse_move={:parcel_hover}
  on_mouse_leave={:parcel_unhover}
/>
def action(:parcel_hover, params, component) do
  case params.features do
    [%{id: id} | _rest] ->
      clear_hover(component)
      HoloMap.API.set_feature_state("explorer", %{source: "parcels", id: id}, %{hover: true})
      put_state(component, :hovered, id)

    [] ->
      component
  end
end

What is not reported back

A camera move the reconciler itself made, because you changed center or zoom from state, does not fire on_move_end. Only a move the user or the imperative API caused does.

Without that, writing the camera from state would echo straight back into an action: a feedback loop at best, and at worst an action arriving on a page that has already changed. If you need to know when your own camera assignment settled, you already do: it is the state change that caused it.

Ordering and timing

Hologram.dispatchAction is asynchronous. An event dispatched at the moment a navigation begins can execute after the new page has mounted, which is why HoloMap refuses to dispatch from a map whose container has gone stale. Your handlers do not need to defend against this, but it is worth knowing why an action can appear to arrive late.