fyne-io / fyne

Cross platform GUI toolkit in Go inspired by Material Design
https://fyne.io/
Other
24.47k stars 1.36k forks source link

[Feature] Chart/Graph widget #2228

Open rjboer opened 3 years ago

rjboer commented 3 years ago

How to draw a graph (live), like qwt or qt charts?

I would love to start using fyne more extensively in projects and a major blocker is the ability to make live charts.. Made a chart that redraws in a canvas, but it's bad and not really efficient... There are probably bigger things to develop right now, but for future reference..

Jacalz commented 3 years ago

Hi. I would suggest using something like https://github.com/wcharczuk/go-chart (or any other Go chart library) and then showing the ìmage.Image type in a canvas.NewImageFromImage(). It won't been interactive, but worked relatively well from my testing. I have an old example for Sparta if it could be of any help: https://github.com/Jacalz/sparta/commit/f9927d8b502e388bda1ab21b3028693b939e9eb2 (you will find the relevant information in the last file).

andydotxyz commented 3 years ago

You could also try a chart package built on Fyne like https://github.com/ajstarks/fc/tree/master/chart/

rjboer commented 3 years ago

Hi, Thanks for the responses, and I had a look. The Go-Chart is more mature, the ajstarks library functionally better matching but definitely not yet a replacement like qwt for qt (with interactions even with some of our modifications). We use fyne with few of our products on the backend..... I cant wait to throw qt out...and put fyne on all the hydrogen fuel stations, cnc machinery and robotics we produce. I love the fyne initiative and I hope see you guys (eventually, when the time is right) develop a fully functional charting widget.

rjboer commented 3 years ago

Similar to:

592

charlesdaniels commented 3 years ago

Relevant wiki entry

rjboer commented 3 years ago

The wiki proposal is very complete, can I upvote somewhere?

andydotxyz commented 3 years ago

Judging by the number of TODO in the wiki I don't think it is ready for discussion/vote. We have a proposal process as described at https://github.com/fyne-io/fyne/wiki/Contributing:-Proposals. Today marks proposal freeze for version 2.1 (Aberlour) so, if this is to be a core widget, it would need to be a later release. Of course there is always scope to enter the ecosystem through the extensions repository which does not have a release cycle.

charlesdaniels commented 3 years ago

The wiki entry (I am the author) was intended to gather information about what kinds of data visualization capabilities we might want to implement and then survey prior art. From there, the next step would be to propose a specific API and plan of work via the proposals process which Andy linked.

Creating a generalized charting widget is a complex problem, and the API needs to be complete and stable for consideration to be added to core. Such a widget should probably start life in fyne-x until it is mature.

I would also like to draw attention to fc, which implements both a canvas as well as a chart, all in Fyne. Anthony has put a lot of work and thought into fc and fc/chart, and he has certainly set the bar for what a data viz library in Fyne would need to have in terms of capabilities.

NHAS commented 1 year ago

Just adding my voice in here that I would love to see some way of easily creating node graphs with a widget

srackham commented 10 months ago

Here's a trivial bar chart example using go-chart:

package main

import (
    "bytes"
    "log"

    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/app"
    "fyne.io/fyne/v2/canvas"
    "github.com/wcharczuk/go-chart/v2"
)

func main() {
    // Create a bar chart.
    barChart := chart.BarChart{
        Title:    "Test Bar Chart",
        Height:   1024,
        BarWidth: 60,
        Bars: []chart.Value{
            {Value: 5.25, Label: "Turquoise"},
            {Value: 4.88, Label: "Green"},
            {Value: 4.74, Label: "Gray"},
            {Value: 3.22, Label: "Orange"},
            {Value: 3, Label: "Blue"},
            {Value: 2.27, Label: "Lime"},
            {Value: 1, Label: "Red"},
        },
    }
    // Render the bar chart to a byte array.
    buf := new(bytes.Buffer)
    err := barChart.Render(chart.PNG, buf)
    if err != nil {
        log.Fatalf("failed to render bar chart: %v", err)
    }
    // Display the PNG image from buf in a window.
    a := app.New()
    w := a.NewWindow("Chart")
    w.Resize(fyne.Size{Width: 512, Height: 384})
    image := canvas.NewImageFromReader(buf, "barChart")
    w.SetContent(image)
    w.ShowAndRun()
}
fgm commented 7 months ago

One thing that became more visible since that wiki page was updated is Mermaid, which replaced Graphviz in a lot of situations I came across. Maybe worth listing too.

xiaobingcaicai commented 2 months ago

Hi. I would suggest using something like https://github.com/wcharczuk/go-chart (or any other Go chart library) and then showing the ìmage.Image type in a canvas.NewImageFromImage(). It won't been interactive, but worked relatively well from my testing. I have an old example for Sparta if it could be of any help: Jacalz/sparta@f9927d8 (you will find the relevant information in the last file).

