From 181a73bca7e3588f489d44ce804cfc1fdd870273 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Wed, 24 Jun 2026 11:29:19 -0700 Subject: [PATCH 01/26] Initial commit for interactivity of plots --- bin/ntsynt_viz.py | 2 +- ...synt_viz_plot_synteny_blocks_ribbon_plot.R | 190 +++++++++++- bin/ntsynt_viz_ribbon-interactive.js | 274 ++++++++++++++++++ 3 files changed, 451 insertions(+), 15 deletions(-) create mode 100644 bin/ntsynt_viz_ribbon-interactive.js diff --git a/bin/ntsynt_viz.py b/bin/ntsynt_viz.py index a61dc0c..86e7e31 100755 --- a/bin/ntsynt_viz.py +++ b/bin/ntsynt_viz.py @@ -67,7 +67,7 @@ def main(): action="store_true") block_filter_group.add_argument("--indel", help="Indel size threshold [50000]", default=50000, type=int) block_filter_group.add_argument("--length", help="Minimum synteny block length [100000]", default=100000, type=int) - block_filter_group.add_argument("--seq_length", help="Minimum sequence length [500000]", default=500000, type=float) + block_filter_group.add_argument("--seq_length", help="Minimum sequence length [500000]", default=500000, type=int) block_filter_group.add_argument("--keep", help="List of genome_name:chromosome to show in visualization. " "All chromosomes with links to the specified chromosomes will also be shown.", nargs="+", required=False, type=str) diff --git a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R index 63210dd..9be22a4 100755 --- a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R +++ b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R @@ -11,7 +11,8 @@ suppressPackageStartupMessages({ library(stringr) library(gggenomes) library(tidyr) - library(svglite)}) + library(svglite) + library(ggiraph)}) # Example script for generating ntSynt synteny ribbon plots using gggenomes @@ -42,7 +43,7 @@ parser$add_argument("--right-ratio", help = paste("Ratio adjustment for space on the right side of the ribbon plot.", "Increase if the labels on the right are cut-off,", "decrease to decrease space between ribbon plot and right edge of the plot"), - default = 0.03, required = FALSE, type = "double") + default = 0.07, required = FALSE, type = "double") parser$add_argument("-p", "--prefix", help = "Output prefix for PNG image (default synteny_gggenomes_plot)", required = FALSE, default = "synteny_gggenomes_plot") @@ -149,9 +150,9 @@ get_bin_annotations <- function(plot){ n_chr = n(), genome_size = sum(length), y = max(y), # matches gggenomes' y layout - x_right = max(xend) # rightmost coordinate + x_right = max(xend) # rightmost coordinate ) %>% - mutate(label = paste0(" ", format_genome_size(genome_size), "; n=", n_chr)) + mutate(label = paste0("\u00A0\u00A0\u00A0", format_genome_size(genome_size), "; n=", n_chr)) bin_stats <- bin_stats %>% mutate(x_right = max(bin_stats$x_right)) return(bin_stats) @@ -170,6 +171,10 @@ make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FA p <- gggenomes(seqs = sequences, links = links, feats = list(painting)) } + # Extract layout-computed coordinates BEFORE adding layers + seq_data <- pull_seqs(p) + link_data <- pull_links(p) + plot <- p + theme_gggenomes_clean(base_size = 15) + geom_link(aes(fill = colour_block, y = get_y_coord(haplotypes, .data$bin_id, .data$y), @@ -226,8 +231,121 @@ make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FA expand_limits(x = max(bin_stats$x_right) + (xmax * (args$right_ratio))) } - return(plot) + # Ribbons + # Build per-block coordinate summaries (all genomes in each block) + + # Combine all unique genome/chrom combinations across your dataset + all_elements <- bind_rows( + pull_links(p) %>% select(genome = bin_id, chrom = seq_id, start, end), + pull_links(p) %>% select(genome = bin_id2, chrom = seq_id2, start = start2, end = end2) + ) %>% distinct() + + # Find the maximum string length of the genome IDs to know how much to pad + max_genome_len <- max(nchar(all_elements$genome), na.rm = TRUE) + max_chrom_len <- max(nchar(all_elements$chrom), na.rm = TRUE) + + block_coords <- pull_links(p) %>% + group_by(block_id) %>% + summarise( + coords = { + # Each row contributes two genomes; collect all unique combinations + g1 <- tibble(genome = bin_id, chrom = seq_id, start = start, end = end) + g2 <- tibble(genome = bin_id2, chrom = seq_id2, start = start2, end = end2) + bind_rows(g1, g2) %>% + distinct() %>% + mutate(line = paste0(stringr::str_pad(genome, width = max_genome_len, side="right", pad='\u00A0'), + " ", + stringr::str_pad(chrom, width = max_chrom_len, side="right", pad='\u00A0'), + ": ", + format(start, big.mark=","), " – ", + format(end, big.mark=","), " bp")) %>% + pull(line) %>% + paste(collapse = "\n") + }, + .groups = "drop" + ) + + link_data <- pull_links(p) %>% + left_join(block_coords, by = "block_id") %>% + mutate( + tooltip = paste0("Block ID: ", block_id, "\n", coords), + group_id = row_number() + ) %>% + rowwise() %>% + mutate(poly = list(tibble( + px = c(x, xend, xmax, xmin), + py = c(y, y, yend, yend), + tooltip = tooltip, + data_id = block_id + ))) %>% + ungroup() %>% + select(group_id, poly) %>% + unnest(poly) + +plot <- plot + + geom_polygon_interactive( + data = link_data, + aes( + x = px, + y = py, + group = group_id, + tooltip = tooltip, + data_id = data_id + ), + alpha = 0, + hover_nearest = FALSE # Changed to FALSE so it respects exact geometry layout boundaries + ) + + # Chromosomes + seq_data <- pull_seqs(p) %>% + mutate( + length_fmt = format(length, big.mark = ",", scientific = FALSE), + tooltip = paste0( + "Genome: ", bin_id, "\n", + "Chromosome: ", seq_id, "\n", + "Length: ", length_fmt, " bp" + ) + ) + plot <- plot + + geom_segment_interactive( + data = seq_data, + aes( + x = x, + xend = xend, + y = y, + yend = y, + tooltip = tooltip, + data_id = seq_id + ), + linewidth = 10, # wide invisible hit area + alpha = 0, + hover_nearest = FALSE # Changed to FALSE so it respects exact geometry layout boundaries + ) + cat(colnames(pull_links(p))) + print(head(pull_links(p))) + +# Build chromosome -> block_id mapping (target genome seq_id only, as these appear in legend) + chrom_block_map <- pull_links(p) %>% + select(block_id, seq_id) %>% + distinct() %>% + group_by(seq_id) %>% + summarise(block_ids = list(unique(block_id)), .groups = "drop") %>% + rename(chrom = seq_id) + + js_map_entries <- chrom_block_map %>% + rowwise() %>% + mutate(entry = paste0( + '"', chrom, '": [', + paste0('"', unlist(block_ids), '"', collapse = ","), + ']' + )) %>% + pull(entry) %>% + paste(collapse = ",\n") + + js_map <- paste0("const chromBlockMap = {\n", js_map_entries, "\n};") + + return(list(plot = plot, js_map = js_map)) } # Read in haplotypes, or set to FALSE @@ -244,8 +362,10 @@ if (! is.null(args$centromeres)) { } # Make the ribbon plot -synteny_plot <- make_plot(links_ntsynt, sequences, painting, colours_df, add_scale_bar = TRUE, centromeres = centromeres, - add_arrow = !args$no_arrow, haplotypes = haplotypes) +synteny_plot_tmp <- make_plot(links_ntsynt, sequences, painting, colours_df, add_scale_bar = TRUE, centromeres = centromeres, + add_arrow = !args$no_arrow, haplotypes = haplotypes) +synteny_plot <- synteny_plot_tmp$plot +js_map <- synteny_plot_tmp$js_map if (is.null(args$tree)) { @@ -301,19 +421,61 @@ if (any_rc && !args$no_arrow) { plots <- ggarrange(plots, note, ncol = 1, heights = c(10, 1)) } -# Save the ribbon plot +# Save static plot in requested format if (args$format == "pdf") { - ggsave(paste(args$prefix, ".pdf", sep = ""), plots, + ggsave(paste0(args$prefix, ".pdf"), plots, units = "cm", width = args$width, height = args$height, bg = "white") - cat(paste("Plot saved:", paste(args$prefix, ".pdf", sep = ""), "\n", sep = " ")) + cat(paste("Plot saved:", paste0(args$prefix, ".pdf"), "\n")) } else if (args$format == "svg") { - ggsave(paste(args$prefix, ".svg", sep = ""), plots, + ggsave(paste0(args$prefix, ".svg"), plots, units = "cm", width = args$width, height = args$height, bg = "white") - cat(paste("Plot saved:", paste(args$prefix, ".svg", sep = ""), "\n", sep = " ")) + cat(paste("Plot saved:", paste0(args$prefix, ".svg"), "\n")) } else { - png(paste(args$prefix, ".png", sep = ""), units = "cm", width = args$width, height = args$height, + png(paste0(args$prefix, ".png"), units = "cm", width = args$width, height = args$height, res = args$dpi, bg = "white") print(plots) dev.off() - cat(paste("Plot saved:", paste(args$prefix, ".png", sep = ""), "\n", sep = " ")) + cat(paste("Plot saved:", paste0(args$prefix, ".png"), "\n")) } + +js_template <- paste( + readLines("/projects/btl/lcoombe/git/ntSynt-viz/bin/ntsynt_viz_ribbon-interactive.js", warn = FALSE), + collapse = "\n" +) + +js_inject <- gsub( + "__CHROM_BLOCK_MAP__", + js_map, + js_template, + fixed = TRUE +) + +interactive_plot <- girafe( + ggobj = plots, + width_svg = args$width / 2.54, + height_svg = args$height / 2.54, + options = list( + opts_hover(css = "stroke:darkgrey; stroke-width:1; fill-opacity:0.1; transition: all 0.1s ease;"), + opts_hover_inv(css = "opacity:0.2;"), + opts_zoom(max = 10), + opts_toolbar(pngname = args$prefix), + opts_tooltip( + css = paste( + "background: rgba(255,255,255,0.9);", + "padding: 10px;", + "border: 1px solid black;", + "border-radius: 4px;", + "font-family: monospace;", + "font-size: 10px;" + ) + ) + ) +) + +html_file <- paste0(args$prefix, ".html") +htmlwidgets::saveWidget(interactive_plot, html_file, selfcontained = TRUE, title = args$prefix) +html_content <- readLines(html_file, warn = FALSE) +body_close <- which(grepl("", html_content)) +html_content <- append(html_content, js_inject, after = body_close - 1) +writeLines(html_content, html_file) +cat(paste("Interactive HTML saved:", html_file, "\n")) \ No newline at end of file diff --git a/bin/ntsynt_viz_ribbon-interactive.js b/bin/ntsynt_viz_ribbon-interactive.js new file mode 100644 index 0000000..77b3f46 --- /dev/null +++ b/bin/ntsynt_viz_ribbon-interactive.js @@ -0,0 +1,274 @@ + \ No newline at end of file From eb58f3967e8d780cab21d0e603ff268d80755554 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Wed, 24 Jun 2026 15:49:28 -0700 Subject: [PATCH 02/26] Refactoring --- README.md | 1 + azure-pipelines.yml | 7 +- bin/ntsynt_viz.smk | 12 +- ...synt_viz_plot_synteny_blocks_ribbon_plot.R | 256 ++++++++++-------- bin/ntsynt_viz_ribbon-interactive.js | 11 +- 5 files changed, 157 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index d3a5383..61ad749 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ These features ensure that the output ribbon plots (powered by [gggenomes](https * [stringr](https://stringr.tidyverse.org/) * [ggplot2](https://ggplot2.tidyverse.org) * [svglite](https://cran.r-project.org/web/packages/svglite/index.html) + * [ggiraph](https://davidgohel.github.io/ggiraph/) ### Installing ntSynt-viz using conda ``` diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 26d4796..045d3f8 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -28,7 +28,7 @@ jobs: - script: | source activate ntsynt_viz_ci - mamba install --yes -c conda-forge -c bioconda quicktree r-base bioconductor-treeio r-ggpubr bioconductor-ggtree r-phytools r-dplyr r-argparse r-scales r-stringr pylint intervaltree snakemake r-ggplot2 r-gggenomes r-svglite + mamba install --yes -c conda-forge -c bioconda quicktree r-base bioconductor-treeio r-ggpubr bioconductor-ggtree r-phytools r-dplyr r-argparse r-scales r-stringr pylint intervaltree snakemake r-ggplot2 r-gggenomes r-svglite r-ggiraph displayName: Install dependencies - script: | @@ -85,6 +85,9 @@ jobs: - publish: tests/great-apes_ribbon-plots_tree_order_ribbon-plot.png artifact: Example4 + - publish: tests/great-apes_ribbon-plots_tree_order_ribbon-plot.html + artifact: Example4_HTML + - job: displayName: macOS-latest @@ -113,7 +116,7 @@ jobs: - script: | source activate ntsynt_viz_ci - mamba install --yes -c conda-forge -c bioconda quicktree r-base bioconductor-treeio r-ggpubr bioconductor-ggtree r-phytools r-dplyr r-argparse r-scales r-stringr pylint intervaltree snakemake r-ggplot2 r-gggenomes r-svglite + mamba install --yes -c conda-forge -c bioconda quicktree r-base bioconductor-treeio r-ggpubr bioconductor-ggtree r-phytools r-dplyr r-argparse r-scales r-stringr pylint intervaltree snakemake r-ggplot2 r-gggenomes r-svglite r-ggiraph displayName: Install dependencies - script: | diff --git a/bin/ntsynt_viz.smk b/bin/ntsynt_viz.smk index cb4a132..62857af 100644 --- a/bin/ntsynt_viz.smk +++ b/bin/ntsynt_viz.smk @@ -91,7 +91,8 @@ output_files = { } rule all: - input: output_files[format_img] + input: output_files[format_img], + f"{prefix}_ribbon-plot.html" ouput_files_tree = { "png": f"{prefix}_ribbon-plot_tree.png", @@ -100,7 +101,8 @@ ouput_files_tree = { } rule gggenomes_ribbon_plot_tree: - input: ouput_files_tree[format_img] + input: ouput_files_tree[format_img], + f"{prefix}_ribbon-plot_tree.html" rule renaming: input: @@ -260,7 +262,8 @@ rule ribbon_plot: haplotypes = rules.nudges.output.nudges if haplotypes else [], colour_seqs = rules.chrom_sorting.output.colour_info output: - out_img = output_files[format_img] + out_img = output_files[format_img], + out_html = f"{prefix}_ribbon-plot.html" params: prefix = f"{prefix}_ribbon-plot", ratio = ribbon_ratio, @@ -287,7 +290,8 @@ rule ribbon_plot_tree: haplotypes = rules.nudges.output.nudges if haplotypes else [], colour_seqs = rules.chrom_sorting.output.colour_info output: - out_img = ouput_files_tree[format_img] + out_img = ouput_files_tree[format_img], + out_html = f"{prefix}_ribbon-plot_tree.html" params: prefix = f"{prefix}_ribbon-plot_tree", ratio = ribbon_ratio, diff --git a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R index 9be22a4..6aa4a79 100755 --- a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R +++ b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R @@ -56,6 +56,10 @@ parser$add_argument("--annotate-genome-info", help = "Add annotations about numb args <- parser$parse_args() +############################# +# Prepare data +############################# + # Read in and prepare sequences sequences <- read.csv(args$sequences, sep = "\t", header = TRUE) %>% mutate(relative_orientation = if_else(relative_orientation == "+", "", "\u2190")) @@ -110,6 +114,23 @@ painting <- read.csv(args$painting, sep = "\t", header = TRUE) %>% # Read in the data frame with info about colours to choose for sequences colours_df <- read.csv(args$colour_indices, sep = "\t", header = TRUE) +# Read in haplotypes, or set to FALSE +if (! is.null(args$haplotypes)) { + haplotypes <- read.csv(args$haplotypes, sep = "\t", header = TRUE) %>% mutate(bin_id = str_replace_all(bin_id, "_", " ")) +} else { + haplotypes <- FALSE +} + +if (! is.null(args$centromeres)) { + centromeres <- read.csv(args$centromeres, sep = "\t", header = TRUE) +} else { + centromeres <- FALSE +} + +############################# +# Helper functions +############################# + # Get the y coordinates for the features get_y_coord <- function(haplotypes, bin_id, y, end=FALSE) { if (typeof(haplotypes) == "logical") { @@ -144,13 +165,13 @@ format_genome_size <- function(bp) { # Return dataframe with bin annotations get_bin_annotations <- function(plot){ bin_stats <- plot %>% - pull_seqs() %>% # or seqs(gggenomes_obj) + pull_seqs() %>% group_by(bin_id) %>% summarise( n_chr = n(), genome_size = sum(length), - y = max(y), # matches gggenomes' y layout - x_right = max(xend) # rightmost coordinate + y = max(y), + x_right = max(xend) ) %>% mutate(label = paste0("\u00A0\u00A0\u00A0", format_genome_size(genome_size), "; n=", n_chr)) @@ -158,7 +179,76 @@ get_bin_annotations <- function(plot){ return(bin_stats) } -# Make the ribbon plot - these layers can be fully customized as needed! +# Prepare information about block coordinates for interactive layers +get_block_coord_info <- function(p, max_genome_len, max_chrom_len) { + block_coords <- pull_links(p) %>% + group_by(block_id) %>% + summarise( + coords = { + # Each row contributes two genomes; collect all unique combinations + g1 <- tibble(genome = bin_id, chrom = seq_id, start = start, end = end) + g2 <- tibble(genome = bin_id2, chrom = seq_id2, start = start2, end = end2) + bind_rows(g1, g2) %>% + distinct() %>% + mutate(line = paste0(stringr::str_pad(genome, width = max_genome_len, side="right", pad='\u00A0'), + " ", + stringr::str_pad(chrom, width = max_chrom_len, side="right", pad='\u00A0'), + ": ", + format(start, big.mark=","), " – ", + format(end, big.mark=","), " bp")) %>% + pull(line) %>% + paste(collapse = "\n") + }, + .groups = "drop" + ) + return(block_coords) +} + +# Get the information about synteny links for interactive layers +get_link_info <- function(p, block_coords) { + link_data <- pull_links(p) %>% + left_join(block_coords, by = "block_id") %>% + mutate( + tooltip = paste0("Block ID: ", block_id, "\n", coords), + group_id = row_number() + ) %>% + rowwise() %>% + mutate(poly = list(tibble( + px = c(x, xend, xmax, xmin), + py = c(y, y, yend, yend), + tooltip = tooltip, + data_id = block_id + ))) %>% + ungroup() %>% + select(group_id, poly) %>% + unnest(poly) + return(link_data) +} + +# Build chromosome -> block_id mapping (target genome seq_id only, as these appear in legend) +build_js_map <- function(p) { + chrom_block_map <- pull_links(p) %>% + select(block_id, seq_id) %>% + distinct() %>% + group_by(seq_id) %>% + summarise(block_ids = list(unique(block_id)), .groups = "drop") %>% + rename(chrom = seq_id) + + js_map_entries <- chrom_block_map %>% + rowwise() %>% + mutate(entry = paste0( + '"', chrom, '": [', + paste0('"', unlist(block_ids), '"', collapse = ","), + ']' + )) %>% + pull(entry) %>% + paste(collapse = ",\n") + + js_map <- paste0("const chromBlockMap = {\n", js_map_entries, "\n};") + return(js_map) +} + +# Make the ribbon plot - these layers can be fully customized as needed make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FALSE, centromeres = FALSE, add_arrow = FALSE, haplotypes = FALSE) { target_genome <- (sequences %>% head(1) %>% select(bin_id))[[1]] sequences_filt <- unique((sequences %>% filter(bin_id == target_genome))$seq_id) @@ -171,10 +261,6 @@ make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FA p <- gggenomes(seqs = sequences, links = links, feats = list(painting)) } - # Extract layout-computed coordinates BEFORE adding layers - seq_data <- pull_seqs(p) - link_data <- pull_links(p) - plot <- p + theme_gggenomes_clean(base_size = 15) + geom_link(aes(fill = colour_block, y = get_y_coord(haplotypes, .data$bin_id, .data$y), @@ -189,7 +275,6 @@ make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FA geom_bin_label(aes(label = bin_id, y = get_y_coord(haplotypes, bin_id, .data$y)), size = 6, fontface = "italic") + # label each bin - #geom_seq_label(aes(label = seq_id), vjust = 1.1, size = 4) + # Can add seq labels if desired theme(axis.text = element_text(size = 25, face = "italic"), legend.position = "bottom", legend.text = element_text(size = 15, margin = margin(r=10, l=3))) + @@ -231,72 +316,36 @@ make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FA expand_limits(x = max(bin_stats$x_right) + (xmax * (args$right_ratio))) } - # Ribbons - # Build per-block coordinate summaries (all genomes in each block) - - # Combine all unique genome/chrom combinations across your dataset + # Prepare interactive components + # Combine all unique genome/chrom combinations across the dataset all_elements <- bind_rows( pull_links(p) %>% select(genome = bin_id, chrom = seq_id, start, end), pull_links(p) %>% select(genome = bin_id2, chrom = seq_id2, start = start2, end = end2) ) %>% distinct() - # Find the maximum string length of the genome IDs to know how much to pad + # Find the maximum string length of the genome IDs to know how much to pad for hover boxes max_genome_len <- max(nchar(all_elements$genome), na.rm = TRUE) max_chrom_len <- max(nchar(all_elements$chrom), na.rm = TRUE) - block_coords <- pull_links(p) %>% - group_by(block_id) %>% - summarise( - coords = { - # Each row contributes two genomes; collect all unique combinations - g1 <- tibble(genome = bin_id, chrom = seq_id, start = start, end = end) - g2 <- tibble(genome = bin_id2, chrom = seq_id2, start = start2, end = end2) - bind_rows(g1, g2) %>% - distinct() %>% - mutate(line = paste0(stringr::str_pad(genome, width = max_genome_len, side="right", pad='\u00A0'), - " ", - stringr::str_pad(chrom, width = max_chrom_len, side="right", pad='\u00A0'), - ": ", - format(start, big.mark=","), " – ", - format(end, big.mark=","), " bp")) %>% - pull(line) %>% - paste(collapse = "\n") - }, - .groups = "drop" - ) - - link_data <- pull_links(p) %>% - left_join(block_coords, by = "block_id") %>% - mutate( - tooltip = paste0("Block ID: ", block_id, "\n", coords), - group_id = row_number() - ) %>% - rowwise() %>% - mutate(poly = list(tibble( - px = c(x, xend, xmax, xmin), - py = c(y, y, yend, yend), - tooltip = tooltip, - data_id = block_id - ))) %>% - ungroup() %>% - select(group_id, poly) %>% - unnest(poly) + block_coords <- get_block_coord_info(p, max_genome_len, max_chrom_len) + link_data <- get_link_info(p, block_coords) -plot <- plot + - geom_polygon_interactive( - data = link_data, - aes( - x = px, - y = py, - group = group_id, - tooltip = tooltip, - data_id = data_id - ), - alpha = 0, - hover_nearest = FALSE # Changed to FALSE so it respects exact geometry layout boundaries - ) + # Add ribbon hover interactivity + plot <- plot + + geom_polygon_interactive( + data = link_data, + aes( + x = px, + y = py, + group = group_id, + tooltip = tooltip, + data_id = data_id + ), + alpha = 0, + hover_nearest = FALSE + ) - # Chromosomes + # Prepare information about chromosomes for interactive layers seq_data <- pull_seqs(p) %>% mutate( length_fmt = format(length, big.mark = ",", scientific = FALSE), @@ -306,60 +355,32 @@ plot <- plot + "Length: ", length_fmt, " bp" ) ) + # Add interactive layer for chromosomes plot <- plot + - geom_segment_interactive( - data = seq_data, - aes( - x = x, - xend = xend, - y = y, - yend = y, - tooltip = tooltip, - data_id = seq_id - ), - linewidth = 10, # wide invisible hit area - alpha = 0, - hover_nearest = FALSE # Changed to FALSE so it respects exact geometry layout boundaries - ) - - cat(colnames(pull_links(p))) - print(head(pull_links(p))) - -# Build chromosome -> block_id mapping (target genome seq_id only, as these appear in legend) - chrom_block_map <- pull_links(p) %>% - select(block_id, seq_id) %>% - distinct() %>% - group_by(seq_id) %>% - summarise(block_ids = list(unique(block_id)), .groups = "drop") %>% - rename(chrom = seq_id) - - js_map_entries <- chrom_block_map %>% - rowwise() %>% - mutate(entry = paste0( - '"', chrom, '": [', - paste0('"', unlist(block_ids), '"', collapse = ","), - ']' - )) %>% - pull(entry) %>% - paste(collapse = ",\n") + geom_segment_interactive( + data = seq_data, + aes( + x = x, + xend = xend, + y = y, + yend = y, + tooltip = tooltip, + data_id = seq_id + ), + linewidth = 10, # Increased area to hit for hover box + alpha = 0, + hover_nearest = FALSE + ) - js_map <- paste0("const chromBlockMap = {\n", js_map_entries, "\n};") + # Build map of chromosome -> block_id for interactive legend + js_map <- build_js_map(p) return(list(plot = plot, js_map = js_map)) } -# Read in haplotypes, or set to FALSE -if (! is.null(args$haplotypes)) { - haplotypes <- read.csv(args$haplotypes, sep = "\t", header = TRUE) %>% mutate(bin_id = str_replace_all(bin_id, "_", " ")) -} else { - haplotypes <- FALSE -} - -if (! is.null(args$centromeres)) { - centromeres <- read.csv(args$centromeres, sep = "\t", header = TRUE) -} else { - centromeres <- FALSE -} +############################# +# Prepare plots +############################# # Make the ribbon plot synteny_plot_tmp <- make_plot(links_ntsynt, sequences, painting, colours_df, add_scale_bar = TRUE, centromeres = centromeres, @@ -438,8 +459,13 @@ if (args$format == "pdf") { cat(paste("Plot saved:", paste0(args$prefix, ".png"), "\n")) } + +# Prepare interactive HTML +script_dir <- dirname(normalizePath(commandArgs(trailingOnly = FALSE)[ + grep("--file=", commandArgs(trailingOnly = FALSE)) +] |> sub("--file=", "", x = _))) js_template <- paste( - readLines("/projects/btl/lcoombe/git/ntSynt-viz/bin/ntsynt_viz_ribbon-interactive.js", warn = FALSE), + readLines(paste(script_dir, "/ntsynt_viz_ribbon-interactive.js", sep=""), warn = FALSE), collapse = "\n" ) @@ -452,7 +478,7 @@ js_inject <- gsub( interactive_plot <- girafe( ggobj = plots, - width_svg = args$width / 2.54, + width_svg = args$width / 2.54, # Converting to inches height_svg = args$height / 2.54, options = list( opts_hover(css = "stroke:darkgrey; stroke-width:1; fill-opacity:0.1; transition: all 0.1s ease;"), diff --git a/bin/ntsynt_viz_ribbon-interactive.js b/bin/ntsynt_viz_ribbon-interactive.js index 77b3f46..2fa17c7 100644 --- a/bin/ntsynt_viz_ribbon-interactive.js +++ b/bin/ntsynt_viz_ribbon-interactive.js @@ -12,7 +12,7 @@ document.addEventListener("DOMContentLoaded", function() { const bgRect = svg.querySelector("rect.ggiraph-svg-bg"); if (bgRect) bgRect.style.pointerEvents = "none"; - // --- FIX: disable pointer events on all ribbon polygons --- + // --- disable pointer events on all ribbon polygons --- // Chromosomes (segments) will be the sole hit targets for ggiraph. // We handle ribbon tooltips manually via JS geometry check below. svg.querySelectorAll("polygon[data-id]").forEach(function(poly) { @@ -115,13 +115,6 @@ container.addEventListener("mousemove", function(e) { p.style.fill = "darkgrey"; p.style.fillOpacity = "0.3"; } - // } else { - // p.style.stroke = ""; - // p.style.strokeWidth = ""; - // p.style.opacity = "0.2"; - // p.style.fill = ""; - // p.style.fillOpacity = ""; - // } }); applyLegendState(hoveredId); @@ -158,7 +151,7 @@ container.addEventListener("mousemove", function(e) { applyLegendState(); }, true); - // ---- Legend click / chromosome filtering (unchanged from original) ---- + // ---- Legend click / chromosome filtering ---- const legendChromMap = {}; svg.querySelectorAll("text").forEach(function(t) { const chrom = t.textContent.trim(); From 556ec2a9803b724fcd2788b9d649d7e76bbb8c0e Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 30 Jun 2026 15:08:00 -0700 Subject: [PATCH 03/26] New feature: --optimize-ordering - Based on total inversion length, optimizes topology of phylogenetic tree, which decides the genome order --- README.md | 1 + azure-pipelines.yml | 4 +- bin/ntsynt_viz.py | 5 + bin/ntsynt_viz.smk | 18 +- bin/ntsynt_viz_optimize_tree_topology.py | 371 +++++++++++++++++++++++ 5 files changed, 394 insertions(+), 5 deletions(-) create mode 100755 bin/ntsynt_viz_optimize_tree_topology.py diff --git a/README.md b/README.md index 61ad749..0661cc7 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ These features ensure that the output ribbon plots (powered by [gggenomes](https * [ggplot2](https://ggplot2.tidyverse.org) * [svglite](https://cran.r-project.org/web/packages/svglite/index.html) * [ggiraph](https://davidgohel.github.io/ggiraph/) + * [biopython](https://biopython.org/) ### Installing ntSynt-viz using conda ``` diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 045d3f8..9195b7f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -28,7 +28,7 @@ jobs: - script: | source activate ntsynt_viz_ci - mamba install --yes -c conda-forge -c bioconda quicktree r-base bioconductor-treeio r-ggpubr bioconductor-ggtree r-phytools r-dplyr r-argparse r-scales r-stringr pylint intervaltree snakemake r-ggplot2 r-gggenomes r-svglite r-ggiraph + mamba install --yes -c conda-forge -c bioconda quicktree r-base bioconductor-treeio r-ggpubr bioconductor-ggtree r-phytools r-dplyr r-argparse r-scales r-stringr pylint intervaltree snakemake r-ggplot2 r-gggenomes r-svglite r-ggiraph biopython displayName: Install dependencies - script: | @@ -116,7 +116,7 @@ jobs: - script: | source activate ntsynt_viz_ci - mamba install --yes -c conda-forge -c bioconda quicktree r-base bioconductor-treeio r-ggpubr bioconductor-ggtree r-phytools r-dplyr r-argparse r-scales r-stringr pylint intervaltree snakemake r-ggplot2 r-gggenomes r-svglite r-ggiraph + mamba install --yes -c conda-forge -c bioconda quicktree r-base bioconductor-treeio r-ggpubr bioconductor-ggtree r-phytools r-dplyr r-argparse r-scales r-stringr pylint intervaltree snakemake r-ggplot2 r-gggenomes r-svglite r-ggiraph biopython displayName: Install dependencies - script: | diff --git a/bin/ntsynt_viz.py b/bin/ntsynt_viz.py index 86e7e31..5e4e268 100755 --- a/bin/ntsynt_viz.py +++ b/bin/ntsynt_viz.py @@ -100,6 +100,9 @@ def main(): help="Add annotations about number of sequences " "and genome size to the right of each genome in the ribbon plot", action="store_true") + output_group.add_argument("--optimize-ordering", + help="Optimize tree-guided genome sorting using inversions. Only use with strictly bifurcating trees.", + action="store_true") main_formatting_group.add_argument("--no-arrow", help="Only used with --normalize; " "do not draw arrows indicating reverse-complementation", action="store_true") @@ -162,6 +165,8 @@ def main(): cmd += f"order={args.order} " if args.annotate_genome_info: cmd += "annotate_genome_info=True " + if args.optimize_ordering: + cmd += "optimize_ordering=True " if args.tree: cmd += f"tree={args.tree} " target = "gggenomes_ribbon_plot_tree" diff --git a/bin/ntsynt_viz.smk b/bin/ntsynt_viz.smk index 62857af..9ebb826 100644 --- a/bin/ntsynt_viz.smk +++ b/bin/ntsynt_viz.smk @@ -30,6 +30,8 @@ res = config.get("dpi", 300) orders = config.get("order", []) annotate_genome_info = config.get("annotate_genome_info", False) +optimize_ordering = config.get("optimize_ordering", True) + def sort_fais(fai_list, name_conversion, orders): "Based on the name conversion TSV, sort the FAIs based on orders" # Read in name conversions @@ -145,15 +147,25 @@ rule make_nj_tree: rule cladogram: input: - rules.make_nj_tree.output + tree = rules.make_nj_tree.output, + blocks = rules.renaming.output.renamed_blocks output: orders_tmp = temp(f"{prefix}_est-distances.order_tmp.tsv"), nwk_tmp = temp(f"{prefix}_est-distances_tmp.cladogram.nwk") params: prefix = f"{prefix}_est-distances", target = f"--target {target_genome}" if target_genome else "" - shell: - "ntsynt_viz_distance_cladogram.R --nwk {input} -p {params.prefix} --update_nwk {params.target}" + run: + if optimize_ordering: + shell(""" + set -eux -o pipefail + ntsynt_viz_distance_cladogram.R --nwk {input.tree} -p {params.prefix} --update_nwk {params.target} + ntsynt_viz_optimize_tree_topology.py --blocks {input.blocks} --tree {output.nwk_tmp} --out-order {output.orders_tmp} \ + --out-tree tmp_tree.nwk {params.target} + mv tmp_tree.nwk {output.nwk_tmp} + """) + else: + shell("set -eux -o pipefail; ntsynt_viz_distance_cladogram.R --nwk {input.tree} -p {params.prefix} --update_nwk {params.target}") rule orders: input: diff --git a/bin/ntsynt_viz_optimize_tree_topology.py b/bin/ntsynt_viz_optimize_tree_topology.py new file mode 100755 index 0000000..c606f49 --- /dev/null +++ b/bin/ntsynt_viz_optimize_tree_topology.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +""" +optimize_tree_topology.py + +Given an ntSynt synteny blocks TSV and a Newick tree, find the tree rotation +that minimizes the total pairwise inversion length between adjacent genomes. + +Outputs: + - TSV: one genome name per line, in optimized top-to-bottom order + - Newick: tree with subtrees rotated to match the optimal ordering +""" + +import argparse +import io +import sys +from collections import defaultdict +from Bio import Phylo +from Bio.Phylo.BaseTree import Clade, Tree + + +# --------------------------------------------------------------------------- +# Column indices (0-based) for the synteny blocks TSV +# --------------------------------------------------------------------------- +COL_BLOCK_ID = 0 +COL_GENOME = 1 +COL_CHROM = 2 +COL_START = 3 +COL_END = 4 +COL_STRAND = 5 +COL_MINIMIZERS = 6 +COL_DISCONTINUITY = 7 + + +def parse_args() -> argparse.Namespace: + "Parse arguments" + parser = argparse.ArgumentParser( + description="Optimize phylogenetic tree rotation to minimize visible inversions " + "in ntSynt-viz ribbon plots." + ) + parser.add_argument( + "--blocks", required=True, + help="ntSynt synteny blocks TSV file." + ) + parser.add_argument( + "--tree", required=True, + help="Newick tree file. Leaf names must match genome file names in the blocks TSV." + ) + parser.add_argument( + "--out-order", required=True, + help="Output TSV: one genome name per line, optimized top-to-bottom order." + ) + parser.add_argument( + "--out-tree", required=True, + help="Output Newick file with subtrees rotated to match the optimal ordering." + ) + parser.add_argument( + "--target", default=None, + help="If specified, this genome is always placed first (top-most) in the ordering." + ) + return parser.parse_args() + + +# --------------------------------------------------------------------------- +# Biopython helpers +# +# --------------------------------------------------------------------------- + +def _is_leaf(clade: Clade) -> bool: + return clade.is_terminal() + +def _get_children(clade: Clade) -> list[Clade]: + return clade.clades + +def _get_leaf_names(clade: Clade) -> list[str]: + return [leaf.name for leaf in clade.get_terminals()] + +def load_tree(newick_path: str) -> Tree: + """ + Read a Newick tree with Biopython. If the tree is unrooted (or has a + trifurcating/polytomous root, as is common from tools like RAxML/IQ-TREE), + midpoint rooting is applied, which also resolves the root into a + strictly bifurcating split. + """ + with open(newick_path) as fh: + newick_str = fh.read().strip() + + tree = Phylo.read(io.StringIO(newick_str), "newick") + + if len(tree.root.clades) != 2: + print( + " Tree root is not bifurcating (likely unrooted input); " + "applying midpoint rooting.", + file=sys.stderr, + ) + tree.root_at_midpoint() + + if not tree.is_bifurcating(): + print("WARNING: --optimize-ordering option is not compatible with polytomies") + + return tree + +# --------------------------------------------------------------------------- +# Step 1: Build pairwise inversion cost matrix +# --------------------------------------------------------------------------- + +def _tally_block( + entries: list[tuple[str, str, int]], + cost: dict[tuple[str, str], int], +) -> None: + """ + Given all rows for a single synteny block, tally inverted pairs + directly into `cost`. Called once per block, then entries is discarded. + """ + for i in range(len(entries)): + for j in range(i + 1, len(entries)): + genome_a, strand_a, length_a = entries[i] + genome_b, strand_b, length_b = entries[j] + if genome_a == genome_b: + continue + if strand_a != strand_b: + inversion_length = (length_a + length_b) // 2 + cost[(genome_a, genome_b)] += inversion_length + cost[(genome_b, genome_a)] += inversion_length + + +def build_cost_matrix( + blocks_path: str, +) -> dict[tuple[str, str], int]: + """ + Stream the ntSynt blocks TSV one block at a time, tallying the total + length of inverted synteny blocks for each genome pair. + + Rows are assumed to be sorted by block ID (consecutive rows with the + same ID belong to the same block). Only the current block's rows are + held in memory at any point. + + Returns: + cost[(genome_a, genome_b)] = total inverted block length (symmetric) + """ + cost: dict[tuple[str, str], int] = defaultdict(int) + current_block_id: str | None = None + current_entries: list[tuple[str, str, int]] = [] + + with open(blocks_path, 'r', encoding="utf-8") as fh: + for line in fh: + fields = line.strip().split("\t") + block_id = fields[COL_BLOCK_ID] + genome = fields[COL_GENOME] + start = int(fields[COL_START]) + end = int(fields[COL_END]) + strand = fields[COL_STRAND] + length = abs(end - start) + + if block_id != current_block_id: + if current_entries: + _tally_block(current_entries, cost) + current_block_id = block_id + current_entries = [] + + current_entries.append((genome, strand, length)) + + # Flush the final block + if current_entries: + _tally_block(current_entries, cost) + + return dict(cost) + + +def get_genomes_from_cost_matrix( + cost: dict[tuple[str, str], int] +) -> list[str]: + "Returns the genomes from the cost matrix" + genomes: set[str] = set() + for a, b in cost: + genomes.add(a) + genomes.add(b) + return sorted(genomes) + + +# --------------------------------------------------------------------------- +# Step 2: Exhaustive memoized tree rotation optimization +# --------------------------------------------------------------------------- + +# Each subtree result is a list of options: +# (internal_cost, topmost_leaf, bottommost_leaf, ordering_tuple) +SubtreeOption = tuple[int, str, str, tuple[str, ...]] + +def build_subtree_options( + node: Clade, + cost: dict[tuple[str, str], int], + target_genome: str | None = None, +) -> list[SubtreeOption]: + """ + Recursively compute all valid leaf orderings for the subtree rooted at + `node`, memoizing results so each clade is computed exactly once. + + If target_genome is specified, any option where it would not appear as + the top leaf is discarded at nodes whose subtree contains it. + + Returns a list of (total_internal_cost, top_leaf, bottom_leaf, ordering). + """ + if _is_leaf(node): + return [(0, node.name, node.name, (node.name,))] + + children = _get_children(node) + if len(children) != 2: + raise ValueError( + f"Tree must be strictly bifurcating; node '{node.name}' " + f"has {len(children)} children. The option --optimize-ordering is not compatible with polytomies." + ) + + top_options = build_subtree_options(children[0], cost, target_genome) + bottom_options = build_subtree_options(children[1], cost, target_genome) + + # Determine whether the target genome falls in the top or bottom subtree, + # so we know which side must come first at this node. + top_leaf_names = set(_get_leaf_names(children[0])) + bottom_leaf_names = set(_get_leaf_names(children[1])) + target_in_top = target_genome in top_leaf_names if target_genome else False + target_in_bottom = target_genome in bottom_leaf_names if target_genome else False + + results: list[SubtreeOption] = [] + + for top_cost, top_top, top_bottom, top_order in top_options: + for bottom_cost, bottom_top, bottom_bottom, bottom_order in bottom_options: + + # Option A: [top subtree | bottom subtree] + # Skip if target must be topmost but would not be. + if not (target_in_bottom and not target_in_top): + boundary_a = cost.get((top_bottom, bottom_top), 0) + results.append(( + top_cost + bottom_cost + boundary_a, + top_top, + bottom_bottom, + top_order + bottom_order, + )) + + # Option B: [bottom subtree | top subtree] + # Skip if target must be topmost but would not be. + if not (target_in_top and not target_in_bottom): + boundary_b = cost.get((bottom_bottom, top_top), 0) + results.append(( + top_cost + bottom_cost + boundary_b, + bottom_top, + top_bottom, + bottom_order + top_order, + )) + return results + + +def optimize_topology( + newick_path: str, + cost: dict[tuple[str, str], int], + target_genome: str | None = None, +) -> tuple[list[str], int, Tree]: + """ + Load the tree, run exhaustive memoized optimization, and return: + - optimal genome ordering (list of names, top to bottom) + - optimal total inversion cost + - Biopython Tree object rotated to match the optimal ordering + + If target_genome is specified, it is guaranteed to be first in the ordering. + The tree is midpoint-rooted if it is not already strictly bifurcating + at the root (e.g. unrooted input). + """ + tree = load_tree(newick_path) + root = tree.root + + if target_genome is not None: + leaf_names = set(_get_leaf_names(root)) + if target_genome not in leaf_names: + raise ValueError( + f"--target-genome '{target_genome}' not found in tree leaves: " + f"{sorted(leaf_names)}" + ) + + options = build_subtree_options(root, cost, target_genome) + for option in sorted(options, key=lambda x: x[0]): + print(option) + best_cost, _, _, best_order_tuple = min(options, key=lambda x: int(x[0])) + print(best_cost) + best_order = list(best_order_tuple) + + _rotate_tree_to_order(root, best_order) + + return best_order, best_cost, tree + + +def _rotate_tree_to_order(node: Clade, target_order: list[str]) -> None: + """ + Rotate subtrees of `node` in-place so that the leaf order matches + target_order (top-to-bottom). + """ + if _is_leaf(node): + return + + children = _get_children(node) + top_leaves = set(_get_leaf_names(children[0])) + + # Find where the top child's leaves sit in target_order + top_positions = [i for i, g in enumerate(target_order) if g in top_leaves] + bottom_positions = [i for i, g in enumerate(target_order) if g not in top_leaves] + + # If the top child's leaves all come after the bottom child's, swap + if top_positions and bottom_positions and min(top_positions) > min(bottom_positions): + node.clades[0], node.clades[1] = node.clades[1], node.clades[0] + children = _get_children(node) + + for child in children: + _rotate_tree_to_order(child, target_order) + + +# --------------------------------------------------------------------------- +# Step 3: Output +# --------------------------------------------------------------------------- + +def write_order_tsv(order: list[str], out_path: str) -> None: + with open(out_path, "w") as fh: + for genome in order: + fh.write(genome + "\n") + + +def write_newick(tree: Tree, out_path: str) -> None: + # format=1 preserves internal node names if present + Phylo.write(tree, out_path, "newick") + + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + args = parse_args() + + # --- Build cost matrix --- + print("Building pairwise inversion cost matrix...", file=sys.stderr) + cost = build_cost_matrix(args.blocks) + genomes = get_genomes_from_cost_matrix(cost) + print(f" Found {len(genomes)} genomes.", file=sys.stderr) + + if not cost: + print( + "Warning: no pairwise inversions found. All costs are zero; " + "the original tree ordering will be preserved.", + file=sys.stderr, + ) + + # --- Optimize --- + print("Optimizing tree topology...", file=sys.stderr) + if args.target: + print(f" Target genome (always first): {args.target}", file=sys.stderr) + best_order, best_cost, rotated_tree = optimize_topology( + args.tree, cost, target_genome=args.target + ) + + print(f" Optimal total inversion cost: {best_cost:,} bp", file=sys.stderr) + print(f" Optimal ordering:", file=sys.stderr) + for i, genome in enumerate(best_order): + print(f" {i + 1}. {genome}", file=sys.stderr) + + # --- Write outputs --- + write_order_tsv(best_order, args.out_order) + print(f"Order written to: {args.out_order}", file=sys.stderr) + + write_newick(rotated_tree, args.out_tree) + print(f"Rotated tree written to: {args.out_tree}", file=sys.stderr) + + +if __name__ == "__main__": + main() \ No newline at end of file From 2dee349819d5e59cba6b32d7eb8a0bf3e1c8e675 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Thu, 2 Jul 2026 13:21:04 -0700 Subject: [PATCH 04/26] Highlight/fade ribbons based on legend click --- bin/ntsynt_viz.smk | 2 +- bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R | 8 +++++--- bin/ntsynt_viz_ribbon-interactive.js | 16 ++++++++-------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/bin/ntsynt_viz.smk b/bin/ntsynt_viz.smk index 9ebb826..adf4bed 100644 --- a/bin/ntsynt_viz.smk +++ b/bin/ntsynt_viz.smk @@ -30,7 +30,7 @@ res = config.get("dpi", 300) orders = config.get("order", []) annotate_genome_info = config.get("annotate_genome_info", False) -optimize_ordering = config.get("optimize_ordering", True) +optimize_ordering = config.get("optimize_ordering", False) def sort_fais(fai_list, name_conversion, orders): "Based on the name conversion TSV, sort the FAIs based on orders" diff --git a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R index 6aa4a79..c9d0459 100755 --- a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R +++ b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R @@ -217,7 +217,8 @@ get_link_info <- function(p, block_coords) { px = c(x, xend, xmax, xmin), py = c(y, y, yend, yend), tooltip = tooltip, - data_id = block_id + data_id = block_id, + colour_block = colour_block ))) %>% ungroup() %>% select(group_id, poly) %>% @@ -339,7 +340,8 @@ make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FA y = py, group = group_id, tooltip = tooltip, - data_id = data_id + data_id = data_id, + fill = colour_block ), alpha = 0, hover_nearest = FALSE @@ -367,7 +369,7 @@ make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FA tooltip = tooltip, data_id = seq_id ), - linewidth = 10, # Increased area to hit for hover box + linewidth = 3, # Increased area to hit for hover box alpha = 0, hover_nearest = FALSE ) diff --git a/bin/ntsynt_viz_ribbon-interactive.js b/bin/ntsynt_viz_ribbon-interactive.js index 2fa17c7..f79656b 100644 --- a/bin/ntsynt_viz_ribbon-interactive.js +++ b/bin/ntsynt_viz_ribbon-interactive.js @@ -183,15 +183,15 @@ container.addEventListener("mousemove", function(e) { poly.style.stroke = ""; poly.style.strokeWidth = ""; } else if (activeBlockIds.has(bid)) { - poly.style.opacity = "1"; - poly.style.fill = "darkgrey"; - poly.style.fillOpacity = "0.5"; - poly.style.stroke = "darkgrey"; - poly.style.strokeWidth = "0.2px"; - } else { - poly.style.opacity = ""; + poly.style.opacity = "0.9"; poly.style.fill = ""; - poly.style.fillOpacity = ""; + poly.style.fillOpacity = "0.9"; + poly.style.stroke = ""; + poly.style.strokeWidth = ""; + } else { + poly.style.opacity = "0.6"; + poly.style.fill = "white"; + poly.style.fillOpacity = "0.6"; poly.style.stroke = ""; poly.style.strokeWidth = ""; } }); From e94765b4a03209adfcc9d948412774548e51a4ab Mon Sep 17 00:00:00 2001 From: lcoombe Date: Thu, 2 Jul 2026 13:52:13 -0700 Subject: [PATCH 05/26] Add strand info to hover ribbon pop-up box --- bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R index c9d0459..1d013b5 100755 --- a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R +++ b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R @@ -195,7 +195,7 @@ get_block_coord_info <- function(p, max_genome_len, max_chrom_len) { stringr::str_pad(chrom, width = max_chrom_len, side="right", pad='\u00A0'), ": ", format(start, big.mark=","), " – ", - format(end, big.mark=","), " bp")) %>% + format(end, big.mark=","), " bp (", strand, ")")) %>% pull(line) %>% paste(collapse = "\n") }, From c26599dc630f2c964f088d0956889ecd8981a0e9 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Thu, 2 Jul 2026 14:06:03 -0700 Subject: [PATCH 06/26] Adjust padding to allow wider ribbon trigger area --- bin/ntsynt_viz_ribbon-interactive.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ntsynt_viz_ribbon-interactive.js b/bin/ntsynt_viz_ribbon-interactive.js index f79656b..0c81268 100644 --- a/bin/ntsynt_viz_ribbon-interactive.js +++ b/bin/ntsynt_viz_ribbon-interactive.js @@ -47,7 +47,7 @@ document.addEventListener("DOMContentLoaded", function() { // Check if cursor is near any chromosome segment (invisible hit area) // Returns true if within CHROM_PRIORITY_PX pixels of a segment element - const CHROM_PRIORITY_PX = 8; + const CHROM_PRIORITY_PX = 5; function isNearChromosome(e) { // ggiraph chromosome segments are elements with data-id From fc0c9562dd49eacf576852fc8c11d10557596f59 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Thu, 2 Jul 2026 14:43:30 -0700 Subject: [PATCH 07/26] Add click+copy function for ribbons and chromosomes --- bin/ntsynt_viz_ribbon-interactive.js | 115 ++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/bin/ntsynt_viz_ribbon-interactive.js b/bin/ntsynt_viz_ribbon-interactive.js index 0c81268..cee6cea 100644 --- a/bin/ntsynt_viz_ribbon-interactive.js +++ b/bin/ntsynt_viz_ribbon-interactive.js @@ -29,6 +29,8 @@ document.addEventListener("DOMContentLoaded", function() { // --- Manual ribbon tooltip --- // We need our own tooltip div since we bypassed ggiraph for ribbons + let pinnedRibbonId = null; + const ribbonTip = document.createElement("div"); ribbonTip.style.cssText = [ "position:fixed", @@ -43,8 +45,36 @@ document.addEventListener("DOMContentLoaded", function() { "z-index:9999", "max-width:400px" ].join(";"); + + const contentDiv = document.createElement("div"); + contentDiv.className = "ribbon-tip-content"; + + const controlsDiv = document.createElement("div"); + controlsDiv.className = "ribbon-tip-controls"; + controlsDiv.style.cssText = "display:none;margin-top:6px;text-align:right;"; + controlsDiv.innerHTML = + '' + + ''; + + ribbonTip.appendChild(contentDiv); + ribbonTip.appendChild(controlsDiv); document.body.appendChild(ribbonTip); + controlsDiv.querySelector(".ribbon-tip-copy").addEventListener("click", function(e) { + e.stopPropagation(); + navigator.clipboard.writeText(contentDiv.innerText).then(function() { + const btn = controlsDiv.querySelector(".ribbon-tip-copy"); + const old = btn.textContent; + btn.textContent = "Copied!"; + setTimeout(function() { btn.textContent = old; }, 1200); + }); + }); + + controlsDiv.querySelector(".ribbon-tip-close").addEventListener("click", function(e) { + e.stopPropagation(); + unpinTooltip(); + }); + // Check if cursor is near any chromosome segment (invisible hit area) // Returns true if within CHROM_PRIORITY_PX pixels of a segment element const CHROM_PRIORITY_PX = 5; @@ -67,6 +97,22 @@ document.addEventListener("DOMContentLoaded", function() { return false; } + function getChromosomeUnderCursor(e) { + const segs = svg.querySelectorAll("line[data-id], [data-id].chromosome"); + for (let seg of segs) { + const bbox = seg.getBoundingClientRect(); + if ( + e.clientX >= bbox.left - CHROM_PRIORITY_PX && + e.clientX <= bbox.right + CHROM_PRIORITY_PX && + e.clientY >= bbox.top - CHROM_PRIORITY_PX && + e.clientY <= bbox.bottom + CHROM_PRIORITY_PX + ) { + return seg; + } + } + return null; + } + // Find which ribbon polygon (if any) the cursor is geometrically inside function getRibbonUnderCursor(e) { @@ -96,6 +142,8 @@ document.addEventListener("DOMContentLoaded", function() { } container.addEventListener("mousemove", function(e) { + if (pinnedRibbonId) return; // frozen while pinned + if (isNearChromosome(e)) { ribbonTip.style.display = "none"; applyLegendState(); @@ -128,8 +176,7 @@ container.addEventListener("mousemove", function(e) { .replaceAll("<br/>", "
") .replaceAll("<br>", "
"); - ribbonTip.innerHTML = tooltipHtml; - ribbonTip.innerHTML = tooltipHtml; + contentDiv.innerHTML = tooltipHtml; ribbonTip.style.display = "block"; ribbonTip.style.left = (e.clientX + 14) + "px"; ribbonTip.style.top = (e.clientY + 14) + "px"; @@ -147,6 +194,7 @@ container.addEventListener("mousemove", function(e) { }, true); container.addEventListener("mouseleave", function() { + if (pinnedRibbonId) return; ribbonTip.style.display = "none"; applyLegendState(); }, true); @@ -197,6 +245,52 @@ container.addEventListener("mousemove", function(e) { }); } + function pinTooltip(el, e) { + const titleEl = el.querySelector ? el.querySelector("title") : null; + const tooltipText = (titleEl ? titleEl.innerHTML : null) + || el.getAttribute("title") + || el.getAttribute("data-original-title"); + if (!tooltipText) return; + + pinnedRibbonId = el.getAttribute("data-id"); + + const tooltipHtml = tooltipText + .replaceAll("<br/>", "
") + .replaceAll("<br>", "
"); + + contentDiv.innerHTML = tooltipHtml; + controlsDiv.style.display = "block"; + ribbonTip.style.pointerEvents = "auto"; + ribbonTip.style.userSelect = "text"; + ribbonTip.style.display = "block"; + ribbonTip.style.left = (e.clientX + 14) + "px"; + ribbonTip.style.top = (e.clientY + 14) + "px"; + const r = ribbonTip.getBoundingClientRect(); + if (r.right > window.innerWidth) ribbonTip.style.left = (e.clientX - r.width - 14) + "px"; + if (r.bottom > window.innerHeight) ribbonTip.style.top = (e.clientY - r.height - 14) + "px"; + + if (el.tagName && el.tagName.toLowerCase() === "polygon") { + svg.querySelectorAll("polygon[data-id]").forEach(function(p) { + if (p.getAttribute("data-id") === pinnedRibbonId) { + p.style.stroke = "black"; + p.style.strokeWidth = "1"; + p.style.opacity = "1"; + p.style.fill = "darkgrey"; + p.style.fillOpacity = "0.3"; + } + }); + applyLegendState(pinnedRibbonId); + } + } + + function unpinTooltip() { + pinnedRibbonId = null; + ribbonTip.style.display = "none"; + ribbonTip.style.pointerEvents = "none"; + controlsDiv.style.display = "none"; + applyLegendState(); + } + function inBBox(el, e, padding = 5) { const bbox = el.getBoundingClientRect(); return e.clientX >= bbox.left - padding && e.clientX <= bbox.right + padding && @@ -257,7 +351,22 @@ container.addEventListener("mousemove", function(e) { container.addEventListener("click", function(e) { const chrom = findLegendChrom(e); - if (chrom) handleChromClick(chrom); + if (chrom) { handleChromClick(chrom); return; } + + if (pinnedRibbonId && ribbonTip.contains(e.target)) return; // let buttons work + + const chromSeg = getChromosomeUnderCursor(e); + if (chromSeg) { + pinTooltip(chromSeg, e); + return; + } + + const poly = getRibbonUnderCursor(e); + if (poly) { + pinTooltip(poly, e); + } else if (pinnedRibbonId) { + unpinTooltip(); + } }, true); From 474d1bce1b74565388fa627de4ad87c4da785593 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Fri, 3 Jul 2026 07:55:41 -0700 Subject: [PATCH 08/26] Fixes for pylint --- bin/ntsynt_viz.py | 3 +- bin/ntsynt_viz_optimize_tree_topology.py | 62 ++++++++++++------------ 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/bin/ntsynt_viz.py b/bin/ntsynt_viz.py index 5e4e268..1485cb6 100755 --- a/bin/ntsynt_viz.py +++ b/bin/ntsynt_viz.py @@ -101,7 +101,8 @@ def main(): "and genome size to the right of each genome in the ribbon plot", action="store_true") output_group.add_argument("--optimize-ordering", - help="Optimize tree-guided genome sorting using inversions. Only use with strictly bifurcating trees.", + help="Optimize tree-guided genome sorting using inversions. " + "Only use with strictly bifurcating trees.", action="store_true") main_formatting_group.add_argument("--no-arrow", help="Only used with --normalize; " "do not draw arrows indicating reverse-complementation", diff --git a/bin/ntsynt_viz_optimize_tree_topology.py b/bin/ntsynt_viz_optimize_tree_topology.py index c606f49..28460dd 100755 --- a/bin/ntsynt_viz_optimize_tree_topology.py +++ b/bin/ntsynt_viz_optimize_tree_topology.py @@ -64,7 +64,7 @@ def parse_args() -> argparse.Namespace: # Biopython helpers # # --------------------------------------------------------------------------- - + def _is_leaf(clade: Clade) -> bool: return clade.is_terminal() @@ -81,11 +81,11 @@ def load_tree(newick_path: str) -> Tree: midpoint rooting is applied, which also resolves the root into a strictly bifurcating split. """ - with open(newick_path) as fh: + with open(newick_path, 'r', encoding="utf-8") as fh: newick_str = fh.read().strip() - + tree = Phylo.read(io.StringIO(newick_str), "newick") - + if len(tree.root.clades) != 2: print( " Tree root is not bifurcating (likely unrooted input); " @@ -93,10 +93,10 @@ def load_tree(newick_path: str) -> Tree: file=sys.stderr, ) tree.root_at_midpoint() - + if not tree.is_bifurcating(): print("WARNING: --optimize-ordering option is not compatible with polytomies") - + return tree # --------------------------------------------------------------------------- @@ -111,9 +111,9 @@ def _tally_block( Given all rows for a single synteny block, tally inverted pairs directly into `cost`. Called once per block, then entries is discarded. """ - for i in range(len(entries)): + for i, el_i in enumerate(entries): for j in range(i + 1, len(entries)): - genome_a, strand_a, length_a = entries[i] + genome_a, strand_a, length_a = el_i genome_b, strand_b, length_b = entries[j] if genome_a == genome_b: continue @@ -201,26 +201,26 @@ def build_subtree_options( """ if _is_leaf(node): return [(0, node.name, node.name, (node.name,))] - + children = _get_children(node) if len(children) != 2: raise ValueError( f"Tree must be strictly bifurcating; node '{node.name}' " f"has {len(children)} children. The option --optimize-ordering is not compatible with polytomies." ) - + top_options = build_subtree_options(children[0], cost, target_genome) bottom_options = build_subtree_options(children[1], cost, target_genome) - + # Determine whether the target genome falls in the top or bottom subtree, # so we know which side must come first at this node. top_leaf_names = set(_get_leaf_names(children[0])) bottom_leaf_names = set(_get_leaf_names(children[1])) target_in_top = target_genome in top_leaf_names if target_genome else False target_in_bottom = target_genome in bottom_leaf_names if target_genome else False - + results: list[SubtreeOption] = [] - + for top_cost, top_top, top_bottom, top_order in top_options: for bottom_cost, bottom_top, bottom_bottom, bottom_order in bottom_options: @@ -234,7 +234,7 @@ def build_subtree_options( bottom_bottom, top_order + bottom_order, )) - + # Option B: [bottom subtree | top subtree] # Skip if target must be topmost but would not be. if not (target_in_top and not target_in_bottom): @@ -246,8 +246,8 @@ def build_subtree_options( bottom_order + top_order, )) return results - - + + def optimize_topology( newick_path: str, cost: dict[tuple[str, str], int], @@ -273,19 +273,16 @@ def optimize_topology( f"--target-genome '{target_genome}' not found in tree leaves: " f"{sorted(leaf_names)}" ) - + options = build_subtree_options(root, cost, target_genome) - for option in sorted(options, key=lambda x: x[0]): - print(option) best_cost, _, _, best_order_tuple = min(options, key=lambda x: int(x[0])) - print(best_cost) best_order = list(best_order_tuple) - + _rotate_tree_to_order(root, best_order) - + return best_order, best_cost, tree - - + + def _rotate_tree_to_order(node: Clade, target_order: list[str]) -> None: """ Rotate subtrees of `node` in-place so that the leaf order matches @@ -293,19 +290,19 @@ def _rotate_tree_to_order(node: Clade, target_order: list[str]) -> None: """ if _is_leaf(node): return - + children = _get_children(node) top_leaves = set(_get_leaf_names(children[0])) - + # Find where the top child's leaves sit in target_order top_positions = [i for i, g in enumerate(target_order) if g in top_leaves] bottom_positions = [i for i, g in enumerate(target_order) if g not in top_leaves] - + # If the top child's leaves all come after the bottom child's, swap if top_positions and bottom_positions and min(top_positions) > min(bottom_positions): node.clades[0], node.clades[1] = node.clades[1], node.clades[0] children = _get_children(node) - + for child in children: _rotate_tree_to_order(child, target_order) @@ -315,12 +312,14 @@ def _rotate_tree_to_order(node: Clade, target_order: list[str]) -> None: # --------------------------------------------------------------------------- def write_order_tsv(order: list[str], out_path: str) -> None: - with open(out_path, "w") as fh: + "Write the optimized genome order to a TSV file, one genome per line." + with open(out_path, "w", encoding="utf-8") as fh: for genome in order: fh.write(genome + "\n") def write_newick(tree: Tree, out_path: str) -> None: + "Write the rotated tree to a Newick file." # format=1 preserves internal node names if present Phylo.write(tree, out_path, "newick") @@ -331,6 +330,7 @@ def write_newick(tree: Tree, out_path: str) -> None: # --------------------------------------------------------------------------- def main() -> None: + "Parse arguments, build cost matrix, optimize tree topology, and write outputs." args = parse_args() # --- Build cost matrix --- @@ -355,7 +355,7 @@ def main() -> None: ) print(f" Optimal total inversion cost: {best_cost:,} bp", file=sys.stderr) - print(f" Optimal ordering:", file=sys.stderr) + print(" Optimal ordering:", file=sys.stderr) for i, genome in enumerate(best_order): print(f" {i + 1}. {genome}", file=sys.stderr) @@ -368,4 +368,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() From ccac094542354b5d371775f68458b28511c0a28d Mon Sep 17 00:00:00 2001 From: lcoombe Date: Fri, 3 Jul 2026 08:06:43 -0700 Subject: [PATCH 09/26] Fixes for pylint --- bin/ntsynt_viz_optimize_tree_topology.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/ntsynt_viz_optimize_tree_topology.py b/bin/ntsynt_viz_optimize_tree_topology.py index 28460dd..c060789 100755 --- a/bin/ntsynt_viz_optimize_tree_topology.py +++ b/bin/ntsynt_viz_optimize_tree_topology.py @@ -223,7 +223,7 @@ def build_subtree_options( for top_cost, top_top, top_bottom, top_order in top_options: for bottom_cost, bottom_top, bottom_bottom, bottom_order in bottom_options: - + # Option A: [top subtree | bottom subtree] # Skip if target must be topmost but would not be. if not (target_in_bottom and not target_in_top): @@ -265,7 +265,7 @@ def optimize_topology( """ tree = load_tree(newick_path) root = tree.root - + if target_genome is not None: leaf_names = set(_get_leaf_names(root)) if target_genome not in leaf_names: From 55cb6056055acc9b6d3413264f80e94c2b010764 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Mon, 6 Jul 2026 09:01:06 -0700 Subject: [PATCH 10/26] Fix inversion annotation in link pop-up box --- bin/ntsynt_viz.py | 2 +- bin/ntsynt_viz.smk | 2 +- bin/ntsynt_viz_format_blocks_gggenomes.py | 8 +++-- ...synt_viz_plot_synteny_blocks_ribbon_plot.R | 5 +-- bin/sort_ntsynt_blocks.py | 31 ++++++++++++++++--- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/bin/ntsynt_viz.py b/bin/ntsynt_viz.py index 1485cb6..d7526de 100755 --- a/bin/ntsynt_viz.py +++ b/bin/ntsynt_viz.py @@ -104,7 +104,7 @@ def main(): help="Optimize tree-guided genome sorting using inversions. " "Only use with strictly bifurcating trees.", action="store_true") - main_formatting_group.add_argument("--no-arrow", help="Only used with --normalize; " + output_group.add_argument("--no-arrow", help="Only used with --normalize; " "do not draw arrows indicating reverse-complementation", action="store_true") output_group.add_argument( diff --git a/bin/ntsynt_viz.smk b/bin/ntsynt_viz.smk index adf4bed..9906dd4 100644 --- a/bin/ntsynt_viz.smk +++ b/bin/ntsynt_viz.smk @@ -264,7 +264,7 @@ rule chrom_paint: input: links = rules.gggenomes_files.output.links output: colour_feats = f"{prefix}.chrom-paint-feats.tsv" shell: - '''cat {input.links} |perl -ne 'chomp; @a=split("\t"); if(!defined $ct){{print "block_id\tseq_id\tbin_id\tstart\tend\tcolour_block\n"; $ct=1}} else {{print "$a[0]\t$a[1]\t$a[2]\t$a[3]\t$a[4]\t$a[10]\n"; print "$a[0]\t$a[5]\t$a[6]\t$a[7]\t$a[8]\t$a[10]\n";}}' > {output.colour_feats}''' + '''cat {input.links} |perl -ne 'chomp; @a=split("\t"); if(!defined $ct){{print "block_id\tseq_id\tbin_id\tstart\tend\tcolour_block\n"; $ct=1}} else {{print "$a[0]\t$a[1]\t$a[2]\t$a[3]\t$a[4]\t$a[12]\n"; print "$a[0]\t$a[5]\t$a[6]\t$a[7]\t$a[8]\t$a[12]\n";}}' > {output.colour_feats}''' rule ribbon_plot: input: diff --git a/bin/ntsynt_viz_format_blocks_gggenomes.py b/bin/ntsynt_viz_format_blocks_gggenomes.py index d5ca58f..bd3abc8 100755 --- a/bin/ntsynt_viz_format_blocks_gggenomes.py +++ b/bin/ntsynt_viz_format_blocks_gggenomes.py @@ -92,9 +92,10 @@ def make_links_file(synteny_file, prefix, valid_blocks_set, target_assembly): prev_line = None lines_to_print = [] target_assembly_chrom = None + target_assembly_ori = None with open(f"{prefix}.links.tsv", 'w', encoding="utf-8") as fout: fout.write("block_id\tseq_id\tbin_id\tstart\tend\t"\ - "seq_id2\tbin_id2\tstart2\tend2\tstrand\tcolour_block\n") + "seq_id2\tbin_id2\tstart2\tend2\tstrand\tstrand1\tstrand2\tcolour_block\n") with open(synteny_file, 'r', encoding="utf-8") as fin: for line in fin: line = line.strip().split("\t") @@ -103,8 +104,10 @@ def make_links_file(synteny_file, prefix, valid_blocks_set, target_assembly): start_prev, end_prev = prev_line.start, prev_line.end start_curr, end_curr = curr_block.start, curr_block.end relative_ori = "-" if curr_block.strand != prev_line.strand else "+" + # summary_ori1 = "+" if prev_line.strand == target_assembly_ori else "-" + # summary_ori2 = "+" if curr_block.strand == target_assembly_ori else "-" out_line = f"{curr_block.id}\t{prev_line.chrom}\t{prev_line.genome}\t{start_prev}\t{end_prev}\t"\ - f"{curr_block.chrom}\t{curr_block.genome}\t{start_curr}\t{end_curr}\t{relative_ori}" + f"{curr_block.chrom}\t{curr_block.genome}\t{start_curr}\t{end_curr}\t{relative_ori}\t{prev_line.strand}\t{curr_block.strand}" lines_to_print.append(out_line) if prev_line is not None and prev_line.id != curr_block.id: @@ -114,6 +117,7 @@ def make_links_file(synteny_file, prefix, valid_blocks_set, target_assembly): lines_to_print = [] if curr_block.genome == target_assembly: target_assembly_chrom = curr_block.chrom + target_assembly_ori = curr_block.strand prev_line = curr_block if prev_line.id in valid_blocks_set: diff --git a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R index 1d013b5..d52f271 100755 --- a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R +++ b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R @@ -186,8 +186,8 @@ get_block_coord_info <- function(p, max_genome_len, max_chrom_len) { summarise( coords = { # Each row contributes two genomes; collect all unique combinations - g1 <- tibble(genome = bin_id, chrom = seq_id, start = start, end = end) - g2 <- tibble(genome = bin_id2, chrom = seq_id2, start = start2, end = end2) + g1 <- tibble(genome = bin_id, chrom = seq_id, start = start, end = end, strand = strand1) + g2 <- tibble(genome = bin_id2, chrom = seq_id2, start = start2, end = end2, strand = strand2) bind_rows(g1, g2) %>% distinct() %>% mutate(line = paste0(stringr::str_pad(genome, width = max_genome_len, side="right", pad='\u00A0'), @@ -487,6 +487,7 @@ interactive_plot <- girafe( opts_hover_inv(css = "opacity:0.2;"), opts_zoom(max = 10), opts_toolbar(pngname = args$prefix), + opts_sizing(rescale = TRUE, width = 1), opts_tooltip( css = paste( "background: rgba(255,255,255,0.9);", diff --git a/bin/sort_ntsynt_blocks.py b/bin/sort_ntsynt_blocks.py index 3b1a5b2..a3c015a 100755 --- a/bin/sort_ntsynt_blocks.py +++ b/bin/sort_ntsynt_blocks.py @@ -13,6 +13,31 @@ faidx_re = re.compile(r'^(\S+)\.fai$') +def normalize_strands(sorted_blocks): + "Re-orient strands so they are relative to the top (first) genome in sorted_blocks" + if not sorted_blocks: + return sorted_blocks + + top_strand = sorted_blocks[0].strand + + normalized_blocks = [] + for block in sorted_blocks: + if top_strand == "+": + new_strand = block.strand + else: + new_strand = "+" if block.strand == "-" else "-" + normalized_blocks.append(block._replace(strand=new_strand)) + + return normalized_blocks + +def print_sorted_block(synteny_block_lines, sorting_order): + "Sort a single synteny block's lines by the given order, normalize strands, and print" + sorted_blocks = sorted(synteny_block_lines, key=lambda x: sorting_order[x.genome]) + sorted_blocks = normalize_strands(sorted_blocks) + for asm_block in sorted_blocks: + print(*asm_block, sep="\t") + + def sort_blocks(synteny_blocks_fin, sorting_order): "Sort the assemblies within each synteny block" @@ -28,15 +53,13 @@ def sort_blocks(synteny_blocks_fin, sorting_order): block = SyntenyBlockComp(*line[:6]) if curr_block_id is not None and block.id != curr_block_id: # Print out the sorted blocks, they are all read in - for asm_block in sorted(synteny_block_lines, key=lambda x: sorting_order[x.genome]): - print(*asm_block, sep="\t") + print_sorted_block(synteny_block_lines, sorting_order) synteny_block_lines = [] synteny_block_lines.append(block) curr_block_id = block.id # Print out the sorted blocks, they are all read in (making sure we have last block) - for asm_block in sorted(synteny_block_lines, key=lambda x: sorting_order[x.genome]): - print(*asm_block, sep="\t") + print_sorted_block(synteny_block_lines, sorting_order) def main(): From 79b21f426b9cd406ff54e12664d3a68cd05bfd80 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Mon, 6 Jul 2026 09:07:21 -0700 Subject: [PATCH 11/26] Code clean-up --- bin/ntsynt_viz_format_blocks_gggenomes.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bin/ntsynt_viz_format_blocks_gggenomes.py b/bin/ntsynt_viz_format_blocks_gggenomes.py index bd3abc8..c6d66dd 100755 --- a/bin/ntsynt_viz_format_blocks_gggenomes.py +++ b/bin/ntsynt_viz_format_blocks_gggenomes.py @@ -92,7 +92,6 @@ def make_links_file(synteny_file, prefix, valid_blocks_set, target_assembly): prev_line = None lines_to_print = [] target_assembly_chrom = None - target_assembly_ori = None with open(f"{prefix}.links.tsv", 'w', encoding="utf-8") as fout: fout.write("block_id\tseq_id\tbin_id\tstart\tend\t"\ "seq_id2\tbin_id2\tstart2\tend2\tstrand\tstrand1\tstrand2\tcolour_block\n") @@ -104,10 +103,9 @@ def make_links_file(synteny_file, prefix, valid_blocks_set, target_assembly): start_prev, end_prev = prev_line.start, prev_line.end start_curr, end_curr = curr_block.start, curr_block.end relative_ori = "-" if curr_block.strand != prev_line.strand else "+" - # summary_ori1 = "+" if prev_line.strand == target_assembly_ori else "-" - # summary_ori2 = "+" if curr_block.strand == target_assembly_ori else "-" out_line = f"{curr_block.id}\t{prev_line.chrom}\t{prev_line.genome}\t{start_prev}\t{end_prev}\t"\ - f"{curr_block.chrom}\t{curr_block.genome}\t{start_curr}\t{end_curr}\t{relative_ori}\t{prev_line.strand}\t{curr_block.strand}" + f"{curr_block.chrom}\t{curr_block.genome}\t{start_curr}\t{end_curr}\t{relative_ori}\t"\ + f"{prev_line.strand}\t{curr_block.strand}" lines_to_print.append(out_line) if prev_line is not None and prev_line.id != curr_block.id: From 6265f972bc112cd7faf9b1c1a1d1158709b776eb Mon Sep 17 00:00:00 2001 From: lcoombe Date: Mon, 6 Jul 2026 09:14:02 -0700 Subject: [PATCH 12/26] Remove unused variable --- bin/ntsynt_viz_format_blocks_gggenomes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/ntsynt_viz_format_blocks_gggenomes.py b/bin/ntsynt_viz_format_blocks_gggenomes.py index c6d66dd..218b27c 100755 --- a/bin/ntsynt_viz_format_blocks_gggenomes.py +++ b/bin/ntsynt_viz_format_blocks_gggenomes.py @@ -115,7 +115,6 @@ def make_links_file(synteny_file, prefix, valid_blocks_set, target_assembly): lines_to_print = [] if curr_block.genome == target_assembly: target_assembly_chrom = curr_block.chrom - target_assembly_ori = curr_block.strand prev_line = curr_block if prev_line.id in valid_blocks_set: From ec3f93d0bb30ffde11d30d7067275ab2553bec7d Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 11:46:40 -0700 Subject: [PATCH 13/26] Print options for tree topology optimization, keep chrom oris file --- bin/ntsynt_viz.smk | 2 +- bin/ntsynt_viz_optimize_tree_topology.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/ntsynt_viz.smk b/bin/ntsynt_viz.smk index 9906dd4..01bdfaf 100644 --- a/bin/ntsynt_viz.smk +++ b/bin/ntsynt_viz.smk @@ -199,7 +199,7 @@ rule sort_blocks: output: sorted_blocks = f"{blocks_no_suffix}.renamed.sorted.blocks.tsv", intermediate_blocks = temp(f"{blocks_no_suffix}.renamed.sorted-tmp.tsv") if normalize else [], - chrom_oris = temp(f"{blocks_no_suffix}.renamed.sorted.chrom-orientations.tsv") if normalize else [] + chrom_oris = f"{blocks_no_suffix}.renamed.sorted.chrom-orientations.tsv" if normalize else [] params: intermediate_blocks = f"{blocks_no_suffix}.renamed.sorted-tmp.tsv", name_conversion = f"-c {name_conversion}" if name_conversion else "", diff --git a/bin/ntsynt_viz_optimize_tree_topology.py b/bin/ntsynt_viz_optimize_tree_topology.py index c060789..37e0a6d 100755 --- a/bin/ntsynt_viz_optimize_tree_topology.py +++ b/bin/ntsynt_viz_optimize_tree_topology.py @@ -275,6 +275,8 @@ def optimize_topology( ) options = build_subtree_options(root, cost, target_genome) + for option in options: + print(option, file=sys.stderr) best_cost, _, _, best_order_tuple = min(options, key=lambda x: int(x[0])) best_order = list(best_order_tuple) From 8a8e73d10e67f1736bc00f37ecb99fa79d766439 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 12:19:09 -0700 Subject: [PATCH 14/26] Add LLM markdown file for ribbon plots - To enable rules, always output PNG format - Can be in addition to specified format --- bin/ntsynt_viz.smk | 50 +- bin/ntsynt_viz_generate-llm-markdown.py | 439 ++++++++++++++++++ ...synt_viz_plot_synteny_blocks_ribbon_plot.R | 14 +- 3 files changed, 490 insertions(+), 13 deletions(-) create mode 100755 bin/ntsynt_viz_generate-llm-markdown.py diff --git a/bin/ntsynt_viz.smk b/bin/ntsynt_viz.smk index 01bdfaf..8b602e4 100644 --- a/bin/ntsynt_viz.smk +++ b/bin/ntsynt_viz.smk @@ -94,17 +94,19 @@ output_files = { rule all: input: output_files[format_img], - f"{prefix}_ribbon-plot.html" + f"{prefix}_ribbon-plot.html", + f"{prefix}_ribbon-plot_LLM-info.md", -ouput_files_tree = { +output_files_tree = { "png": f"{prefix}_ribbon-plot_tree.png", "pdf": f"{prefix}_ribbon-plot_tree.pdf", "svg": f"{prefix}_ribbon-plot_tree.svg", } rule gggenomes_ribbon_plot_tree: - input: ouput_files_tree[format_img], - f"{prefix}_ribbon-plot_tree.html" + input: output_files_tree[format_img], + f"{prefix}_ribbon-plot_tree.html", + f"{prefix}_ribbon-plot_tree_LLM-info.md" rule renaming: input: @@ -274,7 +276,8 @@ rule ribbon_plot: haplotypes = rules.nudges.output.nudges if haplotypes else [], colour_seqs = rules.chrom_sorting.output.colour_info output: - out_img = output_files[format_img], + out_img = output_files[format_img] if format_img != "png" else [], + out_png = output_files["png"], out_html = f"{prefix}_ribbon-plot.html" params: prefix = f"{prefix}_ribbon-plot", @@ -302,7 +305,8 @@ rule ribbon_plot_tree: haplotypes = rules.nudges.output.nudges if haplotypes else [], colour_seqs = rules.chrom_sorting.output.colour_info output: - out_img = ouput_files_tree[format_img], + out_img = output_files_tree[format_img] if format_img != "png" else [], + out_png = output_files_tree["png"], out_html = f"{prefix}_ribbon-plot_tree.html" params: prefix = f"{prefix}_ribbon-plot_tree", @@ -319,3 +323,37 @@ rule ribbon_plot_tree: "ntsynt_viz_plot_synteny_blocks_ribbon_plot.R -s {input.sequences} -l {input.links} -p {params.prefix} --tree {input.tree}" " --ratio {params.ratio} --scale {params.scale} -c {input.colour_feats} --format {params.out_img_format} --height {params.height} --width {params.width}" " --order {input.orders} {params.centromeres} {params.arrow} {params.haplotypes} --colour_indices {input.colour_seqs} {params.resolution} {params.annotate_genome_info}" + +rule llm_instructions: + input: + ribbon_png = rules.ribbon_plot.output.out_png, # png + chrom_oris = f"{blocks_no_suffix}.renamed.sorted.chrom-orientations.tsv" if normalize else [], + synteny_tsv = rules.sort_blocks.output.sorted_blocks, + chrom_lengths = rules.chrom_sorting.output.sorted_seqs, + output: + markdown = f"{prefix}_ribbon-plot_LLM-info.md" + params: + normalize_opt = lambda wc, input: ( + f"--normalize {input.chrom_oris}" if normalize else "" + ) + shell: + """ + ntsynt_viz_generate-llm-markdown.py -o {output.markdown} --image {input.ribbon_png} --lengths {input.chrom_lengths} {params.normalize_opt} {input.synteny_tsv} + """ + +rule llm_instructions_tree: + input: + ribbon_png = rules.ribbon_plot_tree.output.out_png, # png + chrom_oris = f"{blocks_no_suffix}.renamed.sorted.chrom-orientations.tsv" if normalize else [], + synteny_tsv = rules.sort_blocks.output.sorted_blocks, + chrom_lengths = rules.chrom_sorting.output.sorted_seqs, + output: + markdown = f"{prefix}_ribbon-plot_tree_LLM-info.md" + params: + normalize_opt = lambda wc, input: ( + f"--normalize {input.chrom_oris}" if normalize else "" + ) + shell: + """ + ntsynt_viz_generate-llm-markdown.py -o {output.markdown} --image {input.ribbon_png} --lengths {input.chrom_lengths} {params.normalize_opt} {input.synteny_tsv} + """ diff --git a/bin/ntsynt_viz_generate-llm-markdown.py b/bin/ntsynt_viz_generate-llm-markdown.py new file mode 100755 index 0000000..3ae8a56 --- /dev/null +++ b/bin/ntsynt_viz_generate-llm-markdown.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +""" +Generate a markdown "context" file summarizing an ntSynt ribbon plot, +intended to be handed to an LLM (of the user's choice) to produce a +short manuscript-ready summary. Does NOT call an LLM itself. +""" +import argparse +import csv +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path + +LLM_INSTRUCTIONS = """ +## Instructions +#### Output format +The tables below describe a multi-genome synteny (ribbon plot) comparison, +for use in a scientific manuscript. Using ONLY the data provided, write +2-3 sentences in clear, formal, manuscript-appropriate language summarizing +overall structural trends: whether the genomes are largely collinear/ +syntenic or show substantial structural rearrangement; any notable +large-scale fusions/fissions and which genomes are involved; and any +standout individual inversions or genomes with a disproportionate share +of inversion relative to the others. + +#### Fusion / fission description rules +When describing a specific fusion or fission, always name the chromosome +accession(s) involved for both genomes (e.g., \"A. example1 XXX.1 corresponds +to YYY.1 and ZZZ.1 in A. example2\"), not just the genomes. +When a chromosome in one genome corresponds to two chromosomes in all other +genomes, describe it as a single event in the odd-one-out genome (fusion or +fission, whichever direction is more parsimonious) rather than as independent +events in each of the other genomes. + +Chromosome correspondence table: use it only to identify fusions/fissions. +Do not report the % syntenic length values directly. Only pairs contributing a non-trivial share of a chromosome's +syntenic content are informative for calling a fusion/fission -- the +% column distinguishes small/stray correspondences from real ones. + +General: do not restate raw numbers verbatim from any table -- synthesize a +trend instead, and do not enumerate every row. + +#### Companion image usage +If a companion image of the ribbon plot is provided and you are able to view +it, use it only for overall visual impression (e.g. density, general +complexity) and to confirm whether specific table-derived events are +discernible at the plot's scale. Do not estimate numbers from the image -- +rely on the tables for any numeric, comparative, or quantitative claims. + +Before describing any table-derived event (fusion, fission, inversion) as +notable or visually prominent, check the image to confirm it is large enough +to be apparent at the plot's scale. If a real event is too small to be +visible, still report it but explicitly note it is small-scale rather +than calling it visually obvious. Conversely, if something is visually +striking in the image, confirm with the tables which chromosome accession is +responsible before naming it. + +When confirming that a table-derived event is visible in the image, state +only whether it is discernible at the plot's scale. Acceptable: \"consistent +with the [colour] segment visible on chromosome X.\" Do not characterize how +visually prominent, striking, or subtle it appears -- see the general +language rules below. + +#### Multi-genome framing +Remember that the comparisons are multi-genome, meaning that every synteny +block contains coordinates from each genome -- there is no concept of +separate pairwise comparisons when interpreting the plot. + +#### Phylogenetic tree rules +If a phylogenetic tree is provided, derive all statements about which +genomes are \"closely related,\" \"sister taxa,\" etc. strictly from the tree +topology -- never from the structural consistency or correspondence tables. +Use the tables only to say whether structural patterns are consistent or +inconsistent with the tree relationships, not to define them. + +#### General language rules +Avoid non-scientific, unfalsifiable, or aesthetic language throughout, +including superlatives (exceptional, amazing, remarkable) and subjective +visual descriptors (modest, striking, subtle, understated). + +#### Disclaimer +Always print a separate disclaimer that a human expert should review the +summary before including it in a presentation or publication. +""" + +SYNTENY_COLS = [ + "block_id", "genome", "chrom", "start", "end", + "strand", "num_minimizers", "reason", +] + +@dataclass +class SyntenyRow: + block_id: int + genome: str + chrom: str + start: int + end: int + strand: str + num_minimizers: int + reason: str + +def load_synteny_tsv(path): + """Read synteny TSV file""" + rows = [] + with open(path, newline="", encoding="utf-8") as f: + reader = csv.reader(f, delimiter="\t") + for fields in reader: + if not fields: + continue + row = SyntenyRow( + block_id=int(fields[0]), + genome=fields[1], + chrom=fields[2], + start=int(fields[3]), + end=int(fields[4]), + strand=fields[5], + num_minimizers=int(fields[6]), + reason=fields[7] + ) + rows.append(row) + return rows + + +def load_normalization_tsv(path): + """Load the information about chromosome normalization""" + with open(path, newline="", encoding="utf-8") as f: + lines = [line for line in f if not line.startswith("#")] + reader = csv.reader(lines, delimiter="\t") + next(reader) # header + rows = [] + for fields in reader: + if not fields: + continue + rows.append({ + "genome": fields[0], + "chromosome": fields[1], + "relative_orientation": fields[2], + }) + return rows + +def read_lengths_tsv(path): + """Read a TSV with headers bin_id,seq_id,length,relative_orientation and return a dict of lengths.""" + lengths = defaultdict(dict) + with open(path, newline="", encoding="utf-8") as f: + reader = csv.reader(f, delimiter="\t") + next(reader) # header + for fields in reader: + if not fields: + continue + bin_id = fields[0] + seq_id = fields[1] + length = int(fields[2]) + lengths[bin_id][seq_id] = length + return lengths + + +def get_genome_order(rows): + """Genome order is fixed and identical within every block; read it + off the first block.""" + first_block_id = rows[0].block_id + order = [] + for row in rows: + if row.block_id != first_block_id: + break + order.append(row.genome) + return order + + +def normalization_summary(norm_rows): + """Per-genome fraction of chromosomes reverse-complemented during + normalization.""" + counts = defaultdict(lambda: [0, 0]) # genome -> [flipped, total] + for row in norm_rows: + counts[row["genome"]][1] += 1 + if row["relative_orientation"] == "-": + counts[row["genome"]][0] += 1 + return {genome: tuple(v) for genome, v in counts.items()} + + +def chromosome_correspondence(rows, genome_order, chrom_lengths): + """For each genome pair and direction, syntenic bp and % of genome A + chromosome's total syntenic length, per (chrom_a, chrom_b).""" + results = [] + genomes = genome_order + + by_genome = defaultdict(dict) + for r in rows: + by_genome[r.genome][r.block_id] = r + + for ga in genomes: + for gb in genomes: + if ga == gb: + continue + a_rows = by_genome[ga] + b_rows = by_genome[gb] + + pair_totals = defaultdict(int) + for block_id, a_row in a_rows.items(): + b_row = b_rows.get(block_id) + if b_row is None: + continue + chrom_a = a_row.chrom + chrom_b = b_row.chrom + length_a = a_row.end - a_row.start + pair_totals[(chrom_a, chrom_b)] += length_a + + for (chrom_a, chrom_b), bp in pair_totals.items(): + pct = 100 * bp / chrom_lengths[ga][chrom_a] + results.append({ + "genome_a": ga, "chrom_a": chrom_a, + "genome_b": gb, "chrom_b": chrom_b, + "syntenic_bp": bp, "pct_of_chrom_a": round(pct, 1), + }) + return results + + +def inversion_summary(rows, genome_order, top_n=5): + """Per-genome proportion of syntenic length that is inverted relative + to the first genome in the provided order (strand '-'), plus the + top_n largest individually inverted blocks.""" + first_genome = genome_order[0] + + totals = defaultdict(int) + inverted_totals = defaultdict(int) + for r in rows: + totals[r.genome] += (r.end - r.start) + if r.strand == "-": + inverted_totals[r.genome] += (r.end - r.start) + + per_genome = [] + for genome in genome_order: + total_len = totals.get(genome, 0) + inv_len = inverted_totals.get(genome, 0) + pct = round(100 * inv_len / total_len, 1) if total_len > 0 else None + per_genome.append({ + "genome": genome, + "inverted_length": inv_len, + "pct_inverted": pct, + }) + + inverted_blocks = [ + r for r in rows if r.strand == "-" and r.genome != first_genome + ] + inverted_blocks.sort(key=lambda r: (r.end - r.start), reverse=True) + top = inverted_blocks[:top_n] + + return per_genome, top, first_genome + + +def build_header(): + """Build the markdown header""" + return [ + "# Synteny Ribbon Plot Summary Context", + "", + "", + "", + ] + + +def build_about_section(first_genome): + """Build the about information section""" + about_text = f""" +## About this plot +This data describes a multi-genome synteny comparison. A **synteny +block** is a genomic region conserved between genomes; an **inversion** +is a block in reversed orientation relative to {first_genome}, the +first-listed genome (chosen only for its position in the input order, +not as a biological reference); a **fusion/fission** is a case where +regions from one chromosome in one genome correspond to multiple +chromosomes in another. + +If a companion image is provided: each chromosome in the top genome is assigned a distinct +color; ribbon width corresponds to syntenic block length; a +twisted/crossed ribbon indicates an inverted block; genomes are +arranged in the order listed below. +Optionally, a phylogenetic tree is rendered to the left of the ribbon plot. +Arrows under chromosomes indicate reverse complementation during normalization (also optional). + """ + return [about_text] + +def build_genome_table(genome_order, norm_summary): + """Build genome table for markdown""" + lines = [ + "## Genomes Compared", + "", + "Listed in the order shown in the ribbon plot.", + "", + "| Order | Genome | Normalization (optional) |", + "|---|---|---|", + ] + + for i, genome in enumerate(genome_order, 1): + if genome not in norm_summary: + note = "—" + else: + flipped, total = norm_summary[genome] + note = ( + "—" + if flipped == 0 + else f"{flipped}/{total} chromosomes reverse-complemented during normalization" + ) + + lines.append(f"| {i} | {genome} | {note} |") + + lines.append("") + return lines + +def build_chromosome_table(chrom_results): + """Build chromosome correspondence table for markdown""" + lines = [ + "## Chromosome Correspondence (fusions / fissions)", + "", + "Only pairs contributing a non-trivial share:", + "", + "| Genome A | Chr A | Genome B | Chr B | Syntenic bp | % of Chr A Syntenic Length |", + "|---|---|---|---|---|---|", + ] + + for row in chrom_results: + lines.append( + f"| {row['genome_a']} | " + f"{row['chrom_a']} | " + f"{row['genome_b']} | " + f"{row['chrom_b']} | " + f"{row['syntenic_bp']} | " + f"{row['pct_of_chrom_a']}% |" + ) + + lines.append("") + return lines + +def build_inversion_table(inv_per_genome, inv_top, first_genome): + """Build table summarizing inversions""" + lines = [ + "## Inversion Summary", + "", + f"Relative to {first_genome} (first-listed genome; see note above).", + "", + "**Per-genome inverted proportion:**\n", + "| Genome | Inverted Length | % of Syntenic Length Inverted |", + "|---|---|---|" + ] + for row in inv_per_genome: + pct = "N/A" if row["pct_inverted"] is None else f"{row['pct_inverted']}%" + lines.append(f"| {row['genome']} | {row['inverted_length']} | {pct} |") + + lines.extend( + ["", + f"**Largest individual inversions (top {len(inv_top)}):**\n", + "| Genome | Chromosome | Location | Length |", + "|---|---|---|---|", + ] + ) + for row in inv_top: + lines.append(f"| {row.genome} | {row.chrom} | {row.start}-{row.end} | {row.end - row.start} |") + lines.append("") + + return lines + +def build_companion_image(image_path): + """Build companion image section""" + lines = [ + "## Companion Image (optional)", + "", + ] + + if image_path: + lines.append(f"`{image_path}`") + else: + lines.append("Not provided.") + + lines.extend([ + "", + """ +If available, provide this alongside the markdown file to a +vision-capable LLM for an additional, purely qualitative visual +impression -- it is not required to produce a valid summary. + """, + "", + ]) + + return lines + + +def build_markdown(genome_order, norm_summary, chrom_results, inv_per_genome, + inv_top, first_genome, image_path=None,): + """Build the markdown file""" + sections = [ + build_header(), + [LLM_INSTRUCTIONS], + build_about_section(first_genome), + build_genome_table(genome_order, norm_summary), + build_chromosome_table(chrom_results), + build_inversion_table(inv_per_genome, inv_top, first_genome), + build_companion_image(image_path), + ] + + lines = [] + + for section in sections: + lines.extend(section) + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("synteny_tsv") + parser.add_argument("--normalize", help="Path to normalization TSV (optional; only needed if chromosomes were reverse-complemented during normalization)") + parser.add_argument("-o", "--output", default="ribbon_plot_summary_context.md") + parser.add_argument("--image", default=None, help="Path to companion PNG/PDF") + parser.add_argument("--top-n", type=int, default=5) + parser.add_argument("--lengths", type=str, required=True, help="Path to TSV with headers bin_id,seq_id,length,relative_orientation") + args = parser.parse_args() + + rows = load_synteny_tsv(args.synteny_tsv) + norm_rows = [] + if args.normalize: + norm_rows = load_normalization_tsv(args.normalize) + genome_order = get_genome_order(rows) + norm_summary = normalization_summary(norm_rows) + chrom_lengths = read_lengths_tsv(args.lengths) + + chrom_results = chromosome_correspondence(rows, genome_order, chrom_lengths) + inv_per_genome, inv_top, first_genome = inversion_summary(rows, genome_order, top_n=args.top_n) + + md = build_markdown(genome_order, norm_summary, chrom_results, + inv_per_genome, inv_top, first_genome, image_path=args.image) + + Path(args.output).write_text(md, encoding="utf-8") + print(f"Wrote {args.output}") + + +if __name__ == "__main__": + main() diff --git a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R index d52f271..beddb25 100755 --- a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R +++ b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R @@ -453,13 +453,13 @@ if (args$format == "pdf") { ggsave(paste0(args$prefix, ".svg"), plots, units = "cm", width = args$width, height = args$height, bg = "white") cat(paste("Plot saved:", paste0(args$prefix, ".svg"), "\n")) -} else { - png(paste0(args$prefix, ".png"), units = "cm", width = args$width, height = args$height, - res = args$dpi, bg = "white") - print(plots) - dev.off() - cat(paste("Plot saved:", paste0(args$prefix, ".png"), "\n")) -} +} +png(paste0(args$prefix, ".png"), units = "cm", width = args$width, height = args$height, + res = args$dpi, bg = "white") +print(plots) +dev.off() +cat(paste("Plot saved:", paste0(args$prefix, ".png"), "\n")) + # Prepare interactive HTML From d005aa24d05ab6e09a7fe38851fa7637bdf0eb70 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 12:27:03 -0700 Subject: [PATCH 15/26] Fixes for pylint --- bin/ntsynt_viz_generate-llm-markdown.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bin/ntsynt_viz_generate-llm-markdown.py b/bin/ntsynt_viz_generate-llm-markdown.py index 3ae8a56..904de52 100755 --- a/bin/ntsynt_viz_generate-llm-markdown.py +++ b/bin/ntsynt_viz_generate-llm-markdown.py @@ -89,6 +89,7 @@ @dataclass class SyntenyRow: + """Describes a row in the synteny TSV file""" block_id: int genome: str chrom: str @@ -245,7 +246,7 @@ def inversion_summary(rows, genome_order, top_n=5): return per_genome, top, first_genome - + def build_header(): """Build the markdown header""" return [ @@ -347,7 +348,7 @@ def build_inversion_table(inv_per_genome, inv_top, first_genome): for row in inv_per_genome: pct = "N/A" if row["pct_inverted"] is None else f"{row['pct_inverted']}%" lines.append(f"| {row['genome']} | {row['inverted_length']} | {pct} |") - + lines.extend( ["", f"**Largest individual inversions (top {len(inv_top)}):**\n", @@ -408,13 +409,17 @@ def build_markdown(genome_order, norm_summary, chrom_results, inv_per_genome, def main(): + """Generate a LLM markdown file for the ntSynt-viz ribbon plot""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("synteny_tsv") - parser.add_argument("--normalize", help="Path to normalization TSV (optional; only needed if chromosomes were reverse-complemented during normalization)") + parser.add_argument("--normalize", + help="Path to normalization TSV (optional; " + "only needed if chromosomes were reverse-complemented during normalization)") parser.add_argument("-o", "--output", default="ribbon_plot_summary_context.md") parser.add_argument("--image", default=None, help="Path to companion PNG/PDF") parser.add_argument("--top-n", type=int, default=5) - parser.add_argument("--lengths", type=str, required=True, help="Path to TSV with headers bin_id,seq_id,length,relative_orientation") + parser.add_argument("--lengths", type=str, required=True, + help="Path to TSV with headers bin_id,seq_id,length,relative_orientation") args = parser.parse_args() rows = load_synteny_tsv(args.synteny_tsv) From 5191b054501b293d1d18a183b92b2d695839296e Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 12:36:37 -0700 Subject: [PATCH 16/26] Fixes for pylint --- bin/ntsynt_viz.smk | 4 ++-- ...te-llm-markdown.py => ntsynt_viz_generate_llm_markdown.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename bin/{ntsynt_viz_generate-llm-markdown.py => ntsynt_viz_generate_llm_markdown.py} (99%) diff --git a/bin/ntsynt_viz.smk b/bin/ntsynt_viz.smk index 8b602e4..148d669 100644 --- a/bin/ntsynt_viz.smk +++ b/bin/ntsynt_viz.smk @@ -338,7 +338,7 @@ rule llm_instructions: ) shell: """ - ntsynt_viz_generate-llm-markdown.py -o {output.markdown} --image {input.ribbon_png} --lengths {input.chrom_lengths} {params.normalize_opt} {input.synteny_tsv} + ntsynt_viz_generate_llm_markdown.py -o {output.markdown} --image {input.ribbon_png} --lengths {input.chrom_lengths} {params.normalize_opt} {input.synteny_tsv} """ rule llm_instructions_tree: @@ -355,5 +355,5 @@ rule llm_instructions_tree: ) shell: """ - ntsynt_viz_generate-llm-markdown.py -o {output.markdown} --image {input.ribbon_png} --lengths {input.chrom_lengths} {params.normalize_opt} {input.synteny_tsv} + ntsynt_viz_generate_llm_markdown.py -o {output.markdown} --image {input.ribbon_png} --lengths {input.chrom_lengths} {params.normalize_opt} {input.synteny_tsv} """ diff --git a/bin/ntsynt_viz_generate-llm-markdown.py b/bin/ntsynt_viz_generate_llm_markdown.py similarity index 99% rename from bin/ntsynt_viz_generate-llm-markdown.py rename to bin/ntsynt_viz_generate_llm_markdown.py index 904de52..e2f9199 100755 --- a/bin/ntsynt_viz_generate-llm-markdown.py +++ b/bin/ntsynt_viz_generate_llm_markdown.py @@ -412,7 +412,7 @@ def main(): """Generate a LLM markdown file for the ntSynt-viz ribbon plot""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("synteny_tsv") - parser.add_argument("--normalize", + parser.add_argument("--normalize", help="Path to normalization TSV (optional; " "only needed if chromosomes were reverse-complemented during normalization)") parser.add_argument("-o", "--output", default="ribbon_plot_summary_context.md") From 2952e315922660a8b23124a6a74cbede394d40d0 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 13:34:35 -0700 Subject: [PATCH 17/26] Optimizations for ntsynt_viz_ribbon-interactive.js --- bin/ntsynt_viz_ribbon-interactive.js | 139 +++++++++++++++++++-------- 1 file changed, 98 insertions(+), 41 deletions(-) diff --git a/bin/ntsynt_viz_ribbon-interactive.js b/bin/ntsynt_viz_ribbon-interactive.js index cee6cea..ff2617a 100644 --- a/bin/ntsynt_viz_ribbon-interactive.js +++ b/bin/ntsynt_viz_ribbon-interactive.js @@ -27,6 +27,37 @@ document.addEventListener("DOMContentLoaded", function() { poly.style.pointerEvents = "none"; }); + // --------------------------------------------------------------- + // PERF: cache static geometry once instead of re-querying/ + // re-measuring the DOM on every mousemove. None of these elements + // move after render (aside from resize/scroll), so we build the + // lookup tables up front and reuse them. + // --------------------------------------------------------------- + const ribbonPolys = Array.from(svg.querySelectorAll("polygon[data-id]")); + // Local-space bbox per polygon, used as a cheap pre-filter before + // the expensive isPointInFill hit test. + ribbonPolys.forEach(function(poly) { + try { poly._bbox = poly.getBBox(); } catch (err) { poly._bbox = null; } + }); + + let chromSegs = []; // { el, rect } - rect refreshed on resize/scroll + function refreshChromSegRects() { + const segEls = svg.querySelectorAll("line[data-id], [data-id].chromosome"); + chromSegs = Array.from(segEls).map(function(el) { + return { el: el, rect: el.getBoundingClientRect() }; + }); + } + refreshChromSegRects(); + + // Debounced re-measure on resize/scroll, since layout can shift then. + let resizeTimer = null; + function scheduleRectRefresh() { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(refreshChromSegRects, 150); + } + window.addEventListener("resize", scheduleRectRefresh, true); + window.addEventListener("scroll", scheduleRectRefresh, true); + // --- Manual ribbon tooltip --- // We need our own tooltip div since we bypassed ggiraph for ribbons let pinnedRibbonId = null; @@ -76,58 +107,60 @@ document.addEventListener("DOMContentLoaded", function() { }); // Check if cursor is near any chromosome segment (invisible hit area) - // Returns true if within CHROM_PRIORITY_PX pixels of a segment element + // Returns the matched element, or null. + // PERF: uses the cached chromSegs array (no querySelectorAll or + // getBoundingClientRect on every call) instead of two separate + // near-duplicate functions. const CHROM_PRIORITY_PX = 5; - function isNearChromosome(e) { - // ggiraph chromosome segments are elements with data-id - const segs = svg.querySelectorAll("line[data-id], [data-id].chromosome"); - for (let seg of segs) { - const bbox = seg.getBoundingClientRect(); - // Expand bbox by priority zone - if ( - e.clientX >= bbox.left - CHROM_PRIORITY_PX && - e.clientX <= bbox.right + CHROM_PRIORITY_PX && - e.clientY >= bbox.top - CHROM_PRIORITY_PX && - e.clientY <= bbox.bottom + CHROM_PRIORITY_PX - ) { - return true; - } - } - return false; - } - function getChromosomeUnderCursor(e) { - const segs = svg.querySelectorAll("line[data-id], [data-id].chromosome"); - for (let seg of segs) { - const bbox = seg.getBoundingClientRect(); + for (let i = 0; i < chromSegs.length; i++) { + const b = chromSegs[i].rect; if ( - e.clientX >= bbox.left - CHROM_PRIORITY_PX && - e.clientX <= bbox.right + CHROM_PRIORITY_PX && - e.clientY >= bbox.top - CHROM_PRIORITY_PX && - e.clientY <= bbox.bottom + CHROM_PRIORITY_PX + e.clientX >= b.left - CHROM_PRIORITY_PX && + e.clientX <= b.right + CHROM_PRIORITY_PX && + e.clientY >= b.top - CHROM_PRIORITY_PX && + e.clientY <= b.bottom + CHROM_PRIORITY_PX ) { - return seg; + return chromSegs[i].el; } } return null; } + function isNearChromosome(e) { + return getChromosomeUnderCursor(e) !== null; + } - // Find which ribbon polygon (if any) the cursor is geometrically inside + // Find which ribbon polygon (if any) the cursor is geometrically inside. + // PERF: the screen->SVG matrix is identical for every polygon in this + // plot (they share the SVG's coordinate space), so it's computed once + // per call instead of once per polygon. A cheap local-space bbox check + // skips isPointInFill for the vast majority of polygons. function getRibbonUnderCursor(e) { - const polys = svg.querySelectorAll("polygon[data-id]"); - for (let poly of polys) { - // Use SVG geometry: check if point is inside polygon + const ctm = svg.getScreenCTM(); + if (ctm) { + const inv = ctm.inverse(); const svgPt = svg.createSVGPoint(); svgPt.x = e.clientX; svgPt.y = e.clientY; - try { - const localPt = svgPt.matrixTransform(poly.getScreenCTM().inverse()); - if (poly.isPointInFill ? poly.isPointInFill(localPt) : false) { - return poly; + const localPt = svgPt.matrixTransform(inv); + + for (let i = 0; i < ribbonPolys.length; i++) { + const poly = ribbonPolys[i]; + const b = poly._bbox; + if (b) { + if ( + localPt.x < b.x || localPt.x > b.x + b.width || + localPt.y < b.y || localPt.y > b.y + b.height + ) { + continue; // cheap rejection, skip the expensive hit test + } } - } catch(err) { /* skip */ } + try { + if (poly.isPointInFill && poly.isPointInFill(localPt)) return poly; + } catch (err) { /* skip */ } + } } // Fallback: elementsFromPoint but skip if near a chromosome if (!isNearChromosome(e)) { @@ -141,7 +174,16 @@ document.addEventListener("DOMContentLoaded", function() { return null; } -container.addEventListener("mousemove", function(e) { + // --------------------------------------------------------------- + // PERF: throttle mousemove handling to once per animation frame. + // Native mousemove can fire far more often than the screen repaints; + // without this, the full hit-testing pipeline below runs many times + // per rendered frame for no visible benefit. + // --------------------------------------------------------------- + let pendingMoveEvent = null; + let moveRafScheduled = false; + + function onMouseMove(e) { if (pinnedRibbonId) return; // frozen while pinned if (isNearChromosome(e)) { @@ -155,7 +197,7 @@ container.addEventListener("mousemove", function(e) { const hoveredId = poly.getAttribute("data-id"); // Highlight matching ribbons, dim others - svg.querySelectorAll("polygon[data-id]").forEach(function(p) { + ribbonPolys.forEach(function(p) { if (p.getAttribute("data-id") === hoveredId) { p.style.stroke = "black"; p.style.strokeWidth = "1"; @@ -191,7 +233,20 @@ container.addEventListener("mousemove", function(e) { ribbonTip.style.display = "none"; applyLegendState(); } - }, true); + } + + function scheduleMouseMove(e) { + pendingMoveEvent = e; + if (!moveRafScheduled) { + moveRafScheduled = true; + requestAnimationFrame(function() { + moveRafScheduled = false; + onMouseMove(pendingMoveEvent); + }); + } + } + + container.addEventListener("mousemove", scheduleMouseMove, true); container.addEventListener("mouseleave", function() { if (pinnedRibbonId) return; @@ -208,6 +263,8 @@ container.addEventListener("mousemove", function(e) { const activeChromosomes = new Set(); + // PERF: iterates the cached ribbonPolys array instead of re-querying + // the DOM for polygons on every call. function applyLegendState(omit_poly_id = null) { const activeBlockIds = new Set(); @@ -217,7 +274,7 @@ container.addEventListener("mousemove", function(e) { }); }); - svg.querySelectorAll("polygon[data-id]").forEach(function(poly) { + ribbonPolys.forEach(function(poly) { const bid = poly.getAttribute("data-id"); if (omit_poly_id && omit_poly_id == bid) { @@ -270,7 +327,7 @@ container.addEventListener("mousemove", function(e) { if (r.bottom > window.innerHeight) ribbonTip.style.top = (e.clientY - r.height - 14) + "px"; if (el.tagName && el.tagName.toLowerCase() === "polygon") { - svg.querySelectorAll("polygon[data-id]").forEach(function(p) { + ribbonPolys.forEach(function(p) { if (p.getAttribute("data-id") === pinnedRibbonId) { p.style.stroke = "black"; p.style.strokeWidth = "1"; From 1487b373c81a7ac74f5d6a74af9270d3ddaeb948 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 13:35:36 -0700 Subject: [PATCH 18/26] Update README.md --- README.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0661cc7..2939772 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,22 @@ 4. [Installation](#install) 5. [Usage](#usage) 6. [Examples](#example) -7. [Citing](#citing) -8. [License](#license) +7. [Interactive plots](#interactive-plots) +8. [Citing](#citing) +9. [License](#license) ## Credit Written by Lauren Coombe ## Description -ntSynt-viz is an easy-to-use framework for generating ribbon plots combined with chromosome painting to visualize multi-genome synteny blocks. The tool is set-up to accept synteny blocks formatted in the [ntSynt](https://github.com/BirolLab/ntSynt) style, but any multi-genome synteny block file that adheres to the simple, BED-like TSV format of ntSynt can be visualized using ntSynt-viz. +ntSynt-viz is an easy-to-use framework for generating static and interactive ribbon plots combined with chromosome painting to visualize multi-genome synteny blocks. The tool is set-up to accept synteny blocks formatted in the [ntSynt](https://github.com/BirolLab/ntSynt) style, but any multi-genome synteny block file that adheres to the simple, BED-like TSV format of ntSynt can be visualized using ntSynt-viz. This flexible framework implements numerous features, including: * Option to normalize the strands of input chromosomes based on a target assembly * Synteny-guided ordering of assemblies from top-to-bottom, based on an input tree structure or distance estimates from the synteny blocks * Sorting chromosomes right-to-left based on synteny to adjacent assemblies * Colouring both the ribbons and chromosomes based on the target (top) assembly chromosomes +* Static and interactive ribbon plot outputs These features ensure that the output ribbon plots (powered by [gggenomes](https://thackl.github.io/gggenomes/)) are as easily understandable and as information-rich as possible. @@ -68,8 +70,8 @@ export PATH=/path/to/ntsynt-viz/github/ntSynt-viz/bin:$PATH ``` usage: ntsynt_viz.py [-h] --blocks BLOCKS --fais FAIS [FAIS ...] [--name_conversion NAME_CONVERSION] [--tree TREE] [--target-genome TARGET_GENOME] [--normalize] [--indel INDEL] [--length LENGTH] [--seq_length SEQ_LENGTH] [--keep KEEP [KEEP ...]] [--centromeres CENTROMERES] [--haplotypes HAPLOTYPES] - [--order ORDER] [--prefix PREFIX] [--format {png,pdf,svg}] [--scale SCALE] [--height HEIGHT] [--width WIDTH] [--dpi DPI] [--annotate-genome-info] - [--no-arrow] [--ribbon_adjust RIBBON_ADJUST] [-f] [-n] [-v] + [--order ORDER] [--prefix PREFIX] [--format {png,pdf,svg}] [--scale SCALE] [--height HEIGHT] [--width WIDTH] [--dpi DPI] + [--annotate-genome-info] [--optimize-ordering] [--no-arrow] [--ribbon_adjust RIBBON_ADJUST] [-f] [-n] [-v] Visualizing multi-genome synteny @@ -100,7 +102,6 @@ main plot formatting arguments: File listing haplotype assembly names: TSV, maternal/paternal assembly file names separated by tabs. --order ORDER Optional file specifying the order of genomes in the ribbon plot. If supplied, will override synteny distance-based ordering. If --tree supplied, the ordering must be compatible with the phylogenetic tree. - --no-arrow Only used with --normalize; do not draw arrows indicating reverse-complementation block filtering arguments: --indel INDEL Indel size threshold (used in computing synteny-based distances) [50000] @@ -120,6 +121,8 @@ output arguments: --dpi DPI Resolution of output plot - png output only [300] --annotate-genome-info Add annotations about number of sequences and genome size to the right of each genome in the ribbon plot + --optimize-ordering Optimize tree-guided genome sorting using inversions. Only use with strictly bifurcating trees. + --no-arrow Only used with --normalize; do not draw arrows indicating reverse-complementation --ribbon_adjust RIBBON_ADJUST Ratio for adjusting spacing beside ribbon plot. Increase if ribbon plot labels are cut off, and decrease to reduce the white space to the left of the ribbon plot [0.1] @@ -149,6 +152,16 @@ For more information about the output files from ntSynt-viz, check out our [wiki ## Using ntSynt-viz with pangenome graphs or synteny blocks from tools other than ntSynt To visualize synteny information in pangenome graphs or from synteny block detection tools other than ntSynt, the synteny blocks simply need to be converted to the straightforward [ntSynt format](https://github.com/BirolLab/ntsynt?tab=readme-ov-file#output-files). For convenience, we also provide some scripts to do this conversion in the `conversion_scripts` directory. +## Interactive plots +As of v1.1.0, ntSynt-viz will output an HTML file to allow interactive exploration of the ribbon plot. + +Interactive elements include: +- Hover/click on a ribbon to highlight the entire block and display more information +- Hover/click on a chromosome to display more information +- Click on a chromosome in the legend to highlight correseponding synteny blocks + +Example ribbon plot: XXX + ## Citing Thank you for your [![Stars](https://img.shields.io/github/stars/BirolLab/ntSynt-viz.svg)](https://github.com/BirolLab/ntSynt-viz/stargazers) and for using and promoting this free software! We hope that ntSynt-viz (& ntSynt) is useful to you and your research. From 01ff32eb76b7b6eba3a9827c2e33ecc91417a098 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 13:46:44 -0700 Subject: [PATCH 19/26] Publish second HTML in CI --- azure-pipelines.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 9195b7f..6b29bb4 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -46,7 +46,7 @@ jobs: source activate ntsynt_viz_ci export PATH=$(pwd)/bin:$PATH cd tests - ntsynt_viz.py --blocks great-apes.ntSynt.synteny_blocks.tsv --fais fais.tsv --tree great-apes.mt-tree.nwk --name_conversion great-apes.name-conversions.tsv --normalize --prefix great-apes_ribbon-plots --ribbon_adjust 0.14 --scale 1e9 + ntsynt_viz.py --blocks great-apes.ntSynt.synteny_blocks.tsv --fais fais.tsv --tree great-apes.mt-tree.nwk --name_conversion great-apes.name-conversions.tsv --normalize --prefix great-apes_ribbon-plots --ribbon_adjust 0.14 --scale 1e9 --target-genome Homo_sapiens displayName: Run Example 1 test (PNG output) - script: | @@ -76,6 +76,9 @@ jobs: - publish: tests/great-apes_ribbon-plots_ribbon-plot_tree.png artifact: Example1 + - publish: tests/great-apes_ribbon-plots_ribbon-plot_tree.html + artifact: Example1_HTML + - publish: tests/great-apes_ribbon-plots_no-tree_ribbon-plot.pdf artifact: Example2 From 76f91b4cdf49a4a01184e023c4252c06ca3259d5 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 14:17:11 -0700 Subject: [PATCH 20/26] Bugfix for legend clicks - bug in ntsynt_viz_plot_synteny_blocks_ribbon_plot.R could lead to too many blocks lighting up upon legend clicks --- bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R index beddb25..a21710b 100755 --- a/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R +++ b/bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R @@ -84,6 +84,8 @@ links_ntsynt <- read.csv(args$links, sep = "\t", header = TRUE) %>% mutate(bin_id = str_replace_all(bin_id, "_", " "), bin_id2 = str_replace_all(bin_id2, "_", " ")) +target_genome <- links_ntsynt %>% head(1) %>% select(bin_id) %>% pull() +print(paste("Target genome:", target_genome)) links_ntsynt$seq_id <- factor(links_ntsynt$seq_id, levels = input_chrom_order) links_ntsynt <- links_ntsynt %>% arrange(factor(seq_id, levels = input_chrom_order)) @@ -227,8 +229,9 @@ get_link_info <- function(p, block_coords) { } # Build chromosome -> block_id mapping (target genome seq_id only, as these appear in legend) -build_js_map <- function(p) { +build_js_map <- function(p, target_genome) { chrom_block_map <- pull_links(p) %>% + filter(bin_id == target_genome) %>% select(block_id, seq_id) %>% distinct() %>% group_by(seq_id) %>% @@ -375,7 +378,7 @@ make_plot <- function(links, sequences, painting, colours_df, add_scale_bar = FA ) # Build map of chromosome -> block_id for interactive legend - js_map <- build_js_map(p) + js_map <- build_js_map(p, target_genome) return(list(plot = plot, js_map = js_map)) } From 07d4241bc62d858ebff3b58a6571196b7904b213 Mon Sep 17 00:00:00 2001 From: lcoombe Date: Tue, 28 Jul 2026 15:06:45 -0700 Subject: [PATCH 21/26] Documentation tweaks --- README.md | 8 ++++---- bin/ntsynt_viz_generate_llm_markdown.py | 2 +- bin/ntsynt_viz_optimize_tree_topology.py | 1 - bin/ntsynt_viz_plot_synteny_blocks_ribbon_plot.R | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2939772..2c73097 100644 --- a/README.md +++ b/README.md @@ -153,12 +153,12 @@ For more information about the output files from ntSynt-viz, check out our [wiki To visualize synteny information in pangenome graphs or from synteny block detection tools other than ntSynt, the synteny blocks simply need to be converted to the straightforward [ntSynt format](https://github.com/BirolLab/ntsynt?tab=readme-ov-file#output-files). For convenience, we also provide some scripts to do this conversion in the `conversion_scripts` directory. ## Interactive plots -As of v1.1.0, ntSynt-viz will output an HTML file to allow interactive exploration of the ribbon plot. +As of v1.1.0, ntSynt-viz generates an HTML file to allow interactive exploration of the ribbon plot. Interactive elements include: -- Hover/click on a ribbon to highlight the entire block and display more information -- Hover/click on a chromosome to display more information -- Click on a chromosome in the legend to highlight correseponding synteny blocks +* Hover/click on a ribbon to highlight the entire synteny block and display block coordinates and orientations +* Hover/click on a chromosome to display ID and length +* Click on one or more chromosomes in the legend to highlight the correseponding synteny blocks Example ribbon plot: XXX diff --git a/bin/ntsynt_viz_generate_llm_markdown.py b/bin/ntsynt_viz_generate_llm_markdown.py index e2f9199..59e189d 100755 --- a/bin/ntsynt_viz_generate_llm_markdown.py +++ b/bin/ntsynt_viz_generate_llm_markdown.py @@ -253,7 +253,7 @@ def build_header(): "# Synteny Ribbon Plot Summary Context", "", "