// 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/wasmruntime" "kjol/wasmruntime/vdom" ) // ── 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(``) } // 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(``) } 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))), ) }