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