mp-007 / kivy_matplotlib_widget

A fast matplotlib rendering for Kivy based on Kivy_matplotlib project and kivy scatter. Matplotlib used 'agg' backend
MIT License
35 stars 7 forks source link

Allow the handling of multiple axes. #13

Closed abedhammoud closed 1 year ago

abedhammoud commented 1 year ago

Today, the widget provides a good level of interactive control for a single axes figure and does it well/fast. What would be good to have is the ability to display figures with multiple axes and either have the interactive features turned off or attached to one of the axes at a time. Thanks, @mp-007, for the excellent work.

abedhammoud commented 1 year ago

I am closing this because after learning further about this widget, I found that it already does what I am asking for and does it fast. In general, for a function that generates a matplotlib figure with multiple axes (ex. asp_figure() used below), I use the following to display the figure in this widget:

    def asp_graph(self, *args):
        selection, *_ = args
        if self.ids._dashfigure.figure:
            self.ids._dashfigure.figure.clear()
        fig = self.asp_figure(selection)
        self.ids._dashfigure.figure = fig
        self.ids._dashfigure.axes = fig.axes[0]
        self.ids._dashfigure.fast_draw = False
        self.ids._dashfigure.figure.canvas.draw_idle()
        self.ids._dashfigure.figure.canvas.flush_events()

And it works perfectly. Note that you need to set the axes (in this case, I set to axes[0]) to any of the axes in the figure.

mp-007 commented 1 year ago

@abedhammoud I think only the left axis will update when you used the zoombox. Also, the pan/zoom will be only apply on left axis.

mp-007 commented 1 year ago

https://github.com/mp-007/kivy_matplotlib_widget/issues/12#issuecomment-1345746831 As a temporary solution, I have add a new example in the repo "navigation_bar_general_purpose". This general purpose example is using matplotlib home, pan and zoombox (using NavigationToolbar2 like this repo https://github.com/jeysonmc/kivy_matplotlib). You have also the matplotib cursor x,y of mouse. You don't have the speed as MatPlotFigure with 1 axis and the zoombox is different but it is working.

temporary solution link: https://github.com/mp-007/kivy_matplotlib_widget/tree/main/navigation_bar_general_purpose

mp-007 commented 1 year ago

@abedhammoud if you want to test, I add a new experimental widget to handle 2 axis with twinx with lines.

https://github.com/mp-007/kivy_matplotlib_widget/tree/main/example_twinx_experimental

abedhammoud commented 1 year ago

Excellent work and a good complement to the single-axis widget. I have built a dashboard using the MatPlotFigure, and it is working well. My dashboard contains multiple axes. To provide the zoom/pan/ etc., I allow the user to select a given axes by name, as shown below (is there a way to index the axes by label? that would be better than what I am doing below):

    def select_fig_axes(self, axes_name):
        fig = self.ids._dashfigure.figure
        for ax in fig.axes:
            if ax.get_label() == axes_name:
                self.ids._dashfigure.axes = ax
                break

I will later implement the zoom/pan similar to the example you provided.

Now the user has to select the name of the axes from a spinner that I populate with the labels from the figure:

self.ids._axes_select.values = [ax.get_label() for ax in fig.axes]

It would be easier to allow the user to click on a given axes and have that axes selected. Is there a way to do so? i.e., catch the mouse click event and set the selected axes to the axes below the mouse click.

Thanks again,

mp-007 commented 1 year ago

I think you can set label name with set_label, but I think it will better to go with the title with get_title ans set_title. The user see the title in the graph so you can put all the axis title in a spinner for example. I'm not 100% sure if my code will work when you change the axis but you can give a try.

abedhammoud commented 1 year ago

Hi @mp-007, couldn't help myself get started with adding home/zoom/pan/cursor. The user selectes the axes with the spinner:

    def select_fig_axes(self, axes_name):
        fig = self.ids._dashfigure.figure
        for ax in fig.axes:
            if ax.get_label() == axes_name:
                self.ids._dashfigure.axes = ax
                break
        self.ids._dashfigure.register_lines(
            self.ids._dashfigure.axes.lines
        )

The the zoom works perfectly with whatever axes is selected.

The home however, doesn't work following a zoom (nothing happens). This is what I have in the kv file:

                                            Button:
                                                text: 'Home'
                                                height: dp(40)
                                                size_hint: (1, None)
                                                background_color: light_blue
                                                on_release: root.set_home()

and in the py file I have:

    @trace
    def set_home(self):
        self.ids._dashfigure.home()

The Pan works perfect.

the curson however, crashes shortly after it draws the cross-hair.

