library(shiny) library(openxlsx) library(igraph) library(DT) library(ggplot2) library(dplyr) library(RColorBrewer) library(ggrepel) library(scales) # ═══════════════════════════════════════════════════════════════════════════════ # DATA LOADING & AUTO-CONVERSION # ═══════════════════════════════════════════════════════════════════════════════ load_and_convert_data <- function(filepath) { wb_raw <- tryCatch(loadWorkbook(filepath), error = function(e) stop("Cannot open workbook: ", e$message)) df_raw <- read.xlsx(filepath, sheet = 1, colNames = TRUE) if (!("Chromosome" %in% colnames(df_raw))) stop("Column 'Chromosome' not found.") if (!("Marker" %in% colnames(df_raw))) stop("Column 'Marker' not found.") chr_col <- as.character(df_raw$Chromosome) has_formula <- any(grepl("^=", chr_col, ignore.case = FALSE), na.rm = TRUE) if (has_formula) { inferred <- infer_chromosome_from_marker(df_raw$Marker) n_inferred <- sum(!is.na(inferred)) if (n_inferred / nrow(df_raw) >= 0.5) { df_raw$Chromosome <- inferred } else { df_raw$Chromosome <- seq_len(nrow(df_raw)) warning("Could not infer chromosomes from marker names.") } } if (ncol(df_raw) < 3) { df_raw$.input_order <- seq_len(nrow(df_raw)) return(df_raw) } geno_cols <- df_raw[, 3:ncol(df_raw), drop = FALSE] sample_vals <- unique(unlist(geno_cols[1:min(50, nrow(geno_cols)), ])) sample_vals <- sample_vals[!is.na(sample_vals)] numeric_encoding <- length(sample_vals) > 0 && all(sample_vals %in% c(-1, 0, 1, 2, NA)) text_encoding <- length(sample_vals) > 0 && all(sample_vals %in% c("a", "b", "ab", "-", NA, "NA")) if (numeric_encoding) { df_raw[, 3:ncol(df_raw)] <- lapply( df_raw[, 3:ncol(df_raw), drop = FALSE], function(col) { col <- as.character(col) col[col == "2"] <- "a" col[col == "1"] <- "b" col[col == "0"] <- "ab" col[col == "-1"] <- "-" col[is.na(col) | col == "NA"] <- "-" col }) } else if (!text_encoding) { df_raw[, 3:ncol(df_raw)] <- lapply( df_raw[, 3:ncol(df_raw), drop = FALSE], function(col) { col <- as.character(col) col[!(col %in% c("a", "b", "ab"))] <- "-" col }) } df_raw$Chromosome <- as.character(df_raw$Chromosome) df_raw$Chromosome[is.na(df_raw$Chromosome) | df_raw$Chromosome == "NA"] <- "Unknown" df_raw$.input_order <- seq_len(nrow(df_raw)) df_raw } infer_chromosome_from_marker <- function(marker_names) { markers <- as.character(marker_names) chrom <- rep(NA_character_, length(markers)) patterns <- list( list(regex = "(?i)chr(\\d{1,2})[_\\-]", group = 1), list(regex = "(?i)[_\\-]C(\\d{1,2})[_\\-]", group = 1), list(regex = "(?i)^MSU\\d+_(\\d{1,2})_", group = 1), list(regex = "(?i)^DTY(\\d{1,2})[\\-_]", group = 1), list(regex = "(?i)^[A-Za-z]{1,6}(\\d{1,2})[a-zA-Z_]", group = 1), list(regex = "(?i)^Saltol", group = NA), list(regex = "(?i)^SCT(\\d{1,2})[_\\-]", group = 1), list(regex = "(?i)^R(\\d{1,2})", group = 1), list(regex = "(?i)^Pi(\\d{1,2})", group = 1) ) for (i in seq_along(markers)) { m <- markers[i] for (pat in patterns) { if (is.na(pat$group)) { if (grepl(pat$regex, m, perl = TRUE)) { chrom[i] <- "1"; break } } else { mt <- regmatches(m, regexpr(pat$regex, m, perl = TRUE)) if (length(mt) > 0) { sub_mt <- regmatches(mt, regexec(pat$regex, mt, perl = TRUE))[[1]] if (length(sub_mt) >= pat$group + 1) { chr_num <- as.integer(sub_mt[pat$group + 1]) if (!is.na(chr_num) && chr_num >= 1 && chr_num <= 30) { chrom[i] <- as.character(chr_num); break } } } } } } chrom } # ═══════════════════════════════════════════════════════════════════════════════ # CORE GENETIC FUNCTIONS # ═══════════════════════════════════════════════════════════════════════════════ calculate_recombination <- function(population_type, genotype1, genotype2) { g1 <- as.character(genotype1) g2 <- as.character(genotype2) keep <- !(g1 %in% c("-", "NA", NA) | g2 %in% c("-", "NA", NA)) g1 <- g1[keep]; g2 <- g2[keep] n <- length(g1) if (n < 4) return(NA_real_) if (population_type == "RIL") { hom <- !(g1 == "ab" | g2 == "ab") g1h <- g1[hom]; g2h <- g2[hom] if (length(g1h) < 4) return(NA_real_) r_obs <- mean(g1h != g2h) rf <- r_obs / (2 * (1 - r_obs)) return(min(rf, 0.4999)) } else if (population_type == "F2") { AA_AA <- sum(g1=="a" & g2=="a"); AA_Aa <- sum(g1=="a" & g2=="ab") AA_BB <- sum(g1=="a" & g2=="b"); Aa_AA <- sum(g1=="ab" & g2=="a") Aa_Aa <- sum(g1=="ab" & g2=="ab"); Aa_BB <- sum(g1=="ab" & g2=="b") BB_AA <- sum(g1=="b" & g2=="a"); BB_Aa <- sum(g1=="b" & g2=="ab") BB_BB <- sum(g1=="b" & g2=="b") r <- 0.25 for (iter in 1:200) { r_old <- r; q <- 1 - r p_parental <- q^2 + r^2; p_recomb <- 2 * r * q norm <- p_parental + p_recomb n_recomb_AaAa <- if (norm > 0) Aa_Aa * p_recomb / norm else 0 recomb_count <- 2*(AA_BB+BB_AA) + (AA_Aa+Aa_AA+Aa_BB+BB_Aa) + 2*n_recomb_AaAa r_new <- max(1e-6, min(recomb_count / (2*n), 0.4999)) r <- r_new if (abs(r - r_old) < 1e-8) break } return(r) } else stop("population_type must be 'RIL' or 'F2'") } calculate_cM_kosambi <- function(r) { if (is.na(r)) return(NA_real_) r <- max(0, min(r, 0.4999)) if (r == 0) return(0) 25 * log((1 + 2*r) / (1 - 2*r)) } detect_monomorphic_markers <- function(df) { geno_cols_idx <- setdiff(3:ncol(df), which(colnames(df) == ".input_order")) if (length(geno_cols_idx) == 0) return(setNames(rep(FALSE, nrow(df)), df$Marker)) is_mono <- apply(df[, geno_cols_idx, drop = FALSE], 1, function(row) { vals <- row[!is.na(row) & !(row %in% c("-", "NA", ""))] if (length(vals) == 0) return(FALSE) unique_vals <- unique(vals[vals %in% c("a", "b", "ab")]) length(unique_vals) <= 1 }) names(is_mono) <- df$Marker is_mono } perform_chi_square_test <- function(df, population_type) { geno_cols_idx <- setdiff(3:ncol(df), which(colnames(df) == ".input_order")) if (population_type == "F2") { expected <- c(a = 0.25, ab = 0.50, b = 0.25) valid_classes <- c("a", "ab", "b") } else { expected <- c(a = 0.50, b = 0.50) valid_classes <- c("a", "b") } p_values <- apply(df[, geno_cols_idx, drop = FALSE], 1, function(row) { vals <- row[!is.na(row) & row %in% valid_classes] if (length(vals) < 10) return(1.0) obs <- table(factor(vals, levels = valid_classes)) result <- tryCatch(suppressWarnings(chisq.test(obs, p = expected)), error = function(e) list(p.value = 1.0)) result$p.value }) names(p_values) <- df$Marker p_values } correct_errors_and_impute_missing <- function(df) { valid_geno <- c("a", "b", "ab") geno_start <- which(colnames(df) == "Chromosome") + 1 if (length(geno_start) == 0 || geno_start > ncol(df)) return(df) geno_cols_idx <- setdiff(geno_start:ncol(df), which(colnames(df) == ".input_order")) for (j in geno_cols_idx) { x <- as.character(df[[j]]) x[x %in% c("-", "NA", "", " ", "na")] <- NA x[!is.na(x) & !(x %in% valid_geno)] <- NA df[[j]] <- x } if (length(geno_cols_idx) > 0) { na_col <- colMeans(is.na(df[, geno_cols_idx, drop = FALSE])) keep_idx <- c(seq_len(geno_start - 1), geno_cols_idx[na_col < 1], which(colnames(df) == ".input_order")) keep_idx <- keep_idx[keep_idx <= ncol(df)] df <- df[, sort(unique(keep_idx)), drop = FALSE] } geno_cols_idx2 <- setdiff(geno_start:ncol(df), which(colnames(df) == ".input_order")) if (length(geno_cols_idx2) > 0) { df[, geno_cols_idx2] <- t(apply(df[, geno_cols_idx2, drop = FALSE], 1, function(row) { nas <- is.na(row) if (all(nas) || !any(nas)) return(row) tbl <- table(row[!nas]) mode_val <- names(which.max(tbl)) row[nas] <- mode_val row })) } df } quality_control <- function(df, missing_threshold = 0.1) { if (ncol(df) < 3 || nrow(df) == 0) return(df[0, ]) geno_cols_idx <- setdiff(3:(ncol(df)), which(colnames(df) == ".input_order")) if (length(geno_cols_idx) == 0) return(df) geno <- df[, geno_cols_idx, drop = FALSE] miss_frac <- rowMeans(is.na(geno)) miss_frac[is.na(miss_frac)] <- 1 df[miss_frac <= missing_threshold, ] } # ═══════════════════════════════════════════════════════════════════════════════ # POSITION BUILDING # ═══════════════════════════════════════════════════════════════════════════════ build_positions_from_input_order <- function(df, population_type, recombination_threshold) { chromosomes <- unique(df$Chromosome[order(df$.input_order)]) geno_cols_idx <- setdiff(3:ncol(df), which(colnames(df) == ".input_order")) positions_list <- list() recombination_rows <- list() for (chr in chromosomes) { sub <- df[df$Chromosome == chr, ] sub <- sub[order(sub$.input_order), ] mks <- sub$Marker if (length(mks) == 0) next pos <- setNames(numeric(length(mks)), mks) if (length(mks) > 1) { for (i in 2:length(mks)) { m_prev <- mks[i-1]; m_curr <- mks[i] g1 <- unlist(sub[sub$Marker == m_prev, geno_cols_idx]) g2 <- unlist(sub[sub$Marker == m_curr, geno_cols_idx]) rf <- calculate_recombination(population_type, g1, g2) if (!is.na(rf)) { cM <- calculate_cM_kosambi(rf) recombination_rows[[length(recombination_rows)+1]] <- data.frame(Marker1=m_prev, Marker2=m_curr, RecombinationFraction=rf, MapDistance_cM=cM, Chromosome=chr, stringsAsFactors=FALSE) pos[i] <- if (rf < recombination_threshold) pos[i-1] + abs(cM) else pos[i-1] + 50 } else { pos[i] <- pos[i-1] + 5 } } } for (mk in names(pos)) positions_list[[mk]] <- pos[mk] } list(positions_list=positions_list, recombination_rows=recombination_rows) } scale_linkage_map <- function(linkage_map, max_total_cm, method = "proportional") { pos_map <- linkage_map[!is.na(linkage_map$Position), ] if (nrow(pos_map) == 0) return(list(scaled_map=linkage_map, chrom_lengths_before=data.frame(), chrom_lengths_after=data.frame())) before <- pos_map %>% group_by(Chromosome) %>% summarise(Original_Length = max(Position, na.rm=TRUE), .groups="drop") total <- sum(before$Original_Length, na.rm=TRUE) if (total <= max_total_cm || total == 0) return(list(scaled_map=linkage_map, chrom_lengths_before=before, chrom_lengths_after=before)) scaled <- linkage_map if (method == "proportional") { before$sf <- max_total_cm / total } else { n_chr <- nrow(before) before$sf <- ifelse(before$Original_Length > 0, (max_total_cm/n_chr)/before$Original_Length, 1) } for (chr in before$Chromosome) { sf <- before$sf[before$Chromosome == chr] idx <- which(scaled$Chromosome == chr & !is.na(scaled$Position)) if (length(idx) > 0) { mn <- min(scaled$Position[idx]) scaled$Position[idx] <- (scaled$Position[idx] - mn) * sf } } after <- scaled %>% filter(!is.na(Position)) %>% group_by(Chromosome) %>% summarise(Original_Length = max(Position, na.rm=TRUE), .groups="drop") list(scaled_map=scaled, chrom_lengths_before=before, chrom_lengths_after=after) } # ═══════════════════════════════════════════════════════════════════════════════ # MASTER ANALYSIS # ═══════════════════════════════════════════════════════════════════════════════ create_linkage_map <- function(df, population_type = "F2", missing_threshold = 0.1, recombination_threshold = 0.5, max_total_cm = 1500, scaling_method = "proportional", enable_chi_square = FALSE, chi_square_alpha = 0.05, use_bonferroni = FALSE) { empty_result <- function(df_in = df) { list(original_data = df_in, recombination_data = data.frame(), linkage_map = data.frame(Marker=character(0), Chromosome=character(0), Position=numeric(0)), all_markers_positions = data.frame(), retained_markers_list = data.frame(), chrom_lengths_before = data.frame(), chrom_lengths_after = data.frame(), removal_log = data.frame(), qc_report = "No data passed QC.") } df_orig <- df if (!(".input_order" %in% colnames(df))) df$.input_order <- seq_len(nrow(df)) removal_log <- data.frame( Marker = character(0), Chromosome = character(0), Reason = character(0), Detail = character(0), stringsAsFactors = FALSE ) n_input <- nrow(df) df <- correct_errors_and_impute_missing(df) geno_cols_idx <- setdiff(3:ncol(df), which(colnames(df) == ".input_order")) geno_mat <- df[, geno_cols_idx, drop = FALSE] miss_frac <- if (ncol(geno_mat) > 0) rowMeans(is.na(geno_mat)) else rep(0, nrow(df)) miss_frac[is.na(miss_frac)] <- 1 failed_miss <- df$Marker[miss_frac > missing_threshold] if (length(failed_miss) > 0) { removal_log <- rbind(removal_log, data.frame( Marker = failed_miss, Chromosome = df$Chromosome[miss_frac > missing_threshold], Reason = "Excess missing data", Detail = paste0(round(miss_frac[miss_frac > missing_threshold]*100,1), "% missing (threshold: ", round(missing_threshold*100,1), "%)"), stringsAsFactors = FALSE )) } df <- df[miss_frac <= missing_threshold, ] if (nrow(df) == 0 || ncol(df) < 3) { res <- empty_result(df_orig); res$removal_log <- removal_log; return(res) } is_mono <- detect_monomorphic_markers(df) mono_markers <- names(is_mono)[is_mono] if (length(mono_markers) > 0) { mono_df <- df[df$Marker %in% mono_markers, ] geno_cols_idx2 <- setdiff(3:ncol(df), which(colnames(df) == ".input_order")) details <- sapply(mono_markers, function(mk) { row <- df[df$Marker == mk, geno_cols_idx2, drop = FALSE] vals <- unlist(row) vals <- vals[!is.na(vals) & vals %in% c("a","b","ab")] if (length(vals) == 0) return("No informative genotypes") tbl <- table(vals) dominant <- names(which.max(tbl)) allele_name <- switch(dominant, a = "Homozygous Parent 1 (AA)", b = "Homozygous Parent 2 (BB)", ab = "Heterozygous only (ab)", dominant) paste0("Fixed as: ", allele_name, " (", round(max(tbl)/sum(tbl)*100, 1), "% of genotyped individuals)") }) removal_log <- rbind(removal_log, data.frame( Marker = mono_markers, Chromosome = mono_df$Chromosome, Reason = "Monomorphic marker", Detail = details, stringsAsFactors = FALSE )) df <- df[!df$Marker %in% mono_markers, ] } if (nrow(df) == 0) { res <- empty_result(df_orig); res$removal_log <- removal_log; return(res) } n_after_mono <- nrow(df) n_failed_chi <- 0 chi_note <- "Chi-square filter : DISABLED" if (enable_chi_square) { p_values <- perform_chi_square_test(df, population_type) alpha_used <- if (use_bonferroni) chi_square_alpha / nrow(df) else chi_square_alpha failed_chi_markers <- names(p_values)[p_values < alpha_used] if (length(failed_chi_markers) > 0) { chi_df <- df[df$Marker %in% failed_chi_markers, ] chi_details <- sapply(failed_chi_markers, function(mk) { pv <- p_values[mk] paste0("p = ", formatC(pv, format="e", digits=3), " (alpha = ", formatC(alpha_used, format="e", digits=3), ")") }) removal_log <- rbind(removal_log, data.frame( Marker = failed_chi_markers, Chromosome = chi_df$Chromosome, Reason = "Segregation distortion (chi-square)", Detail = chi_details, stringsAsFactors = FALSE )) df <- df[!df$Marker %in% failed_chi_markers, ] n_failed_chi <- length(failed_chi_markers) } bonf_label <- if (use_bonferroni) paste0("Bonferroni-corrected (alpha/", n_after_mono, ")") else "Uncorrected" chi_note <- paste0( "Chi-square filter : ENABLED\n", " Significance : alpha = ", chi_square_alpha, " (", bonf_label, ")\n", " Effective alpha : ", formatC(alpha_used, format="e", digits=3), "\n", " Markers removed : ", n_failed_chi ) } if (nrow(df) == 0) { res <- empty_result(df_orig); res$removal_log <- removal_log; return(res) } n_after_qc <- nrow(df) qc_msg <- paste0( "QC Report (v6.0)\n", "================\n", " Markers input : ", n_input, "\n", " Removed (missing data) : ", length(failed_miss), "\n", " Removed (monomorphic) : ", length(mono_markers), "\n", " Removed (chi-square) : ", n_failed_chi, "\n", " Markers retained : ", n_after_qc, "\n\n", chi_note, "\n\n", " Marker ORDER preserved from input file.\n", " First marker of each chromosome = 0 cM.\n" ) pos_result <- build_positions_from_input_order(df, population_type, recombination_threshold) positions_list <- pos_result$positions_list recombination_rows <- pos_result$recombination_rows rec_df <- if (length(recombination_rows) > 0) do.call(rbind, recombination_rows) else data.frame() if (length(positions_list) == 0) { lm_empty <- df[, c("Marker","Chromosome")] lm_empty$Position <- NA_real_ retained_list <- data.frame(Marker=df$Marker, Chromosome=df$Chromosome, Status="Retained", stringsAsFactors=FALSE) all_markers <- build_all_markers_positions(df_orig, removal_log, lm_empty) return(list(original_data=df_orig, recombination_data=rec_df, linkage_map=lm_empty, all_markers_positions=all_markers, retained_markers_list=retained_list, chrom_lengths_before=data.frame(), chrom_lengths_after=data.frame(), removal_log=removal_log, qc_report=qc_msg)) } pos_df <- data.frame(Marker = names(positions_list), Position = unlist(positions_list), stringsAsFactors = FALSE) linkage_map <- merge(df[, c("Marker","Chromosome",".input_order")], pos_df, by="Marker", all.x=TRUE) linkage_map <- linkage_map[order(linkage_map$Chromosome, linkage_map$.input_order), ] linkage_map$.input_order <- NULL scaled <- scale_linkage_map(linkage_map, max_total_cm, scaling_method) all_markers_positions <- build_all_markers_positions(df_orig, removal_log, scaled$scaled_map) retained_markers_list <- scaled$scaled_map %>% select(Marker, Chromosome, Position) %>% mutate(Status = "Retained") %>% arrange(Chromosome, Position) list(original_data = df_orig, recombination_data = rec_df, linkage_map = scaled$scaled_map, all_markers_positions = all_markers_positions, retained_markers_list = retained_markers_list, chrom_lengths_before = scaled$chrom_lengths_before, chrom_lengths_after = scaled$chrom_lengths_after, removal_log = removal_log, qc_report = qc_msg) } build_all_markers_positions <- function(df_orig, removal_log, linkage_map) { df_all <- df_orig[, c("Marker","Chromosome"), drop=FALSE] df_all$Chromosome <- as.character(df_all$Chromosome) lm_sub <- linkage_map[, c("Marker","Position"), drop=FALSE] df_all <- merge(df_all, lm_sub, by="Marker", all.x=TRUE) removed_markers <- unique(removal_log$Marker) df_all$Status <- ifelse(df_all$Marker %in% removed_markers, "Removed", "Retained") if (nrow(removal_log) > 0) { reason_df <- removal_log[, c("Marker","Reason","Detail"), drop=FALSE] df_all <- merge(df_all, reason_df, by="Marker", all.x=TRUE) } else { df_all$Reason <- NA_character_ df_all$Detail <- NA_character_ } df_all$Position[df_all$Status == "Removed"] <- NA_real_ df_all <- df_all[order(df_all$Chromosome, df_all$Marker), ] df_all[, c("Marker","Chromosome","Status","Position","Reason","Detail")] } # ═══════════════════════════════════════════════════════════════════════════════ # CYTOGENETIC PLOT DATA BUILDER (FIXED) # ═══════════════════════════════════════════════════════════════════════════════ build_cyto_plot_data <- function(linkage_map, df_retained, selected_chromosomes) { if (is.null(selected_chromosomes) || length(selected_chromosomes) == 0) return(list()) # Ensure linkage_map has Position column if (is.null(linkage_map$Position)) linkage_map$Position <- NA_real_ # Get genotype columns (all columns except Marker, Chromosome, .input_order, Position, Status, Reason, Detail) exclude_cols <- c("Marker", "Chromosome", ".input_order", "Position", "Status", "Reason", "Detail") geno_cols <- setdiff(colnames(df_retained), exclude_cols) # Sort chromosomes numerically if possible chr_sorted <- tryCatch( selected_chromosomes[order(as.numeric(selected_chromosomes))], warning = function(w) sort(selected_chromosomes), error = function(e) sort(selected_chromosomes) ) result <- list() for (chr in chr_sorted) { # Get markers for this chromosome with valid positions sub_map <- linkage_map[linkage_map$Chromosome == chr & !is.na(linkage_map$Position), ] if (nrow(sub_map) == 0) next sub_map <- sub_map[order(sub_map$Position), ] # Get genotype data for these markers sub_geno <- df_retained[df_retained$Marker %in% sub_map$Marker, ] marker_data <- list() for (i in seq_len(nrow(sub_map))) { mk <- sub_map$Marker[i] pos <- sub_map$Position[i] # Get genotype row for this marker geno_row <- sub_geno[sub_geno$Marker == mk, geno_cols, drop = FALSE] if (nrow(geno_row) == 0 || length(geno_cols) == 0) { marker_data[[i]] <- list( marker = mk, position = round(pos, 3), allele_a = 0, allele_b = 0, allele_ab = 0, allele_miss = 1 ) next } vals <- unlist(geno_row) vals <- vals[!is.na(vals)] n_total <- length(vals) if (n_total == 0) n_total <- 1 marker_data[[i]] <- list( marker = mk, position = round(pos, 3), allele_a = round(sum(vals == "a", na.rm = TRUE) / n_total, 4), allele_b = round(sum(vals == "b", na.rm = TRUE) / n_total, 4), allele_ab = round(sum(vals == "ab", na.rm = TRUE) / n_total, 4), allele_miss = round(sum(!(vals %in% c("a","b","ab")), na.rm = TRUE) / n_total, 4) ) } if (length(marker_data) > 0) { result[[chr]] <- list( chromosome = chr, max_pos = round(max(sub_map$Position, na.rm = TRUE), 2), n_markers = nrow(sub_map), markers = marker_data ) } } result } # ═══════════════════════════════════════════════════════════════════════════════ # HIGH-RESOLUTION GGPLOT LINKAGE MAP # ═══════════════════════════════════════════════════════════════════════════════ create_ggplot_linkage_map <- function(linkage_map, selected_chromosomes, show_labels = FALSE, label_size = 2.5, color_palette = "Set2", chrom_width = 0.4, point_size = 2.5, chrom_spacing = 2.5, plot_theme = "dark") { if (is.null(selected_chromosomes) || length(selected_chromosomes) == 0) return(ggplot() + annotate("text", x=0.5, y=0.5, label="No chromosomes selected.", size=6) + theme_void()) chr_sorted <- tryCatch( selected_chromosomes[order(as.numeric(selected_chromosomes))], warning = function(w) sort(selected_chromosomes), error = function(e) sort(selected_chromosomes) ) plot_data <- linkage_map %>% filter(Chromosome %in% selected_chromosomes, !is.na(Position)) %>% mutate(Chromosome = factor(Chromosome, levels = chr_sorted)) %>% arrange(Chromosome, Position) if (nrow(plot_data) == 0) { return(ggplot() + annotate("text", x=0.5, y=0.5, label="No valid marker positions.\nTry relaxing QC parameters.", size=5, color="#E74C3C", hjust=0.5) + theme_void()) } n_chr <- length(chr_sorted) chrom_stats <- plot_data %>% group_by(Chromosome) %>% summarise(max_pos = max(Position, na.rm=TRUE), min_pos = min(Position, na.rm=TRUE), num_markers = n(), .groups="drop") %>% mutate(Chromosome = factor(Chromosome, levels = chr_sorted)) %>% arrange(Chromosome) %>% mutate(x_center = as.numeric(Chromosome) * chrom_spacing) plot_data <- plot_data %>% left_join(chrom_stats %>% select(Chromosome, x_center), by="Chromosome") # Color palette n_needed <- max(3, n_chr) chrom_colors <- tryCatch({ if (n_chr <= 8) brewer.pal(n_needed, color_palette)[seq_len(n_chr)] else colorRampPalette(brewer.pal(8, color_palette))(n_chr) }, error = function(e) { colorRampPalette(brewer.pal(8, "Set2"))(n_chr) }) names(chrom_colors) <- chr_sorted # Theme setup if (plot_theme == "dark") { bg_col <- "#1A1A2E" panel_col <- "#16213E" grid_col <- "#2D3561" text_col <- "#E8E8F0" axis_col <- "#8A8AB0" strip_col <- "#0F3460" } else if (plot_theme == "minimal_white") { bg_col <- "#FFFFFF" panel_col <- "#FAFAFA" grid_col <- "#EEEEEE" text_col <- "#222222" axis_col <- "#666666" strip_col <- "#F0F0F0" } else { bg_col <- "#F0F4FF" panel_col <- "#FFFFFF" grid_col <- "#D0DCF0" text_col <- "#1E3A8A" axis_col <- "#4A6FA5" strip_col <- "#DBEAFE" } # Half-width for chromosome bars hw <- chrom_width / 2 p <- ggplot() + # Chromosome body (thick segment with rounded aesthetic via geom_rect) geom_rect(data = chrom_stats, aes(xmin = x_center - hw, xmax = x_center + hw, ymin = min_pos, ymax = max_pos, fill = as.factor(Chromosome)), alpha = 0.18, color = NA) + # Chromosome axis line geom_segment(data = chrom_stats, aes(x = x_center, xend = x_center, y = min_pos, yend = max_pos, color = as.factor(Chromosome)), linewidth = chrom_width * 12, alpha = 0.5, lineend = "round") + # Marker tick marks (horizontal lines) geom_segment(data = plot_data, aes(x = x_center - hw * 1.6, xend = x_center + hw * 1.6, y = Position, yend = Position, color = as.factor(Chromosome)), linewidth = 0.6, alpha = 0.85) + # Marker points geom_point(data = plot_data, aes(x = x_center, y = Position, color = as.factor(Chromosome)), size = point_size, alpha = 0.92, shape = 21, fill = "white", stroke = 0.8) + # Chromosome name label (bottom) geom_text(data = chrom_stats, aes(x = x_center, y = max_pos + max(chrom_stats$max_pos) * 0.025, label = paste0("Chr ", Chromosome), color = as.factor(Chromosome)), size = 3.8, fontface = "bold", vjust = 0) + # Marker count label geom_text(data = chrom_stats, aes(x = x_center, y = max_pos + max(chrom_stats$max_pos) * 0.07, label = paste0("n=", num_markers)), color = axis_col, size = 3.0, vjust = 0) + # Length label (top) geom_text(data = chrom_stats, aes(x = x_center, y = min_pos - max(chrom_stats$max_pos) * 0.025, label = paste0(round(max_pos,1), " cM"), color = as.factor(Chromosome)), size = 3.0, vjust = 1, fontface = "italic") + scale_color_manual(values = chrom_colors) + scale_fill_manual(values = chrom_colors) + scale_y_reverse(name = "Genetic Position (cM)", expand = expansion(mult = c(0.04, 0.15))) + scale_x_continuous(breaks = chrom_stats$x_center, labels = chrom_stats$Chromosome, name = "Linkage Group", expand = expansion(mult = 0.08)) + guides(color = "none", fill = "none") + labs(title = "Genetic Linkage Map", subtitle = paste0(nrow(plot_data), " markers across ", n_chr, " linkage groups"), caption = "Map distances in Kosambi cM | Linkage Map Creator v6.0") + theme_minimal(base_size = 13) + theme( plot.background = element_rect(fill = bg_col, color = NA), panel.background = element_rect(fill = panel_col, color = NA), panel.grid.major.x = element_blank(), panel.grid.minor.x = element_blank(), panel.grid.major.y = element_line(color = grid_col, linewidth = 0.4, linetype = "dashed"), panel.grid.minor.y = element_line(color = grid_col, linewidth = 0.2), axis.text.x = element_text(color = text_col, size = 11, face = "bold"), axis.text.y = element_text(color = axis_col, size = 10), axis.title.x = element_text(color = text_col, size = 13, face = "bold", margin = margin(t = 12)), axis.title.y = element_text(color = text_col, size = 13, face = "bold", margin = margin(r = 12)), plot.title = element_text(color = text_col, size = 18, face = "bold", hjust = 0.5, margin = margin(b = 4)), plot.subtitle = element_text(color = axis_col, size = 12, hjust = 0.5, margin = margin(b = 14)), plot.caption = element_text(color = axis_col, size = 9, hjust = 1), plot.margin = margin(20, 30, 20, 20) ) # Optional marker labels if (show_labels && nrow(plot_data) <= 600) { p <- p + ggrepel::geom_text_repel( data = plot_data, aes(x = x_center, y = Position, label = Marker, color = as.factor(Chromosome)), size = label_size, direction = "y", nudge_x = hw * 2.2, segment.size = 0.25, segment.alpha = 0.55, max.overlaps = 25, show.legend = FALSE, fontface = "plain" ) } p } # ═══════════════════════════════════════════════════════════════════════════════ # RECOMBINATION HEATMAP # ═══════════════════════════════════════════════════════════════════════════════ create_recombination_heatmap <- function(rec_data, selected_chr, top_n = 60) { if (is.null(rec_data) || nrow(rec_data) == 0) return(ggplot() + annotate("text",x=.5,y=.5,label="No recombination data.",size=5) + theme_void()) df <- rec_data if (!is.null(selected_chr) && length(selected_chr) > 0) df <- df[df$Chromosome %in% selected_chr, ] if (nrow(df) == 0) return(ggplot() + annotate("text",x=.5,y=.5,label="No data for selected chromosomes.",size=5) + theme_void()) # Limit pairs for readability if (nrow(df) > top_n) df <- df[1:top_n, ] df$pair <- paste0(df$Marker1, "\n", df$Marker2) df$pair <- factor(df$pair, levels = rev(unique(df$pair))) ggplot(df, aes(x = 1, y = pair, fill = RecombinationFraction)) + geom_tile(color = "white", linewidth = 0.4) + geom_text(aes(label = paste0(round(MapDistance_cM, 1), " cM\nr=", round(RecombinationFraction, 3))), size = 2.8, color = "white", fontface = "bold") + scale_fill_gradientn( colors = c("#0D3B66","#1A6FA5","#4ECDC4","#FFE66D","#FF6B6B","#C0392B"), limits = c(0, 0.5), name = "Recombination\nFraction (r)", breaks = c(0, 0.1, 0.2, 0.3, 0.4, 0.5), labels = c("0.0","0.1","0.2","0.3","0.4","0.5") ) + facet_wrap(~Chromosome, scales = "free_y", ncol = 3) + labs(title = "Adjacent Marker Recombination Fractions", subtitle = paste0("Showing ", nrow(df), " marker pairs"), x = NULL, y = "Marker Pair") + theme_minimal(base_size = 11) + theme( axis.text.x = element_blank(), axis.ticks.x = element_blank(), axis.text.y = element_text(size = 8), strip.text = element_text(face = "bold", size = 10), legend.position = "right", plot.title = element_text(face = "bold", size = 14, hjust = 0.5), plot.subtitle = element_text(size = 10, hjust = 0.5, color = "gray50"), panel.grid = element_blank(), plot.background = element_rect(fill = "#FAFAFA", color = NA) ) } # ═══════════════════════════════════════════════════════════════════════════════ # MARKER DENSITY PLOT # ═══════════════════════════════════════════════════════════════════════════════ create_marker_density_plot <- function(linkage_map, selected_chr, bin_width = 10) { if (is.null(linkage_map) || nrow(linkage_map) == 0) return(ggplot() + annotate("text",x=.5,y=.5,label="No data.",size=5) + theme_void()) df <- linkage_map %>% filter(!is.na(Position)) if (!is.null(selected_chr) && length(selected_chr) > 0) df <- df %>% filter(Chromosome %in% selected_chr) if (nrow(df) == 0) return(ggplot() + annotate("text",x=.5,y=.5,label="No positioned markers.",size=5) + theme_void()) chr_sorted <- tryCatch( sort(unique(df$Chromosome), decreasing = FALSE), warning = function(w) sort(unique(df$Chromosome)) ) df$Chromosome <- factor(df$Chromosome, levels = chr_sorted) n_chr <- length(chr_sorted) pal <- tryCatch( colorRampPalette(brewer.pal(min(n_chr, 9), "Set1"))(n_chr), error = function(e) colorRampPalette(c("#E74C3C","#3498DB","#2ECC71","#F39C12","#9B59B6"))(n_chr) ) ggplot(df, aes(x = Position, fill = Chromosome, color = Chromosome)) + geom_histogram(binwidth = bin_width, alpha = 0.75, linewidth = 0.3) + geom_density(aes(y = after_stat(count) * bin_width), alpha = 0, linewidth = 1.0, linetype = "dashed") + facet_wrap(~Chromosome, scales = "free", ncol = ceiling(sqrt(n_chr))) + scale_fill_manual(values = pal) + scale_color_manual(values = pal) + labs(title = "Marker Density Distribution", subtitle = paste0("Bin width: ", bin_width, " cM"), x = "Genetic Position (cM)", y = "Number of Markers", fill = "Chromosome", color = "Chromosome") + guides(fill = "none", color = "none") + theme_minimal(base_size = 12) + theme( strip.text = element_text(face = "bold", size = 11, color = "#1E3A8A", margin = margin(b=4)), strip.background = element_rect(fill = "#DBEAFE", color = NA), panel.grid.minor = element_blank(), plot.title = element_text(face = "bold", size = 15, hjust = 0.5), plot.subtitle = element_text(size = 10, hjust = 0.5, color = "gray50"), plot.background = element_rect(fill = "#F8FAFF", color = NA), panel.background = element_rect(fill = "#FFFFFF", color = NA) ) } # ═══════════════════════════════════════════════════════════════════════════════ # UI # ═══════════════════════════════════════════════════════════════════════════════ ui <- fluidPage( tags$head( tags$style(HTML(" @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap'); *, *::before, *::after { box-sizing: border-box; } body { background: #F0F4FF; color: #1E293B; font-family: 'DM Sans', sans-serif; margin: 0; padding-bottom: 60px; } .navbar-default { background: linear-gradient(135deg, #0F172A 0%, #1E3A8A 60%, #1D4ED8 100%) !important; border: none !important; box-shadow: 0 2px 16px rgba(0,0,0,0.35); min-height: 52px; } .navbar-default .navbar-brand { color: #E0EAFF !important; font-weight: 700; font-size: 15px; letter-spacing: 0.3px; } .navbar-default .navbar-nav > li > a { color: #A5B4FC !important; font-weight: 500; font-size: 13px; padding: 16px 14px; transition: all .2s; } .navbar-default .navbar-nav > li > a:hover { color: #fff !important; background: rgba(255,255,255,0.10) !important; } .navbar-default .navbar-nav > .active > a, .navbar-default .navbar-nav > .active > a:focus, .navbar-default .navbar-nav > .active > a:hover { color: #fff !important; background: rgba(99,102,241,0.35) !important; border-bottom: 3px solid #818CF8; } .card { background: #fff; border-radius: 12px; border: 1px solid #E2E8F0; box-shadow: 0 2px 12px rgba(30,58,138,0.07); padding: 22px 24px; margin-bottom: 20px; } .card h3 { font-size: 16px; font-weight: 700; color: #1E3A8A; margin: 0 0 16px; padding-bottom: 10px; border-bottom: 2px solid #EEF2FF; display: flex; align-items: center; gap: 8px; } .card h4 { font-size: 14px; font-weight: 600; color: #3B4FCC; margin: 16px 0 10px; } .well { background: #fff; border: 1px solid #E2E8F0; border-radius: 12px; box-shadow: 0 2px 12px rgba(30,58,138,0.07); padding: 18px; } .form-control { border: 1.5px solid #CBD5E1; border-radius: 8px; font-family: 'DM Sans', sans-serif; font-size: 13px; transition: border-color .2s; background: #F8FAFF; } .form-control:focus { border-color: #6366F1; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); } .irs--shiny .irs-bar { background: linear-gradient(90deg,#6366F1,#3B82F6); } .irs--shiny .irs-handle { background: #6366F1; border-color: #4F46E5; } .irs--shiny .irs-from, .irs--shiny .irs-to, .irs--shiny .irs-single { background: #4F46E5; } .btn { border-radius: 8px; font-family: 'DM Sans', sans-serif; font-weight: 600; font-size: 13px; padding: 9px 18px; transition: all .2s; letter-spacing: 0.2px; } .btn-primary { background: linear-gradient(135deg,#4F46E5,#2563EB); border-color: #4338CA; color: #fff; } .btn-primary:hover { background: linear-gradient(135deg,#4338CA,#1D4ED8); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79,70,229,0.4); } .btn-success { background: linear-gradient(135deg,#059669,#047857); border-color: #065F46; color: #fff; } .btn-success:hover { background: linear-gradient(135deg,#047857,#065F46); transform: translateY(-1px); } .btn-info { background: linear-gradient(135deg,#0891B2,#0E7490); border-color: #155E75; color: #fff; } .btn-info:hover { background: linear-gradient(135deg,#0E7490,#164E63); transform: translateY(-1px); } .btn-warning { background: linear-gradient(135deg,#D97706,#B45309); border-color: #92400E; color: #fff; } .btn-block { width: 100%; display: block; } .alert-info { background: #EFF6FF; border: 1px solid #BFDBFE; color: #1E40AF; border-radius: 10px; font-size: 13px; } .alert-warning { background: #FFFBEB; border: 1px solid #FDE68A; color: #92400E; border-radius: 10px; font-size: 13px; } .alert-success { background: #ECFDF5; border: 1px solid #A7F3D0; color: #065F46; border-radius: 10px; font-size: 13px; } .plot-wrapper { background: #fff; border-radius: 12px; border: 1px solid #E2E8F0; overflow: hidden; box-shadow: 0 4px 20px rgba(30,58,138,0.08); } #cyto-scroll-wrapper { overflow-x: auto; overflow-y: hidden; background: #fff; border-radius: 10px; border: 1px solid #E2E8F0; padding: 12px; min-height: 200px; } #cytoCanvas { display: block; cursor: crosshair; image-rendering: crisp-edges; } #cyto-tooltip { position: fixed; background: rgba(15,23,42,0.95); color: #E0EAFF; padding: 10px 14px; border-radius: 10px; font-size: 12px; font-family: 'JetBrains Mono', monospace; pointer-events: none; display: none; z-index: 9999; max-width: 280px; line-height: 1.8; box-shadow: 0 8px 24px rgba(0,0,0,0.4); border: 1px solid rgba(99,102,241,0.4); } .cyto-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 14px; padding: 12px 16px; background: #F8FAFF; border-radius: 8px; border: 1px solid #E2E8F0; } .cyto-controls label { font-size: 12px; color: #64748B; margin: 0; font-weight: 600; } .cyto-controls input[type=range] { width: 130px; accent-color: #6366F1; } #cyto-legend { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; font-size: 12px; align-items: center; padding: 10px 14px; background: #F8FAFF; border-radius: 8px; border: 1px solid #E2E8F0; } .legend-item { display: flex; align-items: center; gap: 7px; font-weight: 500; } .legend-swatch { width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.1); } .dataTables_wrapper { font-size: 13px; } table.dataTable { border: 1px solid #E2E8F0 !important; border-radius: 8px; } table.dataTable thead th { background: linear-gradient(135deg,#1E3A8A,#1D4ED8); color: #fff !important; font-weight: 700; border-color: #2D4EA0 !important; } table.dataTable tbody tr:hover { background: #EEF2FF !important; } pre { font-family: 'JetBrains Mono', monospace; font-size: 12px; background: #F1F5F9; border-radius: 8px; border: 1px solid #E2E8F0; } .footer { position: fixed; bottom: 0; width: 100%; background: linear-gradient(90deg, #0F172A, #1E3A8A); color: #94A3B8; text-align: center; padding: 9px; font-size: 11px; letter-spacing: 0.4px; border-top: 1px solid #334155; z-index: 100; } .chi-panel { background: #F0FDF4; border: 1px solid #86EFAC; border-radius: 8px; padding: 14px; margin-top: 10px; } .chi-disabled { background: #FFF1F2; border: 1px solid #FECACA; border-radius: 8px; padding: 12px; margin-top: 10px; font-size: 12px; color: #991B1B; } .stat-badge { display: inline-flex; align-items: center; justify-content: center; background: linear-gradient(135deg,#EEF2FF,#DBEAFE); border: 1px solid #BFDBFE; border-radius: 20px; padding: 4px 14px; font-size: 12px; font-weight: 700; color: #1E40AF; margin: 3px; } .tab-content { padding-top: 10px; } ")) ), navbarPage( title = "🧬 Linkage Map Creator v6.0", id = "nav", collapsible = TRUE, tabPanel("📁 Upload", icon = icon("upload"), sidebarLayout( sidebarPanel(width = 4, div(class = "card", h3("📂 Upload Genotype Data"), fileInput("file", "Choose Excel File (.xlsx)", accept = c(".xlsx",".xls"), buttonLabel = "Browse…", placeholder = "No file selected"), selectInput("population_type", "Population Type:", choices = c("F2" = "F2", "RIL" = "RIL"), selected = "F2"), hr(), div(class = "alert alert-info", tags$b("✨ v6.0 Features:"), br(), "• High-resolution ggplot linkage map", br(), "• Recombination fraction heatmap", br(), "• Marker density distribution plot", br(), "• Cytogenetic ideogram with hover tooltips", br(), "• Allele frequency banding per chromosome", br(), "• Full Excel export with all analysis sheets" ) ) ), mainPanel(width = 8, div(class = "card", h3("📊 Upload Status"), verbatimTextOutput("upload_status") ), div(class = "card", h3("🔬 Data Preview (first 10 rows, converted)"), div(style="overflow-x:auto;", dataTableOutput("preview_table")) ), div(class = "card", h3("🧬 Chromosome Assignments"), div(style="overflow-x:auto;", dataTableOutput("chr_summary_table")) ) ) ) ), tabPanel("⚙️ Parameters", icon = icon("sliders-h"), sidebarLayout( sidebarPanel(width = 4, div(class = "card", h3("⚙️ Analysis Parameters"), sliderInput("missing_threshold", "Missing Data Threshold:", min=0, max=1, value=0.3, step=0.05), sliderInput("recombination_threshold", "Max Recombination Fraction (r):", min=0, max=0.5, value=0.45, step=0.01), hr(), h4("🧪 Chi-Square Filter"), checkboxInput("enable_chi_square", "Enable chi-square segregation distortion filter", value = FALSE), conditionalPanel("input.enable_chi_square == true", div(class = "chi-panel", numericInput("chi_square_alpha", "Significance level (α):", min=0.001, max=0.2, value=0.05, step=0.005), checkboxInput("use_bonferroni", "Apply Bonferroni correction", value=FALSE), div(class="alert alert-warning", style="font-size:12px; margin-top:8px;", "⚠️ Chi-square filtering removes markers with skewed allele ratios.") ) ), conditionalPanel("input.enable_chi_square == false", div(class="chi-disabled", "ℹ️ Chi-square filter is OFF.") ), hr(), h4("📏 Map Scaling"), numericInput("max_total_cm", "Max Total Map Length (cM):", min=100, max=10000, value=1500, step=100), selectInput("scaling_method", "Scaling Method:", choices = c("Proportional"="proportional","Uniform"="uniform"), selected = "proportional"), hr(), actionButton("run_analysis", "▶ Run Analysis", class="btn-primary btn-block", icon=icon("play")) ) ), mainPanel(width = 8, div(class="card", h3("📖 Parameter Guide"), tags$ul( tags$li(tags$b("Missing Data Threshold:"), " Markers with > this fraction missing are excluded."), tags$li(tags$b("Max Recombination Fraction:"), " Pairs with r ≥ threshold get a 50 cM gap."), tags$li(tags$b("Monomorphic Removal:"), " Automatic — fixed allele markers excluded."), tags$li(tags$b("Chi-Square Filter:"), " Optional — removes segregation-distorted markers."), tags$li(tags$b("Map Scaling:"), " Proportional scales all chromosomes uniformly; Uniform equalises chromosome lengths.") ) ), div(class="card", h4("📐 Scaling Summary"), verbatimTextOutput("scaling_summary"), h4("Before Scaling"), verbatimTextOutput("before_scaling_stats"), h4("After Scaling"), verbatimTextOutput("after_scaling_stats") ) ) ) ), tabPanel("🔍 QC Report", icon = icon("clipboard-check"), div(class="card", h3("📋 Quality Control Report"), verbatimTextOutput("qc_report_output") ), div(class="card", h3("🗑️ Removed Markers — Full Detail"), div(style="overflow-x:auto;", dataTableOutput("removal_log_table")), br(), downloadButton("download_removal_log", "⬇ Download Removal Log (.xlsx)", class="btn-success") ) ), tabPanel("📊 Results", icon = icon("table"), div(class="card", h3("📊 Analysis Results"), conditionalPanel("output.analysis_run", fluidRow( column(4, downloadButton("download_results", "⬇ Full Excel Results", class="btn-success btn-block")), column(4, downloadButton("download_all_markers", "⬇ All Markers + Positions", class="btn-info btn-block")), column(4, downloadButton("download_retained_markers", "⬇ Retained Markers List", class="btn-info btn-block")) ), br(), h4("✅ Retained Markers"), div(style="overflow-x:auto;", dataTableOutput("retained_markers_table")), br(), h4("📋 All Markers — Status + Positions"), div(style="overflow-x:auto;", dataTableOutput("all_markers_table")), br(), h4("🗺️ Linkage Map Table"), div(style="overflow-x:auto;", dataTableOutput("result_table")), br(), h4("📈 Summary Statistics"), verbatimTextOutput("summary_stats") ), conditionalPanel("!output.analysis_run", p("Upload data and click 'Run Analysis' to see results.", style="color:#94A3B8;font-style:italic;text-align:center;padding:50px;font-size:15px;") ) ) ), tabPanel("🗺️ Map Plot", icon = icon("chart-bar"), sidebarLayout( sidebarPanel(width = 3, div(class="card", h3("🎨 Plot Options"), selectInput("gg_chromosome_select", "Chromosomes:", choices=NULL, multiple=TRUE), selectInput("gg_theme", "Plot Theme:", choices = c("Dark"="dark","Blue/White"="blue", "Minimal White"="minimal_white"), selected = "dark"), selectInput("gg_color_palette", "Color Palette:", choices = c("Set1","Set2","Set3","Paired","Dark2", "Accent","Spectral","RdYlBu","PRGn"), selected = "Set1"), checkboxInput("gg_show_labels", "Show Marker Labels", value=FALSE), sliderInput("gg_label_size", "Label Size:", min=1.5, max=6, value=2.5, step=0.5), sliderInput("gg_chrom_width", "Chromosome Width:", min=0.1, max=1.2, value=0.4, step=0.05), sliderInput("gg_point_size", "Marker Point Size:", min=0.5, max=6, value=2.5, step=0.25), sliderInput("gg_chrom_spacing", "LG Spacing:", min=1, max=6, value=2.5, step=0.25), sliderInput("gg_plot_height", "Plot Height (px):", min=400, max=2000, value=700, step=50), br(), downloadButton("download_gg_plot", "⬇ Download PNG (300 dpi)", class="btn-success btn-block") ) ), mainPanel(width = 9, div(class="card", h3("🗺️ Genetic Linkage Map"), conditionalPanel("output.analysis_run", div(class="plot-wrapper", plotOutput("gg_linkage_plot", height = "auto")) ), conditionalPanel("!output.analysis_run", p("Run analysis first.", style="color:#94A3B8;text-align:center;padding:60px;") ) ) ) ) ), tabPanel("📉 Density & RF", icon = icon("chart-area"), fluidRow( column(12, div(class="card", h3("📊 Marker Density per Chromosome"), conditionalPanel("output.analysis_run", fluidRow( column(6, selectInput("density_chr_select", "Chromosomes:", choices=NULL, multiple=TRUE)), column(3, sliderInput("density_bin", "Bin Width (cM):", min=2, max=30, value=10, step=2)), column(3, br(), downloadButton("download_density_plot", "⬇ Download", class="btn-info btn-block")) ), div(class="plot-wrapper", plotOutput("density_plot", height="500px")) ), conditionalPanel("!output.analysis_run", p("Run analysis first.", style="color:#94A3B8;text-align:center;padding:60px;")) ) ) ), fluidRow( column(12, div(class="card", h3("🌡️ Adjacent Marker Recombination Heatmap"), conditionalPanel("output.analysis_run", fluidRow( column(6, selectInput("heatmap_chr_select", "Chromosomes:", choices=NULL, multiple=TRUE)), column(3, sliderInput("heatmap_top_n", "Max Pairs to Show:", min=10, max=200, value=60, step=10)), column(3, br(), downloadButton("download_heatmap_plot", "⬇ Download", class="btn-warning btn-block")) ), div(class="plot-wrapper", plotOutput("heatmap_plot", height="600px")) ), conditionalPanel("!output.analysis_run", p("Run analysis first.", style="color:#94A3B8;text-align:center;padding:60px;")) ) ) ) ), tabPanel("🧬 Cytogenetic", icon = icon("dna"), div(class="card", h3("🧬 High-Resolution Cytogenetic Ideogram"), conditionalPanel("output.analysis_run", fluidRow( column(8, selectInput("cyto_chromosome_select", "Select Chromosomes:", choices=NULL, multiple=TRUE)), column(4, br(), actionButton("cyto_save_png", "📷 Save as PNG", class="btn-primary btn-block")) ), br(), div(class="cyto-controls", tags$label("Zoom:"), tags$input(type="range", id="cytoZoom", min="0.5", max="3", value="1", step="0.1", oninput="updateCytoZoom(this.value)"), tags$span(id="cytoZoomLabel", "1.0×", style="font-size:13px; min-width:40px; font-weight:600; color:#6366F1;") ), div(id="cyto-scroll-wrapper", tags$canvas(id="cytoCanvas", width="1200", height="600") ), div(id="cyto-legend", div(class="legend-item", div(class="legend-swatch", id="sw-a"), tags$span("Parent A (aa)")), div(class="legend-item", div(class="legend-swatch", id="sw-b"), tags$span("Parent B (bb)")), div(class="legend-item", div(class="legend-swatch", id="sw-ab"), tags$span("Heterozygous (ab)")), div(class="legend-item", div(class="legend-swatch", style="background:rgba(148,163,184,0.4); border:1px dashed #94A3B8;"), tags$span("Missing / unknown")) ), br(), fluidRow( column(3, sliderInput("cyto_chrom_width", "Chromosome Width:", min=10, max=80, value=28, step=2)), column(3, sliderInput("cyto_chrom_spacing", "Spacing:", min=20, max=150, value=60, step=5)), column(3, sliderInput("cyto_px_per_cm", "px per cM:", min=1, max=25, value=5, step=0.5)), column(3, sliderInput("cyto_font_size", "Font Size:", min=8, max=20, value=11, step=1)) ), fluidRow( column(4, selectInput("cyto_color_scheme", "Color Scheme:", choices = c("Dark Ideogram"="classic", "Genomics Blue"="blue_orange", "Forest"="green_purple", "Red/Blue"="red_blue", "Pastel"="pastel"), selected = "classic")), column(4, checkboxInput("cyto_show_scale", "Show cM scale bar", value=TRUE)), column(4, checkboxInput("cyto_show_marker_names", "Show marker names (≤200 markers)", value=FALSE)) ) ), conditionalPanel("!output.analysis_run", p("Run analysis first.", style="color:#94A3B8;text-align:center;padding:60px;")) ) ), tabPanel("❓ Help", icon = icon("question-circle"), div(class="card", h3("📖 Application Guide — v6.0"), h4("Required Input Format"), div(class="alert alert-info", tags$ul( tags$li("Excel (.xlsx): Column 1 = 'Marker', Column 2 = 'Chromosome'"), tags$li("Genotype columns 3+: individual sample genotype calls"), tags$li("Accepted codes: a / b / ab / – OR numeric 2 / 1 / 0 / –1"), tags$li("Chromosome column: integer numbers or Excel VLOOKUP formulas") ) ), h4("Genotype Encoding Reference"), tableOutput("encoding_table"), h4("Analysis Workflow"), tags$ol( tags$li("Upload your .xlsx genotype file in the Upload tab."), tags$li("Choose population type (F2 or RIL)."), tags$li("Adjust QC parameters in the Parameters tab."), tags$li("Click 'Run Analysis'."), tags$li("View QC report, download results, and explore all visualisations.") ), h4("Plots Available"), tags$ul( tags$li(tags$b("Map Plot:"), " High-resolution ggplot linkage map with chromosome ideograms."), tags$li(tags$b("Density & RF:"), " Marker density histograms + recombination fraction heatmap."), tags$li(tags$b("Cytogenetic:"), " HTML canvas ideogram with allele-colour banding and hover tooltips.") ) ) ) ), div(id = "cyto-tooltip"), tags$script(HTML(" var cytoData = null; var cytoSettings = {}; var cytoZoomVal = 1.0; var colorSchemes = { classic: { a:'#1a1a2e', b:'#f5f5f5', ab:'#F59E0B', miss:'rgba(148,163,184,0.25)', border:'#475569', text:'#1E293B' }, blue_orange: { a:'#1D4ED8', b:'#F97316', ab:'#FEF08A', miss:'rgba(148,163,184,0.25)', border:'#334155', text:'#0F172A' }, green_purple: { a:'#14532D', b:'#581C87', ab:'#FEF08A', miss:'rgba(148,163,184,0.25)', border:'#334155', text:'#0F172A' }, red_blue: { a:'#991B1B', b:'#1E3A8A', ab:'#FCD34D', miss:'rgba(148,163,184,0.25)', border:'#374151', text:'#111827' }, pastel: { a:'#93C5FD', b:'#F9A8D4', ab:'#86EFAC', miss:'rgba(148,163,184,0.25)', border:'#CBD5E1', text:'#334155' } }; function updateLegendSwatches(scheme) { var cs = colorSchemes[scheme] || colorSchemes.classic; var sa = document.getElementById('sw-a'); var sb = document.getElementById('sw-b'); var sab = document.getElementById('sw-ab'); if (sa) sa.style.background = cs.a; if (sb) sb.style.background = cs.b; if (sab) sab.style.background = cs.ab; } function updateCytoZoom(val) { cytoZoomVal = parseFloat(val); var lbl = document.getElementById('cytoZoomLabel'); if (lbl) lbl.textContent = parseFloat(val).toFixed(1) + '×'; if (cytoData) renderCytogeneticMap(); } function renderCytogeneticMap() { if (!cytoData || !cytoData.chromosomes) return; var canvas = document.getElementById('cytoCanvas'); if (!canvas) return; var ctx = canvas.getContext('2d'); var chromWidth = Math.round((cytoSettings.chromWidth || 28) * cytoZoomVal); var chromSpacing = Math.round((cytoSettings.chromSpacing || 60) * cytoZoomVal); var pxPerCm = (cytoSettings.pxPerCm || 5) * cytoZoomVal; var fontSize = Math.round((cytoSettings.fontSize || 11) * Math.max(0.75, cytoZoomVal)); var showScale = cytoSettings.showScale !== false; var showNames = cytoSettings.showNames || false; var scheme = colorSchemes[cytoSettings.colorScheme] || colorSchemes.classic; var chrs = cytoData.chromosomes; var n_chr = chrs.length; if (n_chr === 0) return; var maxPos = 0; for (var i = 0; i < n_chr; i++) { if (chrs[i].max_pos > maxPos) maxPos = chrs[i].max_pos; } var topPad = Math.round(70 * cytoZoomVal); var botPad = Math.round(80 * cytoZoomVal); var leftPad = Math.round(70 * cytoZoomVal); var rightPad = Math.round(30 * cytoZoomVal); var mapHeight = Math.ceil(maxPos * pxPerCm); var totalW = leftPad + n_chr * chromWidth + (n_chr - 1) * chromSpacing + rightPad; var totalH = topPad + mapHeight + botPad; canvas.width = Math.max(totalW, 300); canvas.height = Math.max(totalH, 300); canvas.style.width = canvas.width + 'px'; canvas.style.height = canvas.height + 'px'; // Background gradient var bgGrad = ctx.createLinearGradient(0, 0, 0, canvas.height); bgGrad.addColorStop(0, '#FAFBFF'); bgGrad.addColorStop(1, '#F0F4FF'); ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, canvas.width, canvas.height); // Horizontal grid lines if (maxPos > 0) { var scaleSteps = [5, 10, 20, 25, 50, 100, 150, 200]; var gridStep = 10; for (var si = 0; si < scaleSteps.length; si++) { if (mapHeight / (maxPos / scaleSteps[si]) >= 20) { gridStep = scaleSteps[si]; break; } } ctx.save(); ctx.strokeStyle = 'rgba(99,102,241,0.10)'; ctx.lineWidth = 1; ctx.setLineDash([4, 6]); for (var pos = 0; pos <= maxPos; pos += gridStep) { var gy = topPad + Math.round(pos * pxPerCm); ctx.beginPath(); ctx.moveTo(leftPad - 8, gy); ctx.lineTo(canvas.width - rightPad, gy); ctx.stroke(); } ctx.setLineDash([]); ctx.restore(); } canvas._hitRegions = []; for (var ci = 0; ci < n_chr; ci++) { var chr = chrs[ci]; var chrH = Math.max(Math.ceil(chr.max_pos * pxPerCm), 10); var x0 = leftPad + ci * (chromWidth + chromSpacing); var y0 = topPad; var markers = chr.markers; var nMk = markers.length; // Draw shadow ctx.save(); ctx.shadowColor = 'rgba(30,58,138,0.18)'; ctx.shadowBlur = Math.round(8 * cytoZoomVal); ctx.shadowOffsetX = Math.round(2 * cytoZoomVal); ctx.shadowOffsetY = Math.round(2 * cytoZoomVal); ctx.fillStyle = '#ccc'; var rShadow = Math.min(chromWidth * 0.35, 10); ctx.beginPath(); ctx.moveTo(x0 + rShadow, y0); ctx.lineTo(x0 + chromWidth - rShadow, y0); ctx.quadraticCurveTo(x0 + chromWidth, y0, x0 + chromWidth, y0 + rShadow); ctx.lineTo(x0 + chromWidth, y0 + chrH - rShadow); ctx.quadraticCurveTo(x0 + chromWidth, y0 + chrH, x0 + chromWidth - rShadow, y0 + chrH); ctx.lineTo(x0 + rShadow, y0 + chrH); ctx.quadraticCurveTo(x0, y0 + chrH, x0, y0 + chrH - rShadow); ctx.lineTo(x0, y0 + rShadow); ctx.quadraticCurveTo(x0, y0, x0 + rShadow, y0); ctx.closePath(); ctx.fill(); ctx.restore(); // Clip to chromosome shape var rr = Math.min(chromWidth * 0.35, 10); ctx.save(); ctx.beginPath(); ctx.moveTo(x0 + rr, y0); ctx.lineTo(x0 + chromWidth - rr, y0); ctx.quadraticCurveTo(x0 + chromWidth, y0, x0 + chromWidth, y0 + rr); ctx.lineTo(x0 + chromWidth, y0 + chrH - rr); ctx.quadraticCurveTo(x0 + chromWidth, y0 + chrH, x0 + chromWidth - rr, y0 + chrH); ctx.lineTo(x0 + rr, y0 + chrH); ctx.quadraticCurveTo(x0, y0 + chrH, x0, y0 + chrH - rr); ctx.lineTo(x0, y0 + rr); ctx.quadraticCurveTo(x0, y0, x0 + rr, y0); ctx.closePath(); ctx.clip(); // Base fill ctx.fillStyle = scheme.miss; ctx.fillRect(x0, y0, chromWidth, chrH); // Bands for (var mi = 0; mi < nMk; mi++) { var mk = markers[mi]; var yTop = y0 + Math.round(mk.position * pxPerCm); var yBot = (mi < nMk - 1) ? y0 + Math.round(markers[mi+1].position * pxPerCm) : y0 + chrH; var bandH = Math.max(yBot - yTop, 1); var dom = 'miss', maxFrac = 0; if (mk.allele_a > maxFrac) { maxFrac = mk.allele_a; dom = 'a'; } if (mk.allele_b > maxFrac) { maxFrac = mk.allele_b; dom = 'b'; } if (mk.allele_ab > maxFrac) { maxFrac = mk.allele_ab; dom = 'ab'; } ctx.fillStyle = scheme[dom] || scheme.miss; ctx.fillRect(x0, yTop, chromWidth, bandH); // Subtle band divider if (mi > 0 && bandH > 3) { ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(x0, yTop); ctx.lineTo(x0 + chromWidth, yTop); ctx.stroke(); } // Marker name in band if (showNames && bandH >= fontSize + 3) { ctx.save(); ctx.fillStyle = (dom === 'a' || dom === 'green_purple') ? '#FFFFFF' : '#0F172A'; ctx.font = 'bold ' + (fontSize - 1) + 'px JetBrains Mono, monospace'; ctx.textAlign = 'left'; ctx.fillText(mk.marker.substring(0, 14), x0 + 4, yTop + bandH / 2 + (fontSize - 1) / 3); ctx.restore(); } canvas._hitRegions.push({ x: x0, y: yTop, w: chromWidth, h: bandH, marker: mk.marker, position: mk.position, chromosome: chr.chromosome, a: mk.allele_a, b: mk.allele_b, ab: mk.allele_ab, miss: mk.allele_miss }); } ctx.restore(); // Chromosome border with gradient stroke ctx.save(); var gradBorder = ctx.createLinearGradient(x0, 0, x0 + chromWidth, 0); gradBorder.addColorStop(0, 'rgba(99,102,241,0.6)'); gradBorder.addColorStop(0.5, 'rgba(99,102,241,0.9)'); gradBorder.addColorStop(1, 'rgba(99,102,241,0.6)'); ctx.strokeStyle = gradBorder; ctx.lineWidth = Math.max(1.2, cytoZoomVal * 1.2); var rb = Math.min(chromWidth * 0.35, 10); ctx.beginPath(); ctx.moveTo(x0 + rb, y0); ctx.lineTo(x0 + chromWidth - rb, y0); ctx.quadraticCurveTo(x0 + chromWidth, y0, x0 + chromWidth, y0 + rb); ctx.lineTo(x0 + chromWidth, y0 + chrH - rb); ctx.quadraticCurveTo(x0 + chromWidth, y0 + chrH, x0 + chromWidth - rb, y0 + chrH); ctx.lineTo(x0 + rb, y0 + chrH); ctx.quadraticCurveTo(x0, y0 + chrH, x0, y0 + chrH - rb); ctx.lineTo(x0, y0 + rb); ctx.quadraticCurveTo(x0, y0, x0 + rb, y0); ctx.closePath(); ctx.stroke(); ctx.restore(); // Centromere-style notch if (chrH > 30) { var centY = y0 + Math.round(chrH * 0.45); ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.strokeStyle = 'rgba(99,102,241,0.6)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x0, centY - 4); ctx.lineTo(x0 + chromWidth / 2, centY); ctx.lineTo(x0 + chromWidth, centY - 4); ctx.lineTo(x0 + chromWidth, centY + 4); ctx.lineTo(x0 + chromWidth / 2, centY); ctx.lineTo(x0, centY + 4); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore(); } // Label below ctx.save(); ctx.fillStyle = '#1E3A8A'; ctx.font = 'bold ' + fontSize + 'px DM Sans, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Chr ' + chr.chromosome, x0 + chromWidth / 2, y0 + chrH + fontSize + 10); ctx.font = (fontSize - 1) + 'px DM Sans, sans-serif'; ctx.fillStyle = '#6366F1'; ctx.fillText('n=' + chr.n_markers, x0 + chromWidth / 2, y0 + chrH + fontSize * 2 + 14); ctx.restore(); // Length label above ctx.save(); ctx.fillStyle = '#475569'; ctx.font = (fontSize - 1) + 'px DM Sans, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(chr.max_pos.toFixed(1) + ' cM', x0 + chromWidth / 2, y0 - 10); ctx.restore(); } // cM scale bar if (showScale && maxPos > 0) { var scaleSteps2 = [5, 10, 20, 25, 50, 100, 150, 200]; var step2 = 10; for (var si2 = 0; si2 < scaleSteps2.length; si2++) { if (mapHeight / (maxPos / scaleSteps2[si2]) >= 22) { step2 = scaleSteps2[si2]; break; } } ctx.save(); ctx.strokeStyle = '#6366F1'; ctx.lineWidth = 1.5; ctx.fillStyle = '#374151'; ctx.font = (fontSize - 1) + 'px JetBrains Mono, monospace'; ctx.textAlign = 'right'; var scaleX = leftPad - 14; ctx.beginPath(); ctx.moveTo(scaleX, topPad); ctx.lineTo(scaleX, topPad + mapHeight); ctx.stroke(); for (var pos2 = 0; pos2 <= maxPos; pos2 += step2) { var ty2 = topPad + Math.round(pos2 * pxPerCm); ctx.beginPath(); ctx.moveTo(scaleX - 5, ty2); ctx.lineTo(scaleX, ty2); ctx.stroke(); ctx.fillText(pos2 + '', scaleX - 7, ty2 + 4); } // Scale label ctx.save(); ctx.translate(scaleX - 22, topPad + mapHeight / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = 'center'; ctx.fillStyle = '#6366F1'; ctx.font = 'bold ' + fontSize + 'px DM Sans, sans-serif'; ctx.fillText('Genetic Position (cM)', 0, 0); ctx.restore(); ctx.restore(); } } // Tooltip document.addEventListener('mousemove', function(e) { var canvas = document.getElementById('cytoCanvas'); if (!canvas || !canvas._hitRegions) return; var rect = canvas.getBoundingClientRect(); var scaleX = canvas.width / rect.width; var scaleY = canvas.height / rect.height; var cx = (e.clientX - rect.left) * scaleX; var cy = (e.clientY - rect.top) * scaleY; var tip = document.getElementById('cyto-tooltip'); var found = null; var regs = canvas._hitRegions; for (var i = 0; i < regs.length; i++) { var r = regs[i]; if (cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h) { found = r; break; } } if (found) { tip.style.display = 'block'; tip.style.left = (e.clientX + 16) + 'px'; tip.style.top = (e.clientY - 12) + 'px'; tip.innerHTML = '' + found.marker + '
' + 'Chr ' + found.chromosome + '  |  ' + found.position.toFixed(3) + ' cM
' + 'Parent A: ' + (found.a * 100).toFixed(1) + '%
' + 'Parent B: ' + (found.b * 100).toFixed(1) + '%
' + 'Het (ab): ' + (found.ab * 100).toFixed(1) + '%
' + 'Missing: ' + (found.miss * 100).toFixed(1) + '%'; } else { tip.style.display = 'none'; } }); // Receive plot data from Shiny Shiny.addCustomMessageHandler('updateCytoPlot', function(msg) { cytoData = msg.data; cytoSettings = msg.settings; updateLegendSwatches(cytoSettings.colorScheme || 'classic'); cytoZoomVal = 1.0; var zs = document.getElementById('cytoZoom'); if (zs) zs.value = '1'; var zl = document.getElementById('cytoZoomLabel'); if (zl) zl.textContent = '1.0×'; renderCytogeneticMap(); }); Shiny.addCustomMessageHandler('updateCytoSettings', function(msg) { cytoSettings = msg; updateLegendSwatches(cytoSettings.colorScheme || 'classic'); if (cytoData) renderCytogeneticMap(); }); // Save cytogenetic PNG document.addEventListener('click', function(e) { if (e.target && (e.target.id === 'cyto_save_png' || e.target.closest && e.target.closest('#cyto_save_png'))) { var canvas = document.getElementById('cytoCanvas'); if (!canvas) return; var link = document.createElement('a'); link.download = 'cytogenetic_map_' + new Date().toISOString().slice(0,10) + '.png'; link.href = canvas.toDataURL('image/png', 1.0); link.click(); } }); ")), div(class = "footer", "🧬 Linkage Map Creator v6.0 | High-Res Cytogenetic + ggplot Visualisation | Kosambi cM | © 2025") ) # ═══════════════════════════════════════════════════════════════════════════════ # SERVER # ═══════════════════════════════════════════════════════════════════════════════ server <- function(input, output, session) { uploaded_data <- reactiveVal(NULL) analysis_results <- reactiveVal(NULL) upload_log <- reactiveVal("") output$encoding_table <- renderTable({ data.frame( `Numeric Code` = c("2", "1", "0", "-1"), `Text Code` = c("a", "b", "ab", "-"), `Meaning` = c("Homozygous Parent 1 (AA)", "Homozygous Parent 2 (BB)", "Heterozygous (Aa)", "Missing data"), check.names = FALSE ) }, striped=TRUE, hover=TRUE, bordered=TRUE) # ── Upload ────────────────────────────────────────────────────────────────── observeEvent(input$file, { req(input$file) tryCatch({ df <- load_and_convert_data(input$file$datapath) n_chr <- length(unique(df$Chromosome)) log_msg <- paste0( "✓ File loaded: ", input$file$name, "\n", " Markers : ", nrow(df), "\n", " Individuals : ", ncol(df) - 3, "\n", " Chromosomes : ", n_chr, "\n", " (", paste(sort(unique(df$Chromosome)), collapse=", "), ")\n", " Genotype codes auto-converted to a/b/ab/-\n", " Marker order will be PRESERVED from input file.\n" ) uploaded_data(df) upload_log(log_msg) showNotification("✓ File uploaded successfully!", type="message", duration=3) }, error=function(e) { upload_log(paste0("✗ Error: ", e$message)) showNotification(paste("Upload error:", e$message), type="error") }) }) output$upload_status <- renderText({ upload_log() }) output$preview_table <- renderDataTable({ req(uploaded_data()) df_show <- head(uploaded_data(), 10) df_show <- df_show[, colnames(df_show) != ".input_order", drop=FALSE] datatable(df_show, options=list(scrollX=TRUE, dom="t", pageLength=10), rownames=FALSE) }) output$chr_summary_table <- renderDataTable({ req(uploaded_data()) df <- uploaded_data() chr_tab <- df %>% group_by(Chromosome) %>% summarise(N_Markers=n(), .groups="drop") %>% arrange(suppressWarnings(as.numeric(Chromosome))) datatable(chr_tab, options=list(dom="t", pageLength=30), rownames=FALSE) }) # ── Run analysis ───────────────────────────────────────────────────────────── observeEvent(input$run_analysis, { req(uploaded_data()) showModal(modalDialog( title = "🔬 Building Linkage Map…", paste0("Population: ", input$population_type, "\n", if (input$enable_chi_square) paste0("Chi-square alpha = ", input$chi_square_alpha) else "Chi-square: DISABLED"), footer = NULL, easyClose = FALSE )) tryCatch({ result <- create_linkage_map( df = uploaded_data(), population_type = input$population_type, missing_threshold = input$missing_threshold, recombination_threshold = input$recombination_threshold, max_total_cm = input$max_total_cm, scaling_method = input$scaling_method, enable_chi_square = input$enable_chi_square, chi_square_alpha = input$chi_square_alpha, use_bonferroni = input$use_bonferroni ) analysis_results(result) chromosomes <- unique(result$linkage_map$Chromosome) chr_sorted <- tryCatch( chromosomes[order(as.numeric(chromosomes))], warning = function(w) sort(chromosomes), error = function(e) sort(chromosomes) ) updateSelectInput(session, "gg_chromosome_select", choices=chr_sorted, selected=chr_sorted) updateSelectInput(session, "cyto_chromosome_select", choices=chr_sorted, selected=chr_sorted) updateSelectInput(session, "density_chr_select", choices=chr_sorted, selected=chr_sorted) updateSelectInput(session, "heatmap_chr_select", choices=chr_sorted, selected=chr_sorted) removeModal() n_pos <- sum(!is.na(result$linkage_map$Position)) n_removed <- nrow(result$removal_log) showNotification( paste0("✓ Analysis complete! ", n_pos, " markers positioned. ", n_removed, " removed. See QC Report."), type="message", duration=5 ) }, error=function(e) { removeModal() showNotification(paste("✗ Analysis error:", e$message), type="error", duration=10) }) }) output$analysis_run <- reactive({ !is.null(analysis_results()) }) outputOptions(output, "analysis_run", suspendWhenHidden=FALSE) # ── Cytogenetic canvas ──────────────────────────────────────────────────────── observe({ req(analysis_results(), input$cyto_chromosome_select) res <- analysis_results() lm <- res$linkage_map df_g <- res$original_data # Build cytogenetic plot data cyto_data_list <- build_cyto_plot_data(lm, df_g, input$cyto_chromosome_select) # Convert list to array for JavaScript chromosomes_array <- list() for (chr_name in names(cyto_data_list)) { chromosomes_array[[length(chromosomes_array) + 1]] <- cyto_data_list[[chr_name]] } msg <- list( data = list(chromosomes = chromosomes_array), settings = list( chromWidth = input$cyto_chrom_width, chromSpacing = input$cyto_chrom_spacing, pxPerCm = input$cyto_px_per_cm, fontSize = input$cyto_font_size, showScale = input$cyto_show_scale, showNames = input$cyto_show_marker_names, colorScheme = input$cyto_color_scheme ) ) session$sendCustomMessage("updateCytoPlot", msg) }) observeEvent( list(input$cyto_chrom_width, input$cyto_chrom_spacing, input$cyto_px_per_cm, input$cyto_font_size, input$cyto_show_scale, input$cyto_show_marker_names, input$cyto_color_scheme), { req(analysis_results()) session$sendCustomMessage("updateCytoSettings", list( chromWidth = input$cyto_chrom_width, chromSpacing = input$cyto_chrom_spacing, pxPerCm = input$cyto_px_per_cm, fontSize = input$cyto_font_size, showScale = input$cyto_show_scale, showNames = input$cyto_show_marker_names, colorScheme = input$cyto_color_scheme )) }, ignoreInit=TRUE) # ── QC outputs ─────────────────────────────────────────────────────────────── output$qc_report_output <- renderText({ if (is.null(analysis_results())) return("No analysis run yet.") analysis_results()$qc_report }) output$removal_log_table <- renderDataTable({ if (is.null(analysis_results())) return(datatable(data.frame(Message="Run analysis first."), options=list(dom="t"), rownames=FALSE)) log_df <- analysis_results()$removal_log if (nrow(log_df) == 0) return(datatable(data.frame(Message="No markers were removed."), options=list(dom="t"), rownames=FALSE)) datatable(log_df, options=list(scrollX=TRUE, pageLength=25), rownames=FALSE, caption=paste0(nrow(log_df), " marker(s) removed")) %>% formatStyle("Reason", color = styleEqual( c("Excess missing data","Monomorphic marker","Segregation distortion (chi-square)"), c("#DC2626","#7C3AED","#D97706")), fontWeight = "bold") }) # ── Results tab tables ──────────────────────────────────────────────────────── output$retained_markers_table <- renderDataTable({ req(analysis_results()) rml <- analysis_results()$retained_markers_list if (is.null(rml) || nrow(rml) == 0) return(datatable(data.frame(Message="No retained markers."), options=list(dom="t"), rownames=FALSE)) datatable(rml, options=list(scrollX=TRUE, pageLength=25), rownames=FALSE) %>% formatRound("Position", 3) }) output$all_markers_table <- renderDataTable({ req(analysis_results()) amp <- analysis_results()$all_markers_positions if (is.null(amp) || nrow(amp) == 0) return(datatable(data.frame(Message="No marker data."), options=list(dom="t"), rownames=FALSE)) datatable(amp, options=list(scrollX=TRUE, pageLength=25), rownames=FALSE) %>% formatRound("Position", 3) %>% formatStyle("Status", color = styleEqual(c("Retained","Removed"), c("#059669","#DC2626")), fontWeight = "bold") }) output$result_table <- renderDataTable({ req(analysis_results()) lm <- analysis_results()$linkage_map if (nrow(lm) == 0) return(datatable(data.frame(Message="No positioned markers."), options=list(dom="t"), rownames=FALSE)) datatable(lm, options=list(scrollX=TRUE, pageLength=25), rownames=FALSE) %>% formatRound("Position", 2) }) output$summary_stats <- renderText({ req(analysis_results()) lm <- analysis_results()$linkage_map rec <- analysis_results()$recombination_data if (nrow(lm) == 0) return("No positioned markers.") n_pos <- sum(!is.na(lm$Position)) n_chr <- length(unique(lm$Chromosome)) chr_lengths <- lm %>% filter(!is.na(Position)) %>% group_by(Chromosome) %>% summarise(Length=max(Position,na.rm=TRUE), N_markers=n(), .groups="drop") %>% arrange(suppressWarnings(as.numeric(Chromosome))) total_len <- sum(chr_lengths$Length) avg_len <- mean(chr_lengths$Length) chr_detail <- paste(apply(chr_lengths, 1, function(x) paste0(" Chr ", x[1], " : ", round(as.numeric(x[2]),2), " cM (", x[3], " markers)")), collapse="\n") log_df <- analysis_results()$removal_log n_removed <- nrow(log_df) n_mono <- sum(log_df$Reason == "Monomorphic marker") n_miss <- sum(log_df$Reason == "Excess missing data") n_chi <- sum(log_df$Reason == "Segregation distortion (chi-square)") chi_line <- if (input$enable_chi_square) paste0(" Removed (chi-square) : ", n_chi) else " Chi-square filter : DISABLED" paste0( "LINKAGE MAP SUMMARY (v6.0)\n===========================\n", "Population type : ", input$population_type, "\n", "Marker order : Preserved from input file\n\n", "Total markers input : ", nrow(analysis_results()$original_data), "\n", " Removed (missing) : ", n_miss, "\n", " Removed (monomorphic): ", n_mono, "\n", chi_line, "\n", " Total removed : ", n_removed, "\n", "Markers positioned : ", n_pos, "\n", "Chromosomes / LGs : ", n_chr, "\n", "Total map length : ", round(total_len,2), " cM\n", "Avg chromosome length : ", round(avg_len, 2), " cM\n", "Marker pairs analysed : ", nrow(rec), "\n\n", "PER-CHROMOSOME DETAILS:\n", chr_detail ) }) output$scaling_summary <- renderText({ req(analysis_results()) paste0("Method : ", input$scaling_method, "\nMax cM : ", input$max_total_cm) }) fmt_chr <- function(df) { if (is.null(df) || nrow(df) == 0) return("No data.") total <- sum(df$Original_Length, na.rm=TRUE) rows <- apply(df, 1, function(x) paste0(" Chr ", x[1], " : ", round(as.numeric(x[2]),2), " cM")) paste0("Total : ", round(total,2), " cM\n", paste(rows, collapse="\n")) } output$before_scaling_stats <- renderText({ req(analysis_results()); fmt_chr(analysis_results()$chrom_lengths_before) }) output$after_scaling_stats <- renderText({ req(analysis_results()); fmt_chr(analysis_results()$chrom_lengths_after) }) # ── ggplot linkage map ──────────────────────────────────────────────────────── gg_plot_reactive <- reactive({ req(analysis_results(), input$gg_chromosome_select) create_ggplot_linkage_map( linkage_map = analysis_results()$linkage_map, selected_chromosomes = input$gg_chromosome_select, show_labels = input$gg_show_labels, label_size = input$gg_label_size, color_palette = input$gg_color_palette, chrom_width = input$gg_chrom_width, point_size = input$gg_point_size, chrom_spacing = input$gg_chrom_spacing, plot_theme = input$gg_theme ) }) output$gg_linkage_plot <- renderPlot({ gg_plot_reactive() }, height = function() input$gg_plot_height, res = 110) output$download_gg_plot <- downloadHandler( filename = function() paste0("linkage_map_", Sys.Date(), ".png"), content = function(file) { req(analysis_results(), input$gg_chromosome_select) p <- create_ggplot_linkage_map( linkage_map = analysis_results()$linkage_map, selected_chromosomes = input$gg_chromosome_select, show_labels = input$gg_show_labels, label_size = input$gg_label_size, color_palette = input$gg_color_palette, chrom_width = input$gg_chrom_width, point_size = input$gg_point_size, chrom_spacing = input$gg_chrom_spacing, plot_theme = input$gg_theme ) n_chr <- length(input$gg_chromosome_select) w_in <- max(7, n_chr * 1.2 + 2) ggsave(file, p, width=w_in, height=10, dpi=300, bg="white") } ) # ── Density plot ────────────────────────────────────────────────────────────── output$density_plot <- renderPlot({ req(analysis_results(), input$density_chr_select) create_marker_density_plot( analysis_results()$linkage_map, input$density_chr_select, input$density_bin ) }, res=110) output$download_density_plot <- downloadHandler( filename = function() paste0("marker_density_", Sys.Date(), ".png"), content = function(file) { req(analysis_results(), input$density_chr_select) p <- create_marker_density_plot(analysis_results()$linkage_map, input$density_chr_select, input$density_bin) ggsave(file, p, width=12, height=8, dpi=300, bg="white") } ) # ── Heatmap plot ────────────────────────────────────────────────────────────── output$heatmap_plot <- renderPlot({ req(analysis_results(), input$heatmap_chr_select) create_recombination_heatmap( analysis_results()$recombination_data, input$heatmap_chr_select, input$heatmap_top_n ) }, res=110) output$download_heatmap_plot <- downloadHandler( filename = function() paste0("recombination_heatmap_", Sys.Date(), ".png"), content = function(file) { req(analysis_results(), input$heatmap_chr_select) p <- create_recombination_heatmap(analysis_results()$recombination_data, input$heatmap_chr_select, input$heatmap_top_n) ggsave(file, p, width=14, height=10, dpi=300, bg="white") } ) # ── Downloads ───────────────────────────────────────────────────────────────── output$download_removal_log <- downloadHandler( filename = function() paste0("removal_log_", Sys.Date(), ".xlsx"), content = function(file) { req(analysis_results()) wb <- createWorkbook() addWorksheet(wb, "Removed_Markers") log_df <- analysis_results()$removal_log if (nrow(log_df) == 0) log_df <- data.frame(Message="No markers were removed.") writeData(wb, "Removed_Markers", log_df) saveWorkbook(wb, file, overwrite=TRUE) } ) output$download_all_markers <- downloadHandler( filename = function() paste0("all_markers_positions_", Sys.Date(), ".xlsx"), content = function(file) { req(analysis_results()) amp <- analysis_results()$all_markers_positions wb <- createWorkbook() addWorksheet(wb, "All_Markers_Positions") writeData(wb, "All_Markers_Positions", amp) hs <- createStyle(fontColour="#FFFFFF", fgFill="#1E3A8A", halign="CENTER", textDecoration="Bold", border="Bottom") addStyle(wb, "All_Markers_Positions", hs, rows=1, cols=1:ncol(amp), gridExpand=TRUE) removed_rows <- which(amp$Status == "Removed") + 1 retained_rows <- which(amp$Status == "Retained") + 1 if (length(removed_rows) > 0) addStyle(wb, "All_Markers_Positions", createStyle(fgFill="#FEE2E2"), rows=removed_rows, cols=1:ncol(amp), gridExpand=TRUE) if (length(retained_rows) > 0) addStyle(wb, "All_Markers_Positions", createStyle(fgFill="#D1FAE5"), rows=retained_rows, cols=1:ncol(amp), gridExpand=TRUE) setColWidths(wb, "All_Markers_Positions", cols=1:ncol(amp), widths=c(20,12,10,12,30,40)) saveWorkbook(wb, file, overwrite=TRUE) } ) output$download_retained_markers <- downloadHandler( filename = function() paste0("retained_markers_", Sys.Date(), ".xlsx"), content = function(file) { req(analysis_results()) rml <- analysis_results()$retained_markers_list wb <- createWorkbook() addWorksheet(wb, "Retained_Markers") writeData(wb, "Retained_Markers", rml) hs <- createStyle(fontColour="#FFFFFF", fgFill="#059669", halign="CENTER", textDecoration="Bold", border="Bottom") addStyle(wb, "Retained_Markers", hs, rows=1, cols=1:ncol(rml), gridExpand=TRUE) if (nrow(rml) > 0) addStyle(wb, "Retained_Markers", createStyle(fgFill="#D1FAE5"), rows=2:(nrow(rml)+1), cols=1:ncol(rml), gridExpand=TRUE) setColWidths(wb, "Retained_Markers", cols=1:ncol(rml), widths=c(20,12,12,10)) saveWorkbook(wb, file, overwrite=TRUE) } ) output$download_results <- downloadHandler( filename = function() paste0("linkage_map_v60_", Sys.Date(), ".xlsx"), content = function(file) { req(analysis_results()) wb <- createWorkbook() sheets <- c("Original_Data","Recombination_Fractions","Linkage_Map", "All_Markers_Positions","Retained_Markers","Removal_Log","QC_Report") for (s in sheets) addWorksheet(wb, s) orig <- analysis_results()$original_data orig <- orig[, colnames(orig) != ".input_order", drop=FALSE] writeData(wb, "Original_Data", orig) writeData(wb, "Recombination_Fractions", analysis_results()$recombination_data) writeData(wb, "Linkage_Map", analysis_results()$linkage_map) writeData(wb, "All_Markers_Positions", analysis_results()$all_markers_positions) writeData(wb, "Retained_Markers", analysis_results()$retained_markers_list) writeData(wb, "Removal_Log", analysis_results()$removal_log) writeData(wb, "QC_Report", data.frame(Report=analysis_results()$qc_report)) saveWorkbook(wb, file, overwrite=TRUE) } ) } shinyApp(ui = ui, server = server)