import math
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.express import colors
from plotly.subplots import make_subplots
import base64
class _CustomizedMarker:
def regular_polygon_coords(self, corners: int) -> list[np.array]:
if corners < 3:
raise ValueError("A polygon must have at least 3 corners.")
radius = 0.4
angle_step = 2 * math.pi / corners
polygon_coordinates = []
for i in range(corners):
angle = i * angle_step
x = radius * math.sin(angle)
y = radius * math.cos(angle)
polygon_coordinates.append(np.array([x, y]))
polygon_coordinates.append(polygon_coordinates[0])
return polygon_coordinates
def clock_marker_coords(self, corners: int, target_corner: int) -> list[np.array]:
if target_corner > corners or target_corner <= 0:
raise ValueError("Target corner outside available value range.")
target_corner -= 1
polygon_coords = self.regular_polygon_coords(corners)
corner_coord = polygon_coords[target_corner]
left_coord = (
corner_coord + polygon_coords[(target_corner+1) % corners]) / 2
right_coord = (
corner_coord + polygon_coords[(target_corner-1) % corners]) / 2
center_coord = np.array([0, 0])
return [corner_coord, right_coord, center_coord, left_coord, corner_coord]
def marker_to_scatter_line_coords(self, clock_marker_coords, x_coords, y_coords):
scatter_marker_x_coords = []
scatter_marker_y_coords = []
for x_coord, y_coord in zip(x_coords, y_coords):
scatter_marker_x_coords.extend([marker_coord[0] + x_coord
for marker_coord in clock_marker_coords])
scatter_marker_x_coords.append(None)
scatter_marker_y_coords.extend([marker_coord[1] + y_coord
for marker_coord in clock_marker_coords])
scatter_marker_y_coords.append(None)
return scatter_marker_x_coords, scatter_marker_y_coords
class QCVisualizer:
@staticmethod
def visualize(site_ins_qc_results: pd.DataFrame,
file_sets: list[str],
file_qc_results: pd.DataFrame,
qc_issues: list[str]
) -> go.Figure:
site_ins_coord_mapping = dict(
zip(list(file_qc_results["Dataset"].unique()), list(range(len(file_qc_results["Dataset"].unique())))))
file_coord_mapping = dict(
zip(file_qc_results["File"].unique(), list(range(len(file_qc_results["File"].unique())))))
fig = make_subplots(rows=1, cols=2,
shared_yaxes=True)
# Figure 1. site-ins QC results
site_ins_plot_table = pd.melt(site_ins_qc_results,
id_vars=[
"Dataset", "Site (anonymized)", "Instrument model"],
value_vars=file_sets,
var_name="File set",
value_name="status")
status_marker_mapping = {"Completed": "circle", "Incomplete": "x"}
default_colors = colors.qualitative.Set1
status_marker_color_mapping = {
"Completed": default_colors[1], "Incomplete": default_colors[0]}
default_colors = [default_colors[i]
for i in range(len(default_colors)) if i not in [0, 1]]
for status in ["Completed", "Incomplete"]:
sub_site_ins_plot_table = site_ins_plot_table[site_ins_plot_table["status"] == status]
sub_site_ins_plot_table.loc[:, "Dataset"] = sub_site_ins_plot_table["Dataset"].map(
site_ins_coord_mapping)
if status == "Incomplete":
for y in sub_site_ins_plot_table["Dataset"].unique():
fig.add_shape(
type="line",
y0=y, y1=y,
x0=-0.5, x1=1.5,
line=dict(color="red", width=1),
layer="between",
row=1, col=1
)
fig.add_shape(
type="line",
y0=y, y1=y,
x0=-2, x1=(len(file_qc_results["File"].unique())-1)+2,
line=dict(color="red", width=1),
layer="between",
row=1, col=2
)
if len(sub_site_ins_plot_table) == 0:
fig.add_trace(go.Scatter(x=[None],
y=[None],
mode="markers",
marker=dict(symbol=status_marker_mapping[status],
color=status_marker_color_mapping[status],
size=12,
),
name=f"File set {status.lower()}",
visible="legendonly"
),
row=1, col=1)
else:
hover_info = []
for _, row in sub_site_ins_plot_table.iterrows():
hover_info.append(
[row["Site (anonymized)"], row["Instrument model"], row["status"]])
fig.add_trace(go.Scatter(x=sub_site_ins_plot_table["File set"],
y=sub_site_ins_plot_table["Dataset"],
mode="markers",
marker=dict(symbol=status_marker_mapping[status],
color=status_marker_color_mapping[status],
size=12,
),
name=f"File set {status.lower()}",
customdata=hover_info,
hovertemplate=("Dataset: %{y}
" +
"Site (anonymized): %{customdata[0]}
" +
"Instrument model: %{customdata[1]}
" +
"File set: %{x}
" +
"Status: %{customdata[2]}" +
""
)
),
row=1, col=1)
fig.update_xaxes(title_text="File set",
range=[
0-0.5, (len(site_ins_plot_table["File set"].unique())-1)+0.5],
tickangle=90,
gridcolor="lightgray",
zeroline=False,
showline=False,
row=1, col=1)
# Figure 2. file QC results
file_plot_table = file_qc_results[file_qc_results[qc_issues].any(
axis=1)]
clock_marker_index = 1
hover_information = pd.DataFrame(
columns=["x", "y", "Dataset", "Site (anonymized)", "Instrument model", "File", "Issues"])
for issue_index, qc_issue in enumerate(qc_issues):
sub_file_plot_table = file_plot_table[file_plot_table[qc_issue]]
if len(sub_file_plot_table) == 0:
fig.add_trace(go.Scatter(x=[None],
y=[None],
mode="lines",
fill="toself",
fillcolor=default_colors[issue_index],
line=dict(color="black", width=0.5),
name=qc_issue,
visible="legendonly"
),
row=1, col=2)
if qc_issue != "Missing file":
clock_marker_index += 1
else:
x_coords = [file_coord_mapping[file_code]
for file_code in sub_file_plot_table["File"]]
y_coords = [site_ins_coord_mapping[site_ins_code]
for site_ins_code in sub_file_plot_table["Dataset"]]
hover_information = pd.concat(
[hover_information, pd.DataFrame({"x": x_coords,
"y": y_coords,
"Dataset": sub_file_plot_table["Dataset"],
"Site (anonymized)": sub_file_plot_table["Site (anonymized)"],
"Instrument model": sub_file_plot_table["Instrument model"],
"File": sub_file_plot_table["File"],
"Issues": qc_issue})])
if qc_issue == "Missing file":
marker_coords = _CustomizedMarker().regular_polygon_coords(len(qc_issues)-1)
else:
marker_coords = _CustomizedMarker().clock_marker_coords(
len(qc_issues)-1, clock_marker_index)
clock_marker_index += 1
issue_marker_x_coords, issue_marker_y_coords = \
_CustomizedMarker().marker_to_scatter_line_coords(marker_coords,
x_coords,
y_coords)
fig.add_trace(go.Scatter(x=issue_marker_x_coords,
y=issue_marker_y_coords,
mode="lines",
fill="toself",
fillcolor=default_colors[issue_index],
line=dict(color="black", width=0.5),
name=qc_issue,
hoverinfo="skip"
),
row=1, col=2)
qc_issues_woMissingFile = [
issue for issue in qc_issues if issue != "Missing file"]
for issue_index, qc_issue in enumerate(qc_issues_woMissingFile):
sub_file_plot_table = file_plot_table[(~file_plot_table[qc_issue]) & (
file_plot_table[[issue for issue in qc_issues_woMissingFile if issue != qc_issue]].any(axis=1))]
x_coords = [file_coord_mapping[file_code]
for file_code in sub_file_plot_table["File"]]
y_coords = [site_ins_coord_mapping[site_ins_code]
for site_ins_code in sub_file_plot_table["Dataset"]]
marker_coords = _CustomizedMarker().clock_marker_coords(
len(qc_issues)-1, issue_index+1)
issue_marker_x_coords, issue_marker_y_coords = \
_CustomizedMarker().marker_to_scatter_line_coords(marker_coords,
x_coords,
y_coords)
fig.add_trace(go.Scatter(x=issue_marker_x_coords,
y=issue_marker_y_coords,
mode="lines",
fill="toself",
fillcolor="rgba(0,0,0,0)",
line=dict(color="black", width=0.5),
showlegend=False,
hoverinfo="skip"
),
row=1, col=2)
pass
hover_information = hover_information.groupby(["x", "y", "Dataset", "Site (anonymized)", "Instrument model", "File"], dropna=False)[
"Issues"].apply(lambda x: ", ".join(x.astype(str))).reset_index()
marker_coords = _CustomizedMarker().regular_polygon_coords(len(qc_issues)-1)
hover_marker_x_coords = []
hover_marker_y_coords = []
hover_marker_customdata = []
for row_index, row in hover_information.iterrows():
hover_marker_x_coords.extend([marker_coord[0] + row["x"]
for marker_coord in marker_coords])
hover_marker_x_coords.append(None)
hover_marker_y_coords.extend([marker_coord[1] + row["y"]
for marker_coord in marker_coords])
hover_marker_y_coords.append(None)
for marker_coord in marker_coords:
hover_marker_customdata.append(
[row["Dataset"], row["Site (anonymized)"], row["Instrument model"], row["File"], row["Issues"]])
hover_marker_customdata.append(None)
fig.add_trace(go.Scatter(x=hover_marker_x_coords,
y=hover_marker_y_coords,
showlegend=False,
mode="none",
customdata=hover_marker_customdata,
hovertemplate=("Dataset: %{customdata[0]}
" +
"Site (anonymized): %{customdata[1]}
" +
"Instrument model: %{customdata[2]}
" +
"File: %{customdata[3]}
" +
"Issues: %{customdata[4]}" +
""
)
),
row=1, col=2)
fig.update_xaxes(title_text="File",
range=[
0-2, (len(file_qc_results["File"].unique())-1)+2],
tickvals=list(file_coord_mapping.values()),
ticktext=list(file_coord_mapping.keys()),
tickangle=90,
gridcolor="lightgray",
zeroline=False,
showline=False,
row=1, col=2)
# figure 3. legend
legend_fig = go.Figure()
legend_list = ["Completed", "Incomplete"] + qc_issues
for status_index, status in enumerate(["Completed", "Incomplete"]):
legend_fig.add_trace(go.Scatter(x=[0], y=[status_index],
marker=dict(symbol=status_marker_mapping[status],
color=status_marker_color_mapping[status],
size=12,
),
hoverinfo="skip"))
legend_fig.add_annotation(x=0, y=status_index,
text=status,
xanchor="left",
yanchor="middle",
showarrow=False,
xshift=15)
clock_marker_count = 1
indexes = [i for i in range(len(legend_list)) if legend_list[i] not in [
"Completed", "Incomplete", "Missing file"]]
for issue_index, qc_issue in enumerate(qc_issues):
if qc_issue == "Missing file":
marker_coords = _CustomizedMarker().regular_polygon_coords(len(qc_issues)-1)
else:
marker_coords = _CustomizedMarker().clock_marker_coords(len(qc_issues)-1,
clock_marker_count)
clock_marker_count += 1
x, y = _CustomizedMarker().marker_to_scatter_line_coords(marker_coords,
[0], [legend_list.index(qc_issue)])
legend_fig.add_trace(go.Scatter(x=x, y=y,
mode="lines",
fill="toself",
fillcolor=default_colors[issue_index],
line=dict(
color="black", width=0.5),
hoverinfo="skip"))
if qc_issue != "Miising file":
x, y = _CustomizedMarker().marker_to_scatter_line_coords(marker_coords,
[0] *
(len(
indexes)-1),
[i for i in indexes
if i != issue_index+2])
legend_fig.add_trace(go.Scatter(x=x, y=y,
mode="lines",
fill="toself",
fillcolor="rgba(0,0,0,0)",
line=dict(
color="black", width=0.5),
hoverinfo="skip"))
legend_fig.add_annotation(x=0, y=issue_index+2,
text=qc_issue,
xanchor="left",
yanchor="middle",
showarrow=False,
xshift=15)
legend_fig.update_xaxes(visible=False,
range=[0-1, 0+7])
legend_fig.update_yaxes(visible=False,
autorange="reversed")
legend_fig_aspect = [25*8, 25*(2+len(qc_issues)+2)]
legend_fig.update_layout(width=legend_fig_aspect[0],
height=legend_fig_aspect[1],
title="Marker Legend",
margin=dict(l=0, r=0, t=50, b=0),
showlegend=False,
plot_bgcolor="white")
image_bytes = legend_fig.to_image(format="png", scale=2)
base64_image_string = base64.b64encode(image_bytes).decode("utf-8")
image_data_uri = f"data:image/png;base64,{base64_image_string}"
file_counts = len(file_qc_results["File"].unique())
fig.update_yaxes(title_text="Dataset",
showticklabels=True,
range=[0-2, (len(site_ins_coord_mapping)-1)+2],
tickvals=list(site_ins_coord_mapping.values()),
ticktext=list(site_ins_coord_mapping.keys()),
gridcolor="lightgray",
zeroline=False,
showline=False)
def _normalize(values: list, value_range: list):
return [(value-min(value_range))/(max(value_range)-min(value_range)) for value in values]
margin_left = 200
subplot1_width = 85
space12 = 200
subplot2_width = 25*(file_counts+4)
margin_right = 250
center_plots_width = subplot1_width + space12 + subplot2_width
figure_width = margin_left + center_plots_width + margin_right
subplot1_x_domain = _normalize([0,
subplot1_width],
[0, center_plots_width])
subplot2_x_domain = _normalize([subplot1_width + space12,
subplot1_width + space12 + subplot2_width],
[0, center_plots_width])
margin_top = 30
center_plots_height = subplot12_height = 25 * \
(len(site_ins_coord_mapping)+4)
margin_bottom = 300
figure_height = margin_top + subplot12_height + margin_bottom
legend_image_x = (center_plots_width + 20) / center_plots_width
legend_image_sizex = legend_fig_aspect[0]*1.2
legend_image_sizey = legend_image_sizex*(legend_fig_aspect[1]/legend_fig_aspect[0])
fig.add_layout_image(dict(source=image_data_uri,
xref="paper", yref="paper",
x=legend_image_x, y=0.98,
sizex=legend_image_sizex/center_plots_width,
sizey=legend_image_sizey/center_plots_height,
sizing="contain",
xanchor="left", yanchor="top",
layer="below"
))
fig.add_annotation(x=np.mean(subplot1_x_domain), y=1, yshift=5,
xref="paper", yref="paper",
text="Dataset QC",
showarrow=False,
xanchor="center",
yanchor="bottom",
font=dict(size=16))
fig.add_annotation(x=np.mean(subplot2_x_domain), y=1, yshift=5,
xref="paper", yref="paper",
text="File QC",
showarrow=False,
xanchor="center",
yanchor="bottom",
font=dict(size=16))
fig.update_layout(height=figure_height, width=figure_width,
xaxis=dict(domain=subplot1_x_domain),
xaxis2=dict(domain=subplot2_x_domain),
yaxis=dict(automargin=False),
margin=dict(
t=margin_top, b=margin_bottom, l=margin_left, r=margin_right),
autosize=False,
showlegend=False,
hoverlabel=dict(bgcolor="white",
font_color="black",
align="left"),
plot_bgcolor="white"
)
return fig
class AnalysisVisualizer:
def visualize_barplot(analyzed_gating_results: pd.DataFrame,
visualized_results: list[str]
) -> go.Figure:
fig = make_subplots(rows=1, cols=len(visualized_results),
horizontal_spacing=0.4/len(visualized_results),
subplot_titles=visualized_results
)
dataset_counts = []
for result_index, visualized_result in enumerate(visualized_results):
result_index += 1
df = analyzed_gating_results.copy()
df[f"{visualized_result}_count"] = df[f"{visualized_result}_count"].astype(
int)
df["Dataset_wCount"] = df["Dataset"].astype(
str) + " (" + df[f"{visualized_result}_count"].astype(str) + ")"
dataset_wCount = df["Dataset_wCount"].to_list()
dataset_wCount_mapping = dict(
zip(dataset_wCount[::-1], list(range(len(dataset_wCount)))))
df["Dataset_wCount_index"] = df["Dataset_wCount"].map(
dataset_wCount_mapping)
if (df[[f"{visualized_result}_mean", f"{visualized_result}_std"]] == "Not reportable").all(axis=None):
fig.add_annotation(x=0, y=np.percentile(list(dataset_wCount_mapping.values()), 50),
text="Not reportable",
xanchor="center",
yanchor="middle",
font=dict(size=24),
showarrow=False,
xshift=0,
row=1, col=result_index)
fig.update_xaxes(range=[-1, 1],
showticklabels=False,
row=1, col=result_index)
else:
df = df[df[f"{visualized_result}_count"]
!= 0].reset_index(drop=True)
df[f"{visualized_result}_std"] = df[f"{visualized_result}_std"].replace({
"Only one data": None})
for analysis in ["mean", "std"]:
df[f"{visualized_result}_{analysis}"] = df[f"{visualized_result}_{analysis}"].astype(
float).round(2)
statistic = {}
statistic["Q1"] = df[f"{visualized_result}_mean"].quantile(
0.25)
statistic["Q2"] = df[f"{visualized_result}_mean"].quantile(0.5)
statistic["Q3"] = df[f"{visualized_result}_mean"].quantile(
0.75)
statistic["IQR"] = statistic["Q3"] - statistic["Q1"]
statistic["Lower fence"] = statistic["Q1"] - \
1.5*statistic["IQR"]
statistic["Upper fence"] = statistic["Q3"] + \
1.5*statistic["IQR"]
statistic["Extreme lower fence"] = statistic["Q1"] - \
3*statistic["IQR"]
statistic["Extreme upper fence"] = statistic["Q3"] + \
3*statistic["IQR"]
for index in df.index.to_list():
if df.loc[index, f"{visualized_result}_mean"] >= statistic["Lower fence"] and \
df.loc[index, f"{visualized_result}_mean"] <= statistic["Upper fence"]:
df.loc[index, "distribution"] = "Normal"
df.loc[index, "bar_color"] = "#636EFA"
elif df.loc[index, f"{visualized_result}_mean"] >= statistic["Extreme lower fence"] and \
df.loc[index, f"{visualized_result}_mean"] <= statistic["Extreme upper fence"]:
df.loc[index, "distribution"] = "Outlier"
df.loc[index, "bar_color"] = "#FFA15A"
else:
df.loc[index, "distribution"] = "Extreme outlier"
df.loc[index, "bar_color"] = "#EF553B"
if visualized_result == "Cell population (%)":
xmin = 0
xmax = 100
else:
xmin = df[f"{visualized_result}_mean"].min()
xmax = df[f"{visualized_result}_mean"].max()
xrange = [xmin-0.02*(xmax-xmin), xmax+0.02*(xmax-xmin)]
for distribution in ["Normal", "Outlier", "Extreme outlier"]:
sub_df = df[
df["distribution"] == distribution]
hover_info = [[row["Dataset"], row["Site (anonymized)"], row["Instrument model"],
row[f"{visualized_result}_count"], row[f"{visualized_result}_mean"], row[f"{visualized_result}_std"],
row["distribution"]]
for row_index, row in sub_df.iterrows()]
fig.add_trace(go.Bar(x=sub_df[f"{visualized_result}_mean"],
y=sub_df["Dataset_wCount_index"],
base=[xrange[0]] * len(sub_df),
orientation="h",
error_x=dict(type="data",
array=sub_df[f"{visualized_result}_std"],
visible=True),
marker_color=sub_df["bar_color"],
width=0.5,
name=distribution,
customdata=hover_info,
hovertemplate=("Dataset: %{customdata[0]}
" +
"Site (anonymized): %{customdata[1]}
" +
"Instrument model: %{customdata[2]}
" +
"Data count: %{customdata[3]}
" +
"Mean (bar): %{customdata[4]}
" +
"STD (error bar): %{customdata[5]}
" +
"Distribution: %{customdata[6]}" +
""
)),
row=1, col=result_index)
for statistic_key, statistic_value in statistic.items():
if statistic_key != "IQR" and (statistic_value >= xrange[0] and statistic_value <= xrange[1]):
if statistic_key in ["Q1", "Q2", "Q3"]:
line_color = "blue"
elif statistic_key in ["Lower fence", "Upper fence"]:
line_color = "orange"
elif statistic_key in ["Extreme lower fence", "Extreme upper fence"]:
line_color = "red"
fig.add_trace(go.Scatter(x=[statistic_value]*(len(dataset_wCount_mapping)+2),
y=[min(list(dataset_wCount_mapping.values()))-0.5] +
list(dataset_wCount_mapping.values()) +
[max(
list(dataset_wCount_mapping.values()))+0.5],
mode="lines",
line=dict(
color=line_color, width=2),
showlegend=False,
hoverinfo="text",
hovertext=f"{statistic_key} ({statistic_value})"
),
row=1, col=result_index
)
fig.update_xaxes(title_text=visualized_result,
range=xrange,
automargin=False,
row=1, col=result_index)
fig.update_yaxes(title_text="Dataset (Result counts)",
range=[min(list(dataset_wCount_mapping.values()))-1,
max(list(dataset_wCount_mapping.values()))+1],
tickvals=list(
dataset_wCount_mapping.values()),
ticktext=list(dataset_wCount_mapping.keys()),
autorange=False,
automargin=False,
row=1, col=result_index)
dataset_counts.append(len(dataset_wCount_mapping))
margin_top = 30
margin_bottom = 50
margin_left = 200
plot_height = 40 * max(dataset_counts)
fig.update_layout(height=max([margin_top + plot_height + margin_bottom,
250]),
width=800*len(visualized_results),
margin=dict(t=margin_top, b=margin_bottom, l=margin_left))
return fig
def visualize_heatmap(analyzed_gating_results: pd.DataFrame,
visualized_results: list[str]
) -> go.Figure:
space_between_ratio = 0.4/(len(visualized_results))
fig = make_subplots(rows=1, cols=len(visualized_results),
subplot_titles=visualized_results,
horizontal_spacing=space_between_ratio,
shared_xaxes=True, shared_yaxes=True
)
for result_index, visualized_result in enumerate(visualized_results):
result_index += 1
std_table = analyzed_gating_results.pivot(
index=["Dataset", "Site (anonymized)", "Instrument model"],
columns="Result ID", values=f"{visualized_result}_std")
count_table = analyzed_gating_results.pivot(
index=["Dataset", "Site (anonymized)", "Instrument model"],
columns="Result ID", values=f"{visualized_result}_count")
std_table = std_table[sorted(
std_table.columns.to_list(), key=lambda s: int(s.split(" ")[0]))]
count_table = count_table[sorted(
count_table.columns.to_list(), key=lambda s: int(s.split(" ")[0]))]
numeric_values = pd.to_numeric(
std_table.values.flatten(), errors="coerce")
numeric_values = numeric_values[~np.isnan(numeric_values)]
def scientific_anno(x):
try:
x = float(x)
if x >= 1000:
return f"{x:.1e}"
else:
return f"{round(x, 2)}"
except:
return x
annotation_table = pd.DataFrame()
for col in std_table.columns:
annotation_table[col] = std_table[col].apply(
scientific_anno)
annotation_table = annotation_table.replace({"Not reportable": "Not
reportable",
"Only one data": "Only
one data"})
hover_info = [[[row["Site (anonymized)"], row["Instrument model"], count_table.iloc[row_index, col_index]]
for col_index, col_key in enumerate(std_table.columns.to_list())]
for row_index, row in std_table.reset_index().iterrows()]
subplot_ratio = (
1 - space_between_ratio * (len(visualized_results)-1))/len(visualized_results)
if len(numeric_values) > 0:
fig.add_trace(go.Heatmap(z=std_table.values,
text=annotation_table.values,
texttemplate="%{text}",
x=std_table.columns,
y=std_table.reset_index()["Dataset"],
xgap=2, ygap=2,
colorbar_x=(
(subplot_ratio+space_between_ratio)*result_index - space_between_ratio*(8/9)),
colorscale="Magma",
zauto=False,
zmin=np.percentile(numeric_values, 2),
zmax=np.percentile(
numeric_values, 98),
customdata=hover_info,
hovertemplate=("Dataset: %{y}
" +
"Site (anonymized): %{customdata[0]}
" +
"Instrument model: %{customdata[1]}
" +
"Result ID: %{x}
" +
"Data count: %{customdata[2]}
" +
"STD value: %{z}" +
""
)
),
row=1, col=result_index)
else:
fig.add_trace(go.Heatmap(z=pd.DataFrame(0.5, index=std_table.index, columns=std_table.columns),
text=annotation_table.values,
texttemplate="%{text}",
x=std_table.columns,
y=std_table.reset_index()["Dataset"],
xgap=2, ygap=2,
showscale=False,
colorscale=[
[0, "blue"], [0.5, "rgba(0,0,0,0)"], [1, "red"]],
zauto=False, zmin=0, zmax=1,
customdata=hover_info,
hovertemplate=("Dataset: %{y}
" +
"Site (anonymized): %{customdata[0]}
" +
"Instrument model: %{customdata[1]}
" +
"Result ID: %{x}
" +
"Data count: %{customdata[2]}
" +
"STD value: %{z}" +
""
)
),
row=1, col=result_index)
fig.update_xaxes(title_text="Result ID",
automargin=False,
showticklabels=True,
type="category",
tickmode="array",
tickvals=std_table.columns.to_list(),
side="top"
)
for col_index in range(len(visualized_results)):
col_index += 1
fig.layout[f"xaxis{len(visualized_results) + col_index}"] = {
"title_text": None,
"automargin": False,
"showticklabels": True,
"type": "category",
"tickmode": "array",
"tickvals": std_table.columns.to_list(),
"mirror": "allticks",
"overlaying": f"x{col_index}",
"anchor": f"y{col_index}",
"side": "top"
}
fig.update_yaxes(title_text="Dataset",
autorange="reversed",
automargin=False,
showticklabels=True,
type="category",
tickmode="array",
tickvals=std_table.reset_index()["Dataset"].to_list())
margin_top = 200
margin_bottom = 30
margin_left = 200
margin_right = 30
plot_height = 50 * len(std_table.reset_index()["Dataset"].to_list())
plot_width = 900*len(visualized_results)
fig.update_layout(height=max([margin_top + plot_height + margin_bottom,
200]),
width=plot_width,
margin=dict(t=margin_top, b=margin_bottom, l=margin_left, r=margin_right))
for annotation in fig.layout.annotations:
annotation.xshift = -400
annotation.yshift = 5
return fig