Files
kjol/go/cmd/kjol-website/app/server_counter.go

121 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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 `<span class="text-muted">Click + / to plot the counter over time (ms since the first click).</span>`
}
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 `<span class="text-danger">chart error</span>`
}
return buf.String()
}