feat: complete Tier 4 marker/popup options, events, and live drag (#1)
All checks were successful
CI / Build and Test (push) Successful in 38s
CI / Build and Test (pull_request) Successful in 36s

Add missing marker options (offset, scale, opacity, opacityWhenCovered,
className) and popup options (closeOnClick, closeOnMove, anchor, offset,
className).

Return MarkerHandle from AddMarker with OnClick, OnDragStart, OnDrag,
OnDragEnd event methods. Return PopupHandle from ShowPopup with OnOpen,
OnClose event methods.

Upgrade drag signal writeback to fire during drag (throttled via
requestAnimationFrame) in addition to dragend, enabling real-time
position sync across clients.
This commit is contained in:
Ryan Hamamura
2026-02-20 14:36:02 -10:00
parent 15fda48844
commit 1d57a0962a
4 changed files with 246 additions and 21 deletions

View File

@@ -158,15 +158,21 @@ func main() {
m.AddMarker("sf", maplibre.Marker{ m.AddMarker("sf", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Color: "#e74c3c", Color: "#e74c3c",
Scale: 1.3,
Popup: &maplibre.Popup{ Popup: &maplibre.Popup{
Content: "<strong>San Francisco</strong><p>The Golden City</p>", Content: "<strong>San Francisco</strong><p>The Golden City</p>",
}, },
}) })
noCloseOnClick := false
m.AddMarker("oak", maplibre.Marker{ m.AddMarker("oak", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Color: "#2ecc71", Color: "#2ecc71",
Opacity: 0.7,
Popup: &maplibre.Popup{ Popup: &maplibre.Popup{
Content: "<strong>Oakland</strong>", Content: "<strong>Oakland</strong>",
Anchor: "bottom",
Offset: [2]float64{0, -10},
CloseOnClick: &noCloseOnClick,
}, },
}) })

View File

