From c167f0c74fdb207800d7d60c6fab3ab846936479 Mon Sep 17 00:00:00 2001 From: Joao Goncalves Date: Wed, 5 Nov 2025 17:29:29 -0100 Subject: [PATCH] feat: add real-time chart example --- configuration.go | 30 ++++++ context.go | 74 ++++++------- h/attributes.go | 8 ++ h/elements.go | 4 + h/h.go | 6 ++ internal/examples/picocss/main.go | 35 ++++++ internal/examples/realtimechart/main.go | 136 ++++++++++++++++++++++++ via.go | 74 ++++++++----- 8 files changed, 302 insertions(+), 65 deletions(-) create mode 100644 configuration.go create mode 100644 internal/examples/picocss/main.go create mode 100644 internal/examples/realtimechart/main.go diff --git a/configuration.go b/configuration.go new file mode 100644 index 0000000..1e62b7b --- /dev/null +++ b/configuration.go @@ -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 +} diff --git a/context.go b/context.go index 05516a2..a13b6ee 100644 --- a/context.go +++ b/context.go @@ -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( -// h.P(h.Span(h.Text("Hello, ")), h.Span(mysignal.Text())), -// h.Input(h.Value("World"), mysignal.Bind()), -// ) +// 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(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") diff --git a/h/attributes.go b/h/attributes.go index bc2b8dd..eed7b84 100644 --- a/h/attributes.go +++ b/h/attributes.go @@ -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) diff --git a/h/elements.go b/h/elements.go index 06f9bfd..03acfa9 100644 --- a/h/elements.go +++ b/h/elements.go @@ -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)...) } diff --git a/h/h.go b/h/h.go index 1190a48..4ce471b 100644 --- a/h/h.go +++ b/h/h.go @@ -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"`). diff --git a/internal/examples/picocss/main.go b/internal/examples/picocss/main.go new file mode 100644 index 0000000..eacf106 --- /dev/null +++ b/internal/examples/picocss/main.go @@ -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") +} diff --git a/internal/examples/realtimechart/main.go b/internal/examples/realtimechart/main.go new file mode 100644 index 0000000..41df6c4 --- /dev/null +++ b/internal/examples/realtimechart/main.go @@ -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); + `)), + ) + }) +} diff --git a/via.go b/via.go index 2c16eb3..7fe1b44 100644 --- a/via.go +++ b/via.go @@ -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")