from __future__ import absolute_import import plotly.colors as clrs from plotly.graph_objs import graph_objs as go from plotly import exceptions, optional_imports from plotly import optional_imports from plotly.graph_objs import graph_objs as go np = optional_imports.get_module("numpy") scipy_interp = optional_imports.get_module("scipy.interpolate") from skimage import measure # -------------------------- Layout ------------------------------ def _ternary_layout( title="Ternary contour plot", width=550, height=525, pole_labels=["a", "b", "c"] ): """ 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. pole_labels : str, default ['a', 'b', 'c'] Names of the three poles of the triangle. """ return dict( title=title, width=width, height=height, ternary=dict( sum=1, aaxis=dict( title=dict(text=pole_labels[0]), min=0.01, linewidth=2, ticks="outside" ), baxis=dict( title=dict(text=pole_labels[1]), min=0.01, linewidth=2, ticks="outside" ), caxis=dict( title=dict(text=pole_labels[2]), min=0.01, linewidth=2, ticks="outside" ), ), showlegend=False, ) # ------------- Transformations of coordinates ------------------- def _replace_zero_coords(ternary_data, delta=0.0005): """ Replaces zero ternary coordinates with delta and normalize the new triplets (a, b, c). Parameters ---------- ternary_data : ndarray of shape (N, 3) delta : float Small float to regularize logarithm. Notes ----- Implements a method by J. A. Martin-Fernandez, C. Barcelo-Vidal, V. Pawlowsky-Glahn, Dealing with zeros and missing values in compositional data sets using nonparametric imputation, Mathematical Geology 35 (2003), pp 253-278. """ zero_mask = ternary_data == 0 is_any_coord_zero = np.any(zero_mask, axis=0) unity_complement = 1 - delta * is_any_coord_zero if np.any(unity_complement) < 0: raise ValueError( "The provided value of delta led to negative" "ternary coords.Set a smaller delta" ) ternary_data = np.where(zero_mask, delta, unity_complement * ternary_data) return ternary_data def _ilr_transform(barycentric): """ Perform Isometric Log-Ratio on barycentric (compositional) data. Parameters ---------- barycentric: ndarray of shape (3, N) Barycentric coordinates. References ---------- "An algebraic method to compute isometric logratio transformation and back transformation of compositional data", Jarauta-Bragulat, E., Buenestado, P.; Hervada-Sala, C., in Proc. of the Annual Conf. of the Intl Assoc for Math Geology, 2003, pp 31-30. """ barycentric = np.asarray(barycentric) x_0 = np.log(barycentric[0] / barycentric[1]) / np.sqrt(2) x_1 = ( 1.0 / np.sqrt(6) * np.log(barycentric[0] * barycentric[1] / barycentric[2] ** 2) ) ilr_tdata = np.stack((x_0, x_1)) return ilr_tdata def _ilr_inverse(x): """ Perform inverse Isometric Log-Ratio (ILR) transform to retrieve barycentric (compositional) data. Parameters ---------- x : array of shape (2, N) Coordinates in ILR space. References ---------- "An algebraic method to compute isometric logratio transformation and back transformation of compositional data", Jarauta-Bragulat, E., Buenestado, P.; Hervada-Sala, C., in Proc. of the Annual Conf. of the Intl Assoc for Math Geology, 2003, pp 31-30. """ x = np.array(x) matrix = np.array([[0.5, 1, 1.0], [-0.5, 1, 1.0], [0.0, 0.0, 1.0]]) s = np.sqrt(2) / 2 t = np.sqrt(3 / 2) Sk = np.einsum("ik, kj -> ij", np.array([[s, t], [-s, t]]), x) Z = -np.log(1 + np.exp(Sk).sum(axis=0)) log_barycentric = np.einsum( "ik, kj -> ij", matrix, np.stack((2 * s * x[0], t * x[1], Z)) ) iilr_tdata = np.exp(log_barycentric) return iilr_tdata 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 _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) and not np.allclose(b_coords.sum(axis=0), 100, rtol=0.01) ): msg = "The sum of coordinates should be 1 or 100 for all data points" raise ValueError(msg) if len(b_coords) == 2: A, B = b_coords C = 1 - (A + B) else: A, B, C = b_coords / b_coords.sum(axis=0) if np.any(np.stack((A, B, C)) < 0): raise ValueError("Barycentric coordinates should be positive.") return np.stack((A, B, C)) def _compute_grid(coordinates, values, interp_mode="ilr"): """ Transform data points with Cartesian or ILR mapping, then Compute interpolation on a regular grid. Parameters ========== coordinates : array-like Barycentric coordinates of data points. values : 1-d array-like Data points, field to be represented as contours. interp_mode : 'ilr' (default) or 'cartesian' Defines how data are interpolated to compute contours. """ if interp_mode == "cartesian": M, invM = _transform_barycentric_cartesian() coord_points = np.einsum("ik, kj -> ij", M, coordinates) elif interp_mode == "ilr": coordinates = _replace_zero_coords(coordinates) coord_points = _ilr_transform(coordinates) else: raise ValueError("interp_mode should be cartesian or ilr") xx, yy = coord_points[:2] x_min, x_max = xx.min(), xx.max() y_min, y_max = yy.min(), yy.max() n_interp = max(200, int(np.sqrt(len(values)))) 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) # We use cubic interpolation, except outside of the convex hull # of data points where we use nearest neighbor values. grid_z = scipy_interp.griddata( coord_points[:2].T, values, (grid_x, grid_y), method="cubic" ) grid_z_other = scipy_interp.griddata( coord_points[:2].T, values, (grid_x, grid_y), method="nearest" ) # mask_nan = np.isnan(grid_z) # grid_z[mask_nan] = grid_z_other[mask_nan] return grid_z, gr_x, gr_y # ----------------------- Contour traces ---------------------- def _polygon_area(x, y): return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))) def _colors(ncontours, colormap=None): """ Return a list of ``ncontours`` colors from the ``colormap`` colorscale. """ if colormap in clrs.PLOTLY_SCALES.keys(): cmap = clrs.PLOTLY_SCALES[colormap] else: raise exceptions.PlotlyError( "Colorscale must be a valid Plotly Colorscale." "The available colorscale names are {}".format(clrs.PLOTLY_SCALES.keys()) ) values = np.linspace(0, 1, ncontours) vals_cmap = np.array([pair[0] for pair in cmap]) cols = np.array([pair[1] for pair in cmap]) inds = np.searchsorted(vals_cmap, values) if "#" in cols[0]: # for Viridis cols = [clrs.label_rgb(clrs.hex_to_rgb(col)) for col in cols] colors = [cols[0]] for ind, val in zip(inds[1:], values[1:]): val1, val2 = vals_cmap[ind - 1], vals_cmap[ind] interm = (val - val1) / (val2 - val1) col = clrs.find_intermediate_color( cols[ind - 1], cols[ind], interm, colortype="rgb" ) colors.append(col) return colors def _is_invalid_contour(x, y): """ Utility function for _contour_trace Contours with an area of the order as 1 pixel are considered spurious. """ too_small = np.all(np.abs(x - x[0]) < 2) and np.all(np.abs(y - y[0]) < 2) return too_small def _extract_contours(im, values, colors): """ Utility function for _contour_trace. In ``im`` only one part of the domain has valid values (corresponding to a subdomain where barycentric coordinates are well defined). When computing contours, we need to assign values outside of this domain. We can choose a value either smaller than all the values inside the valid domain, or larger. This value must be chose with caution so that no spurious contours are added. For example, if the boundary of the valid domain has large values and the outer value is set to a small one, all intermediate contours will be added at the boundary. Therefore, we compute the two sets of contours (with an outer value smaller of larger than all values in the valid domain), and choose the value resulting in a smaller total number of contours. There might be a faster way to do this, but it works... """ mask_nan = np.isnan(im) im_min, im_max = ( im[np.logical_not(mask_nan)].min(), im[np.logical_not(mask_nan)].max(), ) zz_min = np.copy(im) zz_min[mask_nan] = 2 * im_min zz_max = np.copy(im) zz_max[mask_nan] = 2 * im_max all_contours1, all_values1, all_areas1, all_colors1 = [], [], [], [] all_contours2, all_values2, all_areas2, all_colors2 = [], [], [], [] for i, val in enumerate(values): contour_level1 = measure.find_contours(zz_min, val) contour_level2 = measure.find_contours(zz_max, val) all_contours1.extend(contour_level1) all_contours2.extend(contour_level2) all_values1.extend([val] * len(contour_level1)) all_values2.extend([val] * len(contour_level2)) all_areas1.extend( [_polygon_area(contour.T[1], contour.T[0]) for contour in contour_level1] ) all_areas2.extend( [_polygon_area(contour.T[1], contour.T[0]) for contour in contour_level2] ) all_colors1.extend([colors[i]] * len(contour_level1)) all_colors2.extend([colors[i]] * len(contour_level2)) if len(all_contours1) <= len(all_contours2): return all_contours1, all_values1, all_areas1, all_colors1 else: return all_contours2, all_values2, all_areas2, all_colors2 def _add_outer_contour( all_contours, all_values, all_areas, all_colors, values, val_outer, v_min, v_max, colors, color_min, color_max, ): """ Utility function for _contour_trace Adds the background color to fill gaps outside of computed contours. To compute the background color, the color of the contour with largest area (``val_outer``) is used. As background color, we choose the next color value in the direction of the extrema of the colormap. Then we add information for the outer contour for the different lists provided as arguments. A discrete colormap with all used colors is also returned (to be used by colorscale trace). """ # The exact value of outer contour is not used when defining the trace outer_contour = 20 * np.array([[0, 0, 1], [0, 1, 0.5]]).T all_contours = [outer_contour] + all_contours delta_values = np.diff(values)[0] values = np.concatenate( ([values[0] - delta_values], values, [values[-1] + delta_values]) ) colors = np.concatenate(([color_min], colors, [color_max])) index = np.nonzero(values == val_outer)[0][0] if index < len(values) / 2: index -= 1 else: index += 1 all_colors = [colors[index]] + all_colors all_values = [values[index]] + all_values all_areas = [0] + all_areas used_colors = [color for color in colors if color in all_colors] # Define discrete colorscale color_number = len(used_colors) scale = np.linspace(0, 1, color_number + 1) discrete_cm = [] for i, color in enumerate(used_colors): discrete_cm.append([scale[i], used_colors[i]]) discrete_cm.append([scale[i + 1], used_colors[i]]) discrete_cm.append([scale[color_number], used_colors[color_number - 1]]) return all_contours, all_values, all_areas, all_colors, discrete_cm def _contour_trace( x, y, z, ncontours=None, colorscale="Electric", linecolor="rgb(150,150,150)", interp_mode="llr", coloring=None, v_min=0, v_max=1, ): """ Contour trace in Cartesian coordinates. Parameters ========== x, y : array-like Cartesian coordinates z : array-like Field to be represented as contours. ncontours : int or None Number of contours to display (determined automatically if None). colorscale : None or str (Plotly colormap) colorscale of the contours. linecolor : rgb color Color used for lines. If ``colorscale`` is not None, line colors are determined from ``colorscale`` instead. interp_mode : 'ilr' (default) or 'cartesian' Defines how data are interpolated to compute contours. If 'irl', ILR (Isometric Log-Ratio) of compositional data is performed. If 'cartesian', contours are determined in Cartesian space. coloring : None or 'lines' How to display contour. Filled contours if None, lines if ``lines``. vmin, vmax : float Bounds of interval of values used for the colorspace Notes ===== """ # Prepare colors # We do not take extrema, for example for one single contour # the color will be the middle point of the colormap colors = _colors(ncontours + 2, colorscale) # Values used for contours, extrema are not used # For example for a binary array [0, 1], the value of # the contour for ncontours=1 is 0.5. values = np.linspace(v_min, v_max, ncontours + 2) color_min, color_max = colors[0], colors[-1] colors = colors[1:-1] values = values[1:-1] # Color of line contours if linecolor is None: linecolor = "rgb(150, 150, 150)" else: colors = [linecolor] * ncontours # Retrieve all contours all_contours, all_values, all_areas, all_colors = _extract_contours( z, values, colors ) # Now sort contours by decreasing area order = np.argsort(all_areas)[::-1] # Add outer contour all_contours, all_values, all_areas, all_colors, discrete_cm = _add_outer_contour( all_contours, all_values, all_areas, all_colors, values, all_values[order[0]], v_min, v_max, colors, color_min, color_max, ) order = np.concatenate(([0], order + 1)) # Compute traces, in the order of decreasing area traces = [] M, invM = _transform_barycentric_cartesian() dx = (x.max() - x.min()) / x.size dy = (y.max() - y.min()) / y.size for index in order: y_contour, x_contour = all_contours[index].T val = all_values[index] if interp_mode == "cartesian": bar_coords = np.dot( invM, np.stack((dx * x_contour, dy * y_contour, np.ones(x_contour.shape))), ) elif interp_mode == "ilr": bar_coords = _ilr_inverse( np.stack((dx * x_contour + x.min(), dy * y_contour + y.min())) ) if index == 0: # outer triangle a = np.array([1, 0, 0]) b = np.array([0, 1, 0]) c = np.array([0, 0, 1]) else: a, b, c = bar_coords if _is_invalid_contour(x_contour, y_contour): continue _col = all_colors[index] if coloring == "lines" else linecolor trace = dict( type="scatterternary", a=a, b=b, c=c, mode="lines", line=dict(color=_col, shape="spline", width=1), fill="toself", fillcolor=all_colors[index], showlegend=True, hoverinfo="skip", name="%.3f" % val, ) if coloring == "lines": trace["fill"] = None traces.append(trace) return traces, discrete_cm # -------------------- Figure Factory for ternary contour ------------- def create_ternary_contour( coordinates, values, pole_labels=["a", "b", "c"], width=500, height=500, ncontours=None, showscale=False, coloring=None, colorscale="Bluered", linecolor=None, title=None, interp_mode="ilr", showmarkers=False, ): """ 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. 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 str (Plotly colormap) colorscale of the contours. linecolor : None or rgb color Color used for lines. ``colorscale`` has to be set to None, otherwise line colors are determined from ``colorscale``. title : str or None Title of ternary plot interp_mode : 'ilr' (default) or 'cartesian' Defines how data are interpolated to compute contours. If 'irl', ILR (Isometric Log-Ratio) of compositional data is performed. If 'cartesian', contours are determined in Cartesian space. showmarkers : bool, default False If True, markers corresponding to input compositional points are superimposed on contours, using the same colorscale. Examples ======== Example 1: ternary contour plot with filled contours >>> import plotly.figure_factory as ff >>> import numpy as np >>> # 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) >>> fig.show() 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, c)), z, coloring='lines') Example 3: customize number of contours >>> fig = ff.create_ternary_contour(np.stack((a, b, c)), z, ncontours=8) Example 4: superimpose contour plot and original data as markers >>> fig = ff.create_ternary_contour(np.stack((a, b, c)), z, coloring='lines', ... showmarkers=True) Example 5: customize title and pole labels >>> fig = ff.create_ternary_contour(np.stack((a, b, c)), z, ... title='Ternary plot', ... pole_labels=['clay', 'quartz', 'fledspar']) """ if scipy_interp is None: raise ImportError( """\ The create_ternary_contour figure factory requires the scipy package""" ) sk_measure = optional_imports.get_module("skimage") if sk_measure is None: raise ImportError( """\ The create_ternary_contour figure factory requires the scikit-image package""" ) if colorscale is None: showscale = False if ncontours is None: ncontours = 5 coordinates = _prepare_barycentric_coord(coordinates) v_min, v_max = values.min(), values.max() grid_z, gr_x, gr_y = _compute_grid(coordinates, values, interp_mode=interp_mode) layout = _ternary_layout( pole_labels=pole_labels, width=width, height=height, title=title ) contour_trace, discrete_cm = _contour_trace( gr_x, gr_y, grid_z, ncontours=ncontours, colorscale=colorscale, linecolor=linecolor, interp_mode=interp_mode, coloring=coloring, v_min=v_min, v_max=v_max, ) fig = go.Figure(data=contour_trace, layout=layout) opacity = 1 if showmarkers else 0 a, b, c = coordinates hovertemplate = ( pole_labels[0] + ": %{a:.3f}
" + pole_labels[1] + ": %{b:.3f}
" + pole_labels[2] + ": %{c:.3f}
" "z: %{marker.color:.3f}" ) fig.add_scatterternary( a=a, b=b, c=c, mode="markers", marker={ "color": values, "colorscale": colorscale, "line": {"color": "rgb(120, 120, 120)", "width": int(coloring != "lines")}, }, opacity=opacity, hovertemplate=hovertemplate, ) if showscale: if not showmarkers: colorscale = discrete_cm colorbar = dict( { "type": "scatterternary", "a": [None], "b": [None], "c": [None], "marker": { "cmin": values.min(), "cmax": values.max(), "colorscale": colorscale, "showscale": True, }, "mode": "markers", } ) fig.add_trace(colorbar) return fig