1 Commits

Author SHA1 Message Date
Ryan Hamamura
85af1722c3 feat: animate fleet of container ships along bay waypoints
All checks were successful
CI / Build and Test (push) Successful in 35s
CI / Build and Test (pull_request) Successful in 34s
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:09:28 -10:00
4 changed files with 18 additions and 64 deletions

View File

@@ -172,21 +172,19 @@ func main() {
// Animated container ships following waypoint routes
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
fleet.mu.RLock()
for i, s := range fleet.ships {
ships[i].lng = c.Signal(s.lng)
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{
LngSignal: ships[i].lng,
LatSignal: ships[i].lat,
RotationSignal: ships[i].rot,
Element: shipSVG,
Anchor: "center",
LngSignal: ships[i].lng,
LatSignal: ships[i].lat,
Element: shipSVG,
Anchor: "center",
Rotation: s.heading(),
Popup: &maplibre.Popup{
Content: fmt.Sprintf("<strong>%s</strong>", shipNames[i]),
},
@@ -218,7 +216,6 @@ func main() {
for i, s := range fleet.ships {
ships[i].lng.SetValue(s.lng)
ships[i].lat.SetValue(s.lat)
ships[i].rot.SetValue(s.heading() - 90)
}
fleet.mu.RUnlock()

View File

@@ -180,30 +180,22 @@ func initScript(m *Map) string {
func markerBodyJS(mapID, markerID string, mk Marker) string {
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 != "" {
b.WriteString(fmt.Sprintf(
`var _mkEl=document.createElement('div');`+
`_mkEl.style.display='inline-block';_mkEl.style.lineHeight='0';`+
`_mkEl.innerHTML=%s;`,
`var _mkEl=document.createElement('div');_mkEl.innerHTML=%s;`,
jsonStr(mk.Element)))
}
opts := "{"
if mk.Element != "" {
opts += `element:_mkEl,`
opts += `element:_mkEl.firstElementChild||_mkEl,`
} else if mk.Color != "" {
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
}
if mk.Anchor != "" {
opts += fmt.Sprintf(`anchor:%s,`, jsonStr(mk.Anchor))
}
// 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 {
if mk.Rotation != 0 {
opts += fmt.Sprintf(`rotation:%s,`, formatFloat(mk.Rotation))
}
if mk.Draggable {
@@ -220,15 +212,6 @@ func markerBodyJS(mapID, markerID string, mk Marker) string {
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 {
b.WriteString(popupConstructorJS(*mk.Popup, "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 {
// Read signals before the guard so Datastar tracks them as dependencies
// even when the map/marker hasn't loaded yet on first evaluation.
var b strings.Builder
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];`+
`if(m&&m._via_markers[%[2]s]){`+
`m._via_markers[%[2]s].setLngLat([lng,lat])`,
jsonStr(mapID), 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()
return fmt.Sprintf(
`var lng=$%s,lat=$%s;`+
`var m=window.__via_maps&&window.__via_maps[%s];`+
`if(m&&m._via_markers[%s]){`+
`m._via_markers[%s].setLngLat([lng,lat])}`,
mk.LngSignal.ID(), mk.LatSignal.ID(),
jsonStr(mapID), jsonStr(markerID), jsonStr(markerID),
)
}
// 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 {
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
children = append(children,
h.Input(h.Type("hidden"), me.marker.LngSignal.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
// Rotation is clockwise degrees. Useful for directional icons (ships, vehicles).
// Ignored when RotationSignal is set.
Rotation float64
// 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.
LngSignal *via.Signal
LatSignal *via.Signal
// RotationSignal drives marker rotation reactively. When set, Rotation is ignored.
RotationSignal *via.Signal
}
// Popup describes a map popup.