wip-c2-4
This commit is contained in:
9 files changed
+520
-6
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
+9
-6
@@ -2,11 +2,14 @@ package mujica
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
addMessages(map[string][2]string{
|
addMessages(map[string][2]string{
|
||||||
"chart_legend.more": {"+%d more", "另有 %d 项"},
|
"chart_legend.more": {"+%d more", "另有 %d 项"},
|
||||||
"chart_legend.fewer": {"Show fewer", "收起"},
|
"chart_legend.fewer": {"Show fewer", "收起"},
|
||||||
"chart_brush.window": {"%s: %s to %s", "%s:%s 至 %s"},
|
"chart_brush.window": {"%s: %s to %s", "%s:%s 至 %s"},
|
||||||
"chart_brush.start": {"Range start %s", "区间起点 %s"},
|
"chart_brush.start": {"Range start %s", "区间起点 %s"},
|
||||||
"chart_brush.end": {"Range end %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 个值"},
|
"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", "流入"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Reference in new issue
Block a user