1 Commits

Author SHA1 Message Date
Ryan Hamamura
29228fc9ea feat: animate fleet of container ships along bay waypoints
Some checks failed
CI / Build and Test (push) Failing after 7s
CI / Build and Test (pull_request) Failing after 7s
Replace the static ship marker with three signal-backed ships that
lerp along waypoint loops through SF Bay (Golden Gate, Oakland, and
Sausalito ferry routes). All clients see the same ship positions via
shared Go state synced every second.
2026-02-20 11:03:59 -10:00
4 changed files with 19 additions and 65 deletions

View File

@@ -172,21 +172,19 @@ func main() {
// 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, rot *via.Signal } type shipSignals struct{ lng, lat *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]),
}, },
@@ -218,7 +216,6 @@ 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

@@ -180,30 +180,22 @@ 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');`+ `var _mkEl=document.createElement('div');_mkEl.innerHTML=%s;`,
`_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,` opts += `element:_mkEl.firstElementChild||_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))
} }
// When both Element and RotationSignal are set, skip initial rotation if mk.Rotation != 0 {
// 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 {
@@ -220,15 +212,6 @@ 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);`)
@@ -267,31 +250,14 @@ func dragendHandlerJS(mapID, markerID string, mk Marker) string {
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.
var b strings.Builder return fmt.Sprintf(
b.WriteString(fmt.Sprintf(`var lng=$%s,lat=$%s;`, mk.LngSignal.ID(), mk.LatSignal.ID())) `var lng=$%s,lat=$%s;`+
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[%[2]s]){`+ `if(m&&m._via_markers[%s]){`+
`m._via_markers[%[2]s].setLngLat([lng,lat])`, `m._via_markers[%s].setLngLat([lng,lat])}`,
jsonStr(mapID), jsonStr(markerID))) mk.LngSignal.ID(), mk.LatSignal.ID(),
if mk.RotationSignal != nil && mk.Element != "" { jsonStr(mapID), jsonStr(markerID), jsonStr(markerID),
// 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.

View File

@@ -122,18 +122,13 @@ func (m *Map) Element(extra ...h.H) h.H {
} }
} }
// Hidden inputs for signal-backed marker position/rotation writeback // Hidden inputs for signal-backed marker position writeback (drag → signal)
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()),
)
}
} }
} }

View File

@@ -313,7 +313,6 @@ 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.
@@ -321,9 +320,6 @@ 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
} }
// Popup describes a map popup. // Popup describes a map popup.