1"""
2This module provides Highcharts class, which is a thin wrapper around
3Highcharts JS library.
4"""
5from json import dumps as json
6from collections import defaultdict
7from collections.abc import MutableMapping
8from os.path import join, dirname
9
10import numpy as np
11
12from AnyQt.QtCore import QObject, pyqtSlot
13from AnyQt.QtWidgets import QApplication
14
15try:
16    from __opyhighcharts_interfaces import WebviewWidget
17except ImportError:
18    try:
19        from Orange.widgets.utils.webview import WebviewWidget
20    except ImportError:
21        # If required interfaces not available, provide some mocks
22        raise ImportError('opyhighcharts requires interface '
23                          'Orange.widgets.utils.webview.WebviewWidget'
24                          'positioned at '
25                          '__opyhighcharts_interfaces.WebviewWidget')
26
27
28def _Autotree():
29    return defaultdict(_Autotree)
30
31
32def _merge_dicts(master, update):
33    """Merge dicts recursively in place (``master`` is modified)"""
34    for k, v in master.items():
35        if k in update:
36            if isinstance(v, MutableMapping) and isinstance(update[k], MutableMapping):
37                update[k] = _merge_dicts(v, update[k])
38    master.update(update)
39    return master
40
41
42def _kwargs_options(kwargs):
43    """Transforma a dict into a hierarchical dict.
44
45    Example
46    -------
47    >>> (_kwargs_options(dict(a_b_c=1, a_d_e=2, x=3)) ==
48    ...  dict(a=dict(b=dict(c=1), d=dict(e=2)), x=3))
49    True
50    """
51    kwoptions = _Autotree()
52    for kws, val in kwargs.items():
53        cur = kwoptions
54        kws = kws.split('_')
55        for kw in kws[:-1]:
56            cur = cur[kw]
57        cur[kws[-1]] = val
58    return kwoptions
59
60
61class Highchart(WebviewWidget):
62    """Create a Highcharts webview widget.
63
64    Parameters
65    ----------
66    parent: QObject
67        Qt parent object, if any.
68    bridge: QObject
69        Exposed as ``window.pybridge`` in JavaScript.
70    options: dict
71        Default options for this chart. If any option's value is a string
72        that starts with an empty block comment ('/**/'), the expression
73        following is evaluated in JS. See Highcharts docs. Some options are
74        already set in the default theme.
75    highchart: str
76        One of `Chart`, `StockChart`, or `Map` Highcharts JS types.
77    enable_zoom: bool
78        Enables scroll wheel zooming and right-click zoom reset.
79    enable_select: str
80        If '+', allow series' points to be selected by clicking
81        on the markers, bars or pie slices. Can also be one of
82        'x', 'y', or 'xy' (all of which can also end with '+' for the
83        above), in which case it indicates the axes on which
84        to enable rectangle selection. The list of selected points
85        for each input series (i.e. a list of arrays) is
86        passed to the ``selection_callback``.
87        Each selected point is represented as its index in the series.
88        If the selection is empty, the callback parameter is a single
89        empty list.
90    javascript: str
91        Additional JavaScript code to evaluate beforehand. If you
92        need something exposed in the global namespace,
93        assign it as an attribute to the ``window`` object.
94    debug: bool
95        Enables right-click context menu and inspector tools.
96    **kwargs:
97        The additional options. The underscores in argument names imply
98        hierarchy, e.g., keyword argument such as ``chart_type='area'``
99        results in the following object, in JavaScript::
100
101            {
102                chart: {
103                    type: 'area'
104                }
105            }
106
107        The original `options` argument is updated with options from
108        these kwargs-derived objects.
109    """
110
111    _HIGHCHARTS_HTML = join(dirname(__file__), '_highcharts', 'chart.html')
112
113    def __init__(self,
114                 parent=None,
115                 bridge=None,
116                 options=None,
117                 *,
118                 highchart='Chart',
119                 enable_zoom=False,
120                 enable_select=False,
121                 selection_callback=None,
122                 javascript='',
123                 debug=False,
124                 **kwargs):
125        options = (options or {}).copy()
126        enable_select = enable_select or ''
127
128        if not isinstance(options, dict):
129            raise ValueError('options must be dict')
130        if enable_select not in ('', '+', 'x', 'y', 'xy', 'x+', 'y+', 'xy+'):
131            raise ValueError("enable_select must be '+', 'x', 'y', or 'xy'")
132        if enable_select and not selection_callback:
133            raise ValueError('enable_select requires selection_callback')
134
135        if enable_select:
136            # We need to make sure the _Bridge object below with the selection
137            # callback is exposed in JS via QWebChannel.registerObject() and
138            # not through WebviewWidget.exposeObject() as the latter mechanism
139            # doesn't transmit QObjects correctly.
140            class _Bridge(QObject):
141                @pyqtSlot('QVariantList')
142                def _highcharts_on_selected_points(self, points):
143                    selection_callback([np.sort(selected).astype(int)
144                                        for selected in points])
145            if bridge is None:
146                bridge = _Bridge()
147            else:
148                # Thus, we patch existing user-passed bridge with our
149                # selection callback method
150                attrs = bridge.__dict__.copy()
151                attrs['_highcharts_on_selected_points'] = _Bridge._highcharts_on_selected_points
152                assert isinstance(bridge, QObject), 'bridge needs to be a QObject'
153                _Bridge = type(bridge.__class__.__name__,
154                               bridge.__class__.__mro__,
155                               attrs)
156                bridge = _Bridge()
157
158        super().__init__(parent, bridge, debug=debug)
159
160        self.highchart = highchart
161        self.enable_zoom = enable_zoom
162        enable_point_select = '+' in enable_select
163        enable_rect_select = enable_select.replace('+', '')
164
165        self._update_options_dict(options, enable_zoom, enable_select,
166                                  enable_point_select, enable_rect_select,
167                                  kwargs)
168
169        with open(self._HIGHCHARTS_HTML, encoding='utf-8') as html:
170            self.setHtml(html.read() % dict(javascript=javascript,
171                                            options=json(options)),
172                         self.toFileURL(dirname(self._HIGHCHARTS_HTML)) + '/')
173
174    def _update_options_dict(self, options, enable_zoom, enable_select,
175                             enable_point_select, enable_rect_select, kwargs):
176        if enable_zoom:
177            _merge_dicts(options, _kwargs_options(dict(
178                mapNavigation_enableMouseWheelZoom=True,
179                mapNavigation_enableButtons=False)))
180        if enable_select:
181            _merge_dicts(options, _kwargs_options(dict(
182                chart_events_click='/**/unselectAllPoints/**/')))
183        if enable_point_select:
184            _merge_dicts(options, _kwargs_options(dict(
185                plotOptions_series_allowPointSelect=True,
186                plotOptions_series_point_events_click='/**/clickedPointSelect/**/')))
187        if enable_rect_select:
188            _merge_dicts(options, _kwargs_options(dict(
189                chart_zoomType=enable_rect_select,
190                chart_events_selection='/**/rectSelectPoints/**/')))
191        if kwargs:
192            _merge_dicts(options, _kwargs_options(kwargs))
193
194    def contextMenuEvent(self, event):
195        """ Zoom out on right click. Also disable context menu."""
196        if self.enable_zoom:
197            self.evalJS('chart.zoomOut(); 0;')
198        super().contextMenuEvent(event)
199
200    def exposeObject(self, name, obj):
201        if isinstance(obj, np.ndarray):
202            # Highcharts chokes on NaN values. Instead it prefers 'null' for
203            # points it is not intended to show.
204            obj = obj.astype(object)
205            obj[np.isnan(obj)] = None
206        super().exposeObject(name, obj)
207
208    def chart(self, options=None, *,
209              highchart=None, javascript='', javascript_after='', **kwargs):
210        """ Populate the webview with a new Highcharts JS chart.
211
212        Parameters
213        ----------
214        options, highchart, javascript, **kwargs:
215            The parameters are the same as for the object constructor.
216        javascript_after: str
217            Same as `javascript`, except that the code is evaluated
218            after the chart, available as ``window.chart``, is created.
219
220        Notes
221        -----
222        Passing ``{ series: [{ data: some_data }] }``, if ``some_data`` is
223        a numpy array, it is **more efficient** to leave it as numpy array
224        instead of converting it ``some_data.tolist()``, which is done
225        implicitly.
226        """
227        options = (options or {}).copy()
228        if not isinstance(options, MutableMapping):
229            raise ValueError('options must be dict')
230
231        if kwargs:
232            _merge_dicts(options, _kwargs_options(kwargs))
233        self.exposeObject('pydata', options)
234        highchart = highchart or self.highchart or 'Chart'
235        self.evalJS('''
236            {javascript};
237            window.chart = new Highcharts.{highchart}(pydata); 0;
238            {javascript_after};
239        '''.format(javascript=javascript,
240                   javascript_after=javascript_after,
241                   highchart=highchart,))
242
243    def clear(self):
244        """Remove all series from the chart"""
245        self.evalJS('''
246            if (window.chart) {
247                while(chart.series.length > 0) {
248                    chart.series[0].remove(false);
249                }
250                chart.redraw();
251            }; 0;
252        ''')
253
254
255def main():
256    """ A simple test. """
257    app = QApplication([])
258
259    def _on_selected_points(points):
260        print(len(points), points)
261
262    w = Highchart(enable_zoom=True, enable_select='xy+',
263                  selection_callback=_on_selected_points,
264                  debug=True)
265    w.chart(dict(series=[dict(data=np.random.random((100, 2)))]),
266            credits_text='BTYB Yours Truly',
267            title_text='Foo plot',
268            chart_type='scatter')
269    w.show()
270    app.exec()
271
272
273if __name__ == '__main__':
274    main()
275