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