package history import ( "bytes" "fmt" "time" chart "github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2/drawing" ) // ChartOptions controls rendering of the node network PNG. type ChartOptions struct { Title string Subtitle string Width int Height int Percentile float64 } // RenderNodeNetworkPNG returns a PNG of the node-wide Rx+Tx time series with // the requested percentile drawn as a horizontal annotation line. func RenderNodeNetworkPNG(points []NodeSamplePoint, opts ChartOptions) ([]byte, error) { if opts.Width <= 0 { opts.Width = 1280 } if opts.Height <= 0 { opts.Height = 640 } if opts.Percentile <= 0 { opts.Percentile = 95 } if opts.Title == "" { opts.Title = "Node network usage" } if len(points) < 2 { return renderEmptyChart(opts, "insufficient history — need at least 2 samples") } xs := make([]time.Time, 0, len(points)) rxs := make([]float64, 0, len(points)) txs := make([]float64, 0, len(points)) totals := make([]float64, 0, len(points)) maxVal := 0.0 for _, p := range points { xs = append(xs, p.Timestamp) rxs = append(rxs, p.RxMbps) txs = append(txs, p.TxMbps) totals = append(totals, p.TotalMbps) if p.TotalMbps > maxVal { maxVal = p.TotalMbps } } pct := PercentileMbps(points, opts.Percentile) yMax := maxVal * 1.15 if pct*1.10 > yMax { yMax = pct * 1.10 } if yMax <= 0 { yMax = 1 } percentileSeries := chart.ContinuousSeries{ Name: fmt.Sprintf("P%.0f = %.1f Mbps", opts.Percentile, pct), Style: chart.Style{ StrokeColor: drawing.ColorFromHex("e74c3c"), StrokeWidth: 2.0, StrokeDashArray: []float64{6, 4}, }, XValues: []float64{chart.TimeToFloat64(xs[0]), chart.TimeToFloat64(xs[len(xs)-1])}, YValues: []float64{pct, pct}, } graph := chart.Chart{ Title: opts.Title, TitleStyle: chart.Style{ FontSize: 16, }, Width: opts.Width, Height: opts.Height, Background: chart.Style{ Padding: chart.Box{Top: 60, Left: 40, Right: 40, Bottom: 40}, FillColor: drawing.Color{ R: 0xf8, G: 0xf9, B: 0xfa, A: 0xff, }, }, XAxis: chart.XAxis{ Style: chart.Style{FontSize: 9}, ValueFormatter: chart.TimeValueFormatterWithFormat("15:04:05\n02 Jan"), }, YAxis: chart.YAxis{ Name: "Mbps", Style: chart.Style{FontSize: 9}, Range: &chart.ContinuousRange{Min: 0, Max: yMax}, ValueFormatter: func(v any) string { if f, ok := v.(float64); ok { return formatMbps(f) } return "" }, }, Series: []chart.Series{ chart.TimeSeries{ Name: "Total Rx+Tx", Style: chart.Style{ StrokeColor: drawing.ColorFromHex("2d7dd2"), StrokeWidth: 2.0, FillColor: drawing.ColorFromHex("2d7dd2").WithAlpha(50), }, XValues: xs, YValues: totals, }, chart.TimeSeries{ Name: "Rx", Style: chart.Style{ StrokeColor: drawing.ColorFromHex("3cb371"), StrokeWidth: 1.5, }, XValues: xs, YValues: rxs, }, chart.TimeSeries{ Name: "Tx", Style: chart.Style{ StrokeColor: drawing.ColorFromHex("ffa500"), StrokeWidth: 1.5, }, XValues: xs, YValues: txs, }, percentileSeries, }, } if opts.Subtitle != "" { graph.Elements = []chart.Renderable{subtitleRenderable(opts.Subtitle)} } buf := &bytes.Buffer{} if err := graph.Render(chart.PNG, buf); err != nil { return nil, err } return buf.Bytes(), nil } func subtitleRenderable(text string) chart.Renderable { return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) { r.SetFont(chartDefaults.GetFont()) r.SetFontColor(drawing.Color{R: 90, G: 90, B: 90, A: 0xff}) r.SetFontSize(10) r.Text(text, cb.Left+10, cb.Top+30) } } func renderEmptyChart(opts ChartOptions, note string) ([]byte, error) { graph := chart.Chart{ Title: opts.Title, Width: opts.Width, Height: opts.Height, Background: chart.Style{ Padding: chart.Box{Top: 60, Left: 40, Right: 40, Bottom: 40}, }, Series: []chart.Series{ chart.ContinuousSeries{ XValues: []float64{0, 1}, YValues: []float64{0, 0}, Style: chart.Style{ StrokeColor: drawing.ColorTransparent, }, }, }, } graph.Elements = []chart.Renderable{ func(r chart.Renderer, cb chart.Box, cs chart.Style) { r.SetFont(cs.GetFont()) r.SetFontColor(drawing.Color{R: 120, G: 120, B: 120, A: 0xff}) r.SetFontSize(14) r.Text(note, cb.Left+20, cb.Top+cb.Height()/2) }, } buf := &bytes.Buffer{} if err := graph.Render(chart.PNG, buf); err != nil { return nil, err } return buf.Bytes(), nil } func formatMbps(v float64) string { switch { case v >= 1000: return fmt.Sprintf("%.2f Gbps", v/1000) case v >= 1: return fmt.Sprintf("%.1f Mbps", v) default: return fmt.Sprintf("%.0f Kbps", v*1000) } }