gonum / plot

A repository for plotting and visualizing data
BSD 3-Clause "New" or "Revised" License
2.72k stars 202 forks source link

plotter: Labels x/y-offsets are in vg.Length and it's inconvenient #710

Closed sbinet closed 3 years ago

sbinet commented 3 years ago

while working on the glyphboxes issue, I've noticed:

package plotter // import "gonum.org/v1/plot/plotter"

// Labels implements the Plotter interface,
// drawing a set of labels at specified points.
type Labels struct {
        XYs

        // Labels is the set of labels corresponding
        // to each point.
        Labels []string

        // TextStyle is the style of the label text. Each label
        // can have a different text style.
        TextStyle []text.Style

        // XOffset and YOffset are added directly to the final
        // label X and Y location respectively.
        XOffset, YOffset vg.Length
}

the {X,Y}Offset fields are in vg.Length (so, centimeters and what not). it's convenient when drawing the labels:

// Plot implements the Plotter interface, drawing labels.
func (l *Labels) Plot(c draw.Canvas, p *plot.Plot) {
        trX, trY := p.Transforms(&c)
        for i, label := range l.Labels {
                pt := vg.Point{X: trX(l.XYs[i].X), Y: trY(l.XYs[i].Y)}
                if !c.Contains(pt) {
                        continue
                }
                pt.X += l.XOffset
                pt.Y += l.YOffset
                c.FillText(l.TextStyle[i], pt, label)
        }
}

but rather inconvenient when trying to compute the glyphbox around each label:

// GlyphBoxes returns a slice of GlyphBoxes,
// one for each of the labels, implementing the
// plot.GlyphBoxer interface.
func (l *Labels) GlyphBoxes(p *plot.Plot) []plot.GlyphBox {
        bs := make([]plot.GlyphBox, len(l.Labels))
        for i, label := range l.Labels {
                pt := l.XYs[i]
                pt.X += l.XOffset // error (cannot add float64 and vg.Length)
                pt.Y += l.YOffset // ditto
                bs[i].X = p.X.Norm(pt.X)
                bs[i].Y = p.Y.Norm(pt.Y)
                sty := l.TextStyle[i]
                bs[i].Rectangle = sty.Rectangle(label)
        }
        return bs
}

(the original code of Labels.GlyphBoxes(...) was missing the x/y-offsets)

there's no way (at least, I couldn't find one) to go from vg units back to "canvas/user" units. (even less so when one has only a plot.Plot in hands, w/o the corresponding draw.Canvas as one has in Labels.Plot(c,p))

I see two avenues to solve this:

option a) is probably less wide-ranging a change (only a couple of plotters in gonum/plot are using these fields)

(in any event, adding a method to go from vg.Length units back to "data units" may very well be quite convenient, though. I had the use for such a thing for go-hep/hplot here when trying to set labels in "normalized" coordinates.)

thoughts?

kortschak commented 3 years ago

I don't think offsets should be in data units. That would be a very surprising behaviour since offsetting text on a plot is a typesetting action.

It's not entirely clear to me why you need to communicate back from typesetting lengths to data units.

Also, I think that the GlyphBoxes method should look like this,

func (l *Labels) GlyphBoxes(p *plot.Plot) []plot.GlyphBox {
    bs := make([]plot.GlyphBox, len(l.Labels))
    for i, label := range l.Labels {
        bs[i].X = p.X.Norm(l.XYs[i].X) + float64(l.XOffset)
        bs[i].Y = p.Y.Norm(l.XYs[i].Y) + float64(l.YOffset)
        sty := l.TextStyle[i]
        bs[i].Rectangle = sty.Rectangle(label)
    }
    return bs
}

with the offset added after the scaling, just as it is in the Plot method.

sbinet commented 3 years ago

I did have tried that.

with:

// +build ignore

package main

import (
    "image/color"
    "log"
    "os"

    "gonum.org/v1/plot"
    "gonum.org/v1/plot/font"
    "gonum.org/v1/plot/plotter"
    "gonum.org/v1/plot/vg"
    "gonum.org/v1/plot/vg/draw"
    "gonum.org/v1/plot/vg/vgimg"
)

