diff --git a/plotly/figure_factory/README.md b/plotly/figure_factory/README.md index c88385ad457..53e01b5c79e 100644 --- a/plotly/figure_factory/README.md +++ b/plotly/figure_factory/README.md @@ -142,6 +142,10 @@ It is often not a good idea to put all your code into your `create_foo()` functi It is best to make all other functions besides `create_foo()` secret so a user cannot access them. This is done by placing a `_` before the name of the function, so `_aux_func()` for example. +6. Tests + +Add unit tests in +`plotly/tests/test_optional/test_figure_factory/test_figure_factory.py`. ## Create a Pull Request diff --git a/plotly/figure_factory/__init__.py b/plotly/figure_factory/__init__.py index a8be19872e1..b7dd72c21bd 100644 --- a/plotly/figure_factory/__init__.py +++ b/plotly/figure_factory/__init__.py @@ -18,6 +18,7 @@ from plotly.figure_factory._scatterplot import create_scatterplotmatrix from plotly.figure_factory._streamline import create_streamline from plotly.figure_factory._table import create_table +from plotly.figure_factory._ternary_contour import create_ternary_contour from plotly.figure_factory._trisurf import create_trisurf from plotly.figure_factory._violin import create_violin if optional_imports.get_module('pandas') is not None: diff --git a/plotly/figure_factory/_ternary_contour.py b/plotly/figure_factory/_ternary_contour.py new file mode 100644 index 00000000000..6a8079c9224 --- /dev/null +++ b/plotly/figure_factory/_ternary_contour.py @@ -0,0 +1,521 @@ +from __future__ import absolute_import +import numpy as np +from scipy.interpolate import griddata +from plotly.graph_objs import graph_objs as go +import warnings + + +def _pl_deep(): + return [[0.0, 'rgb(253, 253, 204)'], + [0.1, 'rgb(201, 235, 177)'], + [0.2, 'rgb(145, 216, 163)'], + [0.3, 'rgb(102, 194, 163)'], + [0.4, 'rgb(81, 168, 162)'], + [0.5, 'rgb(72, 141, 157)'], + [0.6, 'rgb(64, 117, 152)'], + [0.7, 'rgb(61, 90, 146)'], + [0.8, 'rgb(65, 64, 123)'], + [0.9, 'rgb(55, 44, 80)'], + [1.0, 'rgb(39, 26, 44)']] + + +def _transform_barycentric_cartesian(): + """ + Returns the transformation matrix from barycentric to cartesian + coordinates and conversely. + """ + # reference triangle + tri_verts = np.array([[0.5, np.sqrt(3) / 2], [0, 0], [1, 0]]) + M = np.array([tri_verts[:, 0], tri_verts[:, 1], np.ones(3)]) + return M, np.linalg.inv(M) + + +def _contour_trace(x, y, z, tooltip, ncontours=None, colorscale='Viridis', + showscale=False, linewidth=0.5, + linecolor='rgb(150,150,150)', + coloring=None, fontcolor='blue', + fontsize=12): + """ + Contour trace in Cartesian coordinates. + + Parameters + ========== + + x, y : array-like + Cartesian coordinates + z : array-like + Field to be represented as contours. + tooltip : list of str + Annotations to show on hover. + ncontours : int or None + Number of contours to display (determined automatically if None). + colorscale : str o array, optional + Colorscale to use for contours + showscale : bool + If True, a colorbar showing the color scale is displayed. + linewidth : int + Line width of contours + linecolor : color string + Color on contours + coloring : None or 'lines' + How to display contour. Filled contours if None, lines if ``lines``. + colorscale : None or array-like + Colorscale of the contours. + fontcolor : color str + Color of contour labels. + fontsize : int + Font size of contour labels. + """ + + c_dict = dict(type='contour', + x=x, y=y, z=z, + text=tooltip, + hoverinfo='text', + ncontours=ncontours, + colorscale=colorscale, + showscale=showscale, + line=dict(width=linewidth, color=linecolor), + colorbar=dict(thickness=20, ticklen=4) + ) + if coloring == 'lines': + contours = dict(coloring=coloring) + c_dict.update(contours=contours) + return go.Contour(c_dict) + + +def barycentric_ticks(side): + """ + Barycentric coordinates of ticks locations. + + Parameters + ========== + side : 0, 1 or 2 + side j has 0 in the j^th position of barycentric coords of tick + origin. + """ + p = 10 + if side == 0: # where a=0 + return np.array([(0, j/p, 1-j/p) for j in range(p - 2, 0, -2)]) + elif side == 1: # b=0 + return np.array([(i/p, 0, 1-i/p) for i in range(2, p, 2)]) + elif side == 2: # c=0 + return (np.array([(i/p, j/p, 0) + for i in range(p - 2, 0, -2) + for j in range(p - i, -1, -1) if i + j == p])) + else: + raise ValueError('The side can be only 0, 1, 2') + + +def _side_coord_ticks(side, t=0.01): + """ + Cartesian coordinates of ticks loactions for one side (0, 1, 2) + of ternary diagram. + + Parameters + ========== + + side : int, 0, 1 or 2 + Index of side + t : float, default 0.01 + Length of tick + + Returns + ======= + xt, yt : lists + Lists of x, resp y-coords of tick segments + posx, posy : lists + Lists of ticklabel positions + """ + M, invM = _transform_barycentric_cartesian() + baryc = barycentric_ticks(side) + xy1 = np.dot(M, baryc.T) + xs, ys = xy1[:2] + x_ticks, y_ticks, posx, posy = [], [], [], [] + if side == 0: + for i in range(4): + x_ticks.extend([xs[i], xs[i]+t, None]) + y_ticks.extend([ys[i], ys[i]-np.sqrt(3)*t, None]) + posx.extend([xs[i]+t for i in range(4)]) + posy.extend([ys[i]-np.sqrt(3)*t for i in range(4)]) + elif side == 1: + for i in range(4): + x_ticks.extend([xs[i], xs[i]+t, None]) + y_ticks.extend([ys[i], ys[i]+np.sqrt(3)*t, None]) + posx.extend([xs[i]+t for i in range(4)]) + posy.extend([ys[i]+np.sqrt(3)*t for i in range(4)]) + elif side == 2: + for i in range(4): + x_ticks.extend([xs[i], xs[i]-2*t, None]) + y_ticks.extend([ys[i], ys[i], None]) + posx.extend([xs[i]-2*t for i in range(4)]) + posy.extend([ys[i] for i in range(4)]) + else: + raise ValueError('Side can be only 0, 1, 2') + return x_ticks, y_ticks, posx, posy + + +def _cart_coord_ticks(t=0.01): + """ + Cartesian coordinates of ticks loactions. + + Parameters + ========== + + t : float, default 0.01 + Length of tick + + Returns + ======= + xt, yt : lists + Lists of x, resp y-coords of tick segments (all sides concatenated). + posx, posy : lists + Lists of ticklabel positions (all sides concatenated). + """ + x_ticks, y_ticks, posx, posy = [], [], [], [] + for side in range(3): + xt, yt, px, py = _side_coord_ticks(side, t) + x_ticks.extend(xt) + y_ticks.extend(yt) + posx.extend(px) + posy.extend(py) + return x_ticks, y_ticks, posx, posy + + +def _set_ticklabels(annotations, posx, posy, proportions=True): + """ + + Parameters + ========== + + annotations : list + List of annotations previously defined in layout definition + as a dict, not as an instance of go.Layout. + posx, posy: lists + Lists containing ticklabel position coordinates + proportions : bool + True when ticklabels are 0.2, 0.4, ... False when they are + 20%, 40%... + """ + if not isinstance(annotations, list): + raise ValueError('annotations should be a list') + + ticklabel = [0.8, 0.6, 0.4, 0.2] if proportions \ + else ['80%', '60%', '40%', '20%'] + + # Annotations for ticklabels on side 0 + annotations.extend([dict(showarrow=False, + text=str(ticklabel[j]), + x=posx[j], + y=posy[j], + align='center', + xanchor='center', + yanchor='top', + font=dict(size=12)) for j in range(4)]) + + # Annotations for ticklabels on side 1 + annotations.extend([dict(showarrow=False, + text=str(ticklabel[j]), + x=posx[j+4], + y=posy[j+4], + align='center', + xanchor='left', + yanchor='middle', + font=dict(size=12)) for j in range(4)]) + + # Annotations for ticklabels on side 2 + annotations.extend([dict(showarrow=False, + text=str(ticklabel[j]), + x=posx[j+8], + y=posy[j+8], + align='center', + xanchor='right', + yanchor='middle', + font=dict(size=12)) for j in range(4)]) + return annotations + + +def _styling_traces_ternary(x_ticks, y_ticks): + """ + Traces for outer triangle of ternary plot, and corresponding ticks. + + Parameters + ========== + + x_ticks : array_like, 1D + x Cartesian coordinate of ticks + y_ticks : array_like, 1D + y Cartesian coordinate of ticks + """ + side_trace = dict(type='scatter', + x=[0.5, 0, 1, 0.5], + y=[np.sqrt(3)/2, 0, 0, np.sqrt(3)/2], + mode='lines', + line=dict(width=2, color='#444444'), + hoverinfo='none') + + tick_trace = dict(type='scatter', + x=x_ticks, + y=y_ticks, + mode='lines', + line=dict(width=1, color='#444444'), + hoverinfo='none') + + return side_trace, tick_trace + + +def _ternary_layout(title='Ternary contour plot', width=550, height=525, + fontfamily='Balto, sans-serif', colorbar_fontsize=14, + plot_bgcolor='rgb(240,240,240)', + pole_labels=['a', 'b', 'c'], label_fontsize=16): + """ + Layout of ternary contour plot, to be passed to ``go.FigureWidget`` + object. + + Parameters + ========== + title : str or None + Title of ternary plot + width : int + Figure width. + height : int + Figure height. + fontfamily : str + Family of fonts + colorbar_fontsize : int + Font size of colorbar. + plot_bgcolor : + color of figure background + pole_labels : str, default ['a', 'b', 'c'] + Names of the three poles of the triangle. + label_fontsize : int + Font size of pole labels. + """ + return dict(title=title, + font=dict(family=fontfamily, size=colorbar_fontsize), + width=width, height=height, + xaxis=dict(visible=False), + yaxis=dict(visible=False), + plot_bgcolor=plot_bgcolor, + showlegend=False, + # annotations for strings placed at the triangle vertices + annotations=[dict(showarrow=False, + text=pole_labels[0], + x=0.5, + y=np.sqrt(3)/2, + align='center', + xanchor='center', + yanchor='bottom', + font=dict(size=label_fontsize)), + dict(showarrow=False, + text=pole_labels[1], + x=0, + y=0, + align='left', + xanchor='right', + yanchor='top', + font=dict(size=label_fontsize)), + dict(showarrow=False, + text=pole_labels[2], + x=1, + y=0, + align='right', + xanchor='left', + yanchor='top', + font=dict(size=label_fontsize)) + ]) + + +def _tooltip(N, bar_coords, grid_z, xy1, mode='proportions'): + """ + Tooltip annotations to be displayed on hover. + + Parameters + ========== + + N : int + Number of annotations along each axis. + bar_coords : array-like + Barycentric coordinates. + grid_z : array + Values (e.g. elevation values) at barycentric coordinates. + xy1 : array-like + Cartesian coordinates. + mode : str, 'proportions' or 'percents' + Coordinates inside the ternary plot can be displayed either as + proportions (adding up to 1) or as percents (adding up to 100). + """ + if mode == 'proportions' or mode == 'proportion': + tooltip = [ + ['a: %.2f' % round(bar_coords[0][i, j], 2) + + '
b: %.2f' % round(bar_coords[1][i, j], 2) + + '
c: %.2f' % (round(1-round(bar_coords[0][i, j], 2) - + round(bar_coords[1][i, j], 2), 2)) + + '
z: %.2f' % round(grid_z[i, j], 2) + if ~np.isnan(xy1[0][i, j]) else '' for j in range(N)] + for i in range(N)] + elif mode == 'percents' or mode == 'percent': + tooltip = [ + ['a: %d' % int(100*bar_coords[0][i, j] + 0.5) + + '
b: %d' % int(100*bar_coords[1][i, j] + 0.5) + + '
c: %d' % (100-int(100*bar_coords[0][i, j] + 0.5) - + int(100*bar_coords[1][i, j] + 0.5)) + + '
z: %.2f' % round(grid_z[i, j], 2) + if ~np.isnan(xy1[0][i, j]) else '' for j in range(N)] + for i in range(N)] + else: + raise ValueError("""tooltip mode must be either "proportions" or + "percents".""") + return tooltip + + +def _prepare_barycentric_coord(b_coords): + """ + Check ternary coordinates and return the right barycentric coordinates. + """ + if not isinstance(b_coords, (list, np.ndarray)): + raise ValueError('Data should be either an array of shape (n,m), or a list of n m-lists, m=2 or 3') + b_coords = np.asarray(b_coords) + if b_coords.shape[0] not in (2, 3): + raise ValueError('A point should have 2 (a, b) or 3 (a, b, c) barycentric coordinates') + if ((len(b_coords) == 3) and + not np.allclose(b_coords.sum(axis=0), 1, rtol=0.01)): + msg = "The sum of coordinates should be one for all data points" + raise ValueError(msg) + A, B = b_coords[:2] + C = 1 - (A + B) + if np.any(np.stack((A, B, C)) < 0): + raise ValueError('Barycentric coordinates should be positive.') + return A, B, C + + +def _compute_grid(coordinates, values, tooltip_mode): + """ + Compute interpolation of data points on regular grid in Cartesian + coordinates. + + Parameters + ========== + + coordinates : array-like + Barycentric coordinates of data points. + values : 1-d array-like + Data points, field to be represented as contours. + tooltip_mode : str, 'proportions' or 'percents' + Coordinates inside the ternary plot can be displayed either as + proportions (adding up to 1) or as percents (adding up to 100). + """ + A, B, C = _prepare_barycentric_coord(coordinates) + M, invM = _transform_barycentric_cartesian() + cartes_coord_points = np.einsum('ik, kj -> ij', M, np.stack((A, B, C))) + xx, yy = cartes_coord_points[:2] + x_min, x_max = xx.min(), xx.max() + y_min, y_max = yy.min(), yy.max() + # n_interp = max(100, int(np.sqrt(len(values)))) + n_interp = 20 + gr_x = np.linspace(x_min, x_max, n_interp) + gr_y = np.linspace(y_min, y_max, n_interp) + grid_x, grid_y = np.meshgrid(gr_x, gr_y) + grid_z = griddata(cartes_coord_points[:2].T, values, (grid_x, grid_y), + method='cubic') + bar_coords = np.einsum('ik, kmn -> imn', invM, + np.stack((grid_x, grid_y, np.ones(grid_x.shape)))) + # invalidate the points outside of the reference triangle + bar_coords[np.where(bar_coords < 0)] = 0 # None + # recompute back cartesian coordinates with invalid positions + xy1 = np.einsum('ik, kmn -> imn', M, bar_coords) + is_nan = np.where(np.isnan(xy1[0])) + grid_z[is_nan] = 0 # None + tooltip = _tooltip(n_interp, bar_coords, grid_z, xy1, tooltip_mode) + return grid_z, gr_x, gr_y, tooltip + + +def create_ternary_contour(coordinates, values, pole_labels=['a', 'b', 'c'], + tooltip_mode='proportions', width=500, height=500, + ncontours=None, + showscale=False, + coloring=None, + colorscale=None, + plot_bgcolor='rgb(240,240,240)', + title=None): + """ + Ternary contour plot. + + Parameters + ---------- + + coordinates : list or ndarray + Barycentric coordinates of shape (2, N) or (3, N) where N is the + number of data points. The sum of the 3 coordinates is expected + to be 1 for all data points. + values : array-like + Data points of field to be represented as contours. + pole_labels : str, default ['a', 'b', 'c'] + Names of the three poles of the triangle. + tooltip_mode : str, 'proportions' or 'percents' + Coordinates inside the ternary plot can be displayed either as + proportions (adding up to 1) or as percents (adding up to 100). + width : int + Figure width. + height : int + Figure height. + ncontours : int or None + Number of contours to display (determined automatically if None). + showscale : bool, default False + If True, a colorbar showing the color scale is displayed. + coloring : None or 'lines' + How to display contour. Filled contours if None, lines if ``lines``. + colorscale : None or array-like + colorscale of the contours. + plot_bgcolor : + color of figure background + title : str or None + Title of ternary plot + + Examples + ======== + + Example 1: ternary contour plot with filled contours + + # Define coordinates + a, b = np.mgrid[0:1:20j, 0:1:20j] + mask = a + b <= 1 + a = a[mask].ravel() + b = b[mask].ravel() + c = 1 - a - b + # Values to be displayed as contours + z = a * b * c + fig = ff.create_ternary_contour(np.stack((a, b, c)), z) + + It is also possible to give only two barycentric coordinates for each + point, since the sum of the three coordinates is one: + + fig = ff.create_ternary_contour(np.stack((a, b)), z) + + Example 2: ternary contour plot with line contours + + fig = ff.create_ternary_contour(np.stack((a, b)), z, coloring='lines') + """ + grid_z, gr_x, gr_y, tooltip = _compute_grid(coordinates, values, + tooltip_mode) + + x_ticks, y_ticks, posx, posy = _cart_coord_ticks(t=0.01) + + layout = _ternary_layout(pole_labels=pole_labels, + width=width, height=height, title=title, + plot_bgcolor=plot_bgcolor) + + annotations = _set_ticklabels(layout['annotations'], posx, posy, + proportions=True) + if colorscale is None: + colorscale = _pl_deep() + + contour_trace = _contour_trace(gr_x, gr_y, grid_z, tooltip, + ncontours=ncontours, + showscale=showscale, + colorscale=colorscale, + coloring=coloring) + side_trace, tick_trace = _styling_traces_ternary(x_ticks, y_ticks) + fig = go.Figure(data=[contour_trace, tick_trace, side_trace], + layout=layout) + fig.layout.annotations = annotations + return fig diff --git a/plotly/tests/test_optional/test_figure_factory/test_figure_factory.py b/plotly/tests/test_optional/test_figure_factory/test_figure_factory.py index 37834ec3957..b360fbc72e9 100644 --- a/plotly/tests/test_optional/test_figure_factory/test_figure_factory.py +++ b/plotly/tests/test_optional/test_figure_factory/test_figure_factory.py @@ -2858,6 +2858,7 @@ def test_full_choropleth(self): self.assertEqual(fig['data'][2]['x'][:50], exp_fig_head) + class TestQuiver(TestCase): def test_scaleratio_param(self): @@ -2897,3 +2898,75 @@ def test_scaleratio_param(self): self.assertEqual(fig_head, exp_fig_head) +class TestTernarycontour(NumpyTestUtilsMixin, TestCase): + + def test_wrong_coordinates(self): + a, b = np.mgrid[0:1:20j, 0:1:20j] + a = a.ravel() + b = b.ravel() + z = a * b + with self.assertRaises(ValueError, + msg='Barycentric coordinates should be positive.'): + _ = ff.create_ternary_contour(np.stack((a, b)), z) + mask = a + b < 1. + a = a[mask] + b = b[mask] + with self.assertRaises(ValueError): + _ = ff.create_ternary_contour(np.stack((a, b, a, b)), z) + with self.assertRaises(ValueError, + msg='different number of values and points'): + _ = ff.create_ternary_contour(np.stack((a, b, 1 - a - b)), + np.concatenate((z, [1]))) + # Different sums for different points + c = a + with self.assertRaises(ValueError): + _ = ff.create_ternary_contour(np.stack((a, b, c)), z) + # Sum of coordinates is different from one but is equal + # for all points. + with self.assertRaises(ValueError): + _ = ff.create_ternary_contour(np.stack((a, b, 2 - a - b)), z) + + + def test_tooltip(self): + a, b = np.mgrid[0:1:20j, 0:1:20j] + mask = a + b < 1. + a = a[mask].ravel() + b = b[mask].ravel() + c = 1 - a - b + z = a * b * c + fig = ff.create_ternary_contour(np.stack((a, b, c)), z, + tooltip_mode='percents') + fig = ff.create_ternary_contour(np.stack((a, b, c)), z, + tooltip_mode='percent') + + with self.assertRaises(ValueError): + fig = ff.create_ternary_contour(np.stack((a, b, c)), z, + tooltip_mode='wrong_mode') + + + def test_simple_ternary_contour(self): + a, b = np.mgrid[0:1:20j, 0:1:20j] + mask = a + b < 1. + a = a[mask].ravel() + b = b[mask].ravel() + c = 1 - a - b + z = a * b * c + fig = ff.create_ternary_contour(np.stack((a, b, c)), z) + fig2 = ff.create_ternary_contour(np.stack((a, b)), z) + np.testing.assert_array_equal(fig2['data'][0]['z'], + fig['data'][0]['z']) + + + def test_contour_attributes(self): + a, b = np.mgrid[0:1:20j, 0:1:20j] + mask = a + b < 1. + a = a[mask].ravel() + b = b[mask].ravel() + c = 1 - a - b + z = a * b * c + contour_dict = {'ncontours': 10, + 'showscale': True} + + fig = ff.create_ternary_contour(np.stack((a, b, c)), z, **contour_dict) + for key, value in contour_dict.items(): + assert fig['data'][0][key] == value