//go:build !(js && wasm) package app import ( "bytes" "strconv" "time" chart "github.com/wcharczuk/go-chart/v2" . "kjol/vdom" ui "kjol/webui" ) // ServerCounter is a SERVER component — note it's written exactly like a client // component (same builders, signals, On handlers). The //gowasm:server directive // makes the build generate a client stub so calling ServerCounter() on the // frontend is identical to calling any component; the state and this render run // on the server (its chart is computed there with go-chart), and clicks // round-trip over /rsc. // // The chart plots the counter value against the wall-clock time of each click // (milliseconds since the first click), so spacing clicks out spreads the data // points along the x-axis. Because the component is stateless on the server, the // click points live in a signal that round-trips with the rest of its state // (a plain slice would reset on every request). // //gowasm:server func ServerCounter() func() *VNode { count := NewSignal(0) points := NewSignal([]clickPoint{}) bump := func(delta int) { count.Set(count.Get() + delta) points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()})) } // No card of its own: the component draws bare content and lets the caller frame it. // The docs page already puts it in a demo panel, and a card inside a card gives you // two borders and two shadows around the same thing. return func() *VNode { return Div( Div(Attr("class", "flex items-center gap-2 mb-3"), Span(Attr("class", "text-ink-soft"), Text("Server counter: ")), Strong(Attr("class", "badge inline-flex items-center rounded-full bg-green-700 px-2.5 py-0.5 text-sm font-semibold text-white"), Text(strconv.Itoa(count.Get()))), Div(Attr("class", "ml-auto flex gap-1"), ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { bump(-1) }}), ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "+", OnClick: func() { bump(1) }}), ), ), Div(Attr("class", "rounded-default border border-line bg-surface p-2 overflow-auto"), Raw(clickChartSVG(points.Get()))), ) } } // clickPoint records one click: its wall-clock time and the resulting counter // value. Exported fields so the signal's JSON snapshot round-trips it. type clickPoint struct { T int64 // click time, Unix milliseconds V int // counter value after the click } // clickChartSVG plots counter value vs. time-of-click (ms since the first // click) as a line graph. Explicit axis ranges keep it valid for the tricky // cases (a single click, or several clicks within the same millisecond). func clickChartSVG(points []clickPoint) string { if len(points) == 0 { return `Click + / − to plot the counter over time (ms since the first click).` } t0 := points[0].T xs := make([]float64, len(points)) ys := make([]float64, len(points)) minY, maxY := 0.0, 0.0 // keep the zero baseline in view for context for i, p := range points { xs[i] = float64(p.T - t0) ys[i] = float64(p.V) if ys[i] < minY { minY = ys[i] } if ys[i] > maxY { maxY = ys[i] } } maxX := xs[len(xs)-1] if maxX <= 0 { maxX = 1 // rapid or single clicks: avoid a zero-width x-range } if minY == maxY { maxY++ // avoid a zero-height y-range } graph := chart.Chart{ Title: "Counter over time (computed on the server)", TitleStyle: chart.Style{FontSize: 14}, Background: chart.Style{Padding: chart.Box{Top: 48, Left: 20, Right: 20, Bottom: 40}}, Height: 260, XAxis: chart.XAxis{ Name: "ms since first click", Range: &chart.ContinuousRange{Min: 0, Max: maxX}, }, YAxis: chart.YAxis{ Name: "counter", Range: &chart.ContinuousRange{Min: minY, Max: maxY}, }, Series: []chart.Series{ chart.ContinuousSeries{ XValues: xs, YValues: ys, Style: chart.Style{ StrokeColor: chart.ColorGreen, StrokeWidth: 2, DotColor: chart.ColorGreen, DotWidth: 4, // a dot at each click }, }, }, } var buf bytes.Buffer if graph.Render(chart.SVG, &buf) != nil { return `chart error` } return buf.String() }