fix: maplibre reactive signal bugs and stale signal re-push #3
@@ -97,7 +97,7 @@ func buildAttrKey(event string, opts *triggerOpts) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WithSignal sets a signal value before triggering the action.
|
// 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{
|
return withSignalOpt{
|
||||||
signalID: sig.ID(),
|
signalID: sig.ID(),
|
||||||
value: fmt.Sprintf("'%s'", value),
|
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.
|
// 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{
|
return withSignalOpt{
|
||||||
signalID: sig.ID(),
|
signalID: sig.ID(),
|
||||||
value: strconv.Itoa(value),
|
value: strconv.Itoa(value),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestComputedBasic(t *testing.T) {
|
|||||||
func TestComputedReactivity(t *testing.T) {
|
func TestComputedReactivity(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
var cs *computedSignal
|
var cs *computedSignal
|
||||||
var sig1 *signal
|
var sig1 *Signal
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
sig1 = c.Signal("a")
|
sig1 = c.Signal("a")
|
||||||
sig2 := c.Signal("b")
|
sig2 := c.Signal("b")
|
||||||
@@ -83,7 +83,7 @@ func TestComputedText(t *testing.T) {
|
|||||||
func TestComputedChangeDetection(t *testing.T) {
|
func TestComputedChangeDetection(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
var ctx *Context
|
var ctx *Context
|
||||||
var sig *signal
|
var sig *Signal
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
ctx = c
|
ctx = c
|
||||||
sig = c.Signal("a")
|
sig = c.Signal("a")
|
||||||
|
|||||||
14
context.go
14
context.go
@@ -175,11 +175,11 @@ func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
|
|||||||
// the Context before each action call.
|
// the Context before each action call.
|
||||||
// If any signal value is updated by the server, the update is automatically sent to the
|
// If any signal value is updated by the server, the update is automatically sent to the
|
||||||
// browser when using Sync() or SyncSignsls().
|
// browser when using Sync() or SyncSignsls().
|
||||||
func (c *Context) Signal(v any) *signal {
|
func (c *Context) Signal(v any) *Signal {
|
||||||
sigID := genRandID()
|
sigID := genRandID()
|
||||||
if v == nil {
|
if v == nil {
|
||||||
c.app.logErr(c, "failed to bind signal: nil signal value")
|
c.app.logErr(c, "failed to bind signal: nil signal value")
|
||||||
return &signal{
|
return &Signal{
|
||||||
id: sigID,
|
id: sigID,
|
||||||
val: "error",
|
val: "error",
|
||||||
err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID),
|
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)
|
v = string(j)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sig := &signal{
|
sig := &Signal{
|
||||||
id: sigID,
|
id: sigID,
|
||||||
val: v,
|
val: v,
|
||||||
changed: true,
|
changed: true,
|
||||||
@@ -254,13 +254,13 @@ func (c *Context) injectSignals(sigs map[string]any) {
|
|||||||
for sigID, val := range sigs {
|
for sigID, val := range sigs {
|
||||||
item, ok := c.signals.Load(sigID)
|
item, ok := c.signals.Load(sigID)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.signals.Store(sigID, &signal{
|
c.signals.Store(sigID, &Signal{
|
||||||
id: sigID,
|
id: sigID,
|
||||||
val: val,
|
val: val,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if sig, ok := item.(*signal); ok {
|
if sig, ok := item.(*Signal); ok {
|
||||||
sig.val = val
|
sig.val = val
|
||||||
sig.changed = false
|
sig.changed = false
|
||||||
}
|
}
|
||||||
@@ -284,7 +284,7 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
|
|||||||
updatedSigs := make(map[string]any)
|
updatedSigs := make(map[string]any)
|
||||||
c.signals.Range(func(sigID, value any) bool {
|
c.signals.Range(func(sigID, value any) bool {
|
||||||
switch sig := value.(type) {
|
switch sig := value.(type) {
|
||||||
case *signal:
|
case *Signal:
|
||||||
if sig.err != nil {
|
if sig.err != nil {
|
||||||
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
||||||
return true
|
return true
|
||||||
@@ -594,7 +594,7 @@ func (c *Context) unsubscribeAll() {
|
|||||||
// can operate on all fields by default.
|
// can operate on all fields by default.
|
||||||
func (c *Context) Field(initial any, rules ...Rule) *Field {
|
func (c *Context) Field(initial any, rules ...Rule) *Field {
|
||||||
f := &Field{
|
f := &Field{
|
||||||
signal: c.Signal(initial),
|
Signal: c.Signal(initial),
|
||||||
rules: rules,
|
rules: rules,
|
||||||
initialVal: initial,
|
initialVal: initial,
|
||||||
}
|
}
|
||||||
|
|||||||
4
field.go
4
field.go
@@ -1,10 +1,10 @@
|
|||||||
package via
|
package via
|
||||||
|
|
||||||
// Field is a signal with built-in validation rules and error state.
|
// 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.
|
// work transparently.
|
||||||
type Field struct {
|
type Field struct {
|
||||||
*signal
|
*Signal
|
||||||
rules []Rule
|
rules []Rule
|
||||||
errors []string
|
errors []string
|
||||||
initialVal any
|
initialVal any
|
||||||
|
|||||||
52
internal/examples/effectspike/main.go
Normal file
52
internal/examples/effectspike/main.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ryanhamamura/via"
|
"github.com/ryanhamamura/via"
|
||||||
"github.com/ryanhamamura/via/h"
|
"github.com/ryanhamamura/via/h"
|
||||||
@@ -25,7 +26,10 @@ func main() {
|
|||||||
Height: "500px",
|
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{
|
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",
|
||||||
@@ -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
|
// GeoJSON polygon source + fill layer
|
||||||
m.AddSource("park", maplibre.GeoJSONSource{
|
m.AddSource("park", maplibre.GeoJSONSource{
|
||||||
Data: map[string]any{
|
Data: map[string]any{
|
||||||
@@ -70,24 +111,20 @@ func main() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Viewport info signal (updated on action)
|
// FlyTo actions using CameraOptions
|
||||||
viewportInfo := c.Signal("")
|
zoom14 := 14.0
|
||||||
|
|
||||||
// FlyTo action
|
|
||||||
flyToSF := c.Action(func() {
|
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() {
|
flyToOak := c.Action(func() {
|
||||||
m.FlyTo(maplibre.LngLat{Lng: -122.2711, Lat: 37.8044}, 14)
|
m.FlyTo(maplibre.CameraOptions{
|
||||||
})
|
Center: &maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
|
||||||
|
Zoom: &zoom14,
|
||||||
// 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()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
c.View(func() h.H {
|
c.View(func() h.H {
|
||||||
@@ -95,13 +132,19 @@ func main() {
|
|||||||
h.Div(
|
h.Div(
|
||||||
h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"),
|
h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"),
|
||||||
h.H1(h.Text("MapLibre GL Example")),
|
h.H1(h.Text("MapLibre GL Example")),
|
||||||
m.Element(),
|
m.Element(
|
||||||
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem"),
|
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 San Francisco"), flyToSF.OnClick()),
|
||||||
h.Button(h.Text("Fly to Oakland"), flyToOak.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()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
220
maplibre/js.go
220
maplibre/js.go
@@ -36,8 +36,9 @@ func initScript(m *Map) string {
|
|||||||
jsonStr(m.id),
|
jsonStr(m.id),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// Build constructor options object
|
||||||
b.WriteString(fmt.Sprintf(
|
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("_vmap_"+m.id),
|
||||||
jsonStr(m.opts.Style),
|
jsonStr(m.opts.Style),
|
||||||
formatFloat(m.opts.Center.Lng),
|
formatFloat(m.opts.Center.Lng),
|
||||||
@@ -56,14 +57,49 @@ func initScript(m *Map) string {
|
|||||||
if m.opts.MaxZoom != 0 {
|
if m.opts.MaxZoom != 0 {
|
||||||
b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom)))
|
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(`if(!window.__via_maps)window.__via_maps={};`)
|
||||||
b.WriteString(fmt.Sprintf(`window.__via_maps[%s]=map;`, jsonStr(m.id)))
|
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'
|
// Pre-render sources, layers, markers, popups, controls run on 'load'
|
||||||
if len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 {
|
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(){`)
|
b.WriteString(`map.on('load',function(){`)
|
||||||
for _, src := range m.sources {
|
for _, src := range m.sources {
|
||||||
b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js))
|
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 {
|
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 {
|
for _, pe := range m.popups {
|
||||||
b.WriteString(popupBodyJS(pe.id, pe.popup))
|
b.WriteString(popupBodyJS(pe.id, pe.popup))
|
||||||
}
|
}
|
||||||
|
for _, ce := range m.controls {
|
||||||
|
b.WriteString(controlBodyJS(ce.id, ce.ctrl))
|
||||||
|
}
|
||||||
b.WriteString(`});`)
|
b.WriteString(`});`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
for _, ev := range m.events {
|
||||||
|
b.WriteString(eventListenerJS(m.id, ev))
|
||||||
|
}
|
||||||
|
|
||||||
// Sync viewport signals on moveend via hidden inputs
|
// Sync viewport signals on moveend via hidden inputs
|
||||||
b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+
|
b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+
|
||||||
`var c=map.getCenter();`+
|
`var c=map.getCenter();`+
|
||||||
@@ -100,11 +144,11 @@ func initScript(m *Map) string {
|
|||||||
`});`+
|
`});`+
|
||||||
`});`,
|
`});`,
|
||||||
jsonStr("_vwrap_"+m.id),
|
jsonStr("_vwrap_"+m.id),
|
||||||
jsonStr(m.centerLng.ID()),
|
jsonStr(m.CenterLng.ID()),
|
||||||
jsonStr(m.centerLat.ID()),
|
jsonStr(m.CenterLat.ID()),
|
||||||
jsonStr(m.zoom.ID()),
|
jsonStr(m.Zoom.ID()),
|
||||||
jsonStr(m.bearing.ID()),
|
jsonStr(m.Bearing.ID()),
|
||||||
jsonStr(m.pitch.ID()),
|
jsonStr(m.Pitch.ID()),
|
||||||
))
|
))
|
||||||
|
|
||||||
// ResizeObserver for auto-resize
|
// ResizeObserver for auto-resize
|
||||||
@@ -132,8 +176,7 @@ func initScript(m *Map) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// markerBodyJS generates JS to add a marker, assuming `map` is in scope.
|
// markerBodyJS generates JS to add a marker, assuming `map` is in scope.
|
||||||
// Used inside the init script's load callback.
|
func markerBodyJS(mapID, markerID string, mk Marker) string {
|
||||||
func markerBodyJS(markerID string, mk Marker) string {
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
opts := "{"
|
opts := "{"
|
||||||
if mk.Color != "" {
|
if mk.Color != "" {
|
||||||
@@ -143,16 +186,61 @@ func markerBodyJS(markerID string, mk Marker) string {
|
|||||||
opts += `draggable:true,`
|
opts += `draggable:true,`
|
||||||
}
|
}
|
||||||
opts += "}"
|
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 {
|
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
|
||||||
|
if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil {
|
||||||
|
b.WriteString(dragendHandlerJS(mapID, markerID, mk))
|
||||||
|
}
|
||||||
|
|
||||||
return b.String()
|
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 {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`var m=window.__via_maps&&window.__via_maps[%s];`+
|
||||||
|
`if(m&&m._via_markers[%s]){`+
|
||||||
|
`m._via_markers[%s].setLngLat([$%s,$%s])}`,
|
||||||
|
jsonStr(mapID), jsonStr(markerID), jsonStr(markerID),
|
||||||
|
mk.LngSignal.ID(), mk.LatSignal.ID(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 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) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -163,7 +251,7 @@ func addMarkerJS(mapID, markerID string, mk Marker) string {
|
|||||||
b.WriteString(fmt.Sprintf(
|
b.WriteString(fmt.Sprintf(
|
||||||
`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(markerID, mk))
|
b.WriteString(markerBodyJS(mapID, markerID, mk))
|
||||||
b.WriteString(`})()`)
|
b.WriteString(`})()`)
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -221,6 +309,106 @@ func popupConstructorJS(p Popup, varName string) string {
|
|||||||
varName, opts, jsonStr(p.Content))
|
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 {
|
func formatFloat(f float64) string {
|
||||||
return fmt.Sprintf("%g", f)
|
return fmt.Sprintf("%g", f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"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.
|
// Map represents a MapLibre GL map instance bound to a Via context.
|
||||||
type Map struct {
|
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
|
id string
|
||||||
ctx *via.Context
|
ctx *via.Context
|
||||||
opts Options
|
opts Options
|
||||||
|
|
||||||
// Viewport signals for browser → server sync
|
sources []sourceEntry
|
||||||
centerLng, centerLat viaSignal
|
layers []Layer
|
||||||
zoom, bearing, pitch viaSignal
|
markers []markerEntry
|
||||||
|
popups []popupEntry
|
||||||
// Pre-render accumulation
|
events []eventEntry
|
||||||
sources []sourceEntry
|
controls []controlEntry
|
||||||
layers []Layer
|
|
||||||
markers []markerEntry
|
|
||||||
popups []popupEntry
|
|
||||||
|
|
||||||
rendered bool
|
rendered bool
|
||||||
}
|
}
|
||||||
@@ -81,11 +78,11 @@ func New(c *via.Context, opts Options) *Map {
|
|||||||
opts: opts,
|
opts: opts,
|
||||||
}
|
}
|
||||||
|
|
||||||
m.centerLng = c.Signal(opts.Center.Lng)
|
m.CenterLng = c.Signal(opts.Center.Lng)
|
||||||
m.centerLat = c.Signal(opts.Center.Lat)
|
m.CenterLat = c.Signal(opts.Center.Lat)
|
||||||
m.zoom = c.Signal(opts.Zoom)
|
m.Zoom = c.Signal(opts.Zoom)
|
||||||
m.bearing = c.Signal(opts.Bearing)
|
m.Bearing = c.Signal(opts.Bearing)
|
||||||
m.pitch = c.Signal(opts.Pitch)
|
m.Pitch = c.Signal(opts.Pitch)
|
||||||
|
|
||||||
return m
|
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.
|
// 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
|
// After Element() is called, subsequent source/layer/marker/popup operations
|
||||||
// use ExecScript instead of accumulating for the init script.
|
// 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
|
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()
|
// Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync()
|
||||||
h.Div(
|
h.Div(
|
||||||
h.ID("_vmap_"+m.id),
|
h.ID("_vmap_"+m.id),
|
||||||
@@ -104,14 +105,36 @@ func (m *Map) Element() h.H {
|
|||||||
h.Attr("style", fmt.Sprintf("width:%s;height:%s", m.opts.Width, m.opts.Height)),
|
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)
|
// Hidden inputs for viewport signal binding (outside morph-ignored zone)
|
||||||
h.Input(h.Type("hidden"), m.centerLng.Bind()),
|
h.Input(h.Type("hidden"), m.CenterLng.Bind()),
|
||||||
h.Input(h.Type("hidden"), m.centerLat.Bind()),
|
h.Input(h.Type("hidden"), m.CenterLat.Bind()),
|
||||||
h.Input(h.Type("hidden"), m.zoom.Bind()),
|
h.Input(h.Type("hidden"), m.Zoom.Bind()),
|
||||||
h.Input(h.Type("hidden"), m.bearing.Bind()),
|
h.Input(h.Type("hidden"), m.Bearing.Bind()),
|
||||||
h.Input(h.Type("hidden"), m.pitch.Bind()),
|
h.Input(h.Type("hidden"), m.Pitch.Bind()),
|
||||||
// Init script
|
}
|
||||||
h.Script(h.Raw(initScript(m))),
|
|
||||||
)
|
// 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)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listener binding elements
|
||||||
|
for _, ev := range m.events {
|
||||||
|
children = append(children,
|
||||||
|
h.Input(h.Type("hidden"), ev.signal.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) ---
|
// --- Viewport readers (signal → Go) ---
|
||||||
@@ -119,32 +142,43 @@ func (m *Map) Element() h.H {
|
|||||||
// Center returns the current map center from synced signals.
|
// Center returns the current map center from synced signals.
|
||||||
func (m *Map) Center() LngLat {
|
func (m *Map) Center() LngLat {
|
||||||
return LngLat{
|
return LngLat{
|
||||||
Lng: parseFloat(m.centerLng.String()),
|
Lng: parseFloat(m.CenterLng.String()),
|
||||||
Lat: parseFloat(m.centerLat.String()),
|
Lat: parseFloat(m.CenterLat.String()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zoom returns the current map zoom level from the synced signal.
|
// --- Camera methods ---
|
||||||
func (m *Map) Zoom() float64 {
|
|
||||||
return parseFloat(m.zoom.String())
|
// 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.
|
// EaseTo eases the map to the target camera state.
|
||||||
func (m *Map) Bearing() float64 {
|
func (m *Map) EaseTo(opts CameraOptions) {
|
||||||
return parseFloat(m.bearing.String())
|
m.exec(fmt.Sprintf(`m.easeTo(%s);`, cameraOptionsJS(opts)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pitch returns the current map pitch from the synced signal.
|
// JumpTo jumps the map to the target camera state without animation.
|
||||||
func (m *Map) Pitch() float64 {
|
func (m *Map) JumpTo(opts CameraOptions) {
|
||||||
return parseFloat(m.pitch.String())
|
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.
|
// Stop aborts any in-progress camera animation.
|
||||||
func (m *Map) FlyTo(center LngLat, zoom float64) {
|
func (m *Map) Stop() {
|
||||||
m.exec(fmt.Sprintf(`m.flyTo({center:[%s,%s],zoom:%s});`,
|
m.exec(`m.stop();`)
|
||||||
formatFloat(center.Lng), formatFloat(center.Lat), formatFloat(zoom)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCenter sets the map center without animation.
|
// SetCenter sets the map center without animation.
|
||||||
@@ -175,10 +209,9 @@ func (m *Map) SetStyle(url string) {
|
|||||||
|
|
||||||
// --- Source methods ---
|
// --- Source methods ---
|
||||||
|
|
||||||
// AddSource adds a source to the map. src should be a GeoJSONSource,
|
// AddSource adds a source to the map.
|
||||||
// VectorSource, RasterSource, or any JSON-marshalable value.
|
func (m *Map) AddSource(id string, src Source) {
|
||||||
func (m *Map) AddSource(id string, src any) {
|
js := src.sourceJS()
|
||||||
js := sourceJSON(src)
|
|
||||||
if !m.rendered {
|
if !m.rendered {
|
||||||
m.sources = append(m.sources, sourceEntry{id: id, js: js})
|
m.sources = append(m.sources, sourceEntry{id: id, js: js})
|
||||||
return
|
return
|
||||||
@@ -187,7 +220,6 @@ func (m *Map) AddSource(id string, src any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RemoveSource removes a source from the map.
|
// 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) {
|
func (m *Map) RemoveSource(id string) {
|
||||||
if !m.rendered {
|
if !m.rendered {
|
||||||
for i, s := range m.sources {
|
for i, s := range m.sources {
|
||||||
@@ -222,7 +254,6 @@ func (m *Map) AddLayer(layer Layer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RemoveLayer removes a layer from the map.
|
// 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) {
|
func (m *Map) RemoveLayer(id string) {
|
||||||
if !m.rendered {
|
if !m.rendered {
|
||||||
for i, l := range m.layers {
|
for i, l := range m.layers {
|
||||||
@@ -261,7 +292,6 @@ func (m *Map) AddMarker(id string, marker Marker) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RemoveMarker removes a marker from the map.
|
// 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) {
|
func (m *Map) RemoveMarker(id string) {
|
||||||
if !m.rendered {
|
if !m.rendered {
|
||||||
for i, me := range m.markers {
|
for i, me := range m.markers {
|
||||||
@@ -288,7 +318,6 @@ func (m *Map) ShowPopup(id string, popup Popup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ClosePopup closes a standalone popup on the map.
|
// 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) {
|
func (m *Map) ClosePopup(id string) {
|
||||||
if !m.rendered {
|
if !m.rendered {
|
||||||
for i, pe := range m.popups {
|
for i, pe := range m.popups {
|
||||||
@@ -302,6 +331,64 @@ func (m *Map) ClosePopup(id string) {
|
|||||||
m.exec(closePopupJS(id))
|
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 ---
|
// --- Escape hatch ---
|
||||||
|
|
||||||
// Exec runs arbitrary JS with the map available as `m`.
|
// Exec runs arbitrary JS with the map available as `m`.
|
||||||
@@ -314,6 +401,30 @@ func (m *Map) exec(body string) {
|
|||||||
m.ctx.ExecScript(guard(m.id, body))
|
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 {
|
func parseFloat(s string) float64 {
|
||||||
f, _ := strconv.ParseFloat(s, 64)
|
f, _ := strconv.ParseFloat(s, 64)
|
||||||
return f
|
return f
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package maplibre
|
package maplibre
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via"
|
||||||
|
)
|
||||||
|
|
||||||
// LngLat represents a geographic coordinate.
|
// LngLat represents a geographic coordinate.
|
||||||
type LngLat struct {
|
type LngLat struct {
|
||||||
@@ -8,6 +12,20 @@ type LngLat struct {
|
|||||||
Lat float64
|
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.
|
// Options configures the initial map state.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
// Style is the map style URL (required).
|
// Style is the map style URL (required).
|
||||||
@@ -23,6 +41,30 @@ type Options struct {
|
|||||||
// CSS dimensions for the map container. Defaults: "100%", "400px".
|
// CSS dimensions for the map container. Defaults: "100%", "400px".
|
||||||
Width string
|
Width string
|
||||||
Height 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.
|
// GeoJSONSource provides inline GeoJSON data to MapLibre.
|
||||||
@@ -31,7 +73,7 @@ type GeoJSONSource struct {
|
|||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s GeoJSONSource) toJS() string {
|
func (s GeoJSONSource) sourceJS() string {
|
||||||
data, _ := json.Marshal(s.Data)
|
data, _ := json.Marshal(s.Data)
|
||||||
return `{"type":"geojson","data":` + string(data) + `}`
|
return `{"type":"geojson","data":` + string(data) + `}`
|
||||||
}
|
}
|
||||||
@@ -42,7 +84,7 @@ type VectorSource struct {
|
|||||||
Tiles []string
|
Tiles []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s VectorSource) toJS() string {
|
func (s VectorSource) sourceJS() string {
|
||||||
obj := map[string]any{"type": "vector"}
|
obj := map[string]any{"type": "vector"}
|
||||||
if s.URL != "" {
|
if s.URL != "" {
|
||||||
obj["url"] = s.URL
|
obj["url"] = s.URL
|
||||||
@@ -61,7 +103,7 @@ type RasterSource struct {
|
|||||||
TileSize int
|
TileSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s RasterSource) toJS() string {
|
func (s RasterSource) sourceJS() string {
|
||||||
obj := map[string]any{"type": "raster"}
|
obj := map[string]any{"type": "raster"}
|
||||||
if s.URL != "" {
|
if s.URL != "" {
|
||||||
obj["url"] = s.URL
|
obj["url"] = s.URL
|
||||||
@@ -76,21 +118,135 @@ func (s RasterSource) toJS() string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceJSON converts a source value to its JS object literal string.
|
// RawSource is an escape hatch that passes an arbitrary JSON-marshalable
|
||||||
func sourceJSON(src any) string {
|
// value directly as a MapLibre source definition.
|
||||||
switch s := src.(type) {
|
type RawSource struct {
|
||||||
case GeoJSONSource:
|
Value any
|
||||||
return s.toJS()
|
|
||||||
case VectorSource:
|
|
||||||
return s.toJS()
|
|
||||||
case RasterSource:
|
|
||||||
return s.toJS()
|
|
||||||
default:
|
|
||||||
b, _ := json.Marshal(src)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Layer describes a MapLibre style layer.
|
||||||
type Layer struct {
|
type Layer struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -137,12 +293,20 @@ func (l Layer) toJS() string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Marker ---
|
||||||
|
|
||||||
// Marker describes a map marker.
|
// Marker describes a map marker.
|
||||||
type Marker struct {
|
type Marker struct {
|
||||||
LngLat LngLat
|
LngLat LngLat // static position (used when signals are nil)
|
||||||
Color string
|
Color string
|
||||||
Draggable bool
|
Draggable bool
|
||||||
Popup *Popup
|
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.
|
// Popup describes a map popup.
|
||||||
@@ -156,20 +320,40 @@ type Popup struct {
|
|||||||
MaxWidth string
|
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 {
|
type sourceEntry struct {
|
||||||
id string
|
id string
|
||||||
js string
|
js string
|
||||||
}
|
}
|
||||||
|
|
||||||
// markerEntry pairs a marker ID with its definition for pre-render accumulation.
|
|
||||||
type markerEntry struct {
|
type markerEntry struct {
|
||||||
id string
|
id string
|
||||||
marker Marker
|
marker Marker
|
||||||
}
|
}
|
||||||
|
|
||||||
// popupEntry pairs a popup ID with its definition for pre-render accumulation.
|
|
||||||
type popupEntry struct {
|
type popupEntry struct {
|
||||||
id string
|
id string
|
||||||
popup Popup
|
popup Popup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eventEntry struct {
|
||||||
|
event string
|
||||||
|
layerID string
|
||||||
|
signal *via.Signal
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlEntry struct {
|
||||||
|
id string
|
||||||
|
ctrl Control
|
||||||
|
}
|
||||||
|
|||||||
26
signal.go
26
signal.go
@@ -9,27 +9,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Signal represents a value that is reactive in the browser. Signals
|
// 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
|
// Use Bind() to connect a signal to an input and Text() to display it
|
||||||
// reactively on an html element.
|
// reactively on an html element.
|
||||||
type signal struct {
|
type Signal struct {
|
||||||
id string
|
id string
|
||||||
val any
|
val any
|
||||||
changed bool
|
changed bool
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ID returns the signal ID
|
// ID returns the signal ID.
|
||||||
func (s *signal) ID() string {
|
func (s *Signal) ID() string {
|
||||||
return s.id
|
return s.id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Err returns a signal error or nil if it contains no error.
|
// Err returns a signal error or nil if it contains no error.
|
||||||
//
|
//
|
||||||
// It is useful to check for errors after updating signals with
|
// It is useful to check for errors after updating signals with
|
||||||
// dinamic values.
|
// dynamic values.
|
||||||
func (s *signal) Err() error {
|
func (s *Signal) Err() error {
|
||||||
return s.err
|
return s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ func (s *signal) Err() error {
|
|||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// h.Input(h.Type("number"), mysignal.Bind())
|
// 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)
|
return h.Data("bind", s.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,33 +48,33 @@ func (s *signal) Bind() h.H {
|
|||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// h.Div(mysignal.Text())
|
// h.Div(mysignal.Text())
|
||||||
func (s *signal) Text() h.H {
|
func (s *Signal) Text() h.H {
|
||||||
return h.Span(h.Data("text", "$"+s.id))
|
return h.Span(h.Data("text", "$"+s.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetValue updates the signal’s value and marks it for synchronization with the browser.
|
// 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().
|
// 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.val = v
|
||||||
s.changed = true
|
s.changed = true
|
||||||
s.err = nil
|
s.err = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// String return the signal value as a string.
|
// String returns the signal value as a string.
|
||||||
func (s *signal) String() string {
|
func (s *Signal) String() string {
|
||||||
return fmt.Sprintf("%v", s.val)
|
return fmt.Sprintf("%v", s.val)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bool tries to read the signal value as a bool.
|
// Bool tries to read the signal value as a bool.
|
||||||
// Returns the value or false on failure.
|
// Returns the value or false on failure.
|
||||||
func (s *signal) Bool() bool {
|
func (s *Signal) Bool() bool {
|
||||||
val := strings.ToLower(s.String())
|
val := strings.ToLower(s.String())
|
||||||
return val == "true" || val == "1" || val == "yes" || val == "on"
|
return val == "true" || val == "1" || val == "yes" || val == "on"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Int tries to read the signal value as an int.
|
// Int tries to read the signal value as an int.
|
||||||
// Returns the value or 0 on failure.
|
// 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 {
|
if n, err := strconv.Atoi(s.String()); err == nil {
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestSignalReturnAsString(t *testing.T) {
|
|||||||
for _, testcase := range testcases {
|
for _, testcase := range testcases {
|
||||||
t.Run(testcase.desc, func(t *testing.T) {
|
t.Run(testcase.desc, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
var sig *signal
|
var sig *Signal
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
sig = c.Signal(testcase.given)
|
sig = c.Signal(testcase.given)
|
||||||
@@ -57,7 +57,7 @@ func TestSignalReturnAsStringComplexTypes(t *testing.T) {
|
|||||||
for _, testcase := range testcases {
|
for _, testcase := range testcases {
|
||||||
t.Run(testcase.desc, func(t *testing.T) {
|
t.Run(testcase.desc, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
var sig *signal
|
var sig *Signal
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
c.View(func() h.H { return nil })
|
c.View(func() h.H { return nil })
|
||||||
|
|||||||
10
via_test.go
10
via_test.go
@@ -90,7 +90,7 @@ func TestCustomDatastarPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSignal(t *testing.T) {
|
func TestSignal(t *testing.T) {
|
||||||
var sig *signal
|
var sig *Signal
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
sig = c.Signal("test")
|
sig = c.Signal("test")
|
||||||
@@ -106,7 +106,7 @@ func TestSignal(t *testing.T) {
|
|||||||
|
|
||||||
func TestAction(t *testing.T) {
|
func TestAction(t *testing.T) {
|
||||||
var trigger *actionTrigger
|
var trigger *actionTrigger
|
||||||
var sig *signal
|
var sig *Signal
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
trigger = c.Action(func() {})
|
trigger = c.Action(func() {})
|
||||||
@@ -167,7 +167,7 @@ func TestEventTypes(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("WithSignal", func(t *testing.T) {
|
t.Run("WithSignal", func(t *testing.T) {
|
||||||
var trigger *actionTrigger
|
var trigger *actionTrigger
|
||||||
var sig *signal
|
var sig *Signal
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
trigger = c.Action(func() {})
|
trigger = c.Action(func() {})
|
||||||
@@ -207,7 +207,7 @@ func TestOnKeyDownWithWindow(t *testing.T) {
|
|||||||
func TestOnKeyDownMap(t *testing.T) {
|
func TestOnKeyDownMap(t *testing.T) {
|
||||||
t.Run("multiple bindings with different actions", func(t *testing.T) {
|
t.Run("multiple bindings with different actions", func(t *testing.T) {
|
||||||
var move, shoot *actionTrigger
|
var move, shoot *actionTrigger
|
||||||
var dir *signal
|
var dir *Signal
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
dir = c.Signal("none")
|
dir = c.Signal("none")
|
||||||
@@ -251,7 +251,7 @@ func TestOnKeyDownMap(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("WithSignal per binding", func(t *testing.T) {
|
t.Run("WithSignal per binding", func(t *testing.T) {
|
||||||
var move *actionTrigger
|
var move *actionTrigger
|
||||||
var dir *signal
|
var dir *Signal
|
||||||
v := New()
|
v := New()
|
||||||
v.Page("/", func(c *Context) {
|
v.Page("/", func(c *Context) {
|
||||||
dir = c.Signal("none")
|
dir = c.Signal("none")
|
||||||
|
|||||||
Reference in New Issue
Block a user