update heatmap, add wasm charts

This commit is contained in:
2026-07-16 14:55:16 -04:00
parent 2477c2d6a2
commit 093bad311e
13 changed files with 1949 additions and 352 deletions

View File

@@ -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()))
}` }`

View File

@@ -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,

View File

@@ -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()
} }

View File

@@ -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.

View File

@@ -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

View File

@@ -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=

View File

@@ -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("");
}); });

View File

@@ -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"

View File

@@ -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.

File diff suppressed because it is too large Load Diff

265
go/webui/chart_geom.go Normal file
View 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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
// 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
View 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 16 (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

File diff suppressed because one or more lines are too long