Files
MujicaUI/chart/market_heatmap.go
T
ZacharyZhang-NYandClaude Opus 5.5 bcce34cda1 Upgrade MyGo to v0.5.5
MyGo v0.5.5 returns ui.Element as a checked value handle instead of a
pointer. Migrated with `mygo migrate-ui`, then fixed what the codemod
leaves to review:

- Element nil checks become .Valid(); absent elements are ui.Element{}.
- Input handlers and paint callbacks run outside the build pass, where
  elements expire and Context methods are inert: read IsDisabled into a
  local during the build, keep a ui.Handle for Focus, use c.Services()
  for clipboard access, create ui.Local state during the build.
- Add core.AutoFocus: v0.5.5 records an overlay's opener after its
  content builds, so same-frame autofocus lost focus restore on close.
  It focuses one frame later; cascader rows defer their focus likewise.
- core.OverlayLayer identifies build passes by the root element, not
  c.Now(), which repeats across passes and under the Tester clock.
- Context menus survive the opening press counted as PressedOutside;
  split button tooltip no longer reorders its open menu; disabled
  menubar rows pass presses to the panel; Form defers blur-started
  async checks so the growing message no longer moves Submit away
  from the release; Combobox drops its disabled copy now that MyGo
  blocks typing under a disabled wrapper (canary flipped to a guard).
- The Tester clock is virtual: tests use Advance/Now instead of
  time.Sleep/time.Now and read element state inside the view. The web
  mirror advances the clock to wall time before each frame and input.
- AGENTS.md and docs describe the value Element API and the new pin.

Snapshot goldens are darwin-only and were not re-verified here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 01:37:54 -04:00

242 lines
6.7 KiB
Go