@@ -113,9 +113,19 @@ func initScript(m *Map) string {
} }
for _, me := range m.markers { for _, me := range m.markers {
b.WriteString(markerBodyJS(m.id, me.id, me.marker)) b.WriteString(markerBodyJS(m.id, me.id, me.marker))
if me.handle != nil {
for _, ev := range me.handle.events {
b.WriteString(markerEventListenerJS(m.id, ev))
}
}
} }
for _, pe := range m.popups { for _, pe := range m.popups {
b.WriteString(popupBodyJS(pe.id, pe.popup)) b.WriteString(popupBodyJS(pe.id, pe.popup))
if pe.handle != nil {
for _, ev := range pe.handle.events {
b.WriteString(popupEventListenerJS(m.id, ev))
}
}
} }
for _, ce := range m.controls { for _, ce := range m.controls {
b.WriteString(controlBodyJS(ce.id, ce.ctrl)) b.WriteString(controlBodyJS(ce.id, ce.ctrl))
@@ -209,6 +219,21 @@ func markerBodyJS(mapID, markerID string, mk Marker) string {
if mk.Draggable { if mk.Draggable {
opts += `draggable:true,` opts += `draggable:true,`
} }
if mk.Offset != [2]float64{} {
opts += fmt.Sprintf(`offset:[%s,%s],`, formatFloat(mk.Offset[0]), formatFloat(mk.Offset[1]))
}
if mk.Scale != 0 {
opts += fmt.Sprintf(`scale:%s,`, formatFloat(mk.Scale))
}
if mk.Opacity != 0 {
opts += fmt.Sprintf(`opacity:%s,`, formatFloat(mk.Opacity))
}
if mk.OpacityWhenCovered != 0 {
opts += fmt.Sprintf(`opacityWhenCovered:%s,`, formatFloat(mk.OpacityWhenCovered))
}
if mk.ClassName != "" {
opts += fmt.Sprintf(`className:%s,`, jsonStr(mk.ClassName))
}
opts += "}" opts += "}"
// Determine initial position // Determine initial position
@@ -235,18 +260,21 @@ func markerBodyJS(mapID, markerID string, mk Marker) string {
} }
b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID))) b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID)))
// Dragend → signal writeback // Drag → throttled live signal writeback + dragend final writeback
if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil { if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil {
b.WriteString(dragendHandlerJS(mapID, markerID, mk)) b.WriteString(dragHandlerJS(mapID, mk))
} }
return b.String() return b.String()
} }
// dragendHandlerJS generates JS that writes marker position back to signal hidden inputs on dragend. // dragHandlerJS generates JS that writes marker position to signal hidden inputs
func dragendHandlerJS(mapID, markerID string, mk Marker) string { // during drag (throttled via requestAnimationFrame) and on dragend (unthrottled).
func dragHandlerJS(mapID string, mk Marker) string {
// Shared writeback logic extracted into a local function for both handlers.
return fmt.Sprintf( return fmt.Sprintf(
`mk.on('dragend',function(){`+ `var _raf=0;`+
`function _wb(){`+
`var pos=mk.getLngLat();`+ `var pos=mk.getLngLat();`+
`var el=document.getElementById(%[1]s);if(!el)return;`+ `var el=document.getElementById(%[1]s);if(!el)return;`+
`var inputs=el.querySelectorAll('input[data-bind]');`+ `var inputs=el.querySelectorAll('input[data-bind]');`+
@@ -255,13 +283,32 @@ func dragendHandlerJS(mapID, markerID string, mk Marker) string {
`if(sig===%[2]s){inp.value=pos.lng;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+ `if(sig===%[2]s){inp.value=pos.lng;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`if(sig===%[3]s){inp.value=pos.lat;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+ `if(sig===%[3]s){inp.value=pos.lat;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`+ `});`+
`});`, `}`+
`mk.on('drag',function(){if(_raf)return;_raf=requestAnimationFrame(function(){_raf=0;_wb()});});`+
`mk.on('dragend',function(){cancelAnimationFrame(_raf);_raf=0;_wb()});`,
jsonStr("_vwrap_"+mapID), jsonStr("_vwrap_"+mapID),
jsonStr(mk.LngSignal.ID()), jsonStr(mk.LngSignal.ID()),
jsonStr(mk.LatSignal.ID()), jsonStr(mk.LatSignal.ID()),
) )
} }
// markerEventListenerJS generates JS for a marker event listener.
// Assumes `mk` (the marker) is in scope.
func markerEventListenerJS(mapID string, ev markerEventEntry) string {
return fmt.Sprintf(
`mk.on(%[1]s,function(){`+
`var pos=mk.getLngLat();`+
`var d={lngLat:{Lng:pos.lng,Lat:pos.lat},point:[0,0]};`+
`var el=document.getElementById(%[2]s);if(!el)return;`+
`var inp=el.querySelector('input[data-bind=%[3]s]');`+
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`,
jsonStr(ev.event),
jsonStr("_vwrap_"+mapID),
jsonStr(ev.signal.ID()),
)
}
// markerEffectExpr generates a data-effect expression that moves a signal-backed marker // markerEffectExpr generates a data-effect expression that moves a signal-backed marker
// when its signals change. // when its signals change.
func markerEffectExpr(mapID, markerID string, mk Marker) string { func markerEffectExpr(mapID, markerID string, mk Marker) string {
@@ -295,7 +342,7 @@ func markerEffectExpr(mapID, markerID string, mk Marker) string {
} }
// addMarkerJS generates a self-contained IIFE to add a marker post-render. // addMarkerJS generates a self-contained IIFE to add a marker post-render.
func addMarkerJS(mapID, markerID string, mk Marker) string { func addMarkerJS(mapID, markerID string, mk Marker, events []markerEventEntry) string {
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf( b.WriteString(fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`, `(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
@@ -305,6 +352,9 @@ func addMarkerJS(mapID, markerID string, mk Marker) string {
`if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`, `if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`,
jsonStr(markerID))) jsonStr(markerID)))
b.WriteString(markerBodyJS(mapID, markerID, mk)) b.WriteString(markerBodyJS(mapID, markerID, mk))
for _, ev := range events {
b.WriteString(markerEventListenerJS(mapID, ev))
}
b.WriteString(`})()`) b.WriteString(`})()`)
return b.String() return b.String()
} }
@@ -327,7 +377,7 @@ func popupBodyJS(popupID string, p Popup) string {
} }
// showPopupJS generates a self-contained IIFE to show a popup post-render. // showPopupJS generates a self-contained IIFE to show a popup post-render.
func showPopupJS(mapID, popupID string, p Popup) string { func showPopupJS(mapID, popupID string, p Popup, events []popupEventEntry) string {
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf( b.WriteString(fmt.Sprintf(
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`, `(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
@@ -337,6 +387,9 @@ func showPopupJS(mapID, popupID string, p Popup) string {
`if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`, `if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`,
jsonStr(popupID))) jsonStr(popupID)))
b.WriteString(popupBodyJS(popupID, p)) b.WriteString(popupBodyJS(popupID, p))
for _, ev := range events {
b.WriteString(popupEventListenerJS(mapID, ev))
}
b.WriteString(`})()`) b.WriteString(`})()`)
return b.String() return b.String()
} }
@@ -357,11 +410,50 @@ func popupConstructorJS(p Popup, varName string) string {
if p.MaxWidth != "" { if p.MaxWidth != "" {
opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth)) opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth))
} }
if p.CloseOnClick != nil {
if *p.CloseOnClick {
opts += `closeOnClick:true,`
} else {
opts += `closeOnClick:false,`
}
}
if p.CloseOnMove != nil {
if *p.CloseOnMove {
opts += `closeOnMove:true,`
} else {
opts += `closeOnMove:false,`
}
}
if p.Anchor != "" {
opts += fmt.Sprintf(`anchor:%s,`, jsonStr(p.Anchor))
}
if p.Offset != [2]float64{} {
opts += fmt.Sprintf(`offset:[%s,%s],`, formatFloat(p.Offset[0]), formatFloat(p.Offset[1]))
}
if p.ClassName != "" {
opts += fmt.Sprintf(`className:%s,`, jsonStr(p.ClassName))
}
opts += "}" opts += "}"
return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`, return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`,
varName, opts, jsonStr(p.Content)) varName, opts, jsonStr(p.Content))
} }
// popupEventListenerJS generates JS for a popup event listener.
// Assumes `p` (the popup) is in scope.
func popupEventListenerJS(mapID string, ev popupEventEntry) string {
// open/close carry no spatial data — write a timestamp as change trigger.
return fmt.Sprintf(
`p.on(%[1]s,function(){`+
`var el=document.getElementById(%[2]s);if(!el)return;`+
`var inp=el.querySelector('input[data-bind=%[3]s]');`+
`if(inp){inp.value=Date.now();inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`,
jsonStr(ev.event),
jsonStr("_vwrap_"+mapID),
jsonStr(ev.signal.ID()),
)
}
// --- Control JS --- // --- Control JS ---
// controlBodyJS generates JS to add a control, assuming `map` is in scope. // controlBodyJS generates JS to add a control, assuming `map` is in scope.