Traceback (most recent call last):
   File "C:\Users\abed\miniconda3\envs\alant\lib\runpy.py", line 196, in _run_module_as_main
     return _run_code(code, main_globals, None,
   File "C:\Users\abed\miniconda3\envs\alant\lib\runpy.py", line 86, in _run_code
     exec(code, run_globals)
   File "C:\Users\abed\OneDrive\Documents\dev\GitHub\alant-git\apps\dixisales-app\dixisales\__main__.py", line 2922, in <module>
     DixisalesApp().run()
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy\app.py", line 955, in run
     runTouchApp()
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy\base.py", line 574, in runTouchApp
     EventLoop.mainloop()
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy\base.py", line 339, in mainloop
     self.idle()
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy\base.py", line 383, in idle
     self.dispatch_input()
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy\base.py", line 334, in dispatch_input
     post_dispatch_input(*pop(0))
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy\base.py", line 296, in post_dispatch_input
     wid.dispatch('on_touch_move', me)
   File "kivy\_event.pyx", line 731, in kivy._event.EventDispatcher.dispatch
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy_matplotlib_widget\uix\graph_widget.py", line 445, in on_touch_move
     if self.transform_with_touch(event):
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy_matplotlib_widget\uix\graph_widget.py", line 336, in transform_with_touch
     self.hover(event)
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy_matplotlib_widget\uix\graph_widget.py", line 177, in hover
     index = min(np.searchsorted(self.x_cursor, xdata), len(self.y_cursor) - 1)
 TypeError: object of type 'numpy.float64' has no len()
abedhammoud commented 1 year ago

The only issue I have with using the title is that the title is optional. So far what I am doing seems to work ok.

mp-007 commented 1 year ago

I think you should update the ymin and ymax value when you change the axis.

self.ids._dashfigure.ymin(newaxis.get_ylim()[0])
self.ids._dashfigure.ymax(newaxis.get_ylim()[1])

image

mp-007 commented 1 year ago

for the cursor, I think you need to update self.lines with the new axis lines. ~To do that, used the register_lines function (it will set the cursor to the next axis)~ Wait, try to change self.lines first, because I think register_lines will create multiple cursor :

self.ids._dashfigure.register_lines(newaxis.lines)

image

abedhammoud commented 1 year ago

Thanks, will try by this weekend, and let you know.

mp-007 commented 1 year ago

for the cursor, I think in the end, you will need to remove cursor line from the axis with something like this

ax.lines.remove(self.ids._dashfigure.horizontal_line)
ax.lines.remove(self.ids._dashfigure.vertical_line)

and recreate the same things as register line but without the self.text

        self.horizontal_line = newaxis.axhline(color='k', lw=0.8, ls='--', visible=False)
        self.vertical_line = newaxis.axvline(color='k', lw=0.8, ls='--', visible=False)

        #register lines
        self.lines=newaxis.lines
mp-007 commented 1 year ago

@abedhammoud I have made some tests and it can work if you set fast_draw=False. The current blit method do not correctly work without this.

This is by main.py file

from kivy.utils import platform

#avoid conflict between mouse provider and touch (very important with touch device)
#no need for android platform
if platform != 'android':
    from kivy.config import Config
    Config.set('input', 'mouse', 'mouse,disable_on_activity')

from kivy.lang import Builder
from kivy.app import App
import matplotlib.pyplot as plt
import numpy as np
import matplotlib as mpl
from kivy.metrics import dp

#optimized draw on Agg backend
mpl.rcParams['path.simplify'] = True
mpl.rcParams['path.simplify_threshold'] = 1.0
mpl.rcParams['agg.path.chunksize'] = 1000

#define some matplotlib figure parameters
mpl.rcParams['font.family'] = 'Verdana'
mpl.rcParams['axes.spines.top'] = False
mpl.rcParams['axes.linewidth'] = 1.0

KV = '''
#:import MatplotFigure graph_widget

Screen
    figure_wgt:figure_wgt
    BoxLayout:
        orientation:'vertical'
        BoxLayout:
            size_hint_y:0.2
            Button:
                text:"home"
                on_release:app.home()
            ToggleButton:
                group:'touch_mode'
                state:'down'
                text:"pan" 
                on_release:
                    app.set_touch_mode('pan')
                    self.state='down'
            ToggleButton:
                group:'touch_mode'
                text:"zoom box"  
                on_release:
                    app.set_touch_mode('zoombox')
                    self.state='down' 
            ToggleButton:
                group:'touch_mode'
                text:"cursor"  
                on_release:
                    app.set_touch_mode('cursor')
                    self.state='down'                       
        MatplotFigure:
            id:figure_wgt
        BoxLayout:
            size_hint_y:0.2
            ToggleButton:
                group:'choose_axis'
                state:'down'
                text:"left axis"  
                on_release:
                    app.set_axis('ax1')
                    self.state='down' 
            ToggleButton:
                group:'choose_axis'
                text:"right axis"  
                on_release:
                    app.set_axis('ax2')
                    self.state='down'            
'''

class Test(App):
    lines = []

    def build(self):  
        self.screen=Builder.load_string(KV)
        return self.screen

    def on_start(self, *args):
        fig,ax1 = plt.subplots(1,1)
        ax2 = ax1.twinx()
        x = np.linspace(0,2*np.pi,100)
        ax1.plot(x,np.sin(x),'b')
        ax1.set_xlabel('Scaleable axis')
        ax1.set_ylabel('Scaleable axis')
        ax2.plot(x,np.sin(x+1),'r')
        ax2.set_ylabel('Static axis',weight='bold')

        self.screen.figure_wgt.figure = fig
        self.screen.figure_wgt.fast_draw=False

        self.lines=list(fig.axes[0].lines)
        self.screen.figure_wgt.register_lines(self.lines)

    def set_touch_mode(self,mode):
        self.screen.figure_wgt.touch_mode=mode

    def home(self):
        self.screen.figure_wgt.home()

    def set_axis(self,axis):
        if axis=='ax1':
            #left axis
            newaxis=self.screen.figure_wgt.figure.axes[0]
            self.screen.figure_wgt.axes=newaxis

            #do not include cursor lines
            left_axis_lines=[]
            for left_axis_line in list(self.screen.figure_wgt.figure.axes[0].lines):
                if left_axis_line != self.screen.figure_wgt.horizontal_line and \
                    left_axis_line != self.screen.figure_wgt.vertical_line:
                        left_axis_lines.append(left_axis_line)
            self.screen.figure_wgt.lines = left_axis_lines

        elif axis=='ax2':
            #right axis
            newaxis=self.screen.figure_wgt.figure.axes[1]
            self.screen.figure_wgt.axes=newaxis
            self.screen.figure_wgt.lines = list(self.screen.figure_wgt.figure.axes[1].lines)

        #ymin/ymax from left axis data
        ymin_list=[]
        ymax_list=[]
        for current_lines in self.screen.figure_wgt.lines:
            ymin_list.append(min(current_lines.get_ydata()))
            ymax_list.append(max(current_lines.get_ydata()))

        yoffset = abs(max(ymax_list) - min(ymin_list)) * 0.01                 
        self.screen.figure_wgt.ymin = min(ymin_list) - yoffset  
        self.screen.figure_wgt.ymax = max(ymax_list) + yoffset 

        old_axis=self.screen.figure_wgt.horizontal_line.axes
        old_axis.lines.remove(self.screen.figure_wgt.horizontal_line)
        old_axis.lines.remove(self.screen.figure_wgt.vertical_line)
        self.screen.figure_wgt.horizontal_line = newaxis.axhline(color='k', lw=0.8, ls='--', visible=False)
        self.screen.figure_wgt.vertical_line = newaxis.axvline(color='k', lw=0.8, ls='--', visible=False)

        #register lines
        self.lines=newaxis.lines        

Test().run()

image

abedhammoud commented 1 year ago

Thanks. I will have fun with it over the weekend. @mp-007 if you can publish an example where the figure contains two or more axes and be able to select/control each separately it would be of great help. For example, add a bar or pie plot to the plot example that your showing. Sorry if I am asking too much. Thanks.

mp-007 commented 1 year ago

@abedhammoud for bar and pie plot, the cursor will not work, so only the min/max need to be set. I know it will be great if the cursor work on all different graph type, but this part need a lot of work.

note that I have used graph_widget.py release on 14th december for my example.

abedhammoud commented 1 year ago

@mp-007, is it ok if I email you my trials, and once I get all working, I can post the final version here. Many thanks.

mp-007 commented 1 year ago

yes, you can share me a link via discord as a private message.

abedhammoud commented 1 year ago

Hi @mp-007, I couldn't send the whole file with discord so I will try again here. Basically, I am using your excellent widget as a dashboard for my BI application. For a given report, the dashboard normally contains 1 or more axes (plot, bar, pi, etc). Here is the code so far (I am trying to use it as a component in my own widget):


import kivy.config
from kivy.app import App
from kivy.lang import Builder
from kivy.core.window import Window
from kivy.uix.boxlayout import BoxLayout

import numpy as np
import matplotlib.pyplot as plt

Builder.load_string('''

#:import MatplotFigure kivy_matplotlib_widget
#:set light_blue (176/255, 192/255, 222/255, 1)

<Kivydash>:
    dash: _dash
    axes_selector: _axes_select
    BoxLayout:
        orientation: 'vertical'
        padding: 0
        spacing: 0
        MatplotFigure:
            id: _dash
            size_hint: (1, 1)
        GridLayout:
            cols: 5
            padding: 0
            spacing: 0
            size_hint: (1, None)
            height: self.minimum_height
            Spinner:
                id: _axes_select
                text: 'Select Axes'
                height: dp(40)
                size_hint: (1, None)
                background_color: light_blue
                on_text: root.select_fig_axes(self.text)
            Button:
                text: 'Home'
                height: dp(40)
                size_hint: (1, None)
                background_color: light_blue
                on_release: root.set_home()
            ToggleButton:
                text: 'Pan'
                height: dp(40)
                group: 'touch_mode'
                state: 'down'
                size_hint: (1, None)
                background_color: light_blue
                on_release:
                    root.set_touch_mode('pan')
                    self.state='down'
            ToggleButton:
                text: 'Zoom'
                height: dp(40)
                group: 'touch_mode'
                size_hint: (1, None)
                background_color: light_blue
                on_release:
                    root.set_touch_mode('zoombox')
                    self.state='down'
            ToggleButton:
                text: 'Cursor'
                height: dp(40)
                group: 'touch_mode'
                size_hint: (1, None)
                background_color: light_blue
                on_release:
                    root.set_touch_mode('cursor')
                    self.state='down'

''', filename='kivydash.kv')

class Kivydash(BoxLayout):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def select_fig_axes(self, axes_label):
        fig = self.dash.figure
        self.dash.axes = fig.axes[int(axes_label)]

    def set_touch_mode(self, mode):
        self.dash.touch_mode = mode

    def set_home(self):
        self.dash.home()

class TestApp(App):
    """Test app."""

    def build(self):
        Window.bind(on_resize=self.on_resize)
        return Kivydash()

    def on_start(self, *args):

        ################
        # create figure.
        ################
        fig, ax = plt.subplots(2, 2)

        # plot
        x = np.linspace(0, 2 * np.pi, 100)
        ax[0, 0].plot(x, np.sin(x), 'b')

        # bar
        langs = ['C', 'C++', 'Java', 'Python', 'PHP']
        students = [23, 17, 35, 29, 12]
        ax[0, 1].bar(langs,students)

        # pie
        ax[1, 0].pie(students, labels=langs)

        #####################################
        # plot multi axes figure on dashbord.
        #####################################
        self.root.dash.figure = fig
        self.root.dash.fast_draw=False
        self.root.axes_selector.values = ['0', '1', '2']

        self.lines=list(fig.axes[0].lines)

        # initialize to 1st axes
        self.root.dash.axes = fig.axes[0]

    def on_resize(self, instance, width, height):
        """Window resize callback."""
        kivy.config.Config.set('graphics', 'width', width)
        kivy.config.Config.set('graphics', 'height', height)
        kivy.config.Config.write()

if __name__ == '__main__':
    TestApp().run()

Here are the issues I have - thanks for any pointers:

abedhammoud commented 1 year ago

Code updated:

import kivy.config
from kivy.app import App
from kivy.lang import Builder
from kivy.core.window import Window
from kivy.uix.boxlayout import BoxLayout

import numpy as np
import matplotlib.pyplot as plt

Builder.load_string('''

#:import MatplotFigure kivy_matplotlib_widget
#:set light_blue (176/255, 192/255, 222/255, 1)

<Kivydash>:
    display: _display
    axes_selector: _axes_select
    BoxLayout:
        orientation: 'vertical'
        padding: 0
        spacing: 0
        MatplotFigure:
            id: _display
        GridLayout:
            cols: 5
            padding: 0
            spacing: 0
            size_hint: (1, None)
            height: self.minimum_height
            Spinner:
                id: _axes_select
                text: 'Select Axes'
                height: dp(40)
                size_hint: (1, None)
                background_color: light_blue
                on_text: root.select_fig_axes(self.text)
            Button:
                text: 'Home'
                height: dp(40)
                size_hint: (1, None)
                background_color: light_blue
                on_release: root.set_home()
            ToggleButton:
                text: 'Pan'
                height: dp(40)
                group: 'touch_mode'
                state: 'down'
                size_hint: (1, None)
                background_color: light_blue
                on_release:
                    root.set_touch_mode('pan')
                    self.state='down'
            ToggleButton:
                text: 'Zoom'
                height: dp(40)
                group: 'touch_mode'
                size_hint: (1, None)
                background_color: light_blue
                on_release:
                    root.set_touch_mode('zoombox')
                    self.state='down'
            ToggleButton:
                text: 'Cursor'
                height: dp(40)
                group: 'touch_mode'
                size_hint: (1, None)
                background_color: light_blue
                on_release:
                    root.set_touch_mode('cursor')
                    self.state='down'

''', filename='kivydash.kv')

class Kivydash(BoxLayout):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def select_fig_axes(self, axes_label):
        fig = self.display.figure
        self.display.axes = fig.axes[int(axes_label)]

    def set_touch_mode(self, mode):
        self.display.touch_mode = mode

    def set_home(self):
        self.display.home()

    def show(self, fig):
        if self.display.figure and fig != self.display.figure:
            self.display.figure.clear()

        self.display.figure = fig
        self.display.fast_draw = False

        self.display.axes = fig.axes[0]
        self.axes_selector.values = [ax.get_label() for ax in fig.axes]

        # self.display.figure.canvas.draw_idle()
        # self.display.figure.canvas.flush_events()

class TestApp(App):
    """Test app."""

    def build(self):
        Window.bind(on_resize=self.on_resize)
        return Kivydash()

    def on_start(self, *args):

        ################
        # create figure.
        ################
        fig, ax = plt.subplots(2, 2)

        # plot
        x = np.linspace(0, 2 * np.pi, 100)
        ax[0, 0].plot(x, np.sin(x), 'b')

        # bar
        langs = ['C', 'C++', 'Java', 'Python', 'PHP']
        students = [23, 17, 35, 29, 12]
        ax[0, 1].bar(langs,students)

        # pie
        ax[1, 0].pie(students, labels=langs)

        #####################################
        # plot multi axes figure on dashbord.
        #####################################
        self.root.show(fig)

    def on_resize(self, instance, width, height):
        """Window resize callback."""
        kivy.config.Config.set('graphics', 'width', width)
        kivy.config.Config.set('graphics', 'height', height)
        kivy.config.Config.write()

if __name__ == '__main__':
    TestApp().run()
mp-007 commented 1 year ago

I will have a look during the weekend, but I can already see 2 things:

abedhammoud commented 1 year ago

Thanks @mp-007 , I shortened the code, and integrated the nav tool bar, which I cannot seem to get it connected to the figure. Thanks for any pointers.

import logging
import kivy.config
from kivy.app import App
from kivy.lang import Builder
from kivy.core.window import Window
from kivy.uix.boxlayout import BoxLayout

import numpy as np
import matplotlib.pyplot as plt

Builder.load_string('''

#:import MatplotFigure kivy_matplotlib_widget
#:import MatplotNavToolbar navigation_bar_widget

<Kivydash>:
    display: _figure_wgt
    navbar: _navbar_wgt
    BoxLayout:
        orientation: 'vertical'
        padding: [dp(10), 0, 0, dp(0)]
        spacing: 0
        MatplotFigure:
            id: _figure_wgt
        MatplotNavToolbar:
            id: _navbar_wgt
            size_hint: 1, 0.2
            figure_widget: _figure_wgt

''', filename='kivydash.kv')

class Kivydash(BoxLayout):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def show(self, fig):

        logging.warning(f'{plt.get_fignums()=}')
        logging.warning(f'{plt.gcf()=}')

        if self.display.figure:
            plt.close('all')

        self.display.figure = fig
        self.display.fast_draw = False

        self.display.figure.canvas.draw_idle()
        self.display.figure.canvas.flush_events()

        self.display.axes = fig.axes[0]

        # attach nav bar.
        self.navbar._navtoolbar._init_toolbar()

class TestApp(App):
    """Test app."""

    def build(self):
        Window.bind(on_resize=self.on_resize)
        return Kivydash()

    def set_touch_mode(self, mode):
        print(f'{mode=}')
        self.display.touch_mode = mode

    def home(self):
        print('set home...')
        self.display.home()

    def on_start(self, *args):

        # create figure.
        fig, ax = plt.subplots(2, 2)

        # plot
        x = np.linspace(0, 2 * np.pi, 100)
        ax[0, 0].plot(x, np.sin(x), 'b')

        # bar
        langs = ['C', 'C++', 'Java', 'Python', 'PHP']
        students = [23, 17, 35, 29, 12]
        ax[0, 1].bar(langs,students)

        # pie
        ax[1, 0].pie(students, labels=langs)

        # hbar
        ax[1, 1].barh(langs,students)

        # plot multi axes figure on dashbord.
        self.root.show(fig)

    def on_resize(self, instance, width, height):
        """Window resize callback."""
        kivy.config.Config.set('graphics', 'width', width)
        kivy.config.Config.set('graphics', 'height', height)
        kivy.config.Config.write()

if __name__ == '__main__':
    TestApp().run()
mp-007 commented 1 year ago

you need to used the MatPlotFigureGenaral widget (only in develop branch, will be add on net release). Do something like this.

...
Builder.load_string('''

#:import MatplotFigureGeneral graph_widget_general
#:import MatplotNavToolbar navigation_bar_widget

<Kivydash>:
    display: _figure_wgt
    navbar: _navbar_wgt
    BoxLayout:
        orientation: 'vertical'
        padding: [dp(10), 0, 0, dp(0)]
        spacing: 0
        MatplotFigureGeneral:
            id: _figure_wgt
        MatplotNavToolbar:
            id: _navbar_wgt
            size_hint: 1, 0.2
            figure_widget: _figure_wgt

''', filename='kivydash.kv')

class Kivydash(BoxLayout):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def show(self, fig):

        logging.warning(f'{plt.get_fignums()=}')
        logging.warning(f'{plt.gcf()=}')

        if self.display.figure:
            plt.close('all')

        self.display.figure = fig
        # self.display.fast_draw = False

        self.display.figure.canvas.draw_idle()
        self.display.figure.canvas.flush_events()

        # self.display.axes = fig.axes[0]
...
abedhammoud commented 1 year ago

Thanks, @mp-007, excellent work. Really appreciated. It works great.

abedhammoud commented 1 year ago

Hi @mp-007, this is the latest with the changes you recommended above, and added more to explain my usecase.


# avoid conflict between mouse provider and touch - very important with
# touch device. no need for android platform
from kivy.utils import platform
if platform != 'android':
    from kivy.config import Config
    Config.set('input', 'mouse', 'mouse,disable_on_activity')

import logging
from kivy.app import App
from kivy.metrics import dp
from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout

import numpy as np
import matplotlib.pyplot as plt

from alant.utils.decotools import trace

# optimized draw on Agg backend
import matplotlib as mpl
mpl.rcParams['path.simplify'] = True
mpl.rcParams['agg.path.chunksize'] = 1000
mpl.rcParams['path.simplify_threshold'] = 1.0

# define some matplotlib figure parameters
# mpl.rcParams['axes.linewidth'] = 1.0
# mpl.rcParams['font.family'] = 'Verdana'
# mpl.rcParams['axes.spines.top'] = False

Builder.load_string('''

#:import MatplotFigureGeneral graph_widget_general
#:import MatplotNavToolbar navigation_bar_widget

<Kivydash>:
    display: _figure_wgt
    navbar: _navbar_wgt
    BoxLayout:
        orientation:'vertical'
        # left, top, right, bottom
        padding: [dp(10), 0, 0, 0]
        spacing: 0
        MatplotFigureGeneral:
            id:_figure_wgt
        MatplotNavToolbar:
            id: _navbar_wgt
            height: dp(60)
            size_hint: 1, None
            figure_widget: _figure_wgt
''', filename='kivydash.kv')

class Kivydash(BoxLayout):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    @trace
    def show(self, fig):

        logging.warning(f'{plt.get_fignums()=}')
        logging.warning(f'{plt.gcf()=}')

        if self.display.figure:
            plt.close('all')

        plt.tight_layout()

        self.display.figure = fig

        # attach navigation bar.
        self.navbar._navtoolbar._init_toolbar()

        ####################################
        # if user want to hide cursor label.
        ####################################
        # self.navbar.ids.info_lbl.opacity=0
        # self.navbar.ids.info_lbl.height=dp(0.01)
        # self.navbar.ids.info_lbl.size_hint_y=None

class TestApp(App):

    def build(self):
        root = BoxLayout()

        # dashboard
        self._dash = Kivydash()

        # control
        control = BoxLayout(
            orientation='vertical',
            size_hint=(None, 1), width=dp(100)
        )
        control.add_widget(
            Button(
                text='Fig-0',
                on_release=self.display_figure_0
            )
        )
        control.add_widget(
            Button(
                text='Fig-1',
                on_release=self.display_figure_1
            )
        )
        control.add_widget(
            Button(
                text='Fig-2',
                on_release=self.display_figure_2
            )
        )

        root.add_widget(control)
        root.add_widget(self._dash)

        return root

    def on_start(self, *args):
        fig = self.build_figure_0()
        self._dash.show(fig)

    def display_figure_0(self, *args):
        fig = self.build_figure_0()
        self._dash.show(fig)

    def display_figure_1(self, *args):
        fig = self.build_figure_1()
        self._dash.show(fig)

    def display_figure_2(self, *args):
        fig = self.build_figure_2()
        self._dash.show(fig)

    def build_figure_0(self):
        fig, ax = plt.subplots(
            2, 3,
            figsize=(18, 11),
            constrained_layout=True,
        )

        # twin axes plot
        x = np.linspace(0, 2 * np.pi, 100)
        ax[0, 0].plot(x, np.sin(x), 'b')
        ax[0, 0].set_xlabel('Scaleable axis')
        ax[0, 0].set_ylabel('Scaleable axis')

        ax2 = ax[0, 0].twinx()
        ax2.plot(x, np.sin( x + 1), 'r')
        ax2.set_ylabel('Static axis', weight='bold')

        # bar plots
        langs = ['C', 'C++', 'Java', 'Python', 'PHP']
        students = [23, 17, 35, 29, 12]
        ax[0, 1].bar(langs, students)
        ax[1, 0].barh(langs, students)

        # plot
        ax[1, 1].plot(x, np.cos(x))

        # pie plot
        ax[0, 2].pie(students, labels=langs)
        ax[1, 2].pie(students, labels=langs)

        return fig

    def build_figure_1(self):
        fig, ax = plt.subplots(
            2, 2,
            figsize=(18, 11),
            constrained_layout=True,
        )

        # pie plot
        students = [23, 17, 35, 29, 12]
        langs = ['C', 'C++', 'Java', 'Python', 'PHP']
        ax[0, 0].pie(students, labels=langs)

        # twin axes plot
        x = np.linspace(0, 2 * np.pi, 100)
        ax[1, 1].plot(x, np.sin(x), 'b')
        ax[1, 1].set_xlabel('Scaleable axis')
        ax[1, 1].set_ylabel('Scaleable axis')

        ax2 = ax[1, 1].twinx()
        ax2.plot(x, np.sin( x + 1), 'r')
        ax2.set_ylabel('Static axis', weight='bold')

        # bar plots
        ax[0, 1].bar(langs, students)
        ax[1, 0].barh(langs, students)

        return fig

    def build_figure_2(self):
        fig, ax = plt.subplots(
            1, 1,
            figsize=(18, 11),
            constrained_layout=True,
        )

        # twin axes plot
        x = np.linspace(0, 2 * np.pi, 100)
        ax.plot(x, np.sin(x), 'b')
        ax.set_xlabel('Scaleable axis')
        ax.set_ylabel('Scaleable axis')

        ax2 = ax.twinx()
        ax2.plot(x, np.sin( x + 1), 'r')
        ax2.set_ylabel('Static axis', weight='bold')

        return fig

if __name__ == '__main__':
    TestApp().run()

It allows me to use the widget as a drawing board that I draw on multiple figures. It is close to perfect for what I need, but I don't seem to get the navigation bar working 100% yet.

When I use the zoom on one of the axes, the shaded box is clamped and cannot cover the whole axes. It seems that I need to reinitialize the navigation bar every time the show(fig) method is called with a new figure.

Another issue I noticed is that sometimes pressing the home button takes you to the previously displayed figure and not reset the current figure.

I must not be initializing the navbar correctly between calls to show(figure)

Sorry for my long postings. I hope I am helping somehow.

Thanks. I look forward to the next release.

mp-007 commented 1 year ago

You can't really control the axis with matplotlib navigation. It do want matplotlib decide and you can't really customized the zoom box for example. I think the best option in your case is to used several MatplotFigure and control your navigation tools separately on every figure.

abedhammoud commented 1 year ago

OK, thanks, I will try that.

mp-007 commented 1 year ago

@abedhammoud An example based on your code

# avoid conflict between mouse provider and touch - very important with
# touch device. no need for android platform
from kivy.utils import platform
if platform != 'android':
    from kivy.config import Config
    Config.set('input', 'mouse', 'mouse,disable_on_activity')

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.screenmanager import SlideTransition

import numpy as np
import matplotlib.pyplot as plt
from kivy.core.window import Window

# optimized draw on Agg backend
import matplotlib as mpl
mpl.rcParams['path.simplify'] = True
mpl.rcParams['agg.path.chunksize'] = 1000
mpl.rcParams['path.simplify_threshold'] = 1.0

# define some matplotlib figure parameters
# mpl.rcParams['axes.linewidth'] = 1.0
# mpl.rcParams['font.family'] = 'Verdana'
# mpl.rcParams['axes.spines.top'] = False

Builder.load_string('''
#:import MatplotFigure graph_widget

<Kivydash>:

    BoxLayout
        size_hint_x:None
        width:dp(100)
        orientation:'vertical'
        Button:
            text:'Fig-0'
            on_release:
                app.display_figure('screen1')
        Button:
            text:'Fig-1'
            on_release:
                app.display_figure('screen2')

    ScreenManager:
        id:sm
        Screen1:
            name:'screen1'
            size: sm.size
            pos: sm.pos             
        Screen2:
            name:'screen2'
            size: sm.size
            pos: sm.pos           

<Screen1@Screen>:  
    _figure_wgt:_figure_wgt 
    _figure_wgt2:_figure_wgt2 
    _figure_wgt3:_figure_wgt3 
    _figure_wgt4:_figure_wgt4 
    _figure_wgt5:_figure_wgt5 
    _figure_wgt6:_figure_wgt6 

    BoxLayout:
        orientation:'vertical'
        # left, top, right, bottom
        padding: [dp(10), 0, 0, 0]
        BoxLayout:
            size_hint_y:0.2
            Button:
                text:"home"
                on_release:app.home()
            ToggleButton:
                group:'touch_mode'
                state:'down'
                text:"pan" 
                on_release:
                    app.set_touch_mode('pan')
                    self.state='down'
            ToggleButton:
                group:'touch_mode'
                text:"zoom box"  
                on_release:
                    app.set_touch_mode('zoombox')
                    self.state='down' 
            ToggleButton:
                group:'touch_mode'
                text:"cursor"  
                on_release:
                    app.set_touch_mode('cursor')
                    self.state='down' 

        BoxLayout:
            orientation:'vertical'
            BoxLayout:
                BoxLayout:
                    orientation:'vertical'
                    MatplotFigure:
                        id:_figure_wgt
                    BoxLayout:
                        size_hint_y:0.1
                        ToggleButton:
                            group:'choose_axis1'
                            state:'down'
                            text:"left axis"  
                            on_release:
                                app.set_axis(_figure_wgt,'ax1')
                                self.state='down' 
                        ToggleButton:
                            group:'choose_axis1'
                            text:"right axis"  
                            on_release:
                                app.set_axis(_figure_wgt,'ax2')
                                self.state='down'                     
                BoxLayout:
                    orientation:'vertical'                    
                    MatplotFigure:
                        id:_figure_wgt2
                    Widget:
                        size_hint_y:0.1                        
                BoxLayout:
                    orientation:'vertical'                        
                    MatplotFigure:
                        id:_figure_wgt3 
                    Widget:
                        size_hint_y:0.1                          
            BoxLayout:
                MatplotFigure:
                    id:_figure_wgt4
                MatplotFigure:
                    id:_figure_wgt5
                MatplotFigure:
                    id:_figure_wgt6  

<Screen2@Screen>:  
    _figure_wgt:_figure_wgt 
    _figure_wgt2:_figure_wgt2 
    _figure_wgt3:_figure_wgt3 
    _figure_wgt4:_figure_wgt4

    BoxLayout:
        orientation:'vertical'
        padding: [dp(10), 0, 0, 0]
        BoxLayout:
            size_hint_y:0.2
            Button:
                text:"home"
                on_release:app.home()
            ToggleButton:
                group:'touch_mode'
                state:'down'
                text:"pan" 
                on_release:
                    app.set_touch_mode('pan')
                    self.state='down'
            ToggleButton:
                group:'touch_mode'
                text:"zoom box"  
                on_release:
                    app.set_touch_mode('zoombox')
                    self.state='down' 
            ToggleButton:
                group:'touch_mode'
                text:"cursor"  
                on_release:
                    app.set_touch_mode('cursor')
                    self.state='down' 

        BoxLayout:
            orientation:'vertical'
            BoxLayout:
                BoxLayout:
                    orientation:'vertical'                
                    MatplotFigure:
                        id:_figure_wgt
                    Widget:
                        size_hint_y:0.1                    
                BoxLayout:
                    orientation:'vertical'                     
                    MatplotFigure:
                        id:_figure_wgt2
                    BoxLayout:
                        size_hint_y:0.1
                        ToggleButton:
                            group:'choose_axis2'
                            state:'down'
                            text:"left axis"  
                            on_release:
                                app.set_axis(_figure_wgt2,'ax1')
                                self.state='down' 
                        ToggleButton:
                            group:'choose_axis2'
                            text:"right axis"  
                            on_release:
                                app.set_axis(_figure_wgt2,'ax2')
                                self.state='down'                      

            BoxLayout:
                MatplotFigure:
                    id:_figure_wgt3 

                MatplotFigure:
                    id:_figure_wgt4

''', filename='kivydash.kv')

class Kivydash(BoxLayout):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

class TestApp(App):

    def build(self):

        # dashboard
        self._dash = Kivydash()

        return self._dash

    def on_start(self, *args):
        self.build_figure_0()
        self.build_figure_1()
        Window.maximize()

    def display_figure(self,screen):
        self._dash.ids.sm.tansition = SlideTransition(duration=0.2)
        self._dash.ids.sm.current=screen

    def build_figure_0(self):
        fig1, ax1 = plt.subplots(1,1)
        fig1.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)

        # twin axes plot
        x = np.linspace(0, 2 * np.pi, 100)
        ax1.plot(x, np.sin(x), 'b')
        ax1.set_xlabel('Scaleable axis')
        ax1.set_ylabel('Scaleable axis')

        ax2 = ax1.twinx()
        ax2.plot(x, np.sin( x + 1), 'r')
        ax2.set_ylabel('Static axis', weight='bold')

        fig2, ax3 = plt.subplots(1,1)
        fig2.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)

        # bar plots
        langs = ['C', 'C++', 'Java', 'Python', 'PHP']
        students = [23, 17, 35, 29, 12]
        ax3.bar(langs, students)

        fig3, ax4 = plt.subplots(1,1)
        fig3.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)        

        ax4.barh(langs, students)

        fig4, ax5 = plt.subplots(1,1)
        fig4.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)        

        # plot
        ax5.plot(x, np.cos(x))

        fig5, ax6 = plt.subplots(1,1)
        fig5.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2) 
        # pie plot
        ax6.pie(students, labels=langs)

        fig6, ax7 = plt.subplots(1,1)
        fig6.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)         
        ax7.pie(students, labels=langs)

        screen1=self._dash.ids.sm.get_screen('screen1')
        screen1._figure_wgt.figure=fig1
        screen1._figure_wgt2.figure=fig2
        screen1._figure_wgt2.fast_draw=False
        screen1._figure_wgt3.figure=fig3
        screen1._figure_wgt3.fast_draw=False
        screen1._figure_wgt4.figure=fig4
        screen1._figure_wgt5.figure=fig5
        screen1._figure_wgt5.fast_draw=False
        screen1._figure_wgt6.figure=fig6
        screen1._figure_wgt6.fast_draw=False

        screen1._figure_wgt.register_lines(list(screen1._figure_wgt.axes.lines))
        screen1._figure_wgt.fast_draw=False
        screen1._figure_wgt4.register_lines(list(screen1._figure_wgt4.axes.lines))

    def build_figure_1(self):
        fig1, ax1 = plt.subplots(1,1)
        fig1.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)

        # pie plot
        students = [23, 17, 35, 29, 12]
        langs = ['C', 'C++', 'Java', 'Python', 'PHP']
        ax1.pie(students, labels=langs)

        fig2, ax2 = plt.subplots(1,1)
        fig2.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)
        # twin axes plot
        x = np.linspace(0, 2 * np.pi, 100)
        ax2.plot(x, np.sin(x), 'b')
        ax2.set_xlabel('Scaleable axis')
        ax2.set_ylabel('Scaleable axis')

        ax3 = ax2.twinx()
        ax3.plot(x, np.sin( x + 1), 'r')
        ax3.set_ylabel('Static axis', weight='bold')

        # bar plots
        fig3, ax4 = plt.subplots(1,1)
        fig3.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)        
        ax4.bar(langs, students)
        fig4, ax5 = plt.subplots(1,1)
        fig4.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)       
        ax5.barh(langs, students)

        screen2 = self._dash.ids.sm.get_screen('screen2')
        screen2._figure_wgt.figure=fig1
        screen2._figure_wgt.fast_draw=False
        screen2._figure_wgt2.figure=fig2
        screen2._figure_wgt3.figure=fig3
        screen2._figure_wgt3.fast_draw=False
        screen2._figure_wgt4.figure=fig4
        screen2._figure_wgt4.fast_draw=False

        screen2._figure_wgt2.register_lines(list(screen2._figure_wgt2.axes.lines))
        screen2._figure_wgt2.fast_draw=False

    def set_touch_mode(self,mode):
        if self._dash.ids.sm.current=='screen1':
            screen1=self._dash.ids.sm.get_screen('screen1')
            if mode=='cursor':
                screen1._figure_wgt.touch_mode=mode
                screen1._figure_wgt4.touch_mode=mode
            else:
                screen1._figure_wgt.touch_mode=mode
                screen1._figure_wgt2.touch_mode=mode
                screen1._figure_wgt3.touch_mode=mode
                screen1._figure_wgt4.touch_mode=mode
                screen1._figure_wgt5.touch_mode=mode
                screen1._figure_wgt6.touch_mode=mode        
        elif self._dash.ids.sm.current=='screen2':
            screen2=self._dash.ids.sm.get_screen('screen2')
            if mode=='cursor':
                screen2._figure_wgt2.touch_mode=mode
            else:
                screen2._figure_wgt.touch_mode=mode
                screen2._figure_wgt2.touch_mode=mode
                screen2._figure_wgt3.touch_mode=mode
                screen2._figure_wgt4.touch_mode=mode
    def home(self):
        if self._dash.ids.sm.current=='screen1':
            screen1=self._dash.ids.sm.get_screen('screen1')
            screen1._figure_wgt.home()
            screen1._figure_wgt2.home()
            screen1._figure_wgt3.home()
            screen1._figure_wgt4.home()
            screen1._figure_wgt5.home()
            screen1._figure_wgt6.home()        
        elif self._dash.ids.sm.current=='screen2':
            screen2=self._dash.ids.sm.get_screen('screen2')
            screen2._figure_wgt.home()
            screen2._figure_wgt2.home()
            screen2._figure_wgt3.home()
            screen2._figure_wgt4.home()

    def set_axis(self,instance,axis):

        if axis=='ax1':
            #left axis
            newaxis=instance.figure.axes[0]
            instance.axes=newaxis

            #do not include cursor lines
            left_axis_lines=[]
            for left_axis_line in list(instance.figure.axes[0].lines):
                if left_axis_line != instance.horizontal_line and \
                    left_axis_line != instance.vertical_line:
                        left_axis_lines.append(left_axis_line)
            instance.lines = left_axis_lines

        elif axis=='ax2':
            #right axis
            newaxis=instance.figure.axes[1]
            instance.axes=newaxis
            instance.lines = list(instance.figure.axes[1].lines)

        #ymin/ymax from left axis data
        ymin_list=[]
        ymax_list=[]
        for current_lines in instance.lines:
            ymin_list.append(min(current_lines.get_ydata()))
            ymax_list.append(max(current_lines.get_ydata()))

        instance.ymin = min(ymin_list)  
        instance.ymax = max(ymax_list) 

        yoffset = abs(max(ymax_list) - min(ymin_list)) * 0.01                 
        instance.ymin = min(ymin_list) - yoffset  
        instance.ymax = max(ymax_list) + yoffset 

        # old_axis=instance.horizontal_line.axes
        # old_axis.lines.remove(instance.horizontal_line)
        # old_axis.lines.remove(instance.vertical_line)
        # instance.horizontal_line = newaxis.axhline(color='k', lw=0.8, ls='--', visible=False)
        # instance.vertical_line = newaxis.axvline(color='k', lw=0.8, ls='--', visible=False)

        #register lines
        self.lines=newaxis.lines   

if __name__ == '__main__':
    TestApp().run()

used the MatPlotFigure widget from master branch

image

mp-007 commented 1 year ago

Feel free to close this issue if you think handling multiple axis is working correctly.

abedhammoud commented 1 year ago

Thanks. Excellent work.