package chart
import (
"cmp"
"fmt"
"log/slog"
"math"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
type HeatNode struct {
Name string
Cap float64
Change float64
Children []HeatNode
}
// MarketHeatmapOptions configure a MarketHeatmap.
type MarketHeatmapOptions struct {
Frame ChartFrame
Version int
Scale float64
}
type MarketHeatMemo struct {
id string
Tiles []MarketHeatTile
Caps []float64
width, height float32
Rects []ui.Rect
Visible []int
Small []marketHeatCell
}
type MarketHeatTile struct {
Name string
Cap, Change float64
branch bool
}
func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeatmapOptions) ChartResult {
if opts.Scale < 0 || math.IsNaN(opts.Scale) || math.IsInf(opts.Scale, 0) || path == nil {
panic("mujica: MarketHeatmap Scale is negative")
}
scale := cmp.Or(opts.Scale, 3)
k := core.Tokens(c)
u := core.Density(c).Unit()
size := core.FontSize(c, theme.CaptionSize)
var res ChartResult
wrap := ui.Column(c).Gap(u * 2).MinWidth(0)
wrap.Children(func() {
res.value = chartPath(c, root.Name, path, wrap.IsDisabled())
node := marketHeatDescend(root, *path)
memo := ui.Local(wrap, "mujica.heat", func() MarketHeatMemo { return MarketHeatMemo{} })
if id := strings.Join(*path, "/") + "|" + ChartID(node.Children) + fmt.Sprint(opts.Version); id != memo.id {
marketHeatCheck(root)
memo.id, memo.Tiles, memo.Caps = id, nil, nil
memo.Rects = nil
for _, ch := range node.Children {
t := MarketHeatTile{ch.Name, ch.total(), ch.change(), len(ch.Children) > 0}
if t.Cap > 0 {
memo.Tiles = append(memo.Tiles, t)
}
}
slices.SortStableFunc(memo.Tiles, func(a, b MarketHeatTile) int { return cmp.Compare(b.Cap, a.Cap) })
for _, t := range memo.Tiles {
memo.Caps = append(memo.Caps, t.Cap)
}
}
tiles, caps := memo.Tiles, memo.Caps
color := func(ch float64) ui.Color {
return k.Surface.Mix(core.TrendColor(c, core.TrendSign(ch)), float32(0.15+0.85*min(math.Abs(ch)/scale, 1)))
}
ch := NewChart(c, opts.Frame)
spec := ChartSpec{Marks: len(tiles)}
spec.Readout = func(i int) string {
t := tiles[i]
s := fmt.Sprintf("%s · %s %s%% · %s", t.Name, core.TrendWord(c, core.TrendSign(t.Change)), data.StatisticSigned(t.Change, 2), data.StatisticFormat(t.Cap, 0))
if t.branch {
s += " · " + core.Msg(c, "chart.opens")
}
return s
}
spec.Pick = func(r ui.Rect, x, y float32) int {
memo.Layout(caps, r)
for i, t := range memo.Rects {
if t.Contains(x, y) {
return i
}
}
return -1
}
spec.Activate = func(i int) {
if tiles[i].branch {
*path = append(slices.Clone(*path), tiles[i].Name)
res.value = true
slog.Debug("mujica: market_heatmap drill", "path", strings.Join(*path, "/"))
}
}
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
memo.Layout(caps, r)
for _, cell := range memo.Small {
p.Fill(ui.Rect{X: r.X + cell.x, Y: r.Y + cell.y, W: 4, H: 4}, color(cell.change), 0)
}
for _, i := range memo.Visible {
t := memo.Rects[i]
t.X, t.Y = t.X+r.X, t.Y+r.Y
bg := color(tiles[i].Change)
p.Fill(t, bg, 2)
p.Stroke(t, k.Surface, 2, 2)
ink := ChartInk(k, bg)
if i == active {
p.Stroke(ui.Rect{X: t.X + 3, Y: t.Y + 3, W: t.W - 6, H: t.H - 6}, ink, 1, 2)
}
// Signed percents say the direction without color.
if t.W >= 40 && t.H >= 34 {
ChartText(p, chartFit(p, tiles[i].Name, t.W-12, size), t.X+t.W/2, t.Y+t.H/2-1, size, ink, 0.5, 1)
ChartText(p, data.StatisticSigned(tiles[i].Change, 2)+"%", t.X+t.W/2, t.Y+t.H/2+1, size, ink, 0.5, 0)
}
}
}
r, plot := ch.Build(spec)
if plot.Valid() && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 {
*path, res.value = (*path)[:len(*path)-1], true
}
res.Element, res.Point, res.changed, res.retry = r.Element, r.Point, r.changed, r.retry
marketHeatLegend(c, scale, color)
})
res.Element = wrap
return res
}
func marketHeatLegend(c *ui.Context, scale float64, color func(float64) ui.Color) {
k := core.Tokens(c)
size := core.FontSize(c, theme.CaptionSize)
steps := []float64{-scale, -scale / 2, 0, scale / 2, scale}
ui.Row(c).Gap(2).Children(func() {
for _, s := range steps {
ui.Column(c).Gap(2).AlignItems(ui.Center).Children(func() {
ui.Box(c).Size(44, 10).Background(color(s)).Radius(1)
ui.Text(c, data.StatisticSigned(s, 1)+"%").FontSize(size).TextColor(k.TextMuted).FontFeatures("tnum")
})
}
})
}
func (n HeatNode) total() float64 {
if len(n.Children) == 0 {
return n.Cap
}
s := 0.0
for _, ch := range n.Children {
s += ch.total()
}
return s
}
func (n HeatNode) change() float64 {
if len(n.Children) == 0 {
return n.Change
}
sum, w := 0.0, 0.0
for _, ch := range n.Children {
sum += ch.change() * ch.total()
w += ch.total()
}
if w == 0 {
return 0
}
return sum / w
}
// marketHeatCheck panics on unnamed, duplicate-sibling, negative or non-finite nodes.
func marketHeatCheck(n HeatNode) {
if n.Name == "" || n.Cap < 0 || math.IsNaN(n.Cap) || math.IsInf(n.Cap, 0) || math.IsNaN(n.Change) || math.IsInf(n.Change, 0) {
panic("mujica: heat nodes need a name, a finite cap ≥ 0 and a finite change")
}
seen := map[string]bool{}
for _, ch := range n.Children {
if seen[ch.Name] {
panic("mujica: heat node has two children named " + ch.Name)
}
seen[ch.Name] = true
marketHeatCheck(ch)
}
}
func marketHeatDescend(root HeatNode, path []string) HeatNode {
n := root
for _, name := range path {
i := slices.IndexFunc(n.Children, func(ch HeatNode) bool { return ch.Name == name })
if i < 0 {
panic("mujica: heatmap path has no node " + name)
}
n = n.Children[i]
}
return n
}
type marketHeatCell struct {
x, y float32
change float64
}
func (m *MarketHeatMemo) Layout(caps []float64, r ui.Rect) {
if m.Rects != nil && m.width == r.W && m.height == r.H {
return
}
m.width, m.height = r.W, r.H
m.Rects = squarify(caps, ui.Rect{W: r.W, H: r.H})
m.Visible, m.Small = nil, nil
type aggregate struct{ weight, change float64 }
bins := map[[2]int]aggregate{}
for i, t := range m.Rects {
if t.W >= 4 && t.H >= 4 {
m.Visible = append(m.Visible, i)
continue
}
key := [2]int{int((t.X + t.W/2) / 4), int((t.Y + t.H/2) / 4)}
a := bins[key]
a.weight += caps[i]
a.change += caps[i] * m.Tiles[i].Change
bins[key] = a
}
for key, a := range bins {
m.Small = append(m.Small, marketHeatCell{float32(key[0]) * 4, float32(key[1]) * 4, a.change / a.weight})
}
slices.SortFunc(m.Small, func(a, b marketHeatCell) int {
if a.y != b.y {
return cmp.Compare(a.y, b.y)
}
return cmp.Compare(a.x, b.x)
})
}