func main() {
    p := plot.New()
    p.X.Min = 0
    p.X.Max = 10
    p.Y.Min = 0
    p.Y.Max = 10

    f1 := plotter.NewFunction(func(x float64) float64 { return 5 })
    f1.LineStyle.Color = color.RGBA{R: 255, A: 255}

    f2 := plotter.NewFunction(func(x float64) float64 { return 6 })
    f2.LineStyle.Color = color.RGBA{B: 255, A: 255}

    if true {
        labels, err := plotter.NewLabels(plotter.XYLabels{
            XYs: []plotter.XY{
                {X: 2.5, Y: 2.5},
                {X: 7.5, Y: 2.5},
                {X: 7.5, Y: 7.5},
                {X: 2.5, Y: 7.5},
            },
            Labels: []string{"Agg", "Bgg", "Cgg", "x"},
        })
        if err != nil {
            log.Fatalf("could not creates labels plotter: %+v", err)
        }
        for i := range labels.TextStyle {
            sty := &labels.TextStyle[i]
            sty.Font = font.From(sty.Font, 20)
        }
        p.Add(labels)
    }

    {
        labels, err := plotter.NewLabels(plotter.XYLabels{
            XYs: []plotter.XY{
                {X: 2.5, Y: 2.5},
                {X: 7.5, Y: 2.5},
                {X: 7.5, Y: 7.5},
                {X: 2.5, Y: 7.5},
            },
            Labels: []string{"App", "Bpp", "Cpp", "Dpp"},
        })
        if err != nil {
            log.Fatalf("could not creates labels plotter: %+v", err)
        }
        for i := range labels.TextStyle {
            sty := &labels.TextStyle[i]
            sty.Font = font.From(sty.Font, 20)
        }
        labels.XOffset = 50
        p.Add(labels)
    }

    p.Add(plotter.NewGlyphBoxes())
    p.Add(f1, f2)
    p.Add(plotter.NewGrid())

    p.Legend.Add("f1", f1)
    p.Legend.Add("f2", f2)
    p.Legend.Top = true

    c := vgimg.PngCanvas{
        Canvas: vgimg.New(20*vg.Centimeter, 15*vg.Centimeter),
    }

    d := draw.New(c)
    p.Draw(d)
    p.DrawGlyphBoxes(d)

    f, err := os.Create("box.png")
    if err != nil {
        log.Fatalf("error: %+v", err)
    }
    defer f.Close()

    _, err = c.WriteTo(f)
    if err != nil {
        log.Fatalf("error: %+v", err)
    }

    err = f.Close()
    if err != nil {
        log.Fatalf("error: %+v", err)
    }
}

I get: box

(i.e.: the boxes around the shifted text labels are off screen and one can only see the boxes around the non-shifted labels)

kortschak commented 3 years ago

That doesn't build for me using the current master.

sbinet commented 3 years ago

right, I was running on top of my changes from #708.

here is the diff for running on top of HEAD:

87c87
<   p.DrawGlyphBoxes(d)
---
>   p.DrawGlyphBoxes(&d)

sorry about that.

kortschak commented 3 years ago

This fixes the issue, though it's ugly.

func (l *Labels) GlyphBoxes(p *plot.Plot) []plot.GlyphBox {
    bs := make([]plot.GlyphBox, len(l.Labels))
    offset := vg.Point{X: l.XOffset, Y: l.YOffset}
    for i, label := range l.Labels {
        rect := l.TextStyle[i].Rectangle(label)
        rect.Min = rect.Min.Add(offset)
        rect.Max = rect.Max.Add(offset)
        bs[i] = plot.GlyphBox{
            X:         p.X.Norm(l.XYs[i].X),
            Y:         p.Y.Norm(l.XYs[i].Y),
            Rectangle: rect,
        }
    }
    return bs
}

Obviously, the sty.Rectangle method needs to understand the offset, but this will do.

sbinet commented 3 years ago

that worked. thanks :)