diff --git a/backend/src/app.py b/backend/src/app.py index e6c2065..7e9c077 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -16,6 +16,7 @@ from save_document import pack_document, unpack_document from app_state import create_app_state from validations import validate_fasta +import cluster import platform from Bio import SeqIO import psutil @@ -497,8 +498,22 @@ def load_data_and_stats(self, doc_id: str): cols_path = os.path.join(cols_dir, f"{cols_file_base}_cols.csv") if os.path.exists(cols_path): - identity_scores = read_csv(cols_path, skiprows=1).values.tolist() + cols_data = read_csv(cols_path, skiprows=1).values.tolist() + + id_map = {} + identity_scores = [] + + for row in cols_data: + a, b = row[:2] + if a not in id_map: + id_map[a] = len(id_map) + if b not in id_map: + id_map[b] = len(id_map) + identity_scores.append([id_map[a], id_map[b]] + list(row[2:])) + + ids = list(id_map.keys()) else: + ids = [] identity_scores = [] df = read_csv( @@ -514,10 +529,10 @@ def load_data_and_stats(self, doc_id: str): max_val = int(nanmax(data_no_diag)) # TODO might be able to make one tick text object for both to use? - return data, tick_text, min_val, max_val, identity_scores, stats_df + return data, tick_text, min_val, max_val, ids, identity_scores, stats_df def get_data(self, doc_id: str): - data, tick_text, min_val, max_val, identity_scores, stats_df = ( + data, tick_text, min_val, max_val, ids, identity_scores, stats_df = ( self.load_data_and_stats(doc_id) ) heat_data = DataFrame(data, index=tick_text) @@ -526,11 +541,24 @@ def get_data(self, doc_id: str): data_to_dump = dict( metadata=dict(minVal=min_val, maxVal=max_val), data=([tick_text] + parsedData), + ids=ids, identity_scores=identity_scores, full_stats=stats_df.values.tolist() ) return json.dumps(data_to_dump) + def generate_cluster_data(self, doc_id: str, threshold_one: int, threshold_two: int = 0): + doc = get_document(doc_id) + if doc is None: + raise Exception(f"Could not find document: {doc_id}") + matrix_path = get_matrix_path(doc) + + df = cluster.export(matrix_path, threshold_one, threshold_two, False) + df = df.rename(columns={str(df.columns[0]): 'id', str(df.columns[1]): 'group'}) + if len(df.columns) > 2 and df.columns[2] is not None: + df = df.rename(columns={str(df.columns[2]): 'subgroup'}) + return df.to_dict(orient="records") + def new_doc(self): id = make_doc_id() new_document(id) @@ -570,6 +598,7 @@ def save_doc_settings(self, args: dict): args["id"], dataView=args["dataView"], heatmap=args["heatmap"], + clustermap=args["clustermap"], distribution=args["distribution"], ) doc = get_document(args["id"]) diff --git a/backend/src/cluster.py b/backend/src/cluster.py index 7341993..0e9de5b 100644 --- a/backend/src/cluster.py +++ b/backend/src/cluster.py @@ -1,98 +1,127 @@ import os import numpy as np -from scipy.sparse import csr_matrix -from scipy.sparse.csgraph import connected_components import pandas as pd from collections import defaultdict +import networkx as nx - -def process_groups(threshold, data, index): - # create adjacensy matrix to id which cells are related by the threshold marking as binary with (1) for related or (0) for not meeting the threshold - adjacency_matrix = (data >= threshold).astype(int) - # create sparse matrix(absent 0s) for memeory efficiancy - sparse_matrix = csr_matrix(adjacency_matrix) - # identify connected components - _, labels = connected_components( - csgraph=sparse_matrix, directed=False, return_labels=True - ) - groups_dict = defaultdict(list) - for i, label in enumerate(labels): - groups_dict[label].append(index[i]) - groups = {} - for indx, clade in enumerate(labels): - groups.update({indx: clade}) - return groups_dict - +## switching from scipy to networkx. takes two threshold inputs now +def process_groups(data, index, threshold_1, threshold_2=0): + # check for a threshold 2 + if threshold_2 is None or threshold_2 == 0: + # set all values in the matrix that meet threshold 1 to 1 and all lower or NaN values to 0 + adjacency_matrix = (~np.isnan(data) & (data >= threshold_1)).astype(int) + # Create a graph from the adjacency matrix + G1 = nx.from_numpy_array(adjacency_matrix, parallel_edges=False, create_using=None) + + # empty dict to store the groups + groups_dict = defaultdict(list) + + # look for connected components in graph G1 + for i, component in enumerate(nx.connected_components(G1)): + for node_idx in component: + groups_dict[i].append(index[node_idx]) + + return groups_dict + else: + # Create two adjacency matrices, one for each threshold ~ is the bitwise 'not' operator + adjacency_1 = (~np.isnan(data) & (data >= threshold_1)).astype(int) + adjacency_2 = (~np.isnan(data) & (data >= threshold_2)).astype(int) + + # convert adjacency matrices to networkx graphs + G1 = nx.from_numpy_array(adjacency_1) + G2 = nx.from_numpy_array(adjacency_2) + + # find primary clusters with threshold_1 + groups_dict_1 = defaultdict(list) + for i, component in enumerate(nx.connected_components(G1)): + for node_idx in component: + groups_dict_1[i].append(index[node_idx]) + + # find subclusters with threshold_2 + groups_dict_2 = defaultdict(list) + for i, component in enumerate(nx.connected_components(G2)): + for node_idx in component: + groups_dict_2[i].append(index[node_idx]) + + # return both cluster sets + return groups_dict_1, groups_dict_2 def cluster_by_identity(clusters, nodes): output = [] reverse_clusters = {} - - # Create reverse lookup dictionary were values in value list are extracted to key and groups are assigned to value + + # create lookup table from sequence ID to primary cluster ID for group, values in clusters.items(): for value in values: reverse_clusters[value] = group - - # Initialize subgroup counters + + # initialize counters for subgroups within each primary cluster subgroup_counters = {group: 1 for group in clusters.keys()} - - # Iterate through nodes to determine the subgroup_number within each group_number + + # assign subgroups within each primary cluster for _, node_list in nodes.items(): if node_list: + # get first node to determine which primary cluster this belongs to first_value = node_list[0] if first_value in reverse_clusters: + # get primary cluster ID (add 1 for human-readable indexing) group_number = reverse_clusters[first_value] + 1 + # get next available subgroup number for this primary cluster subgroup_number = subgroup_counters[reverse_clusters[first_value]] + + # process all nodes in this subcluster for value in node_list: + # only include if node belongs to the same primary cluster if value in reverse_clusters: output.append((value, group_number, subgroup_number)) + + # increment subgroup counter for this primary cluster subgroup_counters[reverse_clusters[first_value]] += 1 - + return output -def export(matrix_path, threshold_1=79, threshold_2=0): +def export(matrix_path, threshold_1=79, threshold_2=0, save_csv=True): output_dir = os.path.dirname(matrix_path) file_name = os.path.basename(matrix_path) file_base, _ = os.path.splitext(file_name) file_name = file_base.replace("_mat", "") output_file = os.path.join(output_dir, file_name + "_cluster.csv") - - # https://stackoverflow.com/a/57824142 - # SDT1 matrix CSVs do not have padding for columns + with open(matrix_path, "r") as temp_f: col_count = [len(l.split(",")) for l in temp_f.readlines()] column_names = [i for i in range(0, max(col_count))] - + df = pd.read_csv( matrix_path, delimiter=",", index_col=0, header=None, names=column_names ) - # extract index + index = df.index.tolist() - # convert df to np array data = df.to_numpy() - # format values data = np.round(data, 2) - # maintain order of threshold processing + if threshold_2 != 0 and threshold_1 >= threshold_2: threshold_1, threshold_2 = threshold_2, threshold_1 - # handle instances of no threshold_2 + if threshold_2 is None or threshold_2 == 0: - output = process_groups(threshold_1, data, index) + output = process_groups(data, index, threshold_1) flattened_output = [ (item, key + 1) for key, sublist in output.items() for item in sublist ] - df = pd.DataFrame(flattened_output) - df.columns = ["ID", "Group 1 - Theshold: " + str(threshold_1)] - + df_result = pd.DataFrame(flattened_output) + df_result.columns = ["SeqID", "Group - Threshold: " + str(threshold_1)] else: - clusters = process_groups(threshold_1, data, index) - nodes = process_groups(threshold_2, data, index) + clusters, nodes = process_groups(data, index, threshold_1, threshold_2) output = cluster_by_identity(clusters, nodes) - df = pd.DataFrame(output) - df.columns = [ + df_result = pd.DataFrame(output) + df_result.columns = [ "ID", - "Group 1 - Theshold: " + str(threshold_1), - "Group 2 - Theshold: " + str(threshold_2), + "Group - Threshold: " + str(threshold_1), + "Subgroup - Threshold: " + str(threshold_2), ] - df.to_csv(output_file, index=False) + + if save_csv: + df_result.to_csv(output_file, index=False) + + return df_result + diff --git a/backend/src/document_state.py b/backend/src/document_state.py index a949533..73472a2 100644 --- a/backend/src/document_state.py +++ b/backend/src/document_state.py @@ -23,6 +23,7 @@ "validation_error_id", "compute_stats", "heatmap", + "clustermap", "distribution" ], ) @@ -34,9 +35,7 @@ vmin=65, cellspace=1, annotation=False, - annotation_font_size=10, annotation_rounding=0, - annotation_alpha="0", showscale=True, titleFont="Sans Serif", showTitles=False, @@ -48,14 +47,30 @@ cbar_aspect=2.5, cbar_pad=10, axis_labels=False, - axlabel_xrotation=270, - axlabel_xfontsize=12, - axlabel_yrotation=360, - axlabel_yfontsize=12, + axlabel_xrotation=0, + axlabel_fontsize=12, + axlabel_yrotation=0, cutoff_1=95, cutoff_2=75 ) +default_clustermap_state = dict( + threshold_one=85, + threshold_two=0, + annotation=False, + titleFont="Sans Serif", + showTitles=False, + title="", + subtitle="", + xtitle="", + ytitle="", + axis_labels=False, + axlabel_xrotation=0, + axlabel_fontsize=12, + axlabel_yrotation=0, + cellspace=1, +) + visualization_defaults = dict( plotTitle="Distribution of Percent Identities", lineColor="hsl(9, 100%, 64%)", @@ -163,6 +178,7 @@ def create_document_state( validation_error_id=None, compute_stats=None, heatmap=default_heatmap_state, + clustermap=default_clustermap_state, distribution=default_distribution_state ): if filetype == "application/vnd.sdt" and tempdir_path: @@ -191,6 +207,7 @@ def create_document_state( validation_error_id=validation_error_id, compute_stats=compute_stats, heatmap=heatmap, + clustermap=clustermap, distribution=distribution ) @@ -210,6 +227,7 @@ def save_doc_settings(doc_state: DocState): settings = { "dataView": doc_state.dataView, "heatmap": doc_state.heatmap, + "clustermap": doc_state.clustermap, "distribution": doc_state.distribution } json.dump(settings, f, indent=2) diff --git a/backend/src/export_data.py b/backend/src/export_data.py index 66a8eb8..1029d43 100644 --- a/backend/src/export_data.py +++ b/backend/src/export_data.py @@ -6,7 +6,7 @@ import cluster from constants import data_file_suffixes -image_types = ["heatmap", "histogram", "violin"] +image_types = ["heatmap", "clustermap", "histogram", "violin"] def find_source_files(state: DocState, prefix, suffixes): with os.scandir(state.tempdir_path) as entries: @@ -44,7 +44,6 @@ def prepare_export_data(export_path: str, matrix_path: str, doc: DocState, args: if args["output_cluster"] == True: suffixes.append("_cluster") - # TODO: it's not intuitive that an export happens in here, move it outside? cluster.export( matrix_path, args["cluster_threshold_one"], @@ -62,8 +61,8 @@ def prepare_export_data(export_path: str, matrix_path: str, doc: DocState, args: ) image_filenames = { - img_type: f"{base_filename}_{img_type}.{image_format}" - for img_type in image_types + image_type: f"{base_filename}_{image_type}.{image_format}" + for image_type in image_types } image_destinations = { @@ -86,9 +85,9 @@ def do_export_data(export_path, image_destinations, image_format, doc, prefix, s shutil.copy2(entry.path, temp_destination_path) os.replace(temp_destination_path, destination_path) - for img_type in image_types: + for image_type in image_types: save_image_from_api( - data=args[f"{img_type}_image_data"], + data=args[f"{image_type}_image_data"], format=image_format, - destination=image_destinations[img_type], + destination=image_destinations[image_type], ) diff --git a/bun.lock b/bun.lock index b58ff1e..46c42c6 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "react": "^18.2.0", "react-aria-components": "^1.5.0", "react-dom": "^18.2.0", + "react-icons": "^5.5.0", "react-plotly.js": "^2.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8", @@ -1020,6 +1021,8 @@ "react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="], + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-plotly.js": ["react-plotly.js@2.6.0", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "plotly.js": ">1.34.0", "react": ">0.13.0" } }, "sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA=="], diff --git a/docs/images/2AppRun.png b/docs/images/2AppRun.png new file mode 100644 index 0000000..ea220b0 Binary files /dev/null and b/docs/images/2AppRun.png differ diff --git a/docs/images/3AppRun.png b/docs/images/3AppRun.png new file mode 100644 index 0000000..1fc3d18 Binary files /dev/null and b/docs/images/3AppRun.png differ diff --git a/docs/images/4AppRun.png b/docs/images/4AppRun.png new file mode 100644 index 0000000..f69bd39 Binary files /dev/null and b/docs/images/4AppRun.png differ diff --git a/docs/images/AppRun.png b/docs/images/AppRun.png new file mode 100644 index 0000000..f81973c Binary files /dev/null and b/docs/images/AppRun.png differ diff --git a/docs/images/Clustermap.png b/docs/images/Clustermap.png new file mode 100644 index 0000000..9f5f39a Binary files /dev/null and b/docs/images/Clustermap.png differ diff --git a/docs/images/ColorScale.png b/docs/images/ColorScale.png new file mode 100644 index 0000000..7ebadeb Binary files /dev/null and b/docs/images/ColorScale.png differ diff --git a/docs/images/DataType.png b/docs/images/DataType.png new file mode 100644 index 0000000..95d1878 Binary files /dev/null and b/docs/images/DataType.png differ diff --git a/docs/images/Discrete.png b/docs/images/Discrete.png new file mode 100644 index 0000000..0f38a9d Binary files /dev/null and b/docs/images/Discrete.png differ diff --git a/docs/images/Export.png b/docs/images/Export.png new file mode 100644 index 0000000..28b2d4f Binary files /dev/null and b/docs/images/Export.png differ diff --git a/docs/images/FileMenu.png b/docs/images/FileMenu.png new file mode 100644 index 0000000..bc5d323 Binary files /dev/null and b/docs/images/FileMenu.png differ diff --git a/docs/images/Heatmap.png b/docs/images/Heatmap.png new file mode 100644 index 0000000..ffb1ca2 Binary files /dev/null and b/docs/images/Heatmap.png differ diff --git a/docs/images/HeatmapBar.png b/docs/images/HeatmapBar.png new file mode 100644 index 0000000..cc19200 Binary files /dev/null and b/docs/images/HeatmapBar.png differ diff --git a/docs/images/HistoBar.png b/docs/images/HistoBar.png new file mode 100644 index 0000000..d693bca Binary files /dev/null and b/docs/images/HistoBar.png differ diff --git a/docs/images/Histogram.png b/docs/images/Histogram.png new file mode 100644 index 0000000..e9d5b1b Binary files /dev/null and b/docs/images/Histogram.png differ diff --git a/docs/images/LeftSidebar.png b/docs/images/LeftSidebar.png new file mode 100644 index 0000000..c616163 Binary files /dev/null and b/docs/images/LeftSidebar.png differ diff --git a/docs/images/LoadMain.png b/docs/images/LoadMain.png new file mode 100644 index 0000000..53ab5aa Binary files /dev/null and b/docs/images/LoadMain.png differ diff --git a/docs/images/Loader.png b/docs/images/Loader.png new file mode 100644 index 0000000..e19d0bd Binary files /dev/null and b/docs/images/Loader.png differ diff --git a/docs/images/Tabs.png b/docs/images/Tabs.png new file mode 100644 index 0000000..a58ebda Binary files /dev/null and b/docs/images/Tabs.png differ diff --git a/docs/images/Violin.png b/docs/images/Violin.png new file mode 100644 index 0000000..0056e45 Binary files /dev/null and b/docs/images/Violin.png differ diff --git a/docs/images/ViolinBar.png b/docs/images/ViolinBar.png new file mode 100644 index 0000000..72924c9 Binary files /dev/null and b/docs/images/ViolinBar.png differ diff --git a/docs/images/ViolinButtons.png b/docs/images/ViolinButtons.png new file mode 100644 index 0000000..5b70531 Binary files /dev/null and b/docs/images/ViolinButtons.png differ diff --git a/docs/images/app.png b/docs/images/app.png deleted file mode 100644 index 92b0b99..0000000 Binary files a/docs/images/app.png and /dev/null differ diff --git a/docs/images/manual-01.jpg b/docs/images/manual-01.jpg deleted file mode 100644 index 0d13ab4..0000000 Binary files a/docs/images/manual-01.jpg and /dev/null differ diff --git a/docs/images/manual-02.jpg b/docs/images/manual-02.jpg deleted file mode 100644 index 5c4b3bc..0000000 Binary files a/docs/images/manual-02.jpg and /dev/null differ diff --git a/docs/images/manual-06.jpg b/docs/images/manual-06.jpg deleted file mode 100644 index d16c0fb..0000000 Binary files a/docs/images/manual-06.jpg and /dev/null differ diff --git a/docs/images/manual-07.jpg b/docs/images/manual-07.jpg deleted file mode 100644 index 9d7a0ac..0000000 Binary files a/docs/images/manual-07.jpg and /dev/null differ diff --git a/docs/images/manual-08.gif b/docs/images/manual-08.gif deleted file mode 100644 index 77c0df3..0000000 Binary files a/docs/images/manual-08.gif and /dev/null differ diff --git a/docs/images/manual-09.jpg b/docs/images/manual-09.jpg deleted file mode 100644 index f3449a0..0000000 Binary files a/docs/images/manual-09.jpg and /dev/null differ diff --git a/docs/images/manual-10.jpg b/docs/images/manual-10.jpg deleted file mode 100644 index b95c35f..0000000 Binary files a/docs/images/manual-10.jpg and /dev/null differ diff --git a/docs/images/manual-11.jpg b/docs/images/manual-11.jpg deleted file mode 100644 index 1558fd9..0000000 Binary files a/docs/images/manual-11.jpg and /dev/null differ diff --git a/docs/images/manual-12.jpg b/docs/images/manual-12.jpg deleted file mode 100644 index 0227f20..0000000 Binary files a/docs/images/manual-12.jpg and /dev/null differ diff --git a/docs/images/manual-14.gif b/docs/images/manual-14.gif deleted file mode 100644 index 4f04efb..0000000 Binary files a/docs/images/manual-14.gif and /dev/null differ diff --git a/docs/images/manual-16.jpg b/docs/images/manual-16.jpg deleted file mode 100644 index 0227f20..0000000 Binary files a/docs/images/manual-16.jpg and /dev/null differ diff --git a/docs/images/manual-advanced.jpg b/docs/images/manual-advanced.jpg deleted file mode 100644 index cc2496f..0000000 Binary files a/docs/images/manual-advanced.jpg and /dev/null differ diff --git a/docs/images/manual-export.jpg b/docs/images/manual-export.jpg deleted file mode 100644 index 9be3fed..0000000 Binary files a/docs/images/manual-export.jpg and /dev/null differ diff --git a/docs/images/manual-menu.jpg b/docs/images/manual-menu.jpg deleted file mode 100644 index 4eb9afc..0000000 Binary files a/docs/images/manual-menu.jpg and /dev/null differ diff --git a/docs/images/manual-run.jpg b/docs/images/manual-run.jpg deleted file mode 100644 index 95ba0b9..0000000 Binary files a/docs/images/manual-run.jpg and /dev/null differ diff --git a/docs/images/manual-run.png b/docs/images/manual-run.png deleted file mode 100644 index b48ce23..0000000 Binary files a/docs/images/manual-run.png and /dev/null differ diff --git a/docs/manual.html b/docs/manual.html index feef3fe..1e80532 100644 --- a/docs/manual.html +++ b/docs/manual.html @@ -1,132 +1,547 @@ - - - - Sequence Demarcation Tool 2.0.0 Beta2 - - -
-

Sequence Demarcation Tool2.0.0 Beta2

-

Description

-

- SDT 2.0.0 is a standalone application for Windows/Mac/Linux. It allows - analysis of FASTA files or SDT/SDT2 matrices using pairwise alignments - and computes pairwise identity scores. Clustering is available via - Neighbor-Joining or UPGMA. Results are visualized as a heatmap and a - distribution plot. -

-

Features

-

I. Main Loading Screen

- -

- Select a file via Select File. For FASTA, the - Run Options screen is loaded. For SDT/SDT2 matrices, the - Viewer screen is loaded. -

+ + + + Sequence Demarcation Tool 2.0.0 Beta 4 + + + + +
+

Sequence Demarcation Tool 2.0.0 Beta 4

+
+ +

Description

+

+ Sequence Demarcation Tool 2 (SDT 2) is a standalone application for Windows, Mac, and Linux, created as the modern + successor to the original SDT. Like its predecessor, SDT2 is designed to explore, visualize, and demarcate + biological sequences through lightning-fast global pairwise sequence alignments. + SDT2 employs the highly optimized Parasail library for Needleman-Wunsch global sequence alignments. Copmbined with + seamless multiprocessing + support, SDT2 is capable of handling large datasets and longer sequence lengths. + Alignments can be organized into easy-to-interpret clusters using Neighbor + Joining or UPGMA phylogenies. Additionally, SDT2 utilizes the powerful D3 graphing library to visualize pairwise + sequence identities in a highly customizable, interactive lower triangle heatmap, which can be exported as + high-resolution vector images. The tool also provides distribution statistics for pairwise sequence identity values, + sequence lengths, and GC content, which can be displayed in either histogram or violin plot formats. +

+ +

Getting Started

+ +

Main Loading Screen

+

+ When first launched SDT2, the loading interface provides two primary options for loading data: +

+
+ SDT2 Main Loading Screen +
-

III. File Input

+

I. File Selection

+

Click "Select FASTA or SDT Matrix file..." to choose from:

+
    +
  • FASTA files (`.fasta`, `.fas`, `.faa`, `.fnt`, `.fa`)
  • +
  • SDT Matrix files (`.csv`, `.txt`)
  • +
  • Previously saved SDT files (`.sdt`)
  • +
+ +

II. Recent Files

A section displaying recently used files for quick access + +
+
+
+

For FASTA files, the Runner Interface screen is loaded to prepare for sequence alignment and analysis.

+

For SDT/SDT2 matrices, theViewer screen is loaded, and the pre-run data is displayed as a heatmap.

+

Relocated files will still show in the Recent Files directory if you have moved them since the last they were used

+
+ +

Application Run Interface

+

+ Once a file is selected, the application interface displays several key components and controls. +

+ + + Application Run Interface + + +
+
+ Data File Section +
+
+

Data File Section

    -
  • FASTA or SDT Matrix File: Change the input file.
  • -
  • Select file...: Browse and select the file.
  • +
  • Data File: Displays the selected file name from the user's file selection
  • +
  • Select file... Button to change or browse for a different file
+
+
+ -

IV. Run Settings

+
+
+ Reorder By Section +
+
+

Reorder by

+

+ SDT2 offers sequence order rearrangement based on a phylogenetic tree to better organize the evolutionary relationships of the sequences: +

    -
  • - Clustering Method: Select clustering by Neighbor-Joining, - UPGMA, or None. +
  • Toggle switch to enable/disable phylogenetic reordering
  • +
  • If enabled, choose tree construction method: +
      +
    • Neighbor-Joining: Constructs a tree based on minimizing the total branch length
    • +
    • UPGMA: Creates a tree using average genetic distances
    • +
    • None: Sequences remain in the original order of the input data
    • +
  • -
  • Alignment Type: Choose local or global alignment.
  • +
+
+
+ + +
+
+ Performance Settings +
+
+

Performance Settings

+
    +
  • Cores: Slider to adjust computational cores used
  • +
  • Memory: Meter showing estimated memory usage
-

V. Compute Performance

+

Starting Analysis

    -
  • Performance Selection: Choose Best, Balanced, or Low.
  • -
  • Processing Cores: Specify the number of cores to use.
  • +
  • Start Analysis button becomes active when all requirements are met
  • +
  • Keyboard shortcut: Ctrl/Cmd/Alt + Enter
+
+
+ +

Performance

+

The core selection feature in SDT2 significantly increases both compute speed and memory usage during analysis. The + application leverages Python's Multiprocessing capabilities to dramatically increase processing speed through + parallelization. By distributing the computational workload across multiple CPU cores, analysis times can be reduced + by orders of magnitude. + However, users should be aware that increased parallelization comes with higher memory requirements. Each additional + core allocated to the analysis requires its own memory space, causing total memory usage to scale with the number of + cores selected. The application provides best-estimate memory usage indicators to help users understand these + increasing memory requirements and make appropriate selections based on their available system resources.

+
+

Note: Users on systems that can use hard disk space as a page file to increase available memory may be + able to align very long sequences (100-200kb) by setting cores to a minimal value. However, this approach may + cause system instability and should be used at your own risk. Even with additional virtual memory, the system + performance may degrade significantly when physical memory limits are exceeded.

+
-

VI. Run Button

-

Run: Start the sequence comparison process.

+

Menu and File Operations

+
+
+ File Menu Interface +
+
+

The application menu provides access to all core functions:

+
    +
  • New: Start a new SDT2 session
  • +
  • Open...: Select and open a file
  • +
  • Open Recent: Access a submenu of recently used files
  • +
  • Save: Save the current analysis
  • +
  • Save As...: Save the current analysis with a new name or location
  • +
  • Export...: Export the current visualization or data (see Export section)
  • +
  • Close: Close the current file
  • +
  • Manual: Access this user manual
  • +
  • About: View application information, version, and credits
  • +
  • Exit: Close the application
  • +
+
+
-

VII. Advanced Settings

- -

Select Folder: Choose an output directory for alignments.

+

Loader Interface

+
+
+ Loader Interface +
+
+

During analysis:

+
    +
  • Progress bar Shows current percentage of analysis complete
  • +
  • Estimated time remainingCalculates estimated runtimebased on current performance
  • +
  • Cancel Run Clicl and confirm to cancel a run
  • +
+
+
-

Alignment Output Folder

-

Select...: Choose the folder for alignment output files.

+

Visualization Options

-

VIII. Menu

- +

Heatmap

+
+
+ Heatmap Visualization +
+

- Provides options to load a new file, export change settings, and view manual and - SDT2 project information. + The heatmap visualization in SDT2 provides a color-coded representation of pairwise sequence identity. + It displays sequence relationships in a triangular matrix format where each cell represents the percent + identity between two sequences.

+
+
-

IX. Export Data

- +
+
+ Heatmap Colorscale Options +
+
+

Colorscale

    -
  • Output Folder: Enter or select the path for saving data.
  • -
  • - Cluster By Percent Identity: Enable or disable clustering. -
  • -
  • Thresholds: Set threshold values for clustering.
  • -
  • Actions: Cancel or Export.
  • +
  • Colorscale Selection: Choose from various predefined color schemes (Portland, Viridis, Plasma, etc.)
  • +
  • Reverse: Toggle to invert the color scale direction
  • +
  • Cell Spacing: Adjust the gap between cells in the heatmap matrix
  • +
  • Discrete Cutoff Values (Discrete Colorscale Only): Set thresholds to create distinct color regions
-

X. Heatmap

- -

Heatmap Settings

+

Percent Identities

+

Toggle this option to show or hide percent pairwise identity values within each cell of the heatmap.

    -
  • Color Scale: - Choose the heatmap color scheme.
  • -
  • Cell Spacing: - Adjust the spacing between cells.
  • -
  • Percent Identities: - Set decimal precision and font size.
  • -
  • Axis Labels: - Set font size and rotation for X and Y labels.
  • -
  • Scale Bar: - Adjust height, width, padding, and min/max values.
  • +
  • Precision: Select the number of decimal places to display for percent identities (0, 1, or 2)
  • +
  • Font Size: Adjust the text size for the percentage values
- -

XI. Distribution Plot

- -

Layout options

+

Plot Titles

+

Toggle this option to show or hide plot titles on the heatmap.

    -
  • Title Text: Set the plot title.
  • -
  • Grid: Toggle grid lines.
  • -
  • Tick Labels: Toggle tick labels.
  • -
  • Axis Lines: Toggle axis lines.
  • -
  • Axis Title: Toggle axis title.
  • +
  • Font Type: Choose between Sans Serif or Monospace
  • +
  • Title and Subtitle: Enter the desired text to appear above the heatmap
+ +

Axis Labels

+

Toggle this option to show or hide sequence identifiers along the axes of the heatmap.

+
    +
  • Font Size: Adjust font size for X and Y axis labels
  • +
  • Rotation: Set rotation angle for X and Y labels
  • +
+ +

Scale Bar

+

Toggle to show or hide the color reference scale.

+
    +
  • Height: Adjust the height of the scale bar
  • +
  • Width: Adjust the width of the scale bar
  • +
  • Min: Adjust the minimum value represented in on the color scale
  • +
  • Max: Adjust the maximum value represented in on the colorscale
  • +
+
+
+
+

Note: Displaying percentage values in large datasets may significantly impact rendering performance.

+
+ + +

Distribution Plots

+

+ SDT2 provides visualization tools for examining the distribution of your sequence data: + Histogram and Violin plots. +

-

Bar Plot

+

Histogram

+ + Histogram Example +

+ Histograms divide your data into bins and display the frequency of values within each bin as + bars. This helps identify patterns, peaks, and outliers in your sequence data. +

+
+
+ Histogram Controls +
+
+

Data Set Options

+

Choose which data to visualize:

+
    +
  • Scores: Pairwise sequence identity percentages
  • +
  • GC Content: Percentage of G and C nucleotides in each sequence
  • +
  • Length: Number of nucleotides in each sequence
  • +
+
    +
  • Toggle Buttons: Show or hide grid, axis lines, labels, and tick values
  • +
  • Orientation: Choose between Vertical or Horizontal display
  • +
+

Histogram Options

    -
  • Color: Set bar colors.
  • -
  • Outline: Set bar outline color and width.
  • +
  • Bin Color: Click the color box to select the fill color for the histogram bars
  • +
  • Bin Width: Adjust the width of values included in each bin
  • +
  • Outline Color: Click the color box select the color for bar outlines
  • +
  • Outline Width: Adjust the thickness of bar outlines
  • +
  • Bar Gap: Control the spacing between bars
  • +
  • Plot Titles: Configure title and axis labels
+
+
-

Line Plot

+

Violin Plot

+ Violin Plot Example +

+ Violin plots combine box plots with kernel density plots to show the distribution shape, + central tendency, and variability of your data. +

+
+
+ Violin buttons + Violin options bar +
+
+

Data Set Options

+

Choose which data to visualize:

    -
  • Shape: Set the shape of the line plot.
  • -
  • Color: Set the line color.
  • -
  • Width: Adjust the line width.
  • +
  • Scores: Pairwise sequence identity percentages
  • +
  • GC Content: Percentage of G and C nucleotides in each sequence
  • +
  • Length: Number of nucleotides in each sequence
+
    +
  • Toggle Buttons: Toggle the display of: grid, axis lines, tick values, and mean-line
  • +
  • Orientation: Choose between Vertical or Horizontal plot orientation
  • +
+

Violin Options

+
    +
  • Band Width: Control the smoothness of the violin curve
  • +
  • Fill Color: Click the color box to customize the fill color within the violin
  • +
  • Line Color: Click the color box to customize the line color of the violin
  • +
  • Line Width: Adjust the thickness of the violin outline
  • +
+

Box Options

+
    +
  • Box Width: Control the width of the box plot
  • +
  • Fill/Line Color: Customize the appearance of the box
  • +
  • Whiskers: Control the whisker lines extending from the box
  • +
+

Points Options

:(Examples in Vertical Orientation) +
    +
  • Size: Controls the diameter of each data point in pixels
  • +
  • Position:Determines the horizontal position offset of points from the central violin/box +
      + +
    • Negative values: Points appear to the left of the violin/box
    • +
    • Positive values: Points appear to the right of the violin/box
    • +
    • Zero: Points are centered on the violin/box
    • +
    • Range: Typically -2.0 to 2.0, where each unit represents a fraction of the violin/box width
    • +
    +
  • +
  • Jitter: Add random horizontal displacement to prevent point overlap +
      +
    • 0: No jittering, points with the same value will overlap precisely
    • +
    • 1: Maximum jittering, points are distributed across the full allowed width
    • +
    • This is especially useful for discrete or rounded data where many points might share the same value
    • +
    +
+
+
+

Note: For large sequence sets, consider hiding individual data points to improve performance.

+
-

Markers

+

Export Options

+
+
+ Export Interface +
+
+

+ The Export dialog provides options to save your visualizations and data for use in publications, presentations, or + further analysis: +

+

Export Dialog Options

    -
  • Symbol: Set marker symbols.
  • -
  • Color: Set marker color.
  • -
  • Size: Adjust marker size.
  • +
  • Output Folder: Specify where exported files will be saved
  • +
  • Image Format: Select the file format for visualization exports +
      +
    • SVG: Recommended for publications, maintains quality at any size
    • +
    • PNG: Good for presentations and web use
    • +
    • JPEG: Suitable for general purpose use, smaller file size
    • +
    +
  • +
  • Cancel/Export: Close dialog or save files with current settings
  • +

  • Cluster by Percent Identity: Toggle whether to cluster sequences in exported data
- - +
+ +

Best Practices

+ + +

Troubleshooting

+

If "Start Analysis" is disabled, check:

+
    +
  1. File is selected
  2. +
  3. Alignment export path (if enabled)
  4. +
  5. No active runs in progress
  6. +
+ +

If performance is slow:

+
    +
  1. Reduce the number of computational cores used
  2. +
  3. For large datasets, disable percent identities display in heatmaps
  4. +
  5. For large datasets, hide individual data points in violin plots
  6. +
  7. Check available system memory before running large analyses
  8. +
+ +

System Requirements

+ + +
+

SDT2 Documentation © 2023-2025 | Version 2.0.0 Beta 4

+ + + + \ No newline at end of file diff --git a/frontend/src/appState.ts b/frontend/src/appState.ts index 5528091..a8d92b6 100644 --- a/frontend/src/appState.ts +++ b/frontend/src/appState.ts @@ -6,7 +6,12 @@ import { initialDistributionState, } from "./distributionState"; import type messages from "./messages"; -import { type HeatmapSettings, HeatmapSettingsSchema } from "./plotTypes"; +import { + type ClustermapSettings, + ClustermapSettingsSchema, + type HeatmapSettings, + HeatmapSettingsSchema, +} from "./plotTypes"; export const clusterMethods = ["Neighbor-Joining", "UPGMA"] as const; export const clusterMethodDescriptions = [ @@ -45,11 +50,13 @@ export type DocState = { }; dataView: | "heatmap" + | "clustermap" | "distribution_histogram" | "distribution_violin" | "distribution_raincloud"; distribution: DistributionState; heatmap: HeatmapSettings; + clustermap: ClustermapSettings; }; export type AppState = { @@ -117,12 +124,14 @@ export const docStateSchema = z.object({ ]), dataView: z.enum([ "heatmap", + "clustermap", "distribution_histogram", "distribution_violin", "distribution_raincloud", ]), distribution: DistributionStateSchema, heatmap: HeatmapSettingsSchema, + clustermap: ClustermapSettingsSchema, }); export const initialDocState: DocState = { @@ -149,27 +158,38 @@ export const initialDocState: DocState = { vmin: 65, cellspace: 1, annotation: false, - annotation_font_size: 10, annotation_rounding: 0, - annotation_alpha: "0", showscale: true, titleFont: "Sans Serif", showTitles: false, title: "", - subtitle: "", xtitle: "", ytitle: "", cbar_shrink: 5, cbar_aspect: 2.5, cbar_pad: 10, axis_labels: false, - axlabel_xrotation: 270, - axlabel_xfontsize: 12, - axlabel_yrotation: 360, - axlabel_yfontsize: 12, + axlabel_xrotation: 0, + axlabel_fontsize: 12, + axlabel_yrotation: 0, cutoff_1: 95, cutoff_2: 75, }, + clustermap: { + threshold_one: 85, + threshold_two: 0, + annotation: false, + titleFont: "Sans Serif", + showTitles: false, + title: "", + xtitle: "", + ytitle: "", + axis_labels: false, + axlabel_xrotation: 0, + axlabel_fontsize: 12, + axlabel_yrotation: 0, + cellspace: 1, + }, }; export const initialAppState: AppState = { diff --git a/frontend/src/colorScales.ts b/frontend/src/colorScales.ts index eb5409b..b29de7f 100644 --- a/frontend/src/colorScales.ts +++ b/frontend/src/colorScales.ts @@ -29,7 +29,7 @@ export const colorScales: { } = { Greys: [ [0, "rgb(0,0,0)"], - [1, "rgb(255,255,255)"], + [1, "rgb(235,235,235)"], ], Yellow_Green_Blue: [ diff --git a/frontend/src/colors.ts b/frontend/src/colors.ts index 7401c16..8457859 100644 --- a/frontend/src/colors.ts +++ b/frontend/src/colors.ts @@ -1,4 +1,3 @@ -import Color from "colorjs.io"; import * as d3 from "d3"; import tinycolor from "tinycolor2"; import { z } from "zod"; @@ -18,67 +17,6 @@ export const ColorStringSchema = z }, ); -export const findScaleLower = (colorScale: ColorScaleArray, value: number) => - colorScale - .filter((curr) => curr[0] <= value) - .reduce((prev, curr) => (curr[0] > prev[0] ? curr : prev), colorScale[0]); - -export const findScaleUpper = (colorScale: ColorScaleArray, value: number) => - colorScale - .filter((curr) => curr[0] >= value) - .reduce((prev, curr) => (curr[0] < prev[0] ? curr : prev), colorScale[0]); - -export const originalRgbFormat = { - name: "rgb", - commas: true, - noAlpha: true, - coords: ["[0, 255]", "[0, 255]", "[0, 255]"], -}; - -export const interpolateColor = ( - colorScale: ColorScaleArray, - value: number, - format?: { - name: string; - commas: boolean; - noAlpha: boolean; - coords: string[]; - }, -): { - value: ColorScaleArray[number]; - upper: ColorScaleArray[number]; - lower: ColorScaleArray[number]; -} => { - const lower = findScaleLower(colorScale, value); - const upper = findScaleUpper(colorScale, value); - - const ratio = - lower[0] === upper[0] ? 0 : (value - lower[0]) / (upper[0] - lower[0]); - const lowerColor = new Color(lower[1]); - const upperColor = new Color(upper[1]); - const interpolator = lowerColor.range(upperColor, { - space: "srgb", - outputSpace: "srgb", - }); - const interpolatedColor = - ratio === 0 - ? value === lower[0] - ? lower[1] - : upper[1] - : interpolator(ratio).toString({ format, precision: 0 }); - - return { - value: [ratio, interpolatedColor], - lower, - upper, - }; -}; - -export const makeLinearGradient = (scale: ColorScaleArray) => { - const values = Array.from({ length: 10 }, (_, i) => i / 10); - return values.map((v) => interpolateColor(scale, v).value); -}; - export function createD3ColorScale( colorArray: ColorScaleArray, discrete: boolean, @@ -99,6 +37,14 @@ export function createD3ColorScale( .clamp(true); } +export const distinctColor = (index: number) => { + if (!index) { + return "hsl(245, 245, 245)"; + } + const hue = (index * 137.5) % 360; + return `hsl(${hue}, 85%, 50%)`; +}; + type ColorName = | "White" | "Black" diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 758ce20..89c5c84 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,5 +1,12 @@ import React from "react"; import { Button, type Key, Tab, TabList, Tabs } from "react-aria-components"; +import { + TbFile, + TbFolderShare, + TbLayoutSidebarLeftCollapse, + TbLayoutSidebarLeftExpand, + TbPlus, +} from "react-icons/tb"; import { type AppState, AppStateContext, @@ -16,7 +23,6 @@ import { Document } from "./Document"; import { ErrorBoundary } from "./ErrorBoundary"; import { ExportModal } from "./ExportModal"; import { HeatmapRefProvider } from "./HeatmapRefProvider"; -import Icons from "./Icons"; import { MainMenu } from "./Menu"; import { Select, SelectItem } from "./Select"; @@ -175,24 +181,22 @@ export const App = () => {
- + + {leftSidebarCollapsed + ? "Expand sidebar" + : "Collapse sidebar"} + + {leftSidebarCollapsed ? ( + + ) : ( + + )} + + ) : null}
{tabView === "tabs" ? ( @@ -252,11 +256,12 @@ export const App = () => { )} - + ))} ) : ( @@ -307,25 +302,7 @@ export const App = () => { })) } > - + Export
diff --git a/frontend/src/components/Clustermap.tsx b/frontend/src/components/Clustermap.tsx new file mode 100644 index 0000000..9a7c65d --- /dev/null +++ b/frontend/src/components/Clustermap.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import { type DocState, type SetDocState, useAppState } from "../appState"; +import type { ColorScaleArray } from "../colorScales"; +import { plotFontMonospace, plotFontSansSerif } from "../constants"; +import { formatClustermapData } from "../heatmapUtils"; +import { useMetrics, useSize } from "../hooks/heatmap"; +import { useHeatmapRenderToggle } from "../hooks/useHeatmapRenderToggle"; +import type { HeatmapData } from "../plotTypes"; +import { ClustermapSidebar } from "./ClustermapSidebar"; +import { D3CanvasHeatmap } from "./D3CanvasHeatmap"; +import { D3SvgHeatmap } from "./D3SvgHeatmap"; + +export const Clustermap = ({ + data, + docState, + setDocState, + tickText, + leftSidebarCollapsed, +}: { + data: HeatmapData; + docState: DocState; + setDocState: SetDocState; + tickText: string[]; + leftSidebarCollapsed: boolean; +}) => { + const [clusterData, setClusterData] = + React.useState< + { + id: string; + group: number; + }[] + >(); + + React.useEffect(() => { + window.pywebview.api + .generate_cluster_data( + docState.id, + docState.clustermap.threshold_one, + docState.clustermap.threshold_two, + ) + .then(setClusterData); + }, [docState.id, docState.clustermap]); + + const { appState } = useAppState(); + const elementRef = React.useRef(null); + const size = useSize(elementRef, leftSidebarCollapsed); + const { clustermap: settings } = docState; + const { margin } = useMetrics(settings, tickText); + const updateSettings = React.useCallback( + (values: Partial) => + setDocState((prev) => ({ + ...prev, + clustermap: { + ...prev.clustermap, + ...values, + }, + })), + [setDocState], + ); + + const colorScale: ColorScaleArray = [ + [0, "rgb(245,245,245)"], + [1, "rgb(245,245,245)"], + ]; + + const clustermapData = React.useMemo( + () => formatClustermapData(data, tickText, clusterData), + [data, clusterData, tickText], + ); + + const forceSvgRender = useHeatmapRenderToggle(); + + const titleFont = + settings.titleFont === "Monospace" ? plotFontMonospace : plotFontSansSerif; + + return ( + <> +
+ {clusterData && forceSvgRender ? ( +
SVG
+ ) : null} + {clusterData ? ( + forceSvgRender || + (appState.showExportModal && appState.saveFormat === "svg") ? ( + + ) : ( + + ) + ) : null} +
+ + + ); +}; diff --git a/frontend/src/components/ClustermapSidebar.tsx b/frontend/src/components/ClustermapSidebar.tsx new file mode 100644 index 0000000..7187073 --- /dev/null +++ b/frontend/src/components/ClustermapSidebar.tsx @@ -0,0 +1,201 @@ +import React from "react"; +import { Input, Label, TextField } from "react-aria-components"; +import type { DocState } from "../appState"; +import { NumberInput } from "./NumberInput"; +import { Select, SelectItem } from "./Select"; +import { Slider } from "./Slider"; +import { Switch } from "./Switch"; + +export const ClustermapSidebar = ({ + settings, + updateSettings, + sequences_count, +}: { + settings: DocState["clustermap"]; + updateSettings: (values: Partial) => void; + sequences_count: number; +}) => { + const maybeWarnPerformance = React.useCallback( + (enabled: boolean, fn: () => void) => { + if ( + enabled && + sequences_count > 99 && + !confirm( + "Warning: Enabling this setting may significantly impact render performance.", + ) + ) { + return; + } + fn(); + }, + [sequences_count], + ); + + return ( +
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+ updateSettings({ cellspace: value })} + minValue={0} + maxValue={20} + value={settings.cellspace} + /> +
+
+
+
+ + maybeWarnPerformance(value, () => + updateSettings({ + annotation: value, + }), + ) + } + > + Percent Identities + +
+
+ + maybeWarnPerformance(value, () => + updateSettings({ + axis_labels: value, + }), + ) + } + > + Axis Labels + +
+ + updateSettings({ axlabel_fontsize: value }) + } + value={settings.axlabel_fontsize} + minValue={1} + maxValue={20} + step={1} + /> + + updateSettings({ axlabel_xrotation: value }) + } + value={settings.axlabel_xrotation} + minValue={-90} + maxValue={90} + step={10} + /> + + updateSettings({ axlabel_yrotation: value }) + } + value={settings.axlabel_yrotation} + minValue={-90} + maxValue={90} + step={10} + /> +
+
+
+ { + updateSettings({ + showTitles: value, + }); + }} + > + Plot Titles + +
+
+ + +
+ +
+ updateSettings({ title: value })} + value={settings.title} + > + + + +
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/D3CanvasHeatmap.tsx b/frontend/src/components/D3CanvasHeatmap.tsx index 309b8d7..3ddb490 100644 --- a/frontend/src/components/D3CanvasHeatmap.tsx +++ b/frontend/src/components/D3CanvasHeatmap.tsx @@ -1,13 +1,14 @@ import * as d3 from "d3"; import React from "react"; -import tinycolor from "tinycolor2"; -import { createD3ColorScale } from "../colors"; +import { distinctColor } from "../colors"; import { plotFontMonospace } from "../constants"; +import { getCellMetrics } from "../heatmapUtils"; import { useHeatmapRef } from "../hooks/useHeatmapRef"; import type { HeatmapRenderProps } from "./Heatmap"; export const D3CanvasHeatmap = ({ data, + clusterData, tickText, colorScale, minVal, @@ -18,16 +19,13 @@ export const D3CanvasHeatmap = ({ roundTo, cbarHeight, cbarWidth, - annotation_font_size, - axlabel_xfontsize, - axlabel_yfontsize, + axlabel_fontsize, axlabel_xrotation, axlabel_yrotation, titleFont, showPercentIdentities, showTitles, title, - subtitle, axis_labels, showscale, margin, @@ -39,28 +37,13 @@ export const D3CanvasHeatmap = ({ const [tooltipData, setTooltipData] = React.useState<{ x: number; y: number; - value: number; + value: number | null; xLabel?: string; yLabel?: string; } | null>(null); - const filteredData = React.useMemo( - () => data.filter((d) => Number(d.value)), - [data], - ); - - const size = Math.min(width, height); - const plotSize = size - margin.left - margin.right; - - const n = tickText.length; - const cellSize = plotSize / n; - - const colorFn = createD3ColorScale( - colorScale, - settings.colorScaleKey === "Discrete", - settings.vmax, - settings.vmin, - ); + const plotSize = Math.min(width, height) - margin.left - margin.right; + const cellSize = plotSize / tickText.length; const scale = React.useMemo( () => d3.scaleLinear().domain([maxVal, minVal]).range([0, cbarHeight]), @@ -71,10 +54,16 @@ export const D3CanvasHeatmap = ({ const drawCanvas = React.useCallback(() => { const canvas = canvasRef.current; - if (!canvas) return; + if (!canvas) { + console.warn("Failed to find canvas"); + return; + } const ctx = canvas.getContext("2d"); - if (!ctx) return; + if (!ctx) { + console.warn("Failed to get 2d context"); + return; + } const pixelRatio = window.devicePixelRatio || 1; canvas.width = width * pixelRatio; @@ -85,63 +74,59 @@ export const D3CanvasHeatmap = ({ ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.scale(pixelRatio, pixelRatio); + ctx.textRendering = "optimizeSpeed"; + // set zoom transform ctx.save(); ctx.translate(transform.x + margin.left, transform.y + margin.top); ctx.scale(transform.k, transform.k); - //indexz data - const rows = [...new Set(filteredData.map((d) => d.x))]; - const cols = [...new Set(filteredData.map((d) => d.y))]; - ctx.font = `${annotation_font_size}px ${plotFontMonospace.family}`; - const maxTextWidth = ctx.measureText("100.00").width; - let textFontSize = annotation_font_size; + const cellMetrics = getCellMetrics(cellSize, cellSpace, roundTo + 3); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `${cellMetrics.fontSize}px ${plotFontMonospace.family}`; - if (maxTextWidth > cellSize) { - textFontSize = annotation_font_size / (1 + 0.35 * roundTo); + //index data + const rows = new Map(); + const cols = new Map(); + let rowIndex = 0; + let colIndex = 0; + + for (const { x, y } of data) { + if (!rows.has(x)) rows.set(x, rowIndex++); + if (!cols.has(y)) cols.set(y, colIndex++); } // Draw cells - for (const d of filteredData) { - const x = cols.indexOf(d.x) * cellSize + cellSpace / 2; - const y = rows.indexOf(d.y) * cellSize + cellSpace / 2; - const rectSize = cellSize - cellSpace; + for (const d of data) { + const x = rows.get(d.x) * cellSize + cellMetrics.cellOffset; + const y = cols.get(d.y) * cellSize + cellMetrics.cellOffset; - ctx.fillStyle = colorFn(d.value); - ctx.fillRect(x, y, rectSize, rectSize); + ctx.fillStyle = d.backgroundColor; + ctx.fillRect(x, y, cellMetrics.cellSize, cellMetrics.cellSize); if (showPercentIdentities) { - const textColor = tinycolor(colorFn(d.value)).isLight() - ? "#000" - : "#fff"; - ctx.fillStyle = textColor; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - ctx.font = `${textFontSize}px ${plotFontMonospace.family}`; + ctx.fillStyle = d.foregroundColor; ctx.fillText( - d.value.toFixed(roundTo), - x + rectSize / 2, - y + rectSize / 2, + d.displayValue, + x + cellMetrics.textOffset, + y + cellMetrics.textOffset, ); } } ctx.restore(); - // Titles if (showTitles) { ctx.fillStyle = "black"; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.font = `Bold 20px ${titleFont.family}`; ctx.fillText(title, width / 2, margin.top - 20); - ctx.font = `20px ${titleFont.family}`; - ctx.fillText(subtitle, width / 2, margin.top); } - const axisGap = 5; - if (axis_labels) { + const axisGap = 5; + // X-axis labels for (const [i, txt] of tickText.entries()) { if (txt === undefined) continue; @@ -153,13 +138,13 @@ export const D3CanvasHeatmap = ({ transform.x, // X pan offset for cell transform.y + margin.top + plotSize * transform.k + axisGap, ); - ctx.rotate((axlabel_xrotation * Math.PI) / 180); + ctx.rotate(((axlabel_xrotation + 270) * Math.PI) / 180); ctx.fillStyle = "black"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; ctx.font = `${Math.max( - axlabel_xfontsize * transform.k, - axlabel_xfontsize, + axlabel_fontsize * transform.k, + axlabel_fontsize, )}px ${plotFontMonospace.family}`; ctx.fillText(txt, 0, 0); ctx.restore(); @@ -176,20 +161,19 @@ export const D3CanvasHeatmap = ({ (cellSize * transform.k) / 2 + // Center vertically within cell transform.y, // Y pan offset ); - ctx.rotate((axlabel_yrotation * Math.PI) / 180); + ctx.rotate(((axlabel_yrotation + 360) * Math.PI) / 180); ctx.fillStyle = "black"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; ctx.font = `${Math.max( - axlabel_yfontsize * transform.k, - axlabel_yfontsize, + axlabel_fontsize * transform.k, + axlabel_fontsize, )}px ${plotFontMonospace.family}`; ctx.fillText(txt, 0, 0); ctx.restore(); } } - // Colorbar gradient if (showscale) { const positionX = width - cbarWidth - margin.right; const gradient = ctx.createLinearGradient( @@ -224,10 +208,49 @@ export const D3CanvasHeatmap = ({ ctx.stroke(); } } + + if (clusterData) { + const legendWidth = 80; + const cellSize = 10; + const lineGap = 20; + const labelGap = 5; + const columnGap = 20; + const positionX = width - legendWidth * 2 - columnGap - margin.right; + + const uniqueClusters = [ + ...new Set(clusterData.map((i) => i.group)), + ].slice(0, 50); + + uniqueClusters.forEach((cluster, index) => { + // Determine column (0 for left, 1 for right) + const column = index % 2; + + // Calculate row position (every two items share the same row) + const row = Math.floor(index / 2); + + // Calculate position based on column and row + const itemX = positionX + column * (legendWidth + columnGap); + const itemY = margin.top + lineGap * row; + + // Draw colored square + ctx.fillStyle = distinctColor(index + 1); + ctx.fillRect(itemX, itemY, cellSize, cellSize); + + // Draw text + ctx.fillStyle = "black"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.font = `10px 'Roboto Mono'`; + ctx.fillText( + `Cluster ${cluster.toString()}`, + itemX + cellSize + labelGap, + itemY + cellSize / 2, + ); + }); + } }, [ transform, - filteredData, - colorFn, + data, scale, tickValues, cellSize, @@ -236,10 +259,7 @@ export const D3CanvasHeatmap = ({ roundTo, showTitles, title, - subtitle, - annotation_font_size, - axlabel_xfontsize, - axlabel_yfontsize, + axlabel_fontsize, axlabel_xrotation, axlabel_yrotation, titleFont, @@ -257,6 +277,7 @@ export const D3CanvasHeatmap = ({ minVal, maxVal, settings?.colorScaleKey, + clusterData, ]); React.useEffect(() => { @@ -269,7 +290,7 @@ export const D3CanvasHeatmap = ({ const zoom = d3 .zoom() - .scaleExtent([1, 5]) + .scaleExtent([0.5, 10]) .translateExtent([ [-margin.left, -margin.top], [width, height], @@ -281,19 +302,10 @@ export const D3CanvasHeatmap = ({ ); return () => { - d3.select(canvas).on(".zoom", null); + d3.select(canvas).on("zoom", null); }; }, [canvasRef, width, height, margin]); - console.log( - margin.left, - "left", - margin.right, - "right", - margin.top, - "top", - margin.bottom, - "bottom", - ); + const handleMouseMove = (event: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; @@ -309,13 +321,21 @@ export const D3CanvasHeatmap = ({ (y - margin.top - transform.y) / (cellSize * transform.k), ); - const cell = filteredData.find((d) => d.x === dataX && d.y === dataY); + const cell = data.find((d) => d.x === dataX && d.y === dataY); + + const clusterGroup = + clusterData && + cell && + clusterData.find((i) => i.id === tickText[cell.x])?.group === + clusterData.find((i) => i.id === tickText[cell.y])?.group + ? clusterData.find((i) => i.id === tickText[cell.x])?.group + : null; if (cell) { setTooltipData({ x, y, - value: cell.value, + value: clusterData ? (clusterGroup ?? null) : cell.value, xLabel: tickText[cell.x] || "", yLabel: tickText[cell.y] || "", }); @@ -351,8 +371,18 @@ export const D3CanvasHeatmap = ({
{tooltipData.yLabel}
-
Percent ID:
-
{tooltipData.value.toFixed(2)}%
+
+ {clusterData + ? tooltipData.value + ? "Group:" + : "" + : "Percent ID:"} +
+
+ {clusterData + ? tooltipData.value || "" + : `${tooltipData?.value?.toFixed(2)}%`} +
)} diff --git a/frontend/src/components/D3Heatmap.tsx b/frontend/src/components/D3SvgHeatmap.tsx similarity index 64% rename from frontend/src/components/D3Heatmap.tsx rename to frontend/src/components/D3SvgHeatmap.tsx index c72d037..88c1516 100644 --- a/frontend/src/components/D3Heatmap.tsx +++ b/frontend/src/components/D3SvgHeatmap.tsx @@ -1,14 +1,13 @@ import * as d3 from "d3"; import React from "react"; -import tinycolor from "tinycolor2"; -import { createD3ColorScale } from "../colors"; +import { distinctColor } from "../colors"; import { plotFontMonospace } from "../constants"; +import { getCellMetrics } from "../heatmapUtils"; import { useHeatmapRef } from "../hooks/useHeatmapRef"; import type { HeatmapRenderProps } from "./Heatmap"; -export const D3Heatmap = ({ +export const D3SvgHeatmap = ({ data, - settings, tickText, colorScale, minVal, @@ -20,18 +19,16 @@ export const D3Heatmap = ({ showPercentIdentities, cbarWidth, cbarHeight, - annotation_font_size, - axlabel_xfontsize, - axlabel_yfontsize, + axlabel_fontsize, axlabel_xrotation, axlabel_yrotation, titleFont, showTitles, title, - subtitle, showscale, axis_labels, margin, + clusterData, }: HeatmapRenderProps) => { const svgRef = useHeatmapRef() as React.MutableRefObject; const [_, setSvgTransform] = React.useState({}); @@ -54,56 +51,47 @@ export const D3Heatmap = ({ const d3Svg = d3.select(svgRef.current as Element); d3Svg.selectAll("*").remove(); - const size = Math.min(width, height); - const w = size - margin.left - margin.right; - const h = size - margin.top - margin.bottom; - - const n = tickText.length; - - const colorFn = createD3ColorScale( - colorScale, - settings.colorScaleKey === "Discrete", - settings.vmax, - settings.vmin, - ); + const plotSize = Math.min(width, height); + const plotWidth = plotSize - margin.left - margin.right; + const plotHeight = plotSize - margin.top - margin.bottom; + const cellSize = plotWidth / tickText.length; + const cellMetrics = getCellMetrics(cellSize, cellSpace, roundTo + 3); const g = d3 .select(svgRef.current) .append("g") .attr("transform", `translate(${margin.left}, ${margin.top})`); - const cellW = w / n; - const cellH = h / n; - const cellOffset = cellSpace > 0 ? cellSpace / 2 : 0; const axisGap = 5; const groups = g .selectAll("g") - .data(data.filter((d) => Number(d.value))) + .data(data) .join("g") - .attr("transform", (d) => `translate(${d.x * cellW}, ${d.y * cellH})`); + .attr( + "transform", + (d) => `translate(${d.x * cellSize}, ${d.y * cellSize})`, + ); groups .append("rect") - .attr("width", Math.max(cellW - cellSpace, 1)) - .attr("height", Math.max(cellH - cellSpace, 1)) - .attr("x", cellOffset) - .attr("y", cellOffset) - .attr("fill", (d) => colorFn(d.value)); + .attr("width", Math.max(cellMetrics.cellSize, 1)) + .attr("height", Math.max(cellMetrics.cellSize, 1)) + .attr("x", cellMetrics.cellOffset) + .attr("y", cellMetrics.cellOffset) + .attr("fill", (d) => d.backgroundColor); if (showPercentIdentities) { groups .append("text") - .attr("x", cellW / 2) - .attr("y", cellH / 2) + .attr("x", cellSize / 2) + .attr("y", cellSize / 2) .attr("dy", ".35em") .attr("text-anchor", "middle") .attr("font-family", "Roboto Mono") - .attr("font-size", `${annotation_font_size}px`) - .text((d) => d.value.toFixed(roundTo)) - .attr("fill", (d) => - tinycolor(colorFn(d.value)).isLight() ? "#000" : "#fff", - ); + .attr("font-size", `${cellMetrics.fontSize}px`) + .text((d) => d.displayValue) + .attr("fill", (d) => d.foregroundColor); } d3Svg.call( @@ -130,7 +118,6 @@ export const D3Heatmap = ({ .attr("font-family", titleFont.family) .attr("font-size", "20px") .attr("font-weight", "bold") - // .attr("text-align", "center") .attr("x", (width - margin.left - margin.right) / 2) .attr("y", margin.top - margin.bottom - 2) .text(title); @@ -140,28 +127,27 @@ export const D3Heatmap = ({ .attr("font-family", titleFont.family) .attr("font-size", "20px") .attr("x", (width - margin.left - margin.right) / 2) - .attr("y", margin.top - margin.bottom + 18) - .text(subtitle); + .attr("y", margin.top - margin.bottom + 18); } if (axis_labels) { // x-axis labels g.append("g") - .attr("transform", `translate(0, ${h})`) + .attr("transform", `translate(0, ${plotHeight})`) .selectAll("text") .data(tickText) .join("text") - .attr("x", (_, i) => i * cellW + cellW / 2) + .attr("x", (_, i) => i * cellSize + cellSize / 2) .attr("y", axisGap) .attr("dominant-baseline", "middle") .attr("text-anchor", "end") .attr("font-family", plotFontMonospace.family) - .attr("font-size", `${axlabel_xfontsize}px`) + .attr("font-size", `${axlabel_fontsize}px`) .text((txt) => txt) .attr( "transform", (_, i) => - `rotate(${axlabel_xrotation}, ${i * cellW + cellW / 2}, ${axisGap})`, + `rotate(${270 + axlabel_xrotation}, ${i * cellSize + cellSize / 2}, ${axisGap})`, ); // y-axis labels @@ -170,16 +156,16 @@ export const D3Heatmap = ({ .data(tickText) .join("text") .attr("x", -axisGap) - .attr("y", (_, i) => i * cellH + cellH / 2) + .attr("y", (_, i) => i * cellSize + cellSize / 2) .attr("dominant-baseline", "central") .attr("text-anchor", "end") .attr("font-family", plotFontMonospace.family) - .attr("font-size", `${axlabel_yfontsize}px`) + .attr("font-size", `${axlabel_fontsize}px`) .text((txt) => txt) .attr( "transform", (_, i) => - `rotate(${axlabel_yrotation}, ${-axisGap}, ${i * cellH + cellH / 2})`, + `rotate(${360 + axlabel_yrotation}, ${-axisGap}, ${i * cellSize + cellSize / 2})`, ); } @@ -226,6 +212,52 @@ export const D3Heatmap = ({ .attr("font-family", "Roboto Mono"), ); } + + if (clusterData) { + const legendWidth = 80; + const cellSize = 10; + const lineGap = 20; + const labelGap = 5; + const columnGap = 20; + const positionX = width - legendWidth * 2 - columnGap - margin.right; + + const uniqueClusters = [ + ...new Set(clusterData.map((i) => i.group)), + ].slice(0, 50); + + const legends = g + .append("g") + .attr("transform", () => `translate(-${margin.right}, 0)`) + .selectAll("g") + .data(uniqueClusters) + .join("g") + .attr("transform", (d) => { + const index = d - 1; // data from d3 is 1-indexed + const column = index % 2; + const row = Math.floor(index / 2); + const itemX = positionX + column * (legendWidth + columnGap); + const itemY = lineGap * row; + return `translate(${itemX}, ${itemY})`; + }); + + legends + .append("rect") + .attr("width", cellSize) + .attr("height", cellSize) + .attr("x", 0) + .attr("y", 0) + .attr("fill", (d) => distinctColor(d)); + + legends + .append("text") + .attr("x", cellSize + labelGap) + .attr("y", cellSize / 2) + .attr("dy", ".35em") + .attr("font-family", "Roboto Mono") + .attr("font-size", "10px") + .text((d) => `Cluster ${d}`) + .attr("fill", "black"); + } }, [ tickValues, scale, @@ -235,27 +267,21 @@ export const D3Heatmap = ({ svgRef.current, data, tickText, - colorScale, - settings.colorScaleKey, - settings.vmin, - settings.vmax, width, height, cellSpace, roundTo, showPercentIdentities, - annotation_font_size, - axlabel_xfontsize, - axlabel_yfontsize, + axlabel_fontsize, axlabel_xrotation, axlabel_yrotation, titleFont, showTitles, title, - subtitle, showscale, axis_labels, margin, + clusterData, ]); return ( diff --git a/frontend/src/components/ExportModal.tsx b/frontend/src/components/ExportModal.tsx index 6405b76..dfd17f5 100644 --- a/frontend/src/components/ExportModal.tsx +++ b/frontend/src/components/ExportModal.tsx @@ -123,6 +123,37 @@ export const ExportModal = () => { await new Promise((r) => setTimeout(r, renderTimeout)); + swapDataView("clustermap"); + await new Promise((r) => setTimeout(r, renderTimeout)); + let clustermapImage = ""; + + // Clustermap reuses the heatmap component + if (config.format === "svg") { + if (!heatmapRef.current) { + throw new Error("Expected heatmapRef to have a current value"); + } + const encoded64Svg = encodeURIComponent(heatmapRef.current.outerHTML); + clustermapImage = `data:image/svg+xml;base64,${encoded64Svg}`; + } else { + clustermapImage = await new Promise((resolve) => { + if (!heatmapRef.current) { + throw new Error("Expected heatmapRef to have a current value"); + } + + (heatmapRef.current as HTMLCanvasElement).toBlob(async (blob) => { + if (blob) { + const arrayBuffer = await blob.arrayBuffer(); + const binary = Array.from(new Uint8Array(arrayBuffer)) + .map((byte) => String.fromCharCode(byte)) + .join(""); + resolve(`data:image/${config.format};base64,${btoa(binary)}`); + } else { + resolve(""); + } + }, `image/${config.format}`); + }); + } + // Plotly exports swapDataView("distribution_histogram"); @@ -142,15 +173,8 @@ export const ExportModal = () => { await Plotly.toImage(element, config); const violinImage = await Plotly.toImage(element, config); - // swapDataView("distribution_raincloud"); - - // await new Promise((r) => setTimeout(r, renderTimeout)); - // element = getPlotlyElement(); - // await Plotly.toImage(element, config); - // const raincloudImage = await Plotly.toImage(element, config); - swapDataView(previousDataView); - return { heatmapImage, histogramImage, violinImage }; + return { heatmapImage, clustermapImage, histogramImage, violinImage }; }, [heatmapRef, appState, docState.dataView, swapDataView]); const doExport = React.useCallback(() => { @@ -164,6 +188,7 @@ export const ExportModal = () => { cluster_threshold_one: thresholds.one, cluster_threshold_two: thresholds.two, heatmap_image_data: images.heatmapImage, + clustermap_image_data: images.clustermapImage, histogram_image_data: images.histogramImage, violin_image_data: images.violinImage, image_format: appState.saveFormat, diff --git a/frontend/src/components/Heatmap.tsx b/frontend/src/components/Heatmap.tsx index 41eddb3..4ba157c 100644 --- a/frontend/src/components/Heatmap.tsx +++ b/frontend/src/components/Heatmap.tsx @@ -9,15 +9,19 @@ import { colorScales as defaultColorScales, } from "../colorScales"; import { plotFontMonospace, plotFontSansSerif } from "../constants"; +import { type formatClustermapData, formatHeatmapData } from "../heatmapUtils"; +import { useMetrics, useSize } from "../hooks/heatmap"; +import { useHeatmapRenderToggle } from "../hooks/useHeatmapRenderToggle"; import type { HeatmapData, HeatmapSettings, MetaData } from "../plotTypes"; import { D3CanvasHeatmap } from "./D3CanvasHeatmap"; -import { D3Heatmap } from "./D3Heatmap"; +import { D3SvgHeatmap } from "./D3SvgHeatmap"; import { HeatmapSidebar } from "./HeatmapSidebar"; export type HeatmapRenderProps = { // TODO: just use settings - data: { x: number; y: number; value: number }[]; - metaData: MetaData; + data: + | ReturnType + | ReturnType; settings: HeatmapSettings; tickText: string[]; colorScale: ColorScaleArray; @@ -29,19 +33,18 @@ export type HeatmapRenderProps = { roundTo: number; cbarWidth: number; cbarHeight: number; - axlabel_xfontsize: number; - axlabel_yfontsize: number; + axlabel_fontsize: number; axlabel_xrotation: number; axlabel_yrotation: number; showPercentIdentities: boolean; showTitles: boolean; title: string; - subtitle: string; showscale: boolean; axis_labels: boolean; titleFont: typeof plotFontMonospace | typeof plotFontSansSerif; margin: { top: number; bottom: number; left: number; right: number }; -} & Pick; + clusterData?: { id: string; group: number }[]; +} & Pick; export const Heatmap = ({ data, @@ -95,52 +98,7 @@ export const Heatmap = ({ }), [discreteColorScale], ); - - const d3HeatmapData = React.useMemo( - () => - data.flatMap((row, y) => - row.map((value, x) => ({ - x, - y, - value: Number(value), - })), - ), - [data], - ); - - const elementRef = React.useRef(null); - const [size, setSize] = React.useState<{ width: number; height: number }>({ - width: 0, - height: 0, - }); - - const updateSize = React.useCallback(() => { - if (elementRef.current) { - const { offsetWidth, offsetHeight } = elementRef.current; - setSize({ width: offsetWidth, height: offsetHeight }); - } - }, []); - - // biome-ignore lint/correctness/useExhaustiveDependencies(leftSidebarCollapsed): trigger updateSize - React.useEffect(() => { - updateSize(); - - const handleResize = () => { - updateSize(); - }; - - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [updateSize, leftSidebarCollapsed]); - - const cbar_shrink = settings.cbar_shrink * 60; - const cbar_aspect = settings.cbar_aspect * 10; - let colorScale = colorScales[settings.colorScaleKey]; - if (settings.reverse) { colorScale = [...colorScale] .reverse() @@ -150,33 +108,31 @@ export const Heatmap = ({ ]) as ColorScaleArray; } + const heatmapData = React.useMemo( + () => formatHeatmapData(data, settings, colorScale), + [data, settings, colorScale], + ); + + const { cbar_shrink, cbar_aspect, margin } = useMetrics(settings, tickText); + + const elementRef = React.useRef(null); + const size = useSize(elementRef, leftSidebarCollapsed); + const titleFont = settings.titleFont === "Monospace" ? plotFontMonospace : plotFontSansSerif; - const longestTickWidth = - Math.max(...tickText.map((tick) => tick.length)) * - settings.axlabel_yfontsize; - console.log("longestTickWidth", longestTickWidth); - const margin = { - top: 60, - right: 60, - bottom: settings.axis_labels ? Math.max(longestTickWidth, 60) : 60, - left: settings.axis_labels ? Math.max(longestTickWidth, 60) : 60, - }; + const forceSvgRender = useHeatmapRenderToggle(); return ( <> {data ? ( <> -
- {appState.showExportModal && appState.saveFormat === "svg" ? ( - + {forceSvgRender ?
SVG
: null} + {forceSvgRender || + (appState.showExportModal && appState.saveFormat === "svg") ? ( + ) : ( diff --git a/frontend/src/components/HeatmapSidebar.tsx b/frontend/src/components/HeatmapSidebar.tsx index b5bb2fa..47ab736 100644 --- a/frontend/src/components/HeatmapSidebar.tsx +++ b/frontend/src/components/HeatmapSidebar.tsx @@ -1,5 +1,12 @@ import React from "react"; -import { Input, Label, TextField, ToggleButton } from "react-aria-components"; +import { + Input, + Label, + TextField, + ToggleButton, + ToggleButtonGroup, +} from "react-aria-components"; +import { TbRepeat } from "react-icons/tb"; import type { DocState } from "../appState"; import type { ColorScaleArray } from "../colorScales"; import { formatTitle } from "../helpers"; @@ -90,25 +97,7 @@ export const HeatmapSidebar = ({ }); }} > - +
@@ -125,7 +114,6 @@ export const HeatmapSidebar = ({ value={settings.cellspace} />
- {settings.colorScaleKey === "Discrete" ? (
@@ -145,7 +133,7 @@ export const HeatmapSidebar = ({ field="cutoff_2" value={settings.cutoff_2} updateValue={updateSettings} - min={0} + min={settings.vmin + 1} max={settings.cutoff_1 - 1} step={1} /> @@ -172,94 +160,27 @@ export const HeatmapSidebar = ({ data-hidden={!settings.annotation} aria-hidden={!settings.annotation} > -
-
- - -
- -
-
-
-
- { - updateSettings({ - showTitles: value, - }); - }} - > - Plot Titles - -
-
- - -
- -
- updateSettings({ title: value })} - value={settings.title} - > - - - -
-
- updateSettings({ subtitle: value })} - value={settings.subtitle} - > - - - + 0 + 1 + 2 + +
@@ -281,44 +202,39 @@ export const HeatmapSidebar = ({ data-hidden={!settings.axis_labels} aria-hidden={!settings.axis_labels} > -
- - - - -
+ + updateSettings({ axlabel_fontsize: value }) + } + value={settings.axlabel_fontsize} + minValue={1} + maxValue={20} + step={1} + /> + + updateSettings({ axlabel_xrotation: value }) + } + value={settings.axlabel_xrotation} + minValue={-90} + maxValue={90} + step={10} + /> + + updateSettings({ axlabel_yrotation: value }) + } + value={settings.axlabel_yrotation} + minValue={-90} + maxValue={90} + step={10} + />
@@ -363,7 +279,7 @@ export const HeatmapSidebar = ({ value={settings.vmin} updateValue={updateSettings} min={1} - max={settings.vmax - 1} + max={settings.vmax} step={1} isDisabled={settings.colorScaleKey === "Discrete"} /> @@ -372,7 +288,7 @@ export const HeatmapSidebar = ({ field="vmax" value={settings.vmax} updateValue={updateSettings} - min={settings.vmin + 1} + min={settings.vmin} max={100} step={1} isDisabled={settings.colorScaleKey === "Discrete"} @@ -380,6 +296,55 @@ export const HeatmapSidebar = ({
+
+ { + updateSettings({ + showTitles: value, + }); + }} + > + Plot Titles + +
+
+ + +
+ +
+ updateSettings({ title: value })} + value={settings.title} + > + + + +
+
+
diff --git a/frontend/src/components/Histogram.tsx b/frontend/src/components/Histogram.tsx index ad8cbf3..9c7e9cf 100644 --- a/frontend/src/components/Histogram.tsx +++ b/frontend/src/components/Histogram.tsx @@ -75,11 +75,7 @@ export const Histogram = ({ ...(settings.showTitles ? { title: { - text: - settings.title + - (settings.subtitle - ? `
${settings.subtitle}` - : ""), + text: settings.title, pad: { t: 100, r: 0, diff --git a/frontend/src/components/HistogramSidebar.tsx b/frontend/src/components/HistogramSidebar.tsx index b9278ca..c8b0c2c 100644 --- a/frontend/src/components/HistogramSidebar.tsx +++ b/frontend/src/components/HistogramSidebar.tsx @@ -5,6 +5,12 @@ import { ToggleButton, ToggleButtonGroup, } from "react-aria-components"; +import { + TbGrid4X4, + TbLetterA, + TbNumber10, + TbTableDashed, +} from "react-icons/tb"; import type { ColorString } from "../colors"; import type { DistributionState } from "../distributionState"; import { ColorPicker } from "./ColorPicker"; @@ -54,24 +60,7 @@ export const HistogramSidebar = ({ > - + @@ -79,26 +68,7 @@ export const HistogramSidebar = ({ id="showAxisLines" aria-label="Toggle axis lines" > - + @@ -106,25 +76,7 @@ export const HistogramSidebar = ({ id="showAxisLabels" aria-label="Toggle axis labels" > - + @@ -132,25 +84,7 @@ export const HistogramSidebar = ({ id="showTickLabels" aria-label="Toggle axis tick values" > - + @@ -265,15 +199,6 @@ export const HistogramSidebar = ({ -
- updateSettings({ subtitle: value })} - value={settings.subtitle} - > - - - -
updateSettings({ xtitle: value })} diff --git a/frontend/src/components/Icons.tsx b/frontend/src/components/Icons.tsx deleted file mode 100644 index 26a5f05..0000000 --- a/frontend/src/components/Icons.tsx +++ /dev/null @@ -1,18 +0,0 @@ -const DocumentIcon = () => ( - -); - -const Icons = { - Document: DocumentIcon, -}; - -export default Icons; diff --git a/frontend/src/components/Menu.tsx b/frontend/src/components/Menu.tsx index 1164d4c..1e14612 100644 --- a/frontend/src/components/Menu.tsx +++ b/frontend/src/components/Menu.tsx @@ -12,6 +12,7 @@ import { Separator, SubmenuTrigger, } from "react-aria-components"; +import { TbMenu2 } from "react-icons/tb"; import { type AppState, findDoc, useAppState } from "../appState"; import { isSDTFile } from "../helpers"; import { useCloseDocument } from "../hooks/useCloseDocument"; @@ -27,7 +28,6 @@ interface MyMenuButtonProps } const AppMenuButton = ({ - label, children, ...props }: MyMenuButtonProps) => { @@ -37,33 +37,7 @@ const AppMenuButton = ({ className="react-aria-Button main-menu-button" aria-label="Application Menu" > - + {children} @@ -153,7 +127,7 @@ export const MainMenu = createHideableComponent(() => { }, []); return ( - + New Open... diff --git a/frontend/src/components/Raincloud.tsx b/frontend/src/components/Raincloud.tsx index 68b4485..5b5bbcd 100644 --- a/frontend/src/components/Raincloud.tsx +++ b/frontend/src/components/Raincloud.tsx @@ -70,10 +70,11 @@ export const Raincloud = ({ hoveron: "points", hovertemplate: "%{text}

Percent Identity: %{x}", text: data.identity_combos.map( - (ids) => `Seq 1: ${ids[0]}
Seq 2: ${ids[1]}`, + (idIndexes) => + `Seq 1: ${data.ids[idIndexes[0]]}
Seq 2: ${data.ids[idIndexes[1]]}`, ), }) as Partial, - [data.identity_combos, dataSet, settings], + [data.identity_combos, data.ids, dataSet, settings], ); return ( <> @@ -84,11 +85,7 @@ export const Raincloud = ({ ...(settings.showTitles ? { title: { - text: - settings.title + - (settings.subtitle - ? `
${settings.subtitle}` - : ""), + text: settings.title, pad: { t: 100, r: 0, diff --git a/frontend/src/components/RaincloudSiderbar.tsx b/frontend/src/components/RaincloudSiderbar.tsx index 01cd387..6179c72 100644 --- a/frontend/src/components/RaincloudSiderbar.tsx +++ b/frontend/src/components/RaincloudSiderbar.tsx @@ -301,15 +301,7 @@ export const RaincloudSidebar = ({
-
- updateSettings({ subtitle: value })} - value={settings.subtitle} - > - - - -
+
updateSettings({ xtitle: value })} diff --git a/frontend/src/components/Runner.tsx b/frontend/src/components/Runner.tsx index 4b3226f..d230282 100644 --- a/frontend/src/components/Runner.tsx +++ b/frontend/src/components/Runner.tsx @@ -10,6 +10,7 @@ import { SliderTrack, TabPanel, } from "react-aria-components"; +import { TbAlertTriangleFilled, TbFile } from "react-icons/tb"; import useAppState, { type AppState, type DocState, @@ -22,7 +23,6 @@ import useOpenFileDialog from "../hooks/useOpenFileDialog"; import { useStartRun } from "../hooks/useStartRun"; import messages from "../messages"; import { openFile } from "../services/files"; -import Icons from "./Icons"; import { Select, SelectItem } from "./Select"; import { Switch } from "./Switch"; @@ -32,22 +32,6 @@ export type RunProcessDataArgs = Pick & { export_alignments: "True" | "False"; }; -const WarningIcon = () => ( - -); - const RunnerSettings = ({ docState, setDocState, @@ -295,7 +279,7 @@ const RunnerSettings = ({ (docState.compute_stats.recommended_cores === 0 || appState.compute_cores > docState.compute_stats.recommended_cores) ? ( - + ) : null} {state.getThumbValueLabel(0)} /{" "} {appState.platform.cores} @@ -433,7 +417,7 @@ const RunnerSettings = ({ key={file} onPress={() => openFile(file, docState.id)} > - +

{splitFilePath(file).name}

diff --git a/frontend/src/components/Viewer.tsx b/frontend/src/components/Viewer.tsx index f7fb061..28ca5f7 100644 --- a/frontend/src/components/Viewer.tsx +++ b/frontend/src/components/Viewer.tsx @@ -2,6 +2,7 @@ import React from "react"; import { type Key, Tab, TabList, TabPanel, Tabs } from "react-aria-components"; import type { DocState, SetDocState, UpdateDocState } from "../appState"; import { useGetData } from "../hooks/useGetData"; +import { Clustermap } from "./Clustermap"; import { DistributionPanels } from "./DistributionPanels"; import { Heatmap } from "./Heatmap"; @@ -72,6 +73,26 @@ export const Viewer = ({ Heatmap
+ +
+ + + + + + + + Clustermap +
+
- + @@ -145,6 +163,17 @@ export const Viewer = ({ /> ) : null} + + {!loading && heatmapData ? ( + + ) : null} + {distributionData && metaData ? ( - `Seq 1: ${ids[0]}
Seq 2: ${ids[1]}

${hoverData.scores.title}: `, + (idIndexes) => + `Seq 1: ${data.ids[idIndexes[0]]}
Seq 2: ${data.ids[idIndexes[1]]}

${hoverData.scores.title}: `, ); const { index, suffix, title } = hoverData[dataSetKey]; @@ -124,6 +124,8 @@ export const Violin = ({ fillcolor: settings.fillColor, meanline: { visible: settings.showMeanline, + width: settings.showBox ? settings.boxlineWidth : settings.lineWidth, + color: settings.lineColor, }, points: settings.showPoints && @@ -140,7 +142,10 @@ export const Violin = ({ hoveron: "points", scalemode: "width", // we are going to rip this nonsense out for D3 ASAP - hovertemplate: `%{text}${dataSetKey === "scores" && `%{${settings.plotOrientation === "vertical" ? "y" : "x"}}${hoverData.scores.suffix}`}`, + hovertemplate: + dataSetKey === "scores" + ? `%{text}%{${settings.plotOrientation === "vertical" ? "y" : "x"}}${hoverData.scores.suffix}` + : "%{text}", text: hoverText, }) as Partial, [dataSet, dataSetKey, settings, hoverText], @@ -165,16 +170,21 @@ export const Violin = ({ }, whiskerwidth: settings.whiskerWidth, marker: { - visible: settings.showBox, + visible: settings.showPoints, color: settings.markerColor, size: settings.markerSize, }, fillcolor: settings.boxfillColor, - hovermode: "closest", boxgap: 1 - settings.boxWidth, - hoverinfo: "skip", + hoverinfo: "text", + hovertemplate: + dataSetKey === "scores" + ? `%{text}%{${settings.plotOrientation === "vertical" ? "y" : "x"}}${hoverData.scores.suffix}` + : "%{text}", + text: hoverText, + hoveron: "points", }) as Partial, - [dataSet, settings], + [dataSet, dataSetKey, settings, hoverText], ); return ( @@ -196,9 +206,6 @@ export const Violin = ({ b: 0, l: 0, }, - subtitle: { - text: settings.subtitle, - }, }, } : {}), diff --git a/frontend/src/components/ViolinSidebar.tsx b/frontend/src/components/ViolinSidebar.tsx index 5be9c8d..b62dd3a 100644 --- a/frontend/src/components/ViolinSidebar.tsx +++ b/frontend/src/components/ViolinSidebar.tsx @@ -5,6 +5,12 @@ import { ToggleButton, ToggleButtonGroup, } from "react-aria-components"; +import { + TbGrid4X4, + TbLineDashed, + TbNumber10, + TbTableDashed, +} from "react-icons/tb"; import type { ColorString } from "../colors"; import type { DistributionState } from "../distributionState"; import { ColorPicker } from "./ColorPicker"; @@ -52,95 +58,22 @@ export const ViolinSidebar = ({ > - + - + - + - + @@ -436,25 +369,15 @@ export const ViolinSidebar = ({ )}
- - updateSettings({ title: value })} - value={settings.title} - > - - - -
updateSettings({ subtitle: value })} - value={settings.subtitle} + onChange={(value) => updateSettings({ title: value })} + value={settings.title} > - +
-
updateSettings({ xtitle: value })} diff --git a/frontend/src/distributionState.ts b/frontend/src/distributionState.ts index 7ce7232..8f311a2 100644 --- a/frontend/src/distributionState.ts +++ b/frontend/src/distributionState.ts @@ -39,7 +39,6 @@ export type DistributionState = { dtickx: number; dticky: number; title: string; - subtitle: string; xtitle: string; ytitle: string; plotOrientation: "horizontal" | "vertical"; @@ -63,7 +62,6 @@ export type DistributionState = { editable: boolean; dticks: number; title: string; - subtitle: string; xtitle: string; ytitle: string; titleFont: "Monospace" | "Sans Serif"; @@ -90,7 +88,6 @@ export type DistributionState = { whiskerWidth: number; plotOrientation: "horizontal" | "vertical"; title: string; - subtitle: string; xtitle: string; ytitle: string; titleFont: "Monospace" | "Sans Serif"; @@ -123,7 +120,6 @@ export const DistributionStateSchema = z.object({ dtickx: z.number(), dticky: z.number(), title: z.string(), - subtitle: z.string(), xtitle: z.string(), ytitle: z.string(), plotOrientation: z.enum(["horizontal", "vertical"]), @@ -147,7 +143,6 @@ export const DistributionStateSchema = z.object({ editable: z.boolean(), dticks: z.number(), title: z.string(), - subtitle: z.string(), xtitle: z.string(), ytitle: z.string(), titleFont: z.enum(["Monospace", "Sans Serif"]), @@ -174,7 +169,6 @@ export const DistributionStateSchema = z.object({ whiskerWidth: z.number(), plotOrientation: z.enum(["horizontal", "vertical"]), title: z.string(), - subtitle: z.string(), xtitle: z.string(), ytitle: z.string(), titleFont: z.enum(["Monospace", "Sans Serif"]), @@ -207,7 +201,6 @@ export const initialDistributionState: DistributionState = { dtickx: 5, dticky: 1, showTitles: true, - subtitle: "Histogram", title: "Histogram", xtitle: "Percent Identity", ytitle: "Frequency", @@ -234,7 +227,6 @@ export const initialDistributionState: DistributionState = { makeEditable: true, dticks: 5, showTitles: true, - subtitle: "Raincloud Plot", title: "Raincloud Plot", xtitle: "Percent Identity", ytitle: "Genome", @@ -266,7 +258,6 @@ export const initialDistributionState: DistributionState = { showTitles: true, showTickLabels: true, title: "Violin Plot", - subtitle: "Violin Plot", xtitle: "", ytitle: "", titleFont: "Sans Serif", diff --git a/frontend/src/heatmapUtils.ts b/frontend/src/heatmapUtils.ts new file mode 100644 index 0000000..8e615cf --- /dev/null +++ b/frontend/src/heatmapUtils.ts @@ -0,0 +1,117 @@ +import tinycolor from "tinycolor2"; +import type { ColorScaleArray } from "./colorScales"; +import { createD3ColorScale, distinctColor } from "./colors"; +import type { HeatmapData, HeatmapSettings } from "./plotTypes"; + +export const getPlotMetrics = (width: number, height: number) => { + // WIP + return { + width: width, + height: height, + }; +}; + +export const getCellMetrics = ( + cellSize: number, + cellSpace: number, + characterCount: number, +) => { + if (characterCount <= 0) throw new Error("characterCount must be > 0"); + + const CHARACTER_WIDTH = 0.6; // Approximate width for Roboto Mono @ 10px + const USABLE_SPACE_RATIO = 0.8; + const MIN_FONT_SIZE = 0.25; + const MAX_FONT_SIZE = 20; + + const scaledCellSpace = cellSpace * (cellSize / (cellSize + 20)); + const spacedCellSize = Math.max(1, cellSize - scaledCellSpace); + const scaledFontSize = Math.min( + MAX_FONT_SIZE, + Math.max( + MIN_FONT_SIZE, + (spacedCellSize * USABLE_SPACE_RATIO) / + (characterCount * CHARACTER_WIDTH), + ), + ); + + return { + cellSize: spacedCellSize, + cellSpace: scaledCellSpace, + cellOffset: scaledCellSpace / 2, + fontSize: scaledFontSize, + textOffset: spacedCellSize / 2, + }; +}; + +export const formatHeatmapData = ( + data: HeatmapData, + settings: Pick< + HeatmapSettings, + "colorScaleKey" | "vmax" | "vmin" | "annotation_rounding" + >, + colorScale: ColorScaleArray, +) => { + const colorFn = createD3ColorScale( + colorScale, + settings.colorScaleKey === "Discrete", + settings.vmax, + settings.vmin, + ); + + return data.flatMap((row, y) => + row.filter(Number).map((value, x) => { + const backgroundColor = colorFn(Number(value)); + const foregroundColor = tinycolor(backgroundColor).isLight() + ? "#000" + : "#fff"; + const roundedValue = (value as number).toFixed( + settings.annotation_rounding, + ); + + return { + x, + y, + value: value as number, + displayValue: value === 100 ? "100" : roundedValue.toString(), + backgroundColor, + foregroundColor, + }; + }), + ); +}; + +export const formatClustermapData = ( + data: HeatmapData, + tickText: string[], + clusterData?: { id: string; group: number }[], +) => + data.flatMap((row, y) => + row.filter(Number).map((value, x) => { + const clusterX = clusterData?.find((i) => i.id === tickText[x])?.group; + const clusterY = clusterData?.find((i) => i.id === tickText[y])?.group; + + const clusterMatch = + clusterX !== undefined && + clusterY !== undefined && + clusterX === clusterY; + + const clusterGroup = clusterMatch ? clusterX : null; + + const backgroundColor = clusterGroup + ? distinctColor(clusterGroup) + : "rgb(245, 245, 245)"; + const foregroundColor = tinycolor(backgroundColor).isLight() + ? "#000" + : "#fff"; + const roundedValue = (value as number).toFixed(2); + + return { + x, + y, + value: value as number, + displayValue: value === 100 ? "100" : roundedValue.toString(), + backgroundColor, + foregroundColor, + }; + }), + ); diff --git a/frontend/src/hooks/heatmap.ts b/frontend/src/hooks/heatmap.ts new file mode 100644 index 0000000..1619c9e --- /dev/null +++ b/frontend/src/hooks/heatmap.ts @@ -0,0 +1,69 @@ +import React from "react"; +import type { ClustermapSettings, HeatmapSettings } from "../plotTypes"; + +export const useSize = ( + elementRef: React.MutableRefObject, + leftSidebarCollapsed: boolean, +) => { + const [size, setSize] = React.useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + + const updateSize = React.useCallback(() => { + if (elementRef.current) { + const { offsetWidth, offsetHeight } = elementRef.current; + setSize({ width: offsetWidth, height: offsetHeight }); + } + }, [elementRef.current]); + + // TODO: Do this the right way + // biome-ignore lint/correctness/useExhaustiveDependencies(leftSidebarCollapsed): trigger updateSize + React.useEffect(() => { + updateSize(); + + const handleResize = () => { + updateSize(); + }; + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [updateSize, leftSidebarCollapsed]); + + return size; +}; + +export const useMetrics = ( + settings: HeatmapSettings | ClustermapSettings, + tickText: string[], +) => { + const longestTickWidth = React.useMemo( + () => + Math.max(...tickText.map((tick) => tick.length)) * + settings.axlabel_fontsize, + [tickText, settings.axlabel_fontsize], + ); + + const margin = React.useMemo( + () => ({ + top: 60, + right: 60, + bottom: settings.axis_labels ? Math.max(longestTickWidth, 60) : 60, + left: settings.axis_labels ? Math.max(longestTickWidth, 60) : 60, + }), + [longestTickWidth, settings.axis_labels], + ); + + return { + ...("cbar_shrink" in settings && { + cbar_shrink: settings.cbar_shrink * 60, + }), + ...("cbar_aspect" in settings && { + cbar_aspect: settings.cbar_aspect * 10, + }), + margin, + }; +}; diff --git a/frontend/src/hooks/useAnnotations.ts b/frontend/src/hooks/useAnnotations.ts deleted file mode 100644 index 0124557..0000000 --- a/frontend/src/hooks/useAnnotations.ts +++ /dev/null @@ -1,87 +0,0 @@ -import React from "react"; -import tinycolor from "tinycolor2"; -import type { DocState } from "../appState"; -import type { ColorScaleArray } from "../colorScales"; -import { interpolateColor, originalRgbFormat } from "../colors"; -import type { HeatmapData } from "../plotTypes"; - -export const useAnnotations = ( - enabled: boolean, - data: HeatmapData, - vmin: number, - vmax: number, - _colorScale: ColorScaleArray, - reverse: boolean, - roundTo: DocState["heatmap"]["annotation_rounding"], -) => - React.useMemo(() => { - if (!enabled) { - return { - x: [], - y: [], - text: [], - textColors: [], - }; - } - - const x: number[] = []; - const y: number[] = []; - const text: string[] = []; - const textColors: (string | null)[] = []; - let colorScale = _colorScale; - - if (reverse) { - colorScale = [..._colorScale] - .reverse() - .map((data, i) => [ - (_colorScale[i] ?? _colorScale[0])[0], - data[1], - ]) as ColorScaleArray; - } - - const dataMin = vmin; - const dataMax = vmax; - const dataDiff = dataMax - dataMin; - - data.forEach((row, rowIndex) => { - row.forEach((datum, columnIndex) => { - x.push(columnIndex); - y.push(rowIndex); - - if (datum === null) { - text.push(""); - } else { - text.push(Number.parseFloat(datum).toFixed(roundTo)); - } - - const parsedDatum = Number.parseFloat(datum); - const normalizedDatum = Math.max(0, (parsedDatum - dataMin) / dataDiff); - - if (datum === null) { - textColors.push(null); - } else { - const interpolated = interpolateColor( - colorScale, - normalizedDatum, - originalRgbFormat, - ); - const textColor = tinycolor - .mostReadable(interpolated.value[1], ["#fff", "#000"], { - includeFallbackColors: false, - level: "AAA", - size: "small", - }) - .toHexString(); - - textColors.push(textColor); - } - }); - }); - - return { - x, - y, - text, - textColors, - }; - }, [enabled, data, vmin, vmax, _colorScale, reverse, roundTo]); diff --git a/frontend/src/hooks/useGetData.ts b/frontend/src/hooks/useGetData.ts index 008d588..89add2c 100644 --- a/frontend/src/hooks/useGetData.ts +++ b/frontend/src/hooks/useGetData.ts @@ -30,7 +30,9 @@ export const useGetData = (docState: DocState, setDocState: SetDocState) => { const parsedResponse: GetDataResponse = JSON.parse( rawData.replace(/\bNaN\b/g, "null"), ); - const { data, metadata, identity_scores, full_stats } = parsedResponse; + + const { data, metadata, ids, identity_scores, full_stats } = + parsedResponse; const [tickText, ...parsedData] = data; setMetaData(metadata); @@ -41,12 +43,13 @@ export const useGetData = (docState: DocState, setDocState: SetDocState) => { gc_stats: full_stats.map((row) => row[1]), length_stats: full_stats.map((row) => row[2]), raw_mat: identity_scores.map((i) => i[2]), + ids, identity_combos: identity_scores.map((i) => [i[0], i[1]]), }); const state = await getDocument(docState.id); - const scaledFontSize = getScaledFontSize( - initialDocState.heatmap.annotation_font_size, + const scaledAxisLabelFontSize = getScaledFontSize( + initialDocState.heatmap.axlabel_fontsize, parsedData.map(Boolean).length, ); @@ -68,9 +71,8 @@ export const useGetData = (docState: DocState, setDocState: SetDocState) => { ...(docState.filetype === "application/vnd.sdt" ? null : { - annotation_font_size: scaledFontSize, - axlabel_xfontsize: scaledFontSize, - axlabel_yfontsize: scaledFontSize, + axlabel_fontsize: scaledAxisLabelFontSize, + axlabel_yfontsize: scaledAxisLabelFontSize, }), }, }), diff --git a/frontend/src/hooks/useHeatmapRenderToggle.ts b/frontend/src/hooks/useHeatmapRenderToggle.ts new file mode 100644 index 0000000..97d12cc --- /dev/null +++ b/frontend/src/hooks/useHeatmapRenderToggle.ts @@ -0,0 +1,22 @@ +import React from "react"; + +export const useHeatmapRenderToggle = () => { + const [forceSvgRender, setForceSvgRender] = React.useState(false); + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.altKey) && event.key === "1") { + setForceSvgRender(true); + event.preventDefault(); + } else if ((event.metaKey || event.altKey) && event.key === "2") { + setForceSvgRender(false); + event.preventDefault(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + return forceSvgRender; +}; diff --git a/frontend/src/hooks/useSaveState.ts b/frontend/src/hooks/useSaveState.ts deleted file mode 100644 index d58dbc8..0000000 --- a/frontend/src/hooks/useSaveState.ts +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; -import { type AppState, clientStateKey } from "../appState"; - -export const useSaveState = (initialized: boolean, appState: AppState) => - React.useEffect(() => { - if (!initialized) { - return; - } - localStorage.setItem(clientStateKey, JSON.stringify(appState)); - if (appState.debug) { - window.APP_STATE = appState; - } - }, [initialized, appState]); diff --git a/frontend/src/hooks/useSyncState.ts b/frontend/src/hooks/useSyncState.ts index b7d1078..3f7665e 100644 --- a/frontend/src/hooks/useSyncState.ts +++ b/frontend/src/hooks/useSyncState.ts @@ -24,6 +24,10 @@ export const useSyncState = (setAppState: SetAppState) => { ...beDoc.heatmap, ...feDoc?.heatmap, }, + clustermap: { + ...beDoc.clustermap, + ...feDoc?.clustermap, + }, distribution: { ...beDoc.distribution, ...feDoc?.distribution, diff --git a/frontend/src/parseDocState.ts b/frontend/src/parseDocState.ts index 40e7088..2778bcd 100644 --- a/frontend/src/parseDocState.ts +++ b/frontend/src/parseDocState.ts @@ -31,6 +31,10 @@ export const parseDocState = (state: DocState) => { ...initialDocState.heatmap, ...validData.heatmap, }, + clustermap: { + ...initialDocState.clustermap, + ...validData.clustermap, + }, parsed: true, }; diff --git a/frontend/src/plotTypes.ts b/frontend/src/plotTypes.ts index 091af05..3383612 100644 --- a/frontend/src/plotTypes.ts +++ b/frontend/src/plotTypes.ts @@ -10,14 +10,11 @@ export interface HeatmapSettings { vmin: number; cellspace: number; annotation: boolean; - annotation_font_size: number; annotation_rounding: 0 | 1 | 2; - annotation_alpha: string; showscale: boolean; titleFont: "Sans Serif" | "Monospace"; showTitles: boolean; title: string; - subtitle: string; xtitle: string; ytitle: string; cbar_shrink: number; @@ -25,9 +22,8 @@ export interface HeatmapSettings { cbar_aspect: number; axis_labels: boolean; axlabel_xrotation: number; - axlabel_xfontsize: number; + axlabel_fontsize: number; axlabel_yrotation: number; - axlabel_yfontsize: number; cutoff_1: number; cutoff_2: number; } @@ -59,14 +55,11 @@ export const HeatmapSettingsSchema = z.object({ vmin: z.number(), cellspace: z.number(), annotation: z.boolean(), - annotation_font_size: z.number(), annotation_rounding: z.union([z.literal(0), z.literal(1), z.literal(2)]), - annotation_alpha: z.string(), showscale: z.boolean(), showTitles: z.boolean(), titleFont: z.enum(["Sans Serif", "Monospace"]), title: z.string(), - subtitle: z.string(), xtitle: z.string(), ytitle: z.string(), cbar_shrink: z.number(), @@ -74,23 +67,23 @@ export const HeatmapSettingsSchema = z.object({ cbar_aspect: z.number(), axis_labels: z.boolean(), axlabel_xrotation: z.number(), - axlabel_xfontsize: z.number(), + axlabel_fontsize: z.number(), axlabel_yrotation: z.number(), - axlabel_yfontsize: z.number(), cutoff_1: z.number(), cutoff_2: z.number(), }); -export type HeatmapData = GetDataResponse["data"]; +export type HeatmapData = Array>; export type MetaData = GetDataResponse["metadata"]; export type GetDataResponse = { - data: string[][]; + data: HeatmapData & string[][]; metadata: { minVal: number; maxVal: number; }; - identity_scores: [string, string, number][]; + ids: string[]; + identity_scores: [number, number, number][]; stat_ids: string[]; full_stats: [string, number, number][]; }; @@ -101,7 +94,40 @@ export type DistributionData = Omit< "data" | "identity_scores" | "metadata" | "stat_ids" > & { raw_mat: number[]; - identity_combos: [string, string][]; + ids: string[]; + identity_combos: [number, number][]; gc_stats: number[]; length_stats: number[]; }; + +export interface ClustermapSettings { + threshold_one: number; + threshold_two: number; + annotation: boolean; + titleFont: "Sans Serif" | "Monospace"; + showTitles: boolean; + title: string; + xtitle: string; + ytitle: string; + axis_labels: boolean; + axlabel_xrotation: number; + axlabel_fontsize: number; + axlabel_yrotation: number; + cellspace: number; +} + +export const ClustermapSettingsSchema = z.object({ + threshold_one: z.number(), + threshold_two: z.number(), + annotation: z.boolean(), + showTitles: z.boolean(), + titleFont: z.enum(["Sans Serif", "Monospace"]), + title: z.string(), + xtitle: z.string(), + ytitle: z.string(), + axis_labels: z.boolean(), + axlabel_xrotation: z.number(), + axlabel_fontsize: z.number(), + axlabel_yrotation: z.number(), + cellspace: z.number(), +}); diff --git a/frontend/src/restoreClientState.ts b/frontend/src/restoreClientState.ts deleted file mode 100644 index 3778fd2..0000000 --- a/frontend/src/restoreClientState.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - type AppState, - // clientStateKey, - // clientStateSchema, - initialAppState, -} from "./appState"; -// import { partialSafeParse } from "./zodUtils"; - -export const restoreClientState = (baseClientState: AppState) => { - try { - return baseClientState || initialAppState; - // const state = localStorage.getItem(clientStateKey); - // if (state) { - // const parsedState = JSON.parse(state); - // parsedState.error = null; - // parsedState.errorInfo = null; - // const parsedClient = partialSafeParse(clientStateSchema, parsedState); - // const validData = parsedClient.validData; - // const merged: AppState = { - // ...baseClientState, - // ...parsedClient.validData, - // distribution: { - // ...baseClientState.distribution, - // ...validData.distribution, - // histogram: { - // ...baseClientState.distribution.histogram, - // ...validData.distribution?.histogram, - // }, - // violin: { - // ...baseClientState.distribution.violin, - // ...validData.distribution?.violin, - // }, - // raincloud: { - // ...baseClientState.distribution.raincloud, - // ...validData.distribution?.raincloud, - // }, - // }, - // heatmap: { - // ...baseClientState.heatmap, - // ...validData.heatmap, - // }, - // }; - // return merged; - // } - } catch (e) { - return initialAppState; - } - // return initialAppState;s -}; diff --git a/frontend/src/styles/app.scss b/frontend/src/styles/app.scss index d67ebe2..73dd417 100644 --- a/frontend/src/styles/app.scss +++ b/frontend/src/styles/app.scss @@ -140,6 +140,7 @@ body { overflow: auto; border-radius: 0.6rem; box-shadow: 0 0 0 0.1rem var(--border-color); + z-index: 1; } .app-sidebar { @@ -232,6 +233,15 @@ body { margin: 0.2rem 0 0; border-radius: 0; } + + &.loader { + clip-path: inset(-1rem 0 0 0); + } + + &.viewer { + background: #fff; + position: relative; + } } .app-footer { @@ -264,6 +274,7 @@ body { .react-aria-Button.new-document { width: 3rem; + padding: 0; position: fixed; top: .8rem; left: 0; @@ -341,7 +352,6 @@ body { } > svg { - width: 1rem; position: absolute; left: 0.8rem; color: #777; @@ -1444,3 +1454,15 @@ body.blur { // --button-shadow-bottom-color: transparent; // } } + +.debug-toast { + background: #000; + color: #fff; + padding: 0.6rem 0.8rem; + font-size: 1rem; + font-weight: 700; + position: absolute; + top: 0.8rem; + left: 0.8rem; + border-radius: 0.4rem; +} diff --git a/frontend/src/styles/runner.scss b/frontend/src/styles/runner.scss index 25c36af..99a05db 100644 --- a/frontend/src/styles/runner.scss +++ b/frontend/src/styles/runner.scss @@ -215,7 +215,7 @@ border: 0.1rem solid #e5e5e5; font-size: 1.2rem; display: grid; - grid-template-columns: 1.4rem 1fr; + grid-template-columns: 1.6rem 1fr; gap: 0.8rem; * { diff --git a/frontend/src/types/index.d.ts b/frontend/src/types/index.d.ts index 4c12190..d8941d2 100644 --- a/frontend/src/types/index.d.ts +++ b/frontend/src/types/index.d.ts @@ -6,7 +6,7 @@ import type { } from "../appState"; import type { RunProcessDataArgs } from "../components/Runner"; import { DistributionState } from "../distributionState"; -import { HeatmapSettings } from "../plotTypes"; +import { type GetDataResponse, HeatmapSettings } from "../plotTypes"; import type { AppState } from "../src/appState"; declare global { @@ -39,6 +39,11 @@ declare global { defaultDirectory?: string, ) => Promise; select_path_dialog: (defaultDirectory?: string) => Promise; + generate_cluster_data: ( + doc_id: string, + threshold_one: number, + threshold_two: number, + ) => Promise<{ id: string; group: number }[]>; export_data: (args: { doc_id: string; export_path: string; @@ -46,6 +51,7 @@ declare global { cluster_threshold_one: number; cluster_threshold_two: number; heatmap_image_data: string; + clustermap_image_data: string; histogram_image_data: string; violin_image_data: string; image_format: SaveableImageFormat; diff --git a/frontend/tests/__snapshots__/heatmapUtils.test.ts.snap b/frontend/tests/__snapshots__/heatmapUtils.test.ts.snap new file mode 100644 index 0000000..f116a79 --- /dev/null +++ b/frontend/tests/__snapshots__/heatmapUtils.test.ts.snap @@ -0,0 +1,171 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`formatHeatmapData should return formatted data 1`] = ` +[ + { + "backgroundColor": "rgb(255, 255, 255)", + "displayValue": "100", + "foregroundColor": "#000", + "value": 100, + "x": 0, + "y": 0, + }, + { + "backgroundColor": "rgb(179, 179, 179)", + "displayValue": "72.60", + "foregroundColor": "#000", + "value": 72.6, + "x": 0, + "y": 1, + }, + { + "backgroundColor": "rgb(255, 255, 255)", + "displayValue": "100", + "foregroundColor": "#000", + "value": 100, + "x": 1, + "y": 1, + }, + { + "backgroundColor": "rgb(179, 179, 179)", + "displayValue": "72.62", + "foregroundColor": "#000", + "value": 72.62, + "x": 0, + "y": 2, + }, + { + "backgroundColor": "rgb(248, 248, 248)", + "displayValue": "97.61", + "foregroundColor": "#000", + "value": 97.61, + "x": 1, + "y": 2, + }, + { + "backgroundColor": "rgb(255, 255, 255)", + "displayValue": "100", + "foregroundColor": "#000", + "value": 100, + "x": 2, + "y": 2, + }, + { + "backgroundColor": "rgb(0, 0, 0)", + "displayValue": "8.20", + "foregroundColor": "#fff", + "value": 8.2, + "x": 0, + "y": 3, + }, + { + "backgroundColor": "rgb(163, 163, 163)", + "displayValue": "66.99", + "foregroundColor": "#000", + "value": 66.99, + "x": 1, + "y": 3, + }, + { + "backgroundColor": "rgb(163, 163, 163)", + "displayValue": "66.88", + "foregroundColor": "#000", + "value": 66.88, + "x": 2, + "y": 3, + }, + { + "backgroundColor": "rgb(255, 255, 255)", + "displayValue": "100", + "foregroundColor": "#000", + "value": 100, + "x": 3, + "y": 3, + }, +] +`; + +exports[`formatClustermapData should return formatted data 1`] = ` +[ + { + "backgroundColor": "hsl(137.5, 85%, 50%)", + "displayValue": "100", + "foregroundColor": "#000", + "value": 100, + "x": 0, + "y": 0, + }, + { + "backgroundColor": "rgb(245, 245, 245)", + "displayValue": "72.6", + "foregroundColor": "#000", + "value": 72.6, + "x": 0, + "y": 1, + }, + { + "backgroundColor": "hsl(275, 85%, 50%)", + "displayValue": "100", + "foregroundColor": "#fff", + "value": 100, + "x": 1, + "y": 1, + }, + { + "backgroundColor": "rgb(245, 245, 245)", + "displayValue": "72.62", + "foregroundColor": "#000", + "value": 72.62, + "x": 0, + "y": 2, + }, + { + "backgroundColor": "hsl(275, 85%, 50%)", + "displayValue": "97.61", + "foregroundColor": "#fff", + "value": 97.61, + "x": 1, + "y": 2, + }, + { + "backgroundColor": "hsl(275, 85%, 50%)", + "displayValue": "100", + "foregroundColor": "#fff", + "value": 100, + "x": 2, + "y": 2, + }, + { + "backgroundColor": "rgb(245, 245, 245)", + "displayValue": "8.2", + "foregroundColor": "#000", + "value": 8.2, + "x": 0, + "y": 3, + }, + { + "backgroundColor": "rgb(245, 245, 245)", + "displayValue": "66.99", + "foregroundColor": "#000", + "value": 66.99, + "x": 1, + "y": 3, + }, + { + "backgroundColor": "rgb(245, 245, 245)", + "displayValue": "66.88", + "foregroundColor": "#000", + "value": 66.88, + "x": 2, + "y": 3, + }, + { + "backgroundColor": "hsl(52.5, 85%, 50%)", + "displayValue": "100", + "foregroundColor": "#000", + "value": 100, + "x": 3, + "y": 3, + }, +] +`; diff --git a/frontend/tests/colors.test.ts b/frontend/tests/colors.test.ts deleted file mode 100644 index 7cb9b65..0000000 --- a/frontend/tests/colors.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { type ColorScaleArray, colorScales } from "../src/colorScales"; -import { - findScaleLower, - findScaleUpper, - interpolateColor, - originalRgbFormat, -} from "../src/colors"; - -const testScale: ColorScaleArray = [ - [0, "rgb(8,29,88)"], - [0.125, "rgb(37,52,148)"], - [0.25, "rgb(34,94,168)"], - [0.375, "rgb(29,145,192)"], - [0.5, "rgb(65,182,196)"], - [0.625, "rgb(127,205,187)"], - [0.75, "rgb(199,233,180)"], - [0.875, "rgb(237,248,217)"], - [1, "rgb(255,255,217)"], -]; - -test("findScaleLower works", () => { - expect(findScaleLower(testScale, 0.25)).toEqual([0.25, "rgb(34,94,168)"]); -}); -test("findScaleUpper works", () => { - expect(findScaleUpper(testScale, 0.9)).toEqual([1, "rgb(255,255,217)"]); -}); - -describe("interpolateColor", () => { - test("works with a small dataset", () => { - const interpolatedColor = interpolateColor( - [ - [0, "rgb(0, 0, 0)"], - [1, "rgb(255, 255, 255)"], - ], - 0.5, - originalRgbFormat, - ); - expect(interpolatedColor).toEqual({ - value: [0.5, "rgb(128, 128, 128)"], - lower: [0, "rgb(0, 0, 0)"], - upper: [1, "rgb(255, 255, 255)"], - }); - }); - - describe("with a real dataset", () => { - test("works with normal ratio", () => { - const interpolatedColor = interpolateColor( - colorScales.Yellow_Green_Blue, - 0.57, - originalRgbFormat, - ); - expect(interpolatedColor).toEqual({ - value: [0.5599999999999996, "rgb(100, 195, 191)"], - lower: [0.5, "rgb(65,182,196)"], - upper: [0.625, "rgb(127,205,187)"], - }); - }); - - test("works with zero ratio", () => { - const interpolatedColor = interpolateColor( - colorScales.Yellow_Green_Blue, - 0.5, - originalRgbFormat, - ); - expect(interpolatedColor).toEqual({ - value: [0, "rgb(65,182,196)"], - lower: [0.5, "rgb(65,182,196)"], - upper: [0.5, "rgb(65,182,196)"], - }); - }); - }); -}); diff --git a/frontend/tests/heatmapUtils.test.ts b/frontend/tests/heatmapUtils.test.ts new file mode 100644 index 0000000..4972554 --- /dev/null +++ b/frontend/tests/heatmapUtils.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "bun:test"; +import type { ColorScaleArray } from "../src/colorScales"; +import { + formatClustermapData, + formatHeatmapData, + getCellMetrics, +} from "../src/heatmapUtils"; +import type { ColorScaleKey, HeatmapSettings } from "../src/plotTypes"; + +describe("getCellMetrics", () => { + it("should return the correct metrics", () => { + expect(getCellMetrics(20, 1, 5)).toEqual({ + cellSize: 19.5, + cellSpace: 0.5, + cellOffset: 0.25, + fontSize: 5.2, + textOffset: 9.75, + }); + }); + it("should throw an error if characterCount is less than or equal to 0", () => { + expect(() => getCellMetrics(10, 2, 0)).toThrowError( + "characterCount must be > 0", + ); + expect(() => getCellMetrics(10, 2, -1)).toThrowError( + "characterCount must be > 0", + ); + }); +}); + +describe("formatHeatmapData", () => { + const data = [ + [100, null, null, null], + [72.6, 100, null, null], + [72.62, 97.61, 100, null], + [8.2, 66.99, 66.88, 100], + ]; + + const settings: Pick< + HeatmapSettings, + "colorScaleKey" | "vmax" | "vmin" | "annotation_rounding" + > = { + colorScaleKey: "Test" as ColorScaleKey, + vmax: 100, + vmin: 8.2, + annotation_rounding: 2, + }; + + const colorScale: ColorScaleArray = [ + [0, "rgb(0, 0, 0)"], + [1, "rgb(255, 255, 255)"], + ]; + + it("should return formatted data", () => { + expect(formatHeatmapData(data, settings, colorScale)).toMatchSnapshot(); + }); + + it("should round values", () => { + expect( + formatHeatmapData( + data, + { ...settings, annotation_rounding: 1 }, + colorScale, + ).map((i) => i.displayValue), + ).toEqual([ + "100", + "72.6", + "100", + "72.6", + "97.6", + "100", + "8.2", + "67.0", + "66.9", + "100", + ]); + + expect( + formatHeatmapData( + data, + { ...settings, annotation_rounding: 0 }, + colorScale, + ).map((i) => i.displayValue), + ).toEqual(["100", "73", "100", "73", "98", "100", "8", "67", "67", "100"]); + }); +}); + +describe("formatClustermapData", () => { + it("should return formatted data", () => { + const data = [ + [100, null, null, null], + [72.6, 100, null, null], + [72.62, 97.61, 100, null], + [8.2, 66.99, 66.88, 100], + ]; + + const tickText = ["A", "B", "C", "D"]; + + const clusterData = [ + { id: "A", group: 1 }, + { id: "B", group: 2 }, + { id: "C", group: 2 }, + { id: "D", group: 3 }, + ]; + + expect(formatClustermapData(data, tickText, clusterData)).toMatchSnapshot(); + }); +}); diff --git a/frontend/tests/restoreClientState.test.ts b/frontend/tests/restoreClientState.test.ts deleted file mode 100644 index b96da28..0000000 --- a/frontend/tests/restoreClientState.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// import { beforeEach, expect, test } from "bun:test"; -// import { -// type AppState, -// clientStateKey, -// initialAppState, -// } from "../src/appState"; -// import { restoreClientState } from "../src/restoreClientState"; - -// // Adapted from https://stackoverflow.com/a/26177872 -// const storageMock = () => { -// let storage: { [key: string]: string } = {}; - -// return { -// setItem: (key: string, value: string) => { -// storage[key] = value || ""; -// }, -// getItem: (key: string) => (key in storage ? storage[key] || null : null), -// removeItem: (key: string) => { -// delete storage[key]; -// }, -// get length() { -// return Object.keys(storage).length; -// }, -// key: (i: number) => { -// const keys = Object.keys(storage); -// return keys[i] || null; -// }, -// clear: () => { -// storage = {}; -// }, -// }; -// }; - -// beforeEach(() => { -// if (storageMock().getItem(clientStateKey)) { -// throw new Error("should not be here"); -// } -// global.localStorage = storageMock(); -// }); - -// test("it returns default state when localStorage key is missing", () => { -// const restored = restoreClientState(initialAppState.client); -// expect(restored).toEqual(initialAppState.client); -// }); - -// test("it returns default state if invalid state in localStorage", () => { -// localStorage.setItem(clientStateKey, "{ invalid: true"); -// const restored = restoreClientState(initialAppState.client); -// expect(restored).toEqual(initialAppState.client); -// }); - -// test("doesn't import invalid state", () => { -// const updatedComputeCores = initialAppState.client.compute_cores + 1; -// const state: AppState["client"] & { test: true } = { -// ...initialAppState.client, -// // biome-ignore lint/suspicious/noExplicitAny: test bad values -// dataView: "badvalue" as any, -// compute_cores: updatedComputeCores, -// test: true, -// }; -// localStorage.setItem(clientStateKey, JSON.stringify(state)); -// const restored = restoreClientState(initialAppState.client); -// expect(restored.compute_cores).toEqual(updatedComputeCores); -// // biome-ignore lint/suspicious/noExplicitAny: test bad values -// expect((restored as any)?.test).toBeUndefined(); -// }); - -// test("restores valid state", () => { -// const state = { -// ...initialAppState.client, -// client: { ...initialAppState.client, dataView: "" }, -// }; -// localStorage.setItem(clientStateKey, JSON.stringify(state)); -// const restored = restoreClientState(initialAppState.client); -// expect(restored).toEqual({ -// ...initialAppState.client, -// error: null, -// errorInfo: null, -// }); -// }); diff --git a/package.json b/package.json index 3e3f9f5..58bc4f0 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react": "^18.2.0", "react-aria-components": "^1.5.0", "react-dom": "^18.2.0", + "react-icons": "^5.5.0", "react-plotly.js": "^2.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8"