wg-backend-django/dell-env/lib/python3.11/site-packages/plotly/matplotlylib/mpltools.py
2023-10-30 14:40:43 +07:00

611 lines
20 KiB
Python

"""
Tools
A module for converting from mpl language to plotly language.
"""
import math
import warnings
import matplotlib.dates
def check_bar_match(old_bar, new_bar):
"""Check if two bars belong in the same collection (bar chart).
Positional arguments:
old_bar -- a previously sorted bar dictionary.
new_bar -- a new bar dictionary that needs to be sorted.
"""
tests = []
tests += (new_bar["orientation"] == old_bar["orientation"],)
tests += (new_bar["facecolor"] == old_bar["facecolor"],)
if new_bar["orientation"] == "v":
new_width = new_bar["x1"] - new_bar["x0"]
old_width = old_bar["x1"] - old_bar["x0"]
tests += (new_width - old_width < 0.000001,)
tests += (new_bar["y0"] == old_bar["y0"],)
elif new_bar["orientation"] == "h":
new_height = new_bar["y1"] - new_bar["y0"]
old_height = old_bar["y1"] - old_bar["y0"]
tests += (new_height - old_height < 0.000001,)
tests += (new_bar["x0"] == old_bar["x0"],)
if all(tests):
return True
else:
return False
def check_corners(inner_obj, outer_obj):
inner_corners = inner_obj.get_window_extent().corners()
outer_corners = outer_obj.get_window_extent().corners()
if inner_corners[0][0] < outer_corners[0][0]:
return False
elif inner_corners[0][1] < outer_corners[0][1]:
return False
elif inner_corners[3][0] > outer_corners[3][0]:
return False
elif inner_corners[3][1] > outer_corners[3][1]:
return False
else:
return True
def convert_dash(mpl_dash):
"""Convert mpl line symbol to plotly line symbol and return symbol."""
if mpl_dash in DASH_MAP:
return DASH_MAP[mpl_dash]
else:
dash_array = mpl_dash.split(",")
if len(dash_array) < 2:
return "solid"
# Catch the exception where the off length is zero, in case
# matplotlib 'solid' changes from '10,0' to 'N,0'
if math.isclose(float(dash_array[1]), 0.0):
return "solid"
# If we can't find the dash pattern in the map, convert it
# into custom values in px, e.g. '7,5' -> '7px,5px'
dashpx = ",".join([x + "px" for x in dash_array])
# TODO: rewrite the convert_dash code
# only strings 'solid', 'dashed', etc allowed
if dashpx == "7.4px,3.2px":
dashpx = "dashed"
elif dashpx == "12.8px,3.2px,2.0px,3.2px":
dashpx = "dashdot"
elif dashpx == "2.0px,3.3px":
dashpx = "dotted"
return dashpx
def convert_path(path):
verts = path[0] # may use this later
code = tuple(path[1])
if code in PATH_MAP:
return PATH_MAP[code]
else:
return None
def convert_symbol(mpl_symbol):
"""Convert mpl marker symbol to plotly symbol and return symbol."""
if isinstance(mpl_symbol, list):
symbol = list()
for s in mpl_symbol:
symbol += [convert_symbol(s)]
return symbol
elif mpl_symbol in SYMBOL_MAP:
return SYMBOL_MAP[mpl_symbol]
else:
return "circle" # default
def hex_to_rgb(value):
"""
Change a hex color to an rgb tuple
:param (str|unicode) value: The hex string we want to convert.
:return: (int, int, int) The red, green, blue int-tuple.
Example:
'#FFFFFF' --> (255, 255, 255)
"""
value = value.lstrip("#")
lv = len(value)
return tuple(int(value[i : i + lv // 3], 16) for i in range(0, lv, lv // 3))
def merge_color_and_opacity(color, opacity):
"""
Merge hex color with an alpha (opacity) to get an rgba tuple.
:param (str|unicode) color: A hex color string.
:param (float|int) opacity: A value [0, 1] for the 'a' in 'rgba'.
:return: (int, int, int, float) The rgba color and alpha tuple.
"""
if color is None: # None can be used as a placeholder, just bail.
return None
rgb_tup = hex_to_rgb(color)
if opacity is None:
return "rgb {}".format(rgb_tup)
rgba_tup = rgb_tup + (opacity,)
return "rgba {}".format(rgba_tup)
def convert_va(mpl_va):
"""Convert mpl vertical alignment word to equivalent HTML word.
Text alignment specifiers from mpl differ very slightly from those used
in HTML. See the VA_MAP for more details.
Positional arguments:
mpl_va -- vertical mpl text alignment spec.
"""
if mpl_va in VA_MAP:
return VA_MAP[mpl_va]
else:
return None # let plotly figure it out!
def convert_x_domain(mpl_plot_bounds, mpl_max_x_bounds):
"""Map x dimension of current plot to plotly's domain space.
The bbox used to locate an axes object in mpl differs from the
method used to locate axes in plotly. The mpl version locates each
axes in the figure so that axes in a single-plot figure might have
the bounds, [0.125, 0.125, 0.775, 0.775] (x0, y0, width, height),
in mpl's figure coordinates. However, the axes all share one space in
plotly such that the domain will always be [0, 0, 1, 1]
(x0, y0, x1, y1). To convert between the two, the mpl figure bounds
need to be mapped to a [0, 1] domain for x and y. The margins set
upon opening a new figure will appropriately match the mpl margins.
Optionally, setting margins=0 and simply copying the domains from
mpl to plotly would place axes appropriately. However,
this would throw off axis and title labeling.
Positional arguments:
mpl_plot_bounds -- the (x0, y0, width, height) params for current ax **
mpl_max_x_bounds -- overall (x0, x1) bounds for all axes **
** these are all specified in mpl figure coordinates
"""
mpl_x_dom = [mpl_plot_bounds[0], mpl_plot_bounds[0] + mpl_plot_bounds[2]]
plotting_width = mpl_max_x_bounds[1] - mpl_max_x_bounds[0]
x0 = (mpl_x_dom[0] - mpl_max_x_bounds[0]) / plotting_width
x1 = (mpl_x_dom[1] - mpl_max_x_bounds[0]) / plotting_width
return [x0, x1]
def convert_y_domain(mpl_plot_bounds, mpl_max_y_bounds):
"""Map y dimension of current plot to plotly's domain space.
The bbox used to locate an axes object in mpl differs from the
method used to locate axes in plotly. The mpl version locates each
axes in the figure so that axes in a single-plot figure might have
the bounds, [0.125, 0.125, 0.775, 0.775] (x0, y0, width, height),
in mpl's figure coordinates. However, the axes all share one space in
plotly such that the domain will always be [0, 0, 1, 1]
(x0, y0, x1, y1). To convert between the two, the mpl figure bounds
need to be mapped to a [0, 1] domain for x and y. The margins set
upon opening a new figure will appropriately match the mpl margins.
Optionally, setting margins=0 and simply copying the domains from
mpl to plotly would place axes appropriately. However,
this would throw off axis and title labeling.
Positional arguments:
mpl_plot_bounds -- the (x0, y0, width, height) params for current ax **
mpl_max_y_bounds -- overall (y0, y1) bounds for all axes **
** these are all specified in mpl figure coordinates
"""
mpl_y_dom = [mpl_plot_bounds[1], mpl_plot_bounds[1] + mpl_plot_bounds[3]]
plotting_height = mpl_max_y_bounds[1] - mpl_max_y_bounds[0]
y0 = (mpl_y_dom[0] - mpl_max_y_bounds[0]) / plotting_height
y1 = (mpl_y_dom[1] - mpl_max_y_bounds[0]) / plotting_height
return [y0, y1]
def display_to_paper(x, y, layout):
"""Convert mpl display coordinates to plotly paper coordinates.
Plotly references object positions with an (x, y) coordinate pair in either
'data' or 'paper' coordinates which reference actual data in a plot or
the entire plotly axes space where the bottom-left of the bottom-left
plot has the location (x, y) = (0, 0) and the top-right of the top-right
plot has the location (x, y) = (1, 1). Display coordinates in mpl reference
objects with an (x, y) pair in pixel coordinates, where the bottom-left
corner is at the location (x, y) = (0, 0) and the top-right corner is at
the location (x, y) = (figwidth*dpi, figheight*dpi). Here, figwidth and
figheight are in inches and dpi are the dots per inch resolution.
"""
num_x = x - layout["margin"]["l"]
den_x = layout["width"] - (layout["margin"]["l"] + layout["margin"]["r"])
num_y = y - layout["margin"]["b"]
den_y = layout["height"] - (layout["margin"]["b"] + layout["margin"]["t"])
return num_x / den_x, num_y / den_y
def get_axes_bounds(fig):
"""Return the entire axes space for figure.
An axes object in mpl is specified by its relation to the figure where
(0,0) corresponds to the bottom-left part of the figure and (1,1)
corresponds to the top-right. Margins exist in matplotlib because axes
objects normally don't go to the edges of the figure.
In plotly, the axes area (where all subplots go) is always specified with
the domain [0,1] for both x and y. This function finds the smallest box,
specified by two points, that all of the mpl axes objects fit into. This
box is then used to map mpl axes domains to plotly axes domains.
"""
x_min, x_max, y_min, y_max = [], [], [], []
for axes_obj in fig.get_axes():
bounds = axes_obj.get_position().bounds
x_min.append(bounds[0])
x_max.append(bounds[0] + bounds[2])
y_min.append(bounds[1])
y_max.append(bounds[1] + bounds[3])
x_min, y_min, x_max, y_max = min(x_min), min(y_min), max(x_max), max(y_max)
return (x_min, x_max), (y_min, y_max)
def get_axis_mirror(main_spine, mirror_spine):
if main_spine and mirror_spine:
return "ticks"
elif main_spine and not mirror_spine:
return False
elif not main_spine and mirror_spine:
return False # can't handle this case yet!
else:
return False # nuttin'!
def get_bar_gap(bar_starts, bar_ends, tol=1e-10):
if len(bar_starts) == len(bar_ends) and len(bar_starts) > 1:
sides1 = bar_starts[1:]
sides2 = bar_ends[:-1]
gaps = [s2 - s1 for s2, s1 in zip(sides1, sides2)]
gap0 = gaps[0]
uniform = all([abs(gap0 - gap) < tol for gap in gaps])
if uniform:
return gap0
def convert_rgba_array(color_list):
clean_color_list = list()
for c in color_list:
clean_color_list += [
(dict(r=int(c[0] * 255), g=int(c[1] * 255), b=int(c[2] * 255), a=c[3]))
]
plotly_colors = list()
for rgba in clean_color_list:
plotly_colors += ["rgba({r},{g},{b},{a})".format(**rgba)]
if len(plotly_colors) == 1:
return plotly_colors[0]
else:
return plotly_colors
def convert_path_array(path_array):
symbols = list()
for path in path_array:
symbols += [convert_path(path)]
if len(symbols) == 1:
return symbols[0]
else:
return symbols
def convert_linewidth_array(width_array):
if len(width_array) == 1:
return width_array[0]
else:
return width_array
def convert_size_array(size_array):
size = [math.sqrt(s) for s in size_array]
if len(size) == 1:
return size[0]
else:
return size
def get_markerstyle_from_collection(props):
markerstyle = dict(
alpha=None,
facecolor=convert_rgba_array(props["styles"]["facecolor"]),
marker=convert_path_array(props["paths"]),
edgewidth=convert_linewidth_array(props["styles"]["linewidth"]),
# markersize=convert_size_array(props['styles']['size']), # TODO!
markersize=convert_size_array(props["mplobj"].get_sizes()),
edgecolor=convert_rgba_array(props["styles"]["edgecolor"]),
)
return markerstyle
def get_rect_xmin(data):
"""Find minimum x value from four (x,y) vertices."""
return min(data[0][0], data[1][0], data[2][0], data[3][0])
def get_rect_xmax(data):
"""Find maximum x value from four (x,y) vertices."""
return max(data[0][0], data[1][0], data[2][0], data[3][0])
def get_rect_ymin(data):
"""Find minimum y value from four (x,y) vertices."""
return min(data[0][1], data[1][1], data[2][1], data[3][1])
def get_rect_ymax(data):
"""Find maximum y value from four (x,y) vertices."""
return max(data[0][1], data[1][1], data[2][1], data[3][1])
def get_spine_visible(ax, spine_key):
"""Return some spine parameters for the spine, `spine_key`."""
spine = ax.spines[spine_key]
ax_frame_on = ax.get_frame_on()
position = spine._position or ("outward", 0.0)
if isinstance(position, str):
if position == "center":
position = ("axes", 0.5)
elif position == "zero":
position = ("data", 0)
position_type, amount = position
if position_type == "outward" and amount == 0:
spine_frame_like = True
else:
spine_frame_like = False
if not spine.get_visible():
return False
elif not spine._edgecolor[-1]: # user's may have set edgecolor alpha==0
return False
elif not ax_frame_on and spine_frame_like:
return False
elif ax_frame_on and spine_frame_like:
return True
elif not ax_frame_on and not spine_frame_like:
return True # we've already checked for that it's visible.
else:
return False # oh man, and i thought we exhausted the options...
def is_bar(bar_containers, **props):
"""A test to decide whether a path is a bar from a vertical bar chart."""
# is this patch in a bar container?
for container in bar_containers:
if props["mplobj"] in container:
return True
return False
def make_bar(**props):
"""Make an intermediate bar dictionary.
This creates a bar dictionary which aids in the comparison of new bars to
old bars from other bar chart (patch) collections. This is not the
dictionary that needs to get passed to plotly as a data dictionary. That
happens in PlotlyRenderer in that class's draw_bar method. In other
words, this dictionary describes a SINGLE bar, whereas, plotly will
require a set of bars to be passed in a data dictionary.
"""
return {
"bar": props["mplobj"],
"x0": get_rect_xmin(props["data"]),
"y0": get_rect_ymin(props["data"]),
"x1": get_rect_xmax(props["data"]),
"y1": get_rect_ymax(props["data"]),
"alpha": props["style"]["alpha"],
"edgecolor": props["style"]["edgecolor"],
"facecolor": props["style"]["facecolor"],
"edgewidth": props["style"]["edgewidth"],
"dasharray": props["style"]["dasharray"],
"zorder": props["style"]["zorder"],
}
def prep_ticks(ax, index, ax_type, props):
"""Prepare axis obj belonging to axes obj.
positional arguments:
ax - the mpl axes instance
index - the index of the axis in `props`
ax_type - 'x' or 'y' (for now)
props - an mplexporter poperties dictionary
"""
axis_dict = dict()
if ax_type == "x":
axis = ax.get_xaxis()
elif ax_type == "y":
axis = ax.get_yaxis()
else:
return dict() # whoops!
scale = props["axes"][index]["scale"]
if scale == "linear":
# get tick location information
try:
tickvalues = props["axes"][index]["tickvalues"]
tick0 = tickvalues[0]
dticks = [
round(tickvalues[i] - tickvalues[i - 1], 12)
for i in range(1, len(tickvalues) - 1)
]
if all([dticks[i] == dticks[i - 1] for i in range(1, len(dticks) - 1)]):
dtick = tickvalues[1] - tickvalues[0]
else:
warnings.warn(
"'linear' {0}-axis tick spacing not even, "
"ignoring mpl tick formatting.".format(ax_type)
)
raise TypeError
except (IndexError, TypeError):
axis_dict["nticks"] = props["axes"][index]["nticks"]
else:
axis_dict["tick0"] = tick0
axis_dict["dtick"] = dtick
axis_dict["tickmode"] = None
elif scale == "log":
try:
axis_dict["tick0"] = props["axes"][index]["tickvalues"][0]
axis_dict["dtick"] = (
props["axes"][index]["tickvalues"][1]
- props["axes"][index]["tickvalues"][0]
)
axis_dict["tickmode"] = None
except (IndexError, TypeError):
axis_dict = dict(nticks=props["axes"][index]["nticks"])
base = axis.get_transform().base
if base == 10:
if ax_type == "x":
axis_dict["range"] = [
math.log10(props["xlim"][0]),
math.log10(props["xlim"][1]),
]
elif ax_type == "y":
axis_dict["range"] = [
math.log10(props["ylim"][0]),
math.log10(props["ylim"][1]),
]
else:
axis_dict = dict(range=None, type="linear")
warnings.warn(
"Converted non-base10 {0}-axis log scale to 'linear'" "".format(ax_type)
)
else:
return dict()
# get tick label formatting information
formatter = axis.get_major_formatter().__class__.__name__
if ax_type == "x" and "DateFormatter" in formatter:
axis_dict["type"] = "date"
try:
axis_dict["tick0"] = mpl_dates_to_datestrings(axis_dict["tick0"], formatter)
except KeyError:
pass
finally:
axis_dict.pop("dtick", None)
axis_dict.pop("tickmode", None)
axis_dict["range"] = mpl_dates_to_datestrings(props["xlim"], formatter)
if formatter == "LogFormatterMathtext":
axis_dict["exponentformat"] = "e"
return axis_dict
def prep_xy_axis(ax, props, x_bounds, y_bounds):
xaxis = dict(
type=props["axes"][0]["scale"],
range=list(props["xlim"]),
showgrid=props["axes"][0]["grid"]["gridOn"],
domain=convert_x_domain(props["bounds"], x_bounds),
side=props["axes"][0]["position"],
tickfont=dict(size=props["axes"][0]["fontsize"]),
)
xaxis.update(prep_ticks(ax, 0, "x", props))
yaxis = dict(
type=props["axes"][1]["scale"],
range=list(props["ylim"]),
showgrid=props["axes"][1]["grid"]["gridOn"],
domain=convert_y_domain(props["bounds"], y_bounds),
side=props["axes"][1]["position"],
tickfont=dict(size=props["axes"][1]["fontsize"]),
)
yaxis.update(prep_ticks(ax, 1, "y", props))
return xaxis, yaxis
def mpl_dates_to_datestrings(dates, mpl_formatter):
"""Convert matplotlib dates to iso-formatted-like time strings.
Plotly's accepted format: "YYYY-MM-DD HH:MM:SS" (e.g., 2001-01-01 00:00:00)
Info on mpl dates: http://matplotlib.org/api/dates_api.html
"""
_dates = dates
# this is a pandas datetime formatter, times show up in floating point days
# since the epoch (1970-01-01T00:00:00+00:00)
if mpl_formatter == "TimeSeries_DateFormatter":
try:
dates = matplotlib.dates.epoch2num([date * 24 * 60 * 60 for date in dates])
dates = matplotlib.dates.num2date(dates)
except:
return _dates
# the rest of mpl dates are in floating point days since
# (0001-01-01T00:00:00+00:00) + 1. I.e., (0001-01-01T00:00:00+00:00) == 1.0
# according to mpl --> try num2date(1)
else:
try:
dates = matplotlib.dates.num2date(dates)
except:
return _dates
time_stings = [
" ".join(date.isoformat().split("+")[0].split("T")) for date in dates
]
return time_stings
# dashed is dash in matplotlib
DASH_MAP = {
"10,0": "solid",
"6,6": "dash",
"2,2": "circle",
"4,4,2,4": "dashdot",
"none": "solid",
"7.4,3.2": "dash",
}
PATH_MAP = {
("M", "C", "C", "C", "C", "C", "C", "C", "C", "Z"): "o",
("M", "L", "L", "L", "L", "L", "L", "L", "L", "L", "Z"): "*",
("M", "L", "L", "L", "L", "L", "L", "L", "Z"): "8",
("M", "L", "L", "L", "L", "L", "Z"): "h",
("M", "L", "L", "L", "L", "Z"): "p",
("M", "L", "M", "L", "M", "L"): "1",
("M", "L", "L", "L", "Z"): "s",
("M", "L", "M", "L"): "+",
("M", "L", "L", "Z"): "^",
("M", "L"): "|",
}
SYMBOL_MAP = {
"o": "circle",
"v": "triangle-down",
"^": "triangle-up",
"<": "triangle-left",
">": "triangle-right",
"s": "square",
"+": "cross",
"x": "x",
"*": "star",
"D": "diamond",
"d": "diamond",
}
VA_MAP = {"center": "middle", "baseline": "bottom", "top": "top"}