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.
This commit is contained in:
Ryan Hamamura
2026-02-20 11:03:59 -10:00
parent c2794fa0f9
commit 85af1722c3

View File

@@ -1,6 +1,8 @@
package main package main
import ( import (
"fmt"
"math"
"math/rand" "math/rand"
"strconv" "strconv"
"sync" "sync"
@@ -24,6 +26,55 @@ var (
} }
) )
// shipState tracks a ship lerping along a loop of waypoints.
type shipState struct {
lng, lat float64
waypoints [][2]float64 // [lng, lat] pairs
wpIdx int // index of next target waypoint
progress float64 // 0..1 toward next waypoint
speed float64 // progress increment per tick
}
func (s *shipState) tick() {
s.progress += s.speed
for s.progress >= 1 {
s.progress -= 1
s.wpIdx = (s.wpIdx + 1) % len(s.waypoints)
}
from := s.waypoints[(s.wpIdx-1+len(s.waypoints))%len(s.waypoints)]
to := s.waypoints[s.wpIdx]
s.lng = from[0] + (to[0]-from[0])*s.progress
s.lat = from[1] + (to[1]-from[1])*s.progress
}
// heading returns clockwise degrees from north (for SVG rotation).
func (s *shipState) heading() float64 {
from := s.waypoints[(s.wpIdx-1+len(s.waypoints))%len(s.waypoints)]
to := s.waypoints[s.wpIdx]
dx := to[0] - from[0]
dy := to[1] - from[1]
// atan2 gives angle from +X axis; convert to CW from north
return math.Mod(math.Atan2(dx, dy)*180/math.Pi+360, 360)
}
var (
fleetOnce sync.Once
fleet struct {
mu sync.RWMutex
ships [3]shipState
}
)
const shipSVG = `<svg width="48" height="28" viewBox="0 0 80 44" xmlns="http://www.w3.org/2000/svg">` +
`<path d="M2 30 L10 42 L70 42 L78 30 Z" fill="#1b3a5c"/>` +
`<rect x="12" y="24" width="56" height="6" rx="1" fill="#2c5f8a"/>` +
`<rect x="18" y="14" width="46" height="10" rx="1" fill="#2c5f8a" stroke="#1b3a5c" stroke-width="0.5"/>` +
`<rect x="8" y="12" width="9" height="12" rx="1" fill="#d5dfe8" stroke="#2c5f8a" stroke-width="0.5"/>` +
`<rect x="9.5" y="13" width="6" height="4" rx="0.5" fill="#85c1e9"/>` +
`<rect x="10" y="4" width="5" height="8" rx="0.5" fill="#1b3a5c"/>` +
`<rect x="9.5" y="2" width="6" height="3" rx="0.5" fill="#c0392b"/>` +
`</svg>`
func main() { func main() {
v := via.New() v := via.New()
v.Config(via.Options{ v.Config(via.Options{
@@ -48,6 +99,50 @@ func main() {
}() }()
}) })
// Fleet of ships following waypoint loops through SF Bay.
fleetOnce.Do(func() {
fleet.ships = [3]shipState{
{ // Golden Gate → Alcatraz → Pier 39 → back out
waypoints: [][2]float64{
{-122.478, 37.819}, {-122.423, 37.827},
{-122.410, 37.809}, {-122.423, 37.827},
},
speed: 0.03,
},
{ // Oakland → Treasure Island → Angel Island → loop
waypoints: [][2]float64{
{-122.330, 37.795}, {-122.370, 37.823},
{-122.432, 37.860}, {-122.370, 37.823},
},
speed: 0.02,
},
{ // Sausalito → Pier 39 ferry route
waypoints: [][2]float64{
{-122.480, 37.859}, {-122.435, 37.840},
{-122.410, 37.809}, {-122.435, 37.840},
},
speed: 0.025,
},
}
// Set initial positions.
for i := range fleet.ships {
s := &fleet.ships[i]
s.lng = s.waypoints[0][0]
s.lat = s.waypoints[0][1]
s.wpIdx = 1
}
go func() {
for {
time.Sleep(time.Second)
fleet.mu.Lock()
for i := range fleet.ships {
fleet.ships[i].tick()
}
fleet.mu.Unlock()
}
}()
})
v.Page("/", func(c *via.Context) { v.Page("/", func(c *via.Context) {
m := maplibre.New(c, maplibre.Options{ m := maplibre.New(c, maplibre.Options{
Style: "https://demotiles.maplibre.org/style.json", Style: "https://demotiles.maplibre.org/style.json",
@@ -75,24 +170,27 @@ func main() {
}, },
}) })
// Custom SVG container ship marker (static) // Animated container ships following waypoint routes
m.AddMarker("ship", maplibre.Marker{ shipNames := [3]string{"MSC Adriatica", "Evergreen Harmony", "Maersk Aurora"}
LngLat: maplibre.LngLat{Lng: -122.38, Lat: 37.80}, type shipSignals struct{ lng, lat *via.Signal }
Element: `<svg width="48" height="28" viewBox="0 0 80 44" xmlns="http://www.w3.org/2000/svg">` + var ships [3]shipSignals
`<path d="M2 30 L10 42 L70 42 L78 30 Z" fill="#1b3a5c"/>` +
`<rect x="12" y="24" width="56" height="6" rx="1" fill="#2c5f8a"/>` + fleet.mu.RLock()
`<rect x="18" y="14" width="46" height="10" rx="1" fill="#2c5f8a" stroke="#1b3a5c" stroke-width="0.5"/>` + for i, s := range fleet.ships {
`<rect x="8" y="12" width="9" height="12" rx="1" fill="#d5dfe8" stroke="#2c5f8a" stroke-width="0.5"/>` + ships[i].lng = c.Signal(s.lng)
`<rect x="9.5" y="13" width="6" height="4" rx="0.5" fill="#85c1e9"/>` + ships[i].lat = c.Signal(s.lat)
`<rect x="10" y="4" width="5" height="8" rx="0.5" fill="#1b3a5c"/>` + m.AddMarker(fmt.Sprintf("ship-%d", i), maplibre.Marker{
`<rect x="9.5" y="2" width="6" height="3" rx="0.5" fill="#c0392b"/>` + LngSignal: ships[i].lng,
`</svg>`, LatSignal: ships[i].lat,
Element: shipSVG,
Anchor: "center", Anchor: "center",
Rotation: 45, Rotation: s.heading(),
Popup: &maplibre.Popup{ Popup: &maplibre.Popup{
Content: "<strong>MSC Adriatica</strong><p>Container vessel — heading NE</p>", Content: fmt.Sprintf("<strong>%s</strong>", shipNames[i]),
}, },
}) })
}
fleet.mu.RUnlock()
// Custom SVG vehicle marker — reads shared Go state // Custom SVG vehicle marker — reads shared Go state
vehicleLng := c.Signal(-122.43) vehicleLng := c.Signal(-122.43)
@@ -113,6 +211,14 @@ func main() {
vehicle.mu.RUnlock() vehicle.mu.RUnlock()
vehicleLng.SetValue(lng) vehicleLng.SetValue(lng)
vehicleLat.SetValue(lat) vehicleLat.SetValue(lat)
fleet.mu.RLock()
for i, s := range fleet.ships {
ships[i].lng.SetValue(s.lng)
ships[i].lat.SetValue(s.lat)
}
fleet.mu.RUnlock()
c.SyncSignals() c.SyncSignals()
}) })