7 Commits

Author SHA1 Message Date
Ryan Hamamura
afd73e26de fix: add explicit --login and --repo flags to tea commands
All checks were successful
CI / Build and Test (push) Successful in 43s
2026-02-20 17:05:48 -10:00
5967ca3805 fix: prevent marker snap-back during drag via PubSub echo (#18)
All checks were successful
CI / Build and Test (push) Successful in 38s
2026-02-21 02:51:40 +00:00
63de5f997c fix: increase action rate limit for drag updates in maplibre example (#17)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-21 00:50:26 +00:00
453618f712 feat: complete Tier 4 marker/popup options, events, and live drag (#16)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-21 00:38:28 +00:00
15fda48844 fix: prevent custom element markers from rendering upside down (#15)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-20 21:33:27 +00:00
ae32da77df feat: add RotationSignal for reactive marker rotation (#14)
All checks were successful
CI / Build and Test (push) Successful in 37s
2026-02-20 21:16:40 +00:00
297808d4cc feat: animate fleet of container ships along bay waypoints (#13)
All checks were successful
CI / Build and Test (push) Successful in 36s
2026-02-20 21:10:24 +00:00
6 changed files with 320 additions and 47 deletions

View File

@@ -5,9 +5,9 @@ Create a PR on Gitea, wait for CI, and squash-merge it. Push code to both remote
3. Fetch latest main and rebase: `git fetch gitea main && git rebase gitea/main`. 3. Fetch latest main and rebase: `git fetch gitea main && git rebase gitea/main`.
- If conflicts occur, abort the rebase (`git rebase --abort`), analyze the conflicting files, write a plan to resolve them, and present the plan to the user before proceeding. - If conflicts occur, abort the rebase (`git rebase --abort`), analyze the conflicting files, write a plan to resolve them, and present the plan to the user before proceeding.
4. Push the branch to both remotes: `git push -u gitea <branch> && git push origin <branch>` (use `--force-with-lease` if already pushed). 4. Push the branch to both remotes: `git push -u gitea <branch> && git push origin <branch>` (use `--force-with-lease` if already pushed).
5. Create a Gitea PR: `tea pr create --head <branch> --base main`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue. 5. Create a Gitea PR: `tea pr create --login gitea --repo ryan/via --head <branch> --base main`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue.
6. Wait for CI to pass: poll Gitea CI status. If CI fails, report the failure and stop — do not merge. 6. Wait for CI to pass: poll Gitea CI status. If CI fails, report the failure and stop — do not merge.
7. Once CI passes, squash-merge on Gitea: `tea pr merge <index> --style squash` with a clean, semantic commit message including the PR number. No Claude attribution lines. 7. Once CI passes, squash-merge on Gitea: `tea pr merge --login gitea --repo ryan/via <index> --style squash` with a clean, semantic commit message including the PR number. No Claude attribution lines.
8. Update local main and push to both remotes. If in a worktree, `main` is checked out in the primary tree, so run from there: `cd <primary-worktree> && git pull gitea main && git push origin main` (the primary worktree path is the repo root without `.claude/worktrees/…`). If not in a worktree: `git checkout main && git pull gitea main && git push origin main`. 8. Update local main and push to both remotes. If in a worktree, `main` is checked out in the primary tree, so run from there: `cd <primary-worktree> && git pull gitea main && git push origin main` (the primary worktree path is the repo root without `.claude/worktrees/…`). If not in a worktree: `git checkout main && git pull gitea main && git push origin main`.
9. Clean up remote branches: `git push gitea --delete <branch> && git push origin --delete <branch>`. 9. Clean up remote branches: `git push gitea --delete <branch> && git push origin --delete <branch>`.
10. Prune refs: `git remote prune gitea && git remote prune origin`. 10. Prune refs: `git remote prune gitea && git remote prune origin`.

View File

@@ -16,5 +16,5 @@ Create a new release on Gitea. Push tags to both remotes.
Present the proposed version, bump rationale, and commit list. Wait for user approval. Present the proposed version, bump rationale, and commit list. Wait for user approval.
6. Tag the new version. Push the tag to both remotes: `git push gitea <tag> && git push origin <tag>`. 6. Tag the new version. Push the tag to both remotes: `git push gitea <tag> && git push origin <tag>`.
7. Generate release notes grouped by type (features, fixes, chores). 7. Generate release notes grouped by type (features, fixes, chores).
8. Create a Gitea release with `tea releases create` using the notes. 8. Create a Gitea release with `tea releases create --login gitea --repo ryan/via` using the notes.
9. Report the release URL and confirm all remotes are up to date. 9. Report the release URL and confirm all remotes are up to date.

View File

@@ -82,6 +82,7 @@ func main() {
ServerAddress: ":7331", ServerAddress: ":7331",
DevMode: true, DevMode: true,
Plugins: []via.Plugin{maplibre.Plugin}, Plugins: []via.Plugin{maplibre.Plugin},
ActionRateLimit: via.RateLimitConfig{Rate: 60, Burst: 120},
}) })
// Single goroutine moves the vehicle — all clients read the same position. // Single goroutine moves the vehicle — all clients read the same position.
@@ -158,33 +159,41 @@ 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,
}, },
}) })
// Animated container ships following waypoint routes // Animated container ships following waypoint routes
shipNames := [3]string{"MSC Adriatica", "Evergreen Harmony", "Maersk Aurora"} shipNames := [3]string{"MSC Adriatica", "Evergreen Harmony", "Maersk Aurora"}
type shipSignals struct{ lng, lat *via.Signal } type shipSignals struct{ lng, lat, rot *via.Signal }
var ships [3]shipSignals var ships [3]shipSignals
fleet.mu.RLock() fleet.mu.RLock()
for i, s := range fleet.ships { for i, s := range fleet.ships {
ships[i].lng = c.Signal(s.lng) ships[i].lng = c.Signal(s.lng)
ships[i].lat = c.Signal(s.lat) ships[i].lat = c.Signal(s.lat)
// SVG bow points right (east), so subtract 90° from the north-based heading.
ships[i].rot = c.Signal(s.heading() - 90)
m.AddMarker(fmt.Sprintf("ship-%d", i), maplibre.Marker{ m.AddMarker(fmt.Sprintf("ship-%d", i), maplibre.Marker{
LngSignal: ships[i].lng, LngSignal: ships[i].lng,
LatSignal: ships[i].lat, LatSignal: ships[i].lat,
RotationSignal: ships[i].rot,
Element: shipSVG, Element: shipSVG,
Anchor: "center", Anchor: "center",
Rotation: s.heading(),
Popup: &maplibre.Popup{ Popup: &maplibre.Popup{
Content: fmt.Sprintf("<strong>%s</strong>", shipNames[i]), Content: fmt.Sprintf("<strong>%s</strong>", shipNames[i]),
}, },
@@ -216,6 +225,7 @@ func main() {
for i, s := range fleet.ships { for i, s := range fleet.ships {
ships[i].lng.SetValue(s.lng) ships[i].lng.SetValue(s.lng)
ships[i].lat.SetValue(s.lat) ships[i].lat.SetValue(s.lat)
ships[i].rot.SetValue(s.heading() - 90)
} }
fleet.mu.RUnlock() fleet.mu.RUnlock()

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))
@@ -180,27 +190,50 @@ func initScript(m *Map) string {
func markerBodyJS(mapID, markerID string, mk Marker) string { func markerBodyJS(mapID, markerID string, mk Marker) string {
var b strings.Builder var b strings.Builder
// Use a wrapper div for custom elements so MapLibre rotates the div
// while we can independently flip the inner element to prevent inversion.
if mk.Element != "" { if mk.Element != "" {
b.WriteString(fmt.Sprintf( b.WriteString(fmt.Sprintf(
`var _mkEl=document.createElement('div');_mkEl.innerHTML=%s;`, `var _mkEl=document.createElement('div');`+
`_mkEl.style.display='inline-block';_mkEl.style.lineHeight='0';`+
`_mkEl.innerHTML=%s;`,
jsonStr(mk.Element))) jsonStr(mk.Element)))
} }
opts := "{" opts := "{"
if mk.Element != "" { if mk.Element != "" {
opts += `element:_mkEl.firstElementChild||_mkEl,` opts += `element:_mkEl,`
} else if mk.Color != "" { } else if mk.Color != "" {
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color)) opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
} }
if mk.Anchor != "" { if mk.Anchor != "" {
opts += fmt.Sprintf(`anchor:%s,`, jsonStr(mk.Anchor)) opts += fmt.Sprintf(`anchor:%s,`, jsonStr(mk.Anchor))
} }
if mk.Rotation != 0 { // When both Element and RotationSignal are set, skip initial rotation
// in opts — we apply it post-creation with flip normalization.
if mk.RotationSignal != nil && mk.Element == "" {
opts += fmt.Sprintf(`rotation:%s,`, mk.RotationSignal.String())
} else if mk.RotationSignal == nil && mk.Rotation != 0 {
opts += fmt.Sprintf(`rotation:%s,`, formatFloat(mk.Rotation)) opts += fmt.Sprintf(`rotation:%s,`, formatFloat(mk.Rotation))
} }
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
@@ -212,24 +245,36 @@ func markerBodyJS(mapID, markerID string, mk Marker) string {
opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat))) opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat)))
} }
// Apply initial rotation with flip normalization for custom elements.
if mk.RotationSignal != nil && mk.Element != "" {
b.WriteString(fmt.Sprintf(
`var _r=%s,_f=_r>90||_r<-90;if(_f)_r=_r>0?_r-180:_r+180;`+
`mk.setRotation(_r);`+
`var _ch=_mkEl.firstElementChild;if(_ch&&_f)_ch.style.transform='scaleX(-1)';`,
mk.RotationSignal.String()))
}
if mk.Popup != nil { if mk.Popup != nil {
b.WriteString(popupConstructorJS(*mk.Popup, "pk")) b.WriteString(popupConstructorJS(*mk.Popup, "pk"))
b.WriteString(`mk.setPopup(pk);`) b.WriteString(`mk.setPopup(pk);`)
} }
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;mk._dragging=false;`+
`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]');`+
@@ -238,30 +283,67 @@ 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('dragstart',function(){mk._dragging=true;});`+
`mk.on('drag',function(){if(_raf)return;_raf=requestAnimationFrame(function(){_raf=0;_wb()});});`+
`mk.on('dragend',function(){cancelAnimationFrame(_raf);_raf=0;_wb();mk._dragging=false;});`,
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 {
// Read signals before the guard so Datastar tracks them as dependencies // Read signals before the guard so Datastar tracks them as dependencies
// even when the map/marker hasn't loaded yet on first evaluation. // even when the map/marker hasn't loaded yet on first evaluation.
return fmt.Sprintf( var b strings.Builder
`var lng=$%s,lat=$%s;`+ b.WriteString(fmt.Sprintf(`var lng=$%s,lat=$%s;`, mk.LngSignal.ID(), mk.LatSignal.ID()))
if mk.RotationSignal != nil {
b.WriteString(fmt.Sprintf(`var rot=$%s;`, mk.RotationSignal.ID()))
}
b.WriteString(fmt.Sprintf(
`var m=window.__via_maps&&window.__via_maps[%s];`+ `var m=window.__via_maps&&window.__via_maps[%s];`+
`if(m&&m._via_markers[%s]){`+ `if(m&&m._via_markers[%[2]s]&&!m._via_markers[%[2]s]._dragging){`+
`m._via_markers[%s].setLngLat([lng,lat])}`, `m._via_markers[%[2]s].setLngLat([lng,lat])`,
mk.LngSignal.ID(), mk.LatSignal.ID(), jsonStr(mapID), jsonStr(markerID)))
jsonStr(mapID), jsonStr(markerID), jsonStr(markerID), if mk.RotationSignal != nil && mk.Element != "" {
) // Normalize rotation to [-90,90] and horizontally flip the inner
// element when |rotation| > 90° to prevent upside-down markers.
b.WriteString(fmt.Sprintf(
`;var _mk=m._via_markers[%[1]s],_f=rot>90||rot<-90;`+
`if(_f)rot=rot>0?rot-180:rot+180;`+
`_mk.setRotation(rot);`+
`var _ch=_mk.getElement().firstElementChild;`+
`if(_ch)_ch.style.transform=_f?'scaleX(-1)':''`,
jsonStr(markerID)))
} else if mk.RotationSignal != nil {
b.WriteString(fmt.Sprintf(`;m._via_markers[%s].setRotation(rot)`, jsonStr(markerID)))
}
b.WriteString(`}`)
return b.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;`,
@@ -271,6 +353,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()
} }
@@ -293,7 +378,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;`,
@@ -303,6 +388,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()
} }
@@ -323,11 +411,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

@@ -122,13 +122,37 @@ func (m *Map) Element(extra ...h.H) h.H {
} }
} }
// Hidden inputs for signal-backed marker position writeback (drag → signal) // Hidden inputs for signal-backed marker position/rotation writeback
for _, me := range m.markers { for _, me := range m.markers {
if me.marker.LngSignal != nil && me.marker.LatSignal != nil { if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
children = append(children, children = append(children,
h.Input(h.Type("hidden"), me.marker.LngSignal.Bind()), h.Input(h.Type("hidden"), me.marker.LngSignal.Bind()),
h.Input(h.Type("hidden"), me.marker.LatSignal.Bind()), h.Input(h.Type("hidden"), me.marker.LatSignal.Bind()),
) )
if me.marker.RotationSignal != nil {
children = append(children,
h.Input(h.Type("hidden"), me.marker.RotationSignal.Bind()),
)
}
}
// 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()),
)
}
} }
} }
@@ -285,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.
@@ -311,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

@@ -313,6 +313,7 @@ type Marker struct {
Anchor string Anchor string
// Rotation is clockwise degrees. Useful for directional icons (ships, vehicles). // Rotation is clockwise degrees. Useful for directional icons (ships, vehicles).
// Ignored when RotationSignal is set.
Rotation float64 Rotation float64
// Signal-backed position. When set, signals drive marker position reactively. // Signal-backed position. When set, signals drive marker position reactively.
@@ -320,6 +321,24 @@ type Marker struct {
// If Draggable is true, drag updates write back to these signals. // If Draggable is true, drag updates write back to these signals.
LngSignal *via.Signal LngSignal *via.Signal
LatSignal *via.Signal LatSignal *via.Signal
// 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 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.
@@ -331,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 ---
@@ -350,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 {