prototyped svg reports

This commit is contained in:
2026-07-18 18:24:37 -06:00
parent 22e7b003f6
commit c51ad80dc6
106 changed files with 5450 additions and 46 deletions
+425
View File
@@ -0,0 +1,425 @@
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"
}