termdash/widgets/linechart/internal/axes/label.go

267 lines
7.9 KiB
Go

// Copyright 2019 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package axes
// label.go contains code that calculates the positions of labels on the axes.
import (
"fmt"
"image"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/private/alignfor"
)
// LabelOrientation represents the orientation of text labels.
type LabelOrientation int
// String implements fmt.Stringer()
func (lo LabelOrientation) String() string {
if n, ok := labelOrientationNames[lo]; ok {
return n
}
return "LabelOrientationUnknown"
}
// labelOrientationNames maps LabelOrientation values to human readable names.
var labelOrientationNames = map[LabelOrientation]string{
LabelOrientationHorizontal: "LabelOrientationHorizontal",
LabelOrientationVertical: "LabelOrientationVertical",
}
const (
// LabelOrientationHorizontal is the default label orientation where text
// flows horizontally.
LabelOrientationHorizontal LabelOrientation = iota
// LabelOrientationVertical is an orientation where text flows vertically.
LabelOrientationVertical
)
// Label is one value label on an axis.
type Label struct {
// Value if the value to be displayed.
Value *Value
// Position of the label within the canvas.
Pos image.Point
}
// yLabels returns labels that should be placed next to the Y axis.
// The labelWidth is the width of the area from the left-most side of the
// canvas until the Y axis (not including the Y axis). This is the area where
// the labels will be placed and aligned.
// Labels are returned in an increasing value order.
// Label value is not trimmed to the provided labelWidth, the label width is
// only used to align the labels. Alignment is done with the assumption that
// longer labels will be trimmed.
func yLabels(scale *YScale, labelWidth int) ([]*Label, error) {
if min := 2; scale.GraphHeight < min {
return nil, fmt.Errorf("cannot place labels on a canvas with height %d, minimum is %d", scale.GraphHeight, min)
}
if min := 0; labelWidth < min {
return nil, fmt.Errorf("cannot place labels in label area width %d, minimum is %d", labelWidth, min)
}
var labels []*Label
const labelSpacing = 4
seen := map[string]bool{}
for y := scale.GraphHeight - 1; y >= 0; y -= labelSpacing {
label, err := rowLabel(scale, y, labelWidth)
if err != nil {
return nil, err
}
if !seen[label.Value.Text()] {
labels = append(labels, label)
seen[label.Value.Text()] = true
}
}
// If we have data, place at least two labels, first and last.
haveData := scale.Min.Rounded != 0 || scale.Max.Rounded != 0
if len(labels) < 2 && haveData {
const maxRow = 0
label, err := rowLabel(scale, maxRow, labelWidth)
if err != nil {
return nil, err
}
labels = append(labels, label)
}
return labels, nil
}
// rowLabelArea determines the area available for labels on the specified row.
// The row is the Y coordinate of the row, Y coordinates grow down.
func rowLabelArea(row int, labelWidth int) image.Rectangle {
return image.Rect(0, row, labelWidth, row+1)
}
// rowLabel returns label for the specified row.
func rowLabel(scale *YScale, y int, labelWidth int) (*Label, error) {
v, err := scale.CellLabel(y)
if err != nil {
return nil, fmt.Errorf("unable to determine label value for row %d: %v", y, err)
}
ar := rowLabelArea(y, labelWidth)
pos, err := alignfor.Text(ar, v.Text(), align.HorizontalRight, align.VerticalMiddle)
if err != nil {
return nil, fmt.Errorf("unable to align the label value: %v", err)
}
return &Label{
Value: v,
Pos: pos,
}, nil
}
// xSpace represents an available space among the X axis.
type xSpace struct {
// min is the current relative coordinate.
// These are zero based, i.e. not adjusted to axisStart.
cur int
// max is the maximum relative coordinate.
// These are zero based, i.e. not adjusted to axisStart.
// The xSpace instance contains points 0 <= x < max
max int
// graphZero is the (0, 0) point on the graph.
graphZero image.Point
}
// newXSpace returns a new xSpace instance initialized for the provided width.
func newXSpace(graphZero image.Point, graphWidth int) *xSpace {
return &xSpace{
cur: 0,
max: graphWidth,
graphZero: graphZero,
}
}
// Implements fmt.Stringer.
func (xs *xSpace) String() string {
return fmt.Sprintf("xSpace(size:%d)-cur:%v-max:%v", xs.Remaining(), image.Point{xs.cur, xs.graphZero.Y}, image.Point{xs.max, xs.graphZero.Y})
}
// Remaining returns the remaining size on the X axis.
func (xs *xSpace) Remaining() int {
return xs.max - xs.cur
}
// Relative returns the relative coordinate within the space, these are zero
// based.
func (xs *xSpace) Relative() image.Point {
return image.Point{xs.cur, xs.graphZero.Y + 1}
}
// LabelPos returns the absolute coordinate on the canvas where a label should
// be placed. The is the coordinate that represents the current relative
// coordinate of the space.
func (xs *xSpace) LabelPos() image.Point {
return image.Point{xs.cur + xs.graphZero.X, xs.graphZero.Y + 2} // First down is the axis, second the label.
}
// Sub subtracts the specified size from the beginning of the available
// space.
func (xs *xSpace) Sub(size int) error {
if xs.Remaining() < size {
return fmt.Errorf("unable to subtract %d from the start, not enough size in %v", size, xs)
}
xs.cur += size
return nil
}
// xLabels returns labels that should be placed under the X axis.
// The graphZero is the (0, 0) point of the graph area on the canvas.
// Labels are returned in an increasing value order.
// Returned labels shouldn't be trimmed, their count is adjusted so that they
// fit under the width of the axis.
// The customLabels map value positions in the series to the desired custom
// label. These are preferred if present.
func xLabels(scale *XScale, graphZero image.Point, customLabels map[int]string, lo LabelOrientation) ([]*Label, error) {
space := newXSpace(graphZero, scale.GraphWidth)
const minSpacing = 3
var res []*Label
next := int(scale.Min.Value)
for haveLabels := 0; haveLabels <= int(scale.Max.Value); haveLabels = len(res) {
label, err := colLabel(scale, space, customLabels, lo)
if err != nil {
return nil, err
}
if label == nil {
break
}
res = append(res, label)
next++
if next > int(scale.Max.Value) {
break
}
nextCell, err := scale.ValueToCell(next)
if err != nil {
return nil, err
}
skip := nextCell - space.Relative().X
if skip < minSpacing {
skip = minSpacing
}
if space.Remaining() <= skip {
break
}
if err := space.Sub(skip); err != nil {
return nil, err
}
}
return res, nil
}
// colLabel returns a label placed at the beginning of the space.
// The space is adjusted according to how much space was taken by the label.
// Returns nil, nil if the label doesn't fit in the space.
func colLabel(scale *XScale, space *xSpace, customLabels map[int]string, lo LabelOrientation) (*Label, error) {
pos := space.Relative()
label, err := scale.CellLabel(pos.X)
if err != nil {
return nil, fmt.Errorf("unable to determine label value for column %d: %v", pos.X, err)
}
if custom, ok := customLabels[int(label.Value)]; ok {
label = NewTextValue(custom)
}
var labelLen int
switch lo {
case LabelOrientationHorizontal:
labelLen = len(label.Text())
case LabelOrientationVertical:
labelLen = 1
}
if labelLen > space.Remaining() {
return nil, nil
}
abs := space.LabelPos()
if err := space.Sub(labelLen); err != nil {
return nil, err
}
return &Label{
Value: label,
Pos: abs,
}, nil
}