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)