wip-c2-3
This commit is contained in:
9 files changed
+434
-5
No files matched your search
+144
@@ -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})
|
||||
}
|
||||
@@ -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"}})
|
||||
})
|
||||
})
|
||||
}
|
||||
+6
-5
@@ -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 个值"},
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+150
@@ -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
|
||||
}
|
||||
@@ -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"}})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user