diff --git a/density_plot.go b/density_plot.go new file mode 100644 index 0000000..a62d0ed --- /dev/null +++ b/density_plot.go @@ -0,0 +1,144 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" +) + +// DensityPlotOptions configure a DensityPlot. +type DensityPlotOptions struct { + Frame ChartFrame + // Bandwidth is the Gaussian kernel's width; 0 uses Silverman's rule per group. + Bandwidth float64 + // Points is how many x values the curves are sampled at; 0 means 128. + Points int + Format func(v float64) string +} + +// densitySample is finite values, sorted out of NaN and infinities. +func densitySample(values []float64) []float64 { + var out []float64 + for _, v := range values { + if !math.IsNaN(v) && !math.IsInf(v, 0) { + out = append(out, v) + } + } + return out +} + +// densityBandwidth is Silverman's rule of thumb, or 1 for flat samples. +func densityBandwidth(v []float64) float64 { + n := float64(len(v)) + mean := 0.0 + for _, x := range v { + mean += x + } + mean /= n + sd := 0.0 + for _, x := range v { + sd += (x - mean) * (x - mean) + } + sd = math.Sqrt(sd / max(n-1, 1)) + if sd == 0 { + return 1 + } + return 1.06 * sd * math.Pow(n, -0.2) +} + +// densityKDE estimates the density of v at each x with a Gaussian kernel of width h. +func densityKDE(v []float64, h float64, xs []float64) []float64 { + out := make([]float64, len(xs)) + norm := 1 / (float64(len(v)) * h * math.Sqrt(2*math.Pi)) + for i, x := range xs { + s := 0.0 + for _, p := range v { + u := (x - p) / h + s += math.Exp(-0.5 * u * u) + } + out[i] = s * norm + } + return out +} + +// densityCurves samples every group's density on one x grid, three bandwidths past the data. +func densityCurves(groups []BoxGroup, bandwidth float64, points int) (xs []float64, series []ChartSeries) { + lo, hi := math.Inf(1), math.Inf(-1) + samples := make([][]float64, len(groups)) + widths := make([]float64, len(groups)) + for i, g := range groups { + samples[i] = densitySample(g.Values) + if len(samples[i]) == 0 { + continue + } + widths[i] = bandwidth + if widths[i] == 0 { + widths[i] = densityBandwidth(samples[i]) + } + for _, v := range samples[i] { + lo, hi = min(lo, v-3*widths[i]), max(hi, v+3*widths[i]) + } + } + if math.IsInf(lo, 1) { + return nil, nil + } + xs = make([]float64, points) + for i := range xs { + xs[i] = lo + (hi-lo)*float64(i)/float64(points-1) + } + for i, g := range groups { + ys := make([]float64, points) + if len(samples[i]) > 0 { + ys = densityKDE(samples[i], widths[i], xs) + } + series = append(series, ChartSeries{Name: g.Name, Values: ys}) + } + return xs, series +} + +// DensityPlot draws each group's kernel density curve over one shared axis. +func DensityPlot(c *ui.Context, groups []BoxGroup, opts DensityPlotOptions) ChartResult { + if opts.Bandwidth < 0 || opts.Points < 0 || opts.Points == 1 { + panic("mujica: DensityPlot needs Bandwidth ≥ 0 and Points of 0 or 2 or more") + } + points := opts.Points + if points == 0 { + points = 128 + } + ch := newChart(c, opts.Frame) + ids := "" + for _, g := range groups { + ids += g.Name + chartID(g.Values) + ";" + } + type curves struct { + xs []float64 + series []ChartSeries + } + cv := chartMemo(ch, "kde", fmt.Sprint(ids, opts.Bandwidth, points), func() curves { + xs, s := densityCurves(groups, opts.Bandwidth, points) + return curves{xs, s} + }) + n := len(cv.xs) + series := cv.series + if n == 0 { + series = nil + } + tops, bases := make([][]float64, len(series)), make([][]float64, len(series)) + for i, s := range series { + tops[i], bases[i] = s.Values, make([]float64, n) + } + var ax chartX + if n > 0 { + ax = chartMemo(ch, "x", ids+fmt.Sprint(opts.Bandwidth, points), func() chartX { return newChartX(n, cv.xs, nil) }) + } + lo, hi, ok := chartYRange(ch, series, true, nil) + if !ok { + n = 0 + } + format := opts.Format + if format == nil { + format = func(v float64) string { return fmt.Sprintf("%.3g", v) } + } + return chartLines(ch, chartLineData{series: series, tops: tops, bases: bases, ax: ax, lo: lo, hi: hi, n: n, format: format}) +} diff --git a/density_plot_test.go b/density_plot_test.go new file mode 100644 index 0000000..3cfee28 --- /dev/null +++ b/density_plot_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "math" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func densityGroups() []BoxGroup { + a, b := make([]float64, 200), make([]float64, 200) + for i := range a { + u := float64(i)/200 - 0.5 + a[i] = 40 + 30*u*math.Abs(u) + b[i] = 60 + 12*math.Sin(float64(i)) + } + return []BoxGroup{{Name: "Matins", Values: a}, {Name: "Vespers", Values: b}} +} + +func TestDensityKDEIntegratesToOne(t *testing.T) { + v := []float64{1, 2, 2, 3, 7} + xs := make([]float64, 2001) + for i := range xs { + xs[i] = -10 + 30*float64(i)/2000 + } + d := densityKDE(v, densityBandwidth(v), xs) + sum := 0.0 + for _, y := range d { + sum += y * 30 / 2000 + } + if math.Abs(sum-1) > 0.01 { + t.Fatalf("density integrates to %v", sum) + } + if bw := densityBandwidth([]float64{4, 4}); bw != 1 { + t.Fatalf("flat bandwidth %v", bw) + } +} + +func TestDensityPlotReadoutAndBandwidth(t *testing.T) { + bw := 0.0 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}, Bandwidth: bw, Points: 64}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Ages") + tt.Key(0, ui.KeyRight) + for range 31 { + tt.Key(0, ui.KeyRight) + } + first := chartReadout(tt) + if !strings.Contains(first, "Matins ") || !strings.Contains(first, "Vespers ") { + t.Fatalf("readout %q", first) + } + bw = 1 + tt.Frame() + if chartReadout(tt) == first { + t.Fatal("bandwidth did not change the curve") + } + if err := tt.Click("Vespers"); err != nil || strings.Contains(chartReadout(tt), "Vespers") { + t.Fatalf("legend did not hide a group: %q", chartReadout(tt)) + } +} + +func TestDensityPlotBadAndEmpty(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return DensityPlot(c, []BoxGroup{{Name: "a", Values: []float64{math.NaN()}}}, DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}}) + }} + if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") { + t.Fatal("NaN sample is not empty") + } + defer func() { + if recover() == nil { + t.Fatal("no panic for Points 1") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "x"}, Points: 1}) + }, 300, 300) +} + +func TestDensityPlotSnapshot(t *testing.T) { + snapshot(t, "density_plot", 520, 340, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}}) + }) + }) +} diff --git a/messages_charts2.go b/messages_charts2.go index 35545ad..aa8fced 100644 --- a/messages_charts2.go +++ b/messages_charts2.go @@ -2,10 +2,11 @@ package mujica func init() { addMessages(map[string][2]string{ - "chart_legend.more": {"+%d more", "另有 %d 项"}, - "chart_legend.fewer": {"Show fewer", "收起"}, - "chart_brush.window": {"%s: %s to %s", "%s:%s 至 %s"}, - "chart_brush.start": {"Range start %s", "区间起点 %s"}, - "chart_brush.end": {"Range end %s", "区间终点 %s"}, + "chart_legend.more": {"+%d more", "另有 %d 项"}, + "chart_legend.fewer": {"Show fewer", "收起"}, + "chart_brush.window": {"%s: %s to %s", "%s:%s 至 %s"}, + "chart_brush.start": {"Range start %s", "区间起点 %s"}, + "chart_brush.end": {"Range end %s", "区间终点 %s"}, + "violin_plot.readout": {"%s · median %s · quartiles %s–%s · %d values", "%s · 中位数 %s · 四分位 %s–%s · %d 个值"}, }) } diff --git a/testdata/snapshots/darwin/density_plot-dark.png b/testdata/snapshots/darwin/density_plot-dark.png new file mode 100644 index 0000000..a3cb242 Binary files /dev/null and b/testdata/snapshots/darwin/density_plot-dark.png differ diff --git a/testdata/snapshots/darwin/density_plot-light.png b/testdata/snapshots/darwin/density_plot-light.png new file mode 100644 index 0000000..9059fc0 Binary files /dev/null and b/testdata/snapshots/darwin/density_plot-light.png differ diff --git a/testdata/snapshots/darwin/violin_plot-dark.png b/testdata/snapshots/darwin/violin_plot-dark.png new file mode 100644 index 0000000..e271f52 Binary files /dev/null and b/testdata/snapshots/darwin/violin_plot-dark.png differ diff --git a/testdata/snapshots/darwin/violin_plot-light.png b/testdata/snapshots/darwin/violin_plot-light.png new file mode 100644 index 0000000..c68f1b2 Binary files /dev/null and b/testdata/snapshots/darwin/violin_plot-light.png differ diff --git a/violin_plot.go b/violin_plot.go new file mode 100644 index 0000000..00323fc --- /dev/null +++ b/violin_plot.go @@ -0,0 +1,150 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ViolinPlotOptions configure a ViolinPlot. +type ViolinPlotOptions struct { + Frame ChartFrame + // Bandwidth is the kernel width; 0 uses Silverman's rule per group. + Bandwidth float64 + Format func(v float64) string +} + +// violinShape is one group's density outline over its value range, and its quartiles. +type violinShape struct { + ys, dens []float64 + stats boxStats +} + +// violinPoints is how many values each outline is sampled at. +const violinPoints = 64 + +// ViolinPlot mirrors each group's density around its middle, with the median and quartiles marked. +func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartResult { + if opts.Bandwidth < 0 { + panic("mujica: ViolinPlot Bandwidth must be ≥ 0") + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + ids := "" + for _, g := range groups { + if g.Name == "" { + panic("mujica: ViolinPlot groups need names") + } + ids += chartID(g.Values) + ";" + } + shapes := chartMemo(ch, "violins", fmt.Sprint(ids, opts.Bandwidth), func() []violinShape { + out := make([]violinShape, len(groups)) + for i, g := range groups { + v := densitySample(g.Values) + if len(v) == 0 { + continue + } + h := opts.Bandwidth + if h == 0 { + h = densityBandwidth(v) + } + s := boxSummary(v) + lo, hi := s.lo, s.hi + for _, o := range s.outliers { + lo, hi = min(lo, o), max(hi, o) + } + ys := make([]float64, violinPoints) + for j := range ys { + ys[j] = lo + (hi-lo)*float64(j)/float64(violinPoints-1) + } + out[i] = violinShape{ys: ys, dens: densityKDE(v, h, ys), stats: s} + } + return out + }) + lo, hi := math.Inf(1), math.Inf(-1) + for _, s := range shapes { + if s.stats.n > 0 { + lo, hi = min(lo, s.ys[0]), max(hi, s.ys[len(s.ys)-1]) + } + } + var step float64 + n := len(groups) + if math.IsInf(lo, 1) { + n = 0 + } else { + lo, hi, step = internal.Nice(lo, hi, 4) + } + val := func(v float64) string { return chartValue(c, v, opts.Format) } + geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) { + plot := chartInset(r, 52, 26) + return plot, internal.Band{N: len(groups), R0: plot.X, R1: plot.X + plot.W, Pad: 0.15}, + internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)} + } + spec := ChartSpec{Marks: n} + spec.Readout = func(i int) string { + s := shapes[i].stats + if s.n == 0 { + return groups[i].Name + " · " + msg(c, "chart.missing") + } + return fmt.Sprintf(msg(c, "violin_plot.readout"), groups[i].Name, val(s.median), val(s.q1), val(s.q3), s.n) + } + spec.Pick = func(r ui.Rect, x, y float32) int { + plot, band, _ := geom(r) + if !plot.Contains(x, y) { + return -1 + } + return min(len(groups)-1, int((x-plot.X)/band.Step())) + } + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, band, ys := geom(r) + internal.AxisY(p, plot, ys, internal.Ticks(lo, hi, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k) + var xs []float32 + var names []string + for i, g := range groups { + xs, names = append(xs, band.Center(i)), append(names, g.Name) + } + internal.AxisX(p, plot, xs, names, size, k) + for i, s := range shapes { + if s.stats.n == 0 { + continue + } + // Each violin fills its band at its own widest point. + cx, half, top := band.Center(i), band.Width()/2, 0.0 + for _, d := range s.dens { + top = max(top, d) + } + var outline ui.Path + for j, y := range s.ys { + x := cx + half*float32(s.dens[j]/top) + if j == 0 { + outline.MoveTo(x, ys.Map(y)) + } else { + outline.LineTo(x, ys.Map(y)) + } + } + for j := len(s.ys) - 1; j >= 0; j-- { + outline.LineTo(cx-half*float32(s.dens[j]/top), ys.Map(s.ys[j])) + } + outline.Close() + col := pal[i%len(pal)] + p.FillPath(&outline, col.Alpha(0.22)) + w := float32(1.5) + if i == active { + w = 3 + } + p.StrokePath(&outline, w, col) + // The quartile bar and a diamond at the median read without color. + p.Fill(ui.Rect{X: cx - 3, Y: ys.Map(s.stats.q3), W: 6, H: max(ys.Map(s.stats.q1)-ys.Map(s.stats.q3), 1)}, k.Text, 1) + p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, ys.Map(s.stats.median)), 10), k.Surface) + p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, ys.Map(s.stats.median)), 7), k.Text) + } + } + res, _ := ch.build(spec) + return res +} diff --git a/violin_plot_test.go b/violin_plot_test.go new file mode 100644 index 0000000..3b8a573 --- /dev/null +++ b/violin_plot_test.go @@ -0,0 +1,45 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestViolinPlotReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ViolinPlot(c, append(densityGroups(), boxGroups[0]), ViolinPlotOptions{Frame: ChartFrame{Label: "Hymns"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Hymns") + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Choir · median 5.5 · quartiles 3.25–7.75 · 10 values" { + t.Fatalf("readout %q", got) + } + tt.Key(0, ui.KeyEscape) + b := h.res.Element.Bounds() + tt.Move(b.X+52+(b.W-64)/6, b.Y+120) + if h.res.Point != 0 { + t.Fatalf("hover %d", h.res.Point) + } +} + +func TestViolinPlotBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an unnamed group") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ViolinPlot(c, []BoxGroup{{Values: []float64{1}}}, ViolinPlotOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestViolinPlotSnapshot(t *testing.T) { + snapshot(t, "violin_plot", 520, 320, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ViolinPlot(c, densityGroups(), ViolinPlotOptions{Frame: ChartFrame{Label: "Hymns"}}) + }) + }) +}