diff --git a/actiontrigger.go b/actiontrigger.go index 8863481..aa05ce6 100644 --- a/actiontrigger.go +++ b/actiontrigger.go @@ -97,7 +97,7 @@ func buildAttrKey(event string, opts *triggerOpts) string { } // WithSignal sets a signal value before triggering the action. -func WithSignal(sig *signal, value string) ActionTriggerOption { +func WithSignal(sig *Signal, value string) ActionTriggerOption { return withSignalOpt{ signalID: sig.ID(), value: fmt.Sprintf("'%s'", value), @@ -105,7 +105,7 @@ func WithSignal(sig *signal, value string) ActionTriggerOption { } // WithSignalInt sets a signal to an int value before triggering the action. -func WithSignalInt(sig *signal, value int) ActionTriggerOption { +func WithSignalInt(sig *Signal, value int) ActionTriggerOption { return withSignalOpt{ signalID: sig.ID(), value: strconv.Itoa(value), diff --git a/computed_test.go b/computed_test.go index 3faaf04..3324dd2 100644 --- a/computed_test.go +++ b/computed_test.go @@ -26,7 +26,7 @@ func TestComputedBasic(t *testing.T) { func TestComputedReactivity(t *testing.T) { v := New() var cs *computedSignal - var sig1 *signal + var sig1 *Signal v.Page("/", func(c *Context) { sig1 = c.Signal("a") sig2 := c.Signal("b") @@ -83,7 +83,7 @@ func TestComputedText(t *testing.T) { func TestComputedChangeDetection(t *testing.T) { v := New() var ctx *Context - var sig *signal + var sig *Signal v.Page("/", func(c *Context) { ctx = c sig = c.Signal("a") diff --git a/context.go b/context.go index b143a25..52164e5 100644 --- a/context.go +++ b/context.go @@ -175,11 +175,11 @@ func (c *Context) OnInterval(duration time.Duration, handler func()) func() { // the Context before each action call. // If any signal value is updated by the server, the update is automatically sent to the // browser when using Sync() or SyncSignsls(). -func (c *Context) Signal(v any) *signal { +func (c *Context) Signal(v any) *Signal { sigID := genRandID() if v == nil { c.app.logErr(c, "failed to bind signal: nil signal value") - return &signal{ + return &Signal{ id: sigID, val: "error", err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID), @@ -191,7 +191,7 @@ func (c *Context) Signal(v any) *signal { v = string(j) } } - sig := &signal{ + sig := &Signal{ id: sigID, val: v, changed: true, @@ -254,13 +254,13 @@ func (c *Context) injectSignals(sigs map[string]any) { for sigID, val := range sigs { item, ok := c.signals.Load(sigID) if !ok { - c.signals.Store(sigID, &signal{ + c.signals.Store(sigID, &Signal{ id: sigID, val: val, }) continue } - if sig, ok := item.(*signal); ok { + if sig, ok := item.(*Signal); ok { sig.val = val sig.changed = false } @@ -284,13 +284,14 @@ func (c *Context) prepareSignalsForPatch() map[string]any { updatedSigs := make(map[string]any) c.signals.Range(func(sigID, value any) bool { switch sig := value.(type) { - case *signal: + case *Signal: if sig.err != nil { c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err) return true } if sig.changed { updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val) + sig.changed = false } case *computedSignal: sig.recompute() @@ -594,7 +595,7 @@ func (c *Context) unsubscribeAll() { // can operate on all fields by default. func (c *Context) Field(initial any, rules ...Rule) *Field { f := &Field{ - signal: c.Signal(initial), + Signal: c.Signal(initial), rules: rules, initialVal: initial, } diff --git a/field.go b/field.go index d3ccda7..f1bde4e 100644 --- a/field.go +++ b/field.go @@ -1,10 +1,10 @@ package via // Field is a signal with built-in validation rules and error state. -// It embeds *signal, so all signal methods (Bind, String, Int, Bool, SetValue, Text, ID) +// It embeds *Signal, so all signal methods (Bind, String, Int, Bool, SetValue, Text, ID) // work transparently. type Field struct { - *signal + *Signal rules []Rule errors []string initialVal any diff --git a/internal/examples/effectspike/main.go b/internal/examples/effectspike/main.go new file mode 100644 index 0000000..3a5123c --- /dev/null +++ b/internal/examples/effectspike/main.go @@ -0,0 +1,52 @@ +// Spike to validate that Datastar's data-effect re-evaluates when signals are +// updated via PatchSignals from the server, and that Via's hex signal IDs work +// in $signalID expression syntax. +package main + +import ( + "fmt" + "math/rand" + "time" + + "github.com/ryanhamamura/via" + "github.com/ryanhamamura/via/h" +) + +func main() { + v := via.New() + v.Config(via.Options{ + DocumentTitle: "data-effect Spike", + ServerAddress: ":7332", + DevMode: true, + }) + + v.Page("/", func(c *via.Context) { + x := c.Signal(0) + y := c.Signal(0) + + c.OnInterval(time.Second, func() { + x.SetValue(rand.Intn(500)) + y.SetValue(rand.Intn(500)) + c.SyncSignals() + }) + + c.View(func() h.H { + return h.Div( + h.Attr("style", "padding:1rem;font-family:sans-serif"), + h.H1(h.Text("data-effect Spike")), + h.P(h.Text("x: "), x.Text(), h.Text(" y: "), y.Text()), + h.Div( + h.ID("box"), + h.Attr("style", "width:20px;height:20px;background:red;position:absolute"), + h.DataEffect(fmt.Sprintf( + "document.getElementById('box').style.left=$%s+'px';"+ + "document.getElementById('box').style.top=$%s+'px'", + x.ID(), y.ID(), + )), + ), + ) + }) + }) + + v.Start() +} diff --git a/internal/examples/maplibre/main.go b/internal/examples/maplibre/main.go index f80f56d..9fb4af8 100644 --- a/internal/examples/maplibre/main.go +++ b/internal/examples/maplibre/main.go @@ -1,7 +1,8 @@ package main import ( - "fmt" + "math/rand" + "time" "github.com/ryanhamamura/via" "github.com/ryanhamamura/via/h" @@ -25,7 +26,10 @@ func main() { Height: "500px", }) - // Markers with popups + m.AddControl("nav", maplibre.NavigationControl{}) + m.AddControl("scale", maplibre.ScaleControl{Unit: "metric"}) + + // Static markers with popups m.AddMarker("sf", maplibre.Marker{ LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, Color: "#e74c3c", @@ -41,6 +45,43 @@ func main() { }, }) + // Signal-backed marker — server pushes position updates + vehicleLng := c.Signal(-122.43) + vehicleLat := c.Signal(37.77) + + m.AddMarker("vehicle", maplibre.Marker{ + LngSignal: vehicleLng, + LatSignal: vehicleLat, + Color: "#9b59b6", + }) + + c.OnInterval(time.Second, func() { + vehicleLng.SetValue(-122.43 + (rand.Float64()-0.5)*0.02) + vehicleLat.SetValue(37.77 + (rand.Float64()-0.5)*0.02) + c.SyncSignals() + }) + + // Draggable marker — user drags, signals update + pinLng := c.Signal(-122.41) + pinLat := c.Signal(37.78) + + m.AddMarker("pin", maplibre.Marker{ + LngSignal: pinLng, + LatSignal: pinLat, + Color: "#3498db", + Draggable: true, + }) + + // Click event — click to place a marker + click := m.OnClick() + handleClick := c.Action(func() { + e := click.Data() + m.AddMarker("clicked", maplibre.Marker{ + LngLat: e.LngLat, + Color: "#f39c12", + }) + }) + // GeoJSON polygon source + fill layer m.AddSource("park", maplibre.GeoJSONSource{ Data: map[string]any{ @@ -70,24 +111,20 @@ func main() { }, }) - // Viewport info signal (updated on action) - viewportInfo := c.Signal("") - - // FlyTo action + // FlyTo actions using CameraOptions + zoom14 := 14.0 flyToSF := c.Action(func() { - m.FlyTo(maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, 14) + m.FlyTo(maplibre.CameraOptions{ + Center: &maplibre.LngLat{Lng: -122.4194, Lat: 37.7749}, + Zoom: &zoom14, + }) }) flyToOak := c.Action(func() { - m.FlyTo(maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, 14) - }) - - // Read viewport action - readViewport := c.Action(func() { - center := m.Center() - zoom := m.Zoom() - viewportInfo.SetValue(fmt.Sprintf("Center: %.4f, %.4f | Zoom: %.1f", center.Lng, center.Lat, zoom)) - c.Sync() + m.FlyTo(maplibre.CameraOptions{ + Center: &maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, + Zoom: &zoom14, + }) }) c.View(func() h.H { @@ -95,13 +132,19 @@ func main() { h.Div( h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"), h.H1(h.Text("MapLibre GL Example")), - m.Element(), - h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem"), + m.Element( + click.Input(handleClick.OnInput()), + ), + h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap"), h.Button(h.Text("Fly to San Francisco"), flyToSF.OnClick()), h.Button(h.Text("Fly to Oakland"), flyToOak.OnClick()), - h.Button(h.Text("Read Viewport"), readViewport.OnClick()), ), - h.P(viewportInfo.Text()), + h.Div(h.Attr("style", "margin-top:0.5rem;font-size:0.9rem"), + h.P(h.Text("Zoom: "), m.Zoom.Text()), + h.P(h.Text("Center: "), m.CenterLng.Text(), h.Text(", "), m.CenterLat.Text()), + h.P(h.Text("Vehicle: "), vehicleLng.Text(), h.Text(", "), vehicleLat.Text()), + h.P(h.Text("Draggable Pin: "), pinLng.Text(), h.Text(", "), pinLat.Text()), + ), ), ) }) diff --git a/maplibre/js.go b/maplibre/js.go index bdf4a72..67952f1 100644 --- a/maplibre/js.go +++ b/maplibre/js.go @@ -36,8 +36,9 @@ func initScript(m *Map) string { jsonStr(m.id), )) + // Build constructor options object b.WriteString(fmt.Sprintf( - `var map=new maplibregl.Map({container:%s,style:%s,center:[%s,%s],zoom:%s`, + `var opts={container:%s,style:%s,center:[%s,%s],zoom:%s`, jsonStr("_vmap_"+m.id), jsonStr(m.opts.Style), formatFloat(m.opts.Center.Lng), @@ -56,14 +57,49 @@ func initScript(m *Map) string { if m.opts.MaxZoom != 0 { b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom))) } - b.WriteString(`});`) + // Interaction toggles + writeBoolOpt := func(name string, val *bool) { + if val != nil { + if *val { + b.WriteString(fmt.Sprintf(`,%s:true`, name)) + } else { + b.WriteString(fmt.Sprintf(`,%s:false`, name)) + } + } + } + writeBoolOpt("scrollZoom", m.opts.ScrollZoom) + writeBoolOpt("boxZoom", m.opts.BoxZoom) + writeBoolOpt("dragRotate", m.opts.DragRotate) + writeBoolOpt("dragPan", m.opts.DragPan) + writeBoolOpt("keyboard", m.opts.Keyboard) + writeBoolOpt("doubleClickZoom", m.opts.DoubleClickZoom) + writeBoolOpt("touchZoomRotate", m.opts.TouchZoomRotate) + writeBoolOpt("touchPitch", m.opts.TouchPitch) + writeBoolOpt("renderWorldCopies", m.opts.RenderWorldCopies) + + if m.opts.MaxBounds != nil { + b.WriteString(fmt.Sprintf(`,maxBounds:[[%s,%s],[%s,%s]]`, + formatFloat(m.opts.MaxBounds.SW.Lng), formatFloat(m.opts.MaxBounds.SW.Lat), + formatFloat(m.opts.MaxBounds.NE.Lng), formatFloat(m.opts.MaxBounds.NE.Lat))) + } + + b.WriteString(`};`) + + // Merge Extra options + if len(m.opts.Extra) > 0 { + extra, _ := json.Marshal(m.opts.Extra) + b.WriteString(fmt.Sprintf(`Object.assign(opts,%s);`, string(extra))) + } + + b.WriteString(`var map=new maplibregl.Map(opts);`) b.WriteString(`if(!window.__via_maps)window.__via_maps={};`) b.WriteString(fmt.Sprintf(`window.__via_maps[%s]=map;`, jsonStr(m.id))) - b.WriteString(`map._via_markers={};map._via_popups={};`) + b.WriteString(`map._via_markers={};map._via_popups={};map._via_controls={};`) - // Pre-render sources, layers, markers, popups run on 'load' - if len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 { + // Pre-render sources, layers, markers, popups, controls run on 'load' + hasLoad := len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 || len(m.controls) > 0 + if hasLoad { b.WriteString(`map.on('load',function(){`) for _, src := range m.sources { b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js)) @@ -76,14 +112,22 @@ func initScript(m *Map) string { } } for _, me := range m.markers { - b.WriteString(markerBodyJS(me.id, me.marker)) + b.WriteString(markerBodyJS(m.id, me.id, me.marker)) } for _, pe := range m.popups { b.WriteString(popupBodyJS(pe.id, pe.popup)) } + for _, ce := range m.controls { + b.WriteString(controlBodyJS(ce.id, ce.ctrl)) + } b.WriteString(`});`) } + // Event listeners + for _, ev := range m.events { + b.WriteString(eventListenerJS(m.id, ev)) + } + // Sync viewport signals on moveend via hidden inputs b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+ `var c=map.getCenter();`+ @@ -96,15 +140,16 @@ func initScript(m *Map) string { `else if(sig===%[4]s)inp.value=map.getZoom();`+ `else if(sig===%[5]s)inp.value=map.getBearing();`+ `else if(sig===%[6]s)inp.value=map.getPitch();`+ + `else return;`+ `inp.dispatchEvent(new Event('input',{bubbles:true}));`+ `});`+ `});`, jsonStr("_vwrap_"+m.id), - jsonStr(m.centerLng.ID()), - jsonStr(m.centerLat.ID()), - jsonStr(m.zoom.ID()), - jsonStr(m.bearing.ID()), - jsonStr(m.pitch.ID()), + jsonStr(m.CenterLng.ID()), + jsonStr(m.CenterLat.ID()), + jsonStr(m.Zoom.ID()), + jsonStr(m.Bearing.ID()), + jsonStr(m.Pitch.ID()), )) // ResizeObserver for auto-resize @@ -132,8 +177,7 @@ func initScript(m *Map) string { } // markerBodyJS generates JS to add a marker, assuming `map` is in scope. -// Used inside the init script's load callback. -func markerBodyJS(markerID string, mk Marker) string { +func markerBodyJS(mapID, markerID string, mk Marker) string { var b strings.Builder opts := "{" if mk.Color != "" { @@ -143,16 +187,64 @@ func markerBodyJS(markerID string, mk Marker) string { opts += `draggable:true,` } opts += "}" - b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`, - opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat))) + + // Determine initial position + if mk.LngSignal != nil && mk.LatSignal != nil { + b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`, + opts, mk.LngSignal.String(), mk.LatSignal.String())) + } else { + b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`, + opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat))) + } + if mk.Popup != nil { b.WriteString(popupConstructorJS(*mk.Popup, "pk")) b.WriteString(`mk.setPopup(pk);`) } b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID))) + + // Dragend → signal writeback + if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil { + b.WriteString(dragendHandlerJS(mapID, markerID, 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 { + return fmt.Sprintf( + `mk.on('dragend',function(){`+ + `var pos=mk.getLngLat();`+ + `var el=document.getElementById(%[1]s);if(!el)return;`+ + `var inputs=el.querySelectorAll('input[data-bind]');`+ + `inputs.forEach(function(inp){`+ + `var sig=inp.getAttribute('data-bind');`+ + `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}))}`+ + `});`+ + `});`, + jsonStr("_vwrap_"+mapID), + jsonStr(mk.LngSignal.ID()), + jsonStr(mk.LatSignal.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 { + // Read signals before the guard so Datastar tracks them as dependencies + // even when the map/marker hasn't loaded yet on first evaluation. + 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. func addMarkerJS(mapID, markerID string, mk Marker) string { var b strings.Builder @@ -163,7 +255,7 @@ func addMarkerJS(mapID, markerID string, mk Marker) string { b.WriteString(fmt.Sprintf( `if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`, jsonStr(markerID))) - b.WriteString(markerBodyJS(markerID, mk)) + b.WriteString(markerBodyJS(mapID, markerID, mk)) b.WriteString(`})()`) return b.String() } @@ -221,6 +313,106 @@ func popupConstructorJS(p Popup, varName string) string { varName, opts, jsonStr(p.Content)) } +// --- Control JS --- + +// controlBodyJS generates JS to add a control, assuming `map` is in scope. +func controlBodyJS(controlID string, ctrl Control) string { + return fmt.Sprintf( + `var ctrl=%s;map.addControl(ctrl,%s);map._via_controls[%s]=ctrl;`, + ctrl.controlJS(), jsonStr(ctrl.controlPosition()), jsonStr(controlID)) +} + +// addControlJS generates a self-contained IIFE to add a control post-render. +func addControlJS(mapID, controlID string, ctrl Control) string { + return fmt.Sprintf( + `(function(){var map=window.__via_maps&&window.__via_maps[%[1]s];if(!map)return;`+ + `if(map._via_controls[%[2]s]){map.removeControl(map._via_controls[%[2]s]);delete map._via_controls[%[2]s];}`+ + `var ctrl=%[3]s;map.addControl(ctrl,%[4]s);map._via_controls[%[2]s]=ctrl;`+ + `})()`, + jsonStr(mapID), jsonStr(controlID), ctrl.controlJS(), jsonStr(ctrl.controlPosition())) +} + +// removeControlJS generates JS to remove a control. Expects `m` in scope. +func removeControlJS(controlID string) string { + return fmt.Sprintf( + `if(m._via_controls[%[1]s]){m.removeControl(m._via_controls[%[1]s]);delete m._via_controls[%[1]s];}`, + jsonStr(controlID)) +} + +// --- Event JS --- + +// eventListenerJS generates JS to register a map event listener that writes +// event data to a hidden signal input. +func eventListenerJS(mapID string, ev eventEntry) string { + var handler string + if ev.layerID != "" { + handler = fmt.Sprintf( + `map.on(%[1]s,%[2]s,function(e){`+ + `var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y],layerID:%[2]s};`+ + `if(e.features)d.features=e.features.map(function(f){return JSON.parse(JSON.stringify(f))});`+ + `var el=document.getElementById(%[3]s);if(!el)return;`+ + `var inp=el.querySelector('input[data-bind=%[4]s]');`+ + `if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+ + `});`, + jsonStr(ev.event), jsonStr(ev.layerID), + jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()), + ) + } else { + handler = fmt.Sprintf( + `map.on(%[1]s,function(e){`+ + `var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y]};`+ + `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()), + ) + } + return handler +} + +// --- Camera options JS --- + +// cameraOptionsJS converts CameraOptions to a JS object literal string. +func cameraOptionsJS(opts CameraOptions) string { + obj := map[string]any{} + if opts.Center != nil { + obj["center"] = []float64{opts.Center.Lng, opts.Center.Lat} + } + if opts.Zoom != nil { + obj["zoom"] = *opts.Zoom + } + if opts.Bearing != nil { + obj["bearing"] = *opts.Bearing + } + if opts.Pitch != nil { + obj["pitch"] = *opts.Pitch + } + if opts.Duration != nil { + obj["duration"] = *opts.Duration + } + if opts.Speed != nil { + obj["speed"] = *opts.Speed + } + if opts.Curve != nil { + obj["curve"] = *opts.Curve + } + if opts.Padding != nil { + obj["padding"] = map[string]int{ + "top": opts.Padding.Top, + "bottom": opts.Padding.Bottom, + "left": opts.Padding.Left, + "right": opts.Padding.Right, + } + } + if opts.Animate != nil { + obj["animate"] = *opts.Animate + } + b, _ := json.Marshal(obj) + return string(b) +} + func formatFloat(f float64) string { return fmt.Sprintf("%g", f) } diff --git a/maplibre/maplibre.go b/maplibre/maplibre.go index f7ca166..852a345 100644 --- a/maplibre/maplibre.go +++ b/maplibre/maplibre.go @@ -8,6 +8,7 @@ import ( "crypto/rand" _ "embed" "encoding/hex" + "encoding/json" "fmt" "net/http" "strconv" @@ -38,29 +39,25 @@ func Plugin(v *via.V) { ) } -// viaSignal is the interface satisfied by via's *signal type. -type viaSignal interface { - ID() string - String() string - SetValue(any) - Bind() h.H -} - // Map represents a MapLibre GL map instance bound to a Via context. type Map struct { + // Viewport signals — readable with .Text(), .String(), etc. + CenterLng *via.Signal + CenterLat *via.Signal + Zoom *via.Signal + Bearing *via.Signal + Pitch *via.Signal + id string ctx *via.Context opts Options - // Viewport signals for browser → server sync - centerLng, centerLat viaSignal - zoom, bearing, pitch viaSignal - - // Pre-render accumulation - sources []sourceEntry - layers []Layer - markers []markerEntry - popups []popupEntry + sources []sourceEntry + layers []Layer + markers []markerEntry + popups []popupEntry + events []eventEntry + controls []controlEntry rendered bool } @@ -81,11 +78,11 @@ func New(c *via.Context, opts Options) *Map { opts: opts, } - m.centerLng = c.Signal(opts.Center.Lng) - m.centerLat = c.Signal(opts.Center.Lat) - m.zoom = c.Signal(opts.Zoom) - m.bearing = c.Signal(opts.Bearing) - m.pitch = c.Signal(opts.Pitch) + m.CenterLng = c.Signal(opts.Center.Lng) + m.CenterLat = c.Signal(opts.Center.Lat) + m.Zoom = c.Signal(opts.Zoom) + m.Bearing = c.Signal(opts.Bearing) + m.Pitch = c.Signal(opts.Pitch) return m } @@ -93,10 +90,14 @@ func New(c *via.Context, opts Options) *Map { // Element returns the h.H DOM tree for the map. Call this once inside your View function. // After Element() is called, subsequent source/layer/marker/popup operations // use ExecScript instead of accumulating for the init script. -func (m *Map) Element() h.H { +// +// Extra children are appended inside the wrapper div (useful for event inputs +// and data-effect binding elements). +func (m *Map) Element(extra ...h.H) h.H { m.rendered = true - return h.Div(h.ID("_vwrap_"+m.id), + children := []h.H{ + h.ID("_vwrap_" + m.id), // Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync() h.Div( h.ID("_vmap_"+m.id), @@ -104,14 +105,39 @@ func (m *Map) Element() h.H { h.Attr("style", fmt.Sprintf("width:%s;height:%s", m.opts.Width, m.opts.Height)), ), // Hidden inputs for viewport signal binding (outside morph-ignored zone) - h.Input(h.Type("hidden"), m.centerLng.Bind()), - h.Input(h.Type("hidden"), m.centerLat.Bind()), - h.Input(h.Type("hidden"), m.zoom.Bind()), - h.Input(h.Type("hidden"), m.bearing.Bind()), - h.Input(h.Type("hidden"), m.pitch.Bind()), - // Init script - h.Script(h.Raw(initScript(m))), - ) + h.Input(h.Type("hidden"), m.CenterLng.Bind()), + h.Input(h.Type("hidden"), m.CenterLat.Bind()), + h.Input(h.Type("hidden"), m.Zoom.Bind()), + h.Input(h.Type("hidden"), m.Bearing.Bind()), + h.Input(h.Type("hidden"), m.Pitch.Bind()), + } + + // data-effect elements for signal-backed markers + for _, me := range m.markers { + if me.marker.LngSignal != nil && me.marker.LatSignal != nil { + children = append(children, h.Div( + h.Attr("style", "display:none"), + h.DataEffect(markerEffectExpr(m.id, me.id, me.marker)), + )) + } + } + + // 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()), + ) + } + } + + children = append(children, extra...) + + // Init script last + children = append(children, h.Script(h.Raw(initScript(m)))) + + return h.Div(children...) } // --- Viewport readers (signal → Go) --- @@ -119,32 +145,43 @@ func (m *Map) Element() h.H { // Center returns the current map center from synced signals. func (m *Map) Center() LngLat { return LngLat{ - Lng: parseFloat(m.centerLng.String()), - Lat: parseFloat(m.centerLat.String()), + Lng: parseFloat(m.CenterLng.String()), + Lat: parseFloat(m.CenterLat.String()), } } -// Zoom returns the current map zoom level from the synced signal. -func (m *Map) Zoom() float64 { - return parseFloat(m.zoom.String()) +// --- Camera methods --- + +// FlyTo animates the map to the target camera state. +func (m *Map) FlyTo(opts CameraOptions) { + m.exec(fmt.Sprintf(`m.flyTo(%s);`, cameraOptionsJS(opts))) } -// Bearing returns the current map bearing from the synced signal. -func (m *Map) Bearing() float64 { - return parseFloat(m.bearing.String()) +// EaseTo eases the map to the target camera state. +func (m *Map) EaseTo(opts CameraOptions) { + m.exec(fmt.Sprintf(`m.easeTo(%s);`, cameraOptionsJS(opts))) } -// Pitch returns the current map pitch from the synced signal. -func (m *Map) Pitch() float64 { - return parseFloat(m.pitch.String()) +// JumpTo jumps the map to the target camera state without animation. +func (m *Map) JumpTo(opts CameraOptions) { + m.exec(fmt.Sprintf(`m.jumpTo(%s);`, cameraOptionsJS(opts))) } -// --- Viewport setters (Go → browser) --- +// FitBounds fits the map to the given bounds with optional camera options. +func (m *Map) FitBounds(bounds LngLatBounds, opts ...CameraOptions) { + boundsJS := fmt.Sprintf("[[%s,%s],[%s,%s]]", + formatFloat(bounds.SW.Lng), formatFloat(bounds.SW.Lat), + formatFloat(bounds.NE.Lng), formatFloat(bounds.NE.Lat)) + if len(opts) > 0 { + m.exec(fmt.Sprintf(`m.fitBounds(%s,%s);`, boundsJS, cameraOptionsJS(opts[0]))) + } else { + m.exec(fmt.Sprintf(`m.fitBounds(%s);`, boundsJS)) + } +} -// FlyTo animates the map to the given center and zoom. -func (m *Map) FlyTo(center LngLat, zoom float64) { - m.exec(fmt.Sprintf(`m.flyTo({center:[%s,%s],zoom:%s});`, - formatFloat(center.Lng), formatFloat(center.Lat), formatFloat(zoom))) +// Stop aborts any in-progress camera animation. +func (m *Map) Stop() { + m.exec(`m.stop();`) } // SetCenter sets the map center without animation. @@ -175,10 +212,9 @@ func (m *Map) SetStyle(url string) { // --- Source methods --- -// AddSource adds a source to the map. src should be a GeoJSONSource, -// VectorSource, RasterSource, or any JSON-marshalable value. -func (m *Map) AddSource(id string, src any) { - js := sourceJSON(src) +// AddSource adds a source to the map. +func (m *Map) AddSource(id string, src Source) { + js := src.sourceJS() if !m.rendered { m.sources = append(m.sources, sourceEntry{id: id, js: js}) return @@ -187,7 +223,6 @@ func (m *Map) AddSource(id string, src any) { } // RemoveSource removes a source from the map. -// Before render, it removes a previously accumulated source. After render, it issues an ExecScript. func (m *Map) RemoveSource(id string) { if !m.rendered { for i, s := range m.sources { @@ -222,7 +257,6 @@ func (m *Map) AddLayer(layer Layer) { } // RemoveLayer removes a layer from the map. -// Before render, it removes a previously accumulated layer. After render, it issues an ExecScript. func (m *Map) RemoveLayer(id string) { if !m.rendered { for i, l := range m.layers { @@ -261,7 +295,6 @@ func (m *Map) AddMarker(id string, marker Marker) { } // RemoveMarker removes a marker from the map. -// Before render, it removes a previously accumulated marker. After render, it issues an ExecScript. func (m *Map) RemoveMarker(id string) { if !m.rendered { for i, me := range m.markers { @@ -288,7 +321,6 @@ func (m *Map) ShowPopup(id string, popup Popup) { } // ClosePopup closes a standalone popup on the map. -// Before render, it removes a previously accumulated popup. After render, it issues an ExecScript. func (m *Map) ClosePopup(id string) { if !m.rendered { for i, pe := range m.popups { @@ -302,6 +334,64 @@ func (m *Map) ClosePopup(id string) { m.exec(closePopupJS(id)) } +// --- Control methods --- + +// AddControl adds a control to the map. +func (m *Map) AddControl(id string, ctrl Control) { + if !m.rendered { + m.controls = append(m.controls, controlEntry{id: id, ctrl: ctrl}) + return + } + m.exec(addControlJS(m.id, id, ctrl)) +} + +// RemoveControl removes a control from the map. +func (m *Map) RemoveControl(id string) { + if !m.rendered { + for i, ce := range m.controls { + if ce.id == id { + m.controls = append(m.controls[:i], m.controls[i+1:]...) + return + } + } + return + } + m.exec(removeControlJS(id)) +} + +// --- Event methods --- + +// OnClick returns a MapEvent that fires on map click. +func (m *Map) OnClick() *MapEvent { + return m.on("click", "") +} + +// OnLayerClick returns a MapEvent that fires on click of a specific layer. +func (m *Map) OnLayerClick(layerID string) *MapEvent { + return m.on("click", layerID) +} + +// OnMouseMove returns a MapEvent that fires on map mouse movement. +func (m *Map) OnMouseMove() *MapEvent { + return m.on("mousemove", "") +} + +// OnContextMenu returns a MapEvent that fires on right-click. +func (m *Map) OnContextMenu() *MapEvent { + return m.on("contextmenu", "") +} + +func (m *Map) on(event, layerID string) *MapEvent { + sig := m.ctx.Signal("") + ev := &MapEvent{signal: sig} + m.events = append(m.events, eventEntry{ + event: event, + layerID: layerID, + signal: sig, + }) + return ev +} + // --- Escape hatch --- // Exec runs arbitrary JS with the map available as `m`. @@ -314,6 +404,30 @@ func (m *Map) exec(body string) { m.ctx.ExecScript(guard(m.id, body)) } +// --- MapEvent --- + +// MapEvent wraps a signal that receives map event data as JSON. +type MapEvent struct { + signal *via.Signal +} + +// Bind returns the data-bind attribute for this event's signal. +func (e *MapEvent) Bind() h.H { return e.signal.Bind() } + +// Data parses the event signal's JSON value into EventData. +func (e *MapEvent) Data() EventData { + var d EventData + json.Unmarshal([]byte(e.signal.String()), &d) + return d +} + +// Input creates a hidden input wired to this event's signal. +// Pass action triggers (e.g. handleClick.OnInput()) as attrs. +func (e *MapEvent) Input(attrs ...h.H) h.H { + all := append([]h.H{h.Type("hidden"), e.Bind()}, attrs...) + return h.Input(all...) +} + func parseFloat(s string) float64 { f, _ := strconv.ParseFloat(s, 64) return f diff --git a/maplibre/types.go b/maplibre/types.go index 4698a21..09f0639 100644 --- a/maplibre/types.go +++ b/maplibre/types.go @@ -1,6 +1,10 @@ package maplibre -import "encoding/json" +import ( + "encoding/json" + + "github.com/ryanhamamura/via" +) // LngLat represents a geographic coordinate. type LngLat struct { @@ -8,6 +12,20 @@ type LngLat struct { Lat float64 } +// LngLatBounds represents a rectangular geographic area. +type LngLatBounds struct { + SW LngLat + NE LngLat +} + +// Padding represents padding in pixels on each side of the map viewport. +type Padding struct { + Top int + Bottom int + Left int + Right int +} + // Options configures the initial map state. type Options struct { // Style is the map style URL (required). @@ -23,6 +41,30 @@ type Options struct { // CSS dimensions for the map container. Defaults: "100%", "400px". Width string Height string + + // Interaction toggles (nil = MapLibre default) + ScrollZoom *bool + BoxZoom *bool + DragRotate *bool + DragPan *bool + Keyboard *bool + DoubleClickZoom *bool + TouchZoomRotate *bool + TouchPitch *bool + RenderWorldCopies *bool + + MaxBounds *LngLatBounds + + // Extra is merged last into the MapLibre constructor options object, + // allowing pass-through of any option not covered above. + Extra map[string]any +} + +// --- Source interface --- + +// Source is implemented by map data sources (GeoJSON, vector, raster, etc.). +type Source interface { + sourceJS() string } // GeoJSONSource provides inline GeoJSON data to MapLibre. @@ -31,7 +73,7 @@ type GeoJSONSource struct { Data any } -func (s GeoJSONSource) toJS() string { +func (s GeoJSONSource) sourceJS() string { data, _ := json.Marshal(s.Data) return `{"type":"geojson","data":` + string(data) + `}` } @@ -42,7 +84,7 @@ type VectorSource struct { Tiles []string } -func (s VectorSource) toJS() string { +func (s VectorSource) sourceJS() string { obj := map[string]any{"type": "vector"} if s.URL != "" { obj["url"] = s.URL @@ -61,7 +103,7 @@ type RasterSource struct { TileSize int } -func (s RasterSource) toJS() string { +func (s RasterSource) sourceJS() string { obj := map[string]any{"type": "raster"} if s.URL != "" { obj["url"] = s.URL @@ -76,21 +118,135 @@ func (s RasterSource) toJS() string { return string(b) } -// sourceJSON converts a source value to its JS object literal string. -func sourceJSON(src any) string { - switch s := src.(type) { - case GeoJSONSource: - return s.toJS() - case VectorSource: - return s.toJS() - case RasterSource: - return s.toJS() - default: - b, _ := json.Marshal(src) - return string(b) - } +// RawSource is an escape hatch that passes an arbitrary JSON-marshalable +// value directly as a MapLibre source definition. +type RawSource struct { + Value any } +func (s RawSource) sourceJS() string { + b, _ := json.Marshal(s.Value) + return string(b) +} + +// --- Control interface --- + +// Control is implemented by map controls (navigation, scale, etc.). +type Control interface { + controlJS() string + controlPosition() string +} + +// NavigationControl adds zoom and rotation buttons. +type NavigationControl struct { + Position string // "top-right" (default), "top-left", "bottom-right", "bottom-left" + ShowCompass *bool + ShowZoom *bool + VisualizeRoll *bool + VisualizePitch *bool +} + +func (c NavigationControl) controlJS() string { + opts := map[string]any{} + if c.ShowCompass != nil { + opts["showCompass"] = *c.ShowCompass + } + if c.ShowZoom != nil { + opts["showZoom"] = *c.ShowZoom + } + if c.VisualizeRoll != nil { + opts["visualizeRoll"] = *c.VisualizeRoll + } + if c.VisualizePitch != nil { + opts["visualizePitch"] = *c.VisualizePitch + } + b, _ := json.Marshal(opts) + return "new maplibregl.NavigationControl(" + string(b) + ")" +} + +func (c NavigationControl) controlPosition() string { + if c.Position == "" { + return "top-right" + } + return c.Position +} + +// ScaleControl displays a scale bar. +type ScaleControl struct { + Position string // default "bottom-left" + MaxWidth int + Unit string // "metric", "imperial", "nautical" +} + +func (c ScaleControl) controlJS() string { + opts := map[string]any{} + if c.MaxWidth > 0 { + opts["maxWidth"] = c.MaxWidth + } + if c.Unit != "" { + opts["unit"] = c.Unit + } + b, _ := json.Marshal(opts) + return "new maplibregl.ScaleControl(" + string(b) + ")" +} + +func (c ScaleControl) controlPosition() string { + if c.Position == "" { + return "bottom-left" + } + return c.Position +} + +// GeolocateControl adds a button to track the user's location. +type GeolocateControl struct { + Position string // default "top-right" +} + +func (c GeolocateControl) controlJS() string { + return "new maplibregl.GeolocateControl()" +} + +func (c GeolocateControl) controlPosition() string { + if c.Position == "" { + return "top-right" + } + return c.Position +} + +// FullscreenControl adds a fullscreen toggle button. +type FullscreenControl struct { + Position string // default "top-right" +} + +func (c FullscreenControl) controlJS() string { + return "new maplibregl.FullscreenControl()" +} + +func (c FullscreenControl) controlPosition() string { + if c.Position == "" { + return "top-right" + } + return c.Position +} + +// --- Camera options --- + +// CameraOptions configures animated camera movements (FlyTo, EaseTo, JumpTo). +// Nil pointer fields are omitted from the JS call. +type CameraOptions struct { + Center *LngLat + Zoom *float64 + Bearing *float64 + Pitch *float64 + Duration *int // milliseconds + Speed *float64 // FlyTo only + Curve *float64 // FlyTo only + Padding *Padding + Animate *bool +} + +// --- Layer --- + // Layer describes a MapLibre style layer. type Layer struct { ID string @@ -137,12 +293,20 @@ func (l Layer) toJS() string { return string(b) } +// --- Marker --- + // Marker describes a map marker. type Marker struct { - LngLat LngLat + LngLat LngLat // static position (used when signals are nil) Color string Draggable bool Popup *Popup + + // Signal-backed position. When set, signals drive marker position reactively. + // Initial position is read from the signal values. LngLat is ignored when signals are set. + // If Draggable is true, drag updates write back to these signals. + LngSignal *via.Signal + LatSignal *via.Signal } // Popup describes a map popup. @@ -156,20 +320,40 @@ type Popup struct { MaxWidth string } -// sourceEntry pairs a source ID with its JS representation for pre-render accumulation. +// --- Event data --- + +// EventData contains data from a map event (click, mousemove, etc.). +type EventData struct { + LngLat LngLat `json:"lngLat"` + Point [2]float64 `json:"point"` + Features []json.RawMessage `json:"features,omitempty"` + LayerID string `json:"layerID,omitempty"` +} + +// --- Internal accumulation entries --- + type sourceEntry struct { id string js string } -// markerEntry pairs a marker ID with its definition for pre-render accumulation. type markerEntry struct { id string marker Marker } -// popupEntry pairs a popup ID with its definition for pre-render accumulation. type popupEntry struct { id string popup Popup } + +type eventEntry struct { + event string + layerID string + signal *via.Signal +} + +type controlEntry struct { + id string + ctrl Control +} diff --git a/signal.go b/signal.go index b4e51b2..8ef64eb 100644 --- a/signal.go +++ b/signal.go @@ -9,27 +9,27 @@ import ( ) // Signal represents a value that is reactive in the browser. Signals -// are synct with the server right before an action triggers. +// are synced with the server right before an action triggers. // // Use Bind() to connect a signal to an input and Text() to display it // reactively on an html element. -type signal struct { +type Signal struct { id string val any changed bool err error } -// ID returns the signal ID -func (s *signal) ID() string { +// ID returns the signal ID. +func (s *Signal) ID() string { return s.id } // Err returns a signal error or nil if it contains no error. // // It is useful to check for errors after updating signals with -// dinamic values. -func (s *signal) Err() error { +// dynamic values. +func (s *Signal) Err() error { return s.err } @@ -39,7 +39,7 @@ func (s *signal) Err() error { // Example: // // h.Input(h.Type("number"), mysignal.Bind()) -func (s *signal) Bind() h.H { +func (s *Signal) Bind() h.H { return h.Data("bind", s.id) } @@ -48,33 +48,33 @@ func (s *signal) Bind() h.H { // Example: // // h.Div(mysignal.Text()) -func (s *signal) Text() h.H { +func (s *Signal) Text() h.H { return h.Span(h.Data("text", "$"+s.id)) } // SetValue updates the signal’s value and marks it for synchronization with the browser. // The change will be propagated to the browser using *Context.Sync() or *Context.SyncSignals(). -func (s *signal) SetValue(v any) { +func (s *Signal) SetValue(v any) { s.val = v s.changed = true s.err = nil } -// String return the signal value as a string. -func (s *signal) String() string { +// String returns the signal value as a string. +func (s *Signal) String() string { return fmt.Sprintf("%v", s.val) } // Bool tries to read the signal value as a bool. // Returns the value or false on failure. -func (s *signal) Bool() bool { +func (s *Signal) Bool() bool { val := strings.ToLower(s.String()) return val == "true" || val == "1" || val == "yes" || val == "on" } // Int tries to read the signal value as an int. // Returns the value or 0 on failure. -func (s *signal) Int() int { +func (s *Signal) Int() int { if n, err := strconv.Atoi(s.String()); err == nil { return n } diff --git a/signal_test.go b/signal_test.go index cba3c2f..b7ccb14 100644 --- a/signal_test.go +++ b/signal_test.go @@ -27,7 +27,7 @@ func TestSignalReturnAsString(t *testing.T) { for _, testcase := range testcases { t.Run(testcase.desc, func(t *testing.T) { t.Parallel() - var sig *signal + var sig *Signal v := New() v.Page("/", func(c *Context) { sig = c.Signal(testcase.given) @@ -57,7 +57,7 @@ func TestSignalReturnAsStringComplexTypes(t *testing.T) { for _, testcase := range testcases { t.Run(testcase.desc, func(t *testing.T) { t.Parallel() - var sig *signal + var sig *Signal v := New() v.Page("/", func(c *Context) { c.View(func() h.H { return nil }) diff --git a/via_test.go b/via_test.go index f485344..885edb5 100644 --- a/via_test.go +++ b/via_test.go @@ -90,7 +90,7 @@ func TestCustomDatastarPath(t *testing.T) { } func TestSignal(t *testing.T) { - var sig *signal + var sig *Signal v := New() v.Page("/", func(c *Context) { sig = c.Signal("test") @@ -106,7 +106,7 @@ func TestSignal(t *testing.T) { func TestAction(t *testing.T) { var trigger *actionTrigger - var sig *signal + var sig *Signal v := New() v.Page("/", func(c *Context) { trigger = c.Action(func() {}) @@ -167,7 +167,7 @@ func TestEventTypes(t *testing.T) { t.Run("WithSignal", func(t *testing.T) { var trigger *actionTrigger - var sig *signal + var sig *Signal v := New() v.Page("/", func(c *Context) { trigger = c.Action(func() {}) @@ -207,7 +207,7 @@ func TestOnKeyDownWithWindow(t *testing.T) { func TestOnKeyDownMap(t *testing.T) { t.Run("multiple bindings with different actions", func(t *testing.T) { var move, shoot *actionTrigger - var dir *signal + var dir *Signal v := New() v.Page("/", func(c *Context) { dir = c.Signal("none") @@ -251,7 +251,7 @@ func TestOnKeyDownMap(t *testing.T) { t.Run("WithSignal per binding", func(t *testing.T) { var move *actionTrigger - var dir *signal + var dir *Signal v := New() v.Page("/", func(c *Context) { dir = c.Signal("none")