├── README.md ├── demo.py ├── dfguik.py └── docs ├── sc1.png ├── sc2.png ├── sc3.png ├── sc4.png └── sc5.png /README.md: -------------------------------------------------------------------------------- 1 | # DataframeGUIKivy 2 | 3 | Port of Dataframe GUI module to a Kivy widget. Original module can be found at this repository: https://github.com/bluenote10/PandasDataFrameGUI 4 | 5 | ## Demo Run Instructions 6 | 7 | ```sh 8 | $ python demo.py 9 | ``` 10 | 11 | ## Demo Screenshots 12 | 13 |

14 | 15 |

16 | 17 |

18 | 19 |

20 | 21 |

22 | 23 |

24 | 25 |

26 | 27 |

28 | 29 |

30 | 31 |

32 | 33 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 3 | 4 | from __future__ import absolute_import, division, print_function 5 | 6 | from kivy.app import App 7 | 8 | import datetime 9 | import numpy as np 10 | import pandas as pd 11 | from dfguik import DfguiWidget 12 | 13 | 14 | def create_dummy_data(size): 15 | 16 | user_ids = np.random.randint(1, 1000000, 10) 17 | product_ids = np.random.randint(1, 1000000, 100) 18 | 19 | def choice(*values): 20 | return np.random.choice(values, size) 21 | 22 | random_dates = [ 23 | datetime.date(2016, 1, 1) + datetime.timedelta(days=int(delta)) 24 | for delta in np.random.randint(1, 50, size) 25 | ] 26 | return pd.DataFrame.from_items([ 27 | ("Date", random_dates), 28 | ("UserID", choice(*user_ids)), 29 | ("ProductID", choice(*product_ids)), 30 | ("IntColumn", choice(1, 2, 3)), 31 | ("FloatColumn", choice(np.nan, 1.0, 2.0, 3.0)), 32 | ("StringColumn", choice("A", "B", "C")), 33 | ("Gaussian 1", np.random.normal(0, 1, size)), 34 | ("Gaussian 2", np.random.normal(0, 1, size)), 35 | ("Uniform", np.random.uniform(0, 1, size)), 36 | ("Binomial", np.random.binomial(20, 0.1, size)), 37 | ("Poisson", np.random.poisson(1.0, size)), 38 | ]) 39 | 40 | class DataFrameApp(App): 41 | def build(self): 42 | df = create_dummy_data(1000) 43 | return DfguiWidget(df) 44 | 45 | if __name__ == '__main__': 46 | DataFrameApp().run() 47 | 48 | -------------------------------------------------------------------------------- /dfguik.py: -------------------------------------------------------------------------------- 1 | import kivy 2 | kivy.require('1.10.0') 3 | from kivy.lang import Builder 4 | from kivy.properties import ListProperty 5 | from kivy.uix.actionbar import ActionDropDown 6 | from kivy.uix.boxlayout import BoxLayout 7 | from kivy.uix.button import Button 8 | from kivy.uix.dropdown import DropDown 9 | from kivy.uix.label import Label 10 | from kivy.uix.popup import Popup 11 | from kivy.uix.tabbedpanel import TabbedPanel 12 | from kivy.uix.textinput import TextInput 13 | from kivy.uix.togglebutton import ToggleButton 14 | from kivy.uix.scrollview import ScrollView 15 | from kivy.uix.spinner import Spinner 16 | from kivy.uix.recycleview import RecycleView 17 | from kivy.properties import BooleanProperty,\ 18 | ObjectProperty,\ 19 | NumericProperty,\ 20 | StringProperty 21 | 22 | import matplotlib 23 | matplotlib.use('module://kivy.garden.matplotlib.backend_kivy') 24 | from matplotlib.figure import Figure 25 | from kivy.garden.matplotlib.backend_kivyagg import FigureCanvas,\ 26 | NavigationToolbar2Kivy 27 | import matplotlib.pyplot as plt 28 | 29 | from collections import OrderedDict 30 | import numpy as np 31 | import pandas as pd 32 | 33 | import datetime 34 | from datetime import date 35 | 36 | 37 | Builder.load_string(""" 38 | 39 | size_hint: (None, None) 40 | text_size: self.size 41 | halign: "center" 42 | valign: "middle" 43 | height: '30dp' 44 | background_disabled_normal: '' 45 | disabled_color: (1, 1, 1, 1) 46 | canvas.before: 47 | Color: 48 | rgba: 0.165, 0.165, 0.165, 1 49 | Rectangle: 50 | pos: self.pos 51 | size: self.size 52 | on_release: root.parent.parent.parent.parent._generate_table(self.text) 53 | 54 | : 55 | header: header 56 | bar_width: 0 57 | do_scroll: False 58 | size_hint: (1, None) 59 | effect_cls: "ScrollEffect" 60 | height: '30dp' 61 | GridLayout: 62 | id: header 63 | rows: 1 64 | size_hint: (None, None) 65 | width: self.minimum_width 66 | height: self.minimum_height 67 | 68 | : 69 | canvas.before: 70 | Color: 71 | rgba: [0.23, 0.23, 0.23, 1] if root.is_even else [0.2, 0.2, 0.2, 1] 72 | Rectangle: 73 | pos: self.pos 74 | size: self.size 75 | text: root.text 76 | font_size: "12dp" 77 | halign: "center" 78 | valign: "middle" 79 | text_size: self.size 80 | size_hint: 1, 1 81 | height: 60 82 | width: 400 83 | 84 | : 85 | rgrid: rgrid 86 | scroll_type: ['bars', 'content'] 87 | bar_color: [0.2, 0.7, 0.9, 1] 88 | bar_inactive_color: [0.2, 0.7, 0.9, .5] 89 | do_scroll_x: True 90 | do_scroll_y: True 91 | effect_cls: "ScrollEffect" 92 | viewclass: "ScrollCell" 93 | RecycleGridLayout: 94 | id: rgrid 95 | rows: root.nrows 96 | cols: root.ncols 97 | size_hint: (None, None) 98 | width: self.minimum_width 99 | height: self.minimum_height 100 | 101 | 102 | : 103 | panel1: data_frame_panel 104 | panel2: col_select_panel 105 | panel3: fil_select_panel 106 | panel4: hist_graph_panel 107 | panel5: scat_graph_panel 108 | 109 | do_default_tab: False 110 | 111 | TabbedPanelItem: 112 | text: 'Data Frame' 113 | on_release: root.open_panel1() 114 | DataframePanel: 115 | id: data_frame_panel 116 | TabbedPanelItem: 117 | text: 'Columns' 118 | ColumnSelectionPanel: 119 | id: col_select_panel 120 | TabbedPanelItem: 121 | text: 'Filters' 122 | FilterPanel: 123 | id: fil_select_panel 124 | TabbedPanelItem: 125 | text: 'Histogram' 126 | HistogramPlot: 127 | id: hist_graph_panel 128 | TabbedPanelItem: 129 | text: 'Scatter Plot' 130 | ScatterPlot: 131 | id: scat_graph_panel 132 | 133 | : 134 | orientation: 'vertical' 135 | 136 | : 137 | col_list: col_list 138 | orientation: 'vertical' 139 | ScrollView: 140 | do_scroll_x: False 141 | do_scroll_y: True 142 | size_hint: 1, 1 143 | scroll_timeout: 150 144 | GridLayout: 145 | id: col_list 146 | padding: "10sp" 147 | spacing: "5sp" 148 | cols:1 149 | row_default_height: '55dp' 150 | row_force_default: True 151 | size_hint_y: None 152 | 153 | : 154 | filter_list: filter_list 155 | orientation: 'vertical' 156 | ScrollView: 157 | do_scroll_x: False 158 | do_scroll_y: True 159 | size_hint: 1, 1 160 | scroll_timeout: 150 161 | GridLayout: 162 | id: filter_list 163 | padding: "10sp" 164 | spacing: "5sp" 165 | cols:1 166 | row_default_height: '55dp' 167 | row_force_default: True 168 | size_hint_y: None 169 | 170 | : 171 | select_btn: select_btn 172 | histogram: histogram 173 | orientation: 'vertical' 174 | Histogram: 175 | id: histogram 176 | BoxLayout: 177 | size_hint_y: None 178 | height: '48dp' 179 | Button: 180 | id: select_btn 181 | text: 'Select Column' 182 | on_release: root.dropdown.open(self) 183 | size_hint_y: None 184 | height: '48dp' 185 | 186 | : 187 | orientation: 'vertical' 188 | 189 | : 190 | select_btn1: select_btn1 191 | select_btn2: select_btn2 192 | scatter: scatter 193 | orientation: 'vertical' 194 | ScatterGraph: 195 | id: scatter 196 | BoxLayout: 197 | size_hint_y: None 198 | height: '48dp' 199 | Button: 200 | id: select_btn1 201 | text: 'Select Column 1' 202 | on_release: root.dropdown1.open(self) 203 | size_hint_y: None 204 | height: '48dp' 205 | Button: 206 | id: select_btn2 207 | text: 'Select Column 2' 208 | on_release: root.dropdown2.open(self) 209 | size_hint_y: None 210 | height: '48dp' 211 | 212 | 213 | : 214 | orientation: 'vertical' 215 | 216 | 217 | : 218 | #on_parent: self.dismiss() 219 | #on_select: btn.text = '{}'.format(args[1]) 220 | """) 221 | 222 | 223 | ''' Table Code from https://stackoverflow.com/questions/44463773/kivy-recycleview-recyclegridlayout-scrollable-label-problems#comment75948118_44463773 224 | ''' 225 | 226 | class HeaderCell(Button): 227 | pass 228 | 229 | 230 | class TableHeader(ScrollView): 231 | """Fixed table header that scrolls x with the data table""" 232 | header = ObjectProperty(None) 233 | 234 | def __init__(self, list_dicts=None, *args, **kwargs): 235 | super(TableHeader, self).__init__(*args, **kwargs) 236 | 237 | titles = list_dicts[0].keys() 238 | 239 | for title in titles: 240 | self.header.add_widget(HeaderCell(text=title)) 241 | 242 | 243 | class ScrollCell(Label): 244 | text = StringProperty(None) 245 | is_even = BooleanProperty(None) 246 | 247 | 248 | class TableData(RecycleView): 249 | nrows = NumericProperty(None) 250 | ncols = NumericProperty(None) 251 | rgrid = ObjectProperty(None) 252 | 253 | def __init__(self, list_dicts=[], *args, **kwargs): 254 | self.nrows = len(list_dicts) 255 | self.ncols = len(list_dicts[0]) 256 | 257 | super(TableData, self).__init__(*args, **kwargs) 258 | 259 | self.data = [] 260 | for i, ord_dict in enumerate(list_dicts): 261 | is_even = i % 2 == 0 262 | row_vals = ord_dict.values() 263 | for text in row_vals: 264 | self.data.append({'text': text, 'is_even': is_even}) 265 | 266 | def sort_data(self): 267 | #TODO: Use this to sort table, rather than clearing widget each time. 268 | pass 269 | 270 | 271 | class Table(BoxLayout): 272 | 273 | def __init__(self, list_dicts=[], *args, **kwargs): 274 | 275 | super(Table, self).__init__(*args, **kwargs) 276 | self.orientation = "vertical" 277 | 278 | self.header = TableHeader(list_dicts=list_dicts) 279 | self.table_data = TableData(list_dicts=list_dicts) 280 | 281 | self.table_data.fbind('scroll_x', self.scroll_with_header) 282 | 283 | self.add_widget(self.header) 284 | self.add_widget(self.table_data) 285 | 286 | def scroll_with_header(self, obj, value): 287 | self.header.scroll_x = value 288 | 289 | 290 | class DataframePanel(BoxLayout): 291 | """ 292 | Panel providing the main data frame table view. 293 | """ 294 | 295 | def populate_data(self, df): 296 | self.df_orig = df 297 | self.original_columns = self.df_orig.columns[:] 298 | self.current_columns = self.df_orig.columns[:] 299 | self._disabled = [] 300 | self.sort_key = None 301 | self._reset_mask() 302 | self._generate_table() 303 | 304 | def _generate_table(self, sort_key=None, disabled=None): 305 | self.clear_widgets() 306 | df = self.get_filtered_df() 307 | data = [] 308 | if disabled is not None: 309 | self._disabled = disabled 310 | keys = [x for x in df.columns[:] if x not in self._disabled] 311 | if sort_key is not None: 312 | self.sort_key = sort_key 313 | elif self.sort_key is None or self.sort_key in self._disabled: 314 | self.sort_key = keys[0] 315 | for i1 in range(len(df.iloc[:, 0])): 316 | row = OrderedDict.fromkeys(keys) 317 | for i2 in range(len(keys)): 318 | row[keys[i2]] = str(df.iloc[i1, i2]) 319 | data.append(row) 320 | data = sorted(data, key=lambda k: k[self.sort_key]) 321 | self.add_widget(Table(list_dicts=data)) 322 | 323 | def apply_filter(self, conditions): 324 | """ 325 | External interface to set a filter. 326 | """ 327 | old_mask = self.mask.copy() 328 | 329 | if len(conditions) == 0: 330 | self._reset_mask() 331 | 332 | else: 333 | self._reset_mask() # set all to True for destructive conjunction 334 | 335 | no_error = True 336 | for column, condition in conditions: 337 | if condition.strip() == '': 338 | continue 339 | condition = condition.replace("_", "self.df_orig['{}']".format(column)) 340 | print("Evaluating condition:", condition) 341 | try: 342 | tmp_mask = eval(condition) 343 | if isinstance(tmp_mask, pd.Series) and tmp_mask.dtype == np.bool: 344 | self.mask &= tmp_mask 345 | except Exception as e: 346 | print("Failed with:", e) 347 | no_error = False 348 | 349 | has_changed = any(old_mask != self.mask) 350 | 351 | def get_filtered_df(self): 352 | return self.df_orig.loc[self.mask, :] 353 | 354 | def _reset_mask(self): 355 | pass 356 | self.mask = pd.Series([True] * 357 | self.df_orig.shape[0], 358 | index=self.df_orig.index) 359 | 360 | 361 | class ColumnSelectionPanel(BoxLayout): 362 | """ 363 | Panel for selecting and re-arranging columns. 364 | """ 365 | 366 | def populate_columns(self, columns): 367 | """ 368 | When DataFrame is initialized, fill the columns selection panel. 369 | """ 370 | self.col_list.bind(minimum_height=self.col_list.setter('height')) 371 | for col in columns: 372 | self.col_list.add_widget(ToggleButton(text=col, state='down')) 373 | 374 | def get_disabled_columns(self): 375 | return [x.text for x in self.col_list.children if x.state != 'down'] 376 | 377 | 378 | class FilterPanel(BoxLayout): 379 | 380 | def populate(self, columns): 381 | self.filter_list.bind(minimum_height=self.filter_list.setter('height')) 382 | for col in columns: 383 | self.filter_list.add_widget(FilterOption(columns)) 384 | 385 | def get_filters(self): 386 | result=[] 387 | for opt_widget in self.filter_list.children: 388 | if opt_widget.is_option_set(): 389 | result.append(opt_widget.get_filter()) 390 | return [x.get_filter() for x in self.filter_list.children 391 | if x.is_option_set] 392 | 393 | 394 | 395 | class FilterOption(BoxLayout): 396 | 397 | def __init__(self, columns, **kwargs): 398 | super(FilterOption, self).__init__(**kwargs) 399 | self.height="30sp" 400 | self.size_hint=(0.9, None) 401 | self.spacing=10 402 | options = ["Select Column"] 403 | options.extend(columns) 404 | self.spinner = Spinner(text='Select Column', 405 | values= options, 406 | size_hint=(0.25, None), 407 | height="30sp", 408 | pos_hint={'center_x': .5, 'center_y': .5}) 409 | self.txt = TextInput(multiline=False, size_hint=(0.75, None),\ 410 | font_size="15sp") 411 | self.txt.bind(minimum_height=self.txt.setter('height')) 412 | self.add_widget(self.spinner) 413 | self.add_widget(self.txt) 414 | 415 | def is_option_set(self): 416 | return self.spinner.text != 'Select Column' 417 | 418 | def get_filter(self): 419 | return (self.spinner.text, self.txt.text) 420 | 421 | 422 | class ColDropDown(DropDown): 423 | pass 424 | 425 | 426 | class HistogramPlot(BoxLayout): 427 | """ 428 | Panel providing a histogram plot. 429 | """ 430 | 431 | def __init__(self, **kwargs): 432 | super(HistogramPlot, self).__init__(**kwargs) 433 | self.dropdown = ColDropDown() 434 | self.dropdown.bind(on_select=lambda instance, x: 435 | setattr(self.select_btn, 'text', x)) 436 | 437 | def populate_options(self, options): 438 | for index, option in enumerate(options): 439 | button = Button(text=option, size_hint=(1,None), height='48dp') 440 | button.bind(on_release=lambda x, y=index, z=option: 441 | self.on_combo_box_select(y,z)) 442 | self.dropdown.add_widget(button) 443 | 444 | 445 | def on_combo_box_select(self, index, text): 446 | self.dropdown.select(text) 447 | self.histogram.redraw(index) 448 | 449 | 450 | class Histogram(BoxLayout): 451 | """ 452 | Histogram portion of the histogram panel. 453 | """ 454 | 455 | def __init__(self, **kwargs): 456 | super(Histogram, self).__init__(**kwargs) 457 | self.figure, self.axes = plt.subplots() 458 | self.add_widget(NavigationToolbar2Kivy(self.figure.canvas).actionbar) 459 | self.add_widget(self.figure.canvas) 460 | 461 | def redraw(self, selection): 462 | column_index1 = selection 463 | df = self.parent.parent.parent.df # TODO: Do this more elegantly. 464 | if column_index1 < len(df.iloc[:]) and column_index1 >= 0 and len(df) > 0: 465 | # NOTE: The following code generates a Type error when attempting 466 | # to graph string data. The original code also generates this error, 467 | # but continues silently without alerting the user. 468 | self.axes.clear() 469 | try: 470 | self.axes.hist(np.array(df.iloc[:, column_index1].dropna().values), bins=100) 471 | except TypeError: 472 | self.warning("Invalid data type detected. Unable to generate graph.") 473 | except: 474 | self.warning("An unexpected error has occured.") 475 | finally: 476 | self.figure.canvas.draw() 477 | 478 | def warning(self, msg): 479 | layout = BoxLayout(orientation='vertical') 480 | layout.add_widget(Label(text=msg, 481 | size_hint_y=1, 482 | text_size=(250, None), 483 | halign='left', 484 | valign='middle')) 485 | button_layout = BoxLayout() 486 | close=Button(text="Close", size_hint=(0.8, 0.2)) 487 | close.bind(on_release = lambda x : popup.dismiss()) 488 | button_layout.add_widget(close) 489 | layout.add_widget(button_layout) 490 | popup = Popup(title='Histogram Error', 491 | content=layout, 492 | size_hint=(0.9, 0.9)) 493 | popup.open() 494 | 495 | class ScatterPlot(BoxLayout): 496 | """ 497 | Panel providing a histogram plot. 498 | """ 499 | 500 | def __init__(self, **kwargs): 501 | super(ScatterPlot, self).__init__(**kwargs) 502 | self.dropdown1 = ColDropDown() 503 | self.dropdown2 = ColDropDown() 504 | self.dropdown1.bind(on_select=lambda instance, x: 505 | setattr(self.select_btn1, 'text', x)) 506 | self.dropdown2.bind(on_select=lambda instance, x: 507 | setattr(self.select_btn2, 'text', x)) 508 | self.index1=-1 509 | self.index2=-1 510 | 511 | def populate_options(self, options): 512 | for index, option in enumerate(options): 513 | button = Button(text=option, size_hint=(1,None), height='48dp') 514 | button.bind(on_release=lambda x, y=index, z=option: 515 | self.on_combo_box_select1(y,z)) 516 | self.dropdown1.add_widget(button) 517 | for index, option in enumerate(options): 518 | button = Button(text=option, size_hint=(1,None), height='48dp') 519 | button.bind(on_release=lambda x, y=index, z=option: 520 | self.on_combo_box_select2(y,z)) 521 | self.dropdown2.add_widget(button) 522 | 523 | def on_combo_box_select1(self, index, text): 524 | self.dropdown1.select(text) 525 | self.index1 = index 526 | if self.index1 >=0 and self.index2 >= 0: 527 | self.scatter.redraw(self.index1, self.index2) 528 | 529 | def on_combo_box_select2(self, index, text): 530 | self.dropdown2.select(text) 531 | self.index2 = index 532 | if self.index1 >=0 and self.index2 >= 0: 533 | self.scatter.redraw(self.index1, self.index2) 534 | 535 | 536 | class ScatterGraph(BoxLayout): 537 | """ 538 | Histogram portion of the histogram panel. 539 | """ 540 | 541 | def __init__(self, **kwargs): 542 | super(ScatterGraph, self).__init__(**kwargs) 543 | self.figure, self.axes = plt.subplots() 544 | self.add_widget(NavigationToolbar2Kivy(self.figure.canvas).actionbar) 545 | self.add_widget(self.figure.canvas) 546 | 547 | def redraw(self, selection1, selection2): 548 | column_index1 = selection1 549 | column_index2 = selection2 550 | df = self.parent.parent.parent.df # TODO: Do this more elegantly. 551 | if column_index1 < len(df.iloc[:]) and\ 552 | column_index1 >= 0 and\ 553 | column_index2 < len(df.iloc[:]) and\ 554 | column_index2 >= 0 and len(df) > 0: 555 | # NOTE: The following code generates a Type error when attempting 556 | # to graph string data. The original code also generates this 557 | # error, but continues silently without alerting the user. 558 | self.axes.clear() 559 | try: 560 | self.axes.plot(df.iloc[:, column_index1].values, 561 | df.iloc[:, column_index2].values, 562 | 'o', clip_on=False) 563 | except TypeError: 564 | self.warning("Invalid data type detected. Unable to generate graph.") 565 | except: 566 | self.warning("An unexpected error has occured.") 567 | finally: 568 | self.figure.canvas.draw() 569 | 570 | def warning(self, msg): 571 | layout = BoxLayout(orientation='vertical') 572 | layout.add_widget(Label(text=msg, 573 | size_hint_y=1, 574 | text_size=(250, None), 575 | halign='left', 576 | valign='middle')) 577 | button_layout = BoxLayout() 578 | close=Button(text="Close", size_hint=(0.8, 0.2)) 579 | close.bind(on_release = lambda x : popup.dismiss()) 580 | button_layout.add_widget(close) 581 | layout.add_widget(button_layout) 582 | popup = Popup(title='Histogram Error', 583 | content=layout, 584 | size_hint=(0.9, 0.9)) 585 | popup.open() 586 | 587 | class DfguiWidget(TabbedPanel): 588 | 589 | def __init__(self, df, **kwargs): 590 | super(DfguiWidget, self).__init__(**kwargs) 591 | self.df = df 592 | self.panel1.populate_data(df) 593 | self.panel2.populate_columns(df.columns[:]) 594 | self.panel3.populate(df.columns[:]) 595 | self.panel4.populate_options(df.columns[:]) 596 | self.panel5.populate_options(df.columns[:]) 597 | 598 | # This should be changed so that the table isn't rebuilt 599 | # each time settings change. 600 | def open_panel1(self): 601 | #arr = self.panel3.get_filters() 602 | #print(str(arr)) 603 | self.panel1.apply_filter(self.panel3.get_filters()) 604 | self.panel1._generate_table(disabled= 605 | self.panel2.get_disabled_columns()) 606 | 607 | 608 | -------------------------------------------------------------------------------- /docs/sc1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelStott/PandasDataframeGUIKivy/2df89226539321f63a5c6fc13cd80120e8c99a84/docs/sc1.png -------------------------------------------------------------------------------- /docs/sc2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelStott/PandasDataframeGUIKivy/2df89226539321f63a5c6fc13cd80120e8c99a84/docs/sc2.png -------------------------------------------------------------------------------- /docs/sc3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelStott/PandasDataframeGUIKivy/2df89226539321f63a5c6fc13cd80120e8c99a84/docs/sc3.png -------------------------------------------------------------------------------- /docs/sc4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelStott/PandasDataframeGUIKivy/2df89226539321f63a5c6fc13cd80120e8c99a84/docs/sc4.png -------------------------------------------------------------------------------- /docs/sc5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MichaelStott/PandasDataframeGUIKivy/2df89226539321f63a5c6fc13cd80120e8c99a84/docs/sc5.png --------------------------------------------------------------------------------