if i want show the real time chart, i will update the image Constantly?

ErikKalkoken commented 2 months ago

Hi. I would suggest using something like https://github.com/wcharczuk/go-chart (or any other Go chart library) and then showing the ìmage.Image type in a canvas.NewImageFromImage(). It won't been interactive, but worked relatively well from my testing. I have an old example for Sparta if it could be of any help: Jacalz/sparta@f9927d8 (you will find the relevant information in the last file).

if i want show the real time chart, i will update the image Constantly?

yes. Same as with all the other widgets in your app. If you want to show live data, you need to redraw the widgets, when the underlying data changes.

AlexanderMakarov commented 1 month ago

Have tried to use go-charts (or, any SVG-output library) which is then rendered by canvas.NewImageFromResource from Fyne. Example of the file

It renders few seconds!!! If try to resize window where chart is placed inside container.NewVBox then app almost freezes - because each "resize frame" takes few seconds. Linux Mint on decent CPU.

So it is unusable.

ErikKalkoken commented 1 month ago

Have tried to use go-charts (or, any SVG-output library) which is then rendered by canvas.NewImageFromResource from Fyne. Example of the file

It renders few seconds!!! If try to resize window where chart is placed inside container.NewVBox then app almost freezes - because each "resize frame" takes few seconds. Linux Mint on decent CPU.

So it is unusable.

yes, rendering a new chart can take a moment. Which is why I would not recommend to re-render with every refresh of your container. Only re-render it if the underlying data changed. For the "normal" refresh of your window Fyne should need to redraw the existing image, not recreate it.

AlexanderMakarov commented 1 month ago

@ErikKalkoken could you please share a code snippet or link in docs how to configure Fyne's Image to don't rerender automatically?

Because I want to resize SVG, not to rerender. This is the reason of using SVG instead of raster formats.

ErikKalkoken commented 1 month ago

Sure thing. One approach is to use a box container that contains the generated image. When the images needs to be updated, you clear the container, re-add the newly generated image then force a refresh.

Here is a working example that shows this approach in action:

package main

import (
    "time"

    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/app"
    "fyne.io/fyne/v2/canvas"
    "fyne.io/fyne/v2/container"
)

func main() {
    a := app.New()
    w := a.NewWindow("Hello World")
    charts := container.NewVBox()
    w.SetContent(charts)
    w.Resize(fyne.Size{Width: 800, Height: 600})
    ticker := time.NewTicker(3 * time.Second)
    go func() {
        for {
            updateContainer(charts)
            <-ticker.C
        }
    }()
    w.ShowAndRun()
}

func updateContainer(c *fyne.Container) {
    c.RemoveAll()
    r, err := fyne.LoadResourceFromURLString("https://dynamic-image.vercel.app/api/random/png")
    if err != nil {
        panic(err)
    }
    image := canvas.NewImageFromResource(r)
    image.FillMode = canvas.ImageFillContain
    image.SetMinSize(fyne.Size{Width: 400, Height: 300})
    c.Add(image)
    c.Refresh()
}
ErikKalkoken commented 1 month ago

Or a more direct approach is to update the image directly, by replacing it's content and then forcing a refresh of the image.

package main

import (
    "time"

    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/app"
    "fyne.io/fyne/v2/canvas"
    "fyne.io/fyne/v2/theme"
)

func main() {
    a := app.New()
    w := a.NewWindow("Hello World")
    chart := canvas.NewImageFromResource(theme.BrokenImageIcon())
    chart.FillMode = canvas.ImageFillContain
    chart.SetMinSize(fyne.Size{Width: 400, Height: 300})
    w.SetContent(chart)
    w.Resize(fyne.Size{Width: 800, Height: 600})
    ticker := time.NewTicker(3 * time.Second)
    go func() {
        for {
            updateChart(chart)
            <-ticker.C
        }
    }()
    w.ShowAndRun()
}

func updateChart(c *canvas.Image) {
    r, err := fyne.LoadResourceFromURLString("https://dynamic-image.vercel.app/api/random/png")
    if err != nil {
        panic(err)
    }
    newImage := canvas.NewImageFromResource(r)
    c.Resource = newImage.Resource
    c.Refresh()
}
AlexanderMakarov commented 1 month ago

@ErikKalkoken sorry, but you are explaining "external" chart update. And in your example it happens very rare - once per 3 seconds.

I am talking about Fyne's window resize events - they are triggered by Fyne and I guess it happens at least once per 17 milliseconds (60 HZ refresh rate) while user holds edge of the window and moves mouse cursor

