feat: add real-time chart example

This commit is contained in:
Joao Goncalves
2025-11-05 17:29:29 -01:00
parent 57b22de0e4
commit c167f0c74f
8 changed files with 302 additions and 65 deletions

30
configuration.go Normal file
View File

@@ -0,0 +1,30 @@
package via
import "github.com/go-via/via/h"
type LogLevel int
const (
LogLevelError LogLevel = iota
LogLevelWarn
LogLevelInfo
LogLevelDebug
)
// Config defines configuration options for the via application
type Configuration struct {
// Level of the logs to write to stdout.
// Options: Error, Warn, Info, Debug.
LogLvl LogLevel
// The title of the HTML document.
DocumentTitle string
// Elements to include in the head of the base HTML document.
// Useful for including css stylesheets and JS scripts.
DocumentHeadIncludes []h.H
// Elements to include in the bottom of the body of the base
// HTML document.Useful for including JS scripts or a footer.
DocumentBodyIncludes []h.H
}

View File

@@ -40,44 +40,23 @@ func (c *Context) View(f func() h.H) {
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
}
// Component registers a sub context that has self contained data, actions and signals.
// Component registers a subcontext that has self contained data, actions and signals.
// It returns the component's view as a DOM node fn that can be placed in the view
// of the parent. Components can be added to components.
//
// Example:
//
// counterComponent := func(c *via.Context) {
// count := 0
// step := c.Signal(1)
//
// increment := c.Action(func() {
// count += step.Int()
// c.Sync()
// })
//
// c.View(func() h.H {
// return h.Div(
// h.P(h.Textf("Count: %d", count)),
// h.P(h.Span(h.Text("Step: ")), h.Span(step.Text())),
// h.Label(
// h.Text("Update Step: "),
// h.Input(h.Type("number"), step.Bind()),
// ),
// h.Button(h.Text("Increment"), increment.OnClick()),
// )
// })
// })
// counterCompFn := func(c *via.Context) {
// (...)
// }
//
// v.Page("/", func(c *via.Context) {
// counter1 := c.Component(counterComponent)
// counter2 := c.Component(counterComponent)
// counterComp := c.Component(counterCompFn)
//
// c.View(func() h.H {
// return h.Div(
// h.H1(h.Text("Counter 1")),
// counter1(),
// h.H1(h.Text("Counter 2")),
// counter2(),
// h.H1(h.Text("Counter")),
// counterComp(),
// )
// })
// })
@@ -145,20 +124,24 @@ func (c *Context) Signals() map[string]*signal {
return c.signals
}
// Signal creates a reactive signal and initializes it with a value.
// Signal creates a reactive signal and initializes it with the given value.
// Use Bind() to link the value of input elements to the signal and Text() to
// display the signal value and watch the UI update live as the input changes.
//
// Example:
//
// h.Div(
// mysignal := c.Signal("world")
//
// c.View(func() h.H {
// return h.Div(
// h.P(h.Span(h.Text("Hello, ")), h.Span(mysignal.Text())),
// h.Input(h.Value("World"), mysignal.Bind()),
// h.Input(mysignal.Bind()),
// )
// })
//
// Signals are 'alive' only in the browser, but Via always injects their values into
// 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().
func (c *Context) Signal(v any) *signal {
sigID := genRandID()
@@ -214,7 +197,8 @@ func (c *Context) Sync() {
sse = c.sse
}
if sse == nil {
c.app.logErr(c, "sync view failed: inactive SSE stream")
c.app.logWarn(c, "view out of sync: no sse stream")
return
}
elemsPatch := bytes.NewBuffer(make([]byte, 0))
if err := c.view().Render(elemsPatch); err != nil {
@@ -261,7 +245,8 @@ func (c *Context) SyncElements(elem h.H) {
sse = c.sse
}
if sse == nil {
c.app.logErr(c, "sync element failed: no sse connection")
c.app.logWarn(c, "elements out of sync: no sse stream")
return
}
if c.view == nil {
c.app.logErr(c, "sync element failed: viewfn is nil")
@@ -286,7 +271,8 @@ func (c *Context) SyncSignals() {
sse = c.sse
}
if sse == nil {
c.app.logErr(c, "sync signals failed: sse connection not found")
c.app.logWarn(c, "signals out of sync: no sse stream")
return
}
updatedSigs := make(map[string]any)
for id, sig := range c.signals {
@@ -302,6 +288,20 @@ func (c *Context) SyncSignals() {
}
}
func (c *Context) ExecScript(s string) {
var sse *datastar.ServerSentEventGenerator
if c.isComponent() {
sse = c.parentPageCtx.sse
} else {
sse = c.sse
}
if sse == nil {
c.app.logWarn(c, "script out of sync: no sse stream")
return
}
_ = sse.ExecuteScript(s)
}
func newContext(id string, a *via) *Context {
if a == nil {
log.Fatalf("create context failed: app pointer is nil")

View File

@@ -26,6 +26,14 @@ func Placeholder(v string) H {
return gh.Placeholder(v)
}
func Rel(v string) H {
return gh.Rel(v)
}
func Class(v string) H {
return gh.Class(v)
}
// Data attributes automatically have their name prefixed with "data-".
func Data(name, v string) H {
return gh.Data(name, v)

View File

@@ -392,6 +392,10 @@ func Var(children ...H) H {
return gh.Var(retype(children)...)
}
func StyleEl(children ...H) H {
return gh.StyleEl(retype(children)...)
}
func Video(children ...H) H {
return gh.Video(retype(children)...)
}

6
h/h.go
View File

@@ -35,6 +35,12 @@ func Raw(s string) H {
return g.Raw(s)
}
// Rawf creates a text DOM [Node] that just Renders the interpolated and
// unescaped string format.
func Rawf(format string, a ...any) H {
return g.Rawf(format, a...)
}
// Attr creates an attribute DOM [Node] with a name and optional value.
// If only a name is passed, it's a name-only (boolean) attribute (like "required").
// If a name and value are passed, it's a name-value attribute (like `class="header"`).

View File

@@ -0,0 +1,35 @@
package main
import (
"github.com/go-via/via"
"github.com/go-via/via/h"
)
func main() {
v := via.New()
v.Config(via.Configuration{
DocumentTitle: "Via",
DocumentHeadIncludes: []h.H{
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
},
})
v.Page("/", func(c *via.Context) {
c.View(func() h.H {
return h.Div(
h.H1(h.Text("Hello PicoCSS!")),
h.H2(h.Text("Hello PicoCSS!")),
h.H3(h.Text("Hello PicoCSS!")),
h.H4(h.Text("Hello PicoCSS!")),
h.H5(h.Text("Hello PicoCSS!")),
h.H6(h.Text("Hello PicoCSS!")),
h.Div(h.Class("grid"),
h.Button(h.Text("Primary")),
h.Button(h.Class("secondary"), h.Text("Secondary")),
),
)
})
})
v.Start(":3000")
}

View File

@@ -0,0 +1,136 @@
package main
import (
"encoding/json"
"fmt"
"math/rand"
"time"
"github.com/go-via/via"
"github.com/go-via/via/h"
)
func main() {
v := via.New()
v.Config(via.Configuration{
DocumentTitle: "Via",
DocumentHeadIncludes: []h.H{
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
h.Script(h.Src("https://unpkg.com/echarts@6.0.0/dist/echarts.min.js")),
},
})
v.Page("/", func(c *via.Context) {
chartComp := c.Component(chartCompFn)
c.View(func() h.H {
return h.Div(h.Class("container"),
chartComp(),
)
})
})
v.Start(":3000")
}
func chartCompFn(c *via.Context) {
data := make([]float64, 1000)
labels := make([]string, 1000)
go func() {
tkr := time.NewTicker(60 * time.Millisecond)
defer tkr.Stop()
for range tkr.C {
labels = append(labels[1:], time.Now().Format("15:04:05.000"))
data = append(data[1:], rand.Float64()*1000)
labelsTxt, _ := json.Marshal(labels)
dataTxt, _ := json.Marshal(data)
c.ExecScript(fmt.Sprintf(`
if (myChart)
myChart.setOption({
xAxis: [{data: %s}],
series:[{data: %s}]
});
`, labelsTxt, dataTxt))
}
}()
c.View(func() h.H {
return h.Div(h.ID("chart"), h.Style("width:100%;height:400px;"),
h.Script(h.Raw(`
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
var myChart = echarts.init(document.getElementById('chart'), prefersDark.matches ? 'dark' : 'light');
var option = {
backgroundColor: prefersDark.matches ? 'transparent' : '#ffffff',
animationDurationUpdate: 60, // affects updates/redraws
tooltip: {
trigger: 'axis',
position: function (pt) {
return [pt[0], '10%'];
}
},
title: {
left: 'center',
text: 'Large Area Chart'
},
toolbox: {
feature: {
dataZoom: {
yAxisIndex: 'none'
},
restore: {},
saveAsImage: {}
}
},
xAxis: {
type: 'category',
boundaryGap: false,
data: []
},
yAxis: {
type: 'value',
boundaryGap: [0, '100%']
},
dataZoom: [
{
type: 'inside',
start: 80,
end: 100
},
{
start: 0,
end: 100
}
],
series: [
{
name: 'Fake Data',
type: 'line',
symbol: 'none',
sampling: 'lttb',
itemStyle: {
color: '#1488CC'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: '#1488CC'
},
{
offset: 1,
color: '#2B32B2'
}
])
},
data: []
}
]
};
option && myChart.setOption(option);
`)),
)
})
}

74
via.go
View File

@@ -12,6 +12,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"sync"
"github.com/go-via/via/h"
@@ -21,23 +22,10 @@ import (
//go:embed datastar.js
var datastarJS []byte
type config struct {
logLvl LogLevel
}
type LogLevel int
const (
LogLevelError LogLevel = iota
LogLevelWarn
LogLevelInfo
LogLevelDebug
)
// via is the root application.
// It manages page routing, user sessions, and SSE connections for live updates.
type via struct {
cfg config
cfg Configuration
mux *http.ServeMux
contextRegistry map[string]*Context
contextRegistryMutex sync.RWMutex
@@ -57,7 +45,7 @@ func (v *via) logWarn(c *Context, format string, a ...any) {
if c != nil && c.id != "" {
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
}
if v.cfg.logLvl <= LogLevelWarn {
if v.cfg.LogLvl <= LogLevelWarn {
log.Printf("[warn] %smsg=%q", cRef, fmt.Sprintf(format, a...))
}
}
@@ -67,7 +55,7 @@ func (v *via) logInfo(c *Context, format string, a ...any) {
if c != nil && c.id != "" {
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
}
if v.cfg.logLvl >= LogLevelInfo {
if v.cfg.LogLvl <= LogLevelInfo {
log.Printf("[info] %smsg=%q", cRef, fmt.Sprintf(format, a...))
}
}
@@ -77,11 +65,24 @@ func (v *via) logDebug(c *Context, format string, a ...any) {
if c != nil && c.id != "" {
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
}
if v.cfg.logLvl == LogLevelDebug {
if v.cfg.LogLvl == LogLevelDebug {
log.Printf("[debug] %smsg=%q", cRef, fmt.Sprintf(format, a...))
}
}
// Config overrides the default configuration with the given configuration options.
func (v *via) Config(cfg Configuration) {
if cfg.LogLvl != v.cfg.LogLvl {
v.cfg.LogLvl = cfg.LogLvl
}
if cfg.DocumentHeadIncludes != nil {
v.cfg.DocumentHeadIncludes = cfg.DocumentHeadIncludes
}
if cfg.DocumentBodyIncludes != nil {
v.cfg.DocumentBodyIncludes = cfg.DocumentBodyIncludes
}
}
// Page registers a route and its associated page handler.
// The handler receives a *Context to define UI, signals, and actions.
//
@@ -94,17 +95,25 @@ func (v *via) logDebug(c *Context, format string, a ...any) {
// })
func (v *via) Page(route string, composeContext func(c *Context)) {
v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "favicon") {
return
}
id := fmt.Sprintf("%s_/%s", route, genRandID())
c := newContext(id, v)
v.logDebug(c, "GET %s", route)
composeContext(c)
v.registerCtx(c.id, c)
view := v.baseLayout(h.HTML5Props{
Head: []h.H{
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))),
h.Meta(h.Data("init", "@get('/_sse')")),
},
Body: []h.H{h.Div(h.ID(c.id))},
headElements := v.cfg.DocumentHeadIncludes
headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))))
headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')")))
bottomBodyElements := []h.H{h.Div(h.ID(c.id), c.view())}
for _, el := range v.cfg.DocumentBodyIncludes {
bottomBodyElements = append(bottomBodyElements, el)
}
view := h.HTML5(h.HTML5Props{
Title: v.cfg.DocumentTitle,
Head: headElements,
Body: bottomBodyElements,
})
_ = view.Render(w)
}))
@@ -114,6 +123,7 @@ func (v *via) registerCtx(id string, c *Context) {
v.contextRegistryMutex.Lock()
defer v.contextRegistryMutex.Unlock()
v.contextRegistry[id] = c
v.logDebug(c, "new context added to registry")
}
// func (a *App) unregisterCtx(id string) {
@@ -131,6 +141,12 @@ func (v *via) getCtx(id string) (*Context, error) {
return nil, fmt.Errorf("ctx '%s' not found", id)
}
// HandleFunc registers the HTTP handler function for a given pattern. The handler function panics if
// in conflict with another registered handler with the same pattern.
func (v *via) HandleFunc(pattern string, f http.HandlerFunc) {
v.mux.HandleFunc(pattern, f)
}
// Start starts the Via HTTP server on the given address.
func (v *via) Start(addr string) {
v.logInfo(nil, "via started")
@@ -143,12 +159,14 @@ func New() *via {
app := &via{
mux: mux,
contextRegistry: make(map[string]*Context),
cfg: config{logLvl: LogLevelDebug},
baseLayout: h.HTML5,
cfg: Configuration{
LogLvl: LogLevelDebug,
DocumentTitle: "Via Application",
DocumentHeadIncludes: make([]h.H, 0),
DocumentBodyIncludes: make([]h.H, 0),
},
}
app.mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {})
app.mux.HandleFunc("GET /_datastar.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write(datastarJS)
@@ -165,7 +183,7 @@ func New() *via {
}
c.sse = datastar.NewSSE(w, r)
app.logDebug(c, "SSE connection established")
c.Sync()
c.SyncSignals()
<-c.sse.Context().Done()
c.sse = nil
app.logDebug(c, "SSE connection closed")