From d6aee4dbdddbace54fd8a09ca313c0891fc6633c Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 14 Mar 2025 00:02:24 +0100 Subject: [PATCH 01/66] add support for symbolic x and y coordinates --- src/tests/test_line_charts.py | 18 ++++++++++++++++++ src/tikzplotly/_save.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/tests/test_line_charts.py b/src/tests/test_line_charts.py index 6a56507..70f21e7 100644 --- a/src/tests/test_line_charts.py +++ b/src/tests/test_line_charts.py @@ -394,6 +394,22 @@ def fig18(): fig.update_xaxes(minor=dict(ticks="inside", ticklen=6, showgrid=True)) return fig, "Log scale with range and minor ticks" +def fig19(): + df = pd.DataFrame(dict( + x = ["A", "B", "C", "D"], + y = [1, 2, 3, 4], + )) + fig = px.line(df, x="x", y="y", title="symbolic x coords") + return fig, "Line Charts with symbolic x coords" + +def fig20(): + df = pd.DataFrame(dict( + x = [1, 2, 3, 4], + y = ["A", "B", "C", "D"], + )) + fig = px.line(df, x="x", y="y", title="symbolic y coords") + return fig, "Line Charts with symbolic y coords" + if __name__ == "__main__": print("Tikzploty : ", tikzplotly.__version__) @@ -420,6 +436,8 @@ def fig18(): ("16", fig16), ("17", fig17), ("18", fig18), + ("19", fig19), + ("20", fig20), ] main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 1027d5e..faf2a3a 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -89,6 +89,35 @@ def get_tikz_code( else: warn(f"Trace type {trace.type} is not supported yet.") + # Detect if we have symbolic x values + symbolic_x_vals = [] + for data_obj in data_container.data: + # If all x-values in this data object are strings, consider them symbolic + if all(isinstance(v, str) for v in data_obj.x): + for v in data_obj.x: + if v not in symbolic_x_vals: + symbolic_x_vals.append(v) + + # Detect if we have symbolic y values + symbolic_y_vals = [] + for data_obj in data_container.data: + for y_column in data_obj.y_data: + # If all y-values in this data object are strings, consider them symbolic + if all(isinstance(v, str) for v in y_column): + for val in y_column: + if val not in symbolic_y_vals: + symbolic_y_vals.append(val) + + # If we found symbolic x-values, tell pgfplots to treat them as symbolic + if symbolic_x_vals: + axis.add_option("symbolic x coords", "{" + ",".join(symbolic_x_vals) + "}") + axis.add_option("xtick", "data") + + # If we found symbolic y-values, treat them similarly + if symbolic_y_vals: + axis.add_option("symbolic y coords", "{" + ",".join(symbolic_y_vals) + "}") + axis.add_option("ytick", "data") + annotation_str = str_from_annotation(figure_layout.annotations, axis, colors_set) code = """""" @@ -144,4 +173,4 @@ def save(filepath, *args, **kwargs): if not directory.exists(): directory.mkdir(parents=True) with open(filepath, "w") as fd: - fd.write(code) \ No newline at end of file + fd.write(code) From 3d8b04e7a8afa64969f9c80a42d9b6157de5d140 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Sat, 15 Mar 2025 22:13:22 +0100 Subject: [PATCH 02/66] Fix ValueError when color is not a string --- src/tikzplotly/_color.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/tikzplotly/_color.py b/src/tikzplotly/_color.py index 3dd9590..12fa0e4 100644 --- a/src/tikzplotly/_color.py +++ b/src/tikzplotly/_color.py @@ -3,6 +3,7 @@ """ from warnings import warn import hashlib +import numpy def rgb_str(red, green, blue): return str(red) + ", " + str(green) + ", " + str(blue) @@ -31,16 +32,23 @@ def convert_color(color): """ if color is None: return None, None, None, 1 - if color[0] == "#": + if isinstance(color, numpy.ndarray): + warn(f"Color from data is not supported yet. Returning the default color: blue.") + return "blue", "HTML", "0000ff", 1 + elif not isinstance(color, str): + warn(f"Color {color} type '{color.__class__.__name__}' is not supported yet. Returning the default color: blue.") + return "blue", "HTML", "0000ff", 1 + + if color.startswith("#"): return color[1:], "HTML", color[1:], 1 - elif color[0:4] == "rgba": + elif color.startswith("rgba"): sp = color.split("(")[1].split(",") rgb_color = sp[:-1] rgb_color = convert_color(f"rgb({rgb_color[0]},{rgb_color[1]},{rgb_color[2]})")[:-1] return rgb_color + (float(sp[-1][:-1]),) - elif color[0:3] == "rgb": + elif color.startswith("rgb"): color = color[4:-1].replace("[", "{").replace("]", "}") return hashlib.sha1(color.encode('UTF-8')).hexdigest()[:10], "RGB", color, 1 From 21ae4ab804e8aeee7ced78ca74dcad36351bbd8e Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Tue, 18 Mar 2025 17:43:43 +0100 Subject: [PATCH 03/66] Add more special characters to sanitize_TeX_text --- src/tikzplotly/_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tikzplotly/_utils.py b/src/tikzplotly/_utils.py index 66b6720..65e58fc 100644 --- a/src/tikzplotly/_utils.py +++ b/src/tikzplotly/_utils.py @@ -80,7 +80,8 @@ def sanitize_char(ch, keep_space=False): def sanitize_TeX_text(text: str): s = "".join(map(sanitize_TeX_char, text)) - if '[' in s or ']' in s: + special_chars = "[], " + if any(c in s for c in special_chars): return "{" + s + "}" return s From 9f1e783306b6c5692fed863b6996bbf42aec76bd Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Tue, 18 Mar 2025 18:09:54 +0100 Subject: [PATCH 04/66] add 'treat_data' to sanitize string data --- src/tikzplotly/_data.py | 7 +++++++ src/tikzplotly/_dataContainer.py | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/tikzplotly/_data.py b/src/tikzplotly/_data.py index a427a9b..d2f6c03 100644 --- a/src/tikzplotly/_data.py +++ b/src/tikzplotly/_data.py @@ -29,6 +29,13 @@ def data_type(data): else: return None +def treat_data(data_str): + if not isinstance(data_str, str): + return data_str + if data_str.find(' ') !=- 1: # Add curly braces if there space in string + data_str = "{" + data_str + "}" + return data_str + return data_str def post_treat_data(data_str): data_str = replace_all_mounts(data_str) diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index dca8817..be5ca25 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -1,6 +1,6 @@ import re from ._utils import replace_all_digits, sanitize_text -from ._data import post_treat_data +from ._data import treat_data, post_treat_data class Data: @@ -81,7 +81,8 @@ def exportData(self): export_string += "\\pgfplotstableread{" export_string += f"{sanitize_text(data.name)} {' '.join([sanitize_text(label) for label in data.y_label])}\n" for i in range(len(data.x)): - export_string += f"{data.x[i]} {' '.join([str(y[i]) for y in data.y_data])}\n" + x_val = treat_data(data.x[i]) + export_string += f"{x_val} {' '.join([str(y[i]) for y in data.y_data])}\n" export_string += "}" + sanitize_text(data.macro_name) + "\n" From cefacc54de0fff4e7331b9178fb437b4938c499b Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Tue, 18 Mar 2025 18:12:06 +0100 Subject: [PATCH 05/66] add support for bar plots --- src/tikzplotly/_bar.py | 80 ++++++++++++++++++++++++++++++++++++++++ src/tikzplotly/_save.py | 81 ++++++++++++++++++++++++++--------------- 2 files changed, 132 insertions(+), 29 deletions(-) create mode 100644 src/tikzplotly/_bar.py diff --git a/src/tikzplotly/_bar.py b/src/tikzplotly/_bar.py new file mode 100644 index 0000000..ce4387f --- /dev/null +++ b/src/tikzplotly/_bar.py @@ -0,0 +1,80 @@ +from ._axis import Axis +from ._utils import option_dict_to_str +from ._tex import tex_addplot +from ._color import convert_color +from warnings import warn + +def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_set, row_sep="\\\\"): + """ + Draw a bar chart (vertical or horizontal) referencing the data table + created by DataContainer.addData(...). + + If trace.orientation == 'h', we do xbar (horizontal). + Otherwise, we do ybar (vertical). + + Parameters + ---------- + data_name_macro : str + The LaTeX macro name for the data table (e.g. '\\data0'). + x_col_name : str + The name of the column for x in the data table (e.g. 'data0'). + y_col_name : str + The name of the column for y in the data table (e.g. 'y0'). + trace : plotly.graph_objs._bar.Bar + The bar trace object containing data and style information. + axis : Axis + The axis object to which the bar chart will be added. + colors_set : set + A set to keep track of colors used in the plot (for \\definecolor). + row_sep : str, optional + The row separator for the data table in TikZ, by default "\\\\" + """ + code = "" + plot_options = {} + type_options = {"row sep": row_sep} + + orientation = getattr(trace, "orientation", None) + barmode = getattr(trace, "barmode", None) + stack = " stacked" if axis.layout.barmode in ("stack", "relative") else "" + + if orientation == "h": + plot_options["xbar"] = None + axis.add_option("xbar" + stack, None) + type_options["x"] = y_col_name + type_options["y"] = x_col_name + else: + plot_options["ybar"] = None + axis.add_option("ybar" + stack, None) + type_options["x"] = x_col_name + type_options["y"] = y_col_name + + # Handle marker style (color, opacity, line) + if trace.marker is not None: + m = trace.marker + if m.color is not None: + c = convert_color(m.color) + colors_set.add(c[:3]) + plot_options["fill"] = c[0] + plot_options["color"] = c[0] + if m.opacity is not None: + plot_options["opacity"] = m.opacity + if m.line is not None: + if m.line.width is not None: + plot_options["line width"] = m.line.width + if m.line.color is not None: + linecol = convert_color(m.line.color) + colors_set.add(linecol[:3]) + plot_options["draw"] = linecol[0] + + if trace.text is not None: + warn("Text display for bar chart is not supported yet (ignored).") + + # Build the final addplot referencing the table + code += tex_addplot( + data_str=data_name_macro, + type="table", + options=option_dict_to_str(plot_options), + type_options=option_dict_to_str(type_options) + ) + + return code diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index faf2a3a..0c2b267 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -4,6 +4,7 @@ from ._scatter import draw_scatter2d from ._heatmap import draw_heatmap from ._histogram import draw_histogram +from ._bar import draw_bar from ._axis import Axis from ._color import * from ._annotations import str_from_annotation @@ -64,6 +65,17 @@ def get_tikz_code( trace.y = list(range(len(trace.x))) data_name_macro, y_name = data_container.addData(trace.x, trace.y, trace.name) + + # If x is textual => symbolic x coords + if all(isinstance(v, str) for v in trace.x): + axis.add_option("symbolic x coords", "{" + ",".join(trace.x) + "}") + axis.add_option("xtick", "data") + + # If y is textual => symbolic y coords + if all(isinstance(v, str) for v in trace.y): + axis.add_option("symbolic y coords", "{" + ",".join(trace.y) + "}") + axis.add_option("ytick", "data") + data_str.append( draw_scatter2d(data_name_macro, trace, y_name, axis, colors_set) ) if trace.name and trace['showlegend'] != False: data_str.append( tex_add_legendentry(sanitize_TeX_text(trace.name)) ) @@ -86,38 +98,49 @@ def get_tikz_code( if trace.name and trace['showlegend'] != False: data_str.append( tex_add_legendentry(sanitize_TeX_text(trace.name)) ) + elif trace.type == "bar": + orientation = getattr(trace, "orientation", None) + if orientation == "h": + cat_list = trace.y + val_list = trace.x + else: + cat_list = trace.x + val_list = trace.y + + # If x or y is empty + if trace.x is None and trace.y is None: + warn("Adding empty bar trace.") + data_str.append("\\addplot coordinates {};\n") + continue + else: + if trace.x is None: + trace.x = list(range(len(trace.y))) + if trace.y is None: + trace.y = list(range(len(trace.x))) + + data_name_macro, val_col_name = data_container.addData(cat_list, val_list, trace.name) + x_col_name = data_container.data[-1].name # 'data0 + + if all(isinstance(value, str) for value in cat_list): + if orientation == "h": + # Categories go on the y-axis + axis.add_option("symbolic y coords", sanitize_TeX_text(",".join(cat_list))) + axis.add_option("ytick", "data") + else: + # Categories go on the x-axis + axis.add_option("symbolic x coords", sanitize_TeX_text(",".join(cat_list))) + axis.add_option("xtick", "data") + + # Build the bar code + bar_code = draw_bar(data_name_macro, x_col_name, val_col_name, trace, axis, colors_set) + data_str.append(bar_code) + + if trace.name and trace['showlegend'] != False: + data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) + else: warn(f"Trace type {trace.type} is not supported yet.") - # Detect if we have symbolic x values - symbolic_x_vals = [] - for data_obj in data_container.data: - # If all x-values in this data object are strings, consider them symbolic - if all(isinstance(v, str) for v in data_obj.x): - for v in data_obj.x: - if v not in symbolic_x_vals: - symbolic_x_vals.append(v) - - # Detect if we have symbolic y values - symbolic_y_vals = [] - for data_obj in data_container.data: - for y_column in data_obj.y_data: - # If all y-values in this data object are strings, consider them symbolic - if all(isinstance(v, str) for v in y_column): - for val in y_column: - if val not in symbolic_y_vals: - symbolic_y_vals.append(val) - - # If we found symbolic x-values, tell pgfplots to treat them as symbolic - if symbolic_x_vals: - axis.add_option("symbolic x coords", "{" + ",".join(symbolic_x_vals) + "}") - axis.add_option("xtick", "data") - - # If we found symbolic y-values, treat them similarly - if symbolic_y_vals: - axis.add_option("symbolic y coords", "{" + ",".join(symbolic_y_vals) + "}") - axis.add_option("ytick", "data") - annotation_str = str_from_annotation(figure_layout.annotations, axis, colors_set) code = """""" From e23416e99e9f7e6b2931d03879c6847b95185327 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Tue, 18 Mar 2025 18:15:08 +0100 Subject: [PATCH 06/66] add main tests from plotly documentation --- src/tests/test_bars.py | 337 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 src/tests/test_bars.py diff --git a/src/tests/test_bars.py b/src/tests/test_bars.py new file mode 100644 index 0000000..64c92b8 --- /dev/null +++ b/src/tests/test_bars.py @@ -0,0 +1,337 @@ +# From https://plotly.com/python/bar-charts/ +import plotly +import plotly.express as px +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +import numpy as np +import tikzplotly +import os +from warnings import warn +from tikzplotly._tex import tex_create_document, tex_begin_environment, tex_end_environment, tex_end_all_environment + +def fig1(): + + data_canada = px.data.gapminder().query("country == 'Canada'") + fig = px.bar(data_canada, x='year', y='pop') + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig1.png")) + + return fig, "Bar chart with Plotly Express" + +def fig2(): + + long_df = px.data.medals_long() + + fig = px.bar(long_df, x="nation", y="count", color="medal", title="Long-Form Input") + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig2.png")) + + return fig, "Bar charts with Long Format Data" + +def fig3(): + + wide_df = px.data.medals_wide() + + fig = px.bar(wide_df, x="nation", y=["gold", "silver", "bronze"], title="Wide-Form Input") + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig3.png")) + + return fig, "Bar charts with Wide Format Data" + +def fig5(): + + df = px.data.gapminder().query("country == 'Canada'") + fig = px.bar(df, x='year', y='pop', + hover_data=['lifeExp', 'gdpPercap'], color='lifeExp', + labels={'pop':'population of Canada'}, height=400) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig5.png")) + + return fig, "Colored Bars" + +def fig5bis(): + + df = px.data.gapminder().query("continent == 'Oceania'") + fig = px.bar(df, x='year', y='pop', + hover_data=['lifeExp', 'gdpPercap'], color='country', + labels={'pop':'population of Canada'}, height=400) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig5bis.png")) + + return fig, "Colored Bars" + +def fig6(): + + df = px.data.tips() + fig = px.bar(df, x="sex", y="total_bill", color='time') + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig6.png")) + + return fig, "Stacked vs Grouped Bars" + +def fig6bis(): + + df = px.data.tips() + fig = px.bar(df, x="sex", y="total_bill", + color='smoker', barmode='group', + height=400) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig6bis.png")) + + return fig, "Stacked vs Grouped Bars" + +def fig7(): + + df = px.data.tips() + fig = px.histogram(df, x="sex", y="total_bill", + color='smoker', barmode='group', + height=400) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig7.png")) + + return fig, "Aggregating into Single Colored Bars" + +def fig7bis(): + + df = px.data.tips() + fig = px.histogram(df, x="sex", y="total_bill", + color='smoker', barmode='group', + histfunc='avg', + height=400) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig7bis.png")) + + return fig, "Aggregating into Single Colored Bars" + +def fig8(): + + df = px.data.medals_long() + + fig = px.bar(df, x="medal", y="count", color="nation", text_auto=True) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig8.png")) + + return fig, "Bar Charts with Text" + +def fig8bis(): + + df = px.data.medals_long() + + fig = px.bar(df, x="medal", y="count", color="nation", text="nation") + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig8bis.png")) + + return fig, "Bar Charts with Text" + +def fig8ter(): + + df = px.data.gapminder().query("continent == 'Europe' and year == 2007 and pop > 2.e6") + fig = px.bar(df, y='pop', x='country', text_auto='.2s', + title="Default: various text sizes, positions and angles") + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig8ter.png")) + + return fig, "Bar Charts with Text" + +def fig8quater(): + + df = px.data.gapminder().query("continent == 'Europe' and year == 2007 and pop > 2.e6") + fig = px.bar(df, y='pop', x='country', text_auto='.2s', + title="Controlled text sizes, positions and angles") + fig.update_traces(textfont_size=12, textangle=0, textposition="outside", cliponaxis=False) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig8quater.png")) + + return fig, "Bar Charts with Text" + +def fig9(): + + df = px.data.medals_long() + + fig = px.bar(df, x="medal", y="count", color="nation", + pattern_shape="nation", pattern_shape_sequence=[".", "x", "+"]) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig9.png")) + + return fig, "Pattern fills" + +def fig10(): + animals=['giraffes', 'orangutans', 'monkeys'] + + fig = go.Figure([go.Bar(x=animals, y=[20, 14, 23])]) + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig10.png")) + + return fig, "Basic Bar Charts with plotly graph objects" + +def fig11(): + animals=['giraffes', 'orangutans', 'monkeys'] + + fig = go.Figure(data=[ + go.Bar(name='SF Zoo', x=animals, y=[20, 14, 23]), + go.Bar(name='LA Zoo', x=animals, y=[12, 18, 29]) + ]) + # Change the bar mode + fig.update_layout(barmode='group') + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig11.png")) + + return fig, "Grouped Bar Chart" + +def fig12(): + animals=['giraffes', 'orangutans', 'monkeys'] + + fig = go.Figure(data=[ + go.Bar(name='SF Zoo', x=animals, y=[20, 14, 23]), + go.Bar(name='LA Zoo', x=animals, y=[12, 18, 29]) + ]) + # Change the bar mode + fig.update_layout(barmode='stack') + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig12.png")) + + return fig, "Stacked Bar Chart" + +def fig13(): + x = [1, 2, 3, 4] + + fig = go.Figure() + fig.add_trace(go.Bar(x=x, y=[1, 4, 9, 16])) + fig.add_trace(go.Bar(x=x, y=[6, -8, -4.5, 8])) + fig.add_trace(go.Bar(x=x, y=[-15, -3, 4.5, -8])) + fig.add_trace(go.Bar(x=x, y=[-1, 3, -3, -4])) + + fig.update_layout(barmode='relative', title_text='Relative Barmode') + + # fig.show() + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig13.png")) + + return fig, "Bar Chart with Relative Barmode" + + +def figa(): + """ + Creates a simple vertical grouped bar chart with categorical x-values. + We have two series of data, each with 7 categories. + """ + categories = ["04bits", "08bits", "16bits", "24bits", "32bits", "48bits", "64bits"] + fmax_chisel = [1147, 277, 256, 220, 207, 186, 179] + fmax_sv = [375, 270, 235, 212, 205, 192, 190] + + df = pd.DataFrame({ + "Category": categories, + "ALU1": fmax_chisel, + "ALU2": fmax_sv + }) + + fig = px.bar(df, x="Category", y=["ALU1", "ALU2"], barmode="group", + title="Simple Vertical Bar Chart Example") + + fig.update_layout(xaxis_title="Configuration", yaxis_title="Fmax (MHz)") + + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "figa.png")) + + return fig, "Simple vertical grouped bar chart" + +def figb(): + """ + Creates a simple horizontal grouped bar chart with categorical y-values. + We have two series of data, each with 7 categories. + """ + categories = ["04bits", "08bits", "16bits", "24bits", "32bits", "48bits", "64bits"] + fmax_chisel = [1147, 277, 256, 220, 207, 186, 179] + fmax_sv = [375, 270, 235, 212, 205, 192, 190] + + df = pd.DataFrame({ + "Category": categories, + "ALU1": fmax_chisel, + "ALU2": fmax_sv + }) + + fig = px.bar(df, x=["ALU1", "ALU2"], y="Category", barmode="group", + title="Simple Horizontal Bar Chart Example") + + fig.update_layout(xaxis_title="Fmax (MHz)", yaxis_title="Configuration") + + # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "figb.png")) + + return fig, "Simple horizontal grouped bar chart" + +if __name__ == "__main__": + + print("Tikzploty : ", tikzplotly.__version__) + print("Plotly : ", plotly.__version__) + print("Test histograms") + + + file_directory = os.path.dirname(os.path.abspath(__file__)) + + functions = [ + ("1", fig1), + ("2", fig2), + ("3", fig3), + ("5", fig5), + ("5bis", fig5bis), + ("6", fig6), # Stacked bars are not supported yet. + ("6bis", fig6bis), # Stacked bars are not supported yet. + ("7", fig7), # Aggregated bars are not supported yet. + ("7bis", fig7bis), # Aggregated bars are not supported yet. + ("8", fig8), # Text template is not supported yet. + ("8bis", fig8bis), # Text template is not supported yet. + ("8ter", fig8ter), # Text template is not supported yet. + ("8quater", fig8quater), # Text template is not supported yet. + ("9", fig9), # Pattern fills are not supported yet. + ("10", fig10), + ("11", fig11), + ("12", fig12), + ("13", fig13), + ("a", figa), + ("b", figb), + ] + + main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") + main_tex_content += "\\usepackage[left=1cm, right=1cm, top=1cm, bottom=1cm]{geometry}\n" + main_tex_content += "\n" + stack_env = [] + main_tex_content += tex_begin_environment("document", stack_env) + '\n' + + output_path = os.path.join(file_directory, "outputs", "test_bars") + if not os.path.exists(output_path): + os.makedirs(output_path) + + for i, f in functions: + print(f"Figure {i}") + fig, title = f() + data = fig.data + save_path = os.path.join(file_directory, "outputs", "test_bars", "fig{}.tex".format(i)) + tikzplotly.save(save_path, fig) + main_tex_content += tex_begin_environment("figure", stack_env) + main_tex_content += " \\input{fig" + str(i) + ".tex}\n" + main_tex_content += " \\caption{" + title + "}\n" + main_tex_content += tex_end_environment(stack_env) + '\n' + + main_tex_content += "\n" + tex_end_all_environment(stack_env) + + main_tex_path = os.path.join(file_directory, "outputs", "test_bars", "main.tex") + print("Save main tex file : ", main_tex_path) + with open(main_tex_path, "w") as f: + f.write(main_tex_content) From fc3d5f2d136532ae4d87ee35621eb85855bcfbb8 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 19 Mar 2025 01:45:14 +0100 Subject: [PATCH 07/66] add basic support for polar plots --- src/tests/test_polar.py | 100 ++++++++++++++++++++++++++++++++++++ src/tikzplotly/_polar.py | 108 +++++++++++++++++++++++++++++++++++++++ src/tikzplotly/_save.py | 9 ++++ 3 files changed, 217 insertions(+) create mode 100644 src/tests/test_polar.py create mode 100644 src/tikzplotly/_polar.py diff --git a/src/tests/test_polar.py b/src/tests/test_polar.py new file mode 100644 index 0000000..9e46081 --- /dev/null +++ b/src/tests/test_polar.py @@ -0,0 +1,100 @@ +# From https://plotly.com/python/polar-chart/ +import os +import tikzplotly + +from tikzplotly._tex import tex_create_document, tex_begin_environment, tex_end_environment, tex_end_all_environment + +import plotly +import plotly.express as px +import pandas as pd +import plotly.graph_objects as go +import numpy as np + +def fig1(): + + df = px.data.wind() + fig = px.scatter_polar(df, r="frequency", theta="direction") + # print(f"fig = {fig}") + + # fig.show() + return fig, "Polar chart with Plotly Express" + +def fig2(): + df = px.data.wind() + fig = px.scatter_polar(df, r="frequency", theta="direction", + color="strength", symbol="strength", size="frequency", + color_discrete_sequence=px.colors.sequential.Plasma_r) + + # fig.show() + return fig, "Polar chart with Plotly Express" + +def fig3(): + df = px.data.wind() + fig = px.line_polar(df, r="frequency", theta="direction", color="strength", line_close=True, + color_discrete_sequence=px.colors.sequential.Plasma_r, + template="plotly_dark",) + + # fig.show() + return fig, "Polar chart with Plotly Express" + +def fig4(): + fig = px.scatter_polar(r=range(0,90,10), theta=range(0,90,10), + range_theta=[0,90], start_angle=0, direction="counterclockwise") + + # fig.show() + return fig, "Range polar chart with Plotly Express" + +def fig5(): + fig = go.Figure(data= + go.Scatterpolar( + r = [0.5,1,2,2.5,3,4], + theta = [35,70,120,155,205,240], + mode = 'markers', + )) + + fig.update_layout(showlegend=False) + + # fig.show() + return fig, "Basic Polar Chart" + +if __name__ == "__main__": + + print("Tikzploty : ", tikzplotly.__version__) + print("Plotly : ", plotly.__version__) + print("Test line charts") + + file_directory = os.path.dirname(os.path.abspath(__file__)) + + functions = [ + ("1", fig1), + ("2", fig2), + ("3", fig3), + ("4", fig4), + ("5", fig5), + ] + + main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") + main_tex_content += "\\usepackage[left=1cm, right=1cm, top=1cm, bottom=1cm]{geometry}\n" + main_tex_content += "\\usetikzlibrary{pgfplots.dateplot}\n" + main_tex_content += "\\usepgfplotslibrary{polar}\n" + main_tex_content += "\n" + stack_env = [] + main_tex_content += tex_begin_environment("document", stack_env) + '\n' + + for i, f in functions: + print(f"Figure {i}") + fig, title = f() + data = fig.data + save_path = os.path.join(file_directory, "outputs", "test_polar", "fig{}.tex".format(i)) + tikzplotly.save(save_path, fig) + main_tex_content += tex_begin_environment("figure", stack_env) + main_tex_content += " \\input{fig" + str(i) + ".tex}\n" + main_tex_content += " \\caption{" + title + "}\n" + main_tex_content += tex_end_environment(stack_env) + '\n' + + main_tex_content += "\n" + tex_end_all_environment(stack_env) + + main_tex_path = os.path.join(file_directory, "outputs", "test_polar", "main.tex") + print("Save main tex file : ", main_tex_path) + with open(main_tex_path, "w") as f: + f.write(main_tex_content) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py new file mode 100644 index 0000000..41515e7 --- /dev/null +++ b/src/tikzplotly/_polar.py @@ -0,0 +1,108 @@ +from ._axis import Axis +from ._utils import option_dict_to_str +from ._tex import tex_addplot +from ._color import convert_color +from ._dataContainer import DataContainer +from warnings import warn + +def get_polar_coord(trace, axis: Axis, data_container: DataContainer): + # rotation + polar_layout = getattr(axis.layout, 'polar', None) + if polar_layout: + angularaxis = getattr(polar_layout, 'angularaxis', None) + if angularaxis: + rotation = getattr(angularaxis, 'rotation', None) + if rotation is not None and rotation != 0: + axis.add_option("rotate", rotation) + axis.add_option("xticklabel style", f"{{anchor=\\tick-{rotation}}}") + + theta = [t if t is not None else '' for t in trace.theta] + r = [val if val is not None else 'nan' for val in trace.r] + + if all(isinstance(t, str) for t in theta): + symbolic_theta = list(dict.fromkeys(theta)) + numeric_theta = [symbolic_theta.index(t) * (360 / len(symbolic_theta)) for t in theta] + + axis.environment = "polaraxis" + axis.add_option("xtick", f"{{{','.join(str(i*(360/len(symbolic_theta))) for i in range(len(symbolic_theta)))}}}") + axis.add_option("xticklabels", "{" + ",".join(symbolic_theta) + "}") + else: + symbolic_theta = None + numeric_theta = theta + + data_name_macro, r_col_name = data_container.addData(numeric_theta, r, trace.name) + theta_col_name = data_container.data[-1].name + + return data_name_macro, theta_col_name, r_col_name + + +def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: Axis, colors_set, row_sep="\\"): + """ + Draw a scatterpolar plot using pgfplots polaraxis environment. + + Parameters + ---------- + data_name_macro : str + The LaTeX macro for the data table (e.g. '\data0'). + theta_col_name : str + Name of the column for theta values (angles). + r_col_name : str + The name of the column for radial values in the data table. + trace : plotly.graph_objs._scatterpolar.Scatterpolar + Plotly Scatterpolar trace + axis : Axis + Axis object (to add axis-level options) + colors_set : set + Set of colors defined + row_sep : str, optional + Row separator in LaTeX, default "\\" + + Returns + ------- + str + LaTeX/pgfplots code for scatterpolar plot + """ + + plot_options = {} + mark_options = {} + + mode = trace.mode if trace.mode else "lines" + + if "markers" in mode: + plot_options["only marks"] = None + + if "lines" in mode: + plot_options["no markers"] = None + + # Marker style + if trace.marker is not None: + marker = trace.marker + if marker.color is not None: + col = convert_color(marker.color) + plot_options["color"] = col[0] + mark_opts = {"solid": None, "fill": col[0]} + colors_set.add(col[:3]) + # plot_options["mark options"] = "{" + option_dict_to_str(mark_option_dict=mark_opts) + "}" + + # Line style + if trace.line is not None: + line = trace.line + if line.color is not None: + color = convert_color(line.color) + colors_set.add(color[:3]) + plot_options["color"] = color[0] + if line.width is not None: + plot_options["line width"] = line.width + + # axis options for polar plot + axis.environment = "polaraxis" + + # Construct TikZ addplot + code = tex_addplot( + data_str=data_name_macro, + type="table", + options=option_dict_to_str(plot_options), + type_options=f"x={theta_col_name}, y={r_col_name}" + ) + + return code diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 0c2b267..aef1c39 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -5,6 +5,7 @@ from ._heatmap import draw_heatmap from ._histogram import draw_histogram from ._bar import draw_bar +from ._polar import get_polar_coord, draw_scatterpolar from ._axis import Axis from ._color import * from ._annotations import str_from_annotation @@ -138,6 +139,14 @@ def get_tikz_code( if trace.name and trace['showlegend'] != False: data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) + elif trace.type == "scatterpolar": + data_name_macro, theta_col_name, r_col_name = get_polar_coord(trace, axis, data_container) + polar_code = draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis, colors_set) + data_str.append(polar_code) + + if trace.name and trace['showlegend'] != False: + data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) + else: warn(f"Trace type {trace.type} is not supported yet.") From b09cae7fb44df2665d20031c78b0f290a4f7c68c Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 19 Mar 2025 01:57:12 +0100 Subject: [PATCH 08/66] fix bad tuple return value --- src/tikzplotly/_color.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tikzplotly/_color.py b/src/tikzplotly/_color.py index 12fa0e4..6a76444 100644 --- a/src/tikzplotly/_color.py +++ b/src/tikzplotly/_color.py @@ -60,8 +60,8 @@ def convert_color(color): else: warn(f"Color {color} type is not supported yet. Returning the same color.") - return color, 1 - + return color, None, None, 1 + def hex2rgb(hex_color): """Convert a hex color to a RGB color. From feb06439a1515b8fdf40b334c593541478212833 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 19 Mar 2025 16:44:08 +0100 Subject: [PATCH 09/66] add support for polar plot fill --- src/tikzplotly/_polar.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 41515e7..a6ea257 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -94,6 +94,12 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: if line.width is not None: plot_options["line width"] = line.width + # Fill + if trace.fill is not None: + if trace.fill == 'toself': + plot_options["fill"] = ".!50" + plot_options["opacity"] = 0.6 + # axis options for polar plot axis.environment = "polaraxis" From 50a31a467c5f4eaaff54e0509eadea517bd2ad05 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 19 Mar 2025 16:53:22 +0100 Subject: [PATCH 10/66] fix trace mode --- src/tikzplotly/_polar.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index a6ea257..ffd6cdb 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -68,11 +68,10 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: mode = trace.mode if trace.mode else "lines" - if "markers" in mode: - plot_options["only marks"] = None - - if "lines" in mode: + if not "markers" in mode: plot_options["no markers"] = None + elif not "lines" in mode: + plot_options["only marks"] = None # Marker style if trace.marker is not None: From 4c40f351ef70f4499ce6d45ae412d17c5c12779b Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 19 Mar 2025 18:53:04 +0100 Subject: [PATCH 11/66] add support for symbolic r --- src/tikzplotly/_save.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index aef1c39..5644ce6 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -141,6 +141,12 @@ def get_tikz_code( elif trace.type == "scatterpolar": data_name_macro, theta_col_name, r_col_name = get_polar_coord(trace, axis, data_container) + + if all(isinstance(v, str) for v in trace.r): + unique_r_vals = list(dict.fromkeys(trace.r)) + axis.add_option("symbolic y coords", "{" + ",".join(unique_r_vals) + "}") + axis.add_option("ytick", "data") + polar_code = draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis, colors_set) data_str.append(polar_code) From 0d60171b2aec3f611bfef28658d8d66ecdf9aecf Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 19 Mar 2025 18:54:03 +0100 Subject: [PATCH 12/66] add support for radians --- src/tikzplotly/_polar.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index ffd6cdb..27f7811 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -19,6 +19,8 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): theta = [t if t is not None else '' for t in trace.theta] r = [val if val is not None else 'nan' for val in trace.r] + thetaunit = getattr(trace, "thetaunit", "degrees") + if all(isinstance(t, str) for t in theta): symbolic_theta = list(dict.fromkeys(theta)) numeric_theta = [symbolic_theta.index(t) * (360 / len(symbolic_theta)) for t in theta] @@ -27,8 +29,11 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): axis.add_option("xtick", f"{{{','.join(str(i*(360/len(symbolic_theta))) for i in range(len(symbolic_theta)))}}}") axis.add_option("xticklabels", "{" + ",".join(symbolic_theta) + "}") else: - symbolic_theta = None - numeric_theta = theta + symbolic_theta = None + if thetaunit == "radians": + numeric_theta = [t * (180 / 3.141592653589793) for t in theta] + else: + numeric_theta = theta data_name_macro, r_col_name = data_container.addData(numeric_theta, r, trace.name) theta_col_name = data_container.data[-1].name From e04a4691eccdee5c1aa00051135efb3f488c1834 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 19 Mar 2025 18:56:15 +0100 Subject: [PATCH 13/66] add support for clockwise direction --- src/tikzplotly/_polar.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 27f7811..73ae24c 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -6,15 +6,23 @@ from warnings import warn def get_polar_coord(trace, axis: Axis, data_container: DataContainer): - # rotation polar_layout = getattr(axis.layout, 'polar', None) if polar_layout: angularaxis = getattr(polar_layout, 'angularaxis', None) if angularaxis: + # rotation rotation = getattr(angularaxis, 'rotation', None) - if rotation is not None and rotation != 0: + if rotation is None: + rotation = 0 + else: axis.add_option("rotate", rotation) axis.add_option("xticklabel style", f"{{anchor=\\tick-{rotation}}}") + axis.add_option("yticklabel style", f"{{anchor=\\tick-{rotation}-90}}") + + # direction + direction = getattr(angularaxis, "direction", "counterclockwise") + if direction == "clockwise": + axis.add_option("y dir", "reverse") theta = [t if t is not None else '' for t in trace.theta] r = [val if val is not None else 'nan' for val in trace.r] From 0dcf0cb8dbc362ce64213f3c1fa12a5c8bb64855 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 09:45:19 +0100 Subject: [PATCH 14/66] fix bad tick label anchor on rotate --- src/tikzplotly/_polar.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 73ae24c..ede9c38 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -10,19 +10,20 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): if polar_layout: angularaxis = getattr(polar_layout, 'angularaxis', None) if angularaxis: - # rotation + # Rotation rotation = getattr(angularaxis, 'rotation', None) if rotation is None: rotation = 0 else: axis.add_option("rotate", rotation) - axis.add_option("xticklabel style", f"{{anchor=\\tick-{rotation}}}") + axis.add_option("xticklabel style", f"{{anchor=\\tick+{rotation}+180}}") axis.add_option("yticklabel style", f"{{anchor=\\tick-{rotation}-90}}") - # direction + # Direction direction = getattr(angularaxis, "direction", "counterclockwise") if direction == "clockwise": axis.add_option("y dir", "reverse") + axis.add_option("xticklabel style", f"{{anchor={rotation}-\\tick+180}}") theta = [t if t is not None else '' for t in trace.theta] r = [val if val is not None else 'nan' for val in trace.r] @@ -106,13 +107,13 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: if line.width is not None: plot_options["line width"] = line.width - # Fill + # Fill if trace.fill is not None: if trace.fill == 'toself': plot_options["fill"] = ".!50" plot_options["opacity"] = 0.6 - # axis options for polar plot + # Axis options for polar plot axis.environment = "polaraxis" # Construct TikZ addplot From 1d5bff2fffd5bf2db8db8bd78777e005ed1f8d38 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 11:52:58 +0100 Subject: [PATCH 15/66] add support for polar period --- src/tikzplotly/_polar.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index ede9c38..96945da 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -4,14 +4,15 @@ from ._color import convert_color from ._dataContainer import DataContainer from warnings import warn +import numpy as np def get_polar_coord(trace, axis: Axis, data_container: DataContainer): - polar_layout = getattr(axis.layout, 'polar', None) + polar_layout = getattr(axis.layout, 'polar') if polar_layout: - angularaxis = getattr(polar_layout, 'angularaxis', None) + angularaxis = getattr(polar_layout, 'angularaxis') if angularaxis: # Rotation - rotation = getattr(angularaxis, 'rotation', None) + rotation = getattr(angularaxis, 'rotation') if rotation is None: rotation = 0 else: @@ -24,6 +25,9 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): if direction == "clockwise": axis.add_option("y dir", "reverse") axis.add_option("xticklabel style", f"{{anchor={rotation}-\\tick+180}}") + + # Period + period = getattr(angularaxis, "period") theta = [t if t is not None else '' for t in trace.theta] r = [val if val is not None else 'nan' for val in trace.r] @@ -32,10 +36,15 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): if all(isinstance(t, str) for t in theta): symbolic_theta = list(dict.fromkeys(theta)) - numeric_theta = [symbolic_theta.index(t) * (360 / len(symbolic_theta)) for t in theta] + + n_theta = len(symbolic_theta) + if period is not None: + n_theta = max(period, n_theta) + + numeric_theta = [symbolic_theta.index(t) * (360 / n_theta) for t in theta] axis.environment = "polaraxis" - axis.add_option("xtick", f"{{{','.join(str(i*(360/len(symbolic_theta))) for i in range(len(symbolic_theta)))}}}") + axis.add_option("xtick", f"{{{','.join(str( i * (360 / n_theta)) for i in range(n_theta))}}}") axis.add_option("xticklabels", "{" + ",".join(symbolic_theta) + "}") else: symbolic_theta = None @@ -95,7 +104,12 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: plot_options["color"] = col[0] mark_opts = {"solid": None, "fill": col[0]} colors_set.add(col[:3]) - # plot_options["mark options"] = "{" + option_dict_to_str(mark_option_dict=mark_opts) + "}" + plot_options["mark options"] = "{" + option_dict_to_str(mark_opts) + "}" + if marker.size is not None: + if isinstance(marker.size, np.ndarray): + warn(f"Individual marker sizes in a trace are not supported yet.") + else: + plot_options["mark size"] = marker.size/4 # Line style if trace.line is not None: From 4baf8b786fca9062c5adbcd515f4daab43fac99b Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 11:53:50 +0100 Subject: [PATCH 16/66] add support for polar category order --- src/tikzplotly/_polar.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 96945da..85dbb8d 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -29,14 +29,20 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): # Period period = getattr(angularaxis, "period") + # Category + categoryarray = getattr(angularaxis, 'categoryarray') + theta = [t if t is not None else '' for t in trace.theta] r = [val if val is not None else 'nan' for val in trace.r] thetaunit = getattr(trace, "thetaunit", "degrees") if all(isinstance(t, str) for t in theta): - symbolic_theta = list(dict.fromkeys(theta)) - + if categoryarray is not None: + symbolic_theta = list(categoryarray) + else: + symbolic_theta = list(dict.fromkeys(theta)) + n_theta = len(symbolic_theta) if period is not None: n_theta = max(period, n_theta) From 1282c4f9d32da0fd2fe862e7e03101657cd1ecc9 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 11:54:07 +0100 Subject: [PATCH 17/66] add support for scatterpolargl --- src/tikzplotly/_save.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 5644ce6..a749a9e 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -139,7 +139,7 @@ def get_tikz_code( if trace.name and trace['showlegend'] != False: data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) - elif trace.type == "scatterpolar": + elif trace.type == "scatterpolar" or trace.type == "scatterpolargl": data_name_macro, theta_col_name, r_col_name = get_polar_coord(trace, axis, data_container) if all(isinstance(v, str) for v in trace.r): From 2b938a0ae46ed23353fa4aac1e18b2f7afc05bfa Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 13:10:03 +0100 Subject: [PATCH 18/66] add support for radial category order and array --- src/tikzplotly/_polar.py | 43 ++++++++++++++++++++++++++++++++++++---- src/tikzplotly/_save.py | 5 ----- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 85dbb8d..545839b 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -9,6 +9,7 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): polar_layout = getattr(axis.layout, 'polar') if polar_layout: + # Angular Axis angularaxis = getattr(polar_layout, 'angularaxis') if angularaxis: # Rotation @@ -28,21 +29,38 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): # Period period = getattr(angularaxis, "period") + + # Category + angular_categoryorder = getattr(angularaxis, 'categoryorder', 'trace') + angular_categoryarray = getattr(angularaxis, 'categoryarray', None) + # Radial Axis + radialaxis = getattr(polar_layout, 'radialaxis') + if radialaxis: # Category - categoryarray = getattr(angularaxis, 'categoryarray') + radial_categoryorder = getattr(radialaxis, 'categoryorder', 'trace') + radial_categoryarray = getattr(radialaxis, 'categoryarray', None) theta = [t if t is not None else '' for t in trace.theta] r = [val if val is not None else 'nan' for val in trace.r] thetaunit = getattr(trace, "thetaunit", "degrees") + # Angular Axis if all(isinstance(t, str) for t in theta): - if categoryarray is not None: - symbolic_theta = list(categoryarray) + if angular_categoryarray is not None: + symbolic_theta = list(angular_categoryarray) else: symbolic_theta = list(dict.fromkeys(theta)) + if angular_categoryorder is not None: + if angular_categoryorder == "category ascending": + symbolic_theta = sorted(set(symbolic_theta)) + elif angular_categoryorder == "category descending": + symbolic_theta = sorted(set(symbolic_theta), reverse=True) + else: + warn(f"Polar: Angular category order {angular_categoryorder} is not supported yet.") + n_theta = len(symbolic_theta) if period is not None: n_theta = max(period, n_theta) @@ -59,6 +77,23 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): else: numeric_theta = theta + # Radial Axis + if all(isinstance(v, str) for v in r): + if radial_categoryarray is not None: + symbolic_r = list(radial_categoryarray) + else: + symbolic_r = list(dict.fromkeys(r)) + if radial_categoryorder is not None: + if radial_categoryorder == "category ascending": + symbolic_r = sorted(set(symbolic_r)) + elif radial_categoryorder == "category descending": + symbolic_r = sorted(set(symbolic_r), reverse=True) + else: + warn(f"Polar: Radial category order {radial_categoryorder} is not supported yet.") + + axis.add_option("symbolic y coords", "{" + ",".join(symbolic_r) + "}") + axis.add_option("ytick", "data") + data_name_macro, r_col_name = data_container.addData(numeric_theta, r, trace.name) theta_col_name = data_container.data[-1].name @@ -113,7 +148,7 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: plot_options["mark options"] = "{" + option_dict_to_str(mark_opts) + "}" if marker.size is not None: if isinstance(marker.size, np.ndarray): - warn(f"Individual marker sizes in a trace are not supported yet.") + warn(f"Polar: Individual marker sizes in a trace are not supported yet.") else: plot_options["mark size"] = marker.size/4 diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index a749a9e..6ccf76e 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -142,11 +142,6 @@ def get_tikz_code( elif trace.type == "scatterpolar" or trace.type == "scatterpolargl": data_name_macro, theta_col_name, r_col_name = get_polar_coord(trace, axis, data_container) - if all(isinstance(v, str) for v in trace.r): - unique_r_vals = list(dict.fromkeys(trace.r)) - axis.add_option("symbolic y coords", "{" + ",".join(unique_r_vals) + "}") - axis.add_option("ytick", "data") - polar_code = draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis, colors_set) data_str.append(polar_code) From 2f91ca787d782826c2add90fa0fb738127c7458a Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 13:10:52 +0100 Subject: [PATCH 19/66] add support for sectors (partial polar axes) --- src/tikzplotly/_polar.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 545839b..a0af262 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -41,6 +41,12 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): radial_categoryorder = getattr(radialaxis, 'categoryorder', 'trace') radial_categoryarray = getattr(radialaxis, 'categoryarray', None) + # Sector + sector = getattr(polar_layout, 'sector') + if sector and len(sector) > 1: + axis.add_option("xmin", sector[0]) + axis.add_option("xmax", sector[1]) + theta = [t if t is not None else '' for t in trace.theta] r = [val if val is not None else 'nan' for val in trace.r] From 38af53f0328cac589cf45c12ed8c9a56b6cab8ea Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 15:43:14 +0100 Subject: [PATCH 20/66] Add support for radial range --- src/tikzplotly/_polar.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index a0af262..d1747c1 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -41,6 +41,12 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): radial_categoryorder = getattr(radialaxis, 'categoryorder', 'trace') radial_categoryarray = getattr(radialaxis, 'categoryarray', None) + # Range + sector = getattr(radialaxis, 'range') + if sector and len(sector) > 1: + axis.add_option("ymin", sector[0]) + axis.add_option("ymax", sector[1]) + # Sector sector = getattr(polar_layout, 'sector') if sector and len(sector) > 1: From 0c57421d34a6c3f6720d287e9634b6888372b2aa Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 16:01:17 +0100 Subject: [PATCH 21/66] Add a warning for log radial axis type --- src/tikzplotly/_polar.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index d1747c1..5225330 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -37,6 +37,11 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): # Radial Axis radialaxis = getattr(polar_layout, 'radialaxis') if radialaxis: + # Type + radial_axis_type = getattr(radialaxis, 'type', None) + if radial_axis_type is not None and radial_axis_type not in ['-', 'linear', 'category']: + warn(f"Polar: Radial axis type {radial_axis_type} is not supported yet.") + # Category radial_categoryorder = getattr(radialaxis, 'categoryorder', 'trace') radial_categoryarray = getattr(radialaxis, 'categoryarray', None) From b97e599798048c76d7a3b8b0b1d30d02e5d858d2 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 16:27:09 +0100 Subject: [PATCH 22/66] add missing colors --- src/tikzplotly/_color.py | 67 ++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/src/tikzplotly/_color.py b/src/tikzplotly/_color.py index 6a76444..1d52a48 100644 --- a/src/tikzplotly/_color.py +++ b/src/tikzplotly/_color.py @@ -103,7 +103,7 @@ def hex2rgb(hex_color): BISQUE4 = rgb_str(139, 125, 107) BLACK = rgb_str(0, 0, 0) BLANCHEDALMOND = rgb_str(255, 235, 205) -BLUE = rgb_str(0, 0, 255) +BLUE1 = rgb_str(0, 0, 255) BLUE2 = rgb_str(0, 0, 238) BLUE3 = rgb_str(0, 0, 205) BLUE4 = rgb_str(0, 0, 139) @@ -152,9 +152,12 @@ def hex2rgb(hex_color): CORNSILK3 = rgb_str(205, 200, 177) CORNSILK4 = rgb_str(139, 136, 120) CRIMSON = rgb_str(220, 20, 60) +CYAN1 = rgb_str(0, 255, 255) CYAN2 = rgb_str(0, 238, 238) CYAN3 = rgb_str(0, 205, 205) CYAN4 = rgb_str(0, 139, 139) +DARKBLUE = rgb_str(0, 0, 139) +DARKCYAN = rgb_str(0, 139, 139) DARKGOLDENROD = rgb_str(184, 134, 11) DARKGOLDENROD1 = rgb_str(255, 185, 15) DARKGOLDENROD2 = rgb_str(238, 173, 14) @@ -163,6 +166,7 @@ def hex2rgb(hex_color): DARKGRAY = rgb_str(169, 169, 169) DARKGREEN = rgb_str(0, 100, 0) DARKKHAKI = rgb_str(189, 183, 107) +DARKMAGENTA = rgb_str(139, 0, 139) DARKOLIVEGREEN = rgb_str(85, 107, 47) DARKOLIVEGREEN1 = rgb_str(202, 255, 112) DARKOLIVEGREEN2 = rgb_str(188, 238, 104) @@ -178,6 +182,7 @@ def hex2rgb(hex_color): DARKORCHID2 = rgb_str(178, 58, 238) DARKORCHID3 = rgb_str(154, 50, 205) DARKORCHID4 = rgb_str(104, 34, 139) +DARKRED = rgb_str(139, 0, 0) DARKSALMON = rgb_str(233, 150, 122) DARKSEAGREEN = rgb_str(143, 188, 143) DARKSEAGREEN1 = rgb_str(193, 255, 193) @@ -216,6 +221,7 @@ def hex2rgb(hex_color): FLESH = rgb_str(255, 125, 64) FLORALWHITE = rgb_str(255, 250, 240) FORESTGREEN = rgb_str(34, 139, 34) +FUCHSIA = rgb_str(255, 0, 255) GAINSBORO = rgb_str(220, 220, 220) GHOSTWHITE = rgb_str(248, 248, 255) GOLD1 = rgb_str(255, 215, 0) @@ -340,7 +346,6 @@ def hex2rgb(hex_color): HOTPINK2 = rgb_str(238, 106, 167) HOTPINK3 = rgb_str(205, 96, 144) HOTPINK4 = rgb_str(139, 58, 98) -INDIANRED = rgb_str(176, 23, 31) INDIANRED = rgb_str(205, 92, 92) INDIANRED1 = rgb_str(255, 106, 106) INDIANRED2 = rgb_str(238, 99, 99) @@ -382,6 +387,7 @@ def hex2rgb(hex_color): LIGHTGOLDENROD3 = rgb_str(205, 190, 112) LIGHTGOLDENROD4 = rgb_str(139, 129, 76) LIGHTGOLDENRODYELLOW = rgb_str(250, 250, 210) +LIGHTGREEN = rgb_str(144, 238, 144) LIGHTGREY = rgb_str(211, 211, 211) LIGHTPINK = rgb_str(255, 182, 193) LIGHTPINK1 = rgb_str(255, 174, 185) @@ -411,7 +417,7 @@ def hex2rgb(hex_color): LIGHTYELLOW4 = rgb_str(139, 139, 122) LIMEGREEN = rgb_str(50, 205, 50) LINEN = rgb_str(250, 240, 230) -MAGENTA = rgb_str(255, 0, 255) +MAGENTA1 = rgb_str(255, 0, 255) MAGENTA2 = rgb_str(238, 0, 238) MAGENTA3 = rgb_str(205, 0, 205) MAGENTA4 = rgb_str(139, 0, 139) @@ -477,6 +483,7 @@ def hex2rgb(hex_color): PALEGREEN2 = rgb_str(144, 238, 144) PALEGREEN3 = rgb_str(124, 205, 124) PALEGREEN4 = rgb_str(84, 139, 84) +PALETURQUOISE = rgb_str(175, 238, 238) PALETURQUOISE1 = rgb_str(187, 255, 255) PALETURQUOISE2 = rgb_str(174, 238, 238) PALETURQUOISE3 = rgb_str(150, 205, 205) @@ -492,6 +499,7 @@ def hex2rgb(hex_color): PEACHPUFF3 = rgb_str(205, 175, 149) PEACHPUFF4 = rgb_str(139, 119, 101) PEACOCK = rgb_str(51, 161, 201) +PERU = rgb_str(205, 133, 63) PINK = rgb_str(255, 192, 203) PINK1 = rgb_str(255, 181, 197) PINK2 = rgb_str(238, 169, 184) @@ -531,6 +539,7 @@ def hex2rgb(hex_color): SALMON4 = rgb_str(139, 76, 57) SANDYBROWN = rgb_str(244, 164, 96) SAPGREEN = rgb_str(48, 128, 20) +SEAGREEN = rgb_str(46, 139, 87) SEAGREEN1 = rgb_str(84, 255, 159) SEAGREEN2 = rgb_str(78, 238, 148) SEAGREEN3 = rgb_str(67, 205, 128) @@ -629,7 +638,6 @@ def hex2rgb(hex_color): WHEAT4 = rgb_str(139, 126, 102) WHITE = rgb_str(255, 255, 255) WHITESMOKE = rgb_str(245, 245, 245) -WHITESMOKE = rgb_str(245, 245, 245) YELLOW1 = rgb_str(255, 255, 0) YELLOW2 = rgb_str(238, 238, 0) YELLOW3 = rgb_str(205, 205, 0) @@ -642,23 +650,27 @@ def hex2rgb(hex_color): colors["antiquewhite3"] = ANTIQUEWHITE3 colors["antiquewhite4"] = ANTIQUEWHITE4 colors["aqua"] = AQUA +colors["aquamarine"] = AQUAMARINE1 colors["aquamarine1"] = AQUAMARINE1 colors["aquamarine2"] = AQUAMARINE2 colors["aquamarine3"] = AQUAMARINE3 colors["aquamarine4"] = AQUAMARINE4 +colors["azure"] = AZURE1 colors["azure1"] = AZURE1 colors["azure2"] = AZURE2 colors["azure3"] = AZURE3 colors["azure4"] = AZURE4 colors["banana"] = BANANA colors["beige"] = BEIGE +colors["bisque"] = BISQUE1 colors["bisque1"] = BISQUE1 colors["bisque2"] = BISQUE2 colors["bisque3"] = BISQUE3 colors["bisque4"] = BISQUE4 colors["black"] = BLACK colors["blanchedalmond"] = BLANCHEDALMOND -colors["blue"] = BLUE +colors["blue"] = BLUE1 +colors["blue1"] = BLUE1 colors["blue2"] = BLUE2 colors["blue3"] = BLUE3 colors["blue4"] = BLUE4 @@ -684,6 +696,7 @@ def hex2rgb(hex_color): colors["cadmiumorange"] = CADMIUMORANGE colors["cadmiumyellow"] = CADMIUMYELLOW colors["carrot"] = CARROT +colors["chartreuse"] = CHARTREUSE1 colors["chartreuse1"] = CHARTREUSE1 colors["chartreuse2"] = CHARTREUSE2 colors["chartreuse3"] = CHARTREUSE3 @@ -702,14 +715,19 @@ def hex2rgb(hex_color): colors["coral3"] = CORAL3 colors["coral4"] = CORAL4 colors["cornflowerblue"] = CORNFLOWERBLUE +colors["cornsilk"] = CORNSILK1 colors["cornsilk1"] = CORNSILK1 colors["cornsilk2"] = CORNSILK2 colors["cornsilk3"] = CORNSILK3 colors["cornsilk4"] = CORNSILK4 colors["crimson"] = CRIMSON +colors["cyan"] = CYAN1 +colors["cyan1"] = CYAN1 colors["cyan2"] = CYAN2 colors["cyan3"] = CYAN3 colors["cyan4"] = CYAN4 +colors["darkblue"] = DARKBLUE +colors["darkcyan"] = DARKCYAN colors["darkgoldenrod"] = DARKGOLDENROD colors["darkgoldenrod1"] = DARKGOLDENROD1 colors["darkgoldenrod2"] = DARKGOLDENROD2 @@ -718,6 +736,7 @@ def hex2rgb(hex_color): colors["darkgray"] = DARKGRAY colors["darkgreen"] = DARKGREEN colors["darkkhaki"] = DARKKHAKI +colors["darkmagenta"] = DARKMAGENTA colors["darkolivegreen"] = DARKOLIVEGREEN colors["darkolivegreen1"] = DARKOLIVEGREEN1 colors["darkolivegreen2"] = DARKOLIVEGREEN2 @@ -733,6 +752,7 @@ def hex2rgb(hex_color): colors["darkorchid2"] = DARKORCHID2 colors["darkorchid3"] = DARKORCHID3 colors["darkorchid4"] = DARKORCHID4 +colors["darkred"] = DARKRED colors["darksalmon"] = DARKSALMON colors["darkseagreen"] = DARKSEAGREEN colors["darkseagreen1"] = DARKSEAGREEN1 @@ -741,27 +761,30 @@ def hex2rgb(hex_color): colors["darkseagreen4"] = DARKSEAGREEN4 colors["darkslateblue"] = DARKSLATEBLUE colors["darkslategray"] = DARKSLATEGRAY -colors["darkslategrey"] = DARKSLATEGRAY colors["darkslategray1"] = DARKSLATEGRAY1 -colors["darkslategrey1"] = DARKSLATEGRAY colors["darkslategray2"] = DARKSLATEGRAY2 -colors["darkslategrey2"] = DARKSLATEGRAY colors["darkslategray3"] = DARKSLATEGRAY3 -colors["darkslategrey3"] = DARKSLATEGRAY colors["darkslategray4"] = DARKSLATEGRAY4 -colors["darkslategrey4"] = DARKSLATEGRAY +colors["darkslategrey"] = DARKSLATEGRAY +colors["darkslategrey1"] = DARKSLATEGRAY1 +colors["darkslategrey2"] = DARKSLATEGRAY2 +colors["darkslategrey3"] = DARKSLATEGRAY3 +colors["darkslategrey4"] = DARKSLATEGRAY4 colors["darkturquoise"] = DARKTURQUOISE colors["darkviolet"] = DARKVIOLET +colors["deeppink"] = DEEPPINK1 colors["deeppink1"] = DEEPPINK1 colors["deeppink2"] = DEEPPINK2 colors["deeppink3"] = DEEPPINK3 colors["deeppink4"] = DEEPPINK4 +colors["deepskyblue"] = DEEPSKYBLUE1 colors["deepskyblue1"] = DEEPSKYBLUE1 colors["deepskyblue2"] = DEEPSKYBLUE2 colors["deepskyblue3"] = DEEPSKYBLUE3 colors["deepskyblue4"] = DEEPSKYBLUE4 colors["dimgray"] = DIMGRAY colors["dimgray"] = DIMGRAY +colors["dodgerblue"] = DODGERBLUE1 colors["dodgerblue1"] = DODGERBLUE1 colors["dodgerblue2"] = DODGERBLUE2 colors["dodgerblue3"] = DODGERBLUE3 @@ -776,8 +799,10 @@ def hex2rgb(hex_color): colors["flesh"] = FLESH colors["floralwhite"] = FLORALWHITE colors["forestgreen"] = FORESTGREEN +colors["fuchsia"] = FUCHSIA colors["gainsboro"] = GAINSBORO colors["ghostwhite"] = GHOSTWHITE +colors["gold"] = GOLD1 colors["gold1"] = GOLD1 colors["gold2"] = GOLD2 colors["gold3"] = GOLD3 @@ -891,6 +916,7 @@ def hex2rgb(hex_color): colors["green3"] = GREEN3 colors["green4"] = GREEN4 colors["greenyellow"] = GREENYELLOW +colors["honeydew"] = HONEYDEW1 colors["honeydew1"] = HONEYDEW1 colors["honeydew2"] = HONEYDEW2 colors["honeydew3"] = HONEYDEW3 @@ -901,12 +927,12 @@ def hex2rgb(hex_color): colors["hotpink3"] = HOTPINK3 colors["hotpink4"] = HOTPINK4 colors["indianred"] = INDIANRED -colors["indianred"] = INDIANRED colors["indianred1"] = INDIANRED1 colors["indianred2"] = INDIANRED2 colors["indianred3"] = INDIANRED3 colors["indianred4"] = INDIANRED4 colors["indigo"] = INDIGO +colors["ivory"] = IVORY1 colors["ivory1"] = IVORY1 colors["ivory2"] = IVORY2 colors["ivory3"] = IVORY3 @@ -918,11 +944,13 @@ def hex2rgb(hex_color): colors["khaki3"] = KHAKI3 colors["khaki4"] = KHAKI4 colors["lavender"] = LAVENDER +colors["lavenderblush"] = LAVENDERBLUSH1 colors["lavenderblush1"] = LAVENDERBLUSH1 colors["lavenderblush2"] = LAVENDERBLUSH2 colors["lavenderblush3"] = LAVENDERBLUSH3 colors["lavenderblush4"] = LAVENDERBLUSH4 colors["lawngreen"] = LAWNGREEN +colors["lemonchiffon"] = LEMONCHIFFON1 colors["lemonchiffon1"] = LEMONCHIFFON1 colors["lemonchiffon2"] = LEMONCHIFFON2 colors["lemonchiffon3"] = LEMONCHIFFON3 @@ -942,12 +970,14 @@ def hex2rgb(hex_color): colors["lightgoldenrod3"] = LIGHTGOLDENROD3 colors["lightgoldenrod4"] = LIGHTGOLDENROD4 colors["lightgoldenrodyellow"] = LIGHTGOLDENRODYELLOW +colors["lightgreen"] = LIGHTGREEN colors["lightgrey"] = LIGHTGREY colors["lightpink"] = LIGHTPINK colors["lightpink1"] = LIGHTPINK1 colors["lightpink2"] = LIGHTPINK2 colors["lightpink3"] = LIGHTPINK3 colors["lightpink4"] = LIGHTPINK4 +colors["lightsalmon"] = LIGHTSALMON1 colors["lightsalmon1"] = LIGHTSALMON1 colors["lightsalmon2"] = LIGHTSALMON2 colors["lightsalmon3"] = LIGHTSALMON3 @@ -965,13 +995,15 @@ def hex2rgb(hex_color): colors["lightsteelblue2"] = LIGHTSTEELBLUE2 colors["lightsteelblue3"] = LIGHTSTEELBLUE3 colors["lightsteelblue4"] = LIGHTSTEELBLUE4 +colors["lightyellow"] = LIGHTYELLOW1 colors["lightyellow1"] = LIGHTYELLOW1 colors["lightyellow2"] = LIGHTYELLOW2 colors["lightyellow3"] = LIGHTYELLOW3 colors["lightyellow4"] = LIGHTYELLOW4 colors["limegreen"] = LIMEGREEN colors["linen"] = LINEN -colors["magenta"] = MAGENTA +colors["magenta"] = MAGENTA1 +colors["magenta1"] = MAGENTA1 colors["magenta2"] = MAGENTA2 colors["magenta3"] = MAGENTA3 colors["magenta4"] = MAGENTA4 @@ -1005,6 +1037,7 @@ def hex2rgb(hex_color): colors["mistyrose3"] = MISTYROSE3 colors["mistyrose4"] = MISTYROSE4 colors["moccasin"] = MOCCASIN +colors["navajowhite"] = NAVAJOWHITE1 colors["navajowhite1"] = NAVAJOWHITE1 colors["navajowhite2"] = NAVAJOWHITE2 colors["navajowhite3"] = NAVAJOWHITE3 @@ -1022,6 +1055,7 @@ def hex2rgb(hex_color): colors["orange2"] = ORANGE2 colors["orange3"] = ORANGE3 colors["orange4"] = ORANGE4 +colors["orangered"] = ORANGERED1 colors["orangered1"] = ORANGERED1 colors["orangered2"] = ORANGERED2 colors["orangered3"] = ORANGERED3 @@ -1037,6 +1071,7 @@ def hex2rgb(hex_color): colors["palegreen2"] = PALEGREEN2 colors["palegreen3"] = PALEGREEN3 colors["palegreen4"] = PALEGREEN4 +colors["paleturquoise"] = PALETURQUOISE colors["paleturquoise1"] = PALETURQUOISE1 colors["paleturquoise2"] = PALETURQUOISE2 colors["paleturquoise3"] = PALETURQUOISE3 @@ -1047,11 +1082,13 @@ def hex2rgb(hex_color): colors["palevioletred3"] = PALEVIOLETRED3 colors["palevioletred4"] = PALEVIOLETRED4 colors["papayawhip"] = PAPAYAWHIP +colors["peachpuff"] = PEACHPUFF1 colors["peachpuff1"] = PEACHPUFF1 colors["peachpuff2"] = PEACHPUFF2 colors["peachpuff3"] = PEACHPUFF3 colors["peachpuff4"] = PEACHPUFF4 colors["peacock"] = PEACOCK +colors["peru"] = PERU colors["pink"] = PINK colors["pink1"] = PINK1 colors["pink2"] = PINK2 @@ -1070,6 +1107,7 @@ def hex2rgb(hex_color): colors["purple4"] = PURPLE4 colors["raspberry"] = RASPBERRY colors["rawsienna"] = RAWSIENNA +colors["red"] = RED1 colors["red1"] = RED1 colors["red2"] = RED2 colors["red3"] = RED3 @@ -1091,6 +1129,7 @@ def hex2rgb(hex_color): colors["salmon4"] = SALMON4 colors["sandybrown"] = SANDYBROWN colors["sapgreen"] = SAPGREEN +colors["seagreen"] = SEAGREEN colors["seagreen1"] = SEAGREEN1 colors["seagreen2"] = SEAGREEN2 colors["seagreen3"] = SEAGREEN3 @@ -1141,6 +1180,7 @@ def hex2rgb(hex_color): colors["slategray2"] = SLATEGRAY2 colors["slategray3"] = SLATEGRAY3 colors["slategray4"] = SLATEGRAY4 +colors["snow"] = SNOW1 colors["snow1"] = SNOW1 colors["snow2"] = SNOW2 colors["snow3"] = SNOW3 @@ -1165,6 +1205,7 @@ def hex2rgb(hex_color): colors["thistle2"] = THISTLE2 colors["thistle3"] = THISTLE3 colors["thistle4"] = THISTLE4 +colors["tomato"] = TOMATO1 colors["tomato1"] = TOMATO1 colors["tomato2"] = TOMATO2 colors["tomato3"] = TOMATO3 @@ -1189,7 +1230,7 @@ def hex2rgb(hex_color): colors["wheat4"] = WHEAT4 colors["white"] = WHITE colors["whitesmoke"] = WHITESMOKE -colors["whitesmoke"] = WHITESMOKE +colors["yellow"] = YELLOW1 colors["yellow1"] = YELLOW1 colors["yellow2"] = YELLOW2 colors["yellow3"] = YELLOW3 From 65c7c3d49f52f8f99e5d8180f6ab8574b101e6c6 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Thu, 20 Mar 2025 16:32:24 +0100 Subject: [PATCH 23/66] add all examples from plotly radar and polar charts documentation --- src/tests/test_polar.py | 578 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 567 insertions(+), 11 deletions(-) diff --git a/src/tests/test_polar.py b/src/tests/test_polar.py index 9e46081..23681e4 100644 --- a/src/tests/test_polar.py +++ b/src/tests/test_polar.py @@ -1,3 +1,4 @@ +# From https://plotly.com/python/radar-chart/ # From https://plotly.com/python/polar-chart/ import os import tikzplotly @@ -10,16 +11,88 @@ import plotly.graph_objects as go import numpy as np -def fig1(): +def fig_radar1(): + df = pd.DataFrame(dict( + r=[1, 5, 2, 2, 3], + theta=['processing cost','mechanical properties','chemical stability', + 'thermal stability', 'device integration'])) + fig = px.line_polar(df, r='r', theta='theta', line_close=True) + + # fig.show() + return fig, "Radar Chart with Plotly Express" + +def fig_radar2(): + df = pd.DataFrame(dict( + r=[1, 5, 2, 2, 3], + theta=['processing cost','mechanical properties','chemical stability', + 'thermal stability', 'device integration'])) + fig = px.line_polar(df, r='r', theta='theta', line_close=True) + fig.update_traces(fill='toself') + + # fig.show() + return fig, "Filled Radar Chart with Plotly Express" + +def fig_radar3(): + fig = go.Figure(data=go.Scatterpolar( + r=[1, 5, 2, 2, 3], + theta=['processing cost','mechanical properties','chemical stability', 'thermal stability', + 'device integration'], + fill='toself' + )) + + fig.update_layout( + polar=dict( + radialaxis=dict( + visible=True + ), + ), + showlegend=False + ) + + # fig.show() + return fig, "Basic Filled Radar Chart with go.Scatterpolar" + +def fig_radar4(): + categories = ['processing cost','mechanical properties','chemical stability', + 'thermal stability', 'device integration'] + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + r=[1, 5, 2, 2, 3], + theta=categories, + fill='toself', + name='Product A' + )) + fig.add_trace(go.Scatterpolar( + r=[4, 3, 2.5, 1, 2], + theta=categories, + fill='toself', + name='Product B' + )) + + fig.update_layout( + polar=dict( + radialaxis=dict( + visible=True, + range=[0, 5] + )), + showlegend=False + ) + + # fig.show() + return fig, "Multiple Trace Radar Chart" + + +def fig_polar1(): df = px.data.wind() fig = px.scatter_polar(df, r="frequency", theta="direction") - # print(f"fig = {fig}") # fig.show() return fig, "Polar chart with Plotly Express" -def fig2(): +def fig_polar2(): df = px.data.wind() fig = px.scatter_polar(df, r="frequency", theta="direction", color="strength", symbol="strength", size="frequency", @@ -28,7 +101,7 @@ def fig2(): # fig.show() return fig, "Polar chart with Plotly Express" -def fig3(): +def fig_polar3(): df = px.data.wind() fig = px.line_polar(df, r="frequency", theta="direction", color="strength", line_close=True, color_discrete_sequence=px.colors.sequential.Plasma_r, @@ -37,14 +110,14 @@ def fig3(): # fig.show() return fig, "Polar chart with Plotly Express" -def fig4(): +def fig_polar4(): fig = px.scatter_polar(r=range(0,90,10), theta=range(0,90,10), range_theta=[0,90], start_angle=0, direction="counterclockwise") # fig.show() return fig, "Range polar chart with Plotly Express" -def fig5(): +def fig_polar5(): fig = go.Figure(data= go.Scatterpolar( r = [0.5,1,2,2.5,3,4], @@ -57,6 +130,467 @@ def fig5(): # fig.show() return fig, "Basic Polar Chart" +def fig_polar6(): + df = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/master/polar_dataset.csv") + + fig = go.Figure() + fig.add_trace(go.Scatterpolar( + r = df['x1'], + theta = df['y'], + mode = 'lines', + name = 'Figure 8', + line_color = 'peru' + )) + fig.add_trace(go.Scatterpolar( + r = df['x2'], + theta = df['y'], + mode = 'lines', + name = 'Cardioid', + line_color = 'darkviolet' + )) + fig.add_trace(go.Scatterpolar( + r = df['x3'], + theta = df['y'], + mode = 'lines', + name = 'Hypercardioid', + line_color = 'deepskyblue' + )) + + + fig.update_layout( + title = 'Basic Polar Chart', + showlegend = False + ) + + # fig.show() + return fig, "Mic Patterns" + +def fig_polar7(): + fig = go.Figure(go.Barpolar( + r=[3.5, 1.5, 2.5, 4.5, 4.5, 4, 3], + theta=[65, 15, 210, 110, 312.5, 180, 270], + width=[20,15,10,20,15,30,15,], + marker_color=["#E4FF87", '#709BFF', '#709BFF', '#FFAA70', '#FFAA70', '#FFDF70', '#B6FFB4'], + marker_line_color="black", + marker_line_width=2, + opacity=0.8 + )) + + fig.update_layout( + template=None, + polar = dict( + radialaxis = dict(range=[0, 5], showticklabels=False, ticks=''), + angularaxis = dict(showticklabels=False, ticks='') + ) + ) + + # fig.show() + return fig, "Polar Bar Chart" + +def fig_polar8a(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + name = "angular categories", + r = [5, 4, 2, 4, 5], + theta = ["a", "b", "c", "d", "a"], + )) + + fig.update_traces(fill='toself') + + fig.update_layout( + polar = dict( + radialaxis_angle = -45, + angularaxis = dict( + direction = "clockwise", + period = 6) + ), + ) + + # fig.show() + + return fig, "Categorical Polar Chart" + +def fig_polar8b(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + name = "radial categories", + r = ["a", "b", "c", "d", "b", "f", "a"], + theta = [1, 4, 2, 1.5, 1.5, 6, 5], + thetaunit = "radians", + )) + + fig.update_traces(fill='toself') + + fig.update_layout( + polar = dict( + radialaxis = dict( + angle = 180, + tickangle = -180 # so that tick labels are not upside down + ) + ) + ) + + # fig.show() + + return fig, "Categorical Polar Chart" + +def fig_polar8c(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + name = "angular categories (w/ categoryarray)", + r = [5, 4, 2, 4, 5], + theta = ["a", "b", "c", "d", "a"], + )) + + fig.update_traces(fill='toself') + + fig.update_layout( + polar = dict( + sector = [80, 400], + radialaxis_angle = -45, + angularaxis_categoryarray = ["d", "a", "c", "b"] + ), + ) + + # fig.show() + + return fig, "Categorical Polar Chart" + +def fig_polar8d(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + name = "radial categories (w/ category descending)", + r = ["a", "b", "c", "d", "b", "f", "a", "a"], + theta = [45, 90, 180, 200, 300, 15, 20, 45], + )) + + fig.update_traces(fill='toself') + fig.update_layout( + polar = dict( + radialaxis_categoryorder = "category descending", + angularaxis = dict( + thetaunit = "radians", + dtick = 0.3141592653589793 + )) + ) + + # fig.show() + return fig, "Categorical Polar Chart" + +def fig_polar9a(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar()) + fig.update_traces(mode = "lines+markers", + r = [1,2,3,4,5], + theta = [0,90,180,360,0], + line_color = "magenta", + marker = dict( + color = "royalblue", + symbol = "square", + size = 8 + )) + + fig.update_layout( + showlegend = False, + polar = dict( + sector = [150,210], + )) + + # fig.show() + return fig, "Polar Chart Sector" + +def fig_polar9b(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar()) + fig.update_traces(mode = "lines+markers", + r = [1,2,3,4,5], + theta = [0,90,180,360,0], + line_color = "magenta", + marker = dict( + color = "royalblue", + symbol = "square", + size = 8 + )) + + fig.update_layout( + showlegend = False, + ) + + # fig.show() + return fig, "Polar Chart Sector" + +def fig_polar10a(): + + fig = go.Figure() + + r = [1,2,3,4,5] + theta = [0,90,180,360,0] + + fig.add_trace(go.Scatterpolar()) + fig.update_traces(r= r, theta=theta, + mode="lines+markers", line_color='indianred', + marker=dict(color='lightslategray', size=8, symbol='square')) + fig.update_layout( + showlegend = False, + polar = dict( + radialaxis_tickfont_size = 8, + angularaxis = dict( + tickfont_size=8, + rotation=90, # start position of angular axis + direction="counterclockwise" + ) + ) + ) + + # fig.show() + return fig, "Polar Chart Directions" + +def fig_polar10b(): + + fig = go.Figure() + + r = [1,2,3,4,5] + theta = [0,90,180,360,0] + + fig.add_trace(go.Scatterpolar()) + fig.update_traces(r= r, theta=theta, + mode="lines+markers", line_color='indianred', + marker=dict(color='lightslategray', size=8, symbol='square')) + fig.update_layout( + showlegend = False, + polar = dict( + radialaxis_tickfont_size = 8, + angularaxis = dict( + tickfont_size = 8, + rotation = 90, + direction = "clockwise" + ) + ) + ) + + # fig.show() + return fig, "Polar Chart Directions" + +def fig_polar10c(): + + fig = go.Figure() + + r = [1,2,3,4,5] + theta = [0,90,180,360,0] + + fig.add_trace(go.Scatterpolar()) + fig.update_traces(r= r, theta=theta, + mode="lines+markers", line_color='indianred', + marker=dict(color='lightslategray', size=8, symbol='square')) + fig.update_layout( + showlegend = False, + polar = dict( + radialaxis_tickfont_size = 8, + angularaxis = dict( + tickfont_size=8, + rotation=180, # start position of angular axis + direction="counterclockwise" + ) + ) + ) + + # fig.show() + return fig, "Polar Chart Directions" + +def fig_polar10d(): + + fig = go.Figure() + + r = [1,2,3,4,5] + theta = [0,90,180,360,0] + + fig.add_trace(go.Scatterpolar()) + fig.update_traces(r= r, theta=theta, + mode="lines+markers", line_color='indianred', + marker=dict(color='lightslategray', size=8, symbol='square')) + fig.update_layout( + showlegend = False, + polar = dict( + radialaxis_tickfont_size = 8, + angularaxis = dict( + tickfont_size = 8, + rotation = 180, + direction = "clockwise" + ) + ) + ) + + # fig.show() + return fig, "Polar Chart Directions" + +def fig_polar11(): + + df = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/master/hobbs-pearson-trials.csv") + + fig = go.Figure() + + fig.add_trace(go.Scatterpolargl( + r = df.trial_1_r, + theta = df.trial_1_theta, + name = "Trial 1", + marker=dict(size=15, color="mediumseagreen") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_2_r, + theta = df.trial_2_theta, + name = "Trial 2", + marker=dict(size=20, color="darkorange") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_3_r, + theta = df.trial_3_theta, + name = "Trial 3", + marker=dict(size=12, color="mediumpurple") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_4_r, + theta = df.trial_4_theta, + name = "Trial 4", + marker=dict(size=22, color = "magenta") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_5_r, + theta = df.trial_5_theta, + name = "Trial 5", + marker=dict(size=19, color = "limegreen") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_6_r, + theta = df.trial_6_theta, + name = "Trial 6", + marker=dict(size=10, color = "gold") + )) + + # Common parameters for all traces + fig.update_traces(mode="markers", marker=dict(line_color='white', opacity=0.7)) + + fig.update_layout( + title = "Hobbs-Pearson Trials", + font_size = 15, + showlegend = False, + polar = dict( + bgcolor = "rgb(223, 223, 223)", + angularaxis = dict( + linewidth = 3, + showline=True, + linecolor='black' + ), + radialaxis = dict( + side = "counterclockwise", + showline = True, + linewidth = 2, + gridcolor = "white", + gridwidth = 2, + ) + ), + paper_bgcolor = "rgb(223, 223, 223)" + ) + + # print(f"fig = {fig}") + + # fig.show() + return fig, "Webgl Polar Chart" + +def fig_polar12a(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + r = [1, 2, 3], + theta = [50, 100, 200], + marker_symbol = "square" + )) + fig.add_trace(go.Scatterpolar( + r = [1, 2, 3], + theta = [1, 2, 3], + thetaunit = "radians" + )) + + fig.update_layout( + polar = dict( + radialaxis_range = [1, 4], + angularaxis_thetaunit = "radians" + ), + showlegend = False + ) + + # fig.show() + return fig, "Polar Chart Subplots" + +def fig_polar12b(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + r = ["a", "b", "c", "b"], + theta = ["D", "C", "B", "A"], + )) + + fig.update_layout( + showlegend = False + ) + # fig.show() + return fig, "Polar Chart Subplots" + +def fig_polar12c(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + r = [50, 300, 900], + theta = [0, 90, 180], + )) + + fig.update_layout( + polar = dict( + radialaxis = dict(type = "log", tickangle = 45), + sector = [0, 180] + ), + showlegend = False + ) + + # fig.show() + return fig, "Polar Chart Subplots" + +def fig_polar12d(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + mode = "lines", + r = [3, 3, 4, 3], + theta = [0, 45, 90, 270], + fill = "toself", + subplot = "polar4" + )) + + fig.update_layout( + polar = dict( + radialaxis = dict(visible = False, range = [0, 6]) + ), + showlegend = False + ) + + # fig.show() + return fig, "Polar Chart Subplots" + if __name__ == "__main__": print("Tikzploty : ", tikzplotly.__version__) @@ -66,11 +600,33 @@ def fig5(): file_directory = os.path.dirname(os.path.abspath(__file__)) functions = [ - ("1", fig1), - ("2", fig2), - ("3", fig3), - ("4", fig4), - ("5", fig5), + ("radar1", fig_radar1), + ("radar2", fig_radar2), # Individual marker sizes in a trace are not supported yet + ("radar3", fig_radar3), + ("radar4", fig_radar4), + + ("polar1", fig_polar1), + ("polar2", fig_polar2), + ("polar3", fig_polar3), + ("polar4", fig_polar4), + ("polar5", fig_polar5), + ("polar6", fig_polar6), + ("polar7", fig_polar7), # Polar bar charts are not supported yet. + ("polar8a", fig_polar8a), + ("polar8b", fig_polar8b), + ("polar8c", fig_polar8c), + ("polar8d", fig_polar8d), + ("polar9a", fig_polar9a), + ("polar9b", fig_polar9b), + ("polar10a", fig_polar10a), + ("polar10b", fig_polar10b), + ("polar10c", fig_polar10c), + ("polar10d", fig_polar10d), + ("polar11", fig_polar11), # Webgl polar charts are treated as normal polar charts. + ("polar12a", fig_polar12a), # Radial axis range does not look the same with pgfplots. + ("polar12b", fig_polar12b), + ("polar12c", fig_polar12c), # Radial axis type log is not supported yet. + ("polar12d", fig_polar12d), ] main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") From acf9174edf7ec1d02b16c3529faef933c908ab58 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Sat, 12 Apr 2025 17:04:17 +0200 Subject: [PATCH 24/66] add limited support to horizontal bar charts --- src/tikzplotly/_bar.py | 28 +++++++++++++++++++++------- src/tikzplotly/_save.py | 38 +++++++------------------------------- 2 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/tikzplotly/_bar.py b/src/tikzplotly/_bar.py index ce4387f..03fb1af 100644 --- a/src/tikzplotly/_bar.py +++ b/src/tikzplotly/_bar.py @@ -2,10 +2,12 @@ from ._utils import option_dict_to_str from ._tex import tex_addplot from ._color import convert_color +from ._utils import sanitize_TeX_text from warnings import warn -def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_set, row_sep="\\\\"): - """ + +def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_set, row_sep="\\"): + r""" Draw a bar chart (vertical or horizontal) referencing the data table created by DataContainer.addData(...). @@ -25,16 +27,16 @@ def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_ axis : Axis The axis object to which the bar chart will be added. colors_set : set - A set to keep track of colors used in the plot (for \\definecolor). + A set to keep track of colors used in the plot (for \definecolor). row_sep : str, optional - The row separator for the data table in TikZ, by default "\\\\" + The row separator for the data table in TikZ, by default "\\" """ code = "" plot_options = {} - type_options = {"row sep": row_sep} + type_options = {} + # type_options = {"row sep": row_sep} - orientation = getattr(trace, "orientation", None) - barmode = getattr(trace, "barmode", None) + orientation = getattr(trace, "orientation", "v") # default vertical stack = " stacked" if axis.layout.barmode in ("stack", "relative") else "" if orientation == "h": @@ -42,11 +44,23 @@ def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_ axis.add_option("xbar" + stack, None) type_options["x"] = y_col_name type_options["y"] = x_col_name + categories = trace.y else: plot_options["ybar"] = None axis.add_option("ybar" + stack, None) type_options["x"] = x_col_name type_options["y"] = y_col_name + categories = trace.x + + # Handle symbolic coords + if all(isinstance(value, str) for value in categories): + symbolic = sanitize_TeX_text(",".join(categories)) + if orientation == "h": + axis.add_option("symbolic y coords", "{" + symbolic + "}") + axis.add_option("ytick", "data") + else: + axis.add_option("symbolic x coords", "{" + symbolic + "}") + axis.add_option("xtick", "data") # Handle marker style (color, opacity, line) if trace.marker is not None: diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 6ccf76e..f5652ae 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -12,6 +12,8 @@ from ._dataContainer import DataContainer from ._utils import sanitize_TeX_text from warnings import warn +from collections import defaultdict +import numpy as np import re def get_tikz_code( @@ -100,39 +102,13 @@ def get_tikz_code( data_str.append( tex_add_legendentry(sanitize_TeX_text(trace.name)) ) elif trace.type == "bar": - orientation = getattr(trace, "orientation", None) - if orientation == "h": - cat_list = trace.y - val_list = trace.x - else: - cat_list = trace.x - val_list = trace.y - - # If x or y is empty - if trace.x is None and trace.y is None: - warn("Adding empty bar trace.") - data_str.append("\\addplot coordinates {};\n") - continue - else: - if trace.x is None: - trace.x = list(range(len(trace.y))) - if trace.y is None: - trace.y = list(range(len(trace.x))) + orientation = getattr(trace, "orientation", "v") + cat_list = trace.y if orientation == "h" else trace.x + val_list = trace.x if orientation == "h" else trace.y data_name_macro, val_col_name = data_container.addData(cat_list, val_list, trace.name) - x_col_name = data_container.data[-1].name # 'data0 - - if all(isinstance(value, str) for value in cat_list): - if orientation == "h": - # Categories go on the y-axis - axis.add_option("symbolic y coords", sanitize_TeX_text(",".join(cat_list))) - axis.add_option("ytick", "data") - else: - # Categories go on the x-axis - axis.add_option("symbolic x coords", sanitize_TeX_text(",".join(cat_list))) - axis.add_option("xtick", "data") - - # Build the bar code + x_col_name = data_container.data[-1].name + bar_code = draw_bar(data_name_macro, x_col_name, val_col_name, trace, axis, colors_set) data_str.append(bar_code) From 67701ef1cd1666ad5be3cec534c6088ca6486f29 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Sat, 12 Apr 2025 17:44:44 +0200 Subject: [PATCH 25/66] rename figures in prevision of horizontal bar charts --- src/tests/test_bars.py | 122 ++++++++++++----------------------------- 1 file changed, 36 insertions(+), 86 deletions(-) diff --git a/src/tests/test_bars.py b/src/tests/test_bars.py index 64c92b8..72c69a9 100644 --- a/src/tests/test_bars.py +++ b/src/tests/test_bars.py @@ -11,7 +11,7 @@ from warnings import warn from tikzplotly._tex import tex_create_document, tex_begin_environment, tex_end_environment, tex_end_all_environment -def fig1(): +def fig_vertical1(): data_canada = px.data.gapminder().query("country == 'Canada'") fig = px.bar(data_canada, x='year', y='pop') @@ -21,7 +21,7 @@ def fig1(): return fig, "Bar chart with Plotly Express" -def fig2(): +def fig_vertical2(): long_df = px.data.medals_long() @@ -32,7 +32,7 @@ def fig2(): return fig, "Bar charts with Long Format Data" -def fig3(): +def fig_vertical3(): wide_df = px.data.medals_wide() @@ -43,7 +43,7 @@ def fig3(): return fig, "Bar charts with Wide Format Data" -def fig5(): +def fig_vertical5(): df = px.data.gapminder().query("country == 'Canada'") fig = px.bar(df, x='year', y='pop', @@ -55,7 +55,7 @@ def fig5(): return fig, "Colored Bars" -def fig5bis(): +def fig_vertical6(): df = px.data.gapminder().query("continent == 'Oceania'") fig = px.bar(df, x='year', y='pop', @@ -67,7 +67,7 @@ def fig5bis(): return fig, "Colored Bars" -def fig6(): +def fig_vertical7(): df = px.data.tips() fig = px.bar(df, x="sex", y="total_bill", color='time') @@ -77,7 +77,7 @@ def fig6(): return fig, "Stacked vs Grouped Bars" -def fig6bis(): +def fig_vertical8(): df = px.data.tips() fig = px.bar(df, x="sex", y="total_bill", @@ -89,7 +89,7 @@ def fig6bis(): return fig, "Stacked vs Grouped Bars" -def fig7(): +def fig_vertical9(): df = px.data.tips() fig = px.histogram(df, x="sex", y="total_bill", @@ -101,7 +101,7 @@ def fig7(): return fig, "Aggregating into Single Colored Bars" -def fig7bis(): +def fig_vertical10(): df = px.data.tips() fig = px.histogram(df, x="sex", y="total_bill", @@ -114,7 +114,7 @@ def fig7bis(): return fig, "Aggregating into Single Colored Bars" -def fig8(): +def fig_vertical11(): df = px.data.medals_long() @@ -125,7 +125,7 @@ def fig8(): return fig, "Bar Charts with Text" -def fig8bis(): +def fig_vertical12(): df = px.data.medals_long() @@ -136,7 +136,7 @@ def fig8bis(): return fig, "Bar Charts with Text" -def fig8ter(): +def fig_vertical13(): df = px.data.gapminder().query("continent == 'Europe' and year == 2007 and pop > 2.e6") fig = px.bar(df, y='pop', x='country', text_auto='.2s', @@ -147,7 +147,7 @@ def fig8ter(): return fig, "Bar Charts with Text" -def fig8quater(): +def fig_vertical14(): df = px.data.gapminder().query("continent == 'Europe' and year == 2007 and pop > 2.e6") fig = px.bar(df, y='pop', x='country', text_auto='.2s', @@ -159,7 +159,7 @@ def fig8quater(): return fig, "Bar Charts with Text" -def fig9(): +def fig_vertical15(): df = px.data.medals_long() @@ -171,7 +171,7 @@ def fig9(): return fig, "Pattern fills" -def fig10(): +def fig_vertical16(): animals=['giraffes', 'orangutans', 'monkeys'] fig = go.Figure([go.Bar(x=animals, y=[20, 14, 23])]) @@ -181,7 +181,7 @@ def fig10(): return fig, "Basic Bar Charts with plotly graph objects" -def fig11(): +def fig_vertical17(): animals=['giraffes', 'orangutans', 'monkeys'] fig = go.Figure(data=[ @@ -196,7 +196,7 @@ def fig11(): return fig, "Grouped Bar Chart" -def fig12(): +def fig_vertical18(): animals=['giraffes', 'orangutans', 'monkeys'] fig = go.Figure(data=[ @@ -211,7 +211,7 @@ def fig12(): return fig, "Stacked Bar Chart" -def fig13(): +def fig_vertical19(): x = [1, 2, 3, 4] fig = go.Figure() @@ -228,54 +228,6 @@ def fig13(): return fig, "Bar Chart with Relative Barmode" -def figa(): - """ - Creates a simple vertical grouped bar chart with categorical x-values. - We have two series of data, each with 7 categories. - """ - categories = ["04bits", "08bits", "16bits", "24bits", "32bits", "48bits", "64bits"] - fmax_chisel = [1147, 277, 256, 220, 207, 186, 179] - fmax_sv = [375, 270, 235, 212, 205, 192, 190] - - df = pd.DataFrame({ - "Category": categories, - "ALU1": fmax_chisel, - "ALU2": fmax_sv - }) - - fig = px.bar(df, x="Category", y=["ALU1", "ALU2"], barmode="group", - title="Simple Vertical Bar Chart Example") - - fig.update_layout(xaxis_title="Configuration", yaxis_title="Fmax (MHz)") - - # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "figa.png")) - - return fig, "Simple vertical grouped bar chart" - -def figb(): - """ - Creates a simple horizontal grouped bar chart with categorical y-values. - We have two series of data, each with 7 categories. - """ - categories = ["04bits", "08bits", "16bits", "24bits", "32bits", "48bits", "64bits"] - fmax_chisel = [1147, 277, 256, 220, 207, 186, 179] - fmax_sv = [375, 270, 235, 212, 205, 192, 190] - - df = pd.DataFrame({ - "Category": categories, - "ALU1": fmax_chisel, - "ALU2": fmax_sv - }) - - fig = px.bar(df, x=["ALU1", "ALU2"], y="Category", barmode="group", - title="Simple Horizontal Bar Chart Example") - - fig.update_layout(xaxis_title="Fmax (MHz)", yaxis_title="Configuration") - - # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "figb.png")) - - return fig, "Simple horizontal grouped bar chart" - if __name__ == "__main__": print("Tikzploty : ", tikzplotly.__version__) @@ -286,26 +238,24 @@ def figb(): file_directory = os.path.dirname(os.path.abspath(__file__)) functions = [ - ("1", fig1), - ("2", fig2), - ("3", fig3), - ("5", fig5), - ("5bis", fig5bis), - ("6", fig6), # Stacked bars are not supported yet. - ("6bis", fig6bis), # Stacked bars are not supported yet. - ("7", fig7), # Aggregated bars are not supported yet. - ("7bis", fig7bis), # Aggregated bars are not supported yet. - ("8", fig8), # Text template is not supported yet. - ("8bis", fig8bis), # Text template is not supported yet. - ("8ter", fig8ter), # Text template is not supported yet. - ("8quater", fig8quater), # Text template is not supported yet. - ("9", fig9), # Pattern fills are not supported yet. - ("10", fig10), - ("11", fig11), - ("12", fig12), - ("13", fig13), - ("a", figa), - ("b", figb), + ("vertical1", fig_vertical1), + ("vertical2", fig_vertical2), + ("vertical3", fig_vertical3), + ("vertical5", fig_vertical5), + ("vertical6", fig_vertical6), + ("vertical7", fig_vertical7), # Stacked bars are not supported yet. + ("vertical8", fig_vertical8), # Stacked bars are not supported yet. + ("vertical9", fig_vertical9), # Aggregated bars are not supported yet. + ("vertical10", fig_vertical10), # Aggregated bars are not supported yet. + ("vertical11", fig_vertical11), # Text template is not supported yet. + ("vertical12", fig_vertical12), # Text template is not supported yet. + ("vertical13", fig_vertical13), # Text template is not supported yet. + ("vertical14", fig_vertical14), # Text template is not supported yet. + ("vertical15", fig_vertical15), # Pattern fills are not supported yet. + ("vertical16", fig_vertical16), + ("vertical17", fig_vertical17), + ("vertical18", fig_vertical18), + ("vertical19", fig_vertical19), ] main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") From 59ffb87561be38cc0098c1bcab1cd1473b18f2d2 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 2 Jul 2025 18:59:24 +0200 Subject: [PATCH 26/66] add curly braces only if needed --- src/tikzplotly/_data.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/tikzplotly/_data.py b/src/tikzplotly/_data.py index d2f6c03..cf0c263 100644 --- a/src/tikzplotly/_data.py +++ b/src/tikzplotly/_data.py @@ -1,5 +1,4 @@ from warnings import warn -from ._utils import replace_all_mounts def data_type(data): """Return the type of data, for special handling. @@ -14,15 +13,12 @@ def data_type(data): Type of data, can be : - None : no special handling - 'date' : data is a date - - 'month' : data is a month + - 'string' : a symbolic data """ if isinstance(data, str): if len(data.split('-')) == 3: warn("Assuming this is a date, add \"\\usetikzlibrary{pgfplots.dateplot}\" to your tex preamble.") return 'date' - elif data.lower() in ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august',' september', 'october', 'november', 'december']: - warn(f"Assuming data {data} is a month. This feature is experimental.") - return 'month' else: warn(f"Data type of {data} is not supported yet.") return None @@ -33,10 +29,10 @@ def treat_data(data_str): if not isinstance(data_str, str): return data_str if data_str.find(' ') !=- 1: # Add curly braces if there space in string - data_str = "{" + data_str + "}" - return data_str + if not data_str.startswith("{") and not data_str.startswith("}"): + data_str = "{" + data_str + "}" + return data_str return data_str def post_treat_data(data_str): - data_str = replace_all_mounts(data_str) - return data_str.replace("None", "nan") \ No newline at end of file + return data_str.replace("None", "nan") From b14cb0580fa17e7f7f243f6c17e0c25f4a22db6a Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 2 Jul 2025 18:59:41 +0200 Subject: [PATCH 27/66] remove month case --- src/tikzplotly/_scatter.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/tikzplotly/_scatter.py b/src/tikzplotly/_scatter.py index 49caac1..adccf5c 100644 --- a/src/tikzplotly/_scatter.py +++ b/src/tikzplotly/_scatter.py @@ -36,9 +36,6 @@ def draw_scatter2d(data_name, scatter, y_name, axis: Axis, color_set): if data_type(scatter.x[0]) == "date": axis.add_option("date coordinates in", "x") - if data_type(scatter.x[0]) == "month": - scatter_x_str = "{" + ", ".join([x for x in scatter.x]) + "}" - axis.add_option("xticklabels", scatter_x_str) if mode is None: # by default, plot markers and lines From 56fca2f6e7b0d89139ff47c003f5ba925dd26f78 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 2 Jul 2025 19:15:06 +0200 Subject: [PATCH 28/66] add basic support for scatter 3d plots --- src/tikzplotly/_dataContainer.py | 86 ++++++++++++++++++++++++++------ src/tikzplotly/_save.py | 18 +++++++ src/tikzplotly/_scatter3d.py | 84 +++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 src/tikzplotly/_scatter3d.py diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index be5ca25..a78926a 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -29,12 +29,20 @@ def addYData(self, y, y_label=None): self.y_data.append(y) return self.y_label[-1] +class Data3D: + def __init__(self, x, y, z, name): + self.x = list(x) + self.y = list(y) + self.z = list(z) + self.name = name if name else f"data3d_{id(self)}" + self.z_name = "z" + class DataContainer: def __init__(self): self.data = [] - def addData(self, x, y, y_label=None): + def addData(self, x, y, name=None, y_label=None): """Add data to the container. Parameters @@ -45,28 +53,57 @@ def addData(self, x, y, y_label=None): y values of the data y_label, optional name of the y data, by default None + name, optional + name of the data, by default None Returns ------- tuple (macro_name, y_label), where macro_name is the name of the data in LaTeX and y_label the name of the y data in LaTeX """ for data in self.data: + if isinstance(x, (tuple, list)): + continue if len(data.x) != len(x): continue are_equals = data.x == x if type(are_equals) == bool: if are_equals: - y_label = data.addYData(y, y_label) - return data.macro_name, y_label - elif are_equals.all(): - if (data.x == x).all(): - y_label = data.addYData(y, y_label) - return data.macro_name, y_label + y_label_val = data.addYData(y, y_label or name) + return data.macro_name, y_label_val + elif hasattr(are_equals, "all") and are_equals.all(): + y_label_val = data.addYData(y, y_label or name) + return data.macro_name, y_label_val data_to_add = Data(f"data{len(self.data)}", x) - y_label = data_to_add.addYData(y, y_label) + y_label_val = data_to_add.addYData(y, y_label or name) self.data.append(data_to_add) - return data_to_add.macro_name, sanitize_text(y_label) + return data_to_add.macro_name, sanitize_text(y_label_val) + + def addData3D(self, x, y, z, name=None): + """Add data to the container. + + Parameters + ---------- + x + x values of the data + y + y values of the data + z + z values of the data + name, optional + name of the data, by default None + Returns + ------- + tuple (macro_name, z_name), where macro_name is the name of the data in LaTeX and z_name the name of the z data in LaTeX + """ + for data in self.data: + if hasattr(data, "x") and hasattr(data, "y") and hasattr(data, "z"): + import numpy as np + if np.array_equal(data.x, x) and np.array_equal(data.y, y) and np.array_equal(data.z, z): + return data.name, data.z_name + data_obj = Data3D(x, y, z, name) + self.data.append(data_obj) + return data_obj.name, data_obj.z_name def exportData(self): """Generate LaTeX code to export the data from DataContainer. @@ -78,13 +115,30 @@ def exportData(self): export_string = "" for data in self.data: - export_string += "\\pgfplotstableread{" - export_string += f"{sanitize_text(data.name)} {' '.join([sanitize_text(label) for label in data.y_label])}\n" - for i in range(len(data.x)): - x_val = treat_data(data.x[i]) - export_string += f"{x_val} {' '.join([str(y[i]) for y in data.y_data])}\n" - - export_string += "}" + sanitize_text(data.macro_name) + "\n" + # 3D + if hasattr(data, "z"): + export_string += "\\pgfplotstableread{\n" + export_string += "x y z\n" + for i in range(len(data.x)): + export_string += f"{data.x[i]} {data.y[i]} {data.z[i]}\n" + export_string += f"}}\\{data.name}\n" + + # 2D + else: + export_string += "\\pgfplotstableread{\n" + header = "x" + if hasattr(data, "y_label") and data.y_label: + for label in data.y_label: + header += f" {label}" + else: + header += " y" + export_string += header + "\n" + for i in range(len(data.x)): + row = [str(data.x[i])] + for y_col in data.y_data: + row.append(str(y_col[i])) + export_string += " ".join(row) + "\n" + export_string += f"}}\\{data.name}\n" return post_treat_data(export_string) diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index f5652ae..a1a183b 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -2,6 +2,7 @@ from .__about__ import __version__ from ._tex import * from ._scatter import draw_scatter2d +from ._scatter3d import draw_scatter3d from ._heatmap import draw_heatmap from ._histogram import draw_histogram from ._bar import draw_bar @@ -124,6 +125,23 @@ def get_tikz_code( if trace.name and trace['showlegend'] != False: data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) + elif trace.type == "scatter3d": + # Handle the case where x, y, or z is empty + if trace.x is None or trace.y is None or trace.z is None: + warn("Adding empty 3D trace.") + data_str.append("\\addplot3 coordinates {};\n") + continue + + data_name_macro, z_name = data_container.addData3D(trace.x, trace.y, trace.z, trace.name) + data_str.append(draw_scatter3d(data_name_macro, trace, z_name, axis, colors_set)) + + if trace.name and trace['showlegend'] != False: + data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) + if getattr(trace, "line", None) and getattr(trace.line, "color", None) is not None: + colors_set.add(convert_color(trace.line.color)[:3]) + if getattr(trace, "fillcolor", None) is not None: + colors_set.add(convert_color(trace.fillcolor)[:3]) + else: warn(f"Trace type {trace.type} is not supported yet.") diff --git a/src/tikzplotly/_scatter3d.py b/src/tikzplotly/_scatter3d.py new file mode 100644 index 0000000..a60b0ed --- /dev/null +++ b/src/tikzplotly/_scatter3d.py @@ -0,0 +1,84 @@ +from warnings import warn + +from ._tex import tex_addplot +from ._color import convert_color +from ._marker import marker_symbol_to_tex +from ._axis import Axis +from ._utils import px_to_pt, option_dict_to_str + +def draw_scatter3d(data_name, scatter, z_name, axis: Axis, color_set): + """ + Get code for a scatter3d trace. + """ + code = "" + + mode = scatter.mode or "markers+lines" + marker = scatter.marker + + options_dict = {} + mark_option_dict = {} + + # Markers only + if mode == "markers": + if marker.symbol is not None: + symbol, symbol_options = marker_symbol_to_tex(marker.symbol) + options_dict["mark"] = symbol + options_dict["only marks"] = None + if symbol_options is not None: + mark_option_dict[symbol_options[0]] = symbol_options[1] + else: + options_dict["only marks"] = None + + if marker.size is not None: + options_dict["mark size"] = px_to_pt(marker.size) + + if marker.color is not None: + color_set.add(convert_color(marker.color)[:3]) + mark_option_dict["solid"] = None + mark_option_dict["fill"] = convert_color(marker.color)[0] + + if (line := marker.line) is not None: + if line.color is not None: + color_set.add(convert_color(line.color)[:3]) + mark_option_dict["draw"] = convert_color(line.color)[0] + if line.width is not None: + mark_option_dict["line width"] = px_to_pt(line.width) + + if (opacity := scatter.opacity) is not None: + options_dict["opacity"] = opacity + if (opacity := marker.opacity) is not None: + mark_option_dict["opacity"] = opacity + + if mark_option_dict: + mark_options = option_dict_to_str(mark_option_dict) + options_dict["mark options"] = f"{{{mark_options}}}" + + elif mode == "lines": + options_dict["mark"] = "none" + + elif "lines" in mode and "markers" in mode: + if marker.symbol is not None: + symbol, symbol_options = marker_symbol_to_tex(marker.symbol) + options_dict["mark"] = symbol + if symbol_options is not None: + mark_option_dict[symbol_options[0]] = symbol_options[1] + + else: + warn(f"Scatter3d : Mode {mode} is not supported yet.") + + if scatter.line is not None: + if scatter.line.width is not None: + options_dict["line width"] = px_to_pt(scatter.line.width) + if scatter.line.color is not None: + options_dict["color"] = convert_color(scatter.line.color)[0] + color_set.add(convert_color(scatter.line.color)[:3]) + + if scatter.showlegend is False: + options_dict["forget plot"] = None + + options = option_dict_to_str(options_dict) + if scatter.name: + code += f"\n% {scatter.name}\n" + code += f"\\addplot3+ [{options}] table[x=x, y=y, z=z] {{\\{data_name}}};\n" + + return code \ No newline at end of file From 1f70b83e45ff6f5fb52bf135cfc92ed46381fbe5 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 2 Jul 2025 19:40:25 +0200 Subject: [PATCH 29/66] fix bad data name --- src/tikzplotly/_dataContainer.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index a78926a..3a2c45d 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -2,6 +2,11 @@ from ._utils import replace_all_digits, sanitize_text from ._data import treat_data, post_treat_data +def hexid_to_alpha(num): + hexstr = str(num) + table = "ABCDEFGHIJKLMNOP" + return ''.join(table[int(c, 16)] for c in hexstr if c in "0123456789abcdef") + class Data: def __init__(self, name, x): @@ -34,7 +39,7 @@ def __init__(self, x, y, z, name): self.x = list(x) self.y = list(y) self.z = list(z) - self.name = name if name else f"data3d_{id(self)}" + self.name = name if name else f"data{hexid_to_alpha(id(self))}" self.z_name = "z" class DataContainer: @@ -121,7 +126,7 @@ def exportData(self): export_string += "x y z\n" for i in range(len(data.x)): export_string += f"{data.x[i]} {data.y[i]} {data.z[i]}\n" - export_string += f"}}\\{data.name}\n" + export_string += f"}}{{\\{data.name}}}\n" # 2D else: @@ -142,5 +147,3 @@ def exportData(self): return post_treat_data(export_string) - - From ca7f8c7e5f22953034b988835e2cfa2947b06ed1 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 2 Jul 2025 19:41:00 +0200 Subject: [PATCH 30/66] add scatter3d tests from plotly documentation --- src/tests/test_scatter3d.py | 116 ++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/tests/test_scatter3d.py diff --git a/src/tests/test_scatter3d.py b/src/tests/test_scatter3d.py new file mode 100644 index 0000000..769130e --- /dev/null +++ b/src/tests/test_scatter3d.py @@ -0,0 +1,116 @@ +# From https://plotly.com/python/3d-scatter-plots/ +import os +import tikzplotly + +from tikzplotly._tex import tex_create_document, tex_begin_environment, tex_end_environment, tex_end_all_environment + +import plotly +import plotly.express as px +import pandas as pd +import plotly.graph_objects as go +import numpy as np + +def fig1(): + df = px.data.iris() + fig = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='species') + # fig.show() + return fig, "3D scatter plot with Plotly Express" + +def fig2(): + df = px.data.iris() + fig = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='petal_length', symbol='species') + # fig.show() + return fig, "3D scatter plot with Plotly Express" + +def fig3(): + df = px.data.iris() + fig = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', + color='petal_length', size='petal_length', size_max=18, + symbol='species', opacity=0.7) + + # tight layout + fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) + # fig.show() + return fig, "Style 3d scatter plot" + +def fig4(): + df = px.data.iris() # replace with your own data source + low, high = 0, 2.5 + mask = (df.petal_width > low) & (df.petal_width < high) + fig = px.scatter_3d(df[mask], + x='sepal_length', y='sepal_width', z='petal_width', + color="species", hover_data=['petal_width']) + # fig.show() + return fig, "Style 3d scatter plot" + +def fig5(): + t = np.linspace(0, 10, 50) + x, y, z = np.cos(t), np.sin(t), t + fig = go.Figure(data=[go.Scatter3d(x=x, y=y, z=z, mode='markers')]) + # fig.show() + return fig, "Basic 3D Scatter Plot with go.Scatter3d" + +def fig6(): + t = np.linspace(0, 20, 100) + x, y, z = np.cos(t), np.sin(t), t + + fig = go.Figure(data=[go.Scatter3d( + x=x, + y=y, + z=z, + mode='markers', + marker=dict( + size=12, + color=z, # set color to an array/list of desired values + colorscale='Viridis', # choose a colorscale + opacity=0.8 + ) + )]) + + # tight layout + fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) + # fig.show() + return fig, "3D Scatter Plot with Colorscaling and Marker Styling" + +if __name__ == "__main__": + + print("Tikzploty : ", tikzplotly.__version__) + print("Plotly : ", plotly.__version__) + print("Test line charts") + + file_directory = os.path.dirname(os.path.abspath(__file__)) + + functions = [ + ("1", fig1), + ("2", fig2), + ("3", fig3), + ("4", fig4), + ("5", fig5), + ("6", fig6), + ] + + main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") + main_tex_content += "\\usepackage[left=1cm, right=1cm, top=1cm, bottom=1cm]{geometry}\n" + main_tex_content += "\\usetikzlibrary{pgfplots.dateplot}\n" + main_tex_content += "\\usepgfplotslibrary{polar}\n" + main_tex_content += "\n" + stack_env = [] + main_tex_content += tex_begin_environment("document", stack_env) + '\n' + + for i, f in functions: + print(f"Figure {i}") + fig, title = f() + data = fig.data + save_path = os.path.join(file_directory, "outputs", "test_scatter3d", "fig{}.tex".format(i)) + tikzplotly.save(save_path, fig) + main_tex_content += tex_begin_environment("figure", stack_env) + main_tex_content += " \\input{fig" + str(i) + ".tex}\n" + main_tex_content += " \\caption{" + title + "}\n" + main_tex_content += tex_end_environment(stack_env) + '\n' + + main_tex_content += "\n" + tex_end_all_environment(stack_env) + + main_tex_path = os.path.join(file_directory, "outputs", "test_scatter3d", "main.tex") + print("Save main tex file : ", main_tex_path) + with open(main_tex_path, "w") as f: + f.write(main_tex_content) From e952cbfef83b70ca5435612970bab6a588469fe8 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 2 Jul 2025 19:43:51 +0200 Subject: [PATCH 31/66] add basic axis options for scatter3d --- src/tikzplotly/_save.py | 29 +++++++++++++++++++++++++++++ src/tikzplotly/_scatter3d.py | 9 ++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index a1a183b..974f434 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -132,6 +132,35 @@ def get_tikz_code( data_str.append("\\addplot3 coordinates {};\n") continue + # View + if hasattr(figure_layout.scene, "camera") and hasattr(figure_layout.scene.camera, "eye"): + eye = figure_layout.scene.camera.eye + if eye is not None and eye.x is not None and eye.y is not None and eye.z is not None: + axis.add_option("view", f"{{{eye.x},{eye.y},{eye.z}}}") + + # Labels + if hasattr(figure_layout.scene.xaxis, "title") and getattr(figure_layout.scene.xaxis.title, "text", None): + axis.add_option("xlabel", f"{{{sanitize_TeX_text(figure_layout.scene.xaxis.title.text)}}}") + if hasattr(figure_layout.scene.yaxis, "title") and getattr(figure_layout.scene.yaxis.title, "text", None): + axis.add_option("ylabel", f"{{{sanitize_TeX_text(figure_layout.scene.yaxis.title.text)}}}") + if hasattr(figure_layout.scene.zaxis, "title") and getattr(figure_layout.scene.zaxis.title, "text", None): + axis.add_option("zlabel", f"{{{sanitize_TeX_text(figure_layout.scene.zaxis.title.text)}}}") + + # Grid + if hasattr(figure_layout.scene.xaxis, "showgrid"): + if figure_layout.scene.xaxis.showgrid is False: + axis.add_option("xmajorgrids", "false") + if hasattr(figure_layout.scene.yaxis, "showgrid"): + if figure_layout.scene.yaxis.showgrid is False: + axis.add_option("ymajorgrids", "false") + if hasattr(figure_layout.scene.zaxis, "showgrid"): + if figure_layout.scene.zaxis.showgrid is False: + axis.add_option("zmajorgrids", "false") + + # Title + if hasattr(figure_layout.scene, "title") and getattr(figure_layout.scene.title, "text", None): + axis.add_option("title", f"{{{sanitize_TeX_text(figure_layout.scene.title.text)}}}") + data_name_macro, z_name = data_container.addData3D(trace.x, trace.y, trace.z, trace.name) data_str.append(draw_scatter3d(data_name_macro, trace, z_name, axis, colors_set)) diff --git a/src/tikzplotly/_scatter3d.py b/src/tikzplotly/_scatter3d.py index a60b0ed..484dcb4 100644 --- a/src/tikzplotly/_scatter3d.py +++ b/src/tikzplotly/_scatter3d.py @@ -30,7 +30,14 @@ def draw_scatter3d(data_name, scatter, z_name, axis: Axis, color_set): options_dict["only marks"] = None if marker.size is not None: - options_dict["mark size"] = px_to_pt(marker.size) + size = marker.size + if isinstance(size, (list, tuple)) or (hasattr(size, "shape") and hasattr(size, "__len__")): + try: + import numpy as np + size = float(np.mean(size)) + except Exception: + size = float(size[0]) + options_dict["mark size"] = px_to_pt(size) if marker.color is not None: color_set.add(convert_color(marker.color)[:3]) From 50b48c296a623db275951a4aa4d82e8ea332aceb Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 9 Jul 2025 14:51:38 +0200 Subject: [PATCH 32/66] fix error when options is not defined --- src/tikzplotly/_scatter3d.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tikzplotly/_scatter3d.py b/src/tikzplotly/_scatter3d.py index 484dcb4..ae56508 100644 --- a/src/tikzplotly/_scatter3d.py +++ b/src/tikzplotly/_scatter3d.py @@ -86,6 +86,12 @@ def draw_scatter3d(data_name, scatter, z_name, axis: Axis, color_set): options = option_dict_to_str(options_dict) if scatter.name: code += f"\n% {scatter.name}\n" - code += f"\\addplot3+ [{options}] table[x=x, y=y, z=z] {{\\{data_name}}};\n" + + code += f"\\addplot3+ " + if options is not None: + code += f"[{options}]" + else: + code += "[only marks]" + code += f"table[x=x, y=y, z=z] {{\\{data_name}}};\n" return code \ No newline at end of file From 4c08d029fab15bff8927763de78907902f4d9046 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 9 Jul 2025 14:52:49 +0200 Subject: [PATCH 33/66] fix view not being handled properly --- src/tikzplotly/_save.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 974f434..b7abe9e 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -136,7 +136,10 @@ def get_tikz_code( if hasattr(figure_layout.scene, "camera") and hasattr(figure_layout.scene.camera, "eye"): eye = figure_layout.scene.camera.eye if eye is not None and eye.x is not None and eye.y is not None and eye.z is not None: - axis.add_option("view", f"{{{eye.x},{eye.y},{eye.z}}}") + norm = np.sqrt(eye.x**2 + eye.y**2 + eye.z**2) + azimuth = np.degrees(np.arctan2(eye.y, eye.x)) + elevation = np.degrees(np.arcsin(eye.z / norm)) + axis.add_option("view", f"{{{azimuth:.1f}}}{{{elevation:.1f}}}") # Labels if hasattr(figure_layout.scene.xaxis, "title") and getattr(figure_layout.scene.xaxis.title, "text", None): From b687b9be05dde5815e715765490c66e7a9718f80 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Wed, 9 Jul 2025 15:31:27 +0200 Subject: [PATCH 34/66] fix unsupported characters --- src/tikzplotly/_dataContainer.py | 2 +- src/tikzplotly/_utils.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index 3a2c45d..6352f06 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -39,7 +39,7 @@ def __init__(self, x, y, z, name): self.x = list(x) self.y = list(y) self.z = list(z) - self.name = name if name else f"data{hexid_to_alpha(id(self))}" + self.name = sanitize_text(name, keep_space=False) if name else f"data{hexid_to_alpha(id(self))}" self.z_name = "z" class DataContainer: diff --git a/src/tikzplotly/_utils.py b/src/tikzplotly/_utils.py index 65e58fc..e296d72 100644 --- a/src/tikzplotly/_utils.py +++ b/src/tikzplotly/_utils.py @@ -70,7 +70,12 @@ def sanitize_char(ch, keep_space=False): ------- str: The sanitized character or its hexadecimal representation. """ - if keep_space and ch == " ": return " " + if ch == "_": + return "" + if ch == "@": + return "at" + if ch == " ": + return " " if keep_space else "" if ch in "[]{}= ": return f"x{ord(ch):x}" # if not ascii, return hex if ord(ch) > 127: return f"x{ord(ch):x}" From 74c8828e385fcc1342abea2c481e4564f72a013a Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Tue, 22 Jul 2025 14:38:30 +0200 Subject: [PATCH 35/66] Fix indent --- src/tikzplotly/_scatter3d.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tikzplotly/_scatter3d.py b/src/tikzplotly/_scatter3d.py index ae56508..b2fb820 100644 --- a/src/tikzplotly/_scatter3d.py +++ b/src/tikzplotly/_scatter3d.py @@ -89,9 +89,9 @@ def draw_scatter3d(data_name, scatter, z_name, axis: Axis, color_set): code += f"\\addplot3+ " if options is not None: - code += f"[{options}]" + code += f"[{options}]" else: code += "[only marks]" code += f"table[x=x, y=y, z=z] {{\\{data_name}}};\n" - return code \ No newline at end of file + return code From d32795c6803f4e818833e99c2f634b2e94e196b0 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Sat, 26 Jul 2025 12:09:09 +0200 Subject: [PATCH 36/66] tiny typos fixes --- src/tikzplotly/_bar.py | 6 +++--- src/tikzplotly/_dataContainer.py | 6 +++--- src/tikzplotly/_polar.py | 14 +++++++------- src/tikzplotly/_save.py | 18 +++++++++--------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/tikzplotly/_bar.py b/src/tikzplotly/_bar.py index 03fb1af..3913b7f 100644 --- a/src/tikzplotly/_bar.py +++ b/src/tikzplotly/_bar.py @@ -2,7 +2,7 @@ from ._utils import option_dict_to_str from ._tex import tex_addplot from ._color import convert_color -from ._utils import sanitize_TeX_text +from ._utils import sanitize_tex_text from warnings import warn @@ -27,7 +27,7 @@ def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_ axis : Axis The axis object to which the bar chart will be added. colors_set : set - A set to keep track of colors used in the plot (for \definecolor). + A set to keep track of colors used in the plot (for \\definecolor). row_sep : str, optional The row separator for the data table in TikZ, by default "\\" """ @@ -54,7 +54,7 @@ def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_ # Handle symbolic coords if all(isinstance(value, str) for value in categories): - symbolic = sanitize_TeX_text(",".join(categories)) + symbolic = sanitize_tex_text(",".join(categories)) if orientation == "h": axis.add_option("symbolic y coords", "{" + symbolic + "}") axis.add_option("ytick", "data") diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index 1fcaab2..5a0041e 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -63,7 +63,7 @@ class DataContainer: def __init__(self): self.data = [] - def add_data(self, x, y, name=None y_label=None): + def add_data(self, x, y, name=None, y_label=None): """Add data to the container. Parameters @@ -143,8 +143,8 @@ def export_data(self): for i in range(len(data.x)): export_string += f"{data.x[i]} {data.y[i]} {data.z[i]}\n" export_string += f"}}{{\\{data.name}}}\n" - - # 2D + + # 2D else: export_string += "\\pgfplotstableread{\n" header = "x" diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 5225330..01c707c 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -26,10 +26,10 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): if direction == "clockwise": axis.add_option("y dir", "reverse") axis.add_option("xticklabel style", f"{{anchor={rotation}-\\tick+180}}") - + # Period period = getattr(angularaxis, "period") - + # Category angular_categoryorder = getattr(angularaxis, 'categoryorder', 'trace') angular_categoryarray = getattr(angularaxis, 'categoryarray', None) @@ -88,11 +88,11 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): axis.add_option("xtick", f"{{{','.join(str( i * (360 / n_theta)) for i in range(n_theta))}}}") axis.add_option("xticklabels", "{" + ",".join(symbolic_theta) + "}") else: - symbolic_theta = None + symbolic_theta = None if thetaunit == "radians": numeric_theta = [t * (180 / 3.141592653589793) for t in theta] else: - numeric_theta = theta + numeric_theta = theta # Radial Axis if all(isinstance(v, str) for v in r): @@ -111,7 +111,7 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): axis.add_option("symbolic y coords", "{" + ",".join(symbolic_r) + "}") axis.add_option("ytick", "data") - data_name_macro, r_col_name = data_container.addData(numeric_theta, r, trace.name) + data_name_macro, r_col_name = data_container.add_data(numeric_theta, r, trace.name) theta_col_name = data_container.data[-1].name return data_name_macro, theta_col_name, r_col_name @@ -124,7 +124,7 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: Parameters ---------- data_name_macro : str - The LaTeX macro for the data table (e.g. '\data0'). + The LaTeX macro for the data table (e.g. '\\data0'). theta_col_name : str Name of the column for theta values (angles). r_col_name : str @@ -191,7 +191,7 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: # Construct TikZ addplot code = tex_addplot( data_str=data_name_macro, - type="table", + plot_type="table", options=option_dict_to_str(plot_options), type_options=f"x={theta_col_name}, y={r_col_name}" ) diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 2cfd374..46750f4 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -73,7 +73,7 @@ def get_tikz_code( continue data_name_macro, y_name = data_container.add_data(trace.x, trace.y, trace.name) - + # If x is textual => symbolic x coords if all(isinstance(v, str) for v in trace.x): axis.add_option("symbolic x coords", "{" + ",".join(trace.x) + "}") @@ -118,16 +118,16 @@ def get_tikz_code( data_str.append(bar_code) if trace.name and trace['showlegend'] != False: - data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) + data_str.append(tex_add_legendentry(sanitize_tex_text(trace.name))) elif trace.type == "scatterpolar" or trace.type == "scatterpolargl": data_name_macro, theta_col_name, r_col_name = get_polar_coord(trace, axis, data_container) - + polar_code = draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis, colors_set) data_str.append(polar_code) if trace.name and trace['showlegend'] != False: - data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) + data_str.append(tex_add_legendentry(sanitize_tex_text(trace.name))) elif trace.type == "scatter3d": # Handle the case where x, y, or z is empty @@ -147,11 +147,11 @@ def get_tikz_code( # Labels if hasattr(figure_layout.scene.xaxis, "title") and getattr(figure_layout.scene.xaxis.title, "text", None): - axis.add_option("xlabel", f"{{{sanitize_TeX_text(figure_layout.scene.xaxis.title.text)}}}") + axis.add_option("xlabel", f"{{{sanitize_tex_text(figure_layout.scene.xaxis.title.text)}}}") if hasattr(figure_layout.scene.yaxis, "title") and getattr(figure_layout.scene.yaxis.title, "text", None): - axis.add_option("ylabel", f"{{{sanitize_TeX_text(figure_layout.scene.yaxis.title.text)}}}") + axis.add_option("ylabel", f"{{{sanitize_tex_text(figure_layout.scene.yaxis.title.text)}}}") if hasattr(figure_layout.scene.zaxis, "title") and getattr(figure_layout.scene.zaxis.title, "text", None): - axis.add_option("zlabel", f"{{{sanitize_TeX_text(figure_layout.scene.zaxis.title.text)}}}") + axis.add_option("zlabel", f"{{{sanitize_tex_text(figure_layout.scene.zaxis.title.text)}}}") # Grid if hasattr(figure_layout.scene.xaxis, "showgrid"): @@ -166,13 +166,13 @@ def get_tikz_code( # Title if hasattr(figure_layout.scene, "title") and getattr(figure_layout.scene.title, "text", None): - axis.add_option("title", f"{{{sanitize_TeX_text(figure_layout.scene.title.text)}}}") + axis.add_option("title", f"{{{sanitize_tex_text(figure_layout.scene.title.text)}}}") data_name_macro, z_name = data_container.addData3D(trace.x, trace.y, trace.z, trace.name) data_str.append(draw_scatter3d(data_name_macro, trace, z_name, axis, colors_set)) if trace.name and trace['showlegend'] != False: - data_str.append(tex_add_legendentry(sanitize_TeX_text(trace.name))) + data_str.append(tex_add_legendentry(sanitize_tex_text(trace.name))) if getattr(trace, "line", None) and getattr(trace.line, "color", None) is not None: colors_set.add(convert_color(trace.line.color)[:3]) if getattr(trace, "fillcolor", None) is not None: From 5dfba8f79ac7c0c2249a92090e965167658308c8 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:15:00 +0200 Subject: [PATCH 37/66] fix 'EnumeratedValidator' object is not callable error --- src/tests/test_markers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_markers.py b/src/tests/test_markers.py index 54d5205..b98c9ec 100644 --- a/src/tests/test_markers.py +++ b/src/tests/test_markers.py @@ -227,7 +227,7 @@ def fig3ter(): def fig4(): warn("This example is not exactly the one online, but has been changed as this kind of data is not yet supported.") SymbolValidator = ValidatorCache.get_validator("scatter.marker", "symbol") - raw_symbols = SymbolValidator().values + raw_symbols = SymbolValidator.values namestems = [] namevariants = [] symbols = [] From 98dc167b978413e4c26576cb9811183073ec919d Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:15:23 +0200 Subject: [PATCH 38/66] fix crash if output directory does not exist --- src/tikzplotly/_heatmap.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tikzplotly/_heatmap.py b/src/tikzplotly/_heatmap.py index dd6064f..64e0680 100755 --- a/src/tikzplotly/_heatmap.py +++ b/src/tikzplotly/_heatmap.py @@ -3,6 +3,7 @@ """ from warnings import warn import io +import os from copy import deepcopy import numpy as np from PIL import Image @@ -103,6 +104,8 @@ def draw_heatmap(data, fig, img_name, axis: Axis): img_bytes = fig_copy.to_image(format="png") # The image created by plotly keeps places around the heatmap cropped_image = crop_image(Image.open(io.BytesIO(img_bytes))) # so we crop all the white around the figure resized_image = resize_image(cropped_image, *figure_data.shape) # and we resize it so each square is a 1px x 1px square + + os.makedirs(os.path.dirname(img_name), exist_ok=True) # Make sure the directory exists resized_image.save(img_name) From df0887a82a905672f1a752a2ab5502919c773554 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:29:18 +0200 Subject: [PATCH 39/66] rename addData to add_data and addData3D to add_data3d --- src/tikzplotly/_bar.py | 2 +- src/tikzplotly/_dataContainer.py | 2 +- src/tikzplotly/_save.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tikzplotly/_bar.py b/src/tikzplotly/_bar.py index 3913b7f..46f9c31 100644 --- a/src/tikzplotly/_bar.py +++ b/src/tikzplotly/_bar.py @@ -9,7 +9,7 @@ def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_set, row_sep="\\"): r""" Draw a bar chart (vertical or horizontal) referencing the data table - created by DataContainer.addData(...). + created by DataContainer.add_data(...). If trace.orientation == 'h', we do xbar (horizontal). Otherwise, we do ybar (vertical). diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index 5a0041e..d98e789 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -99,7 +99,7 @@ def add_data(self, x, y, name=None, y_label=None): self.data.append(data_to_add) return data_to_add.macro_name, sanitize_text(y_label_val) - def addData3D(self, x, y, z, name=None): + def add_data3d(self, x, y, z, name=None): """Add data to the container. Parameters diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 46750f4..4d6252f 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -111,7 +111,7 @@ def get_tikz_code( cat_list = trace.y if orientation == "h" else trace.x val_list = trace.x if orientation == "h" else trace.y - data_name_macro, val_col_name = data_container.addData(cat_list, val_list, trace.name) + data_name_macro, val_col_name = data_container.add_data(cat_list, val_list, trace.name) x_col_name = data_container.data[-1].name bar_code = draw_bar(data_name_macro, x_col_name, val_col_name, trace, axis, colors_set) @@ -168,7 +168,7 @@ def get_tikz_code( if hasattr(figure_layout.scene, "title") and getattr(figure_layout.scene.title, "text", None): axis.add_option("title", f"{{{sanitize_tex_text(figure_layout.scene.title.text)}}}") - data_name_macro, z_name = data_container.addData3D(trace.x, trace.y, trace.z, trace.name) + data_name_macro, z_name = data_container.add_data3d(trace.x, trace.y, trace.z, trace.name) data_str.append(draw_scatter3d(data_name_macro, trace, z_name, axis, colors_set)) if trace.name and trace['showlegend'] != False: From 68ac15646d7c85895040037e8454dc6f85e36b14 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:29:55 +0200 Subject: [PATCH 40/66] fix type option being used instead of plot_type --- src/tikzplotly/_bar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tikzplotly/_bar.py b/src/tikzplotly/_bar.py index 46f9c31..f13f445 100644 --- a/src/tikzplotly/_bar.py +++ b/src/tikzplotly/_bar.py @@ -86,7 +86,7 @@ def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_ # Build the final addplot referencing the table code += tex_addplot( data_str=data_name_macro, - type="table", + plot_type="table", options=option_dict_to_str(plot_options), type_options=option_dict_to_str(type_options) ) From 12b4346fe1b3fd3992ab6034e5dd39cfcf25089a Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:31:10 +0200 Subject: [PATCH 41/66] fix error if the input of treat_data is not a string --- src/tikzplotly/_data.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tikzplotly/_data.py b/src/tikzplotly/_data.py index d82c434..7a5edde 100644 --- a/src/tikzplotly/_data.py +++ b/src/tikzplotly/_data.py @@ -33,9 +33,8 @@ def data_type(data): return None def treat_data(data_str): - if not isinstance(data_str, str): - return data_str - if data_str.find(' ') !=- 1: # Add curly braces if there space in string + data_str = str(data_str) + if data_str.find(' ') !=- 1: # Add curly braces if space in string if not data_str.startswith("{") and not data_str.startswith("}"): data_str = "{" + data_str + "}" return data_str From 8cb12085cb25833ab48f3878010d6cbbcdc64b74 Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:31:45 +0200 Subject: [PATCH 42/66] fix bad col name in generated tex --- src/tikzplotly/_save.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 4d6252f..425af8c 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -112,7 +112,7 @@ def get_tikz_code( val_list = trace.x if orientation == "h" else trace.y data_name_macro, val_col_name = data_container.add_data(cat_list, val_list, trace.name) - x_col_name = data_container.data[-1].name + x_col_name = "x" bar_code = draw_bar(data_name_macro, x_col_name, val_col_name, trace, axis, colors_set) data_str.append(bar_code) @@ -122,6 +122,7 @@ def get_tikz_code( elif trace.type == "scatterpolar" or trace.type == "scatterpolargl": data_name_macro, theta_col_name, r_col_name = get_polar_coord(trace, axis, data_container) + theta_col_name = "x" polar_code = draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis, colors_set) data_str.append(polar_code) From 304fe868748fe5045dacf46d610ad1474266056a Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:36:16 +0200 Subject: [PATCH 43/66] fix tex error for data names or values including a space --- src/tikzplotly/_dataContainer.py | 14 +++++++------- src/tikzplotly/_scatter.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index d98e789..8b1505d 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -90,14 +90,14 @@ def add_data(self, x, y, name=None, y_label=None): if isinstance(are_equals, bool): if are_equals: y_label_val = data.add_y_data(y, y_label or name) - return data.macro_name, y_label_val + return data.macro_name, treat_data(y_label_val) elif hasattr(are_equals, "all") and are_equals.all(): y_label_val = data.add_y_data(y, y_label or name) - return data.macro_name, y_label_val + return data.macro_name, treat_data(y_label_val) data_to_add = Data(f"data{len(self.data)}", x) y_label_val = data_to_add.add_y_data(y, y_label or name) self.data.append(data_to_add) - return data_to_add.macro_name, sanitize_text(y_label_val) + return data_to_add.macro_name, treat_data(y_label_val) def add_data3d(self, x, y, z, name=None): """Add data to the container. @@ -141,7 +141,7 @@ def export_data(self): export_string += "\\pgfplotstableread{\n" export_string += "x y z\n" for i in range(len(data.x)): - export_string += f"{data.x[i]} {data.y[i]} {data.z[i]}\n" + export_string += f"{treat_data(data.x[i])} {treat_data(data.y[i])} {treat_data(data.z[i])}\n" export_string += f"}}{{\\{data.name}}}\n" # 2D @@ -150,14 +150,14 @@ def export_data(self): header = "x" if hasattr(data, "y_label") and data.y_label: for label in data.y_label: - header += f" {label}" + header += f" {treat_data(label)}" else: header += " y" export_string += header + "\n" for i in range(len(data.x)): - row = [str(data.x[i])] + row = [treat_data(data.x[i])] for y_col in data.y_data: - row.append(str(y_col[i])) + row.append(treat_data(y_col[i])) export_string += " ".join(row) + "\n" export_string += f"}}\\{data.name}\n" diff --git a/src/tikzplotly/_scatter.py b/src/tikzplotly/_scatter.py index 6334ae0..3034ec5 100644 --- a/src/tikzplotly/_scatter.py +++ b/src/tikzplotly/_scatter.py @@ -127,7 +127,7 @@ def draw_scatter2d(data_name, scatter, y_name, axis: Axis, color_set): options_dict["forget plot"] = None options = option_dict_to_str(options_dict) - code += tex_addplot(data_name, plot_type="table", options=options, type_options=f"y={sanitize_text(y_name)}") + code += tex_addplot(data_name, plot_type="table", options=options, type_options=f"y={y_name}") if scatter.text is not None: for x_data, y_data, text_data in zip(scatter.x, scatter.y, scatter.text): From 70294668ed687992a8381813de46a4bd942881ab Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:39:25 +0200 Subject: [PATCH 44/66] removing call to replace_all_months, as it duplicates the functionality of the symbolic axis --- src/tikzplotly/_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tikzplotly/_data.py b/src/tikzplotly/_data.py index 7a5edde..1ddc39e 100644 --- a/src/tikzplotly/_data.py +++ b/src/tikzplotly/_data.py @@ -50,5 +50,5 @@ def post_treat_data(data_str): ------- string of post-treated data """ - data_str = replace_all_months(data_str) + # data_str = replace_all_months(data_str) return data_str.replace("None", "nan") From 8c5f25da3ae4121f31293b2ba3ad050ae819e2af Mon Sep 17 00:00:00 2001 From: Jonathan Saussereau Date: Fri, 1 Aug 2025 20:43:28 +0200 Subject: [PATCH 45/66] fix tex error when using numbers in macro names --- src/tikzplotly/_dataContainer.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index 8b1505d..12a66ca 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -9,6 +9,17 @@ def hexid_to_alpha(num): table = "ABCDEFGHIJKLMNOP" return ''.join(table[int(c, 16)] for c in hexstr if c in "0123456789abcdef") +def index_to_letters(idx): + """Convert an integer to a string like Excel columns: A, B, ..., Z, AA, AB, ...""" + letters = "" + while True: + idx, rem = divmod(idx, 26) + letters = chr(65 + rem) + letters # 65 = ord('A') + if idx == 0: + break + idx -= 1 + return letters + class Data: """Class to handle data in TikZ plots. """ @@ -94,7 +105,7 @@ def add_data(self, x, y, name=None, y_label=None): elif hasattr(are_equals, "all") and are_equals.all(): y_label_val = data.add_y_data(y, y_label or name) return data.macro_name, treat_data(y_label_val) - data_to_add = Data(f"data{len(self.data)}", x) + data_to_add = Data(f"data{index_to_letters(len(self.data))}", x) y_label_val = data_to_add.add_y_data(y, y_label or name) self.data.append(data_to_add) return data_to_add.macro_name, treat_data(y_label_val) From 659f573af51925f4c97b8e403cf718c3b0027cde Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Tue, 5 Aug 2025 16:56:20 +0200 Subject: [PATCH 46/66] fix dataset headers #29 --- .../test_markers/test_markers_1_reference.tex | 21 ++++++++------- .../test_markers/test_markers_2_reference.tex | 14 +++++----- .../test_markers/test_markers_3_reference.tex | 21 ++++++++------- .../test_markers_angle_reference.tex | 21 ++++++++------- .../test_scatter_10_reference.tex | 9 ++++--- .../test_scatter/test_scatter_1_reference.tex | 9 ++++--- .../test_scatter/test_scatter_2_reference.tex | 14 +++++----- .../test_scatter/test_scatter_3_reference.tex | 7 ++--- .../test_scatter/test_scatter_4_reference.tex | 26 ++++++++++--------- .../test_scatter/test_scatter_5_reference.tex | 9 ++++--- .../test_scatter_6_False_True_reference.tex | 7 ++--- .../test_scatter_6_True_False_reference.tex | 7 ++--- .../test_scatter/test_scatter_7_reference.tex | 17 ++++++------ .../test_scatter/test_scatter_8_reference.tex | 7 ++--- .../test_scatter/test_scatter_9_reference.tex | 7 ++--- ...st_scatter_transparent_color_reference.tex | 7 ++--- ...atter_transparent_color_rgba_reference.tex | 7 ++--- ...test_specific_sanitized_text_reference.tex | 7 ++--- ...cific_transparent_background_reference.tex | 7 ++--- 19 files changed, 127 insertions(+), 97 deletions(-) diff --git a/tests/test_markers/test_markers_1_reference.tex b/tests/test_markers/test_markers_1_reference.tex index 1bd63e4..0569891 100644 --- a/tests/test_markers/test_markers_1_reference.tex +++ b/tests/test_markers/test_markers_1_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 setosa +\pgfplotstableread{ +x setosa 3.5 5.1 3.0 4.9 3.2 4.7 @@ -49,8 +50,9 @@ 3.2 4.6 3.7 5.3 3.3 5.0 -}\dataZ -\pgfplotstableread{data1 versicolor +}\dataA +\pgfplotstableread{ +x versicolor 3.2 7.0 3.2 6.4 3.1 6.9 @@ -101,8 +103,9 @@ 2.9 6.2 2.5 5.1 2.8 5.7 -}\dataO -\pgfplotstableread{data2 virginica +}\dataB +\pgfplotstableread{ +x virginica 3.3 6.3 2.7 5.8 3.0 7.1 @@ -153,7 +156,7 @@ 3.0 6.5 3.4 6.2 3.0 5.9 -}\dataT +}\dataC \begin{tikzpicture} @@ -166,11 +169,11 @@ xlabel=sepal\_width, ylabel=sepal\_length ] -\addplot+ [mark=*, only marks, mark size=9, mark options={solid, fill=636efa, draw=darkslategrey, line width=1.5}] table[y=setosa] {\dataZ}; +\addplot+ [mark=*, only marks, mark size=9, mark options={solid, fill=636efa, draw=darkslategrey, line width=1.5}] table[y=setosa] {\dataA}; \addlegendentry{setosa} -\addplot+ [mark=*, only marks, mark size=9, mark options={solid, fill=EF553B, draw=darkslategrey, line width=1.5}] table[y=versicolor] {\dataO}; +\addplot+ [mark=*, only marks, mark size=9, mark options={solid, fill=EF553B, draw=darkslategrey, line width=1.5}] table[y=versicolor] {\dataB}; \addlegendentry{versicolor} -\addplot+ [mark=*, only marks, mark size=9, mark options={solid, fill=00cc96, draw=darkslategrey, line width=1.5}] table[y=virginica] {\dataT}; +\addplot+ [mark=*, only marks, mark size=9, mark options={solid, fill=00cc96, draw=darkslategrey, line width=1.5}] table[y=virginica] {\dataC}; \addlegendentry{virginica} \end{axis} \end{tikzpicture} diff --git a/tests/test_markers/test_markers_2_reference.tex b/tests/test_markers/test_markers_2_reference.tex index fa2e092..60e4e4e 100644 --- a/tests/test_markers/test_markers_2_reference.tex +++ b/tests/test_markers/test_markers_2_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 4.251066014107722 3.2624466286607725 5.160973480326474 3.6819292088540663 3.0003431244520344 3.943129848342151 @@ -499,11 +500,12 @@ 5.53298984569714 5.006389817768609 5.760619542916981 3.9779016219269576 3.6837008698539506 5.323431798045239 -}\dataZ -\pgfplotstableread{data1 y0 +}\dataA +\pgfplotstableread{ +x y0 2 4.25 2 4.75 -}\dataO +}\dataB \begin{tikzpicture} @@ -511,7 +513,7 @@ \definecolor{mediumpurple}{RGB}{147, 112, 219} \begin{axis} -\addplot+ [only marks, mark size=15, mark options={solid, fill=lightskyblue, draw=mediumpurple, line width=1.5, opacity=0.5}, forget plot] table[y=y0] {\dataZ}; -\addplot+ [only marks, mark size=60, mark options={solid, fill=lightskyblue, draw=mediumpurple, line width=6, opacity=0.5}, forget plot] table[y=y0] {\dataO}; +\addplot+ [only marks, mark size=15, mark options={solid, fill=lightskyblue, draw=mediumpurple, line width=1.5, opacity=0.5}, forget plot] table[y=y0] {\dataA}; +\addplot+ [only marks, mark size=60, mark options={solid, fill=lightskyblue, draw=mediumpurple, line width=6, opacity=0.5}, forget plot] table[y=y0] {\dataB}; \end{axis} \end{tikzpicture} diff --git a/tests/test_markers/test_markers_3_reference.tex b/tests/test_markers/test_markers_3_reference.tex index 6ea2340..9e869e9 100644 --- a/tests/test_markers/test_markers_3_reference.tex +++ b/tests/test_markers/test_markers_3_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 setosa +\pgfplotstableread{ +x setosa 3.5 5.1 3.0 4.9 3.2 4.7 @@ -49,8 +50,9 @@ 3.2 4.6 3.7 5.3 3.3 5.0 -}\dataZ -\pgfplotstableread{data1 versicolor +}\dataA +\pgfplotstableread{ +x versicolor 3.2 7.0 3.2 6.4 3.1 6.9 @@ -101,8 +103,9 @@ 2.9 6.2 2.5 5.1 2.8 5.7 -}\dataO -\pgfplotstableread{data2 virginica +}\dataB +\pgfplotstableread{ +x virginica 3.3 6.3 2.7 5.8 3.0 7.1 @@ -153,7 +156,7 @@ 3.0 6.5 3.4 6.2 3.0 5.9 -}\dataT +}\dataC \begin{tikzpicture} @@ -166,11 +169,11 @@ xlabel=sepal\_width, ylabel=sepal\_length ] -\addplot+ [mark=diamond*, only marks, mark size=6, mark options={solid, fill=636efa, draw=darkslategrey, line width=1.5}] table[y=setosa] {\dataZ}; +\addplot+ [mark=diamond*, only marks, mark size=6, mark options={solid, fill=636efa, draw=darkslategrey, line width=1.5}] table[y=setosa] {\dataA}; \addlegendentry{setosa} -\addplot+ [mark=diamond*, only marks, mark size=6, mark options={solid, fill=EF553B, draw=darkslategrey, line width=1.5}] table[y=versicolor] {\dataO}; +\addplot+ [mark=diamond*, only marks, mark size=6, mark options={solid, fill=EF553B, draw=darkslategrey, line width=1.5}] table[y=versicolor] {\dataB}; \addlegendentry{versicolor} -\addplot+ [mark=diamond*, only marks, mark size=6, mark options={solid, fill=00cc96, draw=darkslategrey, line width=1.5}] table[y=virginica] {\dataT}; +\addplot+ [mark=diamond*, only marks, mark size=6, mark options={solid, fill=00cc96, draw=darkslategrey, line width=1.5}] table[y=virginica] {\dataC}; \addlegendentry{virginica} \end{axis} \end{tikzpicture} diff --git a/tests/test_markers/test_markers_angle_reference.tex b/tests/test_markers/test_markers_angle_reference.tex index 3fec2f1..b06aee8 100644 --- a/tests/test_markers/test_markers_angle_reference.tex +++ b/tests/test_markers/test_markers_angle_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 setosa +\pgfplotstableread{ +x setosa 3.5 5.1 3.0 4.9 3.2 4.7 @@ -49,8 +50,9 @@ 3.2 4.6 3.7 5.3 3.3 5.0 -}\dataZ -\pgfplotstableread{data1 versicolor +}\dataA +\pgfplotstableread{ +x versicolor 3.2 7.0 3.2 6.4 3.1 6.9 @@ -101,8 +103,9 @@ 2.9 6.2 2.5 5.1 2.8 5.7 -}\dataO -\pgfplotstableread{data2 virginica +}\dataB +\pgfplotstableread{ +x virginica 3.3 6.3 2.7 5.8 3.0 7.1 @@ -153,7 +156,7 @@ 3.0 6.5 3.4 6.2 3.0 5.9 -}\dataT +}\dataC \begin{tikzpicture} @@ -166,11 +169,11 @@ xlabel=sepal\_width, ylabel=sepal\_length ] -\addplot+ [mark=triangle*, only marks, mark size=9, mark options={xscale=0.5, solid, fill=636efa, draw=darkslategrey, line width=1.5, rotate=45}] table[y=setosa] {\dataZ}; +\addplot+ [mark=triangle*, only marks, mark size=9, mark options={xscale=0.5, solid, fill=636efa, draw=darkslategrey, line width=1.5, rotate=45}] table[y=setosa] {\dataA}; \addlegendentry{setosa} -\addplot+ [mark=triangle*, only marks, mark size=9, mark options={xscale=0.5, solid, fill=EF553B, draw=darkslategrey, line width=1.5, rotate=45}] table[y=versicolor] {\dataO}; +\addplot+ [mark=triangle*, only marks, mark size=9, mark options={xscale=0.5, solid, fill=EF553B, draw=darkslategrey, line width=1.5, rotate=45}] table[y=versicolor] {\dataB}; \addlegendentry{versicolor} -\addplot+ [mark=triangle*, only marks, mark size=9, mark options={xscale=0.5, solid, fill=00cc96, draw=darkslategrey, line width=1.5, rotate=45}] table[y=virginica] {\dataT}; +\addplot+ [mark=triangle*, only marks, mark size=9, mark options={xscale=0.5, solid, fill=00cc96, draw=darkslategrey, line width=1.5, rotate=45}] table[y=virginica] {\dataC}; \addlegendentry{virginica} \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_10_reference.tex b/tests/test_scatter/test_scatter_10_reference.tex index 5f928aa..4cb7223 100644 --- a/tests/test_scatter/test_scatter_10_reference.tex +++ b/tests/test_scatter/test_scatter_10_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 Australia New_Zealand +\pgfplotstableread{ +x Australia New_Zealand 1952 69.12 69.39 1957 70.33 70.26 1962 70.93 71.24 @@ -11,7 +12,7 @@ 1997 78.83 77.55 2002 80.37 79.11 2007 81.235 80.204 -}\dataZ +}\dataA \begin{tikzpicture} @@ -24,9 +25,9 @@ xlabel=year, ylabel=lifeExp ] -\addplot+ [mark=*, solid, color=636efa] table[y=Australia] {\dataZ}; +\addplot+ [mark=*, solid, color=636efa] table[y=Australia] {\dataA}; \addlegendentry{Australia} -\addplot+ [mark=*, solid, color=EF553B] table[y=New_Zealand] {\dataZ}; +\addplot+ [mark=*, solid, color=EF553B] table[y=New_Zealand] {\dataA}; \addlegendentry{New Zealand} \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_1_reference.tex b/tests/test_scatter/test_scatter_1_reference.tex index 28af129..d3be6b7 100644 --- a/tests/test_scatter/test_scatter_1_reference.tex +++ b/tests/test_scatter/test_scatter_1_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 Australia New_Zealand +\pgfplotstableread{ +x Australia New_Zealand 1952 69.12 69.39 1957 70.33 70.26 1962 70.93 71.24 @@ -11,7 +12,7 @@ 1997 78.83 77.55 2002 80.37 79.11 2007 81.235 80.204 -}\dataZ +}\dataA \begin{tikzpicture} @@ -22,9 +23,9 @@ xlabel=year, ylabel=lifeExp ] -\addplot+ [mark=*, solid, color=636efa] table[y=Australia] {\dataZ}; +\addplot+ [mark=*, solid, color=636efa] table[y=Australia] {\dataA}; \addlegendentry{Australia} -\addplot+ [mark=*, solid, color=EF553B] table[y=New_Zealand] {\dataZ}; +\addplot+ [mark=*, solid, color=EF553B] table[y=New_Zealand] {\dataA}; \addlegendentry{New Zealand} \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_2_reference.tex b/tests/test_scatter/test_scatter_2_reference.tex index a430911..cdf37c2 100644 --- a/tests/test_scatter/test_scatter_2_reference.tex +++ b/tests/test_scatter/test_scatter_2_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 Botswana +\pgfplotstableread{ +x Botswana 47.622 851.2411407 49.618 918.2325349 51.52 983.6539764 @@ -11,8 +12,9 @@ 52.556 8647.142313 46.63399999999999 11003.60508 50.728 12569.85177 -}\dataZ -\pgfplotstableread{data1 Canada +}\dataA +\pgfplotstableread{ +x Canada 68.75 11367.16112 69.96 12489.95006 71.3 13462.48555 @@ -25,7 +27,7 @@ 78.61 28954.92589 79.77 33328.96507 80.653 36319.23501 -}\dataO +}\dataB \begin{tikzpicture} @@ -36,7 +38,7 @@ xlabel=lifeExp, ylabel=gdpPercap ] -\addplot+ [mark=*, solid, color=636efa] table[y=Botswana] {\dataZ}; +\addplot+ [mark=*, solid, color=636efa] table[y=Botswana] {\dataA}; \node at (axis cs:47.622,851.2411407) {1952}; \node at (axis cs:49.618,918.2325349) {1957}; \node at (axis cs:51.52,983.6539764) {1962}; @@ -50,7 +52,7 @@ \node at (axis cs:46.63399999999999,11003.60508) {2002}; \node at (axis cs:50.728,12569.85177) {2007}; \addlegendentry{Botswana} -\addplot+ [mark=*, solid, color=EF553B] table[y=Canada] {\dataO}; +\addplot+ [mark=*, solid, color=EF553B] table[y=Canada] {\dataB}; \node at (axis cs:68.75,11367.16112) {1952}; \node at (axis cs:69.96,12489.95006) {1957}; \node at (axis cs:71.3,13462.48555) {1962}; diff --git a/tests/test_scatter/test_scatter_3_reference.tex b/tests/test_scatter/test_scatter_3_reference.tex index b3e1e53..c0603e2 100644 --- a/tests/test_scatter/test_scatter_3_reference.tex +++ b/tests/test_scatter/test_scatter_3_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 0 0 1 1 2 4 @@ -9,10 +10,10 @@ 7 49 8 64 9 81 -}\dataZ +}\dataA \begin{tikzpicture} \begin{axis} -\addplot+ table[y=y0] {\dataZ}; +\addplot+ table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_4_reference.tex b/tests/test_scatter/test_scatter_4_reference.tex index cd89e0e..525c285 100644 --- a/tests/test_scatter/test_scatter_4_reference.tex +++ b/tests/test_scatter/test_scatter_4_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 Television Newspaper Internet Radio +\pgfplotstableread{ +x Television Newspaper Internet Radio 2001 74 45 13 18 2002 82 42 14 21 2003 80 50 20 18 @@ -11,11 +12,12 @@ 2010 66 31 41 16 2011 66 31 43 19 2012 69 28 50 23 -}\dataZ -\pgfplotstableread{data1 y0 y1 y2 y3 +}\dataA +\pgfplotstableread{ +x y0 y1 y2 y3 2001 74 45 13 18 2012 69 28 50 23 -}\dataO +}\dataB \begin{tikzpicture} @@ -32,14 +34,14 @@ axis background/.style={fill=white}, clip=false ] -\addplot+ [mark=none, line width=1.5, color=6fb95895bc] table[y=Television] {\dataZ}; -\addplot+ [only marks, mark size=3, mark options={solid, fill=6fb95895bc}] table[y=y0] {\dataO}; -\addplot+ [mark=none, line width=1.5, color=9b5dacb44d] table[y=Newspaper] {\dataZ}; -\addplot+ [only marks, mark size=3, mark options={solid, fill=9b5dacb44d}] table[y=y1] {\dataO}; -\addplot+ [mark=none, line width=3, color=8b7c4ec1c1] table[y=Internet] {\dataZ}; -\addplot+ [only marks, mark size=4.5, mark options={solid, fill=8b7c4ec1c1}] table[y=y2] {\dataO}; -\addplot+ [mark=none, line width=1.5, color=9f6fd82da0] table[y=Radio] {\dataZ}; -\addplot+ [only marks, mark size=3, mark options={solid, fill=9f6fd82da0}] table[y=y3] {\dataO}; +\addplot+ [mark=none, line width=1.5, color=6fb95895bc] table[y=Television] {\dataA}; +\addplot+ [only marks, mark size=3, mark options={solid, fill=6fb95895bc}] table[y=y0] {\dataB}; +\addplot+ [mark=none, line width=1.5, color=9b5dacb44d] table[y=Newspaper] {\dataA}; +\addplot+ [only marks, mark size=3, mark options={solid, fill=9b5dacb44d}] table[y=y1] {\dataB}; +\addplot+ [mark=none, line width=3, color=8b7c4ec1c1] table[y=Internet] {\dataA}; +\addplot+ [only marks, mark size=4.5, mark options={solid, fill=8b7c4ec1c1}] table[y=y2] {\dataB}; +\addplot+ [mark=none, line width=1.5, color=9f6fd82da0] table[y=Radio] {\dataA}; +\addplot+ [only marks, mark size=3, mark options={solid, fill=9f6fd82da0}] table[y=y3] {\dataB}; \node[anchor= east] at (axis cs:\pgfkeysvalueof{/pgfplots/xmin} + 0.05*\pgfkeysvalueof{/pgfplots/xmax}-0.05*\pgfkeysvalueof{/pgfplots/xmin}, 74) {Television 74\%}; \node[anchor= west] at (axis cs:\pgfkeysvalueof{/pgfplots/xmin} + 0.95*\pgfkeysvalueof{/pgfplots/xmax}-0.95*\pgfkeysvalueof{/pgfplots/xmin}, 69) {69\%}; \node[anchor= east] at (axis cs:\pgfkeysvalueof{/pgfplots/xmin} + 0.05*\pgfkeysvalueof{/pgfplots/xmax}-0.05*\pgfkeysvalueof{/pgfplots/xmin}, 45) {Newspaper 45\%}; diff --git a/tests/test_scatter/test_scatter_5_reference.tex b/tests/test_scatter/test_scatter_5_reference.tex index b90e837..d905af4 100644 --- a/tests/test_scatter/test_scatter_5_reference.tex +++ b/tests/test_scatter/test_scatter_5_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 2018-01-01 1.0 2018-01-08 1.018172278347936 2018-01-15 1.032007866452698 @@ -104,17 +105,19 @@ 2019-12-16 1.22441776261611 2019-12-23 1.2265044859331442 2019-12-30 1.213013658002661 -}\dataZ +}\dataA \begin{tikzpicture} \definecolor{636efa}{HTML}{636efa} \begin{axis}[ +symbolic x coords={2018-01-01,2018-01-08,2018-01-15,2018-01-22,2018-01-29,2018-02-05,2018-02-12,2018-02-19,2018-02-26,2018-03-05,2018-03-12,2018-03-19,2018-03-26,2018-04-02,2018-04-09,2018-04-16,2018-04-23,2018-04-30,2018-05-07,2018-05-14,2018-05-21,2018-05-28,2018-06-04,2018-06-11,2018-06-18,2018-06-25,2018-07-02,2018-07-09,2018-07-16,2018-07-23,2018-07-30,2018-08-06,2018-08-13,2018-08-20,2018-08-27,2018-09-03,2018-09-10,2018-09-17,2018-09-24,2018-10-01,2018-10-08,2018-10-15,2018-10-22,2018-10-29,2018-11-05,2018-11-12,2018-11-19,2018-11-26,2018-12-03,2018-12-10,2018-12-17,2018-12-24,2018-12-31,2019-01-07,2019-01-14,2019-01-21,2019-01-28,2019-02-04,2019-02-11,2019-02-18,2019-02-25,2019-03-04,2019-03-11,2019-03-18,2019-03-25,2019-04-01,2019-04-08,2019-04-15,2019-04-22,2019-04-29,2019-05-06,2019-05-13,2019-05-20,2019-05-27,2019-06-03,2019-06-10,2019-06-17,2019-06-24,2019-07-01,2019-07-08,2019-07-15,2019-07-22,2019-07-29,2019-08-05,2019-08-12,2019-08-19,2019-08-26,2019-09-02,2019-09-09,2019-09-16,2019-09-23,2019-09-30,2019-10-07,2019-10-14,2019-10-21,2019-10-28,2019-11-04,2019-11-11,2019-11-18,2019-11-25,2019-12-02,2019-12-09,2019-12-16,2019-12-23,2019-12-30}, +xtick=data, date coordinates in=x, xlabel=date, ylabel=GOOG ] -\addplot+ [mark=none, solid, color=636efa, forget plot] table[y=y0] {\dataZ}; +\addplot+ [mark=none, solid, color=636efa, forget plot] table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_6_False_True_reference.tex b/tests/test_scatter/test_scatter_6_False_True_reference.tex index 17ec700..5f454d5 100644 --- a/tests/test_scatter/test_scatter_6_False_True_reference.tex +++ b/tests/test_scatter/test_scatter_6_False_True_reference.tex @@ -1,10 +1,11 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 0 0 1 1 2 2 3 3 4 4 -}\dataZ +}\dataA \begin{tikzpicture} @@ -14,6 +15,6 @@ xlabel=x, ylabel=y ] -\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataZ}; +\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_6_True_False_reference.tex b/tests/test_scatter/test_scatter_6_True_False_reference.tex index 275c1bb..78f926d 100644 --- a/tests/test_scatter/test_scatter_6_True_False_reference.tex +++ b/tests/test_scatter/test_scatter_6_True_False_reference.tex @@ -1,10 +1,11 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 0 0 1 1 2 4 3 9 4 16 -}\dataZ +}\dataA \begin{tikzpicture} @@ -14,6 +15,6 @@ xlabel=x, ylabel=y ] -\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataZ}; +\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_7_reference.tex b/tests/test_scatter/test_scatter_7_reference.tex index 5e5656b..9873d80 100644 --- a/tests/test_scatter/test_scatter_7_reference.tex +++ b/tests/test_scatter/test_scatter_7_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 High_2014 Low_2014 High_2007 Low_2007 High_2000 Low_2000 +\pgfplotstableread{ +x High_2014 Low_2014 High_2007 Low_2007 High_2000 Low_2000 1 28.8 12.7 36.5 23.6 32.5 13.8 2 28.5 14.3 26.6 14.0 37.6 22.3 3 37.0 18.6 43.6 27.0 49.9 32.5 @@ -11,7 +12,7 @@ 10 62.6 45.2 67.3 48.5 60.6 42.8 11 45.3 32.2 46.1 31.0 45.1 31.6 12 39.9 29.1 35.0 23.6 29.3 15.9 -}\dataZ +}\dataA \begin{tikzpicture} @@ -24,17 +25,17 @@ xlabel=Month, ylabel=Temperature (degrees F) ] -\addplot+ [line width=1.125, color=firebrick] table[y=High_2014] {\dataZ}; +\addplot+ [line width=1.125, color=firebrick] table[y=High_2014] {\dataA}; \addlegendentry{High 2014} -\addplot+ [line width=1.125, color=royalblue] table[y=Low_2014] {\dataZ}; +\addplot+ [line width=1.125, color=royalblue] table[y=Low_2014] {\dataA}; \addlegendentry{Low 2014} -\addplot+ [line width=1.125, dashed, color=firebrick] table[y=High_2007] {\dataZ}; +\addplot+ [line width=1.125, dashed, color=firebrick] table[y=High_2007] {\dataA}; \addlegendentry{High 2007} -\addplot+ [line width=1.125, dashed, color=royalblue] table[y=Low_2007] {\dataZ}; +\addplot+ [line width=1.125, dashed, color=royalblue] table[y=Low_2007] {\dataA}; \addlegendentry{Low 2007} -\addplot+ [line width=1.125, dotted, color=firebrick] table[y=High_2000] {\dataZ}; +\addplot+ [line width=1.125, dotted, color=firebrick] table[y=High_2000] {\dataA}; \addlegendentry{High 2000} -\addplot+ [line width=1.125, dotted, color=royalblue] table[y=Low_2000] {\dataZ}; +\addplot+ [line width=1.125, dotted, color=royalblue] table[y=Low_2000] {\dataA}; \addlegendentry{Low 2000} \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_8_reference.tex b/tests/test_scatter/test_scatter_8_reference.tex index 981db8b..053d770 100644 --- a/tests/test_scatter/test_scatter_8_reference.tex +++ b/tests/test_scatter/test_scatter_8_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 974.5803384 43.828 5937.029525999998 76.423 6223.367465 72.301 @@ -141,7 +142,7 @@ 2280.769906 62.698 1271.211593 42.38399999999999 469.70929810000007 43.487 -}\dataZ +}\dataA \begin{tikzpicture} @@ -161,6 +162,6 @@ xlabel=gdpPercap, ylabel=lifeExp ] -\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataZ}; +\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_9_reference.tex b/tests/test_scatter/test_scatter_9_reference.tex index 0c412ab..481e671 100644 --- a/tests/test_scatter/test_scatter_9_reference.tex +++ b/tests/test_scatter/test_scatter_9_reference.tex @@ -1,4 +1,5 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 5.836039190663969 0.40841700265768377 1.4920592434019648 1.4724129208622037 2.661095776728801 0.6000122918441767 @@ -49,7 +50,7 @@ 2.176004410404423 5.964769984228198 0.19910999912356603 1.1353172107281377 0.8083660586147164 1.4947954331172004 -}\dataZ +}\dataA \begin{tikzpicture} @@ -61,6 +62,6 @@ xmode=log, ymode=log ] -\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataZ}; +\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_transparent_color_reference.tex b/tests/test_scatter/test_scatter_transparent_color_reference.tex index 3e81744..84ddc6d 100644 --- a/tests/test_scatter/test_scatter_transparent_color_reference.tex +++ b/tests/test_scatter/test_scatter_transparent_color_reference.tex @@ -1,10 +1,11 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 0 0 1 1 2 4 3 9 4 16 -}\dataZ +}\dataA \begin{tikzpicture} @@ -14,6 +15,6 @@ xlabel=x, ylabel=y ] -\addplot+ [mark=*, only marks, mark options={solid, fill=636efa, opacity=0.5}, forget plot] table[y=y0] {\dataZ}; +\addplot+ [mark=*, only marks, mark options={solid, fill=636efa, opacity=0.5}, forget plot] table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_transparent_color_rgba_reference.tex b/tests/test_scatter/test_scatter_transparent_color_rgba_reference.tex index 3e81744..84ddc6d 100644 --- a/tests/test_scatter/test_scatter_transparent_color_rgba_reference.tex +++ b/tests/test_scatter/test_scatter_transparent_color_rgba_reference.tex @@ -1,10 +1,11 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 0 0 1 1 2 4 3 9 4 16 -}\dataZ +}\dataA \begin{tikzpicture} @@ -14,6 +15,6 @@ xlabel=x, ylabel=y ] -\addplot+ [mark=*, only marks, mark options={solid, fill=636efa, opacity=0.5}, forget plot] table[y=y0] {\dataZ}; +\addplot+ [mark=*, only marks, mark options={solid, fill=636efa, opacity=0.5}, forget plot] table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} diff --git a/tests/test_specific/test_specific_sanitized_text_reference.tex b/tests/test_specific/test_specific_sanitized_text_reference.tex index a2115aa..a2069ac 100644 --- a/tests/test_specific/test_specific_sanitized_text_reference.tex +++ b/tests/test_specific/test_specific_sanitized_text_reference.tex @@ -1,16 +1,17 @@ -\pgfplotstableread{data0 x5btestx200bx5d +\pgfplotstableread{ +x x5btestx200bx5d 0 0 1 1 2 4 3 9 4 16 -}\dataZ +}\dataA \begin{tikzpicture} \begin{axis}[ clip=false ] -\addplot+ table[y=x5btestx200bx5d] {\dataZ}; +\addplot+ table[y=x5btestx200bx5d] {\dataA}; \addlegendentry{{[testx200b]}} \node[anchor=south west] at (axis cs:2, \pgfkeysvalueof{/pgfplots/ymin} + 1.05*\pgfkeysvalueof{/pgfplots/ymax}-1.05*\pgfkeysvalueof{/pgfplots/ymin}) {{==[\{x1d54bop text\}]==x9}}; \node[anchor=south west] at (axis cs:2, 2) {Ouais c'est pas faux}; diff --git a/tests/test_specific/test_specific_transparent_background_reference.tex b/tests/test_specific/test_specific_transparent_background_reference.tex index 94083c0..7421700 100644 --- a/tests/test_specific/test_specific_transparent_background_reference.tex +++ b/tests/test_specific/test_specific_transparent_background_reference.tex @@ -1,10 +1,11 @@ -\pgfplotstableread{data0 y0 +\pgfplotstableread{ +x y0 0 0 1 1 2 4 3 9 4 16 -}\dataZ +}\dataA \begin{tikzpicture} @@ -16,6 +17,6 @@ xlabel=x, ylabel=y ] -\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataZ}; +\addplot+ [mark=*, only marks, mark options={solid, fill=636efa}, forget plot] table[y=y0] {\dataA}; \end{axis} \end{tikzpicture} From d9b96ec941f45f886d897bfba9222be6fcc31b10 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Tue, 5 Aug 2025 17:42:34 +0200 Subject: [PATCH 47/66] sanitize texts and TeX texts separately --- src/tikzplotly/_data.py | 8 ++++---- src/tikzplotly/_scatter.py | 2 +- src/tikzplotly/_utils.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tikzplotly/_data.py b/src/tikzplotly/_data.py index 1ddc39e..3686e65 100644 --- a/src/tikzplotly/_data.py +++ b/src/tikzplotly/_data.py @@ -2,7 +2,7 @@ This module contains the code to handle data types in TikZ using Plotly data. """ from warnings import warn -from ._utils import replace_all_months +from ._utils import sanitize_text def data_type(data): """Return the type of data, for special handling. @@ -23,7 +23,7 @@ def data_type(data): if len(data.split('-')) == 3: warn("Assuming this is a date, add \"\\usetikzlibrary{pgfplots.dateplot}\" to your tex preamble.") return 'date' - + if data.lower() in ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']: warn(f"Assuming data {data} is a month. This feature is experimental.") @@ -32,8 +32,8 @@ def data_type(data): return None return None -def treat_data(data_str): - data_str = str(data_str) +def treat_data(data_str): + data_str = sanitize_text(str(data_str)) if data_str.find(' ') !=- 1: # Add curly braces if space in string if not data_str.startswith("{") and not data_str.startswith("}"): data_str = "{" + data_str + "}" diff --git a/src/tikzplotly/_scatter.py b/src/tikzplotly/_scatter.py index 3034ec5..f387e68 100644 --- a/src/tikzplotly/_scatter.py +++ b/src/tikzplotly/_scatter.py @@ -10,7 +10,7 @@ from ._dash import DASH_PATTERN from ._axis import Axis from ._data import data_type -from ._utils import px_to_pt, option_dict_to_str, sanitize_text +from ._utils import px_to_pt, option_dict_to_str def draw_scatter2d(data_name, scatter, y_name, axis: Axis, color_set): """Get code for a scatter trace. diff --git a/src/tikzplotly/_utils.py b/src/tikzplotly/_utils.py index f006387..941ded5 100644 --- a/src/tikzplotly/_utils.py +++ b/src/tikzplotly/_utils.py @@ -91,8 +91,8 @@ def sanitize_char(ch: str, keep_space: bool = False) -> str: return "" if ch == "@": return "at" - if ch == " ": - return " " if keep_space else "" + if ch == " ": + return " " if keep_space else "_" if ch in "[]{}= ": return f"x{ord(ch):x}" # if not ascii, return hex if ord(ch) > 127: return f"x{ord(ch):x}" @@ -115,7 +115,7 @@ def sanitize_tex_text(text: str): The sanitized text, with special characters escaped and formatted for LaTeX. """ sanitized = "".join(map(sanitize_tex_char, text)) - special_chars = "[], " + special_chars = "[]," if any(c in sanitized for c in special_chars): return "{" + sanitized + "}" return sanitized From 17652053cb5e86d2d60e076d6c9ee184eacd5ad1 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Tue, 5 Aug 2025 22:00:17 +0200 Subject: [PATCH 48/66] fix test with line charts --- .../test_scatter/test_scatter_7_reference.tex | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/test_scatter/test_scatter_7_reference.tex b/tests/test_scatter/test_scatter_7_reference.tex index 9873d80..5a0b06f 100644 --- a/tests/test_scatter/test_scatter_7_reference.tex +++ b/tests/test_scatter/test_scatter_7_reference.tex @@ -1,17 +1,17 @@ \pgfplotstableread{ x High_2014 Low_2014 High_2007 Low_2007 High_2000 Low_2000 -1 28.8 12.7 36.5 23.6 32.5 13.8 -2 28.5 14.3 26.6 14.0 37.6 22.3 -3 37.0 18.6 43.6 27.0 49.9 32.5 -4 56.8 35.5 52.3 36.8 53.0 37.2 -5 69.7 49.9 71.5 47.6 69.1 49.9 -6 79.7 58.0 81.4 57.7 75.4 56.1 -7 78.5 60.0 80.5 58.9 76.5 57.7 -8 77.8 58.6 82.2 61.2 76.6 58.3 -9 74.1 51.7 76.0 53.3 70.7 51.2 -10 62.6 45.2 67.3 48.5 60.6 42.8 -11 45.3 32.2 46.1 31.0 45.1 31.6 -12 39.9 29.1 35.0 23.6 29.3 15.9 +January 28.8 12.7 36.5 23.6 32.5 13.8 +February 28.5 14.3 26.6 14.0 37.6 22.3 +March 37.0 18.6 43.6 27.0 49.9 32.5 +April 56.8 35.5 52.3 36.8 53.0 37.2 +May 69.7 49.9 71.5 47.6 69.1 49.9 +June 79.7 58.0 81.4 57.7 75.4 56.1 +July 78.5 60.0 80.5 58.9 76.5 57.7 +August 77.8 58.6 82.2 61.2 76.6 58.3 +September 74.1 51.7 76.0 53.3 70.7 51.2 +October 62.6 45.2 67.3 48.5 60.6 42.8 +November 45.3 32.2 46.1 31.0 45.1 31.6 +December 39.9 29.1 35.0 23.6 29.3 15.9 }\dataA \begin{tikzpicture} @@ -20,6 +20,8 @@ \definecolor{royalblue}{RGB}{65, 105, 225} \begin{axis}[ +symbolic x coords={January,February,March,April,May,June,July,August,September,October,November,December}, +xtick=data, xticklabels={January, February, March, April, May, June, July, August, September, October, November, December}, title=Average High and Low Temperatures in New York, xlabel=Month, From f378bb3863b0946d8df62901a51326245f41168d Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Thu, 7 Aug 2025 22:39:29 +0200 Subject: [PATCH 49/66] add coverage tests for polar --- src/tests/test_polar.py | 12 +- src/tikzplotly/_polar.py | 1 - tests/empty_plot.tex | 4 + tests/test_polar.py | 278 ++++++++++++ tests/test_polar/test_polar_1_reference.tex | 399 ++++++++++++++++++ ...st_polar_categorical_angular_reference.tex | 20 + ...ategorical_angularcategories_reference.tex | 20 + ...est_polar_categorical_radial_reference.tex | 20 + ...categorical_radialcategories_reference.tex | 21 + .../test_polar_radar_1_reference.tex | 25 ++ .../test_polar_radar_2_reference.tex | 79 ++++ .../test_polar_radar_range_1_reference.tex | 27 ++ .../test_polar_radar_range_2_reference.tex | 16 + 13 files changed, 915 insertions(+), 7 deletions(-) create mode 100644 tests/empty_plot.tex create mode 100644 tests/test_polar.py create mode 100644 tests/test_polar/test_polar_1_reference.tex create mode 100644 tests/test_polar/test_polar_categorical_angular_reference.tex create mode 100644 tests/test_polar/test_polar_categorical_angularcategories_reference.tex create mode 100644 tests/test_polar/test_polar_categorical_radial_reference.tex create mode 100644 tests/test_polar/test_polar_categorical_radialcategories_reference.tex create mode 100644 tests/test_polar/test_polar_radar_1_reference.tex create mode 100644 tests/test_polar/test_polar_radar_2_reference.tex create mode 100644 tests/test_polar/test_polar_radar_range_1_reference.tex create mode 100644 tests/test_polar/test_polar_radar_range_2_reference.tex diff --git a/src/tests/test_polar.py b/src/tests/test_polar.py index 23681e4..bc78c50 100644 --- a/src/tests/test_polar.py +++ b/src/tests/test_polar.py @@ -126,7 +126,7 @@ def fig_polar5(): )) fig.update_layout(showlegend=False) - + # fig.show() return fig, "Basic Polar Chart" @@ -337,7 +337,7 @@ def fig_polar10a(): r = [1,2,3,4,5] theta = [0,90,180,360,0] - + fig.add_trace(go.Scatterpolar()) fig.update_traces(r= r, theta=theta, mode="lines+markers", line_color='indianred', @@ -363,7 +363,7 @@ def fig_polar10b(): r = [1,2,3,4,5] theta = [0,90,180,360,0] - + fig.add_trace(go.Scatterpolar()) fig.update_traces(r= r, theta=theta, mode="lines+markers", line_color='indianred', @@ -389,7 +389,7 @@ def fig_polar10c(): r = [1,2,3,4,5] theta = [0,90,180,360,0] - + fig.add_trace(go.Scatterpolar()) fig.update_traces(r= r, theta=theta, mode="lines+markers", line_color='indianred', @@ -415,7 +415,7 @@ def fig_polar10d(): r = [1,2,3,4,5] theta = [0,90,180,360,0] - + fig.add_trace(go.Scatterpolar()) fig.update_traces(r= r, theta=theta, mode="lines+markers", line_color='indianred', @@ -595,7 +595,7 @@ def fig_polar12d(): print("Tikzploty : ", tikzplotly.__version__) print("Plotly : ", plotly.__version__) - print("Test line charts") + print("Test polar/radar charts") file_directory = os.path.dirname(os.path.abspath(__file__)) diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 01c707c..ec7b69d 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -145,7 +145,6 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: """ plot_options = {} - mark_options = {} mode = trace.mode if trace.mode else "lines" diff --git a/tests/empty_plot.tex b/tests/empty_plot.tex new file mode 100644 index 0000000..25634b8 --- /dev/null +++ b/tests/empty_plot.tex @@ -0,0 +1,4 @@ +\begin{tikzpicture} +\begin{axis} +\end{axis} +\end{tikzpicture} diff --git a/tests/test_polar.py b/tests/test_polar.py new file mode 100644 index 0000000..847a32a --- /dev/null +++ b/tests/test_polar.py @@ -0,0 +1,278 @@ +""" +Test of polar plots https://plotly.com/python/radar-chart/ and https://plotly.com/python/polar-chart/ +""" +import os, pathlib +import plotly.express as px +import plotly.graph_objects as go +import pandas as pd +from .helpers import assert_equality + +this_dir = pathlib.Path(__file__).resolve().parent +test_name = "test_polar" + +def plot_radar_1(): + df = pd.DataFrame(dict( + r=[1, 5, 2, 2, 3], + theta=['processing cost','mechanical properties','chemical stability', + 'thermal stability', 'device integration'])) + fig = px.line_polar(df, r='r', theta='theta', line_close=True) + return fig + +def plot_radar_2(): + df = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/master/polar_dataset.csv") + + fig = go.Figure() + fig.add_trace(go.Scatterpolar( + r = df['x1'], + theta = df['y'], + mode = 'lines', + name = 'Figure 8', + line_color = 'peru' + )) + fig.add_trace(go.Scatterpolar( + r = df['x2'], + theta = df['y'], + mode = 'lines', + name = 'Cardioid', + line_color = 'darkviolet', + line_width=2 + )) + fig.add_trace(go.Scatterpolar( + r = df['x3'], + theta = df['y'], + mode = 'lines', + name = 'Hypercardioid', + line_color = 'deepskyblue' + )) + fig.update_layout( + title = 'Basic Polar Chart', + showlegend = False + ) + return fig + +def fig_polar_categorial_angular(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + name = "angular categories", + r = [5, 4, 2, 4, 5], + theta = ["a", "b", "c", "d", "a"], + )) + + fig.update_traces(fill='toself') + + fig.update_layout( + polar = dict( + radialaxis_angle = -45, + angularaxis = dict( + direction = "clockwise", + period = 6) + ), + ) + return fig + +def fig_polar_categorial_radial(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + name = "radial categories", + r = ["a", "b", "c", "d", "b", "f", "a"], + theta = [1, 4, 2, 1.5, 1.5, 6, 5], + thetaunit = "radians", + )) + + fig.update_traces(fill='toself') + + fig.update_layout( + polar = dict( + radialaxis = dict( + angle = 180, + tickangle = -180 # so that tick labels are not upside down + ) + ) + ) + return fig + +def fig_polar_categorial_angularcategories(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + name = "angular categories (w/ categoryarray)", + r = [5, 4, 2, 4, 5], + theta = ["a", "b", "c", "d", "a"], + )) + + fig.update_traces(fill='toself') + + fig.update_layout( + polar = dict( + sector = [80, 400], + radialaxis_angle = -45, + angularaxis_categoryarray = ["d", "a", "c", "b"] + ), + ) + return fig + +def fig_polar_categorial_radialcategories(): + + fig = go.Figure() + + fig.add_trace(go.Scatterpolar( + name = "radial categories (w/ category descending)", + r = ["a", "b", "c", "d", "b", "f", "a", "a"], + theta = [45, 90, 180, 200, 300, 15, 20, 45], + )) + + fig.update_traces(fill='toself') + fig.update_layout( + polar = dict( + radialaxis_categoryorder = "category descending", + angularaxis = dict( + thetaunit = "radians", + dtick = 0.3141592653589793 + )) + ) + return fig + +def fig_polar_range_1(): + fig = px.scatter_polar(r=range(0,90,10), theta=range(0,90,10), + range_theta=[0,90], start_angle=0, direction="counterclockwise") + return fig + +def fig_polar_range_2(): + fig = go.Figure() + fig.add_trace(go.Scatterpolar( + mode = "lines", + r = [3, 3, 4, 3], + theta = [0, 45, 90, 270], + fill = "toself", + subplot = "polar4", + )) + fig.update_layout( + polar = dict( + radialaxis = dict(visible = False, range = [0, 6]) + ), + showlegend = False + ) + return fig + +def fig_polar_1(): + df = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/master/hobbs-pearson-trials.csv") + + fig = go.Figure() + fig.add_trace(go.Scatterpolargl( + r = df.trial_1_r, + theta = df.trial_1_theta, + name = "Trial 1", + marker=dict(size=15, color="mediumseagreen") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_2_r, + theta = df.trial_2_theta, + name = "Trial 2", + marker=dict(size=20, color="darkorange") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_3_r, + theta = df.trial_3_theta, + name = "Trial 3", + marker=dict(size=12, color="mediumpurple") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_4_r, + theta = df.trial_4_theta, + name = "Trial 4", + marker=dict(size=22, color = "magenta") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_5_r, + theta = df.trial_5_theta, + name = "Trial 5", + marker=dict(size=19, color = "limegreen") + )) + fig.add_trace(go.Scatterpolargl( + r = df.trial_6_r, + theta = df.trial_6_theta, + name = "Trial 6", + marker=dict(size=10, color = "gold") + )) + + # Common parameters for all traces + fig.update_traces(mode="markers", marker=dict(line_color='white', opacity=0.7)) + + fig.update_layout( + title = "Hobbs-Pearson Trials", + font_size = 15, + showlegend = False, + polar = dict( + bgcolor = "rgb(223, 223, 223)", + angularaxis = dict( + linewidth = 3, + showline=True, + linecolor='black' + ), + radialaxis = dict( + side = "counterclockwise", + showline = True, + linewidth = 2, + gridcolor = "white", + gridwidth = 2, + ) + ), + paper_bgcolor = "rgb(223, 223, 223)" + ) + return fig + +def fig_polar_matplotlib(): # not supported yet + fig = go.Figure(go.Barpolar( + r=[3.5, 1.5, 2.5, 4.5, 4.5, 4, 3], + theta=[65, 15, 210, 110, 312.5, 180, 270], + width=[20,15,10,20,15,30,15,], + marker_color=["#E4FF87", '#709BFF', '#709BFF', '#FFAA70', '#FFAA70', '#FFDF70', '#B6FFB4'], + marker_line_color="black", + marker_line_width=2, + opacity=0.8 + )) + + fig.update_layout( + template=None, + polar = dict( + radialaxis = dict(range=[0, 5], showticklabels=False, ticks=''), + angularaxis = dict(showticklabels=False, ticks='') + ) + ) + return fig + + +def test_radar_1(): + assert_equality(plot_radar_1(), os.path.join(this_dir, test_name, test_name + "_radar_1_reference.tex")) + +def test_radar_2(): + assert_equality(plot_radar_2(), os.path.join(this_dir, test_name, test_name + "_radar_2_reference.tex")) + +def test_polar_categorial_angular(): + assert_equality(fig_polar_categorial_angular(), os.path.join(this_dir, test_name, test_name + "_categorical_angular_reference.tex")) + +def test_polar_categorial_radial(): + assert_equality(fig_polar_categorial_radial(), os.path.join(this_dir, test_name, test_name + "_categorical_radial_reference.tex")) + +def test_polar_categorial_angularcategories(): + assert_equality(fig_polar_categorial_angularcategories(), os.path.join(this_dir, test_name, test_name + "_categorical_angularcategories_reference.tex")) + +def test_polar_categorial_radialcategories(): + assert_equality(fig_polar_categorial_radialcategories(), os.path.join(this_dir, test_name, test_name + "_categorical_radialcategories_reference.tex")) + +def test_polar_range_1(): + assert_equality(fig_polar_range_1(), os.path.join(this_dir, test_name, test_name + "_radar_range_1_reference.tex")) + +def test_polar_range_2(): + assert_equality(fig_polar_range_2(), os.path.join(this_dir, test_name, test_name + "_radar_range_2_reference.tex")) + +def test_polar_1(): + assert_equality(fig_polar_1(), os.path.join(this_dir, test_name, test_name + "_1_reference.tex")) + +def test_polar_matplotlib(): + assert_equality(fig_polar_matplotlib(), os.path.join(this_dir, "empty_plot.tex")) diff --git a/tests/test_polar/test_polar_1_reference.tex b/tests/test_polar/test_polar_1_reference.tex new file mode 100644 index 0000000..f8bb42d --- /dev/null +++ b/tests/test_polar/test_polar_1_reference.tex @@ -0,0 +1,399 @@ +\pgfplotstableread{ +x Trial_1 +-30.35294436 6.804985785 +-25.61145985 3.389596011 +-12.42522745 5.381472111 +13.96138052 8.059540219 +-4.950932841 5.318229228 +-25.69227419 2.985099936 +12.46876416 1.966587002 +-4.913764107 6.769265408 +-10.96738029 4.073401899 +30.81419405 6.504371825 +2.474959431 7.556369819 +17.97554375 4.047456094 +0.771130593 7.386662496 +6.137488486 5.413624737 +-14.45196357 7.470716531 +28.18453411 7.982110217 +12.53868007 4.73781408 +-8.983230337 4.206453043 +5.231285165 5.478604805 +-64.48900254 4.824520281 +11.35748668 5.59960061 +3.454074792 6.866795217 +13.92434661 3.085671366 +-25.36400205 7.771810943 +-16.81800639 3.687794435 +-10.26005103 5.360356685 +-13.21213413 5.140446739 +2.579338865 6.045445681 +8.717574966 6.83392094 +-10.67549872 3.620769463 +-2.926366013 3.989430583 +25.19588075 5.3118245 +40.59032932 4.60821348 +-9.12143363 6.640584716 +-24.29736238 3.055188854 +-3.176944506 7.492564164 +10.85049842 5.485078178 +-31.33205975 3.897794997 +4.849567462 5.976245114 +15.04827695 5.447061561 +3.295104699 5.377034117 +-6.197091873 4.690805788 +-8.778574136 4.711640491 +29.54917412 3.629919329 +-5.137448793 5.957668076 +23.02686049 5.357121284 +-6.634816578 3.849235283 +2.755014992 6.250507136 +21.73325011 7.122243357 +-24.81699496 3.399404234 +-7.830547063 3.510556672 +28.32579621 4.100997604 +12.30097747 4.0963821 +-21.56315724 6.233583075 +-19.33551628 3.939488527 +26.14644317 3.925445077 +-1.706071203 6.118132501 +16.0717237 3.940450346 +2.053266303 7.583015573 +-5.097911612 3.513202145 +}\dataA +\pgfplotstableread{ +x Trial_2 +14.80662578 3.488043923 +79.00634037 2.918478576 +49.02206554 4.20182736 +49.69908314 8.227324607 +54.13749108 4.776690427 +86.41932102 3.041912303 +96.95239194 4.789947719 +41.46348826 5.66388078 +67.13769169 3.858262393 +68.06103944 8.260212881 +42.68193032 6.868624486 +76.39865661 5.7401976 +42.19479347 6.594979282 +59.57788897 5.692703778 +27.5108668 5.337916574 +60.75344483 9.283604185 +68.3708328 5.764590893 +65.74802815 4.028864552 +58.53300837 5.662344748 +-176.7441065 0.422837231 +61.17401858 6.201266464 +47.45150859 6.439265381 +84.42665319 5.096758513 +12.47934655 4.632081909 +72.48080276 3.421846136 +50.57883176 4.369404703 +51.56022824 4.028334419 +52.43785618 5.805767198 +51.58682799 6.848189921 +73.87294478 3.809295513 +70.21705693 4.385268184 +70.71429915 6.983326846 +82.23439443 7.396273186 +38.93539045 5.215125003 +84.70936667 3.086148779 +38.16582844 6.335394491 +61.70405365 6.090414714 +70.19695629 2.448056007 +54.45429259 5.94278402 +64.33489497 6.373129886 +58.27389315 5.454205341 +60.49982239 4.393337617 +59.15523254 4.20594468 +83.86561847 6.155542288 +47.8734099 5.119087171 +69.28260157 6.869860831 +71.18991043 4.104599861 +51.04839646 5.954348126 +59.42758242 8.092332877 +78.59873696 2.961769705 +75.75586452 3.974012188 +79.97048372 6.373384129 +73.89378025 5.415409143 +31.73341113 3.87689092 +68.08475118 3.261446947 +80.41107998 6.14580853 +48.92425071 5.502451987 +76.65025576 5.571553295 +42.18286436 6.853049261 +76.03333589 4.140355075 +}\dataB +\pgfplotstableread{ +x Trial_3 +151.2942552 1.855870835 +147.188025 5.286962062 +125.2821571 3.886013392 +87.06729797 6.282863313 +119.6278984 4.453414848 +147.7408241 5.688008051 +139.5645981 7.330864283 +101.3914971 3.825660595 +134.5601843 4.989604177 +104.0244447 7.89743147 +89.39314294 4.656693113 +123.1940314 6.667153696 +91.47434052 4.431006287 +113.3323736 5.346113253 +96.14992557 2.479945696 +93.28073452 8.113477349 +118.2155652 6.081311682 +132.3229374 4.968216896 +112.9411864 5.244453921 +-179.7462331 5.422207884 +110.3035136 5.792774616 +97.75083617 4.787580592 +131.6080893 6.784318637 +115.4969192 1.108936909 +140.5811822 5.138911105 +123.3966621 4.042929657 +128.342009 4.02289203 +107.6088104 4.828428791 +97.90468979 5.417378374 +137.128448 5.378635211 +130.4312449 5.421097175 +112.2270845 7.120561979 +118.6302022 8.34930854 +106.0582256 3.410485588 +146.9081097 5.628378471 +90.27734956 3.914936976 +111.5052824 5.763940262 +151.0897425 4.764374107 +107.7213942 5.076236268 +111.300855 6.165558183 +114.6802779 5.105576516 +126.5693795 4.761036377 +128.2189522 4.596249541 +125.3548572 7.504188411 +112.4180683 4.107031418 +111.7973557 6.920422299 +133.4180523 5.34912895 +105.1841168 4.798065719 +97.23103612 7.023251532 +146.6680368 5.283680965 +136.2393152 5.569071152 +121.7918442 7.383794908 +123.911328 6.26923321 +129.862245 2.656529645 +141.3439509 4.843984339 +123.2709677 7.247992362 +108.4588217 4.372959394 +124.4123771 6.570981081 +89.02711074 4.602479244 +134.8767011 5.670052051 +}\dataC +\pgfplotstableread{ +x Trial_4 +-140.2033276 5.372470924 +-168.0842454 7.096355572 +-166.2851413 4.883823903 +138.2488668 2.920135441 +-174.4243864 4.723963046 +-169.9604828 7.423693951 +176.9918227 8.090946075 +-169.9014162 3.306844591 +-172.6415816 6.050828483 +142.9516688 5.530232074 +172.4157464 2.472306953 +168.5193592 6.275670537 +177.8220537 2.615896174 +172.8551903 4.653539945 +-146.0145217 3.335440014 +128.177293 4.795883605 +169.1670728 5.472711346 +-173.5885738 5.881930491 +173.7269927 4.571587072 +-151.2061048 9.03986117 +166.2604772 4.6429076 +172.5075661 3.172767736 +173.9491839 7.044248139 +-131.8068409 4.466336514 +-170.6352738 6.55733029 +-168.5770855 4.820849437 +-166.7655034 5.131915515 +176.0704873 3.970012237 +162.2975015 3.406323813 +-174.0557463 6.476722964 +-178.0609299 6.019218509 +156.4712689 5.664501535 +155.2391421 7.158758523 +-163.0005264 3.600712662 +-170.1167133 7.324127169 +-170.6392725 2.552946156 +167.3831437 4.72713386 +-163.0988171 6.971755207 +172.880737 4.076578361 +163.3860077 4.946223407 +176.182542 4.642155449 +-174.5796802 5.360574864 +-172.3358449 5.391719067 +165.3380257 7.072524305 +-172.5256643 4.10111157 +157.5428777 5.485732621 +-175.8815111 6.192535286 +175.427644 3.768711392 +142.0696747 4.29031139 +-168.340734 7.06019537 +-175.8058311 6.539691844 +163.0637454 6.679744406 +171.720975 6.060825359 +-151.4039046 4.786574041 +-168.2713691 6.41668653 +165.0453279 6.703281333 +-177.3153367 3.88884781 +170.0424129 6.308591081 +173.5991966 2.437044771 +-177.2506567 6.508186348 +}\dataD +\pgfplotstableread{ +x Trial_5 +-101.8337858 7.937557871 +-127.4783916 7.302746492 +-112.244285 5.929302221 +-82.32591087 2.407178713 +-114.6888556 5.270921887 +-130.5378634 7.400596128 +-145.010265 6.810820338 +-98.74884501 4.967759034 +-124.4417488 6.19022937 +-152.4541193 2.158518658 +-89.29423655 4.004125894 +-139.8324517 4.776617322 +-91.54359518 4.232250452 +-119.442163 4.307654873 +-92.45583853 6.200275173 +-129.6599243 0.727513849 +-131.0512351 4.378006804 +-123.8529175 6.004964939 +-118.086739 4.341931703 +-121.9792171 10.23798294 +-121.91503 3.802158889 +-99.36184758 3.96928117 +-141.467702 5.758980142 +-93.56626319 7.674179069 +-126.3369014 6.699953533 +-112.8349442 5.734310388 +-114.3864799 6.044275915 +-109.7960723 4.312943066 +-102.7432647 3.377545282 +-128.2467289 6.367666727 +-127.7920926 5.737244182 +-142.4736297 3.396351472 +-161.5872942 4.216467481 +-99.94061078 5.464885017 +-130.1631173 7.311135578 +-90.22881201 4.745400769 +-122.6504912 3.916468532 +-123.2677506 7.60297299 +-111.9973088 4.125204829 +-127.5283168 3.67679495 +-117.9312953 4.551235789 +-120.3916342 5.606960532 +-119.3868715 5.794844257 +-149.6746955 5.030528156 +-107.8505175 5.109586241 +-138.9899313 3.405440208 +-127.5954702 6.026306125 +-107.3208354 4.221109264 +-117.5738074 1.909782937 +-127.481661 7.254669394 +-129.9120332 6.268875872 +-148.4952117 4.562580567 +-135.3316414 4.918057965 +-104.4216593 6.836560963 +-123.8754402 6.786486549 +-146.8168266 4.751014334 +-107.0584854 4.719926348 +-138.9025649 4.927805215 +-88.89688252 4.059190587 +-130.7544674 6.128338984 +}\dataE +\pgfplotstableread{ +x Trial_6 +-66.53583633 8.469180528 +-84.51442268 5.821997567 +-63.3397417 6.140918328 +-24.14681274 5.831724285 +-59.70124532 5.546754472 +-88.06537268 5.627487709 +-98.44420454 3.948328976 +-49.15839682 6.490184615 +-73.63622331 5.320618245 +-17.92387468 3.243593041 +-38.41239945 6.444085332 +-66.34036238 3.363778101 +-40.88883874 6.463116811 +-52.46063321 4.730944926 +-52.61046256 7.796578411 +-7.039351051 4.57012783 +-57.23545869 3.926206816 +-71.6422035 5.25434814 +-52.34539617 4.838411107 +-92.78303867 8.694523999 +-47.18716306 4.399531818 +-41.96920846 5.856483905 +-82.14422825 3.621577039 +-59.4391656 8.894912373 +-79.19482259 5.494542836 +-62.29990854 5.968980891 +-65.53790404 6.047899574 +-48.90605545 5.384671397 +-37.74831104 5.381220018 +-78.05333346 5.111574623 +-71.87311766 4.770561105 +-41.89109283 3.098330883 +-53.11545549 1.665083172 +-52.9976281 6.740258533 +-87.08436102 5.594494929 +-43.61190484 6.879630826 +-48.79799841 4.382792466 +-82.56680316 6.410843616 +-47.909963 5.154204318 +-46.57048559 4.015158519 +-54.50048322 4.939148868 +-65.90072713 5.298297314 +-66.87331746 5.490417177 +-75.48080725 2.623751259 +-54.77769387 5.953588662 +-42.59833459 3.301479372 +-74.50816627 4.954889001 +-47.11021844 5.50005367 +-22.35687318 4.45051235 +-84.19298675 5.786624513 +-78.50528476 4.906834424 +-65.03637179 2.629969473 +-66.51373368 3.769703608 +-63.52677656 7.396735716 +-77.80907855 5.764481902 +-68.51017974 2.794585196 +-51.29686931 5.78203327 +-68.33991303 3.485351918 +-38.63173307 6.500653599 +-77.85184859 4.74864071 +}\dataF + +\begin{tikzpicture} + +\definecolor{darkorange}{RGB}{255, 140, 0} +\definecolor{gold}{RGB}{255, 215, 0} +\definecolor{limegreen}{RGB}{50, 205, 50} +\definecolor{magenta}{RGB}{255, 0, 255} +\definecolor{mediumpurple}{RGB}{147, 112, 219} +\definecolor{mediumseagreen}{RGB}{60, 179, 113} + +\begin{polaraxis}[ +title=Hobbs-Pearson Trials +] +\addplot+ [only marks, color=mediumseagreen, mark options={solid, fill=mediumseagreen}, mark size=3.75] table[x=x, y=Trial_1] {\dataA}; +\addplot+ [only marks, color=darkorange, mark options={solid, fill=darkorange}, mark size=5.0] table[x=x, y=Trial_2] {\dataB}; +\addplot+ [only marks, color=mediumpurple, mark options={solid, fill=mediumpurple}, mark size=3.0] table[x=x, y=Trial_3] {\dataC}; +\addplot+ [only marks, color=magenta, mark options={solid, fill=magenta}, mark size=5.5] table[x=x, y=Trial_4] {\dataD}; +\addplot+ [only marks, color=limegreen, mark options={solid, fill=limegreen}, mark size=4.75] table[x=x, y=Trial_5] {\dataE}; +\addplot+ [only marks, color=gold, mark options={solid, fill=gold}, mark size=2.5] table[x=x, y=Trial_6] {\dataF}; +\end{polaraxis} +\end{tikzpicture} diff --git a/tests/test_polar/test_polar_categorical_angular_reference.tex b/tests/test_polar/test_polar_categorical_angular_reference.tex new file mode 100644 index 0000000..2efd21c --- /dev/null +++ b/tests/test_polar/test_polar_categorical_angular_reference.tex @@ -0,0 +1,20 @@ +\pgfplotstableread{ +x angular_categories +0.0 5 +60.0 4 +120.0 2 +180.0 4 +0.0 5 +}\dataA + +\begin{tikzpicture} +\begin{polaraxis}[ +y dir=reverse, +xticklabel style={anchor=0-\tick+180}, +xtick={0.0,60.0,120.0,180.0,240.0,300.0}, +xticklabels={a,b,c,d} +] +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=angular_categories] {\dataA}; +\addlegendentry{angular categories} +\end{polaraxis} +\end{tikzpicture} diff --git a/tests/test_polar/test_polar_categorical_angularcategories_reference.tex b/tests/test_polar/test_polar_categorical_angularcategories_reference.tex new file mode 100644 index 0000000..ab15cfb --- /dev/null +++ b/tests/test_polar/test_polar_categorical_angularcategories_reference.tex @@ -0,0 +1,20 @@ +\pgfplotstableread{ +x angular_categories_(w/_categoryarray) +90.0 5 +270.0 4 +180.0 2 +0.0 4 +90.0 5 +}\dataA + +\begin{tikzpicture} +\begin{polaraxis}[ +xmin=80, +xmax=400, +xtick={0.0,90.0,180.0,270.0}, +xticklabels={d,a,c,b} +] +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=angular_categories_(w/_categoryarray)] {\dataA}; +\addlegendentry{angular categories (w/ categoryarray)} +\end{polaraxis} +\end{tikzpicture} diff --git a/tests/test_polar/test_polar_categorical_radial_reference.tex b/tests/test_polar/test_polar_categorical_radial_reference.tex new file mode 100644 index 0000000..9b8859e --- /dev/null +++ b/tests/test_polar/test_polar_categorical_radial_reference.tex @@ -0,0 +1,20 @@ +\pgfplotstableread{ +x radial_categories +57.29577951308232 a +229.1831180523293 b +114.59155902616465 c +85.94366926962348 d +85.94366926962348 b +343.77467707849394 f +286.4788975654116 a +}\dataA + +\begin{tikzpicture} +\begin{polaraxis}[ +symbolic y coords={a,b,c,d,f}, +ytick=data +] +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=radial_categories] {\dataA}; +\addlegendentry{radial categories} +\end{polaraxis} +\end{tikzpicture} diff --git a/tests/test_polar/test_polar_categorical_radialcategories_reference.tex b/tests/test_polar/test_polar_categorical_radialcategories_reference.tex new file mode 100644 index 0000000..250af8b --- /dev/null +++ b/tests/test_polar/test_polar_categorical_radialcategories_reference.tex @@ -0,0 +1,21 @@ +\pgfplotstableread{ +x radial_categories_(w/_category_descending) +45 a +90 b +180 c +200 d +300 b +15 f +20 a +45 a +}\dataA + +\begin{tikzpicture} +\begin{polaraxis}[ +symbolic y coords={f,d,c,b,a}, +ytick=data +] +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=radial_categories_(w/_category_descending)] {\dataA}; +\addlegendentry{radial categories (w/ category descending)} +\end{polaraxis} +\end{tikzpicture} diff --git a/tests/test_polar/test_polar_radar_1_reference.tex b/tests/test_polar/test_polar_radar_1_reference.tex new file mode 100644 index 0000000..21e76bf --- /dev/null +++ b/tests/test_polar/test_polar_radar_1_reference.tex @@ -0,0 +1,25 @@ +\pgfplotstableread{ +x y0 +0.0 1 +72.0 5 +144.0 2 +216.0 2 +288.0 3 +0.0 1 +}\dataA + +\begin{tikzpicture} + +\definecolor{636efa}{HTML}{636efa} + +\begin{polaraxis}[ +rotate=90, +xticklabel style={anchor=90-\tick+180}, +yticklabel style={anchor=\tick-90-90}, +y dir=reverse, +xtick={0.0,72.0,144.0,216.0,288.0}, +xticklabels={processing cost,mechanical properties,chemical stability,thermal stability,device integration} +] +\addplot+ [no markers, color=636efa] table[x=x, y=y0] {\dataA}; +\end{polaraxis} +\end{tikzpicture} diff --git a/tests/test_polar/test_polar_radar_2_reference.tex b/tests/test_polar/test_polar_radar_2_reference.tex new file mode 100644 index 0000000..4b5e401 --- /dev/null +++ b/tests/test_polar/test_polar_radar_2_reference.tex @@ -0,0 +1,79 @@ +\pgfplotstableread{ +x Figure_8 Cardioid Hypercardioid +0 1.0 1.0 1.0 +6 0.995 0.997 0.996 +12 0.978 0.989 0.984 +18 0.951 0.976 0.963 +24 0.914 0.957 0.935 +30 0.866 0.933 0.9 +36 0.809 0.905 0.857 +42 0.743 0.872 0.807 +48 0.669 0.835 0.752 +54 0.588 0.794 0.691 +60 0.5 0.75 0.625 +66 0.407 0.703 0.555 +72 0.309 0.655 0.482 +78 0.208 0.604 0.406 +84 0.105 0.552 0.328 +90 0.105 0.5 0.25 +96 0.208 0.448 0.172 +102 0.309 0.396 0.094 +108 0.407 0.345 0.018 +114 0.5 0.297 0.055 +120 0.588 0.25 0.125 +126 0.669 0.206 0.191 +132 0.743 0.165 0.252 +138 0.809 0.128 0.307 +144 0.866 0.095 0.357 +150 0.914 0.067 0.4 +156 0.951 0.043 0.435 +162 0.978 0.024 0.463 +168 0.995 0.011 0.484 +174 1.0 0.003 0.496 +180 0.995 0.0 0.5 +186 0.978 0.003 0.496 +192 0.951 0.011 0.484 +198 0.914 0.024 0.463 +204 0.866 0.043 0.435 +210 0.809 0.067 0.4 +216 0.743 0.095 0.357 +222 0.669 0.128 0.307 +228 0.588 0.165 0.252 +234 0.5 0.206 0.191 +240 0.407 0.25 0.125 +246 0.309 0.297 0.055 +252 0.208 0.345 0.018 +258 0.105 0.396 0.094 +264 0.0 0.448 0.172 +270 0.105 0.5 0.25 +276 0.208 0.552 0.328 +282 0.309 0.604 0.406 +288 0.407 0.655 0.482 +294 0.5 0.703 0.555 +300 0.588 0.75 0.625 +306 0.669 0.794 0.691 +312 0.743 0.835 0.752 +318 0.809 0.872 0.807 +324 0.866 0.905 0.857 +330 0.914 0.933 0.9 +336 0.951 0.957 0.935 +342 0.978 0.976 0.963 +348 0.995 0.989 0.984 +354 1.0 0.997 0.996 +360 1.0 1.0 1.0 +}\dataA + +\begin{tikzpicture} + +\definecolor{darkviolet}{RGB}{148, 0, 211} +\definecolor{deepskyblue}{RGB}{0, 191, 255} +\definecolor{peru}{RGB}{205, 133, 63} + +\begin{polaraxis}[ +title=Basic Polar Chart +] +\addplot+ [no markers, color=peru] table[x=x, y=Figure_8] {\dataA}; +\addplot+ [no markers, color=darkviolet, line width=2] table[x=x, y=Cardioid] {\dataA}; +\addplot+ [no markers, color=deepskyblue] table[x=x, y=Hypercardioid] {\dataA}; +\end{polaraxis} +\end{tikzpicture} diff --git a/tests/test_polar/test_polar_radar_range_1_reference.tex b/tests/test_polar/test_polar_radar_range_1_reference.tex new file mode 100644 index 0000000..19c5667 --- /dev/null +++ b/tests/test_polar/test_polar_radar_range_1_reference.tex @@ -0,0 +1,27 @@ +\pgfplotstableread{ +x y0 +0 0 +10 10 +20 20 +30 30 +40 40 +50 50 +60 60 +70 70 +80 80 +}\dataA + +\begin{tikzpicture} + +\definecolor{636efa}{HTML}{636efa} + +\begin{polaraxis}[ +rotate=0, +xticklabel style={anchor=\tick+0+180}, +yticklabel style={anchor=\tick-0-90}, +xmin=0, +xmax=90 +] +\addplot+ [only marks, color=636efa, mark options={solid, fill=636efa}] table[x=x, y=y0] {\dataA}; +\end{polaraxis} +\end{tikzpicture} diff --git a/tests/test_polar/test_polar_radar_range_2_reference.tex b/tests/test_polar/test_polar_radar_range_2_reference.tex new file mode 100644 index 0000000..52eee34 --- /dev/null +++ b/tests/test_polar/test_polar_radar_range_2_reference.tex @@ -0,0 +1,16 @@ +\pgfplotstableread{ +x y0 +0 3 +45 3 +90 4 +270 3 +}\dataA + +\begin{tikzpicture} +\begin{polaraxis}[ +ymin=0, +ymax=6 +] +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=y0] {\dataA}; +\end{polaraxis} +\end{tikzpicture} From 09cea5edc449c1665cf2ade93dfd55e7a3c93b02 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Thu, 7 Aug 2025 22:45:46 +0200 Subject: [PATCH 50/66] improve test_tikzplotly --- tests/test_tikzplotly.py | 7 ++++--- ...document.tex => test_create_document_None.tex} | 0 .../test_create_document_a4paper.tex | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) rename tests/test_tikzplotly/{test_create_document.tex => test_create_document_None.tex} (100%) create mode 100644 tests/test_tikzplotly/test_create_document_a4paper.tex diff --git a/tests/test_tikzplotly.py b/tests/test_tikzplotly.py index 2fa2bd8..dee7f75 100644 --- a/tests/test_tikzplotly.py +++ b/tests/test_tikzplotly.py @@ -18,8 +18,9 @@ def test_tikzplotly(axis_options): else: tikzplotly.save("/tmp/tikzplotly/test_tikzplotly_none.tex", fig) -def test_create_document(): - main_tex_content = tex_create_document(compatibility="newest") +@pytest.mark.parametrize("options", [None, "a4paper"]) +def test_create_document(options): + main_tex_content = tex_create_document(compatibility="newest", options=options) main_tex_content += "\\usepackage{graphicx}\n" main_tex_content += "\n" stack_env = [] @@ -36,4 +37,4 @@ def test_create_document(): with open(main_tex_path, "w") as f: f.write(main_tex_content) - compare_two_files(main_tex_path, os.path.join(this_dir, "test_tikzplotly", "test_create_document.tex")) + compare_two_files(main_tex_path, os.path.join(this_dir, "test_tikzplotly", f"test_create_document_{options}.tex")) diff --git a/tests/test_tikzplotly/test_create_document.tex b/tests/test_tikzplotly/test_create_document_None.tex similarity index 100% rename from tests/test_tikzplotly/test_create_document.tex rename to tests/test_tikzplotly/test_create_document_None.tex diff --git a/tests/test_tikzplotly/test_create_document_a4paper.tex b/tests/test_tikzplotly/test_create_document_a4paper.tex new file mode 100644 index 0000000..25ab1ca --- /dev/null +++ b/tests/test_tikzplotly/test_create_document_a4paper.tex @@ -0,0 +1,15 @@ +\documentclass[a4paper]{article} +\usepackage{pgfplots} +\pgfplotsset{compat=newest} + +\usepackage{graphicx} + +\begin{document} + +\begin{figure} + \includegraphics{example-image-a} + \caption{Test figure} +\end{figure} + + +\end{document} From 27e9b2080c6e8b609711c3c4995566b72a95238e Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Fri, 8 Aug 2025 09:39:00 +0200 Subject: [PATCH 51/66] begin to work on scatter3d tests --- tests/test_scatter3d.py | 61 ++++++ .../test_scatter3d_1_reference.tex | 185 ++++++++++++++++++ .../test_scatter3d_2_reference.tex | 112 +++++++++++ .../test_scatter3d_3_reference.tex | 112 +++++++++++ 4 files changed, 470 insertions(+) create mode 100644 tests/test_scatter3d.py create mode 100644 tests/test_scatter3d/test_scatter3d_1_reference.tex create mode 100644 tests/test_scatter3d/test_scatter3d_2_reference.tex create mode 100644 tests/test_scatter3d/test_scatter3d_3_reference.tex diff --git a/tests/test_scatter3d.py b/tests/test_scatter3d.py new file mode 100644 index 0000000..a16ce17 --- /dev/null +++ b/tests/test_scatter3d.py @@ -0,0 +1,61 @@ +""" +Test of 3D scatter plots https://plotly.com/python/3d-scatter-plots/ +""" +import os, pathlib +import plotly.express as px +import plotly.graph_objects as go +import numpy as np +from .helpers import assert_equality + +this_dir = pathlib.Path(__file__).resolve().parent +test_name = "test_scatter3d" + +def plot_scatter_3d_1(): + df = px.data.iris() + fig = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='species') + return fig + +def plot_scatter_3d_2(): + t = np.linspace(0, 20, 100) + x, y, z = np.cos(t), np.sin(t), t + + fig = go.Figure(data=[go.Scatter3d( + x=x, + y=y, + z=z, + mode='markers', + marker=dict( + size=12, + color=z, # set color to an array/list of desired values + colorscale='Viridis', # choose a colorscale + opacity=0.8 + ) + )]) + fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) + return fig + +def plot_scatter_3d_3(): + t = np.linspace(0, 20, 100) + x, y, z = np.cos(t), np.sin(t), t + + fig = go.Figure(data=[go.Scatter3d( + x=x, + y=y, + z=z, + mode='lines', + line=dict( + color='darkblue', + width=4 + ) + )]) + fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) + return fig + +def test_scatter_3d_1(): + assert_equality(plot_scatter_3d_1(), os.path.join(this_dir, test_name, test_name + "_1_reference.tex")) + +def test_scatter_3d_2(): + assert_equality(plot_scatter_3d_2(), os.path.join(this_dir, test_name, test_name + "_2_reference.tex")) + +def test_scatter_3d_3(): + assert_equality(plot_scatter_3d_3(), os.path.join(this_dir, test_name, test_name + "_3_reference.tex")) diff --git a/tests/test_scatter3d/test_scatter3d_1_reference.tex b/tests/test_scatter3d/test_scatter3d_1_reference.tex new file mode 100644 index 0000000..453a4f4 --- /dev/null +++ b/tests/test_scatter3d/test_scatter3d_1_reference.tex @@ -0,0 +1,185 @@ +\pgfplotstableread{ +x y z +5.1 3.5 0.2 +4.9 3.0 0.2 +4.7 3.2 0.2 +4.6 3.1 0.2 +5.0 3.6 0.2 +5.4 3.9 0.4 +4.6 3.4 0.3 +5.0 3.4 0.2 +4.4 2.9 0.2 +4.9 3.1 0.1 +5.4 3.7 0.2 +4.8 3.4 0.2 +4.8 3.0 0.1 +4.3 3.0 0.1 +5.8 4.0 0.2 +5.7 4.4 0.4 +5.4 3.9 0.4 +5.1 3.5 0.3 +5.7 3.8 0.3 +5.1 3.8 0.3 +5.4 3.4 0.2 +5.1 3.7 0.4 +4.6 3.6 0.2 +5.1 3.3 0.5 +4.8 3.4 0.2 +5.0 3.0 0.2 +5.0 3.4 0.4 +5.2 3.5 0.2 +5.2 3.4 0.2 +4.7 3.2 0.2 +4.8 3.1 0.2 +5.4 3.4 0.4 +5.2 4.1 0.1 +5.5 4.2 0.2 +4.9 3.1 0.1 +5.0 3.2 0.2 +5.5 3.5 0.2 +4.9 3.1 0.1 +4.4 3.0 0.2 +5.1 3.4 0.2 +5.0 3.5 0.3 +4.5 2.3 0.3 +4.4 3.2 0.2 +5.0 3.5 0.6 +5.1 3.8 0.4 +4.8 3.0 0.3 +5.1 3.8 0.2 +4.6 3.2 0.2 +5.3 3.7 0.2 +5.0 3.3 0.2 +}{\setosa} +\pgfplotstableread{ +x y z +7.0 3.2 1.4 +6.4 3.2 1.5 +6.9 3.1 1.5 +5.5 2.3 1.3 +6.5 2.8 1.5 +5.7 2.8 1.3 +6.3 3.3 1.6 +4.9 2.4 1.0 +6.6 2.9 1.3 +5.2 2.7 1.4 +5.0 2.0 1.0 +5.9 3.0 1.5 +6.0 2.2 1.0 +6.1 2.9 1.4 +5.6 2.9 1.3 +6.7 3.1 1.4 +5.6 3.0 1.5 +5.8 2.7 1.0 +6.2 2.2 1.5 +5.6 2.5 1.1 +5.9 3.2 1.8 +6.1 2.8 1.3 +6.3 2.5 1.5 +6.1 2.8 1.2 +6.4 2.9 1.3 +6.6 3.0 1.4 +6.8 2.8 1.4 +6.7 3.0 1.7 +6.0 2.9 1.5 +5.7 2.6 1.0 +5.5 2.4 1.1 +5.5 2.4 1.0 +5.8 2.7 1.2 +6.0 2.7 1.6 +5.4 3.0 1.5 +6.0 3.4 1.6 +6.7 3.1 1.5 +6.3 2.3 1.3 +5.6 3.0 1.3 +5.5 2.5 1.3 +5.5 2.6 1.2 +6.1 3.0 1.4 +5.8 2.6 1.2 +5.0 2.3 1.0 +5.6 2.7 1.3 +5.7 3.0 1.2 +5.7 2.9 1.3 +6.2 2.9 1.3 +5.1 2.5 1.1 +5.7 2.8 1.3 +}{\versicolor} +\pgfplotstableread{ +x y z +6.3 3.3 2.5 +5.8 2.7 1.9 +7.1 3.0 2.1 +6.3 2.9 1.8 +6.5 3.0 2.2 +7.6 3.0 2.1 +4.9 2.5 1.7 +7.3 2.9 1.8 +6.7 2.5 1.8 +7.2 3.6 2.5 +6.5 3.2 2.0 +6.4 2.7 1.9 +6.8 3.0 2.1 +5.7 2.5 2.0 +5.8 2.8 2.4 +6.4 3.2 2.3 +6.5 3.0 1.8 +7.7 3.8 2.2 +7.7 2.6 2.3 +6.0 2.2 1.5 +6.9 3.2 2.3 +5.6 2.8 2.0 +7.7 2.8 2.0 +6.3 2.7 1.8 +6.7 3.3 2.1 +7.2 3.2 1.8 +6.2 2.8 1.8 +6.1 3.0 1.8 +6.4 2.8 2.1 +7.2 3.0 1.6 +7.4 2.8 1.9 +7.9 3.8 2.0 +6.4 2.8 2.2 +6.3 2.8 1.5 +6.1 2.6 1.4 +7.7 3.0 2.3 +6.3 3.4 2.4 +6.4 3.1 1.8 +6.0 3.0 1.8 +6.9 3.1 2.1 +6.7 3.1 2.4 +6.9 3.1 2.3 +5.8 2.7 1.9 +6.8 3.2 2.3 +6.7 3.3 2.5 +6.7 3.0 2.3 +6.3 2.5 1.9 +6.5 3.0 2.0 +6.2 3.4 2.3 +5.9 3.0 1.8 +}{\virginica} + +\begin{tikzpicture} + +\definecolor{00cc96}{HTML}{00cc96} +\definecolor{636efa}{HTML}{636efa} +\definecolor{EF553B}{HTML}{EF553B} + +\begin{axis}[ +xlabel={sepal\_length}, +ylabel={sepal\_width}, +zlabel={petal\_width} +] + +% setosa +\addplot3+ [mark=*, only marks, mark options={solid, fill=636efa}]table[x=x, y=y, z=z] {\setosa}; +\addlegendentry{setosa} + +% versicolor +\addplot3+ [mark=*, only marks, mark options={solid, fill=EF553B}]table[x=x, y=y, z=z] {\versicolor}; +\addlegendentry{versicolor} + +% virginica +\addplot3+ [mark=*, only marks, mark options={solid, fill=00cc96}]table[x=x, y=y, z=z] {\virginica}; +\addlegendentry{virginica} +\end{axis} +\end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_2_reference.tex b/tests/test_scatter3d/test_scatter3d_2_reference.tex new file mode 100644 index 0000000..da82d55 --- /dev/null +++ b/tests/test_scatter3d/test_scatter3d_2_reference.tex @@ -0,0 +1,112 @@ +\pgfplotstableread{ +x y z +1.0 0.0 0.0 +0.9796632259996998 0.2006488565226854 0.20202020202020202 +0.9194800727522776 0.3931366121483298 0.40404040404040403 +0.82189840263017 0.5696341069089657 0.6060606060606061 +0.6908872083770674 0.7229625614794605 0.8080808080808081 +0.5317751800910392 0.8468855636029834 1.0101010101010102 +0.3510339684920502 0.9363627251042848 1.2121212121212122 +0.15601495992575853 0.9877546923600838 1.4141414141414141 +-0.04534973060188524 0.9989711717233568 1.6161616161616161 +-0.24486988668507892 0.9695559491823237 1.8181818181818181 +-0.43443031567828566 0.9007054462029555 2.0202020202020203 +-0.6063209223738354 0.7952200570230491 2.2222222222222223 +-0.7535503059294446 0.6573902466827755 2.4242424242424243 +-0.8701301249459654 0.4928220425889235 2.6262626262626263 +-0.9513186645587279 0.30820901749007684 2.8282828282828283 +-0.993813698804694 0.11106003812412972 3.0303030303030303 +-0.9958868038686729 -0.09060614703340773 3.2323232323232323 +-0.9574536592123347 -0.28858705872043244 3.4343434343434343 +-0.8800774771896732 -0.47483011082223947 3.6363636363636362 +-0.7669054216542901 -0.6417601376193878 3.8383838383838382 +-0.6225406016393301 -0.7825875026542022 4.040404040404041 +-0.45285484658127084 -0.8915842573351402 4.242424242424242 +-0.2647498781834829 -0.9643171169287782 4.444444444444445 +-0.06587659290724678 -0.9978277779792126 4.646464646464646 +0.13567612713271912 -0.9907532430056771 4.848484848484849 +0.33171041770321597 -0.9433812584459996 5.05050505050505 +0.5142528686769626 -0.8576386109880517 5.252525252525253 +0.6758788309121296 -0.7370127583189133 5.454545454545454 +0.810014403075603 -0.586409981847235 5.656565656565657 +0.9112038155344026 -0.4119558308308628 5.858585858585858 +0.9753313358637337 -0.22074597455506334 6.0606060606060606 +0.9997886702873213 -0.020557596287260064 6.262626262626262 +0.983581052239521 0.18046693235991093 6.4646464646464645 +0.9273677030509753 0.37415123057121996 6.666666666666667 +0.8334350190781794 0.5526174707464059 6.8686868686868685 +0.7056035758515253 0.7086067976992182 7.070707070707071 +0.5490727317130796 0.8357745720522589 7.2727272727272725 +0.3702091514654802 0.9289484292312513 7.474747474747475 +0.17628785152548898 0.9843386578838236 7.6767676767676765 +-0.02480370080544784 0.9996923408861117 7.878787878787879 +-0.22488639862108173 0.9743849894755358 8.080808080808081 +-0.41582216870771727 0.9094459434244625 8.282828282828282 +-0.5898449758557073 0.8075165041395626 8.484848484848484 +-0.7398766950653171 0.6727425035622647 8.686868686868687 +-0.859815004003662 0.510605678474283 8.88888888888889 +-0.9447815861050266 0.32770070881349983 9.09090909090909 +-0.9913205490138658 0.13146698864295842 9.292929292929292 +-0.9975389879884077 -0.07011396040064677 9.494949494949495 +-0.9631839770525324 -0.26884312591038406 9.696969696969697 +-0.8896528563926016 -0.45663748763377376 9.8989898989899 +-0.779936397574316 -0.6258587825850161 10.1010101010101 +-0.638497158251875 -0.769624180301191 10.303030303030303 +-0.4710879741150293 -0.8820862319774624 10.505050505050505 +-0.2845179706505102 -0.9586707069567294 10.707070707070708 +-0.08637561184970585 -0.9962626429198221 10.909090909090908 +0.11527994954575044 -0.9933330424549106 11.11111111111111 +0.3122466663798508 -0.9500010628071266 11.313131313131313 +0.4965132034409228 -0.8680291693306353 11.515151515151516 +0.6605847868889071 -0.7507514497694541 11.717171717171716 +0.7977880432989004 -0.6029380050795535 11.919191919191919 +0.9025424294354707 -0.43060093249866344 12.121212121212121 +0.9705872127458185 -0.2407497922206855 12.323232323232324 +0.9991547704697801 -0.04110650371268662 12.525252525252524 +0.9870831586770104 0.1602087321472088 12.727272727272727 +0.9348633726492067 0.3550077104499993 12.929292929292929 +0.8446193763599521 0.5353672656012185 13.131313131313131 +0.7200217133240836 0.6939515345770562 13.333333333333334 +0.5661382125698547 0.8243103325011825 13.535353535353535 +0.38922786205169047 0.9211415045489321 13.737373737373737 +0.19648623340319554 0.9805065833960652 13.93939393939394 +-0.004247187491081489 0.9999909806585335 14.141414141414142 +-0.20480786020107072 0.9788021967690197 14.343434343434343 +-0.3970382705782732 0.9178020547461276 14.545454545454545 +-0.5731197257990347 0.8194716467944692 14.747474747474747 +-0.7258903683424182 0.6878104194817846 14.94949494949495 +-0.8491364741458517 0.5281735020569958 15.15151515151515 +-0.9378451868090543 0.3470538943436452 15.353535353535353 +-0.9884084082494465 0.15181837339991294 15.555555555555555 +-0.9987695528527076 -0.04959213944167377 15.757575757575758 +-0.9685071961064762 -0.24898556401922536 15.959595959595958 +-0.8988522154304799 -0.43825186230718777 16.161616161616163 +-0.7926377260247273 -0.609692902437243 16.363636363636363 +-0.6541838480224215 -0.7563355690343919 16.565656565656564 +-0.48912199187635547 -0.8722153845598611 16.767676767676768 +-0.30416580891556017 -0.9526191057745708 16.96969696969697 +-0.106838123325693 -0.9942764280642703 17.171717171717173 +0.09483504780155239 -0.9954930003312314 17.373737373737374 +0.29265094105990214 -0.9562193402649591 17.575757575757574 +0.47856368221963436 -0.8780528469633162 17.77777777777778 +0.645011540479259 -0.7641728290436485 17.97979797979798 +0.7852244908862596 -0.6192111908811196 18.18181818181818 +0.8934995752719529 -0.4490640366237758 18.383838383838384 +0.9654328617943044 -0.26065185471747443 18.585858585858585 +0.9980985684711091 -0.06163803708687286 18.78787878787879 +0.9901680651138731 0.13988281820384094 18.98989898989899 +0.9419639134315667 0.3357141429738816 19.19191919191919 +0.8554467473014667 0.5178907824351968 19.393939393939394 +0.7341355268330447 0.6790029662980626 19.595959595959595 +0.5829644097750302 0.8124976904186563 19.7979797979798 +0.40808206181339196 0.9129452507276277 20.0 +}{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} + +\begin{tikzpicture} + +\definecolor{blue}{HTML}{0000ff} + +\begin{axis} +\addplot3+ [only marks, mark size=9, mark options={solid, fill=blue, opacity=0.8}]table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\end{axis} +\end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_3_reference.tex b/tests/test_scatter3d/test_scatter3d_3_reference.tex new file mode 100644 index 0000000..06f5ca7 --- /dev/null +++ b/tests/test_scatter3d/test_scatter3d_3_reference.tex @@ -0,0 +1,112 @@ +\pgfplotstableread{ +x y z +1.0 0.0 0.0 +0.9796632259996998 0.2006488565226854 0.20202020202020202 +0.9194800727522776 0.3931366121483298 0.40404040404040403 +0.82189840263017 0.5696341069089657 0.6060606060606061 +0.6908872083770674 0.7229625614794605 0.8080808080808081 +0.5317751800910392 0.8468855636029834 1.0101010101010102 +0.3510339684920502 0.9363627251042848 1.2121212121212122 +0.15601495992575853 0.9877546923600838 1.4141414141414141 +-0.04534973060188524 0.9989711717233568 1.6161616161616161 +-0.24486988668507892 0.9695559491823237 1.8181818181818181 +-0.43443031567828566 0.9007054462029555 2.0202020202020203 +-0.6063209223738354 0.7952200570230491 2.2222222222222223 +-0.7535503059294446 0.6573902466827755 2.4242424242424243 +-0.8701301249459654 0.4928220425889235 2.6262626262626263 +-0.9513186645587279 0.30820901749007684 2.8282828282828283 +-0.993813698804694 0.11106003812412972 3.0303030303030303 +-0.9958868038686729 -0.09060614703340773 3.2323232323232323 +-0.9574536592123347 -0.28858705872043244 3.4343434343434343 +-0.8800774771896732 -0.47483011082223947 3.6363636363636362 +-0.7669054216542901 -0.6417601376193878 3.8383838383838382 +-0.6225406016393301 -0.7825875026542022 4.040404040404041 +-0.45285484658127084 -0.8915842573351402 4.242424242424242 +-0.2647498781834829 -0.9643171169287782 4.444444444444445 +-0.06587659290724678 -0.9978277779792126 4.646464646464646 +0.13567612713271912 -0.9907532430056771 4.848484848484849 +0.33171041770321597 -0.9433812584459996 5.05050505050505 +0.5142528686769626 -0.8576386109880517 5.252525252525253 +0.6758788309121296 -0.7370127583189133 5.454545454545454 +0.810014403075603 -0.586409981847235 5.656565656565657 +0.9112038155344026 -0.4119558308308628 5.858585858585858 +0.9753313358637337 -0.22074597455506334 6.0606060606060606 +0.9997886702873213 -0.020557596287260064 6.262626262626262 +0.983581052239521 0.18046693235991093 6.4646464646464645 +0.9273677030509753 0.37415123057121996 6.666666666666667 +0.8334350190781794 0.5526174707464059 6.8686868686868685 +0.7056035758515253 0.7086067976992182 7.070707070707071 +0.5490727317130796 0.8357745720522589 7.2727272727272725 +0.3702091514654802 0.9289484292312513 7.474747474747475 +0.17628785152548898 0.9843386578838236 7.6767676767676765 +-0.02480370080544784 0.9996923408861117 7.878787878787879 +-0.22488639862108173 0.9743849894755358 8.080808080808081 +-0.41582216870771727 0.9094459434244625 8.282828282828282 +-0.5898449758557073 0.8075165041395626 8.484848484848484 +-0.7398766950653171 0.6727425035622647 8.686868686868687 +-0.859815004003662 0.510605678474283 8.88888888888889 +-0.9447815861050266 0.32770070881349983 9.09090909090909 +-0.9913205490138658 0.13146698864295842 9.292929292929292 +-0.9975389879884077 -0.07011396040064677 9.494949494949495 +-0.9631839770525324 -0.26884312591038406 9.696969696969697 +-0.8896528563926016 -0.45663748763377376 9.8989898989899 +-0.779936397574316 -0.6258587825850161 10.1010101010101 +-0.638497158251875 -0.769624180301191 10.303030303030303 +-0.4710879741150293 -0.8820862319774624 10.505050505050505 +-0.2845179706505102 -0.9586707069567294 10.707070707070708 +-0.08637561184970585 -0.9962626429198221 10.909090909090908 +0.11527994954575044 -0.9933330424549106 11.11111111111111 +0.3122466663798508 -0.9500010628071266 11.313131313131313 +0.4965132034409228 -0.8680291693306353 11.515151515151516 +0.6605847868889071 -0.7507514497694541 11.717171717171716 +0.7977880432989004 -0.6029380050795535 11.919191919191919 +0.9025424294354707 -0.43060093249866344 12.121212121212121 +0.9705872127458185 -0.2407497922206855 12.323232323232324 +0.9991547704697801 -0.04110650371268662 12.525252525252524 +0.9870831586770104 0.1602087321472088 12.727272727272727 +0.9348633726492067 0.3550077104499993 12.929292929292929 +0.8446193763599521 0.5353672656012185 13.131313131313131 +0.7200217133240836 0.6939515345770562 13.333333333333334 +0.5661382125698547 0.8243103325011825 13.535353535353535 +0.38922786205169047 0.9211415045489321 13.737373737373737 +0.19648623340319554 0.9805065833960652 13.93939393939394 +-0.004247187491081489 0.9999909806585335 14.141414141414142 +-0.20480786020107072 0.9788021967690197 14.343434343434343 +-0.3970382705782732 0.9178020547461276 14.545454545454545 +-0.5731197257990347 0.8194716467944692 14.747474747474747 +-0.7258903683424182 0.6878104194817846 14.94949494949495 +-0.8491364741458517 0.5281735020569958 15.15151515151515 +-0.9378451868090543 0.3470538943436452 15.353535353535353 +-0.9884084082494465 0.15181837339991294 15.555555555555555 +-0.9987695528527076 -0.04959213944167377 15.757575757575758 +-0.9685071961064762 -0.24898556401922536 15.959595959595958 +-0.8988522154304799 -0.43825186230718777 16.161616161616163 +-0.7926377260247273 -0.609692902437243 16.363636363636363 +-0.6541838480224215 -0.7563355690343919 16.565656565656564 +-0.48912199187635547 -0.8722153845598611 16.767676767676768 +-0.30416580891556017 -0.9526191057745708 16.96969696969697 +-0.106838123325693 -0.9942764280642703 17.171717171717173 +0.09483504780155239 -0.9954930003312314 17.373737373737374 +0.29265094105990214 -0.9562193402649591 17.575757575757574 +0.47856368221963436 -0.8780528469633162 17.77777777777778 +0.645011540479259 -0.7641728290436485 17.97979797979798 +0.7852244908862596 -0.6192111908811196 18.18181818181818 +0.8934995752719529 -0.4490640366237758 18.383838383838384 +0.9654328617943044 -0.26065185471747443 18.585858585858585 +0.9980985684711091 -0.06163803708687286 18.78787878787879 +0.9901680651138731 0.13988281820384094 18.98989898989899 +0.9419639134315667 0.3357141429738816 19.19191919191919 +0.8554467473014667 0.5178907824351968 19.393939393939394 +0.7341355268330447 0.6790029662980626 19.595959595959595 +0.5829644097750302 0.8124976904186563 19.7979797979798 +0.40808206181339196 0.9129452507276277 20.0 +}{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} + +\begin{tikzpicture} + +\definecolor{darkblue}{RGB}{0, 0, 139} + +\begin{axis} +\addplot3+ [mark=none, line width=3, color=darkblue]table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\end{axis} +\end{tikzpicture} From 83ffaf8fe622d3016d7923984d917cd8db869616 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Fri, 8 Aug 2025 15:30:43 +0200 Subject: [PATCH 52/66] continue to work on test for scatter 3d --- tests/test_scatter3d.py | 50 +++++++- .../test_scatter3d_1_reference.tex | 6 +- .../test_scatter3d_2_lines_reference.tex | 110 ++++++++++++++++++ ...st_scatter3d_2_markers+lines_reference.tex | 110 ++++++++++++++++++ ...=> test_scatter3d_2_markers_reference.tex} | 2 +- .../test_scatter3d_3_reference.tex | 2 +- .../test_scatter3d_empty_reference.tex | 5 + .../test_scatter3d_view_reference.tex | 19 +++ tests/test_specific.py | 8 ++ 9 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 tests/test_scatter3d/test_scatter3d_2_lines_reference.tex create mode 100644 tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex rename tests/test_scatter3d/{test_scatter3d_2_reference.tex => test_scatter3d_2_markers_reference.tex} (98%) create mode 100644 tests/test_scatter3d/test_scatter3d_empty_reference.tex create mode 100644 tests/test_scatter3d/test_scatter3d_view_reference.tex diff --git a/tests/test_scatter3d.py b/tests/test_scatter3d.py index a16ce17..20d554c 100644 --- a/tests/test_scatter3d.py +++ b/tests/test_scatter3d.py @@ -5,6 +5,7 @@ import plotly.express as px import plotly.graph_objects as go import numpy as np +import pytest from .helpers import assert_equality this_dir = pathlib.Path(__file__).resolve().parent @@ -15,7 +16,7 @@ def plot_scatter_3d_1(): fig = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='species') return fig -def plot_scatter_3d_2(): +def plot_scatter_3d_2(mode): t = np.linspace(0, 20, 100) x, y, z = np.cos(t), np.sin(t), t @@ -23,13 +24,13 @@ def plot_scatter_3d_2(): x=x, y=y, z=z, - mode='markers', + mode=mode, marker=dict( size=12, color=z, # set color to an array/list of desired values colorscale='Viridis', # choose a colorscale opacity=0.8 - ) + ), )]) fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) return fig @@ -46,16 +47,53 @@ def plot_scatter_3d_3(): line=dict( color='darkblue', width=4 - ) + ), + showlegend=False )]) fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) return fig +def plot_scatter_3d_view(): + fig = go.Figure(data=[go.Scatter3d( + x=[1, 2, 3], + y=[4, 5, 6], + z=[7, 8, 9], + mode='markers', + marker=dict(size=6, color='red') + )]) + fig.update_layout( + scene=dict( + camera=dict( + eye=dict(x=1.5, y=1.5, z=0.5) + ), + xaxis=dict(showgrid=False), + yaxis=dict(showgrid=False), + zaxis=dict(showgrid=False) + ), + margin=dict(l=0, r=0, b=0, t=0) + ) + return fig + + +def plot_scatter_3d_empty(): + return go.Figure(data=[go.Scatter3d( + x=None, + y=None, + z=None, + )]) + def test_scatter_3d_1(): assert_equality(plot_scatter_3d_1(), os.path.join(this_dir, test_name, test_name + "_1_reference.tex")) -def test_scatter_3d_2(): - assert_equality(plot_scatter_3d_2(), os.path.join(this_dir, test_name, test_name + "_2_reference.tex")) +@pytest.mark.parametrize("mode", ["markers", "markers+lines", "lines"]) +def test_scatter_3d_2(mode): + assert_equality(plot_scatter_3d_2(mode), os.path.join(this_dir, test_name, test_name + f"_2_{mode}_reference.tex")) def test_scatter_3d_3(): assert_equality(plot_scatter_3d_3(), os.path.join(this_dir, test_name, test_name + "_3_reference.tex")) + +def test_scatter_3d_view(): + assert_equality(plot_scatter_3d_view(), os.path.join(this_dir, test_name, test_name + "_view_reference.tex")) + +def test_scatter_3d_empty(): + assert_equality(plot_scatter_3d_empty(), os.path.join(this_dir, test_name, test_name + "_empty_reference.tex")) diff --git a/tests/test_scatter3d/test_scatter3d_1_reference.tex b/tests/test_scatter3d/test_scatter3d_1_reference.tex index 453a4f4..b10e77c 100644 --- a/tests/test_scatter3d/test_scatter3d_1_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_1_reference.tex @@ -171,15 +171,15 @@ ] % setosa -\addplot3+ [mark=*, only marks, mark options={solid, fill=636efa}]table[x=x, y=y, z=z] {\setosa}; +\addplot3+ [mark=*, only marks, mark options={solid, fill=636efa}] table[x=x, y=y, z=z] {\setosa}; \addlegendentry{setosa} % versicolor -\addplot3+ [mark=*, only marks, mark options={solid, fill=EF553B}]table[x=x, y=y, z=z] {\versicolor}; +\addplot3+ [mark=*, only marks, mark options={solid, fill=EF553B}] table[x=x, y=y, z=z] {\versicolor}; \addlegendentry{versicolor} % virginica -\addplot3+ [mark=*, only marks, mark options={solid, fill=00cc96}]table[x=x, y=y, z=z] {\virginica}; +\addplot3+ [mark=*, only marks, mark options={solid, fill=00cc96}] table[x=x, y=y, z=z] {\virginica}; \addlegendentry{virginica} \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex b/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex new file mode 100644 index 0000000..13db63b --- /dev/null +++ b/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex @@ -0,0 +1,110 @@ +\pgfplotstableread{ +x y z +1.0 0.0 0.0 +0.9796632259996998 0.2006488565226854 0.20202020202020202 +0.9194800727522776 0.3931366121483298 0.40404040404040403 +0.82189840263017 0.5696341069089657 0.6060606060606061 +0.6908872083770674 0.7229625614794605 0.8080808080808081 +0.5317751800910392 0.8468855636029834 1.0101010101010102 +0.3510339684920502 0.9363627251042848 1.2121212121212122 +0.15601495992575853 0.9877546923600838 1.4141414141414141 +-0.04534973060188524 0.9989711717233568 1.6161616161616161 +-0.24486988668507892 0.9695559491823237 1.8181818181818181 +-0.43443031567828566 0.9007054462029555 2.0202020202020203 +-0.6063209223738354 0.7952200570230491 2.2222222222222223 +-0.7535503059294446 0.6573902466827755 2.4242424242424243 +-0.8701301249459654 0.4928220425889235 2.6262626262626263 +-0.9513186645587279 0.30820901749007684 2.8282828282828283 +-0.993813698804694 0.11106003812412972 3.0303030303030303 +-0.9958868038686729 -0.09060614703340773 3.2323232323232323 +-0.9574536592123347 -0.28858705872043244 3.4343434343434343 +-0.8800774771896732 -0.47483011082223947 3.6363636363636362 +-0.7669054216542901 -0.6417601376193878 3.8383838383838382 +-0.6225406016393301 -0.7825875026542022 4.040404040404041 +-0.45285484658127084 -0.8915842573351402 4.242424242424242 +-0.2647498781834829 -0.9643171169287782 4.444444444444445 +-0.06587659290724678 -0.9978277779792126 4.646464646464646 +0.13567612713271912 -0.9907532430056771 4.848484848484849 +0.33171041770321597 -0.9433812584459996 5.05050505050505 +0.5142528686769626 -0.8576386109880517 5.252525252525253 +0.6758788309121296 -0.7370127583189133 5.454545454545454 +0.810014403075603 -0.586409981847235 5.656565656565657 +0.9112038155344026 -0.4119558308308628 5.858585858585858 +0.9753313358637337 -0.22074597455506334 6.0606060606060606 +0.9997886702873213 -0.020557596287260064 6.262626262626262 +0.983581052239521 0.18046693235991093 6.4646464646464645 +0.9273677030509753 0.37415123057121996 6.666666666666667 +0.8334350190781794 0.5526174707464059 6.8686868686868685 +0.7056035758515253 0.7086067976992182 7.070707070707071 +0.5490727317130796 0.8357745720522589 7.2727272727272725 +0.3702091514654802 0.9289484292312513 7.474747474747475 +0.17628785152548898 0.9843386578838236 7.6767676767676765 +-0.02480370080544784 0.9996923408861117 7.878787878787879 +-0.22488639862108173 0.9743849894755358 8.080808080808081 +-0.41582216870771727 0.9094459434244625 8.282828282828282 +-0.5898449758557073 0.8075165041395626 8.484848484848484 +-0.7398766950653171 0.6727425035622647 8.686868686868687 +-0.859815004003662 0.510605678474283 8.88888888888889 +-0.9447815861050266 0.32770070881349983 9.09090909090909 +-0.9913205490138658 0.13146698864295842 9.292929292929292 +-0.9975389879884077 -0.07011396040064677 9.494949494949495 +-0.9631839770525324 -0.26884312591038406 9.696969696969697 +-0.8896528563926016 -0.45663748763377376 9.8989898989899 +-0.779936397574316 -0.6258587825850161 10.1010101010101 +-0.638497158251875 -0.769624180301191 10.303030303030303 +-0.4710879741150293 -0.8820862319774624 10.505050505050505 +-0.2845179706505102 -0.9586707069567294 10.707070707070708 +-0.08637561184970585 -0.9962626429198221 10.909090909090908 +0.11527994954575044 -0.9933330424549106 11.11111111111111 +0.3122466663798508 -0.9500010628071266 11.313131313131313 +0.4965132034409228 -0.8680291693306353 11.515151515151516 +0.6605847868889071 -0.7507514497694541 11.717171717171716 +0.7977880432989004 -0.6029380050795535 11.919191919191919 +0.9025424294354707 -0.43060093249866344 12.121212121212121 +0.9705872127458185 -0.2407497922206855 12.323232323232324 +0.9991547704697801 -0.04110650371268662 12.525252525252524 +0.9870831586770104 0.1602087321472088 12.727272727272727 +0.9348633726492067 0.3550077104499993 12.929292929292929 +0.8446193763599521 0.5353672656012185 13.131313131313131 +0.7200217133240836 0.6939515345770562 13.333333333333334 +0.5661382125698547 0.8243103325011825 13.535353535353535 +0.38922786205169047 0.9211415045489321 13.737373737373737 +0.19648623340319554 0.9805065833960652 13.93939393939394 +-0.004247187491081489 0.9999909806585335 14.141414141414142 +-0.20480786020107072 0.9788021967690197 14.343434343434343 +-0.3970382705782732 0.9178020547461276 14.545454545454545 +-0.5731197257990347 0.8194716467944692 14.747474747474747 +-0.7258903683424182 0.6878104194817846 14.94949494949495 +-0.8491364741458517 0.5281735020569958 15.15151515151515 +-0.9378451868090543 0.3470538943436452 15.353535353535353 +-0.9884084082494465 0.15181837339991294 15.555555555555555 +-0.9987695528527076 -0.04959213944167377 15.757575757575758 +-0.9685071961064762 -0.24898556401922536 15.959595959595958 +-0.8988522154304799 -0.43825186230718777 16.161616161616163 +-0.7926377260247273 -0.609692902437243 16.363636363636363 +-0.6541838480224215 -0.7563355690343919 16.565656565656564 +-0.48912199187635547 -0.8722153845598611 16.767676767676768 +-0.30416580891556017 -0.9526191057745708 16.96969696969697 +-0.106838123325693 -0.9942764280642703 17.171717171717173 +0.09483504780155239 -0.9954930003312314 17.373737373737374 +0.29265094105990214 -0.9562193402649591 17.575757575757574 +0.47856368221963436 -0.8780528469633162 17.77777777777778 +0.645011540479259 -0.7641728290436485 17.97979797979798 +0.7852244908862596 -0.6192111908811196 18.18181818181818 +0.8934995752719529 -0.4490640366237758 18.383838383838384 +0.9654328617943044 -0.26065185471747443 18.585858585858585 +0.9980985684711091 -0.06163803708687286 18.78787878787879 +0.9901680651138731 0.13988281820384094 18.98989898989899 +0.9419639134315667 0.3357141429738816 19.19191919191919 +0.8554467473014667 0.5178907824351968 19.393939393939394 +0.7341355268330447 0.6790029662980626 19.595959595959595 +0.5829644097750302 0.8124976904186563 19.7979797979798 +0.40808206181339196 0.9129452507276277 20.0 +}{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} + +\begin{tikzpicture} + +\begin{axis} +\addplot3+ [mark=none] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\end{axis} +\end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex b/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex new file mode 100644 index 0000000..e86503b --- /dev/null +++ b/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex @@ -0,0 +1,110 @@ +\pgfplotstableread{ +x y z +1.0 0.0 0.0 +0.9796632259996998 0.2006488565226854 0.20202020202020202 +0.9194800727522776 0.3931366121483298 0.40404040404040403 +0.82189840263017 0.5696341069089657 0.6060606060606061 +0.6908872083770674 0.7229625614794605 0.8080808080808081 +0.5317751800910392 0.8468855636029834 1.0101010101010102 +0.3510339684920502 0.9363627251042848 1.2121212121212122 +0.15601495992575853 0.9877546923600838 1.4141414141414141 +-0.04534973060188524 0.9989711717233568 1.6161616161616161 +-0.24486988668507892 0.9695559491823237 1.8181818181818181 +-0.43443031567828566 0.9007054462029555 2.0202020202020203 +-0.6063209223738354 0.7952200570230491 2.2222222222222223 +-0.7535503059294446 0.6573902466827755 2.4242424242424243 +-0.8701301249459654 0.4928220425889235 2.6262626262626263 +-0.9513186645587279 0.30820901749007684 2.8282828282828283 +-0.993813698804694 0.11106003812412972 3.0303030303030303 +-0.9958868038686729 -0.09060614703340773 3.2323232323232323 +-0.9574536592123347 -0.28858705872043244 3.4343434343434343 +-0.8800774771896732 -0.47483011082223947 3.6363636363636362 +-0.7669054216542901 -0.6417601376193878 3.8383838383838382 +-0.6225406016393301 -0.7825875026542022 4.040404040404041 +-0.45285484658127084 -0.8915842573351402 4.242424242424242 +-0.2647498781834829 -0.9643171169287782 4.444444444444445 +-0.06587659290724678 -0.9978277779792126 4.646464646464646 +0.13567612713271912 -0.9907532430056771 4.848484848484849 +0.33171041770321597 -0.9433812584459996 5.05050505050505 +0.5142528686769626 -0.8576386109880517 5.252525252525253 +0.6758788309121296 -0.7370127583189133 5.454545454545454 +0.810014403075603 -0.586409981847235 5.656565656565657 +0.9112038155344026 -0.4119558308308628 5.858585858585858 +0.9753313358637337 -0.22074597455506334 6.0606060606060606 +0.9997886702873213 -0.020557596287260064 6.262626262626262 +0.983581052239521 0.18046693235991093 6.4646464646464645 +0.9273677030509753 0.37415123057121996 6.666666666666667 +0.8334350190781794 0.5526174707464059 6.8686868686868685 +0.7056035758515253 0.7086067976992182 7.070707070707071 +0.5490727317130796 0.8357745720522589 7.2727272727272725 +0.3702091514654802 0.9289484292312513 7.474747474747475 +0.17628785152548898 0.9843386578838236 7.6767676767676765 +-0.02480370080544784 0.9996923408861117 7.878787878787879 +-0.22488639862108173 0.9743849894755358 8.080808080808081 +-0.41582216870771727 0.9094459434244625 8.282828282828282 +-0.5898449758557073 0.8075165041395626 8.484848484848484 +-0.7398766950653171 0.6727425035622647 8.686868686868687 +-0.859815004003662 0.510605678474283 8.88888888888889 +-0.9447815861050266 0.32770070881349983 9.09090909090909 +-0.9913205490138658 0.13146698864295842 9.292929292929292 +-0.9975389879884077 -0.07011396040064677 9.494949494949495 +-0.9631839770525324 -0.26884312591038406 9.696969696969697 +-0.8896528563926016 -0.45663748763377376 9.8989898989899 +-0.779936397574316 -0.6258587825850161 10.1010101010101 +-0.638497158251875 -0.769624180301191 10.303030303030303 +-0.4710879741150293 -0.8820862319774624 10.505050505050505 +-0.2845179706505102 -0.9586707069567294 10.707070707070708 +-0.08637561184970585 -0.9962626429198221 10.909090909090908 +0.11527994954575044 -0.9933330424549106 11.11111111111111 +0.3122466663798508 -0.9500010628071266 11.313131313131313 +0.4965132034409228 -0.8680291693306353 11.515151515151516 +0.6605847868889071 -0.7507514497694541 11.717171717171716 +0.7977880432989004 -0.6029380050795535 11.919191919191919 +0.9025424294354707 -0.43060093249866344 12.121212121212121 +0.9705872127458185 -0.2407497922206855 12.323232323232324 +0.9991547704697801 -0.04110650371268662 12.525252525252524 +0.9870831586770104 0.1602087321472088 12.727272727272727 +0.9348633726492067 0.3550077104499993 12.929292929292929 +0.8446193763599521 0.5353672656012185 13.131313131313131 +0.7200217133240836 0.6939515345770562 13.333333333333334 +0.5661382125698547 0.8243103325011825 13.535353535353535 +0.38922786205169047 0.9211415045489321 13.737373737373737 +0.19648623340319554 0.9805065833960652 13.93939393939394 +-0.004247187491081489 0.9999909806585335 14.141414141414142 +-0.20480786020107072 0.9788021967690197 14.343434343434343 +-0.3970382705782732 0.9178020547461276 14.545454545454545 +-0.5731197257990347 0.8194716467944692 14.747474747474747 +-0.7258903683424182 0.6878104194817846 14.94949494949495 +-0.8491364741458517 0.5281735020569958 15.15151515151515 +-0.9378451868090543 0.3470538943436452 15.353535353535353 +-0.9884084082494465 0.15181837339991294 15.555555555555555 +-0.9987695528527076 -0.04959213944167377 15.757575757575758 +-0.9685071961064762 -0.24898556401922536 15.959595959595958 +-0.8988522154304799 -0.43825186230718777 16.161616161616163 +-0.7926377260247273 -0.609692902437243 16.363636363636363 +-0.6541838480224215 -0.7563355690343919 16.565656565656564 +-0.48912199187635547 -0.8722153845598611 16.767676767676768 +-0.30416580891556017 -0.9526191057745708 16.96969696969697 +-0.106838123325693 -0.9942764280642703 17.171717171717173 +0.09483504780155239 -0.9954930003312314 17.373737373737374 +0.29265094105990214 -0.9562193402649591 17.575757575757574 +0.47856368221963436 -0.8780528469633162 17.77777777777778 +0.645011540479259 -0.7641728290436485 17.97979797979798 +0.7852244908862596 -0.6192111908811196 18.18181818181818 +0.8934995752719529 -0.4490640366237758 18.383838383838384 +0.9654328617943044 -0.26065185471747443 18.585858585858585 +0.9980985684711091 -0.06163803708687286 18.78787878787879 +0.9901680651138731 0.13988281820384094 18.98989898989899 +0.9419639134315667 0.3357141429738816 19.19191919191919 +0.8554467473014667 0.5178907824351968 19.393939393939394 +0.7341355268330447 0.6790029662980626 19.595959595959595 +0.5829644097750302 0.8124976904186563 19.7979797979798 +0.40808206181339196 0.9129452507276277 20.0 +}{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} + +\begin{tikzpicture} + +\begin{axis} +\addplot3+ table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\end{axis} +\end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_2_reference.tex b/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex similarity index 98% rename from tests/test_scatter3d/test_scatter3d_2_reference.tex rename to tests/test_scatter3d/test_scatter3d_2_markers_reference.tex index da82d55..926532b 100644 --- a/tests/test_scatter3d/test_scatter3d_2_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex @@ -107,6 +107,6 @@ \definecolor{blue}{HTML}{0000ff} \begin{axis} -\addplot3+ [only marks, mark size=9, mark options={solid, fill=blue, opacity=0.8}]table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\addplot3+ [only marks, mark size=9, mark options={solid, fill=blue, opacity=0.8}] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_3_reference.tex b/tests/test_scatter3d/test_scatter3d_3_reference.tex index 06f5ca7..c59bab7 100644 --- a/tests/test_scatter3d/test_scatter3d_3_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_3_reference.tex @@ -107,6 +107,6 @@ \definecolor{darkblue}{RGB}{0, 0, 139} \begin{axis} -\addplot3+ [mark=none, line width=3, color=darkblue]table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\addplot3+ [mark=none, line width=3, color=darkblue, forget plot] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_empty_reference.tex b/tests/test_scatter3d/test_scatter3d_empty_reference.tex new file mode 100644 index 0000000..811f72d --- /dev/null +++ b/tests/test_scatter3d/test_scatter3d_empty_reference.tex @@ -0,0 +1,5 @@ +\begin{tikzpicture} +\begin{axis} +\addplot3 coordinates {}; +\end{axis} +\end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_view_reference.tex b/tests/test_scatter3d/test_scatter3d_view_reference.tex new file mode 100644 index 0000000..0823227 --- /dev/null +++ b/tests/test_scatter3d/test_scatter3d_view_reference.tex @@ -0,0 +1,19 @@ +\pgfplotstableread{ +x y z +1 4 7 +2 5 8 +3 6 9 +}{\dataIEJOMDLLMKDNMPIGNPBKAFCKOIAKBFCN} + +\begin{tikzpicture} + + +\begin{axis}[ +view={45.0}{13.3}, +xmajorgrids=false, +ymajorgrids=false, +zmajorgrids=false +] +\addplot3+ [only marks, mark size=4.5, mark options={solid, fill=red}] table[x=x, y=y, z=z] {\dataIEJOMDLLMKDNMPIGNPBKAFCKOIAKBFCN}; +\end{axis} +\end{tikzpicture} diff --git a/tests/test_specific.py b/tests/test_specific.py index 9dade9d..f638320 100644 --- a/tests/test_specific.py +++ b/tests/test_specific.py @@ -36,9 +36,17 @@ def plot_transparent_background(): return fig +def plot_empty_figure(): + fig = go.Figure() + fig.show() + return fig + def test_sanitized_text(): assert_equality(plot_sanitized_text(), os.path.join(this_dir, test_name, test_name + "_sanitized_text_reference.tex")) def test_transparent_background(): assert_equality(plot_transparent_background(), os.path.join(this_dir, test_name, test_name + "_transparent_background_reference.tex")) + +def test_empty_figure(): + assert_equality(plot_empty_figure(), os.path.join(this_dir, "empty_plot.tex")) From 9e6c6140081a417c1e9b668eec52018569f02d3e Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Wed, 20 Aug 2025 10:28:19 +0200 Subject: [PATCH 53/66] remove test of instance [ci skip] --- src/tikzplotly/_dataContainer.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index 12a66ca..ce4f53e 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -5,6 +5,26 @@ from ._data import treat_data, post_treat_data def hexid_to_alpha(num): + """ + Converts a hexadecimal string or integer to an alphabetic representation using the letters A-P. + Each hexadecimal digit (0-15) is mapped to a corresponding uppercase letter (A-P). + + Parameters + ---------- + num : int or str + The hexadecimal number to convert. Can be an integer or a string containing hexadecimal digits. + + Returns + ------- + Alphabetic representation of the hexadecimal input, where each digit is replaced by a letter from A to P. + + Examples + -------- + >>> hexid_to_alpha(255) + 'PP' + >>> hexid_to_alpha('1a3') + 'ABD' + """ hexstr = str(num) table = "ABCDEFGHIJKLMNOP" return ''.join(table[int(c, 16)] for c in hexstr if c in "0123456789abcdef") @@ -93,8 +113,6 @@ def add_data(self, x, y, name=None, y_label=None): tuple (macro_name, y_label), where macro_name is the name of the data in LaTeX and y_label the name of the y data in LaTeX """ for data in self.data: - if isinstance(x, (tuple, list)): - continue if len(data.x) != len(x): continue are_equals = data.x == x From 060066f8370807b4448cbc0bbdd8899c87858c7c Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Sat, 23 Aug 2025 14:59:58 +0200 Subject: [PATCH 54/66] enhance Data3D initialization to generate a unique name using MD5 hash if not provided --- src/tikzplotly/_dataContainer.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index ce4f53e..ef2d5cd 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -1,6 +1,7 @@ """ Contain the code to handle data in TikZ plots. """ +import hashlib from ._utils import replace_all_digits, sanitize_text from ._data import treat_data, post_treat_data @@ -84,7 +85,12 @@ def __init__(self, x, y, z, name): self.x = list(x) self.y = list(y) self.z = list(z) - self.name = sanitize_text(name, keep_space=False) if name else f"data{hexid_to_alpha(id(self))}" + if name: + self.name = sanitize_text(name, keep_space=False) + else: + hash_input = str(self.x) + str(self.y) + str(self.z) + hash_digest = hashlib.md5(hash_input.encode()).hexdigest() + self.name = f"data{hexid_to_alpha(hash_digest)}" self.z_name = "z" class DataContainer: From 71470230aeaf7e9f792e89c29f7e7bde85d6befb Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Mon, 25 Aug 2025 17:06:04 +0200 Subject: [PATCH 55/66] fix failing tests regarding empty plots [ci skip] --- src/tikzplotly/_heatmap.py | 2 +- src/tikzplotly/_save.py | 5 +++++ tests/test_scatter3d/test_scatter3d_2_lines_reference.tex | 1 - .../test_scatter3d_2_markers+lines_reference.tex | 1 - 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/tikzplotly/_heatmap.py b/src/tikzplotly/_heatmap.py index 64e0680..5ce2fe3 100755 --- a/src/tikzplotly/_heatmap.py +++ b/src/tikzplotly/_heatmap.py @@ -104,7 +104,7 @@ def draw_heatmap(data, fig, img_name, axis: Axis): img_bytes = fig_copy.to_image(format="png") # The image created by plotly keeps places around the heatmap cropped_image = crop_image(Image.open(io.BytesIO(img_bytes))) # so we crop all the white around the figure resized_image = resize_image(cropped_image, *figure_data.shape) # and we resize it so each square is a 1px x 1px square - + os.makedirs(os.path.dirname(img_name), exist_ok=True) # Make sure the directory exists resized_image.save(img_name) diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 425af8c..09a0ff3 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -72,6 +72,11 @@ def get_tikz_code( data_str.append( "\\addplot coordinates {};\n" ) continue + if trace.x is None: + trace.x = list(range(len(trace.y))) + if trace.y is None: + trace.y = list(range(len(trace.x))) + data_name_macro, y_name = data_container.add_data(trace.x, trace.y, trace.name) # If x is textual => symbolic x coords diff --git a/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex b/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex index 13db63b..7ea951c 100644 --- a/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex @@ -103,7 +103,6 @@ }{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} \begin{tikzpicture} - \begin{axis} \addplot3+ [mark=none] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; \end{axis} diff --git a/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex b/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex index e86503b..0144e88 100644 --- a/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex @@ -103,7 +103,6 @@ }{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} \begin{tikzpicture} - \begin{axis} \addplot3+ table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; \end{axis} From 8700a6af4a8f2208b78addb5f2260fa4f68b2777 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Mon, 25 Aug 2025 17:09:00 +0200 Subject: [PATCH 56/66] fix raisong of unnecessary warnings --- src/tikzplotly/_axis.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/tikzplotly/_axis.py b/src/tikzplotly/_axis.py index efa52dc..499ceee 100644 --- a/src/tikzplotly/_axis.py +++ b/src/tikzplotly/_axis.py @@ -158,13 +158,14 @@ def treat_axis_layout(self): self.add_option("xtick", ticks) self.add_option("xticklabels", ticklabels) - if ( - self.layout.xaxis.categoryorder != "trace" - or self.layout.yaxis.categoryorder != "trace" - or self.layout.xaxis.categoryorder != "total descending" - ): + # At this point, only layout.xaxis.categoryarray = "array" is supported + if self.layout.xaxis.categoryorder is not None and self.layout.xaxis.categoryorder not in ["array"]: warn( - "The categoryorder option is not supported (yet 🤞) for the axis environment." + f"The xaxis categoryorder option {self.layout.xaxis.categoryorder} is not supported (yet 🤞) for the axis environment." + ) + if self.layout.yaxis.categoryorder is not None and self.layout.yaxis.categoryorder not in []: + warn( + f"The yaxis categoryorder option {self.layout.yaxis.categoryorder} is not supported (yet 🤞) for the axis environment." ) if self.layout.xaxis.showgrid: From bcd047d52a9dfe187ab7b48f05b4b6b1369892ca Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Mon, 25 Aug 2025 18:02:21 +0200 Subject: [PATCH 57/66] improve code coverage for scatter3d --- src/tikzplotly/_scatter3d.py | 6 ++---- tests/test_scatter3d.py | 3 ++- .../test_scatter3d_2_markers+lines_reference.tex | 2 +- tests/test_scatter3d/test_scatter3d_2_markers_reference.tex | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/tikzplotly/_scatter3d.py b/src/tikzplotly/_scatter3d.py index b2fb820..44863b6 100644 --- a/src/tikzplotly/_scatter3d.py +++ b/src/tikzplotly/_scatter3d.py @@ -86,12 +86,10 @@ def draw_scatter3d(data_name, scatter, z_name, axis: Axis, color_set): options = option_dict_to_str(options_dict) if scatter.name: code += f"\n% {scatter.name}\n" - + code += f"\\addplot3+ " if options is not None: - code += f"[{options}]" - else: - code += "[only marks]" + code += f"[{options}] " code += f"table[x=x, y=y, z=z] {{\\{data_name}}};\n" return code diff --git a/tests/test_scatter3d.py b/tests/test_scatter3d.py index 20d554c..55f4349 100644 --- a/tests/test_scatter3d.py +++ b/tests/test_scatter3d.py @@ -29,7 +29,8 @@ def plot_scatter_3d_2(mode): size=12, color=z, # set color to an array/list of desired values colorscale='Viridis', # choose a colorscale - opacity=0.8 + opacity=0.8, + symbol="diamond" ), )]) fig.update_layout(margin=dict(l=0, r=0, b=0, t=0)) diff --git a/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex b/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex index 0144e88..f5b64ed 100644 --- a/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex @@ -104,6 +104,6 @@ \begin{tikzpicture} \begin{axis} -\addplot3+ table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\addplot3+ [mark=diamond*] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex b/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex index 926532b..8d1893c 100644 --- a/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex @@ -107,6 +107,6 @@ \definecolor{blue}{HTML}{0000ff} \begin{axis} -\addplot3+ [only marks, mark size=9, mark options={solid, fill=blue, opacity=0.8}] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\addplot3+ [mark=diamond*, only marks, mark size=9, mark options={solid, fill=blue, opacity=0.8}] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; \end{axis} \end{tikzpicture} From 3af6bfa0ae5042c9aa9673bfcf39fd7330004600 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Sun, 12 Oct 2025 12:36:08 +0200 Subject: [PATCH 58/66] refactor: update sanitize_text function to use integer for keep_space parameter and adjust related usages #33 --- src/tests/test_line_charts.py | 2 +- src/tikzplotly/_data.py | 3 +-- src/tikzplotly/_dataContainer.py | 2 +- src/tikzplotly/_save.py | 8 +++++--- src/tikzplotly/_scatter.py | 6 ++++-- src/tikzplotly/_utils.py | 25 +++++++++++++++---------- 6 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/tests/test_line_charts.py b/src/tests/test_line_charts.py index 70f21e7..05f0f82 100644 --- a/src/tests/test_line_charts.py +++ b/src/tests/test_line_charts.py @@ -396,7 +396,7 @@ def fig18(): def fig19(): df = pd.DataFrame(dict( - x = ["A", "B", "C", "D"], + x = ["A et B", "B", "C", "D"], y = [1, 2, 3, 4], )) fig = px.line(df, x="x", y="y", title="symbolic x coords") diff --git a/src/tikzplotly/_data.py b/src/tikzplotly/_data.py index 3686e65..b3a310b 100644 --- a/src/tikzplotly/_data.py +++ b/src/tikzplotly/_data.py @@ -28,12 +28,11 @@ def data_type(data): 'july', 'august', 'september', 'october', 'november', 'december']: warn(f"Assuming data {data} is a month. This feature is experimental.") return 'month' - warn(f"Data type of {data} is not supported yet.") return None return None def treat_data(data_str): - data_str = sanitize_text(str(data_str)) + data_str = sanitize_text(str(data_str), keep_space=-1) if data_str.find(' ') !=- 1: # Add curly braces if space in string if not data_str.startswith("{") and not data_str.startswith("}"): data_str = "{" + data_str + "}" diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index ef2d5cd..e04d136 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -86,7 +86,7 @@ def __init__(self, x, y, z, name): self.y = list(y) self.z = list(z) if name: - self.name = sanitize_text(name, keep_space=False) + self.name = sanitize_text(name, keep_space=0) else: hash_input = str(self.x) + str(self.y) + str(self.z) hash_digest = hashlib.md5(hash_input.encode()).hexdigest() diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 09a0ff3..0473dd5 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -19,7 +19,7 @@ from ._color import convert_color from ._annotations import str_from_annotation from ._dataContainer import DataContainer -from ._utils import sanitize_tex_text +from ._utils import sanitize_tex_text, sanitize_text from warnings import warn from collections import defaultdict import numpy as np @@ -81,12 +81,14 @@ def get_tikz_code( # If x is textual => symbolic x coords if all(isinstance(v, str) for v in trace.x): - axis.add_option("symbolic x coords", "{" + ",".join(trace.x) + "}") + sanitized_trace_x = [sanitize_text(x, keep_space=-1) for x in trace.x] + axis.add_option("symbolic x coords", "{" + ",".join(sanitized_trace_x) + "}") axis.add_option("xtick", "data") # If y is textual => symbolic y coords if all(isinstance(v, str) for v in trace.y): - axis.add_option("symbolic y coords", "{" + ",".join(trace.y) + "}") + sanitized_trace_y = [sanitize_text(y, keep_space=-1) for y in trace.y] + axis.add_option("symbolic y coords", "{" + ",".join(sanitized_trace_y) + "}") axis.add_option("ytick", "data") data_str.append( draw_scatter2d(data_name_macro, trace, y_name, axis, colors_set) ) diff --git a/src/tikzplotly/_scatter.py b/src/tikzplotly/_scatter.py index f387e68..90c1d4d 100644 --- a/src/tikzplotly/_scatter.py +++ b/src/tikzplotly/_scatter.py @@ -37,10 +37,12 @@ def draw_scatter2d(data_name, scatter, y_name, axis: Axis, color_set): mode = scatter.mode marker = scatter.marker - if data_type(scatter.x[0]) == "date": + type_of_data = data_type(scatter.x[0]) + + if type_of_data == "date": axis.add_option("date coordinates in", "x") - if data_type(scatter.x[0]) == "month": + if type_of_data == "month": scatter_x_str = "{" + ", ".join(list(scatter.x)) + "}" axis.add_option("xticklabels", scatter_x_str) diff --git a/src/tikzplotly/_utils.py b/src/tikzplotly/_utils.py index 941ded5..36a7472 100644 --- a/src/tikzplotly/_utils.py +++ b/src/tikzplotly/_utils.py @@ -52,7 +52,7 @@ def replace_all_months(text): """ return pattern_months.sub(lambda m: rep_months[re.escape(m.group(0))], text) -def sanitize_text(text: str, keep_space: bool = False) -> str: +def sanitize_text(text: str, keep_space: int = 0) -> str: """ Sanitize the input text by removing or replacing unwanted characters. @@ -60,9 +60,10 @@ def sanitize_text(text: str, keep_space: bool = False) -> str: ---------- text : str The input text to be sanitized. - keep_space : bool, optional - If True, spaces will be preserved in the sanitized text. - If False, spaces will be replaced with underscores. Defaults to False. + keep_space : int, optional + If 1, spaces will be preserved in the sanitized text. + If 0, spaces will be replaced with underscores. Defaults to 0. + If -1, spaces will be deleted from the text Returns ------- @@ -71,7 +72,7 @@ def sanitize_text(text: str, keep_space: bool = False) -> str: """ return ''.join(sanitize_char(ch, keep_space) for ch in text) -def sanitize_char(ch: str, keep_space: bool = False) -> str: +def sanitize_char(ch: str, keep_space: int = 0) -> str: """ Sanitize a character by escaping special characters or converting to hex if non-ASCII/non-printable. @@ -79,20 +80,24 @@ def sanitize_char(ch: str, keep_space: bool = False) -> str: ---------- ch : str The character to sanitize. - keep_space : bool, optional - If True, spaces will be kept as is. Defaults to False. + keep_space : int, optional + If 1, spaces will be preserved in the sanitized text. + If 0, spaces will be replaced with underscores. Defaults to 0. + If -1, spaces will be deleted from the text Returns ------- str The sanitized character. """ - if ch == "_": - return "" if ch == "@": return "at" if ch == " ": - return " " if keep_space else "_" + if keep_space == 1: + return " " + if keep_space == 0: + return "_" + return "" if ch in "[]{}= ": return f"x{ord(ch):x}" # if not ascii, return hex if ord(ch) > 127: return f"x{ord(ch):x}" From d436878bb8e25ea3fb93c318bca7d4a6b99ab283 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Sun, 12 Oct 2025 12:36:48 +0200 Subject: [PATCH 59/66] add remark in doc #33 --- docs/plot/NB.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plot/NB.md b/docs/plot/NB.md index 1720774..272d631 100644 --- a/docs/plot/NB.md +++ b/docs/plot/NB.md @@ -6,3 +6,4 @@ We gather here some points that are different between a plotly figure and the co * By default, the colors or the markers are not the same in plotly and pgfplots. For instance, if nothing is specified, plotly will always use a dot marker, while pgfplot will change for each trace. * The order of displaying the traces may be unconsistent between plotly and pgfplots. For instance, for [this example](https://plotly.com/python/histograms/#several-histograms-for-the-different-values-of-one-column), the two traces are inverted. * The angle of rotation is different between Plotly and Ti*k*Z, but the function Plotly ↦ Ti*k*Z is not know at this current point. +* When tricky names are used in symbolic expression (such as names with a space within), the space is removed by tisk plotly (*e.g.* the text `United Kingdom` in Plotly will be exported as `UnitedKindgon` in Ti*k*Z), fill free to update the exported file to render the figure you wish! \ No newline at end of file From 564ca87f39495c3e3f82ab8629b296c953f762d8 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Sun, 12 Oct 2025 12:40:56 +0200 Subject: [PATCH 60/66] fix tests #33 --- tests/test_polar/test_polar_1_reference.tex | 24 +++++++++---------- ...st_polar_categorical_angular_reference.tex | 4 ++-- ...ategorical_angularcategories_reference.tex | 4 ++-- ...est_polar_categorical_radial_reference.tex | 4 ++-- ...categorical_radialcategories_reference.tex | 4 ++-- .../test_polar_radar_2_reference.tex | 4 ++-- .../test_scatter_10_reference.tex | 4 ++-- .../test_scatter/test_scatter_1_reference.tex | 4 ++-- .../test_scatter/test_scatter_7_reference.tex | 14 +++++------ 9 files changed, 33 insertions(+), 33 deletions(-) diff --git a/tests/test_polar/test_polar_1_reference.tex b/tests/test_polar/test_polar_1_reference.tex index f8bb42d..526841e 100644 --- a/tests/test_polar/test_polar_1_reference.tex +++ b/tests/test_polar/test_polar_1_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x Trial_1 +x Trial1 -30.35294436 6.804985785 -25.61145985 3.389596011 -12.42522745 5.381472111 @@ -62,7 +62,7 @@ -5.097911612 3.513202145 }\dataA \pgfplotstableread{ -x Trial_2 +x Trial2 14.80662578 3.488043923 79.00634037 2.918478576 49.02206554 4.20182736 @@ -125,7 +125,7 @@ 76.03333589 4.140355075 }\dataB \pgfplotstableread{ -x Trial_3 +x Trial3 151.2942552 1.855870835 147.188025 5.286962062 125.2821571 3.886013392 @@ -188,7 +188,7 @@ 134.8767011 5.670052051 }\dataC \pgfplotstableread{ -x Trial_4 +x Trial4 -140.2033276 5.372470924 -168.0842454 7.096355572 -166.2851413 4.883823903 @@ -251,7 +251,7 @@ -177.2506567 6.508186348 }\dataD \pgfplotstableread{ -x Trial_5 +x Trial5 -101.8337858 7.937557871 -127.4783916 7.302746492 -112.244285 5.929302221 @@ -314,7 +314,7 @@ -130.7544674 6.128338984 }\dataE \pgfplotstableread{ -x Trial_6 +x Trial6 -66.53583633 8.469180528 -84.51442268 5.821997567 -63.3397417 6.140918328 @@ -389,11 +389,11 @@ \begin{polaraxis}[ title=Hobbs-Pearson Trials ] -\addplot+ [only marks, color=mediumseagreen, mark options={solid, fill=mediumseagreen}, mark size=3.75] table[x=x, y=Trial_1] {\dataA}; -\addplot+ [only marks, color=darkorange, mark options={solid, fill=darkorange}, mark size=5.0] table[x=x, y=Trial_2] {\dataB}; -\addplot+ [only marks, color=mediumpurple, mark options={solid, fill=mediumpurple}, mark size=3.0] table[x=x, y=Trial_3] {\dataC}; -\addplot+ [only marks, color=magenta, mark options={solid, fill=magenta}, mark size=5.5] table[x=x, y=Trial_4] {\dataD}; -\addplot+ [only marks, color=limegreen, mark options={solid, fill=limegreen}, mark size=4.75] table[x=x, y=Trial_5] {\dataE}; -\addplot+ [only marks, color=gold, mark options={solid, fill=gold}, mark size=2.5] table[x=x, y=Trial_6] {\dataF}; +\addplot+ [only marks, color=mediumseagreen, mark options={solid, fill=mediumseagreen}, mark size=3.75] table[x=x, y=Trial1] {\dataA}; +\addplot+ [only marks, color=darkorange, mark options={solid, fill=darkorange}, mark size=5.0] table[x=x, y=Trial2] {\dataB}; +\addplot+ [only marks, color=mediumpurple, mark options={solid, fill=mediumpurple}, mark size=3.0] table[x=x, y=Trial3] {\dataC}; +\addplot+ [only marks, color=magenta, mark options={solid, fill=magenta}, mark size=5.5] table[x=x, y=Trial4] {\dataD}; +\addplot+ [only marks, color=limegreen, mark options={solid, fill=limegreen}, mark size=4.75] table[x=x, y=Trial5] {\dataE}; +\addplot+ [only marks, color=gold, mark options={solid, fill=gold}, mark size=2.5] table[x=x, y=Trial6] {\dataF}; \end{polaraxis} \end{tikzpicture} diff --git a/tests/test_polar/test_polar_categorical_angular_reference.tex b/tests/test_polar/test_polar_categorical_angular_reference.tex index 2efd21c..4bbcedb 100644 --- a/tests/test_polar/test_polar_categorical_angular_reference.tex +++ b/tests/test_polar/test_polar_categorical_angular_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x angular_categories +x angularcategories 0.0 5 60.0 4 120.0 2 @@ -14,7 +14,7 @@ xtick={0.0,60.0,120.0,180.0,240.0,300.0}, xticklabels={a,b,c,d} ] -\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=angular_categories] {\dataA}; +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=angularcategories] {\dataA}; \addlegendentry{angular categories} \end{polaraxis} \end{tikzpicture} diff --git a/tests/test_polar/test_polar_categorical_angularcategories_reference.tex b/tests/test_polar/test_polar_categorical_angularcategories_reference.tex index ab15cfb..61e242a 100644 --- a/tests/test_polar/test_polar_categorical_angularcategories_reference.tex +++ b/tests/test_polar/test_polar_categorical_angularcategories_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x angular_categories_(w/_categoryarray) +x angularcategories(w/categoryarray) 90.0 5 270.0 4 180.0 2 @@ -14,7 +14,7 @@ xtick={0.0,90.0,180.0,270.0}, xticklabels={d,a,c,b} ] -\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=angular_categories_(w/_categoryarray)] {\dataA}; +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=angularcategories(w/categoryarray)] {\dataA}; \addlegendentry{angular categories (w/ categoryarray)} \end{polaraxis} \end{tikzpicture} diff --git a/tests/test_polar/test_polar_categorical_radial_reference.tex b/tests/test_polar/test_polar_categorical_radial_reference.tex index 9b8859e..5186fec 100644 --- a/tests/test_polar/test_polar_categorical_radial_reference.tex +++ b/tests/test_polar/test_polar_categorical_radial_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x radial_categories +x radialcategories 57.29577951308232 a 229.1831180523293 b 114.59155902616465 c @@ -14,7 +14,7 @@ symbolic y coords={a,b,c,d,f}, ytick=data ] -\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=radial_categories] {\dataA}; +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=radialcategories] {\dataA}; \addlegendentry{radial categories} \end{polaraxis} \end{tikzpicture} diff --git a/tests/test_polar/test_polar_categorical_radialcategories_reference.tex b/tests/test_polar/test_polar_categorical_radialcategories_reference.tex index 250af8b..330d7b4 100644 --- a/tests/test_polar/test_polar_categorical_radialcategories_reference.tex +++ b/tests/test_polar/test_polar_categorical_radialcategories_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x radial_categories_(w/_category_descending) +x radialcategories(w/categorydescending) 45 a 90 b 180 c @@ -15,7 +15,7 @@ symbolic y coords={f,d,c,b,a}, ytick=data ] -\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=radial_categories_(w/_category_descending)] {\dataA}; +\addplot+ [no markers, fill=.!50, opacity=0.6] table[x=x, y=radialcategories(w/categorydescending)] {\dataA}; \addlegendentry{radial categories (w/ category descending)} \end{polaraxis} \end{tikzpicture} diff --git a/tests/test_polar/test_polar_radar_2_reference.tex b/tests/test_polar/test_polar_radar_2_reference.tex index 4b5e401..6ef6087 100644 --- a/tests/test_polar/test_polar_radar_2_reference.tex +++ b/tests/test_polar/test_polar_radar_2_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x Figure_8 Cardioid Hypercardioid +x Figure8 Cardioid Hypercardioid 0 1.0 1.0 1.0 6 0.995 0.997 0.996 12 0.978 0.989 0.984 @@ -72,7 +72,7 @@ \begin{polaraxis}[ title=Basic Polar Chart ] -\addplot+ [no markers, color=peru] table[x=x, y=Figure_8] {\dataA}; +\addplot+ [no markers, color=peru] table[x=x, y=Figure8] {\dataA}; \addplot+ [no markers, color=darkviolet, line width=2] table[x=x, y=Cardioid] {\dataA}; \addplot+ [no markers, color=deepskyblue] table[x=x, y=Hypercardioid] {\dataA}; \end{polaraxis} diff --git a/tests/test_scatter/test_scatter_10_reference.tex b/tests/test_scatter/test_scatter_10_reference.tex index 4cb7223..f3cc525 100644 --- a/tests/test_scatter/test_scatter_10_reference.tex +++ b/tests/test_scatter/test_scatter_10_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x Australia New_Zealand +x Australia NewZealand 1952 69.12 69.39 1957 70.33 70.26 1962 70.93 71.24 @@ -27,7 +27,7 @@ ] \addplot+ [mark=*, solid, color=636efa] table[y=Australia] {\dataA}; \addlegendentry{Australia} -\addplot+ [mark=*, solid, color=EF553B] table[y=New_Zealand] {\dataA}; +\addplot+ [mark=*, solid, color=EF553B] table[y=NewZealand] {\dataA}; \addlegendentry{New Zealand} \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_1_reference.tex b/tests/test_scatter/test_scatter_1_reference.tex index d3be6b7..0e26cca 100644 --- a/tests/test_scatter/test_scatter_1_reference.tex +++ b/tests/test_scatter/test_scatter_1_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x Australia New_Zealand +x Australia NewZealand 1952 69.12 69.39 1957 70.33 70.26 1962 70.93 71.24 @@ -25,7 +25,7 @@ ] \addplot+ [mark=*, solid, color=636efa] table[y=Australia] {\dataA}; \addlegendentry{Australia} -\addplot+ [mark=*, solid, color=EF553B] table[y=New_Zealand] {\dataA}; +\addplot+ [mark=*, solid, color=EF553B] table[y=NewZealand] {\dataA}; \addlegendentry{New Zealand} \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter/test_scatter_7_reference.tex b/tests/test_scatter/test_scatter_7_reference.tex index 5a0b06f..2baf1e0 100644 --- a/tests/test_scatter/test_scatter_7_reference.tex +++ b/tests/test_scatter/test_scatter_7_reference.tex @@ -1,5 +1,5 @@ \pgfplotstableread{ -x High_2014 Low_2014 High_2007 Low_2007 High_2000 Low_2000 +x High2014 Low2014 High2007 Low2007 High2000 Low2000 January 28.8 12.7 36.5 23.6 32.5 13.8 February 28.5 14.3 26.6 14.0 37.6 22.3 March 37.0 18.6 43.6 27.0 49.9 32.5 @@ -27,17 +27,17 @@ xlabel=Month, ylabel=Temperature (degrees F) ] -\addplot+ [line width=1.125, color=firebrick] table[y=High_2014] {\dataA}; +\addplot+ [line width=1.125, color=firebrick] table[y=High2014] {\dataA}; \addlegendentry{High 2014} -\addplot+ [line width=1.125, color=royalblue] table[y=Low_2014] {\dataA}; +\addplot+ [line width=1.125, color=royalblue] table[y=Low2014] {\dataA}; \addlegendentry{Low 2014} -\addplot+ [line width=1.125, dashed, color=firebrick] table[y=High_2007] {\dataA}; +\addplot+ [line width=1.125, dashed, color=firebrick] table[y=High2007] {\dataA}; \addlegendentry{High 2007} -\addplot+ [line width=1.125, dashed, color=royalblue] table[y=Low_2007] {\dataA}; +\addplot+ [line width=1.125, dashed, color=royalblue] table[y=Low2007] {\dataA}; \addlegendentry{Low 2007} -\addplot+ [line width=1.125, dotted, color=firebrick] table[y=High_2000] {\dataA}; +\addplot+ [line width=1.125, dotted, color=firebrick] table[y=High2000] {\dataA}; \addlegendentry{High 2000} -\addplot+ [line width=1.125, dotted, color=royalblue] table[y=Low_2000] {\dataA}; +\addplot+ [line width=1.125, dotted, color=royalblue] table[y=Low2000] {\dataA}; \addlegendentry{Low 2000} \end{axis} \end{tikzpicture} From ce08e4349a5a0ec02f44c931adb32dc2b1fc1141 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Sun, 12 Oct 2025 14:38:58 +0200 Subject: [PATCH 61/66] add tests and coverage for bar --- src/tests/test_bars.py | 33 ++- src/tests/test_line_charts.py | 10 +- src/tests/test_polar.py | 2 +- src/tests/test_scatter3d.py | 24 +- .../test_bars_horizontal1_reference.tex | 16 ++ .../test_bars_horizontal2_reference.tex | 262 ++++++++++++++++++ .../test_bars_vertical1_reference.tex | 28 ++ .../test_bars_vertical2_reference.tex | 29 ++ 8 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 tests/test_bars/test_bars_horizontal1_reference.tex create mode 100644 tests/test_bars/test_bars_horizontal2_reference.tex create mode 100644 tests/test_bars/test_bars_vertical1_reference.tex create mode 100644 tests/test_bars/test_bars_vertical2_reference.tex diff --git a/src/tests/test_bars.py b/src/tests/test_bars.py index 72c69a9..bfecbcd 100644 --- a/src/tests/test_bars.py +++ b/src/tests/test_bars.py @@ -1,4 +1,4 @@ -# From https://plotly.com/python/bar-charts/ +# From https://plotly.com/python/bar-charts/ and https://plotly.com/python/horizontal-bar-charts/ import plotly import plotly.express as px import pandas as pd @@ -35,9 +35,18 @@ def fig_vertical2(): def fig_vertical3(): wide_df = px.data.medals_wide() - - fig = px.bar(wide_df, x="nation", y=["gold", "silver", "bronze"], title="Wide-Form Input") - + fig = px.bar( + wide_df, + x="nation", + y=["gold", "silver", "bronze"], + title="Wide-Form Input", + color_discrete_map={ + "gold": "gold", + "silver": "silver", + "bronze": "#cd7f32" + } + ) + fig.update_traces(marker_line_width=2, marker_line_color="black") # fig.show() # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig3.png")) @@ -64,7 +73,7 @@ def fig_vertical6(): # fig.show() # fig.write_image(os.path.join(file_directory, "outputs", "test_bars", "fig5bis.png")) - + return fig, "Colored Bars" def fig_vertical7(): @@ -227,6 +236,18 @@ def fig_vertical19(): return fig, "Bar Chart with Relative Barmode" +def fig_horizontal1(): + df = px.data.tips() + fig = px.bar(df, x="total_bill", y="day", orientation='h') + return fig, "Horizontal figure" + +def fig_horizontal2(): + fig = go.Figure(go.Bar( + x=[20, 14, 23], + y=['giraffes', 'orangutans', 'monkeys'], + orientation='h')) + return fig, "Basic Horizontal Bar Chart" + if __name__ == "__main__": @@ -256,6 +277,8 @@ def fig_vertical19(): ("vertical17", fig_vertical17), ("vertical18", fig_vertical18), ("vertical19", fig_vertical19), + ("horizontal1", fig_horizontal1), + ("horizontal2", fig_horizontal2), ] main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") diff --git a/src/tests/test_line_charts.py b/src/tests/test_line_charts.py index 05f0f82..e71abb2 100644 --- a/src/tests/test_line_charts.py +++ b/src/tests/test_line_charts.py @@ -410,6 +410,13 @@ def fig20(): fig = px.line(df, x="x", y="y", title="symbolic y coords") return fig, "Line Charts with symbolic y coords" + +def fig21(x=True, y=False): + fig = px.scatter(x=[0, 1, 2, 3, 4], y=[0, 1, 4, 9, 16]) + if x: fig.data[0].x = None + if y: fig.data[0].y = None + return fig, "empty fig" + if __name__ == "__main__": print("Tikzploty : ", tikzplotly.__version__) @@ -430,7 +437,7 @@ def fig20(): ("9", fig9), ("10", fig10), ("11", fig11), - # ("12", fig12), + ("12", fig12), ("14", fig14), ("15", fig15), ("16", fig16), @@ -438,6 +445,7 @@ def fig20(): ("18", fig18), ("19", fig19), ("20", fig20), + ("21", fig21), ] main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") diff --git a/src/tests/test_polar.py b/src/tests/test_polar.py index bc78c50..b715474 100644 --- a/src/tests/test_polar.py +++ b/src/tests/test_polar.py @@ -43,7 +43,7 @@ def fig_radar3(): fig.update_layout( polar=dict( radialaxis=dict( - visible=True + visible=True ), ), showlegend=False diff --git a/src/tests/test_scatter3d.py b/src/tests/test_scatter3d.py index 769130e..b47c301 100644 --- a/src/tests/test_scatter3d.py +++ b/src/tests/test_scatter3d.py @@ -72,11 +72,32 @@ def fig6(): # fig.show() return fig, "3D Scatter Plot with Colorscaling and Marker Styling" +def fig7(): + fig = go.Figure(data=[go.Scatter3d( + x=[1, 2, 3], + y=[4, 5, 6], + z=[7, 8, 9], + mode='markers', + )]) + fig.update_layout( + scene=dict( + xaxis=dict(showgrid=True), + yaxis=dict(showgrid=True), + zaxis=dict(showgrid=True), + camera=dict( + eye=dict(x=1.5, y=1.5, z=0.5) + ) + ) + ) + fig.show() + return fig, "3D plot with custom view" + + if __name__ == "__main__": print("Tikzploty : ", tikzplotly.__version__) print("Plotly : ", plotly.__version__) - print("Test line charts") + print("Test scatter 3d charts") file_directory = os.path.dirname(os.path.abspath(__file__)) @@ -87,6 +108,7 @@ def fig6(): ("4", fig4), ("5", fig5), ("6", fig6), + ("7", fig7), ] main_tex_content = tex_create_document(options="twocolumn", compatibility="newest") diff --git a/tests/test_bars/test_bars_horizontal1_reference.tex b/tests/test_bars/test_bars_horizontal1_reference.tex new file mode 100644 index 0000000..0984220 --- /dev/null +++ b/tests/test_bars/test_bars_horizontal1_reference.tex @@ -0,0 +1,16 @@ +\pgfplotstableread{ +x y0 +giraffes 20 +orangutans 14 +monkeys 23 +}\dataA + +\begin{tikzpicture} +\begin{axis}[ +xbar, +symbolic y coords={giraffes,orangutans,monkeys}, +ytick=data +] +\addplot+ [xbar] table[x=y0, y=x] {\dataA}; +\end{axis} +\end{tikzpicture} diff --git a/tests/test_bars/test_bars_horizontal2_reference.tex b/tests/test_bars/test_bars_horizontal2_reference.tex new file mode 100644 index 0000000..edb84e5 --- /dev/null +++ b/tests/test_bars/test_bars_horizontal2_reference.tex @@ -0,0 +1,262 @@ +\pgfplotstableread{ +x y0 +Sun 16.99 +Sun 10.34 +Sun 21.01 +Sun 23.68 +Sun 24.59 +Sun 25.29 +Sun 8.77 +Sun 26.88 +Sun 15.04 +Sun 14.78 +Sun 10.27 +Sun 35.26 +Sun 15.42 +Sun 18.43 +Sun 14.83 +Sun 21.58 +Sun 10.33 +Sun 16.29 +Sun 16.97 +Sat 20.65 +Sat 17.92 +Sat 20.29 +Sat 15.77 +Sat 39.42 +Sat 19.82 +Sat 17.81 +Sat 13.37 +Sat 12.69 +Sat 21.7 +Sat 19.65 +Sat 9.55 +Sat 18.35 +Sat 15.06 +Sat 20.69 +Sat 17.78 +Sat 24.06 +Sat 16.31 +Sat 16.93 +Sat 18.69 +Sat 31.27 +Sat 16.04 +Sun 17.46 +Sun 13.94 +Sun 9.68 +Sun 30.4 +Sun 18.29 +Sun 22.23 +Sun 32.4 +Sun 28.55 +Sun 18.04 +Sun 12.54 +Sun 10.29 +Sun 34.81 +Sun 9.94 +Sun 25.56 +Sun 19.49 +Sat 38.01 +Sat 26.41 +Sat 11.24 +Sat 48.27 +Sat 20.29 +Sat 13.81 +Sat 11.02 +Sat 18.29 +Sat 17.59 +Sat 20.08 +Sat 16.45 +Sat 3.07 +Sat 20.23 +Sat 15.01 +Sat 12.02 +Sat 17.07 +Sat 26.86 +Sat 25.28 +Sat 14.73 +Sat 10.51 +Sat 17.92 +Thur 27.2 +Thur 22.76 +Thur 17.29 +Thur 19.44 +Thur 16.66 +Thur 10.07 +Thur 32.68 +Thur 15.98 +Thur 34.83 +Thur 13.03 +Thur 18.28 +Thur 24.71 +Thur 21.16 +Fri 28.97 +Fri 22.49 +Fri 5.75 +Fri 16.32 +Fri 22.75 +Fri 40.17 +Fri 27.28 +Fri 12.03 +Fri 21.01 +Fri 12.46 +Fri 11.35 +Fri 15.38 +Sat 44.3 +Sat 22.42 +Sat 20.92 +Sat 15.36 +Sat 20.49 +Sat 25.21 +Sat 18.24 +Sat 14.31 +Sat 14.0 +Sat 7.25 +Sun 38.07 +Sun 23.95 +Sun 25.71 +Sun 17.31 +Sun 29.93 +Thur 10.65 +Thur 12.43 +Thur 24.08 +Thur 11.69 +Thur 13.42 +Thur 14.26 +Thur 15.95 +Thur 12.48 +Thur 29.8 +Thur 8.52 +Thur 14.52 +Thur 11.38 +Thur 22.82 +Thur 19.08 +Thur 20.27 +Thur 11.17 +Thur 12.26 +Thur 18.26 +Thur 8.51 +Thur 10.33 +Thur 14.15 +Thur 16.0 +Thur 13.16 +Thur 17.47 +Thur 34.3 +Thur 41.19 +Thur 27.05 +Thur 16.43 +Thur 8.35 +Thur 18.64 +Thur 11.87 +Thur 9.78 +Thur 7.51 +Sun 14.07 +Sun 13.13 +Sun 17.26 +Sun 24.55 +Sun 19.77 +Sun 29.85 +Sun 48.17 +Sun 25.0 +Sun 13.39 +Sun 16.49 +Sun 21.5 +Sun 12.66 +Sun 16.21 +Sun 13.81 +Sun 17.51 +Sun 24.52 +Sun 20.76 +Sun 31.71 +Sat 10.59 +Sat 10.63 +Sat 50.81 +Sat 15.81 +Sun 7.25 +Sun 31.85 +Sun 16.82 +Sun 32.9 +Sun 17.89 +Sun 14.48 +Sun 9.6 +Sun 34.63 +Sun 34.65 +Sun 23.33 +Sun 45.35 +Sun 23.17 +Sun 40.55 +Sun 20.69 +Sun 20.9 +Sun 30.46 +Sun 18.15 +Sun 23.1 +Sun 15.69 +Thur 19.81 +Thur 28.44 +Thur 15.48 +Thur 16.58 +Thur 7.56 +Thur 10.34 +Thur 43.11 +Thur 13.0 +Thur 13.51 +Thur 18.71 +Thur 12.74 +Thur 13.0 +Thur 16.4 +Thur 20.53 +Thur 16.47 +Sat 26.59 +Sat 38.73 +Sat 24.27 +Sat 12.76 +Sat 30.06 +Sat 25.89 +Sat 48.33 +Sat 13.27 +Sat 28.17 +Sat 12.9 +Sat 28.15 +Sat 11.59 +Sat 7.74 +Sat 30.14 +Fri 12.16 +Fri 13.42 +Fri 8.58 +Fri 15.98 +Fri 13.42 +Fri 16.27 +Fri 10.09 +Sat 20.45 +Sat 13.28 +Sat 22.12 +Sat 24.01 +Sat 15.69 +Sat 11.61 +Sat 10.77 +Sat 15.53 +Sat 10.07 +Sat 12.6 +Sat 32.83 +Sat 35.83 +Sat 29.03 +Sat 27.18 +Sat 22.67 +Sat 17.82 +Thur 18.78 +}\dataA + +\begin{tikzpicture} + +\definecolor{636efa}{HTML}{636efa} + +\begin{axis}[ +xbar stacked, +symbolic y coords={Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Fri,Fri,Fri,Fri,Fri,Fri,Fri,Fri,Fri,Fri,Fri,Fri,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sun,Sun,Sun,Sun,Sun,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sat,Sat,Sat,Sat,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Sun,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Thur,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Fri,Fri,Fri,Fri,Fri,Fri,Fri,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Sat,Thur}, +ytick=data, +xlabel=total\_bill, +ylabel=day +] +\addplot+ [xbar, fill=636efa, color=636efa] table[x=y0, y=x] {\dataA}; +\end{axis} +\end{tikzpicture} diff --git a/tests/test_bars/test_bars_vertical1_reference.tex b/tests/test_bars/test_bars_vertical1_reference.tex new file mode 100644 index 0000000..81db6b8 --- /dev/null +++ b/tests/test_bars/test_bars_vertical1_reference.tex @@ -0,0 +1,28 @@ +\pgfplotstableread{ +x y0 +1952 14785584 +1957 17010154 +1962 18985849 +1967 20819767 +1972 22284500 +1977 23796400 +1982 25201900 +1987 26549700 +1992 28523502 +1997 30305843 +2002 31902268 +2007 33390141 +}\dataA + +\begin{tikzpicture} + +\definecolor{636efa}{HTML}{636efa} + +\begin{axis}[ +ybar stacked, +xlabel=year, +ylabel=pop +] +\addplot+ [ybar, fill=636efa, color=636efa] table[x=x, y=y0] {\dataA}; +\end{axis} +\end{tikzpicture} diff --git a/tests/test_bars/test_bars_vertical2_reference.tex b/tests/test_bars/test_bars_vertical2_reference.tex new file mode 100644 index 0000000..b63b189 --- /dev/null +++ b/tests/test_bars/test_bars_vertical2_reference.tex @@ -0,0 +1,29 @@ +\pgfplotstableread{ +x gold silver bronze +SouthKorea 24 13 11 +China 10 15 8 +Canada 9 12 12 +}\dataA + +\begin{tikzpicture} + +\definecolor{cd7f32}{HTML}{cd7f32} +\definecolor{gold}{RGB}{255, 215, 0} +\definecolor{silver}{RGB}{192, 192, 192} + +\begin{axis}[ +ybar stacked, +symbolic x coords={SouthKorea,China,Canada}, +xtick=data, +title=Wide-Form Input, +xlabel=nation, +ylabel=value +] +\addplot+ [ybar, fill=gold, color=gold, line width=2, draw=black] table[x=x, y=gold] {\dataA}; +\addlegendentry{gold} +\addplot+ [ybar, fill=silver, color=silver, line width=2, draw=black] table[x=x, y=silver] {\dataA}; +\addlegendentry{silver} +\addplot+ [ybar, fill=cd7f32, color=cd7f32, line width=2, draw=black] table[x=x, y=bronze] {\dataA}; +\addlegendentry{bronze} +\end{axis} +\end{tikzpicture} From 754ea735a9178a36457a0f9e1a5b2948c804999e Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Sun, 12 Oct 2025 15:51:41 +0200 Subject: [PATCH 62/66] improve code #30 --- src/tikzplotly/_bar.py | 14 ++++---- src/tikzplotly/_color.py | 8 ++--- src/tikzplotly/_data.py | 12 ++++++- src/tikzplotly/_dataContainer.py | 14 ++++---- src/tikzplotly/_heatmap.py | 2 +- src/tikzplotly/_polar.py | 35 +++++++++++++++----- src/tikzplotly/_save.py | 13 +++----- src/tikzplotly/_scatter.py | 5 ++- src/tikzplotly/_scatter3d.py | 29 ++++++++++------ src/tikzplotly/_utils.py | 9 +++-- tests/test_bars.py | 57 ++++++++++++++++++++++++++++++++ 11 files changed, 146 insertions(+), 52 deletions(-) create mode 100644 tests/test_bars.py diff --git a/src/tikzplotly/_bar.py b/src/tikzplotly/_bar.py index f13f445..eb7dd06 100644 --- a/src/tikzplotly/_bar.py +++ b/src/tikzplotly/_bar.py @@ -1,12 +1,15 @@ +""" +This module handles bar plots. +""" +from warnings import warn from ._axis import Axis from ._utils import option_dict_to_str from ._tex import tex_addplot from ._color import convert_color -from ._utils import sanitize_tex_text -from warnings import warn +from ._utils import sanitize_text -def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_set, row_sep="\\"): +def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_set): r""" Draw a bar chart (vertical or horizontal) referencing the data table created by DataContainer.add_data(...). @@ -28,13 +31,10 @@ def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_ The axis object to which the bar chart will be added. colors_set : set A set to keep track of colors used in the plot (for \\definecolor). - row_sep : str, optional - The row separator for the data table in TikZ, by default "\\" """ code = "" plot_options = {} type_options = {} - # type_options = {"row sep": row_sep} orientation = getattr(trace, "orientation", "v") # default vertical stack = " stacked" if axis.layout.barmode in ("stack", "relative") else "" @@ -54,7 +54,7 @@ def draw_bar(data_name_macro, x_col_name, y_col_name, trace, axis: Axis, colors_ # Handle symbolic coords if all(isinstance(value, str) for value in categories): - symbolic = sanitize_tex_text(",".join(categories)) + symbolic = sanitize_text(",".join(categories), keep_space=-1) if orientation == "h": axis.add_option("symbolic y coords", "{" + symbolic + "}") axis.add_option("ytick", "data") diff --git a/src/tikzplotly/_color.py b/src/tikzplotly/_color.py index eb231cb..21ed038 100644 --- a/src/tikzplotly/_color.py +++ b/src/tikzplotly/_color.py @@ -45,22 +45,22 @@ def convert_color(color): if color is None: return None, None, None, 1 if isinstance(color, numpy.ndarray): - warn(f"Color from data is not supported yet. Returning the default color: blue.") + warn("Color from data is not supported yet. Returning the default color: blue.") return "blue", "HTML", "0000ff", 1 - elif not isinstance(color, str): + if not isinstance(color, str): warn(f"Color {color} type '{color.__class__.__name__}' is not supported yet. Returning the default color: blue.") return "blue", "HTML", "0000ff", 1 if color.startswith("#"): return color[1:], "HTML", color[1:], 1 - elif color.startswith("rgba"): + if color.startswith("rgba"): sp = color.split("(")[1].split(",") rgb_color = sp[:-1] rgb_color = convert_color(f"rgb({rgb_color[0]},{rgb_color[1]},{rgb_color[2]})")[:-1] return rgb_color + (float(sp[-1][:-1]),) - elif color.startswith("rgb"): + if color.startswith("rgb"): color = color[4:-1].replace("[", "{").replace("]", "}") return hashlib.sha1(color.encode('UTF-8')).hexdigest()[:10], "RGB", color, 1 diff --git a/src/tikzplotly/_data.py b/src/tikzplotly/_data.py index b3a310b..33a77e6 100644 --- a/src/tikzplotly/_data.py +++ b/src/tikzplotly/_data.py @@ -28,10 +28,20 @@ def data_type(data): 'july', 'august', 'september', 'october', 'november', 'december']: warn(f"Assuming data {data} is a month. This feature is experimental.") return 'month' - return None return None def treat_data(data_str): + """Treat data for correct TeX display + + Parameters + ---------- + data_str + string of data to be treated + + Returns + ------- + Sanitized TeX string + """ data_str = sanitize_text(str(data_str), keep_space=-1) if data_str.find(' ') !=- 1: # Add curly braces if space in string if not data_str.startswith("{") and not data_str.startswith("}"): diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index e04d136..887c143 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -2,6 +2,7 @@ Contain the code to handle data in TikZ plots. """ import hashlib +import numpy as np from ._utils import replace_all_digits, sanitize_text from ._data import treat_data, post_treat_data @@ -81,6 +82,8 @@ def add_y_data(self, y, y_label=None): return self.y_label[-1] class Data3D: + """Handle 3D data in Tikz plots + """ def __init__(self, x, y, z, name): self.x = list(x) self.y = list(y) @@ -154,7 +157,6 @@ def add_data3d(self, x, y, z, name=None): """ for data in self.data: if hasattr(data, "x") and hasattr(data, "y") and hasattr(data, "z"): - import numpy as np if np.array_equal(data.x, x) and np.array_equal(data.y, y) and np.array_equal(data.z, z): return data.name, data.z_name data_obj = Data3D(x, y, z, name) @@ -175,8 +177,8 @@ def export_data(self): if hasattr(data, "z"): export_string += "\\pgfplotstableread{\n" export_string += "x y z\n" - for i in range(len(data.x)): - export_string += f"{treat_data(data.x[i])} {treat_data(data.y[i])} {treat_data(data.z[i])}\n" + for x, y, z in zip(data.x, data.y, data.z): + export_string += f"{treat_data(x)} {treat_data(y)} {treat_data(z)}\n" export_string += f"}}{{\\{data.name}}}\n" # 2D @@ -189,13 +191,11 @@ def export_data(self): else: header += " y" export_string += header + "\n" - for i in range(len(data.x)): - row = [treat_data(data.x[i])] + for i, x in enumerate(data.x): + row = [treat_data(x)] for y_col in data.y_data: row.append(treat_data(y_col[i])) export_string += " ".join(row) + "\n" export_string += f"}}\\{data.name}\n" return post_treat_data(export_string) - - diff --git a/src/tikzplotly/_heatmap.py b/src/tikzplotly/_heatmap.py index 5ce2fe3..309a070 100755 --- a/src/tikzplotly/_heatmap.py +++ b/src/tikzplotly/_heatmap.py @@ -93,7 +93,7 @@ def draw_heatmap(data, fig, img_name, axis: Axis): fig_copy.update_layout(coloraxis_showscale=False, coloraxis_colorbar=None, xaxis_visible=False, yaxis_visible=False) try: fig_copy.update_traces(showscale=False) - except ValueError as e: + except ValueError as _: pass if data.texttemplate is not None: diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index ec7b69d..5c498c2 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -1,12 +1,33 @@ +""" +Provides functionality to convert Plotly 3D polar plots into TikZ/PGFPlots code for LaTeX documents. +""" +from warnings import warn +import numpy as np from ._axis import Axis from ._utils import option_dict_to_str from ._tex import tex_addplot from ._color import convert_color from ._dataContainer import DataContainer -from warnings import warn -import numpy as np def get_polar_coord(trace, axis: Axis, data_container: DataContainer): + """Get polar coordinates from the trace + + Parameters + ---------- + trace + polar trace from Plotly figure + axis + axis object previously created + data_container + Data table, created before + + Returns + ------- + Tuple (data_name_macro, theta_col_name, r_col_name): + - data_name_macro: name of the data in LaTeX + - theta_col_name: name of the theta column + - r_col_name: name of the r column + """ polar_layout = getattr(axis.layout, 'polar') if polar_layout: # Angular Axis @@ -117,7 +138,7 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): return data_name_macro, theta_col_name, r_col_name -def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: Axis, colors_set, row_sep="\\"): +def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: Axis, colors_set): """ Draw a scatterpolar plot using pgfplots polaraxis environment. @@ -135,8 +156,6 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: Axis object (to add axis-level options) colors_set : set Set of colors defined - row_sep : str, optional - Row separator in LaTeX, default "\\" Returns ------- @@ -148,9 +167,9 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: mode = trace.mode if trace.mode else "lines" - if not "markers" in mode: + if "markers" not in mode: plot_options["no markers"] = None - elif not "lines" in mode: + elif "lines" not in mode: plot_options["only marks"] = None # Marker style @@ -164,7 +183,7 @@ def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: plot_options["mark options"] = "{" + option_dict_to_str(mark_opts) + "}" if marker.size is not None: if isinstance(marker.size, np.ndarray): - warn(f"Polar: Individual marker sizes in a trace are not supported yet.") + warn("Polar: Individual marker sizes in a trace are not supported yet.") else: plot_options["mark size"] = marker.size/4 diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 0473dd5..1fc4df2 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -7,6 +7,7 @@ from pathlib import Path from warnings import warn import re +import numpy as np from .__about__ import __version__ from ._tex import tex_add_legendentry, tex_comment, tex_begin_environment, tex_add_color, tex_end_all_environment from ._scatter import draw_scatter2d @@ -20,10 +21,6 @@ from ._annotations import str_from_annotation from ._dataContainer import DataContainer from ._utils import sanitize_tex_text, sanitize_text -from warnings import warn -from collections import defaultdict -import numpy as np -import re def get_tikz_code( @@ -124,17 +121,17 @@ def get_tikz_code( bar_code = draw_bar(data_name_macro, x_col_name, val_col_name, trace, axis, colors_set) data_str.append(bar_code) - if trace.name and trace['showlegend'] != False: + if trace.name and trace['showlegend'] is not False: data_str.append(tex_add_legendentry(sanitize_tex_text(trace.name))) - elif trace.type == "scatterpolar" or trace.type == "scatterpolargl": + elif trace.type in ('scatterpolar', 'scatterpolargl'): data_name_macro, theta_col_name, r_col_name = get_polar_coord(trace, axis, data_container) theta_col_name = "x" polar_code = draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis, colors_set) data_str.append(polar_code) - if trace.name and trace['showlegend'] != False: + if trace.name and trace['showlegend'] is not False: data_str.append(tex_add_legendentry(sanitize_tex_text(trace.name))) elif trace.type == "scatter3d": @@ -179,7 +176,7 @@ def get_tikz_code( data_name_macro, z_name = data_container.add_data3d(trace.x, trace.y, trace.z, trace.name) data_str.append(draw_scatter3d(data_name_macro, trace, z_name, axis, colors_set)) - if trace.name and trace['showlegend'] != False: + if trace.name and trace['showlegend'] is not False: data_str.append(tex_add_legendentry(sanitize_tex_text(trace.name))) if getattr(trace, "line", None) and getattr(trace.line, "color", None) is not None: colors_set.add(convert_color(trace.line.color)[:3]) diff --git a/src/tikzplotly/_scatter.py b/src/tikzplotly/_scatter.py index 90c1d4d..a064a8a 100644 --- a/src/tikzplotly/_scatter.py +++ b/src/tikzplotly/_scatter.py @@ -63,7 +63,6 @@ def draw_scatter2d(data_name, scatter, y_name, axis: Axis, color_set): else: options_dict["only marks"] = None - mark_options = "" if scatter.marker.size is not None: options_dict["mark size"] = px_to_pt(marker.size) @@ -82,9 +81,9 @@ def draw_scatter2d(data_name, scatter, y_name, axis: Axis, color_set): if (angle:=scatter.marker.angle) is not None: mark_option_dict["rotate"] = angle - if (opacity:=scatter.opacity) is not None: + if (opacity := scatter.opacity) is not None: options_dict["opacity"] = np.round(opacity, 2) - if (opacity:=scatter.marker.opacity) is not None: + if (opacity := scatter.marker.opacity) is not None: mark_option_dict["opacity"] = np.round(opacity, 2) if mark_option_dict: diff --git a/src/tikzplotly/_scatter3d.py b/src/tikzplotly/_scatter3d.py index 44863b6..9ac833e 100644 --- a/src/tikzplotly/_scatter3d.py +++ b/src/tikzplotly/_scatter3d.py @@ -1,14 +1,24 @@ +""" +Provides functionality to convert Plotly 3D scatter traces into TikZ/PGFPlots code for LaTeX documents. +""" from warnings import warn - -from ._tex import tex_addplot +import numpy as np from ._color import convert_color from ._marker import marker_symbol_to_tex -from ._axis import Axis from ._utils import px_to_pt, option_dict_to_str -def draw_scatter3d(data_name, scatter, z_name, axis: Axis, color_set): +def draw_scatter3d(data_name, scatter, color_set): """ Get code for a scatter3d trace. + + Parameters + ---------- + data_name + name of the data imported in LaTeX + scatter + scatter trace from Plotly figure + color_set + set of colors used in the figure """ code = "" @@ -33,16 +43,15 @@ def draw_scatter3d(data_name, scatter, z_name, axis: Axis, color_set): size = marker.size if isinstance(size, (list, tuple)) or (hasattr(size, "shape") and hasattr(size, "__len__")): try: - import numpy as np size = float(np.mean(size)) - except Exception: + except (TypeError, ValueError): size = float(size[0]) options_dict["mark size"] = px_to_pt(size) - if marker.color is not None: - color_set.add(convert_color(marker.color)[:3]) + if (c := marker.color) is not None: + color_set.add(convert_color(c)[:3]) mark_option_dict["solid"] = None - mark_option_dict["fill"] = convert_color(marker.color)[0] + mark_option_dict["fill"] = convert_color(c)[0] if (line := marker.line) is not None: if line.color is not None: @@ -87,7 +96,7 @@ def draw_scatter3d(data_name, scatter, z_name, axis: Axis, color_set): if scatter.name: code += f"\n% {scatter.name}\n" - code += f"\\addplot3+ " + code += "\\addplot3+ " if options is not None: code += f"[{options}] " code += f"table[x=x, y=y, z=z] {{\\{data_name}}};\n" diff --git a/src/tikzplotly/_utils.py b/src/tikzplotly/_utils.py index 36a7472..4a33af0 100644 --- a/src/tikzplotly/_utils.py +++ b/src/tikzplotly/_utils.py @@ -98,11 +98,14 @@ def sanitize_char(ch: str, keep_space: int = 0) -> str: if keep_space == 0: return "_" return "" - if ch in "[]{}= ": return f"x{ord(ch):x}" + if ch in "[]{}= ": + return f"x{ord(ch):x}" # if not ascii, return hex - if ord(ch) > 127: return f"x{ord(ch):x}" + if ord(ch) > 127: + return f"x{ord(ch):x}" # if not printable, return hex - if not ch.isprintable(): return f"x{ord(ch):x}" + if not ch.isprintable(): + return f"x{ord(ch):x}" return ch def sanitize_tex_text(text: str): diff --git a/tests/test_bars.py b/tests/test_bars.py new file mode 100644 index 0000000..f2c5889 --- /dev/null +++ b/tests/test_bars.py @@ -0,0 +1,57 @@ +import plotly.express as px +import plotly.graph_objects as go +import numpy as np +import os +from .helpers import assert_equality +import pathlib +import pytest + +this_dir = pathlib.Path(__file__).resolve().parent +test_name = "test_bars" + + +def plot_vertical1(): + data_canada = px.data.gapminder().query("country == 'Canada'") + fig = px.bar(data_canada, x='year', y='pop') + return fig + +def plot_vertical2(): + wide_df = px.data.medals_wide() + fig = px.bar( + wide_df, + x="nation", + y=["gold", "silver", "bronze"], + title="Wide-Form Input", + color_discrete_map={ + "gold": "gold", + "silver": "silver", + "bronze": "#cd7f32" + } + ) + fig.update_traces(marker_line_width=2, marker_line_color="black") + return fig + +def plot_horizontal1(): + fig = go.Figure(go.Bar( + x=[20, 14, 23], + y=['giraffes', 'orangutans', 'monkeys'], + orientation='h')) + return fig + +def plot_horizontal2(): + df = px.data.tips() + fig = px.bar(df, x="total_bill", y="day", orientation='h') + return fig + + +def test_vertical1(): + assert_equality(plot_vertical1(), os.path.join(this_dir, test_name, test_name + "_vertical1_reference.tex")) + +def test_vertical2(): + assert_equality(plot_vertical2(), os.path.join(this_dir, test_name, test_name + "_vertical2_reference.tex")) + +def test_horizontal1(): + assert_equality(plot_horizontal1(), os.path.join(this_dir, test_name, test_name + "_horizontal1_reference.tex")) + +def test_horizontal2(): + assert_equality(plot_horizontal2(), os.path.join(this_dir, test_name, test_name + "_horizontal2_reference.tex")) From ddc63806c3af5590e1487a9be64169fb819df5f7 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Sun, 12 Oct 2025 16:00:01 +0200 Subject: [PATCH 63/66] other tiny improvments in code (doctrings etc) #30 --- src/tikzplotly/_axis.py | 5 ----- src/tikzplotly/_dataContainer.py | 13 +++++++++++++ src/tikzplotly/_heatmap.py | 1 - src/tikzplotly/_polar.py | 1 - src/tikzplotly/_save.py | 2 +- src/tikzplotly/_tex.py | 4 ---- src/tikzplotly/_utils.py | 1 - 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/tikzplotly/_axis.py b/src/tikzplotly/_axis.py index 499ceee..74fdc55 100644 --- a/src/tikzplotly/_axis.py +++ b/src/tikzplotly/_axis.py @@ -51,9 +51,6 @@ def __init__(self, layout, colors_set, axis_options=None): self.treat_background_layout(colors_set) self.treat_bar_layout() - - - def set_x_label(self, x_label): """Set the x label. @@ -113,7 +110,6 @@ def get_options(self): options_str = option_dict_to_str(self.options, sep="\n") return options_str - def treat_axis_layout(self): """Treat the layout of the axis.""" @@ -183,7 +179,6 @@ def treat_axis_layout(self): if m.ticklen is not None: self.add_option("subtickwidth", m.ticklen) - def treat_background_layout(self, colors_set): """Treat the background layout of the axis. Parameters diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index 887c143..1a4a278 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -85,6 +85,19 @@ class Data3D: """Handle 3D data in Tikz plots """ def __init__(self, x, y, z, name): + """Initialize the Data3D object + + Parameters + ---------- + x + x_values of the data + y + y_values of the data + z + z_values of the data + name + name of the data + """ self.x = list(x) self.y = list(y) self.z = list(z) diff --git a/src/tikzplotly/_heatmap.py b/src/tikzplotly/_heatmap.py index 309a070..afaf6bd 100755 --- a/src/tikzplotly/_heatmap.py +++ b/src/tikzplotly/_heatmap.py @@ -64,7 +64,6 @@ def resize_image(img, nb_row, nb_col): return resized_image - def draw_heatmap(data, fig, img_name, axis: Axis): """Draw a heatmap, and return the tikz code. diff --git a/src/tikzplotly/_polar.py b/src/tikzplotly/_polar.py index 5c498c2..a293a9a 100644 --- a/src/tikzplotly/_polar.py +++ b/src/tikzplotly/_polar.py @@ -137,7 +137,6 @@ def get_polar_coord(trace, axis: Axis, data_container: DataContainer): return data_name_macro, theta_col_name, r_col_name - def draw_scatterpolar(data_name_macro, theta_col_name, r_col_name, trace, axis: Axis, colors_set): """ Draw a scatterpolar plot using pgfplots polaraxis environment. diff --git a/src/tikzplotly/_save.py b/src/tikzplotly/_save.py index 1fc4df2..a4e4390 100755 --- a/src/tikzplotly/_save.py +++ b/src/tikzplotly/_save.py @@ -174,7 +174,7 @@ def get_tikz_code( axis.add_option("title", f"{{{sanitize_tex_text(figure_layout.scene.title.text)}}}") data_name_macro, z_name = data_container.add_data3d(trace.x, trace.y, trace.z, trace.name) - data_str.append(draw_scatter3d(data_name_macro, trace, z_name, axis, colors_set)) + data_str.append(draw_scatter3d(data_name_macro, trace, colors_set)) if trace.name and trace['showlegend'] is not False: data_str.append(tex_add_legendentry(sanitize_tex_text(trace.name))) diff --git a/src/tikzplotly/_tex.py b/src/tikzplotly/_tex.py index 5c39ec1..8225291 100644 --- a/src/tikzplotly/_tex.py +++ b/src/tikzplotly/_tex.py @@ -162,7 +162,6 @@ def tex_add_legendentry(legend, options=None): return f"\\addlegendentry[{options}]{{{legend}}}\n" return f"\\addlegendentry{{{legend}}}\n" - def tex_create_document(document_class="article", options=None, compatibility="newest"): """Create a LaTeX document. @@ -212,9 +211,6 @@ def tex_create_document(document_class="article", options=None, compatibility="n # code += f"];\n" # return code - - - def tex_text(text): """Convert a string to LaTeX, escaping the special characters %, _, &, #, $, {, }, ~. diff --git a/src/tikzplotly/_utils.py b/src/tikzplotly/_utils.py index 4a33af0..29bdca4 100644 --- a/src/tikzplotly/_utils.py +++ b/src/tikzplotly/_utils.py @@ -166,7 +166,6 @@ def px_to_pt(px): return int(pt) return pt - def option_dict_to_str(options_dict, sep=" "): """Convert a dictionary of options to a string of options for TikZ. From bf835f521ea7d69c0197a49fc398720e31388643 Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Mon, 13 Oct 2025 14:41:19 +0200 Subject: [PATCH 64/66] attemp to fix tests on MacOS due to different precision #19 --- src/tikzplotly/_dataContainer.py | 22 ++++++++++++++----- .../test_scatter3d_2_lines_reference.tex | 4 ++-- ...st_scatter3d_2_markers+lines_reference.tex | 4 ++-- .../test_scatter3d_2_markers_reference.tex | 4 ++-- .../test_scatter3d_3_reference.tex | 4 ++-- .../test_scatter3d_view_reference.tex | 4 ++-- 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/tikzplotly/_dataContainer.py b/src/tikzplotly/_dataContainer.py index 1a4a278..9ccd493 100644 --- a/src/tikzplotly/_dataContainer.py +++ b/src/tikzplotly/_dataContainer.py @@ -98,17 +98,29 @@ def __init__(self, x, y, z, name): name name of the data """ - self.x = list(x) - self.y = list(y) - self.z = list(z) + self.x = np.array(x) + self.y = np.array(y) + self.z = np.array(z) if name: self.name = sanitize_text(name, keep_space=0) else: - hash_input = str(self.x) + str(self.y) + str(self.z) - hash_digest = hashlib.md5(hash_input.encode()).hexdigest() + hash_digest = self.get_hash() self.name = f"data{hexid_to_alpha(hash_digest)}" self.z_name = "z" + def get_hash(self, tolerance=1e-6): + """ + Generates the unique hash corresponding to the data, up to tolerance + """ + def normalize_float(value): + return np.round(value / tolerance) * tolerance + x_norm = normalize_float(self.x) + y_norm = normalize_float(self.y) + z_norm = normalize_float(self.z) + hash_input = f"{x_norm}{y_norm}{z_norm}" + return hashlib.md5(hash_input.encode()).hexdigest() + + class DataContainer: """Container for data used in TikZ plots. """ diff --git a/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex b/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex index 7ea951c..e2e5bb1 100644 --- a/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_2_lines_reference.tex @@ -100,10 +100,10 @@ 0.7341355268330447 0.6790029662980626 19.595959595959595 0.5829644097750302 0.8124976904186563 19.7979797979798 0.40808206181339196 0.9129452507276277 20.0 -}{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} +}{\dataDFHNIDGLEFPCLNOOILBDMAJGJEBAMDGP} \begin{tikzpicture} \begin{axis} -\addplot3+ [mark=none] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\addplot3+ [mark=none] table[x=x, y=y, z=z] {\dataDFHNIDGLEFPCLNOOILBDMAJGJEBAMDGP}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex b/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex index f5b64ed..019173b 100644 --- a/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_2_markers+lines_reference.tex @@ -100,10 +100,10 @@ 0.7341355268330447 0.6790029662980626 19.595959595959595 0.5829644097750302 0.8124976904186563 19.7979797979798 0.40808206181339196 0.9129452507276277 20.0 -}{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} +}{\dataDFHNIDGLEFPCLNOOILBDMAJGJEBAMDGP} \begin{tikzpicture} \begin{axis} -\addplot3+ [mark=diamond*] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\addplot3+ [mark=diamond*] table[x=x, y=y, z=z] {\dataDFHNIDGLEFPCLNOOILBDMAJGJEBAMDGP}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex b/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex index 8d1893c..44b1c57 100644 --- a/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_2_markers_reference.tex @@ -100,13 +100,13 @@ 0.7341355268330447 0.6790029662980626 19.595959595959595 0.5829644097750302 0.8124976904186563 19.7979797979798 0.40808206181339196 0.9129452507276277 20.0 -}{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} +}{\dataDFHNIDGLEFPCLNOOILBDMAJGJEBAMDGP} \begin{tikzpicture} \definecolor{blue}{HTML}{0000ff} \begin{axis} -\addplot3+ [mark=diamond*, only marks, mark size=9, mark options={solid, fill=blue, opacity=0.8}] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\addplot3+ [mark=diamond*, only marks, mark size=9, mark options={solid, fill=blue, opacity=0.8}] table[x=x, y=y, z=z] {\dataDFHNIDGLEFPCLNOOILBDMAJGJEBAMDGP}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_3_reference.tex b/tests/test_scatter3d/test_scatter3d_3_reference.tex index c59bab7..c80baa6 100644 --- a/tests/test_scatter3d/test_scatter3d_3_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_3_reference.tex @@ -100,13 +100,13 @@ 0.7341355268330447 0.6790029662980626 19.595959595959595 0.5829644097750302 0.8124976904186563 19.7979797979798 0.40808206181339196 0.9129452507276277 20.0 -}{\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN} +}{\dataDFHNIDGLEFPCLNOOILBDMAJGJEBAMDGP} \begin{tikzpicture} \definecolor{darkblue}{RGB}{0, 0, 139} \begin{axis} -\addplot3+ [mark=none, line width=3, color=darkblue, forget plot] table[x=x, y=y, z=z] {\dataBEACHOBCMAOKOFCMEDAJKLFELMLEEHDN}; +\addplot3+ [mark=none, line width=3, color=darkblue, forget plot] table[x=x, y=y, z=z] {\dataDFHNIDGLEFPCLNOOILBDMAJGJEBAMDGP}; \end{axis} \end{tikzpicture} diff --git a/tests/test_scatter3d/test_scatter3d_view_reference.tex b/tests/test_scatter3d/test_scatter3d_view_reference.tex index 0823227..64af757 100644 --- a/tests/test_scatter3d/test_scatter3d_view_reference.tex +++ b/tests/test_scatter3d/test_scatter3d_view_reference.tex @@ -3,7 +3,7 @@ 1 4 7 2 5 8 3 6 9 -}{\dataIEJOMDLLMKDNMPIGNPBKAFCKOIAKBFCN} +}{\dataMJPPLKDJHBCHLGPPMBABHNCKCPHFLJMO} \begin{tikzpicture} @@ -14,6 +14,6 @@ ymajorgrids=false, zmajorgrids=false ] -\addplot3+ [only marks, mark size=4.5, mark options={solid, fill=red}] table[x=x, y=y, z=z] {\dataIEJOMDLLMKDNMPIGNPBKAFCKOIAKBFCN}; +\addplot3+ [only marks, mark size=4.5, mark options={solid, fill=red}] table[x=x, y=y, z=z] {\dataMJPPLKDJHBCHLGPPMBABHNCKCPHFLJMO}; \end{axis} \end{tikzpicture} From ef7a1f9d42cc74a33892541815401e24ca4f9cad Mon Sep 17 00:00:00 2001 From: Thomas Saigre Date: Tue, 21 Oct 2025 14:59:05 +0200 Subject: [PATCH 65/66] add documentation of new features in PR#30 --- README.md | 11 ++++++----- docs/assets/examples/bar.png | Bin 0 -> 17602 bytes docs/assets/examples/polar.png | Bin 0 -> 54760 bytes docs/assets/examples/radar.png | Bin 0 -> 32841 bytes docs/assets/examples/scatter3d.png | Bin 0 -> 60577 bytes src/tests/test_scatter3d.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 docs/assets/examples/bar.png create mode 100644 docs/assets/examples/polar.png create mode 100644 docs/assets/examples/radar.png create mode 100644 docs/assets/examples/scatter3d.png diff --git a/README.md b/README.md index 5871867..d01aaf5 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ tikzplotly.save("example.tex", fig) will result in the following ti*k*z code ```latex -\pgfplotstableread{data0 Australia New_Zealand +\pgfplotstableread{ +x Australia NewZealand 1952 69.12 69.39 1957 70.33 70.26 1962 70.93 71.24 @@ -51,7 +52,7 @@ will result in the following ti*k*z code 1997 78.83 77.55 2002 80.37 79.11 2007 81.235 80.204 -}\dataZ +}\dataA \begin{tikzpicture} @@ -60,11 +61,11 @@ will result in the following ti*k*z code \begin{axis}[ xlabel=year, -ylabel=lifeExp, +ylabel=lifeExp ] -\addplot+ [mark=*, solid, color=636efa, mark options={solid, draw=636efa}] table[y=Australia] {\dataZ}; +\addplot+ [mark=*, solid, color=636efa] table[y=Australia] {\dataA}; \addlegendentry{Australia} -\addplot+ [mark=*, solid, color=EF553B, mark options={solid, draw=EF553B}] table[y=New_Zealand] {\dataZ}; +\addplot+ [mark=*, solid, color=EF553B] table[y=NewZealand] {\dataA}; \addlegendentry{New Zealand} \end{axis} \end{tikzpicture} diff --git a/docs/assets/examples/bar.png b/docs/assets/examples/bar.png new file mode 100644 index 0000000000000000000000000000000000000000..4ea90ce58c0d8e17412e4ed49a87767a1e9e29bd GIT binary patch literal 17602 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�WbSez+t4-g0rgA&nnc~=-I*u2gkhxhY(x9< z`Us;hC?J3pz=j}4H+W)qqrZac$Cm`unW~eQ@^3Q)@0fU8LwABR#-a99PBZ&3j8Hc( zJUo8q`Hww&e-MQH>~BS4Th9pcJoz#VN-oYBUmG4-3o2ahFYC{Z@I6DbK==t3e@hMJ zf}sD-n9ZrtQxUND2}j_fL=U8nfJiJ?d)B9xfvyfz?qH_{04Het3_;?B+)u9I9FM>x zN51LQGjZzf`MS5)I)L8jQN!cEw!k|6@=vBwqJaq4u@%yw+E5`A&>iVa{-V1)P_xD1F$U=ssA`pCK8@E=p_aGJ8r zd=@*H9=C?VY$=ma0;F=4LcmF!Amd&LAg{I9@$9D`ix2Rd-N?zHbE#)?RW2>avtkVe|85#M`Adsx;cb2M|x9vdnogm$F$d7_ST$R5%wUTcznbQK-)APS! zJb*q$sgg=lLh_8{W8oterfG|V=_&3HI1|hNOdlLJD}3=4ujO*s=VyIejkiD)ZEHv) zx5LV&?OBE6f%XHFnAuKQ;DBMLenq%ctzQTU`R(2ueb-WxxGB##}600A`;Le`d1rz<%?hvf%T2(!}#elgJDwVYoIQqvhi28>$2q52f z2ZVBeU)wt@{I!vhR5UlbuiSqncrA>I)3;=F7dttKn%ksaHb3_C2Iik>4H~V}z$zi8 z2Ru4bSI?M8YQoi}!FUnRU-dAM!}uJFU}O|A|K6EHJJ8b>IMYh2ZJT^xM2l7OBvh{>{S>ddJOz9u0^)R|`!4fuAdrvu1cT4q`g#{RH8JlLLt&_MjA_rtH7?A%R{l6}SWy?^2nSOyrv$02 z&|ZN-lk@y)=BpJvO!uBRG7R@6{^}4>RHS^W;E~d*=PBIfr4#0c`O6aKK*I`#oY^F+8&emdtD9(|jJ` zJdf6CoC{CzMND6t_AZ-Dsny4mk0L0S)e9M{EDz@|wr+K(@||lQ8Ou$5b09sFhqhxm zxRGp@Z7TljnDoDd(Jt>4GRECQJ*z5c(XsM3w3Ohyr%qoV;x>Q8WRoa z4P4IHaGuDgXf)*6=6m~~M&L`1a`4DZ?F6&FD}l@JiVQL*KuYto8>Fm^j*BbdYn9c< z941nmI)W(d$XWbFwOJXvAj5&+v)VNVhdXGE#^H}RzWKShY}PYdiYp-ami-)3-~tW9 zQE}&5Jw?ATDcqZ74Zja*BerIPa^mC{BH^mZjdv#E2M=0rjjLRP(`zzq;xv|qBw429 zAgk8BSKgsHn}qU0YJ0ZfK==bhK1jw51g#~QWb!y2;Df$dv={Z(lZq-~8 zw>2rq@uQ)nd>I2r-59get1;t4o|tu>jhex2qY_UZCjW8ljU68YEQWGvuj8%GjstI0 zQ{sTHroq+^oo$su6;X(OBHIs{%^wJr0;`yJ-HrWACor0olZYaiM8`|QR&fEPzG?jB zG{`mN=v9}@wTF&Ru}(M#8_)>p0@2FZMy5pMD!2R}i>s;2OOi-hK?acNkR}_mP>B5Y ziY3p!BGPHCw~>Km?Z3eUUp;@yJ&yBKt5aUozgT}F$+8okd=8d>=hL(W>^b*Bk-B?( zD^15K!M*``0{0W^A?VyEET4By+bgNv})d%dyWOoA{Ht`CfTAT`~(ozKWQ&_KWY2 z)$?KY2t`T=qJQjUG5ZZG3ulZJ^LiIVV_3EQ$Y zvBY9&*MZ`zrptjs^i;Le%B=3JC0B-6y+?B=e?p`RDjcIr@?HoLd6?337K51k}$qu6Zp0 zs)zx2H!#QsbQchuGpEc$A|hzm*kXySn`3kyPZK?EhOW-`TGc9Ew^Y;MR_)0an=UVi zT=>ab70G1%nw|$GgV@>ex1<{8px5A#_r1xv3RV2HU z4f*&kIT$VVbVZeH-apocF?qWs`7$H3Jic~RsPATU=GL#QE$906L(9FGAfduuo}Q=0fTuqYY_EJa$5m=WI9M01H-jg zY5>`VPMOm@vcLG#z!d(C`#yd~`$LC_J4{8cYGp=4T6;f_KE083;kNnbD@R;rsl?T;^nQ>TXQTEcs-ZMsT@l)QgC>M@S%j*>RIdeZKc841<(( z^7E;Qp_Xf^ehKxHHX1NM|SE$Xat9j>$v7PW?_!%+&D zdt$3m6v#rlR>=XK3VKg8s{63d`Tn6ADH?s=Ld8>|pp3OI=^(i=rJNq)5Fee4%3PzW zSJ6|6wAG0l@Ry?{GL1@b$cZ)a!k771EKct?TE6#Q8fG?}dE~wrD?nAO6i0DKOdr=w z@m0eY^1Bq0V7p+cYi_npB{$v|60Ods$Cz^$4KqmE7;+nVh}citjMf41~7_=bWhBM0w%wWQWN*A-9pb8z^Tgw`|2c~} zAcUz`7rOcl)9ic`UhttSzT3?z1>d(G~ev^7~%@7gR>{gW#C4Gu%1qE z5s?H*zL^(>`x*o0xw2aPYoBVww^t;c)81B-dZ2tLnA&@B^W7h=UoOu6HiSQ1pu@q# z9Zfh4b8I+0kdUNSm2OL$Zd#kqEQ~5x-p0hM*<;x@MgCHEKh3X;GWx-j$b!tF$vx7l zZ1bFmiQbV7o0mZEt;X~*L~^&5Z7;)AypHirfPAC~P4?@AxFnKL$f1{6()_o6PPQNO zXG)z98`_|f0ogac;SY@zI5lucz)S{JK4i`RKm5GDW&-( z`^oY9_V2QgwOATw{e%jm& zKz_-cl%D!GTNqwT`jZ4^)nui=9zN4>-+NK{%C%3A2NmX}Mc-_DyB z7s<*}?fJJxRO16~@)61UCpdx^+c6)JddBadSYDU%tL7`I0LFSxuIy2Fx#bz#MzVz%J^A} zE#DP+DhDKmH|TsH%e6(zy$rwCjEOOE^Sidq{>7 zPI(!p*T|eIJ5_ z^$IFgvmVB46=@Q7zd6LYxxcN1U&c3$J>vM8Un*VOf}^&|+Z`Y&{LdQGZ)lS+k+H9* z`+CiT!tl2M9`|lhUiE-TEZK}5Qrz8#85GSqty>thtF{sS%GpXT|KTQNL{}Gw#BiD1 z6paoFSGZZviTHoCrql$H?a&?3uDjaVoqUhod^tqtGrO?lVKvgXp5o}aFmizKkdmlF}4I{ToL1@wl6(B zJ&YsT?M2+x{0}Wx4L8H^*v+TR10m)NpJ?6O-0H!_cp%dq4aBhv6t_OW*%u+xk46cj zMCn04$?|BaeM+4O)FzXR@?;hsyh0B?Yo-q(+5dpN1Xdb@1qT?>v=;}fhmY-_j^ff^FS-+}O^F77{SfDbEFIfh(&SLF60*pY+caBifX64>RCqMs z+l6)@h?h|@_M_=%+8v{%=hX`#;|86*4SCF;WD5hm(OHb7RhO(z#)XoO1MTAFprNqH zLseO)y7CH9jVk}S_^F(Vovyw;M7U**u<`L)D{2F5xBPp_^aeR&zi1|^-bZC}LK_&c zI|#P~a{$up%gP;43%luGCM8aq**R{}16h_{L2M{zPDBRQBXGZ$c?}`h@r<_`Mwv07 zEw*U$rl^4$u&fsP&s6(|g5(cqW{3h)5qTuOM{-7KZ$m>vUr(264T0Oyo`bHz{cqIN zZ^L?|zv36|BRch?v$F&B3V&YEukg&(&{jD77p>mAfi6`W7|mTpm9NF)+9$3Xh4#(2 zj5hN&B;K#pZZw0DkHlIJh82S}OQvn5rJ{(I?rb>kNo+@&;+)ay4gKqzzth<{-Ij#sc5&1)-Vbl4RjF2ol#z7O zLyZSGP*7h1y$1-;f71AzyI;{0JudS;R_(Tl8B11P%UWTSmzFZKF^wk?xhHvz@@B8E zOHG@9uvvjwfg&+$H9GjLyy)cC5kIqkqO~3O1mgVow_=IvAe-BBGeb54x0ZApS-fcj zGJPfZJY(0^Yn7_&hJ~-Eoipf4zHjeF^<)&VF^Gx(4E78q3wkz6 z+7*8*HG-CHR~)fAvnEkXbEFl~vIzzzqj?(Zrqb{7>014t1qki#?jD=nIeM6X&*cO% zQeh^|27+ba^J3Xy5DP*gkPU!l3oPjRj2~pqMua8`-w3-ualLFk?-~z_V)iMvL5L)o zBUht%I+YBrvOc+;N5;s@x=Ai0lss;|;4RU~y}D9WOdUjFU$)HMQ%Er>Qnn}v+596D zE;68jHgL&AHPAbn`&!%AINt%sYY+6>){4oa$ma5uekoDWjKF$(p;e9UT)BQ@z#WracV6j)}k^q$gxOv+KR;a>BskOx|`O&<*eG_CTP8C>?vh zmk_f@3Of)Oea+rByo;JDzugzOnE%_P3a@^{^9V~^sNrtj!(}l#$Zw#osOm9uFJO8) zcO!N;$Zq8IE%X)vnBgdFOl?Et^K$@t4xr(v8A<5r*TGtkpZ|uT1Gr&-vTGiwiUyY& zuYi&J543*Jh9sn}g#4ry=lll;iu%FoYSuZUWAyoyWp&6Z7)Jt&J9|<5g2JGm4!Iwa zpvBo#^|4!2ru!uM*A>WG+>oJIDW6Uu0V%1xiwh5K7(Cc0!SzUhRqr19sYrH5q7QuD z+>Z!9pcZ#g{$7PT-ta29MPz=)uSEW+J+Lji=vJsR69`?Z*Z=5$z_M;oa8wW##`wPR z@6gM<#!W0t&Ah*f+P4r-Bo0{KFn(WwBfhbbznJ=xN`J_)y7j)dzyPH9esJ8whL=pt zoNVb`gwF?3;7I+?^tz&9Sdt&rNP69tgTy<|ZhAy5G3Z!W0P|g7k#*0cD;^FW9`m2b z6kYQ`*LfzfUup4>08SMoL_VuO)h)ye1BvwV_kzPAb#h6w9VEn;iN`ZvPM8oO4vN7f ziyMNQ73^duZyOXS@}NxX?(s#Ec5VLmhkoNa6>b#ZdQGmM-~JH#{To)Bs4*d9KKo~4 zWok%_5jKpZHUvwE)f#gJiJ?0!Tyj$qqxWd1~ zs#w-mg5H%`pW-qEMefu^1YK?nhf}0Z!6%NwP^Xi6Rwo>Unx$wd^GUa4yvn zkrGw=(+z1bL-w3^z4cqv>Y1V1YeUUaUKp`uVZwI*qb`vAkAO-_y*K9Alw%C?nQw` zv@gAqjUw)24`093uZ4t=rHhf^^)m%YQksWt@>m4qKw`cMP3XJEGrbP^WuI0Oq zLH>hM=bb8R@imVeZ0q0_KpjU%N87^hOH@KNq1QB=LQhzVIxR5;7G(H{l`^XA{ToL@ zc%9Q>>`&5$b7y*exWMaKPmP82CHIi>1gj74X6AIxvUV z3*xs#V5&z(lqhFpL?e-NqW|6i28>JdPuJbm* z2MgX@Fd6{XIL36mmp1x zM2$6Fj;I&qgRW`er`oLw@7{vf9BNlwCs8u>SX8IF5c=g^%anP1!g(zYq-eZIjES>9 zwqGCIPy;7I+b&1DFLyb>wtfGIkBQmO#-7Z?ay;|y{cy5<>#|AbpKbo_8^Pu6ueB&O z8lS);ySq9j z0)eEq9EEm#l#=Ri=Y9Vm<$8;;_joH9xZEs!+C`LFDNOO3obCEu(SF9q_aE3Tx;@xOT47 zUY7Q>D!dmBmU8&8h)YJM09@H1APul@Aj=HQn8Jh9fj?r3n?iLa{!DFHB9!H4T`>YIRUZgw0KH#5Jt6f8?i)wiiwdTlG)4gDc+>{s*lb!?v@MoZhYH zUH6xxEe)gw`^WKLs0%&z9~{tjv@nE6fBPl{M=*?~p(aO|_^c0Siqd@>uf>(S#V(%g z^Kaosq~c;=V9c4bGfLb`sX^PTKIIJ#O#~~LOFcDx4&NO9_1nC*#+1WBm=?!G zNkSnEhN#wayN9hcZqyO_H!EI0#1h^u`=^rnYN-xvJwH2$&=Cqu35+Zq_4QLdI zkdvN-CWvZ{>Rv(QxepD=gXIc6hmP6IC)GNn$({zc)|A1anHg>)90ihvs!LzUV$@^g zxIrRh`8m-E(@S9mh2ez9T_EFyL7@b2#G_rgv0T3ncoQ{;Q_5;d(2Y;+qk;T$`3% zv7jJhavL!J?BkO_MY#6yurnz`lOJ0+R78J%o&=~YP+!E+7U9Zsft`l1u<$n?Hyo&4 zH|7M)dPG?*b40gn9>Ju~Q^B_z10*8w4OD;{d?}}({$R>IU)leGTeuxidU%YVT(gqt zmrNquSK!xp{70#xeG+ z*RrjP&O?9Aga}R6B)Vy_SK|VbZMUz=Opuc-WiX^90y8bU`y;brf@)6_b=hd{4yg9! zuFMm>`0RuQT%z<}&*lV)>FB~a>{j0b?FY!{9K}vFy=w=ik{`XCJCj4nZTMhd2_UKG z=(7PY+I?c+r=M8m*M!89xCMnG3o3hIli1vJQo2P!P%Vr%5n(+*%mQ=Nx8Q$R&q*)f z`{coYdWVhZ?>>G6OxbD!tV^eoF9CrSPdj^9V`Z@!bTOPt;-GKdgAasI7qnEIj!cx5rPP z8iMOnk2tMx=+#4jG+{+`*2t7S(<_<0(A&}f1gk5*`cG51y7nvLos@SNo3=d!#Py;w zHGe9$k1iCnw0a>k3nUn1u#FM9pW-nTPw~){$YffLxIMb|OG}ym{J}4H#DtBNaz+w% zd_0ljf<$uau~F_JwwWex@g|Q{dwO_SP*AYN+xB4{DsIX;=DdK}zz3ee=1TIDmsb7j z`eI{_%{cBYX^}A_AMcTjY17;+3IjB}*!nm#J4K@0=*OrjLWBzk2vpK%V1X*C)!&}X z%*KXxvAbM=pvC}||8gqyt|rD}{-6o0)v2j89;OP$U?e5)z15|WPK6nh$U}r*FIXiZ zk`%yi39P{MZ?4Wst-|HVq|-Z`O-)45A0VvagRnlcoqhZQxQ2jo{)de?9W1xar=0?V zCR0`Me*#kf9(|UPyZ&>ld+yGbtQ7wQ{vdvFc&Mgopk@Mp{B}QX-H?Q6KfTGN!QG-9 zx^$j$k0Z)v>x+Kf&E6x?>;~eU1>R5AR#v~t%0{btRe8d`2i_%yaU|GRf@7hCm>4pU zSRJcU!ni;b{DR5wQ6nGEX}2eq0)5?FjfxpxC+WM5hgnz;aI5=Oh`!Ut%W0hOr`UYVWD__MbWENDyZT_zuOFcbxj`$;)rC)ffJ!fShEbdummY z6&`*d!TCo<|9p+GI<(C9y)@S?6NX@tUloe~GY3EFlpj^*82kq>FT=|m_}(Q{CVtf- zVmj_`xcmKZp+S~RMj&6D9QAT%FzShe_*2SAY5WANd7T9}w_?t25T8TLMYSF7aRNWX zfO3^)pYLO=gtxl)z$yr?(LdeY;_u%Bm1(jhto%_LMEHO!h^s^2;6g--+q)Sa;Yhsd zO=9O-yYVvR&lA$liwJwqx>q}}h2Yh|i-d|4+_Ne*d+lkOWsUj>Y(r80CK8eSnWGd7%v>iL zldL^SxUI<8J=NazP_?n9C0eLDo#cMPIaQ>Y^Sjx#XZ0{2qH#bQ2|D;FCao3$8OohP>a8CNzqFc7gcU?m20*b7ZO5=4IX z%3@4W&${c^$|~(hgk^fh9=g9ygZ~7tvxOQRtpJ@Asroe3x|mBqStK z-`JzTbQk00OG!9`AgHU+gqf2qGX`Q0FtM19dOPN+XlRz)<`1RoF8@~QwR+6ex*%c< z=8^{5Dr*c%*D4}jvOV(?_$wQvVhjl2)_g-M2t8n^460rbulHK!^=#RkO5!THCBl+` z%IewZq?6LbrGA;ZskJpF9m%KkfE|*JB+0DGTk)RM9lWC}*|z?mz^Q=TOQjyJC$d@< zs3c;aM#e&q8`pfb-PUnkDko}q=6K!xgpHBvp#TT-mViEjcMZ-pOk`wa2tz+a!n>p- z3UC6xak~tq75DXlx5g^lQ+oWg@828UEV+{xf-z13DjA-c*(wuOa?7tdOmVx-mjvf7 ztC9-H4F(?oDE~k-qYol1Kms8oL&9Z9$ihFZjbKZ+7~-3r&Y@p%@1Q$YmR{($Ugabd zA0t(VvM63R=nNayG7BGWWf^0nyD)p6aWJq=*PWDmT?Os!Uaft%Luh=~2d9_}e}$W5 z{=DY}7Kgv25*Md>D=&xOBOv4p5GSCVtIDsc`T}Az}FRUTZ zLF-wUF!Nv7JVNNDfEjYwXTaZ=DJW5vfjk()uS`EK`(7-`3Cw&d_K=_dwU;? zi;ELD9kNB25`&kUn2hL*Ad?N~$7mN{YkUd(cgnwf9N#9Jse9OR==J?3&r9>b)lH5; zmnrjmVEB)Y1?_5Y%b)fS9q&Ef5 z8JthF>ZQne%92`I1n`bCii>SqPl8+IG&DBjwf8&DZYMSy zVtN1l`*_g}38`IB$-z;N2J=~jnhD~I0;apdaD?UYcu1XhKF~))q3+d1_`vB&O_u>) zIZc$UJhdtPHJZRj^yjC1{2lu-9tCAP6T&^zm%lPjB5s5x(6!y@1&>u@@!k`+a!~BN zEo~eJ35SjjU-kvLybP%iif-ax@D=I##t{R_qVN%5D=Z*FZhMU~J3w*yQejbBS=nFY zr;TKTx)T<_nLB?mZ0;rKd4YfMPfS2Q`X6WUBQ<;O z;4gm#X%ISP2ZzDAxdIfGLp9_OBtJWjXTuP!44 zNjy3*6q0eeU9gJ=)30a)228Q!b~wdGzV6;>>6oHl)!WrFWB-J{R5?DEs{QE}ZgkLc zLSkKihRwqPeL~i!Ls}4+1yuaXjqDuw_CJ7+^fz`Jy$M!(I;Y43lAn`57fkc1V3}7~ zB|ANs>r(7&s&y>fCH_LT9zB;0+EO5NLFF%KY;3HfOXbzYC`%@7PsKbe2?7eZH%P~c zobHL%orDBcpO{UJ8Hh? zx?+5R0}er}h+Sx4KnAMih0THn!qOCJ>({GAzHNHCkbhgxsxO<*Zj;LCKn)JOgPoM& zIF$N3EwN|PQc_#ptzasLxRBu_fwCGPQw}ihajd@#5BM(|wduo=zaF>*T{6BE384xe z85yah!(sf-UTytskw>85w!S6)vCrFuc}cSYlGds*lWK_Ggxdiq>Ho^rnNJdGtQT8? zf`Slo0XW6WTWR14&vO6op?>x=<&1Zm)F44C6oP*2LGBdH-G{Pgy}zlRNu7 zAcu5`iYN6pY%d8PrQ;~G;neDMCK;_eL5czrOfLi&tY}!rwv!8cJ_?D5FoFOJ5oRbW zi;g&ccwFs!zMO6w!f6K4T|?tWs9nz~cYwGD$X0mvRc;IW*JqRI0pDejiBypaAh{V* zcNNstgC#QbHb$~QG0cT8ctU<~_ILtMY5*=J%inFo-~$Ilxrq|vi0&iRxjiY;^_h!h!M&;tWO zR#mkViW0DH+%Iz>UEE#tY#WJN2hRpp@Oa48sOT%fdfgXb-!$jbiL2c3= z_Ob&?9q{8!gYJNS+bs7(zY|~Bs}=cvhxFVRu@Y_2+d*tF2?Ima{t`jKAbe4B)Ukpp z&$o62EXs(>RU_5koZMPla^AXO>E$F=#u;H(+*GvKX(O~tt1+n;PIPt_?kXo-j|+ojT~X$_vKxN6kPo0UtmNycwYe^xPZ-<5M@vCkwAs(klxfDD8CK zYyNPNa7%SYi*YNH)R5dboCpzqnx0O!+Mg;T*6DXa1T8Yq{v)!gUmhIVoQAZ`4%&CX zzK8YJ3F`~?EIL-)z&z~Dh3AI=4oKupK$tv03d+ksxxCVCBvTAAwj_bp#8npJ`59&J>;}?0o z>g&`q<+BXH^%&881Ec^h4^ZAi-6zKrRFwo6TX6}2$*p^QAriTP6YID;*9&_? zTt2Kt*|<^Jy7ac9FEDS;TU93vJt+_!L&l^Q?*he92)o#Z+86n`Kxb!2e|dFOl2@n@ zZQ01f0t0q&Gq4(mZv$v$F^P%mdlPhzYl&R9VF?Hc3qN%gloS&q1_vQ%qrr5w#&P@q zOr2B5T~ALVQ8m*R34!IyR=@ug@m5&IHJ7S_eDu=RhU`1R91!YV82-aK`=h=nD2I32 zoBOZnhkrXvP=0>n_4o?b<_v?LQc>Uh%l2aG=aVrV@}n zp_pPhUGAbfv-JH?YQ6xT0URjR)zt{35{TN!%`Oc$^9_yVhYSx|JPi^fP&o`FB!VT6 z2RuG;Gd$HOBYdGgk%42Ex@LJ>F#8g>*P(M%ymPdxb1_<0>YH%qkIkr@7?YC8;Q?1C zA?nN7fAB;@aM4oaH(08t(Isz$1{yrx3z@!#I^q5a5g4w)JtvnlDwVw9`iz_X^&a6& z^2QTrLtw)PSX=lIzxBLwZjT^}n6#)*PYbMI-6jA|drC@*TD2Yf8>#)i74CGI}DgO@1x&9mT5Ho?|~kW8o)J^;U2zuC>& z^<0kjQ27U5+FXB_UOs$-cBk%YI9+3?Z@-g!%xVKsN_% zomJI70k?A`G>7pw?d3j{QKlv6rSs{qP2rSeFdw6ZpNE`t$gD@oQ{Gez$ccg^+Eu_& z@U$0tQ%P9e11%1^t&*IW5myfM&!N~QKcTw zDG2@$VQ`QHD-&UF%$U@h&|i*jW+^C!4#}fYf+>Eq*KVEBI71~%k2=?KMH8wwqV3?k z{hgb8RFe|+dGH%+ybV!H(eOx~i$OQlv1IvI?CZ*M^ z?BuxHA@d{WX9Mm#$-T(xZcj3#D1Jf)5YQjt>yS}ajuTyVX#E`T+QU2DXwLv&C*nBb z%wvOrF8~jSKb)5*Trhsh^B*NSxt24dBPpbNl%JX@hWXGa&mdfhTfnD#h1F&^PuTM9 zi@uHr4gB9Gkj5H_AMmW7y^DHHW#xu8JtzHPCW3nt_r7*`NC+bO6#$!tK9_>uzllx8 zF!ikL-2*ad5Ota&HHYmoGIXE_lX);!B53D%zkWJ+Rr$0b_6+9~^bLo^)2@7%i++ELK#@%)M zC7`FbSl9*?6qHgw2Lqv$wP(>8x<&Kte4Q{$Gy~6R4G2 zjK+ET8>N*u!Rv-jV#rZEiEDc(DT(kkIZD#gt#j6xXqwxw_#GKA zY0<7%^no=AAGm;;pWCq>K!{Bo-QspeY9nHrPpGZ zPI&&`P@c=@U&jc`4+4ViGHH{~k8ABOAj zy&8amb;6>&-g4K6m{=S$n(R2p6DG|d-l%+ySWcLjK9*}soxf%RBN?4flz5;-68bplD+s(W_d)JB?F9`4hz*qhb%tE;19|%Pd^7O0^ z$g@X-kMg;$OTf@@r7&j;ply#=LvCwC*){!<_T!|4T7DM!n|U{63Jm4_wvjrQG`^1| zvtTc+iyz{%JwG^8K&ohGd`c{M`bUQWTRj8y$IeP%4b3rEf zb_puRUUH!ZEe<&DgMCiEMu!8%>fGTNHFnO;gS3_aM6+fJ268;JWtffmLp0fv?L40> zE|SoTG{- zdi7O3rsGE$)A>PS9BY17%VP*7K5<=zbK_arH#^EYHD(-h=1!jR7ar_jrUd2bDVOde zWWaEt1VW&%u#4ZPaCZSD3#N`cT*iQ=%s@gCZ|RUr{y)V|MAl`n$WIb5x&uoo*cLFU zvUST=rWd$M7E_WT8LbaO-*x@^-J;#HGa47;c>S||Ye9Ow_R?{ilFk!1=#Jo)tM7hc zRv^rMjuAY4h+m&xF;-L2Vq)v@EDCE^Uq&}eruh|Qb=1#uY~8^Ndl)7n2vLFF1clbM z`wt<4w5XuhF4%j`GX?Ub!R@;nh}NLSN$DuT^Bu{I#N+diyZ`eg74`GBZ5I>l<0JRK zLUeU6_c97U&zheaX1s&5xHF!2XM_c=R1dAMwSETfNF)~2PaMjpIXO965LKsH8SnP` zCN0dJNWl`yJip3+5v0{iOLfuJ>+kO^dZuvg)|Su{jcIfa-TFIkG;AX!iv;OS$~;cI zlCHjS<&pnvwTvPXRNzOTxf`|dEIs?4EEPsHLBTTe3A(p@){*wsn7sFh>kE=|{>CtZ zWPCsx{6H$6xv_tVFzMH-aXk0P`qYFR=*9<8$s#FUo@b$toJeanuv+C$zdQ>|aihsJ z;+0;DZxEop4$)}GNA*HKxwvqJwqQ*@znH7gZJW<#UA@3%ceX6>0x(k#k49bVCs$FV zq9VB$|N5al4@cuY^M)kfwx>VU7}kTSsHg;ti|;AZ;;^A|J@{mi=visrkv&}M?q}ls zpIWus0~biKp+(_)cx-s`&AG`#hV+Jb>ki<^JDc(=kNpA0L%Sgqcon7PklDSoJ6*9G z?N7TSLaP>RVMO@gX?@`O`iPLV^~}Koh$ewgyAO0o?Ta57zS{*W#U7XD?%(@A%wEtk zOI%7ShlKmoVlvu>boP#UX9SKD_6w&*Nyp*XpP&EB!^usQDRL=`;i4LXn6p`4FCZ`21B_M?u_+CEl-y5%p;?VU{_khI^xtj$5^cC$^2g!E8ILNI zBnadK6t9NxOnb)|*PCCD4uwBb5o2qZa_fn*`PNdR`(H@-cG6f(7A88gn#GK!_net< zYD402BHjaj;nfT2<@N0ds_I(l-1jO!q;z_J-NRCqUbdbRdvEoaBIo(+N#;aCNz}2- zihi?z+|E*Nuk0&i^;8befBVo|@v=fL;yL)ofi;kb+-JSn_&~$mS39@czuS*+BM97I znpbSwd15%cjgF3ne$=Y)pFCNMk#8VspgO~1RSNPTWXxPT%#w<_KqTJTV1(!L!SUA~ zTE?O~6!yZLxm1@85@Q#~2H9T(wRk((-!CrrHX`-N|JAok_ic+1_gh5~=^PPRezdAyr&w=6b9oJB*8zHFj@a?zgF&<;>P4LQ$;VrNjdA+uXf8N@n{I~ zO%*SX;rn>O=;XQwqeFS{ozrMPAP4YW9_-GMjkPvxR6L8Mi@T))3|O7S>hNB~x|STt z>v~-?NRE2QA&a)#Jfo-moh{biC-9U0`#bAxC(WRtY*IcMr@tu%ENLYsT?15iX?{gx zKGeKtQo>fuxzs%SKBg#+Ha5KW9?yN5GTG?(`CV5USAz7+2>gnR{Rpp~M?~%m%a`_O z#YYXqDXs%^yS85dM2-hoV|p{GU-k2=O;gt!8@8b=8Ik%k?wjSJ&mQr{?(VfRj!RP~ zRs!_~M*vsWaqL?C?DlqERq3Q?SUk$uIamNBLrhxw*d=0k@L){Cx=6R>VFw$Xy8Rb$ zSc^>UUhO?k$h{+>NKO9z6_YX6CJPd`y~8UXc|q4PS8bZY6tCGqSmreI=t}Ko#mue; z2M%jU6T#YOiIeI_NG|{Sa%x7?TYiZ1H6(l&ip_8PddeQL7N=Qx_cxGOMz&Idi?|Q zly|J2k9nr8yxU_wYp+E#&&|xBE{ry7+);f`iih?p$);vHz%l-mE=4uM;^5Y2rpWbJ z@Nae_8w(4h1F&zT6y7v~l9Auhu{@+Ekj22raEM0#*N=PlIBrkLzEBS_m8dnCa*z)tO2u&*_aPus>n_P)u#WsMos`YdYHJTB+t z|0HNlhFPeT)^mHue)RYM6v}KF!Hq7`FHeASSC|B@Avlz!1baoN&(=2kIwQ?)ITx!)qvGO3fLTJJ zz{f`-gSWCCfVyJZmr<`gki7XPhhmyak@<^$Ef|1Uwn?g-D^$R=(tVwmFzKgR8q~O97mk){BDC<%vZ0zd|9rS z;?MCv>YvaSNhWUP1|73rHg=p1A{yaJUq^Wt~ktc zYtK(J5eFRhe|*Yru!WIsmmxx%a@Xn{iG!m+_m8{xqx_I}@u@OFZOvH<6uI6R%UZS} z6n6bfIf)hy_TTpVlw9pO8;r0MoMtF9{K>@3%au;JRH=B_DTO4?PIBZYy)9CoWu1+( z41HAzp_edrS5hxo&5*>r&iG?F^wk&LbjpJ(9i=N4>IR1_d9e+=_3J1m;zuiI`%6Pk z4VPNubdIFu1qE^GbZf&e9wBHh(LfS=gwN%$@kLQdjbt*s;mxu8fsXuX5}R1LN5ABc z{qGv9_-`QnOy13{W_*?FL@_`VkT3PBsLDlv9nkL3{1(6|tUqt15yFnhQ=luPHpQ)@p zyhlCqJs)655D4c@F3)hFs9dDxjG8&_fP~lJz$~OR1Y=}gnA`Qfz@oogL@Y!ZrwIng z=8RHd2cx7VD8BJ=r6_*Q_A1HtzPvG$ybIyt&HSsf7w883$We3jcSd1c2A^0ZL`IFe z{#CgReXwEvXoL+8*a|~2*K*v~2AF}Q8_t(3!g7m`Xg3hE1VRE#XzcgT>poEh(`wgF z=>KP1C-WybkiFf!EOcDRxpuBP-n?72D1nxW8LQC~F{65SW1nOw$%qFL zqTmfN`G?D?NmJxf_cl&^8TIMQma4hKb{F)sZM4r7U|7 zzqa#VB$%euYO@^ZrB`ncIO6kjRlk4#L5DjpFV7IBGXRTmIm*dgx}?#+%ZAt{SM7fR zCa65>2-cGQKXl1B=LL}=?FdB=^g(CztP5hQFP)gU#AI*2S5gFKF|D$X81-ubj*JJ- z*=%Eg4Lge$?JEif1@9V5%?0H42>|5K9Z!GSb(QiibJ(W)O@*wo@~XMQjKsT{WMYV^ z1FPGM+W*QFk5Q@0kRRXf9K5(L(L33h?FK|2UqfgkB5%`2)XR#*7l+Kw##xpQr#7eBF zgyoBNJB({}$ao%L)t~oTUrb?FV4^!wA~akR)ebZ+F8RCn>~R-^jyo}T?Os>76xw(E z`0*;gdV{*TMQf|*^&}m#htgdEvpB7PW#i%5%X<*Y*V)q}l=hKC;B}DL=%c7?v`OK* zbJS)Rl6Z1D@84Qnp#Vi{DtY8j^xNMqv?$vzq7|)kBRVJftf`&1RgxZ$Bmfxr#*g*oXXT1 zo3aE`Xs9CyW6c(BcDnf&8V-|p_omDX!Y1F#wP3�f-*9LjnNB=Tq2)EF^^W&nCG$ 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 literal 0 HcmV?d00001 diff --git a/docs/assets/examples/scatter3d.png b/docs/assets/examples/scatter3d.png new file mode 100644 index 0000000000000000000000000000000000000000..05a9266f0f5f8b7198c7d130e4edc909df5e8b4f GIT binary patch literal 60577 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

0W-Y8!Xutomh^J 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 Date: Tue, 21 Oct 2025 15:01:05 +0200 Subject: [PATCH 66/66] up [ci skip] --- docs/plot/supported.md | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/docs/plot/supported.md b/docs/plot/supported.md index c7849f3..e903f01 100644 --- a/docs/plot/supported.md +++ b/docs/plot/supported.md @@ -62,3 +62,64 @@ The examples of the page [Histograms in Python](https://plotly.com/python/histog !!! Note - There may be issues when many histograms are plotted on the same figure... + + +## Bar plots + +Some examples of the page [Bar Charts in Python](https://plotly.com/python/bar-charts/) of Plotly documentation are supported (not stacked and aggregated bars). + +??? example "Bar plot" + ```python + wide_df = px.data.medals_wide() + fig = px.bar( + wide_df, + x="nation", + y=["gold", "silver", "bronze"], + color_discrete_map = {"gold": "gold", "silver": "silver", "bronze": "#cd7f32"} + ) + fig.update_traces(marker_line_width=2, marker_line_color="black") + tikzplotly.save("bar.tex", fig) + ``` + ![Bar plot Example](../assets/examples/bar.png) + + +## Polar and radar plots + +The examples from the pages [Polar Charts in Python](https://plotly.com/python/polar-chart/) and [Radar Charts in Python](https://plotly.com/python/radar-chart/) are supported. + + +??? example "Polar plot" + ```python + df = px.data.wind() + fig = px.line_polar(df, r="frequency", theta="direction", color="strength", line_close=True, + color_discrete_sequence=px.colors.sequential.Plasma_r, + template="plotly_dark",) + tikzploylt.save("polar.tex", fig) + ``` + ![Polar plot Example](../assets/examples/polar.png) + + +??? example "Radar plot" + ```python + df = pd.DataFrame(dict( + r=[1, 5, 2, 2, 3], + theta=['processing cost','mechanical properties','chemical stability', + 'thermal stability', 'device integration'])) + fig = px.line_polar(df, r='r', theta='theta', line_close=True) + tikzploylt.save("radar.tex", fig) + ``` + ![Radar plot Example](../assets/examples/radar.png) + + +## 3D scatter plots + +Examples from [3D Scatter Plots in Python ](https://plotly.com/python/3d-scatter-plots/) can be exported with tikzplotly. + + +??? example "3D scatter plot" + ```python + df = px.data.iris() + fig = px.scatter_3d(df, x='sepal_length', y='sepal_width', z='petal_width', color='species') + tikzploylt.save("scatter3d.tex", fig) + ``` + ![3D scatter plot Example](../assets/examples/scatter3d.png)