package mujica import ( "fmt" "math" "github.com/egoist/mygo/ui" "mujicaui/internal" "mujicaui/theme" ) // ChartSlice is one share of a PieChart or FunnelChart. type ChartSlice struct { Label string Value float64 } // PieChartOptions configure a PieChart. type PieChartOptions struct { Frame ChartFrame // Donut cuts out the middle and writes the total there. Donut bool // MinAngle merges two or more narrower slices into Other. MinAngle float64 Format func(v float64) string } // PieChart shows shares as slices labeled with percents. func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartResult { if opts.MinAngle < 0 || opts.MinAngle >= 360 { panic("mujica: PieChart MinAngle must be in [0, 360)") } ch := newChart(c, opts.Frame) k := Tokens(c) pal := internal.Palette(k) size := FontSize(c, theme.CaptionSize) parts := chartMemo(ch, "parts", [2]any{chartID(slices), opts.MinAngle}, func() []ChartSlice { return pieMerge(slices, opts.MinAngle, msg(c, "pie_chart.other")) }) total := 0.0 for _, s := range parts { total += s.Value } n := len(parts) if total == 0 { n = 0 } keys := make([]ChartLegendItem, len(parts)) for i, s := range parts { keys[i] = ChartLegendItem{Name: s.Label, Color: pal[i%len(pal)], Shape: i} } // angle returns slice i's start and end, clockwise. angle := func(i int) (float64, float64) { a := 0.0 for j := range i { a += parts[j].Value } return 2 * math.Pi * a / total, 2 * math.Pi * (a + parts[i].Value) / total } geom := func(r ui.Rect) (cx, cy, r0, r1 float32) { r1 = min(r.W, r.H)/2 - 8 if opts.Donut { r0 = r1 * 0.58 } return r.X + r.W/2, r.Y + r.H/2, r0, r1 } share := func(i int) string { return fmt.Sprintf("%.1f%%", 100*parts[i].Value/total) } spec := ChartSpec{Marks: n, Legend: keys} spec.Readout = func(i int) string { return parts[i].Label + " · " + chartValue(c, parts[i].Value, opts.Format) + " · " + share(i) } spec.Pick = func(r ui.Rect, x, y float32) int { cx, cy, r0, r1 := geom(r) d := float32(math.Hypot(float64(x-cx), float64(y-cy))) if d < r0 || d > r1 { return -1 } a := math.Atan2(float64(x-cx), float64(cy-y)) if a < 0 { a += 2 * math.Pi } for i := range parts { if a0, a1 := angle(i); a >= a0 && a < a1 { return i } } return -1 } spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { cx, cy, r0, r1 := geom(r) for i := range parts { a0, a1 := angle(i) if a1 <= a0 { continue } grow := float32(0) if i == active { grow = 6 } p.FillPath(internal.Sector(cx, cy, r0, r1+grow, a0, a1), keys[i].Color) p.StrokePath(internal.Sector(cx, cy, r0, r1+grow, a0, a1), 1.5, k.Surface) // Wide slices carry their shape and percent. if a1-a0 > 0.38 { mx, my := internal.Polar(cx, cy, (r0+r1)/2+float32(chartBool(r0 == 0))*r1*0.12, (a0+a1)/2) ink := chartInk(k, keys[i].Color) internal.Marker(p, i, mx, my-8, 9, ink) // The percent shows where the ring is wide enough to hold it. if w, _ := p.MeasureText(0, ui.Span{Text: share(i), Size: size}); r1-r0 >= w+6 { chartText(p, share(i), mx, my+2, size, ink, 0.5, 0) } } } if opts.Donut { chartText(p, chartValue(c, total, opts.Format), cx, cy, FontSize(c, theme.H3Size), k.Text, 0.5, 0.75) chartText(p, msg(c, "pie_chart.total"), cx, cy+4, size, k.TextMuted, 0.5, 0) } } res, _ := ch.build(spec) return res } // pieMerge folds two or more narrow slices into other. func pieMerge(slices []ChartSlice, minAngle float64, other string) []ChartSlice { total := 0.0 for _, s := range slices { if s.Value < 0 || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) || s.Label == "" { panic("mujica: PieChart slices need a label and a finite value ≥ 0") } total += s.Value } var keep []ChartSlice small, rest := 0, 0.0 for _, s := range slices { if total > 0 && s.Value/total*360 < minAngle { small++ rest += s.Value } } for _, s := range slices { if small < 2 || total == 0 || s.Value/total*360 >= minAngle { keep = append(keep, s) } } if small >= 2 { keep = append(keep, ChartSlice{Label: other, Value: rest}) } return keep }