diff --git a/internal/examples/maplibre/main.go b/internal/examples/maplibre/main.go index 293e56d..3b7a7a6 100644 --- a/internal/examples/maplibre/main.go +++ b/internal/examples/maplibre/main.go @@ -158,15 +158,21 @@ func main() { m.AddMarker("sf", maplibre.Marker{ LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, Color: "#e74c3c", + Scale: 1.3, Popup: &maplibre.Popup{ Content: "San Francisco

The Golden City

", }, }) + noCloseOnClick := false m.AddMarker("oak", maplibre.Marker{ - LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, - Color: "#2ecc71", + LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, + Color: "#2ecc71", + Opacity: 0.7, Popup: &maplibre.Popup{ - Content: "Oakland", + Content: "Oakland", + Anchor: "bottom", + Offset: [2]float64{0, -10}, + CloseOnClick: &noCloseOnClick, }, }) diff --git a/maplibre/js.go b/maplibre/js.go index 050b4a7..5077118 100644 --- a/maplibre/js.go +++ b/maplibre/js.go @@ -113,9 +113,19 @@ func initScript(m *Map) string { } for _, me := range m.markers { 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 { 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 { b.WriteString(controlBodyJS(ce.id, ce.ctrl)) @@ -209,6 +219,21 @@ func markerBodyJS(mapID, markerID string, mk Marker) string { if mk.Draggable { 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 += "}" // 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))) - // Dragend → signal writeback + // Drag → throttled live signal writeback + dragend final writeback if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil { - b.WriteString(dragendHandlerJS(mapID, markerID, mk)) + b.WriteString(dragHandlerJS(mapID, mk)) } return b.String() } -// dragendHandlerJS generates JS that writes marker position back to signal hidden inputs on dragend. -func dragendHandlerJS(mapID, markerID string, mk Marker) string { +// dragHandlerJS generates JS that writes marker position to signal hidden inputs +// 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( - `mk.on('dragend',function(){`+ + `var _raf=0;`+ + `function _wb(){`+ `var pos=mk.getLngLat();`+ `var el=document.getElementById(%[1]s);if(!el)return;`+ `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===%[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(mk.LngSignal.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 // when its signals change. 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. -func addMarkerJS(mapID, markerID string, mk Marker) string { +func addMarkerJS(mapID, markerID string, mk Marker, events []markerEventEntry) string { var b strings.Builder b.WriteString(fmt.Sprintf( `(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];}`, jsonStr(markerID))) b.WriteString(markerBodyJS(mapID, markerID, mk)) + for _, ev := range events { + b.WriteString(markerEventListenerJS(mapID, ev)) + } b.WriteString(`})()`) 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. -func showPopupJS(mapID, popupID string, p Popup) string { +func showPopupJS(mapID, popupID string, p Popup, events []popupEventEntry) string { var b strings.Builder b.WriteString(fmt.Sprintf( `(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];}`, jsonStr(popupID))) b.WriteString(popupBodyJS(popupID, p)) + for _, ev := range events { + b.WriteString(popupEventListenerJS(mapID, ev)) + } b.WriteString(`})()`) return b.String() } @@ -357,11 +410,50 @@ func popupConstructorJS(p Popup, varName string) string { if 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 += "}" return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`, 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 --- // controlBodyJS generates JS to add a control, assuming `map` is in scope. diff --git a/maplibre/maplibre.go b/maplibre/maplibre.go index 0cc9556..3da89ec 100644 --- a/maplibre/maplibre.go +++ b/maplibre/maplibre.go @@ -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...) @@ -290,13 +309,43 @@ func (m *Map) SetLayoutProperty(layerID, name string, value any) { // --- Marker methods --- // 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 { - m.markers = append(m.markers, markerEntry{id: id, marker: marker}) - return + m.markers = append(m.markers, markerEntry{id: id, marker: marker, handle: h}) + return h } - js := addMarkerJS(m.id, id, marker) + js := addMarkerJS(m.id, id, marker, h.events) 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. @@ -316,13 +365,33 @@ func (m *Map) RemoveMarker(id string) { // --- Popup methods --- // 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 { - m.popups = append(m.popups, popupEntry{id: id, popup: popup}) - return + m.popups = append(m.popups, popupEntry{id: id, popup: popup, handle: ph}) + return ph } - js := showPopupJS(m.id, id, popup) + js := showPopupJS(m.id, id, popup, ph.events) 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. diff --git a/maplibre/types.go b/maplibre/types.go index f45d190..91ece16 100644 --- a/maplibre/types.go +++ b/maplibre/types.go @@ -324,6 +324,21 @@ type Marker struct { // RotationSignal drives marker rotation reactively. When set, Rotation is ignored. 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 0–1 (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. @@ -335,6 +350,23 @@ type Popup struct { LngLat LngLat HideCloseButton bool // true removes the close button (MapLibre shows it by default) 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 --- @@ -354,14 +386,40 @@ type sourceEntry struct { 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 { id string 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 { - id string - popup Popup + id string + popup Popup + handle *PopupHandle } type eventEntry struct {