update heatmap, add wasm charts
This commit is contained in:
@@ -1,119 +1,39 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"math/rand"
|
||||
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
var chartPageDays = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
|
||||
// fixed initial data so the server SSR and the client's first render match.
|
||||
func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} }
|
||||
|
||||
func randomValues() []int {
|
||||
v := make([]int, len(chartLabels))
|
||||
for i := range v {
|
||||
v[i] = rand.Intn(95) + 5
|
||||
// chartDemoData is a week of values; a non-zero seed reshuffles them deterministically, so
|
||||
// "Shuffle" changes the chart without a data source and without a random that would differ
|
||||
// between the server's render and the client's first one.
|
||||
func chartDemoData(seed int) []float64 {
|
||||
base := []float64{42, 17, 63, 28, 55, 9, 71}
|
||||
if seed == 0 {
|
||||
return base
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func renderSVG(c interface {
|
||||
Render(chart.RendererProvider, io.Writer) error
|
||||
}) string {
|
||||
var buf bytes.Buffer
|
||||
if c.Render(chart.SVG, &buf) != nil {
|
||||
return "<p class=\"text-danger m-0\">chart error</p>"
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func barSVG(values []int) string {
|
||||
bars := make([]chart.Value, len(values))
|
||||
for i, v := range values {
|
||||
bars[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
|
||||
}
|
||||
return renderSVG(&chart.BarChart{
|
||||
Title: "Weekly values (bar)",
|
||||
TitleStyle: chart.Style{FontSize: 15},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 16, Right: 16, Bottom: 16}},
|
||||
Height: 320, BarWidth: 48, Bars: bars,
|
||||
})
|
||||
}
|
||||
|
||||
func pieSVG(values []int) string {
|
||||
vs := make([]chart.Value, len(values))
|
||||
for i, v := range values {
|
||||
vs[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
|
||||
}
|
||||
return renderSVG(&chart.PieChart{
|
||||
Title: "Share by day (pie)",
|
||||
TitleStyle: chart.Style{FontSize: 15},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48}},
|
||||
Width: 320, Height: 320, Values: vs,
|
||||
})
|
||||
}
|
||||
|
||||
// chartSkeleton is what the SERVER puts where a chart is going to be: a box of the right
|
||||
// height, so nothing jumps when the real one arrives.
|
||||
func chartSkeleton(height string) *VNode {
|
||||
return Div(Attr("class", "flex animate-pulse items-center justify-center rounded-default bg-surface-muted "+height),
|
||||
Span(Attr("class", "text-xs text-ink-faint"), Text("drawing…")),
|
||||
)
|
||||
}
|
||||
|
||||
// newChartDrawing returns a signal that is FALSE on the server and on the client's first
|
||||
// render, and true from the moment the WebAssembly has committed that first render.
|
||||
//
|
||||
// It is what keeps the charts CLIENT-DRAWN. go-chart is ordinary Go and would run just as
|
||||
// happily on the server — it used to, and this page's markup carried two finished SVGs.
|
||||
// Two reasons not to:
|
||||
//
|
||||
// - It is work the server does on every single request for a picture that only matters
|
||||
// once the page is alive. Drawing it in the browser costs the server nothing and the
|
||||
// reader nothing they can see.
|
||||
// - It is the more honest demonstration. A Go charting library, compiled to WebAssembly,
|
||||
// drawing an SVG in the browser is the thing this layer claims it can do. Shipping a
|
||||
// server-rendered picture of one proves the opposite point.
|
||||
//
|
||||
// The false-on-first-render part is not optional: hydration walks the server's DOM
|
||||
// alongside the client's first tree, so that tree has to be the SAME tree. Draw the charts
|
||||
// on the client's first pass and the two disagree, and the reconciler has to rebuild what
|
||||
// it should have adopted.
|
||||
func newChartDrawing() *Signal[bool] {
|
||||
drawn := NewSignal(false)
|
||||
wasmruntime.AfterRender(func() {
|
||||
if !drawn.Get() {
|
||||
drawn.Set(true) // a write re-renders; the second pass draws for real
|
||||
out := make([]float64, len(base))
|
||||
for i, b := range base {
|
||||
m := (int(b)*7 + seed*13) % 80
|
||||
if m < 4 {
|
||||
m = 4
|
||||
}
|
||||
})
|
||||
return drawn
|
||||
}
|
||||
|
||||
// chartBox renders one chart, or the placeholder standing in for it. draw is a closure so
|
||||
// that on the server go-chart is never called at all — not called and discarded, but never
|
||||
// entered.
|
||||
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
|
||||
if !drawn {
|
||||
return Div(Attr("class", class), chartSkeleton(height))
|
||||
out[i] = float64(m)
|
||||
}
|
||||
return Div(Attr("class", class), Raw(draw()))
|
||||
return out
|
||||
}
|
||||
|
||||
//gowasm:page /wasm/chart static layout=app
|
||||
func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
drawn := newChartDrawing()
|
||||
seed := NewSignal(0)
|
||||
barChart := ui.NewChart()
|
||||
areaChart := ui.NewChart()
|
||||
|
||||
return func() *VNode {
|
||||
values := data.Get()
|
||||
values := chartDemoData(seed.Get())
|
||||
|
||||
return docPage("Rendering", "SSR & hydration",
|
||||
"A static route is rendered to HTML by the server, so the page is complete before any "+
|
||||
@@ -132,34 +52,27 @@ func ChartPage(d Deps) func() *VNode {
|
||||
"class onto the page forever."),
|
||||
),
|
||||
|
||||
docSection("charts", "A worked example: charts",
|
||||
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing "+
|
||||
"about the browser. They are drawn by the WEBASSEMBLY, in your browser, and never by the "+
|
||||
"server: what the server sends is the two placeholders you may have seen for a moment, "+
|
||||
"and the WebAssembly replaces them on its first commit."),
|
||||
prose("That is the demonstration. A Go charting library, compiled to wasm, drawing an SVG in "+
|
||||
"the browser is exactly what this layer claims it can do — and a server-rendered picture "+
|
||||
"of a chart would prove the opposite point while looking identical. Shuffle redraws them, "+
|
||||
"and no request is made."),
|
||||
prose("The rest of the page IS server-rendered — the headings, the prose, the code you are "+
|
||||
"reading. Static and client-drawn are not opposites: a route can be pre-rendered and still "+
|
||||
"leave the expensive, browser-only parts of itself for the client."),
|
||||
docSection("charts", "A worked example: a chart",
|
||||
prose("The two charts below are webui.Chart. Their SVG — axes, rounded columns, the smoothed "+
|
||||
"area — is drawn on the SERVER and shipped in the page's HTML: view source and the marks "+
|
||||
"are already there, complete before any WebAssembly runs. That is what static buys."),
|
||||
prose("What the server cannot send is the interaction. The hover tooltip and the resize-to-fit "+
|
||||
"come alive when the WebAssembly hydrates the page — it adopts the SVG already on screen and "+
|
||||
"wires the pointer handlers to it, redrawing nothing. Server-rendered picture, client-side "+
|
||||
"behaviour, one component. Shuffle re-renders it in the browser, and no request is made."),
|
||||
|
||||
Div(Attr("class", "mt-4"),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
|
||||
OnClick: func() { data.Set(randomValues()) }}),
|
||||
OnClick: func() { seed.Set(seed.Get() + 1) }}),
|
||||
),
|
||||
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-12"),
|
||||
chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
|
||||
"h-[260px]", drawn.Get(), func() string { return barSVG(values) }),
|
||||
chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
|
||||
"h-[320px]", drawn.Get(), func() string { return pieSVG(values) }),
|
||||
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-2"),
|
||||
Div(Attr("class", "rounded-default border border-line bg-surface p-3 shadow-xs"),
|
||||
barChart.Render(ui.ChartProps{Kind: ui.ChartBar, Labels: chartPageDays,
|
||||
Series: []ui.ChartSeries{{Name: "Requests", Data: values}}, Height: 240})),
|
||||
Div(Attr("class", "rounded-default border border-line bg-surface p-3 shadow-xs"),
|
||||
areaChart.Render(ui.ChartProps{Kind: ui.ChartArea, Smooth: true, Labels: chartPageDays,
|
||||
Series: []ui.ChartSeries{{Name: "Requests", Data: values}}, Height: 240})),
|
||||
),
|
||||
|
||||
note("go-chart lives in the EXAMPLE, not in kjol",
|
||||
"The engine is standard-library-only. This example is its own Go module precisely so a "+
|
||||
"charting dependency it happens to want does not become a dependency of everyone who "+
|
||||
"uses the framework."),
|
||||
),
|
||||
|
||||
docSection("api", "Reference",
|
||||
@@ -167,7 +80,7 @@ func ChartPage(d Deps) func() *VNode {
|
||||
apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
|
||||
apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
|
||||
apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
|
||||
apiRow{"vdom.Raw", "Insert markup verbatim — how the SVG the WebAssembly drew gets in. The reconciler clears it correctly when the element is reused."},
|
||||
apiRow{"vdom.Raw", "Insert markup verbatim — how webui.Chart's SVG string enters the tree. The reconciler clears it correctly when the element is reused."},
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -176,42 +89,22 @@ func ChartPage(d Deps) func() *VNode {
|
||||
|
||||
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
|
||||
func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
drawn := NewSignal(false) // false on the server AND on the first client render
|
||||
|
||||
// AfterRender is the post-commit hook. It fires once the WebAssembly has put its
|
||||
// first tree on the page — the earliest moment at which drawing is a client act.
|
||||
wasmruntime.AfterRender(func() {
|
||||
if !drawn.Get() {
|
||||
drawn.Set(true) // a write re-renders; the second pass draws
|
||||
}
|
||||
})
|
||||
seed := NewSignal(0)
|
||||
chart := ui.NewChart() // a controller: holds hover + refs across renders
|
||||
|
||||
return func() *VNode {
|
||||
return Div(
|
||||
ui.Button(ui.ButtonProps{
|
||||
Text: "Shuffle data",
|
||||
OnClick: func() { data.Set(randomValues()) },
|
||||
return docPage("Rendering", "SSR & hydration",
|
||||
// The chart's SVG is rendered on the SERVER and shipped in the HTML.
|
||||
// On hydration the client adopts those nodes and wires the pointer
|
||||
// handlers — the hover tooltip comes alive without redrawing anything.
|
||||
chart.Render(ui.ChartProps{
|
||||
Kind: ui.ChartBar,
|
||||
Labels: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"},
|
||||
Series: []ui.ChartSeries{{Name: "Requests", Data: chartDemoData(seed.Get())}},
|
||||
}),
|
||||
|
||||
// The server never enters barSVG: chartBox takes a CLOSURE, and calls it only
|
||||
// once drawn is true. It renders the placeholder instead, and the WebAssembly
|
||||
// swaps in the real chart on its first commit.
|
||||
//
|
||||
// drawn must be FALSE on the client's first render too. Hydration walks the
|
||||
// server's DOM alongside the client's first tree, so the two have to BE the
|
||||
// same tree; draw on that first pass and the reconciler rebuilds what it
|
||||
// should have adopted.
|
||||
chartBox("", "h-[260px]", drawn.Get(),
|
||||
func() string { return barSVG(data.Get()) }),
|
||||
ui.Button(ui.ButtonProps{Text: "Shuffle", OnClick: func() {
|
||||
seed.Set(seed.Get() + 1) // a signal write re-renders, in the browser
|
||||
}}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// chartBox is the whole trick, and it is four lines.
|
||||
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
|
||||
if !drawn {
|
||||
return Div(Attr("class", class), chartSkeleton(height))
|
||||
}
|
||||
return Div(Attr("class", class), Raw(draw()))
|
||||
}`
|
||||
|
||||
Reference in New Issue
Block a user