mirror of https://github.com/mum4k/termdash.git
267 lines
7.9 KiB
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
|
|
}
|