Probably generating PNG from go-charts to draw it 1:1 into canvas is a faster approach than through SVG (while I think BMP would work better here, but unfortunately go-charts don't have BMP renderer) but it would include extra step to generate new PNG each time, because otherwise chart would be blurred. And, IMHO, SVG is designed to be generated once and be scaled on any rectangle without extra effort. Could you explain why you are providing example with PNG image?

AlexanderMakarov commented 1 month ago

Actually I've expected to get code which instructs Fyne to don't reload SVG Resource each frame/refresh event (which includes parsing of XML code and creating tree of primitives to draw) but instead just to redraw SVG already-parsed tree of primitives on the resized canvas (like browsers do).

ErikKalkoken commented 1 month ago

sorry. I may have misunderstood your issue. Thought you were re-generating the image on each refresh, instead of re-using the existing resource. Or trying to generate the chart in the main thread (instead of a separate go-routine) and thereby blocking your app.

andydotxyz commented 1 month ago

Fyne will not re-load the XML unless you ask it to (with Image.Refresh()). When you resize an SVG it must re-rasterise for a crisp finish which should be fast - especially for the trivial image included above. If the refresh is slow then something may be accidentally invalidating the refresh or calling into the chart library.

You could also choose to rasterise the image once and simply scale it using the window resize on the GPU (default for image types not SVG) - but you won't get the nice crisp output.

AlexanderMakarov commented 1 month ago

@andydotxyz could you please look at my code snippet and on result?

package main

import (
    "bytes"
    "regexp"
    "strings"

    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/app"
    "fyne.io/fyne/v2/canvas"
    "fyne.io/fyne/v2/container"
    "github.com/wcharczuk/go-chart/v2"
)

func main() {
    a := app.New()
    w := a.NewWindow("TODO App")
    w.Resize(fyne.NewSize(800, 600))

    svgResource := &fyne.StaticResource{
        StaticName:    "some.svg",
        StaticContent: getChart(),
    }
    image := canvas.NewImageFromResource(svgResource)
    image.SetMinSize(fyne.NewSize(0, 200)) // Otherwise will be a line.

    w.SetContent(container.NewBorder(
        nil, // TOP
        nil, // BOTTOM
        nil, // LEFT
        nil, // RIGHT
        container.NewVScroll(
            container.NewVBox(
                container.NewVBox(
                    image,
                ),
            ),
        ),
    ))
    w.ShowAndRun()
}

func getChart() []byte {
    graph := chart.Chart{
        XAxis: chart.XAxis{
            Name: "The XAxis",
        },
        YAxis: chart.YAxis{
            Name: "The YAxis",
        },
        Series: []chart.Series{
            chart.ContinuousSeries{
                Style: chart.Style{
                    StrokeColor: chart.GetDefaultColor(0).WithAlpha(64),
                    FillColor:   chart.GetDefaultColor(0).WithAlpha(64),
                },
                XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
                YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
            },
        },
    }

    buffer := bytes.Buffer{}
    err := graph.Render(chart.SVG, &buffer)
    if err != nil {
        panic(err) // TODO Handle error properly
    }
    svgContent := buffer.String()
    // Remove unsupported by oksvg line breaks from SVG content.
    svgContent = strings.ReplaceAll(svgContent, "\n", "")
    // Remove unsupported by oksvg color definitions from SVG content.
    svgContent = regexp.MustCompile(`rgba\((\d+),(\d+),(\d+),\d+(\.\d+)?\)`).ReplaceAllString(svgContent, "rgb($1,$2,$3)")
    return []byte(svgContent)
}

Video of how it looks on my machine (Dell Latitude-5511, Linux Mint, Xfce): https://github.com/user-attachments/assets/8e4ad1de-467e-4448-916b-27f6a0362349

So it is not fast at all. Imagine how it would look like for a graph with thousand of dots. Video doesn't show it, but when window opens then I may see how it renders and resizes to 800*600 first time, which is also not user-friendly.

Notes:

  1. SVG is provided to Fyne as bytes, go-charts don't participate in frames preparation.
  2. There is an issue with fonts on chart - I don't know source of the issue, tried to load fonts directly into chart.Font field but it didn't help.
  3. "oksvg" (which Fyne uses for SVG) doesn't support (and fails) a set of SVG features - see last comments in getChart. It causes graph look solid blue, without opacity (if render SVG into file and see in browser then chart looks better). Issue with not rendered fonts (described in point above) probably is also caused by oksvg. I've tried https://github.com/tdewolff/canvas as a renderer for go-charts SVG (instead of native chart.SVG) and it produced more "compatible"/simple code, but without texts (i.e. fonts loaded) as well.
andydotxyz commented 1 month ago

There is a lot I don't understand in the code. Can you start by removing all the strange forcing of min-size so it fills the space?

Instead of

    image := canvas.NewImageFromResource(svgResource)
    image.SetMinSize(fyne.NewSize(0, 200)) // Otherwise will be a line.

    w.SetContent(container.NewBorder(
        nil, // TOP
        nil, // BOTTOM
        nil, // LEFT
        nil, // RIGHT
        container.NewVScroll(
            container.NewVBox(
                container.NewVBox(
                    image,
                ),
            ),
        ),
    ))

Make it

    image := canvas.NewImageFromResource(svgResource)
    w.SetContent(image)

Of note regarding text in SVG you probably mean: #685

Once those are considered if you still have performance issues please open an issue - this thread is for a feature request.

AlexanderMakarov commented 1 month ago

@andydotxyz thank you for your reply.

removing all the strange forcing of min-size so it fills the space?

I've added it to replicate what I need in my app/layout. When applied your suggestion then image just took the whole available space, but app is still the same slow on window resizing.

Thank you for the https://github.com/fyne-io/fyne/issues/685 - I've not noticed it before.

I've added my example here just as a follow up for conversation related to "Made a chart that redraws in a canvas, but it's bad and not really efficient..." from the topic message plus to get know if I am doing something wrong - it is my first attempt to use Fyne.

andydotxyz commented 1 month ago

When applied your suggestion then image just took the whole available space, but app is still the same slow on window resizing.

Thanks for confirming.

but it's bad and not really efficient...

The efficient way to draw a graph would be using the canvas package and some custom layout code. Is there a feature of your graph that cannot be created in this way?

AlexanderMakarov commented 1 month ago

I need in:

Unfortunately I don't want to draw them from primitives like fyne canvas provides - it would take too much effort.

ErikKalkoken commented 1 month ago

I recognize you would prefer the SVG format, but maybe take another look at rendering the charts in PNG format? I have been using the go-charts library that way to render charts in my Fyne app and it works pretty well.

AlexanderMakarov commented 1 month ago

@ErikKalkoken it is what I told about in the https://github.com/fyne-io/fyne/issues/2228#issuecomment-2227044661. And I've tried fyne.StaticResource from PNG and it works much faster:

https://github.com/user-attachments/assets/3583833e-c9ad-42e7-80c3-c9aed97abf58

Code ``` package main import ( "bytes" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "github.com/wcharczuk/go-chart/v2" ) func main() { a := app.New() w := a.NewWindow("TODO App") w.Resize(fyne.NewSize(800, 600)) svgResource := &fyne.StaticResource{ StaticName: "some.png", StaticContent: getChart(int(w.Canvas().Size().Width), int(w.Canvas().Size().Height)), } image := canvas.NewImageFromResource(svgResource) w.SetContent(image) w.ShowAndRun() } func getChart(weight, height int) []byte { graph := chart.Chart{ Width: weight, Height: height, XAxis: chart.XAxis{ Name: "The XAxis", }, YAxis: chart.YAxis{ Name: "The YAxis", }, Series: []chart.Series{ chart.ContinuousSeries{ Style: chart.Style{ StrokeColor: chart.GetDefaultColor(0).WithAlpha(64), FillColor: chart.GetDefaultColor(0).WithAlpha(64), }, XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, }, }, } buffer := bytes.Buffer{} err := graph.Render(chart.PNG, &buffer) if err != nil { panic(err) // TODO Handle error properly } return buffer.Bytes() } ```
= 10%, which may be a tradeoff between performance and quality while is: - still requires custom layout - would become slower with more charts on it, - ratio distortion is still would be noticeable, especially on big thresholds. One more idea - don't resize content on window resize events until user releases window edge (aka end of resize action) to redraw everything one time for "final" size. But I am not sure that Fyne provides API for this. BTW - my app is for desktops mostly, this is why I care about window resizing.
ErikKalkoken commented 1 month ago

I think you might get better results if you render the image once and then let Fyne resize the image to fit the canvas. You can specify how Fyne renders images with the FillMode. This also let's you choose if you want to maintain aspect ratio or not. The default is canvas.ImageFillStretch, which does not maintain aspect ratio. Would suggest to try canvas.ImageFillContain. You also might want to set a minimum size of your image, so Fyne can calculate the layout better.

andydotxyz commented 1 month ago

Unfortunately I don't want to draw them from primitives like fyne canvas provides - it would take too much effort.

Sometimes it is a tradeoff between developer effort and runtime performance. I know the SVG performance could be improved - but it will always be slower than using the canvas API. There are abstractions and over time a chart API will exist, but for now its that tradeoff I think.

If you can profile what the hold-up is we can try to optimise based on that info.