prototyped svg reports
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Failing after 15s

This commit is contained in:
2026-08-20 00:23:54 -06:00
parent 55a3694d89
commit fb2f4255f4
106 changed files with 5450 additions and 46 deletions
+159
View File
@@ -0,0 +1,159 @@
package charts
import (
"fmt"
"slices"
"strings"
"ruben/inventory2/server/ui/svg"
)
type (
// TODO: rename to BarChart
Bar struct {
values []BarValue
classes barChartClasses
}
BarValue struct {
Label string
Value float64
}
barChartClasses struct {
svg []string
bar []string
}
)
func NewBar(vs ...BarValue) *Bar {
return &Bar{
values: vs,
}
}
func (c *Bar) WithSVGClass(classes ...string) *Bar {
c.classes.svg = append(c.classes.svg, classes...)
return c
}
func (c *Bar) WithBarClass(classes ...string) *Bar {
c.classes.bar = append(c.classes.bar, classes...)
return c
}
func (c *Bar) SVG() svg.MarshalerReader {
const (
minX = 0
minY = 0
maxX = 100
maxY = 100
rangeX = maxX - minX
rangeY = maxY - minY
)
s := svg.NewSVG().
Attr(
svg.Style{
"width": "100%",
"height": "auto",
"padding": "1em",
"border-width": "2px",
},
svg.ViewBox{
X: minX,
Y: minY,
Width: maxX - minX,
Height: maxY - minY,
},
svg.Class(
strings.Join(append(
[]string{"rounded-lg", "border-border"},
c.classes.bar...,
), " "),
),
)
if len(c.classes.svg) != 0 {
s = s.Attr(svg.Class(strings.Join(c.classes.svg, " ")))
}
if len(c.values) > 0 {
minV := slices.MinFunc(c.values, func(a, b BarValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
})
maxV := slices.MaxFunc(c.values, func(a, b BarValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
})
rangeV := maxV.Value - minV.Value
spacePerBar := float64(rangeX) / float64(len(c.values))
barPadding := 0.025 * spacePerBar
barWidth := spacePerBar - (2 * barPadding)
fontSize := barWidth / 2
for i, v := range c.values {
scaledV := (v.Value - minV.Value) * rangeY / rangeV
barX := float64(i)*spacePerBar + barPadding
s = s.Child(
new(svg.Rect).
Attr(
svg.NewLength(barX).AsX(),
svg.NewLength(maxY-scaledV).AsY(),
svg.NewLength(barWidth).AsWidth(),
svg.NewLength(scaledV).AsHeight(),
svg.Class(
strings.Join(append(
[]string{"fill-accent"},
c.classes.bar...,
), " "),
),
),
svg.NewText(fmt.Sprint(v.Value)).
Attr(
svg.NewLength(barX+(barWidth/2)).AsX(),
svg.NewLength(maxY-scaledV+fontSize).AsY(),
svg.Fill("var(--accent-foreground)"),
svg.TextAnchorMiddle,
// svg.NewLength(fontSize).AsFontSize(),
svg.Style{
"font-size": fmt.Sprintf("%vpx", fontSize),
},
),
)
if v.Label != "" {
x := barX + (barWidth / 2)
y := maxY - fontSize
s = s.Child(
svg.NewText(v.Label).
Attr(
svg.NewLength(x).AsX(),
svg.NewLength(y).AsY(),
svg.Fill("var(--accent-foreground)"),
//svg.NewLength(fontSize).AsFontSize(),
svg.Style{
"font-size": fmt.Sprintf("%vpx", fontSize),
},
svg.DominantBaselineCentral,
svg.TransformRotate(-90).About(x, y),
),
)
}
}
}
return s
}
+95
View File
@@ -0,0 +1,95 @@
package charts
import (
"fmt"
"os"
)
func ExampleBar() {
mu := NewBar(
BarValue{
Label: "label: 101",
Value: 101,
},
BarValue{
Label: "label: -09",
Value: -99,
},
BarValue{
Label: "label: 201",
Value: 201,
},
BarValue{
Label: "label: -199",
Value: -199,
},
BarValue{
Label: "label: 301",
Value: 301,
},
BarValue{
Label: "label: -299",
Value: -299,
},
BarValue{
Label: "label: 401",
Value: 401,
},
BarValue{
Label: "label: -399",
Value: -399,
},
BarValue{
Label: "label: 501",
Value: 501,
},
BarValue{
Label: "label: -499",
Value: -499,
},
BarValue{
Label: "label: 601",
Value: 601,
},
BarValue{
Label: "label: -599",
Value: -599,
},
BarValue{
Label: "label: 701",
Value: 701,
},
BarValue{
Label: "label: -699",
Value: -699,
},
BarValue{
Label: "label: 801",
Value: 801,
},
BarValue{
Label: "label: -799",
Value: -799,
},
BarValue{
Label: "label: 901",
Value: 901,
},
BarValue{
Label: "label: -899",
Value: -899,
},
BarValue{
Label: "label: 1001",
Value: 1001,
},
BarValue{
Label: "label: -999",
Value: -999,
},
).SVG().GetMarkup()
os.WriteFile("./test.svg", []byte(mu), 666)
fmt.Println(mu)
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" style="height: auto; width: 100%" viewBox="0 0 100 100"><rect x="0" y="45" width="4.75" height="55" class="fill-accent"/><rect x="5" y="55" width="4.75" height="45" class="fill-accent"/><rect x="10" y="40" width="4.75" height="60" class="fill-accent"/><rect x="15" y="60" width="4.75" height="40" class="fill-accent"/><rect x="20" y="35" width="4.75" height="65" class="fill-accent"/><rect x="25" y="65" width="4.75" height="35" class="fill-accent"/><rect x="30" y="30" width="4.75" height="70" class="fill-accent"/><rect x="35" y="70" width="4.75" height="30" class="fill-accent"/><rect x="40" y="25" width="4.75" height="75" class="fill-accent"/><rect x="45" y="75" width="4.75" height="25" class="fill-accent"/><rect x="50" y="20" width="4.75" height="80" class="fill-accent"/><rect x="55" y="80" width="4.75" height="20" class="fill-accent"/><rect x="60" y="15" width="4.75" height="85" class="fill-accent"/><rect x="65" y="85" width="4.75" height="15" class="fill-accent"/><rect x="70" y="10" width="4.75" height="90" class="fill-accent"/><rect x="75" y="90" width="4.75" height="10" class="fill-accent"/><rect x="80" y="5" width="4.75" height="95" class="fill-accent"/><rect x="85" y="95" width="4.75" height="5" class="fill-accent"/><rect x="90" y="0" width="4.75" height="100" class="fill-accent"/><rect x="95" y="100" width="4.75" height="0" class="fill-accent"/></svg>
}
+236
View File
@@ -0,0 +1,236 @@
package charts
import (
"ruben/inventory2/server/ui/svg"
"slices"
)
type (
LineChart struct {
width float64
height float64
foreground string
background string
values []LineValue
max *float64
min *float64
}
LineValue struct {
Label string
Value float64
}
)
func NewLineChart(width, height float64, vs ...LineValue) *LineChart {
return &LineChart{
width: width,
height: height,
values: vs,
}
}
func (c *LineChart) Max(n float64) *LineChart {
c.max = &n
return c
}
func (c *LineChart) Min(n float64) *LineChart {
c.min = &n
return c
}
func (c *LineChart) Foreground(fg string) *LineChart {
c.foreground = fg
return c
}
func (c *LineChart) Background(bg string) *LineChart {
c.background = bg
return c
}
func (c *LineChart) SVG() svg.MarshalerReader {
var (
minVal float64
maxVal float64
)
fg := "black"
if c.foreground != "" {
fg = c.foreground
}
bg := c.background
if c.min != nil {
minVal = *c.min
} else if len(c.values) > 0 {
minVal = slices.MinFunc(c.values, compareLineValues).Value
}
if c.max != nil {
maxVal = *c.max
} else if len(c.values) > 0 {
maxVal = slices.MaxFunc(c.values, compareLineValues).Value
}
svgBg := bg
if svgBg == "" {
svgBg = "auto"
}
s := svg.NewSVG().
Attr(
svg.NewLength(c.width).
AsWidth(),
svg.NewLength(c.height).
AsHeight(),
svg.Style{
// "width": "100%",
// "height": "100%",
"background": svgBg,
},
/*
svg.ViewBox{
X: 0,
Y: minVal,
Width: float64(len(c.values) - 1),
Height: maxVal - minVal,
},
*/
svg.ViewBox{
X: 0,
Y: 0,
Width: c.width,
Height: c.height,
},
svg.PreserveAspectRatio{},
)
g := new(svg.G).
Attr(
// getChartOrientationTransform(c.height),
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.9,
},
// flip
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
Y: -c.height,
},
},
)
// lines
pts := make(svg.Points, len(c.values))
for i, p := range c.values {
pts[i] = svg.Point{
X: (c.width / float64(len(c.values)-1)) * float64(i),
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
}
}
g = g.Child(
new(svg.Polyline).Attr(
pts,
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
svg.Stroke(fg),
svg.Fill("none"),
),
)
// points
if len(c.values) > 0 {
r := 1.0 / float64(len(c.values))
rx := r
ry := r
for i, p := range c.values {
// dot on the line graph
x := pts[i].X
y := pts[i].Y
ell := new(svg.Ellipse).
Attr(
svg.NewLength(x).
AsCX(),
svg.NewLength(y).
AsCY(),
svg.Percentage(rx).
AsRX(),
svg.Percentage(ry).
AsRY(),
svg.Stroke(fg),
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
)
if bg != "" {
ell = ell.Attr(
svg.Fill(bg),
)
} else {
ell = ell.Attr(
svg.Fill(fg),
)
}
g = g.Child(
ell,
// label
upsideDownCenteredText(p.Label, x, y).
Attr(
svg.NewLength(c.height*0.05).
Unit(svg.Px).
AsFontSize(),
),
)
}
}
return s.Child(g)
}
func compareLineValues(a, b LineValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
}
func upsideDownCenteredText(txt string, x, y float64) *svg.Text {
return svg.NewText(txt).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.NewLength(x).
AsX(),
svg.NewLength(y).
AsY(),
upsideDownTransform(x, y),
)
}
+40
View File
@@ -0,0 +1,40 @@
package charts
import (
"fmt"
"os"
)
func ExampleLineChart() {
c := NewLineChart(100, 100,
LineValue{
Value: 1,
},
LineValue{
Value: 2,
},
LineValue{
Value: 4,
},
LineValue{
Value: 8,
},
LineValue{
Value: 16,
},
LineValue{
Value: 32,
},
).
Min(0).
Max(40).
Foreground("purple").
Background("pink").
SVG().
GetMarkup()
output := []byte(`<html><body>` + c + `</body></html>`)
os.WriteFile("./line_chart.html", []byte(output), 0666)
fmt.Println(c)
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" style="height: auto; width: 100%" viewBox="0 0 100 100"></svg>
}
+32
View File
@@ -0,0 +1,32 @@
package charts
import "ruben/inventory2/server/ui/svg"
func getChartOrientationTransform(height float64) svg.Transform {
return svg.Transform{
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
Y: -height,
},
}
}
func upsideDownTransform(x, y float64) svg.Transform {
return svg.Transform{
svg.TransformTranslate{
X: x,
Y: y,
},
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
X: -x,
Y: -y,
},
}
}
+1
View File
@@ -0,0 +1 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" style="height: auto; width: 100%" viewBox="0 0 100 100"><rect x="0" y="45" width="4.75" height="55" class="fill-accent"/><rect x="5" y="55" width="4.75" height="45" class="fill-accent"/><rect x="10" y="40" width="4.75" height="60" class="fill-accent"/><rect x="15" y="60" width="4.75" height="40" class="fill-accent"/><rect x="20" y="35" width="4.75" height="65" class="fill-accent"/><rect x="25" y="65" width="4.75" height="35" class="fill-accent"/><rect x="30" y="30" width="4.75" height="70" class="fill-accent"/><rect x="35" y="70" width="4.75" height="30" class="fill-accent"/><rect x="40" y="25" width="4.75" height="75" class="fill-accent"/><rect x="45" y="75" width="4.75" height="25" class="fill-accent"/><rect x="50" y="20" width="4.75" height="80" class="fill-accent"/><rect x="55" y="80" width="4.75" height="20" class="fill-accent"/><rect x="60" y="15" width="4.75" height="85" class="fill-accent"/><rect x="65" y="85" width="4.75" height="15" class="fill-accent"/><rect x="70" y="10" width="4.75" height="90" class="fill-accent"/><rect x="75" y="90" width="4.75" height="10" class="fill-accent"/><rect x="80" y="5" width="4.75" height="95" class="fill-accent"/><rect x="85" y="95" width="4.75" height="5" class="fill-accent"/><rect x="90" y="0" width="4.75" height="100" class="fill-accent"/><rect x="95" y="100" width="4.75" height="0" class="fill-accent"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+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"
}