View File

@@ -135,6 +135,25 @@ func (m *Map) Element(extra ...h.H) h.H {
) )
} }
} }
// Hidden inputs for marker event signals
if me.handle != nil {
for _, ev := range me.handle.events {
children = append(children,
h.Input(h.Type("hidden"), ev.signal.Bind()),
)
}
}
}
// Hidden inputs for popup event signals
for _, pe := range m.popups {
if pe.handle != nil {
for _, ev := range pe.handle.events {
children = append(children,
h.Input(h.Type("hidden"), ev.signal.Bind()),
)
}
}
} }
children = append(children, extra...) children = append(children, extra...)
@@ -290,13 +309,43 @@ func (m *Map) SetLayoutProperty(layerID, name string, value any) {
// --- Marker methods --- // --- Marker methods ---
// AddMarker adds or replaces a marker on the map. // AddMarker adds or replaces a marker on the map.
func (m *Map) AddMarker(id string, marker Marker) { // The returned MarkerHandle can be used to subscribe to marker-level events.
func (m *Map) AddMarker(id string, marker Marker) *MarkerHandle {
h := &MarkerHandle{markerID: id, m: m}
if !m.rendered { if !m.rendered {
m.markers = append(m.markers, markerEntry{id: id, marker: marker}) m.markers = append(m.markers, markerEntry{id: id, marker: marker, handle: h})
return return h
} }
js := addMarkerJS(m.id, id, marker) js := addMarkerJS(m.id, id, marker, h.events)
m.ctx.ExecScript(js) m.ctx.ExecScript(js)
return h
}
// OnClick returns a MapEvent that fires when this marker is clicked.
func (h *MarkerHandle) OnClick() *MapEvent {
return h.on("click")
}
// OnDragStart returns a MapEvent that fires when dragging starts.
func (h *MarkerHandle) OnDragStart() *MapEvent {
return h.on("dragstart")
}
// OnDrag returns a MapEvent that fires during dragging.
func (h *MarkerHandle) OnDrag() *MapEvent {
return h.on("drag")
}
// OnDragEnd returns a MapEvent that fires when dragging ends.
func (h *MarkerHandle) OnDragEnd() *MapEvent {
return h.on("dragend")
}
func (h *MarkerHandle) on(event string) *MapEvent {
sig := h.m.ctx.Signal("")
ev := &MapEvent{signal: sig}
h.events = append(h.events, markerEventEntry{event: event, signal: sig})
return ev
} }
// RemoveMarker removes a marker from the map. // RemoveMarker removes a marker from the map.
@@ -316,13 +365,33 @@ func (m *Map) RemoveMarker(id string) {
// --- Popup methods --- // --- Popup methods ---
// ShowPopup shows a standalone popup on the map. // ShowPopup shows a standalone popup on the map.
func (m *Map) ShowPopup(id string, popup Popup) { // The returned PopupHandle can be used to subscribe to popup events.
func (m *Map) ShowPopup(id string, popup Popup) *PopupHandle {
ph := &PopupHandle{popupID: id, m: m}
if !m.rendered { if !m.rendered {
m.popups = append(m.popups, popupEntry{id: id, popup: popup}) m.popups = append(m.popups, popupEntry{id: id, popup: popup, handle: ph})
return return ph
} }
js := showPopupJS(m.id, id, popup) js := showPopupJS(m.id, id, popup, ph.events)
m.ctx.ExecScript(js) m.ctx.ExecScript(js)
return ph
}
// OnOpen returns a MapEvent that fires when the popup opens.
func (ph *PopupHandle) OnOpen() *MapEvent {
return ph.on("open")
}
// OnClose returns a MapEvent that fires when the popup closes.
func (ph *PopupHandle) OnClose() *MapEvent {
return ph.on("close")
}
func (ph *PopupHandle) on(event string) *MapEvent {
sig := ph.m.ctx.Signal("")
ev := &MapEvent{signal: sig}
ph.events = append(ph.events, popupEventEntry{event: event, signal: sig})
return ev
} }
// ClosePopup closes a standalone popup on the map. // ClosePopup closes a standalone popup on the map.

View File

@@ -324,6 +324,21 @@ type Marker struct {
// RotationSignal drives marker rotation reactively. When set, Rotation is ignored. // RotationSignal drives marker rotation reactively. When set, Rotation is ignored.
RotationSignal *via.Signal RotationSignal *via.Signal
// Offset is a pixel offset from the anchor point as [x, y].
Offset [2]float64
// Scale is a scaling factor for the default marker pin (0 = omit, MapLibre default 1).
Scale float64
// Opacity is the marker opacity 01 (0 = omit, MapLibre default 1).
Opacity float64
// OpacityWhenCovered is the marker opacity when behind 3D terrain (0 = omit).
OpacityWhenCovered float64
// ClassName is a CSS class added to the marker container element.
ClassName string
} }
// Popup describes a map popup. // Popup describes a map popup.
@@ -335,6 +350,23 @@ type Popup struct {
LngLat LngLat LngLat LngLat
HideCloseButton bool // true removes the close button (MapLibre shows it by default) HideCloseButton bool // true removes the close button (MapLibre shows it by default)
MaxWidth string MaxWidth string
// CloseOnClick controls whether the popup closes on map click.
// nil = MapLibre default (true).
CloseOnClick *bool
// CloseOnMove controls whether the popup closes on map move.
// nil = MapLibre default (false).
CloseOnMove *bool
// Anchor forces the popup anchor position ("top", "bottom", "left", "right", etc.).
Anchor string
// Offset is a pixel offset from the anchor as [x, y].
Offset [2]float64
// ClassName is a CSS class added to the popup container.
ClassName string
} }
// --- Event data --- // --- Event data ---
@@ -354,14 +386,40 @@ type sourceEntry struct {
js string js string
} }
// MarkerHandle is returned by AddMarker and allows subscribing to marker events.
type MarkerHandle struct {
markerID string
m *Map
events []markerEventEntry
}
type markerEventEntry struct {
event string
signal *via.Signal
}
type markerEntry struct { type markerEntry struct {
id string id string
marker Marker marker Marker
handle *MarkerHandle
}
// PopupHandle is returned by ShowPopup and allows subscribing to popup events.
type PopupHandle struct {
popupID string
m *Map
events []popupEventEntry
}
type popupEventEntry struct {
event string
signal *via.Signal
} }
type popupEntry struct { type popupEntry struct {
id string id string
popup Popup popup Popup
handle *PopupHandle
} }
type eventEntry struct { type eventEntry struct {