426 lines
8.0 KiB
Go
426 lines
8.0 KiB
Go
package charts
|
|
|
|
import (
|
|
"ruben/inventory2/server/ui/svg"
|
|
"slices"
|
|
"time"
|
|
)
|
|
|
|
type (
|
|
TimeLineChart struct {
|
|
width float64
|
|
height float64
|
|
foreground string
|
|
background string
|
|
values []TimeLineValue
|
|
max *float64
|
|
min *float64
|
|
}
|
|
TimeLineValue struct {
|
|
Label string
|
|
Time time.Time
|
|
Value float64
|
|
}
|
|
)
|
|
|
|
func NewTimeLineChart(width, height float64, vs ...TimeLineValue) *TimeLineChart {
|
|
return &TimeLineChart{
|
|
width: width,
|
|
height: height,
|
|
values: validValues(vs),
|
|
}
|
|
}
|
|
|
|
func validValues(vs []TimeLineValue) []TimeLineValue {
|
|
values := make([]TimeLineValue, 0, len(vs))
|
|
for _, v := range vs {
|
|
if !v.Time.IsZero() {
|
|
values = append(values, v)
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func (c *TimeLineChart) Max(n float64) *TimeLineChart {
|
|
c.max = &n
|
|
return c
|
|
}
|
|
|
|
func (c *TimeLineChart) Min(n float64) *TimeLineChart {
|
|
c.min = &n
|
|
return c
|
|
}
|
|
|
|
func (c *TimeLineChart) Foreground(fg string) *TimeLineChart {
|
|
c.foreground = fg
|
|
return c
|
|
}
|
|
|
|
func (c *TimeLineChart) Background(bg string) *TimeLineChart {
|
|
c.background = bg
|
|
return c
|
|
}
|
|
|
|
func (c *TimeLineChart) SVG() svg.MarshalerReader {
|
|
return svg.NewSVG().
|
|
Attr(
|
|
svg.NewLength(c.width).
|
|
AsWidth(),
|
|
svg.NewLength(c.height).
|
|
AsHeight(),
|
|
svg.Style{
|
|
"background": c.getOuterSVGBackground(),
|
|
},
|
|
svg.ViewBox{
|
|
X: 0,
|
|
Y: 0,
|
|
Width: c.width,
|
|
Height: c.height,
|
|
},
|
|
svg.PreserveAspectRatio{},
|
|
).
|
|
Child(
|
|
new(svg.G).
|
|
Attr(
|
|
svg.Transform{
|
|
// scale down to provide padding
|
|
svg.TransformTranslate{
|
|
X: 0.05 * c.width,
|
|
Y: 0.05 * c.height,
|
|
},
|
|
svg.TransformScale{
|
|
X: 0.9,
|
|
Y: 0.85, // a little more padding on the bottom
|
|
},
|
|
|
|
// flip
|
|
svg.TransformScale{
|
|
X: 1,
|
|
Y: -1,
|
|
},
|
|
svg.TransformTranslate{
|
|
Y: -c.height,
|
|
},
|
|
},
|
|
).
|
|
Child(c.getChildren()...),
|
|
)
|
|
}
|
|
|
|
func (c *TimeLineChart) getOuterSVGBackground() string {
|
|
if c.background != "" {
|
|
return c.background
|
|
}
|
|
return "auto"
|
|
}
|
|
|
|
func (c *TimeLineChart) getChildren() []svg.GChildren {
|
|
pts := c.getPoints()
|
|
|
|
return append(
|
|
append(
|
|
[]svg.GChildren{
|
|
new(svg.Polyline).Attr(
|
|
pts,
|
|
svg.NewLength(2).
|
|
Unit(svg.Px).
|
|
AsStrokeWidth(),
|
|
svg.VectorEffectNonScalingStroke,
|
|
svg.Stroke(c.getStroke()),
|
|
svg.Fill("none"),
|
|
),
|
|
},
|
|
c.buildDots(pts)...,
|
|
),
|
|
c.buildLabels(pts)...,
|
|
)
|
|
}
|
|
|
|
func (c *TimeLineChart) getPoints() svg.Points {
|
|
minVal, _ := c.getMinVal()
|
|
maxVal, _ := c.getMaxVal()
|
|
|
|
minTime, maxTime := c.getMinAndMaxUnixTimes()
|
|
|
|
pts := make(svg.Points, len(c.values))
|
|
for i, p := range c.values {
|
|
pts[i] = svg.Point{
|
|
X: c.width * float64(p.Time.UnixNano()-minTime) / float64(maxTime-minTime),
|
|
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
|
|
}
|
|
}
|
|
|
|
return pts
|
|
}
|
|
|
|
func (c *TimeLineChart) getMinVal() (float64, bool) {
|
|
if c.min != nil {
|
|
return *c.min, true
|
|
}
|
|
if values := c.values; len(values) > 0 {
|
|
return slices.MinFunc(values, compareTimeLineValues).Value, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func (c *TimeLineChart) getMaxVal() (float64, bool) {
|
|
if c.max != nil {
|
|
return *c.max, true
|
|
}
|
|
if len(c.values) > 0 {
|
|
return slices.MaxFunc(c.values, compareTimeLineValues).Value, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func compareTimeLineValues(a, b TimeLineValue) int {
|
|
if a.Value < b.Value {
|
|
return -1
|
|
}
|
|
if a.Value > b.Value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (c *TimeLineChart) getMinAndMaxUnixTimes() (minTime, maxTime int64) {
|
|
if len(c.values) > 0 {
|
|
return slices.MinFunc(c.values, compareTimeLineTimeValues).Time.UnixNano(),
|
|
slices.MaxFunc(c.values, compareTimeLineTimeValues).Time.UnixNano()
|
|
}
|
|
return 0, 0
|
|
}
|
|
|
|
func compareTimeLineTimeValues(a, b TimeLineValue) int {
|
|
at := a.Time
|
|
bt := b.Time
|
|
an := at.UnixNano()
|
|
bn := bt.UnixNano()
|
|
if at.IsZero() {
|
|
an = 0
|
|
}
|
|
if bt.IsZero() {
|
|
bn = 0
|
|
}
|
|
return int(an - bn)
|
|
}
|
|
|
|
func (c *TimeLineChart) buildDots(pts svg.Points) []svg.GChildren {
|
|
if len(c.values) == 0 {
|
|
return nil
|
|
}
|
|
|
|
chn := make([]svg.GChildren, len(c.values))
|
|
for i, p := range pts {
|
|
chn[i] = c.buildDot(p)
|
|
}
|
|
|
|
return chn
|
|
}
|
|
|
|
func (c *TimeLineChart) buildDot(p svg.Point) *svg.Circle {
|
|
return new(svg.Circle).
|
|
Attr(
|
|
svg.NewLength(p.X).
|
|
AsCX(),
|
|
svg.NewLength(p.Y).
|
|
AsCY(),
|
|
svg.NewLength(0.01*min(c.width, c.height)).
|
|
AsR(),
|
|
svg.Stroke(c.getStroke()),
|
|
svg.NewLength(2).
|
|
Unit(svg.Px).
|
|
AsStrokeWidth(),
|
|
svg.VectorEffectNonScalingStroke,
|
|
c.getDotFill(),
|
|
)
|
|
}
|
|
|
|
func (c *TimeLineChart) getDotFill() svg.Fill {
|
|
if c.background != "" {
|
|
return svg.Fill(c.background)
|
|
} else {
|
|
return svg.Fill(c.getStroke())
|
|
}
|
|
}
|
|
|
|
func (c *TimeLineChart) buildLabels(pts svg.Points) []svg.GChildren {
|
|
if len(c.values) == 0 {
|
|
return nil
|
|
}
|
|
|
|
chn := make([]svg.GChildren, len(c.values))
|
|
for i, p := range pts {
|
|
chn[i] = c.buildLabel(c.values[i], p)
|
|
}
|
|
|
|
return chn
|
|
}
|
|
|
|
func (c *TimeLineChart) buildLabel(v TimeLineValue, p svg.Point) *svg.Element[svg.SVGTag, svg.SVGAttribute, svg.SVGChildren] {
|
|
return svg.NewSVG().
|
|
Attr(
|
|
svg.NewLength(p.X).
|
|
AsX(),
|
|
svg.NewLength(p.Y).
|
|
AsY(),
|
|
svg.NewLength(24).
|
|
Unit(svg.Px).
|
|
AsWidth(),
|
|
svg.NewLength(24).
|
|
Unit(svg.Px).
|
|
AsHeight(),
|
|
svg.Class("group"),
|
|
svg.Style{
|
|
"overflow": "visible",
|
|
"fill": "var(--accent-foreground)",
|
|
},
|
|
svg.ViewBox{
|
|
X: 0,
|
|
Y: 0,
|
|
Width: 1,
|
|
Height: 1,
|
|
},
|
|
svg.PreserveAspectRatio{
|
|
Align: &svg.AlignValue{
|
|
X: svg.AlignMid,
|
|
Y: svg.AlignMid,
|
|
},
|
|
},
|
|
).
|
|
Child(
|
|
c.buildAlwaysDisplayedLabelText(v, p),
|
|
new(svg.Rect).
|
|
Attr(
|
|
svg.NewLength(0.1).
|
|
AsStrokeWidth(),
|
|
svg.Fill("var(--muted)"),
|
|
svg.Stroke("var(--accent-foreground)"),
|
|
svg.NewLength(2.5).
|
|
AsHeight(),
|
|
svg.NewLength(-5).
|
|
AsX(),
|
|
svg.NewLength(-3).
|
|
AsY(),
|
|
svg.NewLength(10).
|
|
AsWidth(),
|
|
svg.Class("not-group-hover:hidden"),
|
|
),
|
|
c.buildHoverLabelText(v, p),
|
|
)
|
|
}
|
|
|
|
func (c *TimeLineChart) buildAlwaysDisplayedLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
|
return c.buildLabelText(v, p)
|
|
}
|
|
|
|
func (c *TimeLineChart) buildHoverLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
|
labelText := c.buildLabelText(v, p).
|
|
Attr(
|
|
svg.Class("not-group-hover:hidden"),
|
|
)
|
|
|
|
if v.Time.IsZero() {
|
|
return labelText
|
|
}
|
|
|
|
return labelText.Child(
|
|
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
|
|
Attr(
|
|
svg.TextAnchorMiddle,
|
|
svg.DominantBaselineMiddle,
|
|
svg.Style{
|
|
"font-size": "1px",
|
|
},
|
|
svg.Class("not-group-hover:hidden"),
|
|
|
|
svg.NewLength(0).
|
|
AsX(),
|
|
svg.NewLength(2.5).
|
|
AsY(),
|
|
),
|
|
)
|
|
}
|
|
|
|
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
|
return new(svg.G).
|
|
Attr(
|
|
svg.NewLength(0).
|
|
AsX(),
|
|
svg.NewLength(0).
|
|
AsY(),
|
|
upsideDownTransform(0, 0),
|
|
).
|
|
Child(
|
|
svg.NewText(v.Label).
|
|
Attr(
|
|
svg.TextAnchorMiddle,
|
|
svg.DominantBaselineMiddle,
|
|
svg.Style{
|
|
"font-size": "1px",
|
|
},
|
|
|
|
svg.NewLength(0).
|
|
AsX(),
|
|
svg.NewLength(1.25).
|
|
AsY(),
|
|
),
|
|
)
|
|
}
|
|
|
|
/*
|
|
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
|
labelText := new(svg.G).
|
|
Attr(
|
|
svg.NewLength(0).
|
|
AsX(),
|
|
svg.NewLength(0).
|
|
AsY(),
|
|
upsideDownTransform(0, 0),
|
|
).
|
|
Child(
|
|
svg.NewText(v.Label).
|
|
Attr(
|
|
svg.TextAnchorMiddle,
|
|
svg.DominantBaselineMiddle,
|
|
svg.Style{
|
|
"font-size": "1px",
|
|
},
|
|
|
|
svg.NewLength(0).
|
|
AsX(),
|
|
svg.NewLength(1.25).
|
|
AsY(),
|
|
),
|
|
)
|
|
|
|
if v.Time.IsZero() {
|
|
return labelText
|
|
}
|
|
|
|
return labelText.Child(
|
|
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
|
|
Attr(
|
|
svg.TextAnchorMiddle,
|
|
svg.DominantBaselineMiddle,
|
|
svg.Style{
|
|
"font-size": "1px",
|
|
},
|
|
svg.Class("not-group-hover:hidden"),
|
|
|
|
svg.NewLength(0).
|
|
AsX(),
|
|
svg.NewLength(2.5).
|
|
AsY(),
|
|
),
|
|
)
|
|
}
|
|
*/
|
|
|
|
func (c *TimeLineChart) getStroke() string {
|
|
if c.foreground != "" {
|
|
return c.foreground
|
|
}
|
|
return "black"
|
|
}
|