update heatmap, add wasm charts
This commit is contained in:
@@ -37,7 +37,7 @@ See the runnable **`/wasm/kit`** demo page in `cmd/kjol-website` (Layers → Kjo
|
||||
`Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`.
|
||||
- **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/
|
||||
`HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`.
|
||||
- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `Chart`, `Calendar`,
|
||||
- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `Chart`, `USHeatmap`, `Calendar`,
|
||||
`DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`.
|
||||
- **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and
|
||||
`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,
|
||||
auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable`
|
||||
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
|
||||
yet ported (the example draws charts server-side with go-chart); a few `Form*` controls
|
||||
that needed canvas/async (`FormSignaturePad`,
|
||||
`FormAsyncCombobox`) are omitted. Everything compiles on native (SSR) and js/wasm.
|
||||
a few `Form*` controls 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