wip markets

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:54:26 -04:00
1 parent fbd584b6aa
commit b2fafd99ce
21 files changed
+2836

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
package mujica
import (
"cmp"
"fmt"
"log/slog"
"math"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// HeatNode is a sector or a stock: market cap sizes it, Change (percent) colors it.
type HeatNode struct {
Name string
Cap float64
Change float64
Children []HeatNode
}
// MarketHeatmapOptions configure a MarketHeatmap.
type MarketHeatmapOptions struct {
Frame ChartFrame
// Scale is the change in percent at full color; zero means 3.
Scale float64
}
type marketHeatTile struct {
name string
cap, change float64
branch bool
}
// MarketHeatmap tiles sectors and stocks by market cap, colored by change; branches drill in.
func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeatmapOptions) ChartResult {
if opts.Scale < 0 {
panic("mujica: MarketHeatmap Scale is negative")
}
scale := cmp.Or(opts.Scale, 3)
marketHeatCheck(root)
k := Tokens(c)
u := Density(c).Unit()
size := 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)
var tiles []marketHeatTile
for _, ch := range node.Children {
t := marketHeatTile{ch.Name, ch.total(), ch.change(), len(ch.Children) > 0}
if t.cap > 0 {
tiles = append(tiles, t)
}
}
slices.SortStableFunc(tiles, func(a, b marketHeatTile) int { return cmp.Compare(b.cap, a.cap) })
caps := make([]float64, len(tiles))
for i, t := range tiles {
caps[i] = t.cap
}
color := func(ch float64) ui.Color {
return k.Surface.Mix(trendColor(c, trendSign(ch)), float32(0.15+0.85*min(math.Abs(ch)/scale, 1)))
}
ch := newChart(c, opts.Frame)
spec := chartSpec{n: len(tiles)}
spec.readout = func(i int) string {
t := tiles[i]
s := fmt.Sprintf("%s · %s %s%% · %s", t.name, trendWord(c, trendSign(t.change)), statisticSigned(t.change, 2), statisticFormat(t.cap, 0))
if t.branch {
s += " · " + msg(c, "chart.opens")
}
return s
}
spec.pick = func(r ui.Rect, x, y float32) int {
for i, t := range squarify(caps, r) {
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) {
for i, t := range squarify(caps, r) {
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, 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 != nil && !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
}
// marketHeatLegend is the color scale from −Scale to +Scale with signed labels.
func marketHeatLegend(c *ui.Context, scale float64, color func(float64) ui.Color) {
k := Tokens(c)
size := 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, 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
}
// change is the node's own change, or its children's cap-weighted mean.
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
}