update heatmap, add wasm charts
This commit is contained in:
@@ -1,119 +1,39 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"math/rand"
|
|
||||||
|
|
||||||
chart "github.com/wcharczuk/go-chart/v2"
|
|
||||||
|
|
||||||
. "kjol/vdom"
|
. "kjol/vdom"
|
||||||
"kjol/wasmruntime"
|
|
||||||
ui "kjol/webui"
|
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.
|
// chartDemoData is a week of values; a non-zero seed reshuffles them deterministically, so
|
||||||
func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} }
|
// "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 randomValues() []int {
|
func chartDemoData(seed int) []float64 {
|
||||||
v := make([]int, len(chartLabels))
|
base := []float64{42, 17, 63, 28, 55, 9, 71}
|
||||||
for i := range v {
|
if seed == 0 {
|
||||||
v[i] = rand.Intn(95) + 5
|
return base
|
||||||
}
|
}
|
||||||
return v
|
out := make([]float64, len(base))
|
||||||
}
|
for i, b := range base {
|
||||||
|
m := (int(b)*7 + seed*13) % 80
|
||||||
func renderSVG(c interface {
|
if m < 4 {
|
||||||
Render(chart.RendererProvider, io.Writer) error
|
m = 4
|
||||||
}) 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()
|
out[i] = float64(m)
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
return out
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
return Div(Attr("class", class), Raw(draw()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//gowasm:page /wasm/chart static layout=app
|
//gowasm:page /wasm/chart static layout=app
|
||||||
func ChartPage(d Deps) func() *VNode {
|
func ChartPage(d Deps) func() *VNode {
|
||||||
data := NewSignal(fixedChartData())
|
seed := NewSignal(0)
|
||||||
drawn := newChartDrawing()
|
barChart := ui.NewChart()
|
||||||
|
areaChart := ui.NewChart()
|
||||||
|
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
values := data.Get()
|
values := chartDemoData(seed.Get())
|
||||||
|
|
||||||
return docPage("Rendering", "SSR & hydration",
|
return docPage("Rendering", "SSR & hydration",
|
||||||
"A static route is rendered to HTML by the server, so the page is complete before any "+
|
"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."),
|
"class onto the page forever."),
|
||||||
),
|
),
|
||||||
|
|
||||||
docSection("charts", "A worked example: charts",
|
docSection("charts", "A worked example: a chart",
|
||||||
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing "+
|
prose("The two charts below are webui.Chart. Their SVG — axes, rounded columns, the smoothed "+
|
||||||
"about the browser. They are drawn by the WEBASSEMBLY, in your browser, and never by the "+
|
"area — is drawn on the SERVER and shipped in the page's HTML: view source and the marks "+
|
||||||
"server: what the server sends is the two placeholders you may have seen for a moment, "+
|
"are already there, complete before any WebAssembly runs. That is what static buys."),
|
||||||
"and the WebAssembly replaces them on its first commit."),
|
prose("What the server cannot send is the interaction. The hover tooltip and the resize-to-fit "+
|
||||||
prose("That is the demonstration. A Go charting library, compiled to wasm, drawing an SVG in "+
|
"come alive when the WebAssembly hydrates the page — it adopts the SVG already on screen and "+
|
||||||
"the browser is exactly what this layer claims it can do — and a server-rendered picture "+
|
"wires the pointer handlers to it, redrawing nothing. Server-rendered picture, client-side "+
|
||||||
"of a chart would prove the opposite point while looking identical. Shuffle redraws them, "+
|
"behaviour, one component. Shuffle re-renders it in the browser, and no request is made."),
|
||||||
"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."),
|
|
||||||
|
|
||||||
Div(Attr("class", "mt-4"),
|
Div(Attr("class", "mt-4"),
|
||||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
|
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"),
|
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-2"),
|
||||||
chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
|
Div(Attr("class", "rounded-default border border-line bg-surface p-3 shadow-xs"),
|
||||||
"h-[260px]", drawn.Get(), func() string { return barSVG(values) }),
|
barChart.Render(ui.ChartProps{Kind: ui.ChartBar, Labels: chartPageDays,
|
||||||
chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
|
Series: []ui.ChartSeries{{Name: "Requests", Data: values}}, Height: 240})),
|
||||||
"h-[320px]", drawn.Get(), func() string { return pieSVG(values) }),
|
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",
|
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{"//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{"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{"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
|
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
|
||||||
func ChartPage(d Deps) func() *VNode {
|
func ChartPage(d Deps) func() *VNode {
|
||||||
data := NewSignal(fixedChartData())
|
seed := NewSignal(0)
|
||||||
drawn := NewSignal(false) // false on the server AND on the first client render
|
chart := ui.NewChart() // a controller: holds hover + refs across renders
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
return Div(
|
return docPage("Rendering", "SSR & hydration",
|
||||||
ui.Button(ui.ButtonProps{
|
// The chart's SVG is rendered on the SERVER and shipped in the HTML.
|
||||||
Text: "Shuffle data",
|
// On hydration the client adopts those nodes and wires the pointer
|
||||||
OnClick: func() { data.Set(randomValues()) },
|
// 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())}},
|
||||||
}),
|
}),
|
||||||
|
ui.Button(ui.ButtonProps{Text: "Shuffle", OnClick: func() {
|
||||||
// The server never enters barSVG: chartBox takes a CLOSURE, and calls it only
|
seed.Set(seed.Get() + 1) // a signal write re-renders, in the browser
|
||||||
// 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()) }),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 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()))
|
|
||||||
}`
|
}`
|
||||||
|
|||||||
@@ -1157,43 +1157,99 @@ func searchSection() func() *VNode {
|
|||||||
|
|
||||||
// ---- charts --------------------------------------------------------------
|
// ---- charts --------------------------------------------------------------
|
||||||
|
|
||||||
|
var chartDaysWasm = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||||
|
var pieLabelsWasm = []string{"Direct", "Search", "Social", "Email", "Referral"}
|
||||||
|
var regionsWasm = []string{"East", "Central", "Mountain", "Pacific"}
|
||||||
|
|
||||||
|
// A made-up per-state metric for the choropleth, and a few cities (lat/lng) — Anchorage
|
||||||
|
// and Honolulu land on albersUsa's Alaska and Hawaii insets.
|
||||||
|
var usSignups = map[string]float64{
|
||||||
|
"CA": 4820, "TX": 3910, "NY": 3120, "FL": 2870, "IL": 1740, "PA": 1610, "OH": 1490, "GA": 1450,
|
||||||
|
"NC": 1360, "MI": 1280, "WA": 1230, "AZ": 1180, "MA": 1120, "VA": 1090, "CO": 980, "TN": 940,
|
||||||
|
"NJ": 910, "OR": 720, "MN": 690, "WI": 610, "MO": 560, "MD": 540, "IN": 520, "NV": 480,
|
||||||
|
"UT": 430, "AL": 390, "SC": 360, "KY": 310, "LA": 300, "OK": 280, "CT": 260, "IA": 210,
|
||||||
|
"KS": 180, "AK": 140, "HI": 160, "ME": 120, "MT": 90, "WY": 60, "ND": 70, "SD": 80,
|
||||||
|
}
|
||||||
|
var usCities = []ui.USHeatmapPoint{
|
||||||
|
{Label: "Seattle", Lat: 47.6062, Lng: -122.3321, Value: 1230},
|
||||||
|
{Label: "San Francisco", Lat: 37.7749, Lng: -122.4194, Value: 2110},
|
||||||
|
{Label: "Denver", Lat: 39.7392, Lng: -104.9903, Value: 980},
|
||||||
|
{Label: "Chicago", Lat: 41.8781, Lng: -87.6298, Value: 1740},
|
||||||
|
{Label: "New York", Lat: 40.7128, Lng: -74.006, Value: 3120},
|
||||||
|
{Label: "Miami", Lat: 25.7617, Lng: -80.1918, Value: 1460},
|
||||||
|
{Label: "Anchorage", Lat: 61.2181, Lng: -149.9003, Value: 140},
|
||||||
|
{Label: "Honolulu", Lat: 21.3069, Lng: -157.8583, Value: 160},
|
||||||
|
}
|
||||||
|
|
||||||
func chartsSection() func() *VNode {
|
func chartsSection() func() *VNode {
|
||||||
values := NewSignal(fixedChartData())
|
seed := NewSignal(0)
|
||||||
drawn := newChartDrawing()
|
threeD := NewSignal(false)
|
||||||
|
barC, donutC, areaC, lineC := ui.NewChart(), ui.NewChart(), ui.NewChart(), ui.NewChart()
|
||||||
|
horizC, stackC := ui.NewChart(), ui.NewChart()
|
||||||
|
heat := ui.NewUSHeatmap()
|
||||||
|
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
v := values.Get()
|
sh := seed.Get()
|
||||||
|
shuffle := func(base []float64) []float64 {
|
||||||
|
out := make([]float64, len(base))
|
||||||
|
for i, b := range base {
|
||||||
|
if sh == 0 {
|
||||||
|
out[i] = b
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := (int(b)*7 + sh*13) % 80
|
||||||
|
if m < 4 {
|
||||||
|
m = 4
|
||||||
|
}
|
||||||
|
out[i] = float64(m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
requests := ui.ChartSeries{Name: "Requests", Data: shuffle([]float64{42, 17, 63, 28, 55, 9, 71})}
|
||||||
|
errs := ui.ChartSeries{Name: "Errors", Data: shuffle([]float64{8, 3, 12, 6, 9, 2, 14})}
|
||||||
|
pie := ui.ChartSeries{Name: "Traffic", Data: shuffle([]float64{40, 25, 20, 15, 8})}
|
||||||
|
threeDLabel := "3D"
|
||||||
|
if threeD.Get() {
|
||||||
|
threeDLabel = "Flat"
|
||||||
|
}
|
||||||
|
|
||||||
return docSection("charts", "Charts",
|
return docSection("charts", "Charts",
|
||||||
prose("These are SVG, produced by go-chart — a plain Go library that knows nothing about "+
|
prose("webui.Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, a "+
|
||||||
"browsers. They are drawn by the WEBASSEMBLY, in your browser. The server never enters "+
|
"pointer crosshair — with no charting library. The geometry is pure Go, so the same shapes "+
|
||||||
"the drawing code at all; it renders a placeholder, and the wasm replaces it on its first "+
|
"the Solid kit draws on /js run here in the WebAssembly, against the same theme tokens. Change "+
|
||||||
"commit."),
|
"the data and only the marks that moved re-render."),
|
||||||
prose("A Go charting library, compiled to wasm, drawing an SVG in the browser is the thing "+
|
|
||||||
"this layer claims it can do. Server-rendering a picture of a chart would look identical "+
|
|
||||||
"and prove the opposite point."),
|
|
||||||
|
|
||||||
demo("Drawn in the browser, by Go",
|
demo("Bar, donut, smooth area, two lines — one data set, and a 3D toggle",
|
||||||
row("grid gap-4 lg:grid-cols-12",
|
row("grid gap-6 lg:grid-cols-12",
|
||||||
chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
|
Div(Attr("class", "lg:col-span-7"), barC.Render(ui.ChartProps{Kind: ui.ChartBar, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260, ThreeD: threeD.Get()})),
|
||||||
"h-[260px]", drawn.Get(), func() string { return barSVG(v) }),
|
Div(Attr("class", "lg:col-span-5"), donutC.Render(ui.ChartProps{Kind: ui.ChartDonut, Labels: pieLabelsWasm, Series: []ui.ChartSeries{pie}, Height: 260, ThreeD: threeD.Get()})),
|
||||||
chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
|
Div(Attr("class", "lg:col-span-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, Smooth: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests}, Height: 220})),
|
||||||
"h-[320px]", drawn.Get(), func() string { return pieSVG(v) }),
|
Div(Attr("class", "lg:col-span-5"), lineC.Render(ui.ChartProps{Kind: ui.ChartLine, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220})),
|
||||||
),
|
),
|
||||||
row("mt-4 flex items-center gap-2",
|
row("mt-4 flex items-center gap-3",
|
||||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data",
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", OnClick: func() { seed.Set(seed.Get() + 1) }}),
|
||||||
OnClick: func() { values.Set(randomValues()) }}),
|
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Text: threeDLabel, OnClick: func() { threeD.Set(!threeD.Get()) }}),
|
||||||
Span(Attr("class", "text-xs text-ink-muted"),
|
Span(Attr("class", "text-xs text-ink-muted"), Text("Hover any chart. 3D extrudes the bars and tilts the donut.")),
|
||||||
Text("Re-drawn in Go, in the browser. No request is made.")),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
note("webui.Chart is a stub, and says so",
|
demo("Horizontal bars, and stacked",
|
||||||
"The Solid kit's Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, "+
|
row("grid gap-6 lg:grid-cols-2",
|
||||||
"a pointer-driven crosshair — with no charting library at all. That geometry has not "+
|
horizC.Render(ui.ChartProps{Kind: ui.ChartBar, Horizontal: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260}),
|
||||||
"been ported to the neutral Go runtime yet, so the Go port keeps the props shape for "+
|
stackC.Render(ui.ChartProps{Kind: ui.ChartBar, Stacked: true, Labels: regionsWasm, Series: []ui.ChartSeries{
|
||||||
"parity and renders only its box. This example draws with go-chart "+
|
{Name: "Requests", Data: shuffle([]float64{42, 55, 28, 63})},
|
||||||
"instead, as above. Pretending the port drew charts would be the one thing this site refuses to do."),
|
{Name: "Errors", Data: shuffle([]float64{8, 9, 6, 12})},
|
||||||
|
{Name: "Retries", Data: shuffle([]float64{5, 7, 3, 9})},
|
||||||
|
}, Height: 260}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
demo("US heatmap — a value per state, with proportional lat/lng points on top",
|
||||||
|
heat.Render(ui.USHeatmapProps{Data: usSignups, Points: usCities, Proportional: true}),
|
||||||
|
Span(Attr("class", "mt-3 block text-xs text-ink-muted"),
|
||||||
|
Text("webui.USHeatmap shades each state on the choropleth ramp and projects lat/lng points "+
|
||||||
|
"with a Go albersUsa port — Anchorage and Honolulu land on the insets. Hover a state or a point.")),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1337,7 +1393,6 @@ func languageOptions() []ui.FormSelectOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const kitSnippet = `// A component is a function taking a props struct.
|
const kitSnippet = `// A component is a function taking a props struct.
|
||||||
ui.Button(ui.ButtonProps{
|
ui.Button(ui.ButtonProps{
|
||||||
Color: ui.ButtonPrimary,
|
Color: ui.ButtonPrimary,
|
||||||
|
|||||||
@@ -3,12 +3,9 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
chart "github.com/wcharczuk/go-chart/v2"
|
|
||||||
|
|
||||||
. "kjol/vdom"
|
. "kjol/vdom"
|
||||||
ui "kjol/webui"
|
ui "kjol/webui"
|
||||||
)
|
)
|
||||||
@@ -17,7 +14,7 @@ import (
|
|||||||
// component (same builders, signals, On handlers). The //gowasm:server directive
|
// component (same builders, signals, On handlers). The //gowasm:server directive
|
||||||
// makes the build generate a client stub so calling ServerCounter() on the
|
// 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
|
// 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
|
// on the server (its chart SVG is drawn there by webui.ChartSVG), and clicks
|
||||||
// round-trip over /rsc.
|
// round-trip over /rsc.
|
||||||
//
|
//
|
||||||
// The chart plots the counter value against the wall-clock time of each click
|
// The chart plots the counter value against the wall-clock time of each click
|
||||||
@@ -60,61 +57,24 @@ type clickPoint struct {
|
|||||||
V int // counter value after the click
|
V int // counter value after the click
|
||||||
}
|
}
|
||||||
|
|
||||||
// clickChartSVG plots counter value vs. time-of-click (ms since the first
|
// clickChartSVG plots the counter value at each click as a line, drawn on the server by
|
||||||
// click) as a line graph. Explicit axis ranges keep it valid for the tricky
|
// webui.ChartSVG — the same chart geometry the client kit uses, rendered to a static SVG
|
||||||
// cases (a single click, or several clicks within the same millisecond).
|
// string (no controller, no hover) because a server component's output is HTML.
|
||||||
func clickChartSVG(points []clickPoint) string {
|
func clickChartSVG(points []clickPoint) string {
|
||||||
if len(points) == 0 {
|
if len(points) == 0 {
|
||||||
return `<span class="text-muted">Click + / − to plot the counter over time (ms since the first click).</span>`
|
return `<span class="text-ink-muted">Click + / − to plot the counter over each click.</span>`
|
||||||
}
|
}
|
||||||
t0 := points[0].T
|
vals := make([]float64, len(points))
|
||||||
xs := make([]float64, len(points))
|
labels := make([]string, 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 {
|
for i, p := range points {
|
||||||
xs[i] = float64(p.T - t0)
|
vals[i] = float64(p.V)
|
||||||
ys[i] = float64(p.V)
|
labels[i] = strconv.Itoa(i + 1)
|
||||||
if ys[i] < minY {
|
|
||||||
minY = ys[i]
|
|
||||||
}
|
}
|
||||||
if ys[i] > maxY {
|
return ui.ChartSVG(ui.ChartProps{
|
||||||
maxY = ys[i]
|
Kind: ui.ChartLine,
|
||||||
}
|
Labels: labels,
|
||||||
}
|
Series: []ui.ChartSeries{{Name: "Counter", Data: vals, Color: "var(--color-chart-4)"}},
|
||||||
maxX := xs[len(xs)-1]
|
Height: 240,
|
||||||
if maxX <= 0 {
|
NoTooltip: true,
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,24 @@
|
|||||||
--color-text-on-dark: #f9fafb;
|
--color-text-on-dark: #f9fafb;
|
||||||
--color-text-on-dark-muted: #9ca3af;
|
--color-text-on-dark-muted: #9ca3af;
|
||||||
|
|
||||||
|
/* The categorical CHART palette (webui/chart.go) and the sequential choropleth ramp
|
||||||
|
(webui/usheatmap.go) — the SAME tokens the Solid kit names in jsruntime/styles/
|
||||||
|
theme.css, so a chart looks identical on both front-ends. Marks name these as raw
|
||||||
|
CSS variables in the SVG they draw. Dark values in the .dark block below. */
|
||||||
|
--color-chart-1: #2a78d6;
|
||||||
|
--color-chart-2: #1baf7a;
|
||||||
|
--color-chart-3: #eda100;
|
||||||
|
--color-chart-4: #008300;
|
||||||
|
--color-chart-5: #4a3aa7;
|
||||||
|
--color-chart-6: #e34948;
|
||||||
|
--color-chart-7: #e87ba4;
|
||||||
|
--color-chart-8: #eb6834;
|
||||||
|
--color-choropleth-1: #dbe9fb;
|
||||||
|
--color-choropleth-2: #b3d0f6;
|
||||||
|
--color-choropleth-3: #85b3ee;
|
||||||
|
--color-choropleth-4: #5591e4;
|
||||||
|
--color-choropleth-5: #2f6fca;
|
||||||
|
--color-choropleth-6: #124f8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -162,6 +180,23 @@
|
|||||||
|
|
||||||
/* The grid is drawn in ink, not in shadow, once the page is dark. */
|
/* The grid is drawn in ink, not in shadow, once the page is dark. */
|
||||||
--grid-line: rgba(226, 232, 240, 0.05);
|
--grid-line: rgba(226, 232, 240, 0.05);
|
||||||
|
|
||||||
|
/* Chart palette re-stepped for the dark surface; choropleth inverts its lightness
|
||||||
|
direction so a high value reads as brighter. Mirrors the Solid scaffold's .dark. */
|
||||||
|
--color-chart-1: #3987e5;
|
||||||
|
--color-chart-2: #199e70;
|
||||||
|
--color-chart-3: #c98500;
|
||||||
|
--color-chart-4: #008300;
|
||||||
|
--color-chart-5: #9085e9;
|
||||||
|
--color-chart-6: #e66767;
|
||||||
|
--color-chart-7: #d55181;
|
||||||
|
--color-chart-8: #d95926;
|
||||||
|
--color-choropleth-1: #1b2a44;
|
||||||
|
--color-choropleth-2: #21406c;
|
||||||
|
--color-choropleth-3: #2c5f97;
|
||||||
|
--color-choropleth-4: #3f80c8;
|
||||||
|
--color-choropleth-5: #649de8;
|
||||||
|
--color-choropleth-6: #93c2f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The page's own background — painted before the app mounts, and behind it afterwards.
|
/* The page's own background — painted before the app mounts, and behind it afterwards.
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
// The example is its own module so its go-chart dependency (and freetype /
|
// The example is its own module so its build/SSR toolchain (esbuild, goja, minify) stays
|
||||||
// x/image) stays out of the kjol module — kjol's engine packages are
|
// out of the kjol module — kjol's engine packages are stdlib-only. kjol is resolved
|
||||||
// stdlib-only. kjol is resolved locally via the replace below (no publish step).
|
// locally via the replace below (no publish step).
|
||||||
module kjolwebsite
|
module kjolwebsite
|
||||||
|
|
||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require kjol v0.0.0
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.2
|
|
||||||
kjol v0.0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||||
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 // indirect
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 // indirect
|
||||||
github.com/evanw/esbuild v0.28.0 // indirect
|
github.com/evanw/esbuild v0.28.0 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
github.com/tdewolff/minify/v2 v2.24.13 // indirect
|
github.com/tdewolff/minify/v2 v2.24.13 // indirect
|
||||||
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
||||||
golang.org/x/image v0.18.0 // indirect
|
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyL
|
|||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
||||||
@@ -21,73 +18,10 @@ github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5
|
|||||||
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
|
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
|
||||||
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
|
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
|
||||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
|
||||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|
||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
|
||||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface USHeatmapProps {
|
|||||||
valueFormat?: (v: number) => string;
|
valueFormat?: (v: number) => string;
|
||||||
pointColor?: string; // default var(--color-chart-1) (blue).
|
pointColor?: string; // default var(--color-chart-1) (blue).
|
||||||
pointRadius?: number; // fixed dot radius (default 5); the MAXIMUM radius when proportional.
|
pointRadius?: number; // fixed dot radius (default 5); the MAXIMUM radius when proportional.
|
||||||
|
pointOpacity?: number; // dot fill opacity (default 0.85), so the state beneath still reads.
|
||||||
// Scale each dot's AREA by its value (radius ∝ √value) so a bigger dot means "more" —
|
// Scale each dot's AREA by its value (radius ∝ √value) so a bigger dot means "more" —
|
||||||
// area, not radius, because the eye reads a circle by its area.
|
// area, not radius, because the eye reads a circle by its area.
|
||||||
proportional?: boolean;
|
proportional?: boolean;
|
||||||
@@ -146,7 +147,7 @@ export function USHeatmap(props: USHeatmapProps): JSXElement {
|
|||||||
for (const m of points()) {
|
for (const m of points()) {
|
||||||
const isHover = hv?.kind === "point" && hv.idx === m.idx;
|
const isHover = hv?.kind === "point" && hv.idx === m.idx;
|
||||||
const r = radiusOf(m.pt.value);
|
const r = radiusOf(m.pt.value);
|
||||||
out.push(`<circle data-pt="${m.idx}" cx="${m.xy[0]}" cy="${m.xy[1]}" r="${isHover ? r + 2 : r}" fill="${pc}" fill-opacity="${POINT_OPACITY}"/>`);
|
out.push(`<circle data-pt="${m.idx}" cx="${m.xy[0]}" cy="${m.xy[1]}" r="${isHover ? r + 2 : r}" fill="${pc}" fill-opacity="${props.pointOpacity ?? POINT_OPACITY}"/>`);
|
||||||
}
|
}
|
||||||
return out.join("");
|
return out.join("");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
EVENT_POINTERMOVE = "pointermove"
|
EVENT_POINTERMOVE = "pointermove"
|
||||||
EVENT_POINTERUP = "pointerup"
|
EVENT_POINTERUP = "pointerup"
|
||||||
EVENT_POINTERCANCEL = "pointercancel"
|
EVENT_POINTERCANCEL = "pointercancel"
|
||||||
|
EVENT_POINTERLEAVE = "pointerleave"
|
||||||
|
|
||||||
EVENT_SCROLL = "scroll"
|
EVENT_SCROLL = "scroll"
|
||||||
EVENT_RESIZE = "resize"
|
EVENT_RESIZE = "resize"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ See the runnable **`/wasm/kit`** demo page in `cmd/kjol-website` (Layers → Kjo
|
|||||||
`Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`.
|
`Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`.
|
||||||
- **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/
|
- **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/
|
||||||
`HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`.
|
`HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`.
|
||||||
- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `Chart`, `Calendar`,
|
- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `Chart`, `USHeatmap`, `Calendar`,
|
||||||
`DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`.
|
`DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`.
|
||||||
- **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and
|
- **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and
|
||||||
`validation.go` (`IsEmailValid`, `CreateValidation`, …).
|
`validation.go` (`IsEmailValid`, `CreateValidation`, …).
|
||||||
@@ -52,7 +52,11 @@ with a `// NOTE:` in the component's file. Concretely: dropdown/menu/tooltip/mod
|
|||||||
positioning is static (not computed); outside-click / Escape / hover-delay dismissal,
|
positioning is static (not computed); outside-click / Escape / hover-delay dismissal,
|
||||||
auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable`
|
auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable`
|
||||||
omits virtual scrolling, column resize/reorder, inline editing, and the formula engine;
|
omits virtual scrolling, column resize/reorder, inline editing, and the formula engine;
|
||||||
`Chart` renders only its container — the Solid kit's dependency-free SVG geometry is not
|
a few `Form*` controls that needed canvas/async (`FormSignaturePad`, `FormAsyncCombobox`)
|
||||||
yet ported (the example draws charts server-side with go-chart); a few `Form*` controls
|
are omitted. Everything compiles on native (SSR) and js/wasm.
|
||||||
that needed canvas/async (`FormSignaturePad`,
|
|
||||||
`FormAsyncCombobox`) are omitted. Everything compiles on native (SSR) and js/wasm.
|
`Chart` and `USHeatmap` are full SVG ports: the geometry (nice-scale axes, rounded
|
||||||
|
columns, arc slices, the tilted 3D forms, the albersUsa projection) is pure Go, drawn as
|
||||||
|
an SVG string via `vdom.Raw` inside an `<svg>` that carries the pointer handler; hover
|
||||||
|
writes a signal and the tooltip is drawn into the same SVG. They read the same theme
|
||||||
|
tokens as the Solid kit, so a chart is identical on both front-ends.
|
||||||
|
|||||||
1051
go/webui/chart.go
1051
go/webui/chart.go
File diff suppressed because it is too large
Load Diff
265
go/webui/chart_geom.go
Normal file
265
go/webui/chart_geom.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pure geometry + formatting for chart.go — the Go twins of the helpers in
|
||||||
|
// jsruntime/uikit/Chart.tsx, producing the same SVG path/rect/text markup.
|
||||||
|
|
||||||
|
// num formats a coordinate compactly (2 decimals, trailing zeros trimmed) for a path
|
||||||
|
// string — 44.57 not 44.571428571428.
|
||||||
|
func num(f float64) string {
|
||||||
|
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
s := strconv.FormatFloat(f, 'f', 2, 64)
|
||||||
|
if strings.ContainsRune(s, '.') {
|
||||||
|
s = strings.TrimRight(s, "0")
|
||||||
|
s = strings.TrimRight(s, ".")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var svgEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||||
|
|
||||||
|
// svgEsc escapes text/attribute content going into the Raw SVG string — labels and
|
||||||
|
// colours can be untrusted (a series name from data, a "<").
|
||||||
|
func svgEsc(s string) string { return svgEscaper.Replace(s) }
|
||||||
|
|
||||||
|
// groupNum is the default value format: up to 2 decimals, thousands grouped.
|
||||||
|
func groupNum(v float64) string {
|
||||||
|
neg := v < 0
|
||||||
|
s := strconv.FormatFloat(math.Abs(v), 'f', 2, 64)
|
||||||
|
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
|
||||||
|
intPart, frac := s, ""
|
||||||
|
if i := strings.IndexByte(s, '.'); i >= 0 {
|
||||||
|
intPart, frac = s[:i], s[i:]
|
||||||
|
}
|
||||||
|
if n := len(intPart); n > 3 {
|
||||||
|
var b strings.Builder
|
||||||
|
pre := n % 3
|
||||||
|
if pre > 0 {
|
||||||
|
b.WriteString(intPart[:pre])
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
for i := pre; i < n; i += 3 {
|
||||||
|
b.WriteString(intPart[i : i+3])
|
||||||
|
if i+3 < n {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intPart = b.String()
|
||||||
|
}
|
||||||
|
out := intPart + frac
|
||||||
|
if neg && out != "0" {
|
||||||
|
out = "-" + out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTo(v float64, n int) float64 {
|
||||||
|
p := math.Pow(10, float64(n))
|
||||||
|
return math.Round(v*p) / p
|
||||||
|
}
|
||||||
|
|
||||||
|
// niceScale: rounded min/max plus recognisable tick values (0, 20, 40 …).
|
||||||
|
func niceScale(mn, mx float64, maxTicks int) (float64, float64, []float64) {
|
||||||
|
if math.IsInf(mn, 0) || math.IsInf(mx, 0) || math.IsNaN(mn) || math.IsNaN(mx) || mn == mx {
|
||||||
|
v := mx
|
||||||
|
if math.IsInf(v, 0) || math.IsNaN(v) {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
mn = math.Min(0, v)
|
||||||
|
if v == mn {
|
||||||
|
mx = mn + 1
|
||||||
|
} else {
|
||||||
|
mx = math.Max(0, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
niceNum := func(rng float64, round bool) float64 {
|
||||||
|
if rng <= 0 {
|
||||||
|
rng = 1
|
||||||
|
}
|
||||||
|
exp := math.Floor(math.Log10(rng))
|
||||||
|
frac := rng / math.Pow(10, exp)
|
||||||
|
var nf float64
|
||||||
|
if round {
|
||||||
|
switch {
|
||||||
|
case frac < 1.5:
|
||||||
|
nf = 1
|
||||||
|
case frac < 3:
|
||||||
|
nf = 2
|
||||||
|
case frac < 7:
|
||||||
|
nf = 5
|
||||||
|
default:
|
||||||
|
nf = 10
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch {
|
||||||
|
case frac <= 1:
|
||||||
|
nf = 1
|
||||||
|
case frac <= 2:
|
||||||
|
nf = 2
|
||||||
|
case frac <= 5:
|
||||||
|
nf = 5
|
||||||
|
default:
|
||||||
|
nf = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nf * math.Pow(10, exp)
|
||||||
|
}
|
||||||
|
step := niceNum((mx-mn)/math.Max(1, float64(maxTicks-1)), true)
|
||||||
|
niceMin := math.Floor(mn/step) * step
|
||||||
|
niceMax := math.Ceil(mx/step) * step
|
||||||
|
decimals := int(math.Max(0, -math.Floor(math.Log10(step))))
|
||||||
|
var ticks []float64
|
||||||
|
for v := niceMin; v <= niceMax+step*0.5; v += step {
|
||||||
|
ticks = append(ticks, roundTo(v, decimals+2))
|
||||||
|
}
|
||||||
|
return niceMin, niceMax, ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundRectPath: a rectangle with the two corners on `side` rounded (the data-end), the
|
||||||
|
// rest square.
|
||||||
|
func roundRectPath(x, y, w, h, r float64, side string) string {
|
||||||
|
rr := math.Max(0, math.Min(math.Min(r, w/2), h/2))
|
||||||
|
var tl, tr, br, bl float64
|
||||||
|
switch side {
|
||||||
|
case "top":
|
||||||
|
tl, tr = rr, rr
|
||||||
|
case "bottom":
|
||||||
|
bl, br = rr, rr
|
||||||
|
case "left":
|
||||||
|
tl, bl = rr, rr
|
||||||
|
case "right":
|
||||||
|
tr, br = rr, rr
|
||||||
|
}
|
||||||
|
return "M" + num(x+tl) + "," + num(y) +
|
||||||
|
" L" + num(x+w-tr) + "," + num(y) + " Q" + num(x+w) + "," + num(y) + " " + num(x+w) + "," + num(y+tr) +
|
||||||
|
" L" + num(x+w) + "," + num(y+h-br) + " Q" + num(x+w) + "," + num(y+h) + " " + num(x+w-br) + "," + num(y+h) +
|
||||||
|
" L" + num(x+bl) + "," + num(y+h) + " Q" + num(x) + "," + num(y+h) + " " + num(x) + "," + num(y+h-bl) +
|
||||||
|
" L" + num(x) + "," + num(y+tl) + " Q" + num(x) + "," + num(y) + " " + num(x+tl) + "," + num(y) + " Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
// bar3D: a bar extruded up-and-right by (dx, dy) — a darkened right face, a lightened top
|
||||||
|
// face, then the front. Flat overlays instead of colour maths on a CSS variable.
|
||||||
|
func bar3D(x, y, w, h float64, color string, op, dx, dy float64) string {
|
||||||
|
top := "M" + num(x) + "," + num(y) + " L" + num(x+dx) + "," + num(y-dy) + " L" + num(x+w+dx) + "," + num(y-dy) + " L" + num(x+w) + "," + num(y) + " Z"
|
||||||
|
right := "M" + num(x+w) + "," + num(y) + " L" + num(x+w+dx) + "," + num(y-dy) + " L" + num(x+w+dx) + "," + num(y+h-dy) + " L" + num(x+w) + "," + num(y+h) + " Z"
|
||||||
|
return `<path d="` + right + `" fill="` + color + `" fill-opacity="` + num(op) + `"/><path d="` + right + `" fill="#000" fill-opacity="` + num(0.24*op) + `"/>` +
|
||||||
|
`<path d="` + top + `" fill="` + color + `" fill-opacity="` + num(op) + `"/><path d="` + top + `" fill="#fff" fill-opacity="` + num(0.2*op) + `"/>` +
|
||||||
|
`<rect x="` + num(x) + `" y="` + num(y) + `" width="` + num(w) + `" height="` + num(h) + `" fill="` + color + `" fill-opacity="` + num(op) + `"/>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func linePathD(pts [][2]float64) string {
|
||||||
|
if len(pts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i, p := range pts {
|
||||||
|
if i == 0 {
|
||||||
|
b.WriteByte('M')
|
||||||
|
} else {
|
||||||
|
b.WriteString(" L")
|
||||||
|
}
|
||||||
|
b.WriteString(num(p[0]) + "," + num(p[1]))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// smoothPathD: Catmull-Rom → cubic bezier through every point.
|
||||||
|
func smoothPathD(pts [][2]float64) string {
|
||||||
|
if len(pts) < 3 {
|
||||||
|
return linePathD(pts)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("M" + num(pts[0][0]) + "," + num(pts[0][1]))
|
||||||
|
for i := 0; i < len(pts)-1; i++ {
|
||||||
|
p0 := pts[i]
|
||||||
|
if i > 0 {
|
||||||
|
p0 = pts[i-1]
|
||||||
|
}
|
||||||
|
p1, p2 := pts[i], pts[i+1]
|
||||||
|
p3 := p2
|
||||||
|
if i+2 < len(pts) {
|
||||||
|
p3 = pts[i+2]
|
||||||
|
}
|
||||||
|
c1x, c1y := p1[0]+(p2[0]-p0[0])/6, p1[1]+(p2[1]-p0[1])/6
|
||||||
|
c2x, c2y := p2[0]-(p3[0]-p1[0])/6, p2[1]-(p3[1]-p1[1])/6
|
||||||
|
b.WriteString(" C" + num(c1x) + "," + num(c1y) + " " + num(c2x) + "," + num(c2y) + " " + num(p2[0]) + "," + num(p2[1]))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tiltPoint: a point on a circle tilted about its horizontal axis by k (k=1 upright).
|
||||||
|
func tiltPoint(cx, cy, r, deg, k float64) (float64, float64) {
|
||||||
|
a := (deg - 90) * math.Pi / 180
|
||||||
|
return cx + r*math.Cos(a), cy + k*r*math.Sin(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slicePathD: one pie/donut slice a0→a1 degrees, tilted by k. Elliptical arcs make the
|
||||||
|
// tilt exact.
|
||||||
|
func slicePathD(cx, cy, rOut, rIn, a0, a1, k float64) string {
|
||||||
|
large := "0"
|
||||||
|
if a1-a0 > 180 {
|
||||||
|
large = "1"
|
||||||
|
}
|
||||||
|
ox0, oy0 := tiltPoint(cx, cy, rOut, a0, k)
|
||||||
|
ox1, oy1 := tiltPoint(cx, cy, rOut, a1, k)
|
||||||
|
ryO := k * rOut
|
||||||
|
if rIn <= 0 {
|
||||||
|
return "M" + num(cx) + "," + num(cy) + " L" + num(ox0) + "," + num(oy0) +
|
||||||
|
" A" + num(rOut) + "," + num(ryO) + " 0 " + large + " 1 " + num(ox1) + "," + num(oy1) + " Z"
|
||||||
|
}
|
||||||
|
ix1, iy1 := tiltPoint(cx, cy, rIn, a1, k)
|
||||||
|
ix0, iy0 := tiltPoint(cx, cy, rIn, a0, k)
|
||||||
|
ryI := k * rIn
|
||||||
|
return "M" + num(ox0) + "," + num(oy0) +
|
||||||
|
" A" + num(rOut) + "," + num(ryO) + " 0 " + large + " 1 " + num(ox1) + "," + num(oy1) +
|
||||||
|
" L" + num(ix1) + "," + num(iy1) +
|
||||||
|
" A" + num(rIn) + "," + num(ryI) + " 0 " + large + " 0 " + num(ix0) + "," + num(iy0) + " Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small numeric helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func maxf(a, b float64) float64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
func maxi(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
func clampf(v, lo, hi float64) float64 { return math.Min(hi, math.Max(lo, v)) }
|
||||||
|
func clampi(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
func maxLen(ss []string) int {
|
||||||
|
m := 0
|
||||||
|
for _, s := range ss {
|
||||||
|
if len(s) > m {
|
||||||
|
m = len(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
func fmtAll(p ChartProps, ticks []float64) []string {
|
||||||
|
out := make([]string, len(ticks))
|
||||||
|
for i, t := range ticks {
|
||||||
|
out[i] = chartFmt(p, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
395
go/webui/usheatmap.go
Normal file
395
go/webui/usheatmap.go
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
// Port of jsruntime/uikit/USHeatmap.tsx — a choropleth of the 50 states + DC, plus
|
||||||
|
// optional lat/lng markers, drawn by the WebAssembly. The state boundaries (usstates.go)
|
||||||
|
// are pre-projected with d3's albersUsa; the SAME projection is reimplemented below so a
|
||||||
|
// lat/lng point lands on top of the states — a faithful port validated to 0px against
|
||||||
|
// d3-geo (Alaska and Hawaii insets included).
|
||||||
|
//
|
||||||
|
// The map scales by viewBox (there is no axis text to keep crisp), so no measurement.
|
||||||
|
// Hover reads e.Target()'s data-attribute — the state path or point marker under the
|
||||||
|
// pointer — so no coordinate maths is needed to know what is being pointed at; the tooltip
|
||||||
|
// anchors to the hovered state's centroid (or the point), drawn into the same SVG.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
"kjol/wasmruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── the albersUsa projection (ported from d3-geo; scale 1280, translate [480,300]) ──────
|
||||||
|
|
||||||
|
const (
|
||||||
|
usRad = math.Pi / 180
|
||||||
|
usTau = 2 * math.Pi
|
||||||
|
usK = 1280.0
|
||||||
|
usTX = 480.0
|
||||||
|
usTY = 300.0
|
||||||
|
usEps = 1e-6
|
||||||
|
)
|
||||||
|
|
||||||
|
func conicEqualAreaRaw(y0, y1 float64) func(lambda, phi float64) (float64, float64) {
|
||||||
|
sy0 := math.Sin(y0)
|
||||||
|
n := (sy0 + math.Sin(y1)) / 2
|
||||||
|
c := 1 + sy0*(2*n-sy0)
|
||||||
|
r0 := math.Sqrt(c) / n
|
||||||
|
return func(lambda, phi float64) (float64, float64) {
|
||||||
|
r := math.Sqrt(c-2*n*math.Sin(phi)) / n
|
||||||
|
return r * math.Sin(lambda*n), r0 - r*math.Cos(lambda*n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// albersLobe is one conic-equal-area lobe. center is in the rotated frame (near 0° lon),
|
||||||
|
// so it is not re-rotated; the input point is rotated by rotateLon before projecting.
|
||||||
|
func albersLobe(rotateLon, centerLon, centerLat, p0, p1, scale, tx, ty float64) func(lon, lat float64) (float64, float64) {
|
||||||
|
raw := conicEqualAreaRaw(p0*usRad, p1*usRad)
|
||||||
|
rot := func(lon float64) float64 {
|
||||||
|
l := (lon + rotateLon) * usRad
|
||||||
|
return math.Mod(math.Mod(l+math.Pi, usTau)+usTau, usTau) - math.Pi
|
||||||
|
}
|
||||||
|
cx, cy := raw(centerLon*usRad, centerLat*usRad)
|
||||||
|
return func(lon, lat float64) (float64, float64) {
|
||||||
|
x, y := raw(rot(lon), lat*usRad)
|
||||||
|
return tx + scale*(x-cx), ty - scale*(y-cy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
usLower48 = albersLobe(96, -0.6, 38.7, 29.5, 45.5, usK, usTX, usTY)
|
||||||
|
usAlaska = albersLobe(154, -2, 58.5, 55, 65, usK*0.35, usTX-0.307*usK, usTY+0.201*usK)
|
||||||
|
usHawaii = albersLobe(157, -3, 19.9, 8, 18, usK, usTX-0.205*usK, usTY+0.212*usK)
|
||||||
|
)
|
||||||
|
|
||||||
|
func usInBox(x, y, x0, y0, x1, y1 float64) bool {
|
||||||
|
return x >= x0 && x <= x1 && y >= y0 && y <= y1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectUS maps [lng, lat] to the 960×600 map, picking the lower-48 / Alaska / Hawaii
|
||||||
|
// lobe by which one's clip box the point falls in, the way albersUsa does. ok is false
|
||||||
|
// when the point is off-map.
|
||||||
|
func ProjectUS(lng, lat float64) (x, y float64, ok bool) {
|
||||||
|
x, y = usLower48(lng, lat)
|
||||||
|
if usInBox(x, y, usTX-0.455*usK, usTY-0.238*usK, usTX+0.455*usK, usTY+0.238*usK) {
|
||||||
|
return x, y, true
|
||||||
|
}
|
||||||
|
x, y = usAlaska(lng, lat)
|
||||||
|
if usInBox(x, y, usTX-0.425*usK+usEps, usTY+0.120*usK+usEps, usTX-0.214*usK-usEps, usTY+0.234*usK-usEps) {
|
||||||
|
return x, y, true
|
||||||
|
}
|
||||||
|
x, y = usHawaii(lng, lat)
|
||||||
|
if usInBox(x, y, usTX-0.214*usK+usEps, usTY+0.166*usK+usEps, usTX-0.115*usK-usEps, usTY+0.234*usK-usEps) {
|
||||||
|
return x, y, true
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── state centroids (bounding-box centre of each path, for the tooltip anchor) ──────
|
||||||
|
|
||||||
|
var usStateCodes = func() []string {
|
||||||
|
codes := make([]string, 0, len(usStates))
|
||||||
|
for c := range usStates {
|
||||||
|
codes = append(codes, c)
|
||||||
|
}
|
||||||
|
sort.Strings(codes)
|
||||||
|
return codes
|
||||||
|
}()
|
||||||
|
|
||||||
|
var usCentroids = map[string][2]float64{}
|
||||||
|
|
||||||
|
func stateCentroid(code string) (float64, float64) {
|
||||||
|
if c, ok := usCentroids[code]; ok {
|
||||||
|
return c[0], c[1]
|
||||||
|
}
|
||||||
|
nums := pathNums(usStates[code].D)
|
||||||
|
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||||
|
for i := 0; i+1 < len(nums); i += 2 {
|
||||||
|
minX, maxX = math.Min(minX, nums[i]), math.Max(maxX, nums[i])
|
||||||
|
minY, maxY = math.Min(minY, nums[i+1]), math.Max(maxY, nums[i+1])
|
||||||
|
}
|
||||||
|
cx, cy := (minX+maxX)/2, (minY+maxY)/2
|
||||||
|
usCentroids[code] = [2]float64{cx, cy}
|
||||||
|
return cx, cy
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathNums extracts every number from a path d string. The state coords are all positive
|
||||||
|
// (the albersUsa box is 0..960 × 0..600), comma/letter separated, so a simple digit-run
|
||||||
|
// scan suffices — no sign or exponent handling needed.
|
||||||
|
func pathNums(s string) []float64 {
|
||||||
|
var out []float64
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
if c := s[i]; (c >= '0' && c <= '9') || c == '.' {
|
||||||
|
j := i
|
||||||
|
for j < len(s) && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.') {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(s[i:j], 64); err == nil {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type USHeatmapPoint struct {
|
||||||
|
Lat, Lng float64
|
||||||
|
Value float64
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type USHeatmapProps struct {
|
||||||
|
// State value map: USPS code ("CA", "TX", "DC") → number. Present states are shaded on
|
||||||
|
// the sequential ramp; absent states get the no-data neutral.
|
||||||
|
Data map[string]float64
|
||||||
|
// lat/lng markers, projected onto the map. Points outside the US are dropped.
|
||||||
|
Points []USHeatmapPoint
|
||||||
|
|
||||||
|
Height float64 // px; 0 = size from container width (960:600 aspect).
|
||||||
|
Class string
|
||||||
|
Steps int // choropleth buckets 1–6 (default 6, the token count).
|
||||||
|
NoTooltip bool //
|
||||||
|
Proportional bool // scale each dot's AREA by its value (radius ∝ √value).
|
||||||
|
|
||||||
|
ValueFormat func(float64) string
|
||||||
|
PointColor string // default var(--color-chart-1)
|
||||||
|
PointRadius float64 // fixed radius (default 5); MAX radius when proportional.
|
||||||
|
PointOpacity float64 // default 0.85
|
||||||
|
StateName func(string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
const choroplethSteps = 6 // must match --color-choropleth-1..N
|
||||||
|
|
||||||
|
type usHover struct {
|
||||||
|
kind string // "" | "state" | "point"
|
||||||
|
code string
|
||||||
|
idx int
|
||||||
|
}
|
||||||
|
|
||||||
|
// USHeatmap is a CONTROLLER (it holds the hover signal across renders). Build it once and
|
||||||
|
// call Render(props) each render.
|
||||||
|
type USHeatmap struct {
|
||||||
|
hover *vdom.Signal[usHover]
|
||||||
|
svgRef *vdom.Ref
|
||||||
|
tipRef *vdom.Ref // the HTML tooltip, positioned imperatively so it follows the cursor
|
||||||
|
props USHeatmapProps
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUSHeatmap() *USHeatmap {
|
||||||
|
return &USHeatmap{hover: vdom.NewSignal(usHover{}), svgRef: vdom.NewRef(), tipRef: vdom.NewRef()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *USHeatmap) setHover(h usHover) {
|
||||||
|
if h != c.hover.Get() {
|
||||||
|
c.hover.Set(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (c *USHeatmap) onLeave() { c.setHover(usHover{}) }
|
||||||
|
|
||||||
|
func (c *USHeatmap) onMove(e vdom.Event) {
|
||||||
|
if c.props.NoTooltip {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var hv usHover
|
||||||
|
if s, ok := wasmruntime.ClosestAttr(e.Target(), "[data-pt]", "data-pt"); ok {
|
||||||
|
idx, _ := strconv.Atoi(s)
|
||||||
|
hv = usHover{kind: "point", idx: idx}
|
||||||
|
} else if code, ok := wasmruntime.ClosestAttr(e.Target(), "[data-state]", "data-state"); ok {
|
||||||
|
hv = usHover{kind: "state", code: code}
|
||||||
|
}
|
||||||
|
c.setHover(hv)
|
||||||
|
if hv.kind == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// position the tooltip at the pointer (follows the cursor, like the Solid heatmap). The
|
||||||
|
// svg is at the wrapper's top-left, so pointer-minus-svg-rect is the tooltip's left/top.
|
||||||
|
r := wasmruntime.Measure(c.svgRef)
|
||||||
|
if r.Width == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wasmruntime.SetStyle(c.tipRef, "left", num(clampf(float64(e.ClientX())-r.X, 8, r.Width-8))+"px")
|
||||||
|
wasmruntime.SetStyle(c.tipRef, "top", num(clampf(float64(e.ClientY())-r.Y, 8, r.Height-8))+"px")
|
||||||
|
wasmruntime.SetStyle(c.tipRef, "transform", "translate(-50%, calc(-100% - 12px))")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tooltipNode is the HTML tooltip (absolute, pointer-events-none), rendered every pass so
|
||||||
|
// its ref stays valid; onMove positions it imperatively. Hidden when nothing is hovered.
|
||||||
|
func (c *USHeatmap) tooltipNode(p USHeatmapProps, hv usHover, mn, mx float64, steps int) *vdom.VNode {
|
||||||
|
base := "pointer-events-none absolute z-10 min-w-28 max-w-64 rounded-default border border-line bg-surface px-3 py-2 text-xs shadow-lg"
|
||||||
|
if p.NoTooltip || hv.kind == "" {
|
||||||
|
return vdom.Div(vdom.WithRef(c.tipRef), vdom.Attr("class", cx(base, "hidden")))
|
||||||
|
}
|
||||||
|
var title, value, swatch string
|
||||||
|
if hv.kind == "state" {
|
||||||
|
title = c.stateName(hv.code)
|
||||||
|
if v, has := p.Data[hv.code]; has && !math.IsNaN(v) {
|
||||||
|
value = c.valFmt(v)
|
||||||
|
swatch = "var(--color-choropleth-" + strconv.Itoa(heatBucket(v, mn, mx, steps)) + ")"
|
||||||
|
} else {
|
||||||
|
value = "no data"
|
||||||
|
swatch = "var(--color-surface-strong)"
|
||||||
|
}
|
||||||
|
} else if hv.idx >= 0 && hv.idx < len(p.Points) {
|
||||||
|
pt := p.Points[hv.idx]
|
||||||
|
title = pt.Label
|
||||||
|
if title == "" {
|
||||||
|
title = strconv.FormatFloat(pt.Lat, 'f', 2, 64) + ", " + strconv.FormatFloat(pt.Lng, 'f', 2, 64)
|
||||||
|
}
|
||||||
|
value = c.valFmt(pt.Value)
|
||||||
|
swatch = p.PointColor
|
||||||
|
if swatch == "" {
|
||||||
|
swatch = "var(--color-chart-1)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content := []*vdom.VNode{
|
||||||
|
vdom.Div(vdom.Attr("class", "flex items-center gap-2"),
|
||||||
|
swatchSpan(swatch),
|
||||||
|
vdom.Span(vdom.Attr("class", "font-medium text-ink"), vdom.Text(title))),
|
||||||
|
}
|
||||||
|
if value != "" {
|
||||||
|
content = append(content, vdom.Div(vdom.Attr("class", "mt-1 font-semibold text-ink"),
|
||||||
|
vdom.Attr("style", "font-variant-numeric:tabular-nums"), vdom.Text(value)))
|
||||||
|
}
|
||||||
|
return vdom.Div(kids([]vdom.Mod{vdom.WithRef(c.tipRef), vdom.Attr("class", base)}, content)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *USHeatmap) valFmt(v float64) string {
|
||||||
|
if c.props.ValueFormat != nil {
|
||||||
|
return c.props.ValueFormat(v)
|
||||||
|
}
|
||||||
|
return groupNum(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *USHeatmap) stateName(code string) string {
|
||||||
|
if c.props.StateName != nil {
|
||||||
|
return c.props.StateName(code)
|
||||||
|
}
|
||||||
|
if s, ok := usStates[code]; ok {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
func heatBucket(v, mn, mx float64, steps int) int {
|
||||||
|
t := 1.0
|
||||||
|
if mx > mn {
|
||||||
|
t = (v - mn) / (mx - mn)
|
||||||
|
}
|
||||||
|
return clampi(int(math.Floor(t*float64(steps))), 0, steps-1) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *USHeatmap) Render(p USHeatmapProps) *vdom.VNode {
|
||||||
|
c.props = p
|
||||||
|
hv := c.hover.Get()
|
||||||
|
steps := clampi(p.Steps, 1, choroplethSteps)
|
||||||
|
if p.Steps == 0 {
|
||||||
|
steps = choroplethSteps
|
||||||
|
}
|
||||||
|
|
||||||
|
mn, mx, hasData := math.Inf(1), math.Inf(-1), false
|
||||||
|
for _, v := range p.Data {
|
||||||
|
if !math.IsNaN(v) && !math.IsInf(v, 0) {
|
||||||
|
mn, mx, hasData = math.Min(mn, v), math.Max(mx, v), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
// states (sorted for a stable diff order)
|
||||||
|
for _, code := range usStateCodes {
|
||||||
|
v, has := p.Data[code]
|
||||||
|
fill := "var(--color-surface-strong)"
|
||||||
|
if has && !math.IsNaN(v) {
|
||||||
|
fill = "var(--color-choropleth-" + strconv.Itoa(heatBucket(v, mn, mx, steps)) + ")"
|
||||||
|
}
|
||||||
|
op := "1"
|
||||||
|
if hv.kind == "state" && hv.code == code {
|
||||||
|
op = "0.82"
|
||||||
|
}
|
||||||
|
b.WriteString(`<path d="` + usStates[code].D + `" data-state="` + code + `" fill="` + fill + `" fill-opacity="` + op + `" stroke="var(--color-surface)" stroke-width="0.8"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// points
|
||||||
|
pc := p.PointColor
|
||||||
|
if pc == "" {
|
||||||
|
pc = "var(--color-chart-1)"
|
||||||
|
}
|
||||||
|
pop := p.PointOpacity
|
||||||
|
if pop == 0 {
|
||||||
|
pop = 0.85
|
||||||
|
}
|
||||||
|
maxR := p.PointRadius
|
||||||
|
if maxR == 0 {
|
||||||
|
if p.Proportional {
|
||||||
|
maxR = 16
|
||||||
|
} else {
|
||||||
|
maxR = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
minR := math.Min(3, maxR*0.35)
|
||||||
|
maxV := 1.0
|
||||||
|
if p.Proportional {
|
||||||
|
for _, pt := range p.Points {
|
||||||
|
if pt.Value > maxV {
|
||||||
|
maxV = pt.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, pt := range p.Points {
|
||||||
|
x, y, ok := ProjectUS(pt.Lng, pt.Lat)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := maxR
|
||||||
|
if p.Proportional {
|
||||||
|
r = minR + (maxR-minR)*math.Sqrt(clampf(pt.Value/maxV, 0, 1))
|
||||||
|
}
|
||||||
|
if hv.kind == "point" && hv.idx == i {
|
||||||
|
r += 2
|
||||||
|
}
|
||||||
|
b.WriteString(`<circle data-pt="` + strconv.Itoa(i) + `" cx="` + num(x) + `" cy="` + num(y) + `" r="` + num(r) + `" fill="` + svgEsc(pc) + `" fill-opacity="` + num(pop) + `"/>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
svgMods := []vdom.Mod{
|
||||||
|
vdom.WithRef(c.svgRef),
|
||||||
|
vdom.Attr("viewBox", usViewBox),
|
||||||
|
vdom.Attr("role", "img"),
|
||||||
|
vdom.Raw(b.String()),
|
||||||
|
}
|
||||||
|
if p.Height > 0 {
|
||||||
|
svgMods = append(svgMods, vdom.Attr("class", "block"), vdom.Attr("style", "height:"+num(p.Height)+"px;width:auto;margin:0 auto"))
|
||||||
|
} else {
|
||||||
|
svgMods = append(svgMods, vdom.Attr("class", "block w-full"), vdom.Attr("style", "aspect-ratio:960/600"))
|
||||||
|
}
|
||||||
|
if !p.NoTooltip {
|
||||||
|
svgMods = append(svgMods,
|
||||||
|
vdom.OnEvent(vdom.EVENT_POINTERMOVE, c.onMove),
|
||||||
|
vdom.On(vdom.EVENT_POINTERLEAVE, c.onLeave),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
children := []*vdom.VNode{vdom.Svg(svgMods...), c.tooltipNode(p, hv, mn, mx, steps)}
|
||||||
|
if hasData {
|
||||||
|
children = append(children, choroplethLegend(mn, mx, steps, c.valFmt))
|
||||||
|
}
|
||||||
|
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("relative w-full", p.Class))}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func choroplethLegend(mn, mx float64, steps int, fmt func(float64) string) *vdom.VNode {
|
||||||
|
swatches := []*vdom.VNode{}
|
||||||
|
for k := 1; k <= steps; k++ {
|
||||||
|
swatches = append(swatches, vdom.Span(vdom.Attr("class", "h-3 w-6"),
|
||||||
|
vdom.Attr("style", "background-color:var(--color-choropleth-"+strconv.Itoa(k)+")")))
|
||||||
|
}
|
||||||
|
ramp := vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex overflow-hidden rounded-xs")}, swatches)...)
|
||||||
|
return vdom.Div(vdom.Attr("class", "mt-3 flex items-center gap-2 text-xs text-ink-muted"),
|
||||||
|
vdom.Span(vdom.Attr("style", "font-variant-numeric:tabular-nums"), vdom.Text(fmt(mn))),
|
||||||
|
ramp,
|
||||||
|
vdom.Span(vdom.Attr("style", "font-variant-numeric:tabular-nums"), vdom.Text(fmt(mx))),
|
||||||
|
)
|
||||||
|
}
|
||||||
66
go/webui/usstates.go
Normal file
66
go/webui/usstates.go
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user