diff --git a/chord_diagram.go b/chord_diagram.go new file mode 100644 index 0000000..b6979b7 --- /dev/null +++ b/chord_diagram.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChordDiagramOptions configure a ChordDiagram. +type ChordDiagramOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// chordLayout is each group's arc and each pair's chord ends, as angles. +type chordLayout struct { + arcs [][2]float64 + sub [][][2]float64 + pairs [][2]int + total float64 +} + +// chordGap is the angle left between group arcs. +const chordGap = 0.04 + +func chordPlace(matrix [][]float64) chordLayout { + n := len(matrix) + l := chordLayout{arcs: make([][2]float64, n), sub: make([][][2]float64, n)} + sums := make([]float64, n) + for i, row := range matrix { + if len(row) != n { + panic("mujica: ChordDiagram matrix must be square") + } + for j, v := range row { + if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { + panic("mujica: ChordDiagram values must be finite and ≥ 0") + } + sums[i] += v + if j > i && v+matrix[j][i] > 0 { + l.pairs = append(l.pairs, [2]int{i, j}) + } + } + l.total += sums[i] + } + if l.total == 0 { + return l + } + span := 2*math.Pi - chordGap*float64(n) + a := 0.0 + for i := range n { + l.arcs[i] = [2]float64{a, a + span*sums[i]/l.total} + l.sub[i] = make([][2]float64, n) + at := a + for j := range n { + w := span * matrix[i][j] / l.total + l.sub[i][j] = [2]float64{at, at + w} + at += w + } + a = l.arcs[i][1] + chordGap + } + return l +} + +// ChordDiagram joins group arcs by chords; reading a group highlights them. +func ChordDiagram(c *ui.Context, names []string, matrix [][]float64, opts ChordDiagramOptions) ChartResult { + if len(names) != len(matrix) { + panic("mujica: ChordDiagram needs a name per matrix row") + } + ch := newChart(c, opts.Frame) + l := chartMemo(ch, "chords", chartID(matrix), func() chordLayout { return chordPlace(matrix) }) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + val := func(v float64) string { return chartValue(c, v, opts.Format) } + n := len(names) + len(l.pairs) + if l.total == 0 { + n = 0 + } + geom := func(r ui.Rect) (cx, cy, rad float32) { + return r.X + r.W/2, r.Y + r.H/2, min(r.W, r.H)/2 - 30 + } + spec := ChartSpec{Marks: n} + spec.Readout = func(i int) string { + if i < len(names) { + out, in := 0.0, 0.0 + for j := range names { + out += matrix[i][j] + in += matrix[j][i] + } + return names[i] + " · " + msg(c, "chord_diagram.out") + " " + val(out) + " · " + msg(c, "chord_diagram.in") + " " + val(in) + } + p := l.pairs[i-len(names)] + return names[p[0]] + " → " + names[p[1]] + " " + val(matrix[p[0]][p[1]]) + " · " + names[p[1]] + " → " + names[p[0]] + " " + val(matrix[p[1]][p[0]]) + } + spec.Pick = func(r ui.Rect, x, y float32) int { + cx, cy, rad := geom(r) + d := float32(math.Hypot(float64(x-cx), float64(y-cy))) + a := math.Atan2(float64(x-cx), float64(cy-y)) + if a < 0 { + a += 2 * math.Pi + } + if d >= rad-2 && d <= rad+14 { + for i, arc := range l.arcs { + if a >= arc[0] && a < arc[1] { + return i + } + } + } + return -1 + } + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { + cx, cy, rad := geom(r) + lit := func(pair [2]int) bool { + switch { + case active < 0: + return true + case active < len(names): + return pair[0] == active || pair[1] == active + } + return l.pairs[active-len(names)] == pair + } + for pi, pair := range l.pairs { + i, j := pair[0], pair[1] + s, t := l.sub[i][j], l.sub[j][i] + var path ui.Path + internal.Arc(&path, cx, cy, rad, s[0], s[1], true) + tx, ty := internal.Polar(cx, cy, rad, t[0]) + path.QuadTo(cx, cy, tx, ty) + internal.Arc(&path, cx, cy, rad, t[0], t[1], false) + sx, sy := internal.Polar(cx, cy, rad, s[0]) + path.QuadTo(cx, cy, sx, sy).Close() + alpha := float32(0.45) + if !lit(pair) { + alpha = 0.08 + } + p.FillPath(&path, pal[i%len(pal)].Alpha(alpha)) + if active == len(names)+pi { + p.StrokePath(&path, 1.5, k.Text) + } + } + for i, arc := range l.arcs { + grow := float32(0) + if i == active { + grow = 4 + } + p.FillPath(internal.Sector(cx, cy, rad+2, rad+12+grow, arc[0], arc[1]), pal[i%len(pal)]) + if arc[1]-arc[0] > 0.15 { + lx, ly := internal.Polar(cx, cy, rad+20, (arc[0]+arc[1])/2) + ax := 0.5 - 0.5*float32(math.Sin((arc[0]+arc[1])/2)) + chartText(p, names[i], lx, ly, size, k.Text, ax, 0.5+0.5*float32(math.Cos((arc[0]+arc[1])/2))) + } + } + } + res, _ := ch.build(spec) + return res +} diff --git a/chord_diagram_test.go b/chord_diagram_test.go new file mode 100644 index 0000000..e513e1d --- /dev/null +++ b/chord_diagram_test.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +var chordNames = []string{"Nave", "Choir", "Crypt", "Cloister"} +var chordMatrix = [][]float64{{0, 5, 2, 3}, {4, 0, 1, 0}, {1, 2, 0, 6}, {2, 0, 3, 0}} + +func TestChordPlace(t *testing.T) { + l := chordPlace(chordMatrix) + if l.total != 29 || len(l.pairs) != 5 { + t.Fatalf("total %v pairs %v", l.total, l.pairs) + } + span := 2*math.Pi - 4*chordGap + if got := l.arcs[0][1] - l.arcs[0][0]; math.Abs(got-span*10/29) > 1e-9 { + t.Fatalf("Nave arc %v", got) + } + if l.sub[0][1][1]-l.sub[0][1][0] <= 0 || l.sub[1][3][1] != l.sub[1][3][0] { + t.Fatal("sub-arcs not sized by the matrix") + } +} + +func TestChordDiagramReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ChordDiagram(c, chordNames, chordMatrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Paths"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Paths") + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Nave · out 10 · in 7" { + t.Fatalf("group %q", got) + } + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Crypt → Cloister 6 · Cloister → Crypt 3" { + t.Fatalf("chord %q", got) + } + tt.Key(0, ui.KeyEscape) + b := h.res.Element.Bounds() + cx, cy, rad := b.X+b.W/2, b.Y+120, float32(240)/2-30 + tt.Move(cx+(rad+6)*0.5, cy-(rad+6)*0.866) // 30°: inside the first arc + if h.res.Point != 0 { + t.Fatalf("hover %d", h.res.Point) + } +} + +func TestChordDiagramBad(t *testing.T) { + for name, m := range map[string][][]float64{"ragged": {{1, 2}, {1}}, "negative": {{0, -1}, {1, 0}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ChordDiagram(c, []string{"a", "b"}, m, ChordDiagramOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) + }() + } +} + +func TestChordDiagramSnapshot(t *testing.T) { + snapshot(t, "chord_diagram", 520, 340, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ChordDiagram(c, chordNames, chordMatrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Paths"}}) + }) + }) +} diff --git a/messages_charts2.go b/messages_charts2.go index aa8fced..bc2e4d9 100644 --- a/messages_charts2.go +++ b/messages_charts2.go @@ -2,11 +2,14 @@ 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"}, - "violin_plot.readout": {"%s · median %s · quartiles %s–%s · %d values", "%s · 中位数 %s · 四分位 %s–%s · %d 个值"}, + "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 个值"}, + "parallel_coordinates.filtered": {"filtered out", "已筛除"}, + "chord_diagram.out": {"out", "流出"}, + "chord_diagram.in": {"in", "流入"}, }) } diff --git a/parallel_coordinates.go b/parallel_coordinates.go new file mode 100644 index 0000000..6381b05 --- /dev/null +++ b/parallel_coordinates.go @@ -0,0 +1,196 @@ +package mujica + +import ( + "log/slog" + "maps" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ParallelCoordinatesOptions configure a ParallelCoordinates chart. +type ParallelCoordinatesOptions struct { + Frame ChartFrame + // Names labels each row in the readout; nil uses the row number. + Names []string + Format func(v float64) string +} + +// pcDrag is a brush being dragged on one axis. +type pcDrag struct { + axis int + y0, y1 float32 +} + +// ParallelCoordinates draws rows across parallel axes; dragging an axis filters. +func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []string, rows [][]float64, opts ParallelCoordinatesOptions) ChartResult { + if filters == nil || len(axes) < 2 || opts.Names != nil && len(opts.Names) != len(rows) { + panic("mujica: ParallelCoordinates needs filters, two or more axes and a name per row") + } + for _, r := range rows { + if len(r) != len(axes) { + panic("mujica: ParallelCoordinates rows need one value per axis") + } + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + ink := internal.Palette(k)[0] + type scales struct{ lo, hi []float64 } + sc := chartMemo(ch, "scales", chartID(rows), func() scales { + s := scales{lo: make([]float64, len(axes)), hi: make([]float64, len(axes))} + for a := range axes { + lo, hi := math.Inf(1), math.Inf(-1) + for _, r := range rows { + if !math.IsNaN(r[a]) { + lo, hi = min(lo, r[a]), max(hi, r[a]) + } + } + if math.IsInf(lo, 1) { + lo, hi = 0, 1 + } + s.lo[a], s.hi[a], _ = internal.Nice(lo, hi, 4) + } + return s + }) + keep := func(r []float64) bool { + for a, name := range axes { + if f, ok := (*filters)[name]; ok && (math.IsNaN(r[a]) || r[a] < f[0] || r[a] > f[1]) { + return false + } + } + return true + } + geom := func(r ui.Rect) (ui.Rect, func(a int) float32, func(a int, v float64) float32) { + plot := ui.Rect{X: r.X + 36, Y: r.Y + 24, W: r.W - 72, H: r.H - 32} + ax := func(a int) float32 { return plot.X + plot.W*float32(a)/float32(len(axes)-1) } + y := func(a int, v float64) float32 { + return plot.Y + plot.H*float32(1-(v-sc.lo[a])/(sc.hi[a]-sc.lo[a])) + } + return plot, ax, y + } + name := func(i int) string { + if opts.Names != nil { + return opts.Names[i] + } + return "#" + chartValue(c, float64(i+1), nil) + } + spec := ChartSpec{Marks: len(rows)} + spec.Readout = func(i int) string { + parts := []string{name(i)} + for a, n := range axes { + parts = append(parts, n+" "+chartValue(c, rows[i][a], opts.Format)) + } + if !keep(rows[i]) { + parts = append(parts, msg(c, "parallel_coordinates.filtered")) + } + return strings.Join(parts, " · ") + } + spec.Pick = func(r ui.Rect, x, y float32) int { + _, ax, ys := geom(r) + best, dist := -1, float32(8) + for i, row := range rows { + for a := 0; a+1 < len(axes); a++ { + x0, x1 := ax(a), ax(a+1) + if x < x0 || x > x1 || math.IsNaN(row[a]) || math.IsNaN(row[a+1]) { + continue + } + t := (x - x0) / (x1 - x0) + if d := abs32(ys(a, row[a]) + t*(ys(a+1, row[a+1])-ys(a, row[a])) - y); d < dist { + best, dist = i, d + } + } + } + return best + } + drag := ui.Local(ch.res.Element, "mujica.pc.drag", func() pcDrag { return pcDrag{axis: -1} }) + spec.Input = func(plot *ui.Element, r ui.Rect) { + box, ax, _ := geom(r) + px, py, _ := plot.PointerPosition() + _, _, held := plot.Dragged() + switch { + case held && drag.axis < 0: + for a := range axes { + if abs32(px-ax(a)) <= 12 { + *drag = pcDrag{axis: a, y0: py, y1: py} + } + } + case held: + drag.y1 = max(box.Y, min(box.Y+box.H, py)) + case drag.axis >= 0: + a := drag.axis + m := maps.Clone(*filters) + if m == nil { + m = map[string][2]float64{} + } + if abs32(drag.y1-drag.y0) < 4 { + delete(m, axes[a]) + } else { + v := func(y float32) float64 { + return sc.lo[a] + float64((box.Y+box.H-y)/box.H)*(sc.hi[a]-sc.lo[a]) + } + m[axes[a]] = [2]float64{min(v(drag.y0), v(drag.y1)), max(v(drag.y0), v(drag.y1))} + } + *filters, ch.res.value = m, true + *drag = pcDrag{axis: -1} + slog.Debug("mujica: parallel_coordinates brush", "axis", axes[a], "filters", len(m)) + } + } + shownDrag := *drag + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, ax, ys := geom(r) + line := func(row []float64, w float32, col ui.Color) { + var path ui.Path + started := false + for a := range axes { + if math.IsNaN(row[a]) { + started = false + continue + } + x, y := ax(a), ys(a, row[a]) + if started { + path.LineTo(x, y) + } else { + path.MoveTo(x, y) + } + started = true + } + p.StrokePath(&path, w, col) + } + for _, row := range rows { + if !keep(row) { + line(row, 1, k.Border) + } + } + for _, row := range rows { + if keep(row) { + line(row, 1.25, ink.Alpha(0.6)) + } + } + if active >= 0 { + line(rows[active], 3, k.Text) + } + for a, n := range axes { + x := internal.Snap(p, ax(a)) + p.Line(x, plot.Y, x, plot.Y+plot.H, 1.5, k.ControlBorder) + if f, ok := (*filters)[n]; ok { + y0, y1 := max(plot.Y, ys(a, f[1])), min(plot.Y+plot.H, ys(a, f[0])) + p.Fill(ui.Rect{X: x - 6, Y: y0, W: 12, H: max(y1-y0, 1)}, k.Selection, 2) + p.Stroke(ui.Rect{X: x - 6, Y: y0, W: 12, H: max(y1-y0, 1)}, k.AccentText, 2, 1.5) + } + chartText(p, n, x, plot.Y-8, size, k.Text, 0.5, 1) + chartText(p, internal.FormatTick(sc.hi[a], (sc.hi[a]-sc.lo[a])/4), x+10, plot.Y, size, k.TextMuted, 0, 0) + chartText(p, internal.FormatTick(sc.lo[a], (sc.hi[a]-sc.lo[a])/4), x+10, plot.Y+plot.H, size, k.TextMuted, 0, 1) + } + if shownDrag.axis >= 0 { + x := ax(shownDrag.axis) + p.Stroke(ui.Rect{X: x - 6, Y: min(shownDrag.y0, shownDrag.y1), W: 12, H: abs32(shownDrag.y1 - shownDrag.y0)}, k.AccentText, 2, 1.5) + } + } + res, _ := ch.build(spec) + return res +} diff --git a/parallel_coordinates_test.go b/parallel_coordinates_test.go new file mode 100644 index 0000000..fbb1a32 --- /dev/null +++ b/parallel_coordinates_test.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +var pcAxes = []string{"Age", "Hymns", "Vigils"} +var pcRows = [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}, {65, 1, math.NaN()}} +var pcNames = []string{"Agnès", "Clare", "Hild", "Bede"} + +func TestParallelCoordinatesReadoutAndBrush(t *testing.T) { + filters := map[string][2]float64{} + values := 0 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + r := ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}, Names: pcNames}) + if r.ValueChanged() { + values++ + } + return r + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Monks") + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Bede · Age 65 · Hymns 1 · Vigils no value" { + t.Fatalf("readout %q", got) + } + b := h.res.Element.Bounds() + plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32} + // Age runs 20..80 on the first axis: brush 30..55. + y := func(v float32) float32 { return plot.Y + plot.H*(1-(v-20)/60) } + tt.Press(plot.X, y(55)) + tt.Move(plot.X, y(40)) + tt.Move(plot.X, y(30)) + tt.Release(plot.X, y(30)) + f, ok := filters["Age"] + if !ok || math.Abs(f[0]-30) > 0.5 || math.Abs(f[1]-55) > 0.5 || values != 1 { + t.Fatalf("brush %v %v (%d)", f, ok, values) + } + tt.Key(0, ui.KeyHome) + if got := chartReadout(tt); got != "Agnès · Age 20 · Hymns 3 · Vigils 9 · filtered out" { + t.Fatalf("filtered readout %q", got) + } + tt.Press(plot.X, y(45)) // a click on the axis clears it + tt.Release(plot.X, y(45)) + if _, ok := filters["Age"]; ok { + t.Fatal("click did not clear the brush") + } +} + +func TestParallelCoordinatesHoverAndBad(t *testing.T) { + filters := map[string][2]float64{} + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32} + // A quarter into the first gap, row 2 (Age 50 of 20..80, Hymns 5 of 0..8) is 0.53 up. + tt.Move(plot.X+plot.W/8, plot.Y+plot.H*(1-0.53125)) + if h.res.Point != 2 { + t.Fatalf("hover picked %d", h.res.Point) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for a short row") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ParallelCoordinates(c, &filters, pcAxes, [][]float64{{1}}, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestParallelCoordinatesSnapshot(t *testing.T) { + snapshot(t, "parallel_coordinates", 520, 320, func(c *ui.Context) { + filters := map[string][2]float64{"Hymns": {2, 9}} + ui.Column(c).Padding(16).Children(func() { + ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}, Names: pcNames}) + }) + }) +} diff --git a/testdata/snapshots/darwin/chord_diagram-dark.png b/testdata/snapshots/darwin/chord_diagram-dark.png new file mode 100644 index 0000000..0b369b7 Binary files /dev/null and b/testdata/snapshots/darwin/chord_diagram-dark.png differ diff --git a/testdata/snapshots/darwin/chord_diagram-light.png b/testdata/snapshots/darwin/chord_diagram-light.png new file mode 100644 index 0000000..c7a36e9 Binary files /dev/null and b/testdata/snapshots/darwin/chord_diagram-light.png differ diff --git a/testdata/snapshots/darwin/parallel_coordinates-dark.png b/testdata/snapshots/darwin/parallel_coordinates-dark.png new file mode 100644 index 0000000..b5039ea Binary files /dev/null and b/testdata/snapshots/darwin/parallel_coordinates-dark.png differ diff --git a/testdata/snapshots/darwin/parallel_coordinates-light.png b/testdata/snapshots/darwin/parallel_coordinates-light.png new file mode 100644 index 0000000..59f768c Binary files /dev/null and b/testdata/snapshots/darwin/parallel_coordinates-light.png differ