From 4c56f809e306ce4b9d35028255d816bc66a1b2a2 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:01:15 +0200 Subject: [PATCH 01/20] Add exact residue numbering, pLDDT colours and comparison lookup helpers --- shinyRam/R/inspection.R | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/shinyRam/R/inspection.R b/shinyRam/R/inspection.R index f6fe41d..cab9443 100644 --- a/shinyRam/R/inspection.R +++ b/shinyRam/R/inspection.R @@ -155,6 +155,46 @@ ram_sequence_status <- function(region) { out } +# Confidence is an independent per-residue signal: never use its colours to +# replace the Ramachandran classification background. +ram_plddt_color <- function(score) { + vapply(as.numeric(score), function(value) { + if (!is.finite(value)) "#cbd7db" else if (value < 50) "#d75e56" + else if (value < 70) "#d6ac52" else if (value < 90) "#7bbcb1" + else "#126e74" + }, character(1)) +} + +# Permanent position labels mark every tenth *PDB residue number*, not every +# tenth item in the sequence. Keep insertion codes on labelled residues. +ram_sequence_position_labels <- function(resi, insertion_code = rep("", length(resi))) { + stopifnot(length(resi) == length(insertion_code)) + if (!length(resi)) return(character()) + index <- seq_along(resi) + label <- index == 1L | index == length(resi) | + (!is.na(resi) & resi %% 10L == 0L) + out <- rep("", length(resi)) + out[label] <- paste0(resi[label], ifelse(is.na(insertion_code[label]), "", + insertion_code[label])) + out +} + +# Map an NGL/sequence residue to its pair using chain, PDB numbering and +# insertion code. The displayed amino-acid order may contain alignment gaps. +ram_comparison_find <- function(data, side, chain, resi, insertion_code = "") { + stopifnot(side %in% c("a", "b")) + if (!nrow(data) || length(chain) != 1L || length(resi) != 1L || + length(insertion_code) != 1L || is.na(chain) || is.na(resi) || + is.na(insertion_code)) return(NA_integer_) + number <- suppressWarnings(as.integer(resi)) + if (is.na(number)) return(NA_integer_) + ix <- which(!is.na(data[[paste0("residue_", side)]]) & + data[[paste0("chain_", side)]] == as.character(chain) & + data[[paste0("residue_", side)]] == number & + data[[paste0("insertion_", side)]] == as.character(insertion_code)) + if (!length(ix)) NA_integer_ else as.integer(ix[[1L]]) +} + # Small overview strips represent *positions*, not aggregate percentages. # For long chains each strip cell represents a consecutive residue bin. # A bin takes the highest-priority review state so isolated outliers remain From e6bda4406e22bd48c8d7f43db805f7e3b32a7a06 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:01:39 +0200 Subject: [PATCH 02/20] Redesign Compare workspace and add indexed, confidence-labelled sequence navigation --- shinyRam/app.R | 60 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index 3eb8944..c1543ed 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -347,7 +347,7 @@ ui <- fluidPage( ), tags$div(class = "ram-sequence-detail", tags$p(class = "ram-sequence-instruction", - "Select a letter to highlight its Ramachandran point and zoom in 3D. Dimmed letters are hidden by the current filters; scroll each chain sideways for more residues."), + "Permanent labels show actual PDB residue numbers every ten positions. Enter a number beside a chain to jump directly to it. The colour behind each letter shows Ramachandran classification; the separate coloured underline and number indicate pLDDT, when available."), tags$div(class = "ram-sequence-legend", tags$span(class="ram-swatch ram-sw-favoured", "Favoured"), tags$span(class="ram-swatch ram-sw-allowed", "Allowed"), @@ -355,6 +355,14 @@ ui <- fluidPage( tags$span(class="ram-swatch ram-sw-outlier", "Outlier"), tags$span(class="ram-swatch ram-sw-missing", "Missing angles") ), + tags$div(class="ram-sequence-confidence-key", + tags$strong("Model confidence · pLDDT"), + tags$span(class="ram-confidence-key-high", "≥90"), + tags$span(class="ram-confidence-key-good", "70–89"), + tags$span(class="ram-confidence-key-low", "50–69"), + tags$span(class="ram-confidence-key-poor", "<50"), + tags$span("The number below each amino acid is its pLDDT score.") + ), uiOutput("sequenceView") ) ) @@ -477,7 +485,41 @@ ui <- fluidPage( ), uiOutput("compareChainControls"), tags$div(class = "ram-compare-status", uiOutput("compareSummary")), - tags$div(id = "comparePlot", class = "ram-compare-plot"), + tags$div(class = "ram-compare-toolbar", + selectInput("compareJumpSide", "Locate in", c( + "Primary chain" = "a", "Comparison chain" = "b")), + numericInput("compareJumpResidue", "Residue number", + value = NA, min = 1, step = 1, width = "135px"), + actionButton("compareJump", "Find aligned pair", + class = "btn-primary"), + tags$p(class = "ram-compare-toolbar-hint", + "Uses actual residue numbers, including alignment gaps.") + ), + tags$div(class = "ram-compare-workspace", + tags$section(class = "ram-compare-card", + tags$div(class = "ram-compare-card-head", + tags$h3("Aligned backbone angles"), + tags$p("Select either colour to inspect that aligned residue pair.") + ), + tags$div(id = "comparePlot", class = "ram-compare-plot") + ), + tags$section(class = "ram-compare-card ram-compare-viewer", + tags$div(class = "ram-compare-card-head", + tags$h3("3D superposition"), + tags$p("Primary chain in coral, comparison chain in blue. Click either structure to inspect aligned residues.") + ), + tags$div(class = "ram-compare-viewer-controls", + checkboxInput("showComparison3D", "Show 3D", value = TRUE), + actionButton("compareResetView", "Fit both chains", + class = "btn-default btn-sm") + ), + conditionalPanel(condition = "input.showComparison3D", + tags$div(class = "ram-compare-ngl", + NGLVieweR::NGLVieweROutput("NGLCompare", + height = "410px"))) + ) + ), + uiOutput("compareSelectionInfo"), tags$div(class = "ram-table-toolbar", selectInput("compareFilter", "Show comparison", choices = c("All aligned residues" = "All", @@ -486,16 +528,9 @@ ui <- fluidPage( "Insertions / deletions" = "gaps"), selected = "All"), downloadButton("downloadComparison", "Export comparison CSV") ), - tags$div(class = "ram-residue-table", DT::DTOutput("comparison")), - tags$details(class = "ram-details", - tags$summary("Optional 3D superposition"), - tags$p(class = "ram-field-hint", - "Aligns the chosen chains in NGL for a visual comparison. Large structures may render slowly."), - checkboxInput("showComparison3D", "Show superposed structures", - value = FALSE), - conditionalPanel(condition = "input.showComparison3D", - NGLVieweR::NGLVieweROutput("NGLCompare", height = "460px")) - ) + tags$p(class = "ram-table-hint", + "Select a row to highlight its corresponding residues in both 3D structures and the angle plot."), + tags$div(class = "ram-residue-table", DT::DTOutput("comparison")) ) ), tabPanel( @@ -539,6 +574,7 @@ ui <- fluidPage( ), tags$script(src = "plotly-loader.js"), tags$script(src = "custom.js"), + tags$script(src = "compare.js"), tags$script(src = "prediction.js"), tags$script(src = "density.js") ) From 667cc3802735e1b329c41c249677434379637c8c Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:02:05 +0200 Subject: [PATCH 03/20] Add visible residue numbers, per-residue pLDDT scores and per-chain jump controls --- shinyRam/app.R | 65 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index c1543ed..f9fd822 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -1150,28 +1150,57 @@ server <- function(input, output, session) { chain_name else paste("Chain",chain_name)), tags$span(sprintf("%s residues", format(nrow(chain),big.mark=","))), + tags$div(class="ram-sequence-jump", + tags$label("Go to", class="sr-only"), + tags$input(type="number", class="ram-seq-jump-input", + min="1", step="1", placeholder="Residue #", + "aria-label"=paste("Jump to residue number in",chain_name)), + tags$button(type="button", class="ram-seq-jump", + "data-chain"=if (identical(chain_name,"Unassigned")) "" else chain_name, + "aria-label"=paste("Go to residue in",chain_name), "Go") + ), tags$span(class="ram-sequence-scroll-hint","Scroll sideways →") ), tags$div(class="ram-sequence-grid", role="group", "aria-label"=paste("Select a residue in",chain_name), - lapply(seq_len(nrow(chain)), function(i) { - residue <- chain[i,,drop=FALSE] - tags$button(type="button", - class=paste("ram-seq-res", - paste0("ram-seq-",statuses[[i]])), - disabled=if (!selectable[[i]]) "disabled" else NULL, - "data-chain"=residue$chain[[1L]], - "data-resi"=residue$resi[[1L]], - "data-insertion"=residue$insertion_code[[1L]], - title=sprintf("%s %s%d%s · %s", - residue$resn[[1L]],residue$chain[[1L]], - residue$resi[[1L]],residue$insertion_code[[1L]], - paste0(if (is.na(residue$region[[1L]])) "Missing angles" - else residue$region[[1L]], - if (!selectable[[i]]) " · Hidden by current filters" else "")), - "aria-pressed"="false", - residue$letter[[1L]]) - }) + { + labels <- ram_sequence_position_labels(chain$resi, chain$insertion_code) + show_confidence <- any(is.finite(chain$plddt)) + lapply(seq_len(nrow(chain)), function(i) { + residue <- chain[i,,drop=FALSE] + score <- residue$plddt[[1L]] + position <- paste0(residue$resi[[1L]], + residue$insertion_code[[1L]]) + tags$div(class="ram-seq-slot", + tags$span(class="ram-seq-position", + if (nzchar(labels[[i]])) labels[[i]] else "\u00a0", + "aria-hidden"="true"), + tags$button(type="button", + class=paste("ram-seq-res", + paste0("ram-seq-",statuses[[i]]), + if (show_confidence) "ram-seq-with-confidence" else ""), + style=if (is.finite(score)) + paste0("--ram-plddt-color:",ram_plddt_color(score)) else NULL, + disabled=if (!selectable[[i]]) "disabled" else NULL, + "data-chain"=residue$chain[[1L]], + "data-resi"=residue$resi[[1L]], + "data-insertion"=residue$insertion_code[[1L]], + title=paste0(residue$resn[[1L]], " ", + residue$chain[[1L]], position, " · ", + if (is.na(residue$region[[1L]])) "Missing angles" + else residue$region[[1L]], + if (is.finite(score)) sprintf(" · pLDDT %.1f",score) + else "", + if (!selectable[[i]]) " · Hidden by current filters" + else ""), + "aria-pressed"="false", + tags$span(class="ram-seq-aa",residue$letter[[1L]]), + if (show_confidence) tags$span(class="ram-seq-plddt", + if (is.finite(score)) sprintf("%.0f",score) else "\u2014") + ) + ) + }) + } ) ) }) From 95d12d9c443b7e7af74952aedf3759934318fbeb Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:03:14 +0200 Subject: [PATCH 04/20] Wire aligned-pair selection across table, plot, sequence and model-aware 3D comparison --- shinyRam/app.R | 258 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 233 insertions(+), 25 deletions(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index f9fd822..60f9208 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -587,6 +587,7 @@ server <- function(input, output, session) { external_validation <- reactiveVal(NULL) ensemble_results <- reactiveVal(NULL) selected_residue <- reactiveVal(NULL) + selected_comparison <- reactiveVal(NULL) viewer_ready <- reactiveVal(FALSE) current_model <- reactive({ value <- input$modelChoice @@ -1380,8 +1381,96 @@ server <- function(input, output, session) { secondary <- secondary[secondary$chain == input$compareChainB, , drop=FALSE] if (!nrow(original) || !nrow(secondary)) return(data.frame()) - ram_compare_torsions(original, secondary) + result <- ram_compare_torsions(original, secondary) + result$row_id <- seq_len(nrow(result)) + result + }) + # A selection identifies a whole aligned pair, not just a numeric residue. + # It remains stable when the comparison table is filtered or re-ordered. + choose_comparison <- function(index) { + data <- isolate(comparison_data()) + row_index <- suppressWarnings(as.integer(index)) + if (length(row_index) != 1L || is.na(row_index) || + row_index < 1L || row_index > nrow(data)) return(invisible(FALSE)) + row <- data[row_index, , drop=FALSE] + selected_comparison(row$row_id[[1L]]) + # Make the shared inspector and main sequence navigator follow the + # primary chain, without selecting residues hidden by main plot filters. + if (!is.na(row$residue_a[[1L]])) { + visible <- isolate(displayed()) + matches <- which(visible$chain == row$chain_a[[1L]] & + visible$resi == row$residue_a[[1L]] & + visible$insertion_code == row$insertion_a[[1L]]) + if (length(matches)) selected_residue(list( + chain = row$chain_a[[1L]], + resi = as.integer(row$residue_a[[1L]]), + insertion_code = row$insertion_a[[1L]] + )) + } + invisible(TRUE) + } + observeEvent(list(input$compareChainA, input$compareChainB, + input$compareModel, comparison_loaded()), { + selected_comparison(NULL) + }, ignoreInit=TRUE) + observeEvent(input$ramComparePlotPick, { + choose_comparison(input$ramComparePlotPick) + }, ignoreInit=TRUE) + observeEvent(input$comparison_row_last_clicked, { + rows <- filtered_comparison() + i <- suppressWarnings(as.integer(input$comparison_row_last_clicked)) + if (length(i) != 1L || is.na(i) || i < 1L || i > nrow(rows)) return() + choose_comparison(rows$row_id[[i]]) + }, ignoreInit=TRUE) + observeEvent(input$ramCompareNglPick, { + item <- input$ramCompareNglPick + if (!is.list(item) || is.null(item$side) || + !item$side %in% c("a", "b")) return() + data <- isolate(comparison_data()) + index <- ram_comparison_find(data, item$side, item$chain, + item$resi, if (is.null(item$insertion_code)) + "" else item$insertion_code) + if (!is.na(index)) choose_comparison(index) + }, ignoreInit=TRUE) + observeEvent(input$compareJump, { + value <- suppressWarnings(as.integer(input$compareJumpResidue)) + if (length(value) != 1L || is.na(value)) { + showNotification("Enter a valid residue number.", type="warning") + return() + } + data <- isolate(comparison_data()) + side <- isolate(input$compareJumpSide) + chain <- if (identical(side,"b")) isolate(input$compareChainB) else + isolate(input$compareChainA) + index <- ram_comparison_find(data, if (identical(side,"b")) "b" else "a", + chain, value) + if (is.na(index)) { + # A jump to 104 should also find insertion-only 104A when necessary. + positions <- data[[paste0("residue_", if (identical(side,"b")) "b" else "a")]] + candidates <- which(!is.na(positions) & positions == value & + data[[paste0("chain_", if (identical(side,"b")) "b" else "a")]] == chain) + index <- if (length(candidates)) candidates[[1L]] else NA_integer_ + } + if (is.na(index)) { + showNotification("That number is not present in the selected chain.", + type="warning") + return() + } + choose_comparison(index) }) + # Selecting a residue in the primary sequence/plot also locates its aligned + # partner when the comparison is available. + observeEvent(selected_residue(), { + item <- selected_residue() + if (is.null(item) || is.null(isolate(input$compareChainA)) || + !identical(item$chain, isolate(input$compareChainA)) || + is.null(isolate(comparison_loaded()))) return() + index <- ram_comparison_find(isolate(comparison_data()), "a", + item$chain, item$resi, item$insertion_code) + if (!is.na(index) && + !identical(isolate(selected_comparison()), index)) + selected_comparison(index) + }, ignoreNULL=TRUE) filtered_comparison <- reactive({ result <- comparison_data() if (!nrow(result)) return(result) @@ -1410,20 +1499,36 @@ server <- function(input, output, session) { }) output$comparison <- DT::renderDT({ result <- filtered_comparison() - names <- c("chain_a","residue_a","amino_a", - "chain_b","residue_b","amino_b","delta_phi","delta_psi", - "class_changed","alignment") - if (!all(names %in% names(result))) return(DT::datatable(data.frame())) - shown <- result[,names,drop=FALSE] - shown$delta_phi <- round(shown$delta_phi,1) - shown$delta_psi <- round(shown$delta_psi,1) - shown$class_changed <- ifelse(shown$class_changed,"Yes","No") + fields <- c("chain_a", "residue_a", "insertion_a", "amino_a", + "chain_b", "residue_b", "insertion_b", "amino_b", + "delta_phi", "delta_psi", "class_changed", "alignment") + if (!all(fields %in% names(result))) + return(DT::datatable(data.frame())) + shown <- result[, fields, drop=FALSE] + shown$pos_a <- ifelse(is.na(shown$residue_a), "—", + paste0(shown$residue_a, shown$insertion_a)) + shown$pos_b <- ifelse(is.na(shown$residue_b), "—", + paste0(shown$residue_b, shown$insertion_b)) + shown$delta_phi <- round(shown$delta_phi, 1) + shown$delta_psi <- round(shown$delta_psi, 1) + shown$class_changed <- ifelse(shown$class_changed, "Yes", "No") + shown <- shown[, c("chain_a", "pos_a", "amino_a", + "chain_b", "pos_b", "amino_b", "delta_phi", "delta_psi", + "class_changed", "alignment"), drop=FALSE] DT::datatable(shown, rownames=FALSE, - colnames=c("Chain A","Pos A","AA A","Chain B","Pos B","AA B", - "Δφ (°)","Δψ (°)","Region changed","Alignment"), + colnames=c("Chain A", "Pos A", "AA A", "Chain B", "Pos B", "AA B", + "Δφ (°)", "Δψ (°)", "Region changed", "Alignment"), + selection="single", options=list(pageLength=15,scrollX=FALSE,autoWidth=FALSE,dom="ftip"), class="compact stripe hover") }, server=FALSE) + observe({ + rows <- req(filtered_comparison()) + index <- selected_comparison() + pos <- if (is.null(index)) integer() else match(index, rows$row_id) + DT::selectRows(DT::dataTableProxy("comparison", session = session), + if (length(pos) && !is.na(pos)) pos else integer()) + }) output$downloadComparison <- downloadHandler( filename=function() "RamplotR_structure_comparison.csv", content=function(file) utils::write.csv( @@ -1431,31 +1536,134 @@ server <- function(input, output, session) { ) observeEvent(comparison_data(), { result <- comparison_data() - if (nrow(result)) - session$sendCustomMessage("ram-comparison", list( - nameA=req(loaded())$name, - nameB=req(comparison_loaded())$name, - phiA=result$phi_a, psiA=result$psi_a, - phiB=result$phi_b, psiB=result$psi_b - )) + if (!nrow(result)) return() + session$sendCustomMessage("ram-comparison", list( + nameA=req(loaded())$name, + nameB=req(comparison_loaded())$name, + phiA=result$phi_a, psiA=result$psi_a, + phiB=result$phi_b, psiB=result$psi_b, + rowIds=result$row_id, + chainA=result$chain_a, posA=result$residue_a, + insA=result$insertion_a, aminoA=result$amino_a, + chainB=result$chain_b, posB=result$residue_b, + insB=result$insertion_b, aminoB=result$amino_b, + deltaPhi=result$delta_phi, deltaPsi=result$delta_psi, + alignment=result$alignment + )) + session$sendCustomMessage("ram-compare-config", list( + chainA=input$compareChainA, chainB=input$compareChainB, + modelA=current_model(), modelB=if (is.null(input$compareModel)) 1L + else as.integer(input$compareModel), + multipleA=req(loaded())$nmodels > 1L, + multipleB=req(comparison_loaded())$nmodels > 1L + )) + }) + output$compareSelectionInfo <- renderUI({ + data <- req(comparison_data()) + id <- selected_comparison() + if (!length(id) || is.null(id) || !nrow(data)) + return(tags$div(class="ram-compare-selection ram-compare-selection-empty", + tags$strong("Inspect an aligned pair"), + tags$p("Click a point, table row or residue in either 3D structure. Use the position finder for residues such as 104."))) + match_index <- match(id, data$row_id) + if (is.na(match_index)) return(NULL) + row <- data[match_index,,drop=FALSE] + label <- function(side) { + number <- row[[paste0("residue_",side)]][[1L]] + if (is.na(number)) return("Alignment gap") + paste0(row[[paste0("amino_",side)]][[1L]], " ", + row[[paste0("chain_",side)]][[1L]], ":", + number, row[[paste0("insertion_",side)]][[1L]]) + } + angle <- function(x) if (is.finite(x)) sprintf("%.1f°",x) else "N/A" + tags$div(class="ram-compare-selection", + tags$div(class="ram-compare-selection-pair", + tags$span(class="ram-compare-primary", + tags$small("Primary"), tags$strong(label("a")), + tags$span(paste("φ",angle(row$phi_a[[1L]]), + "· ψ",angle(row$psi_a[[1L]])))), + tags$span(class="ram-compare-pair-arrow", "↔", "aria-hidden"="true"), + tags$span(class="ram-compare-secondary", + tags$small("Comparison"), tags$strong(label("b")), + tags$span(paste("φ",angle(row$phi_b[[1L]]), + "· ψ",angle(row$psi_b[[1L]])))) + ), + tags$div(class="ram-compare-selection-deltas", + tags$span(paste("Δφ",angle(row$delta_phi[[1L]]))), + tags$span(paste("Δψ",angle(row$delta_psi[[1L]]))), + tags$span(row$alignment[[1L]]), + if (isTRUE(row$class_changed[[1L]])) tags$span( + class="ram-compare-change", "Classification changed") + ) + ) + }) + outputOptions(output, "compareSelectionInfo", suspendWhenHidden=FALSE) + observe({ + id <- selected_comparison() + data <- comparison_data() + if (!length(id) || is.null(id) || !nrow(data)) { + session$sendCustomMessage("ram-comparison-selected", list(clear=TRUE)) + session$sendCustomMessage("ram-compare-pair", list(clear=TRUE)) + return() + } + pos <- match(id,data$row_id) + if (is.na(pos)) return() + row <- data[pos,,drop=FALSE] + pair <- function(side,model,multiple) { + number <- row[[paste0("residue_",side)]][[1L]] + if (is.na(number)) return(NULL) + list(chain=as.character(row[[paste0("chain_",side)]][[1L]]), + resi=as.integer(number), + insertion_code=as.character(row[[paste0("insertion_",side)]][[1L]]), + modelIndex=model, multipleModels=multiple) + } + second <- req(comparison_loaded()) + session$sendCustomMessage("ram-comparison-selected", + list(rowId=id)) + session$sendCustomMessage("ram-compare-pair", list( + a=pair("a",current_model(),req(loaded())$nmodels > 1L), + b=pair("b",if (is.null(input$compareModel)) 1L else + as.integer(input$compareModel),second$nmodels > 1L) + )) }) output$NGLCompare <- NGLVieweR::renderNGLVieweR({ req(input$showComparison3D,input$compareChainA,input$compareChainB) + req(comparison_data()) first <- req(loaded()); second <- req(comparison_loaded()) + model_a <- if (first$nmodels > 1L) + paste0(" and /",current_model()-1L) else "" + model_b <- if (second$nmodels > 1L) + paste0(" and /",if (is.null(input$compareModel)) 0L else + as.integer(input$compareModel)-1L) else "" + sel_a <- paste0(":", input$compareChainA, model_a, " and protein") + sel_b <- paste0(":", input$compareChainB, model_b, " and protein") widget <- NGLVieweR(data=first$source_id,format=first$viewer_format) %>% NGLVieweR::stageParameters(backgroundColor="#f7fafb") %>% - addRepresentation("cartoon", param=list( - sele=paste0(":",input$compareChainA," and protein"), - color="#CE6A4D",name="primary")) + addRepresentation("cartoon",param=list( + sele=sel_a,color="#CE6A4D",name="ram-compare-chain-a")) %>% + addRepresentation("ball+stick",param=list( + sele="none",color="#ffc04a",scale=1.5,name="ram-compare-highlight-a")) widget <- NGLVieweR::addStructure(widget, data=second$source_id, format=second$viewer_format) %>% addRepresentation("cartoon",param=list( - sele=paste0(":",input$compareChainB," and protein"), - color="#317E9A",name="secondary")) + sele=sel_b,color="#317E9A",name="ram-compare-chain-b")) %>% + addRepresentation("ball+stick",param=list( + sele="none",color="#83e6f5",scale=1.5,name="ram-compare-highlight-b")) NGLVieweR::setSuperpose(widget, reference=1, - sele_reference=paste0(":",input$compareChainA), - sele_target=paste0(":",input$compareChainB)) + sele_reference=sel_a, sele_target=sel_b) }) + observeEvent(input$NGLCompare_PDB, { + if (is.null(isolate(comparison_loaded()))) return() + session$sendCustomMessage("ram-compare-ready", list()) + }, ignoreInit=TRUE) + observeEvent(input$NGLCompare_rendering, { + if (!identical(input$NGLCompare_rendering, FALSE) || + is.null(isolate(comparison_loaded()))) return() + # Some NGL versions do not emit a changed PDB input when users switch + # chains of the same structure. The JS readiness guard checks both + # structure objects before reframing. + session$sendCustomMessage("ram-compare-ready", list()) + }, ignoreInit=TRUE) output$ensemblePanel <- renderUI({ structure <- req(loaded()) From 36a38c89b627db3d8da2c656dafa6b969d7fa41f Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:04:06 +0200 Subject: [PATCH 05/20] Link NGL superposition clicks, paired highlights and selection-aware camera framing --- shinyRam/www/compare.js | 245 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 shinyRam/www/compare.js diff --git a/shinyRam/www/compare.js b/shinyRam/www/compare.js new file mode 100644 index 0000000..ce29d42 --- /dev/null +++ b/shinyRam/www/compare.js @@ -0,0 +1,245 @@ +/* Linked 3D comparison. The NGL widget owns loading and superposition; + * RamplotR only frames the two selected chains and highlights aligned pairs. + */ +(function (window, document) { + "use strict"; + let config = null; + let selected = null; + let stage = null; + let components = null; + let boundStage = null; + let pickHandler = null; + let needsFit = true; + let retry = 0; + let fitScheduled = false; + + function ready() { + if (typeof window.getNGLStage !== "function" || + typeof window.getNGLStructure !== "function") return false; + const currentStage = window.getNGLStage("NGLCompare"); + const loaded = window.getNGLStructure("NGLCompare"); + // NGLVieweR replaces stage components asynchronously when the two inputs + // or the selected chains change. Do not operate on its previous render. + if (!currentStage || !Array.isArray(loaded) || loaded.length < 2 || + !loaded[0] || !loaded[1] || !loaded[0].structure || + !loaded[1].structure) return false; + if (Array.isArray(currentStage.compList) && + (!currentStage.compList.includes(loaded[0]) || + !currentStage.compList.includes(loaded[1]))) return false; + stage = currentStage; + components = loaded; + return true; + } + + function validChain(value) { + return typeof value === "string" && /^[A-Za-z0-9_-]{1,16}$/.test(value); + } + + function chainSelection(side) { + if (!config) return null; + const chain = side === "a" ? config.chainA : config.chainB; + if (!validChain(chain)) return null; + const model = side === "a" ? config.modelA : config.modelB; + const multiple = side === "a" ? config.multipleA : config.multipleB; + const index = Number(model); + return ":" + chain + " and protein" + + (multiple && Number.isInteger(index) && index >= 1 + ? " and /" + (index - 1) : ""); + } + + function residueSelection(item) { + if (!item || !validChain(item.chain)) return null; + const number = Number(item.resi); + const insertion = String(item.insertion_code || ""); + if (!Number.isInteger(number) || !/^[A-Za-z0-9]*$/.test(insertion)) + return null; + const model = Number(item.modelIndex); + return String(number) + (insertion ? "^" + insertion : "") + + ":" + item.chain + " and protein" + + (item.multipleModels && Number.isInteger(model) && model >= 1 + ? " and /" + (model - 1) : ""); + } + + // getBoundingBox honours NGL's atom selection, unlike autoView() over the + // entire downloaded PDB/mmCIF, which can include many hidden chains. + function selectedBox(selections) { + if (!stage || !components || !window.NGL || !window.NGL.Selection) + return null; + let result = null; + for (let i = 0; i < 2; i++) { + if (!selections[i]) continue; + const structure = components[i].structure; + if (!structure || typeof structure.getBoundingBox !== "function") continue; + const box = structure.getBoundingBox(new window.NGL.Selection(selections[i])); + if (!box || (typeof box.isEmpty === "function" && box.isEmpty())) + continue; + if (!result) result = box.clone(); + else result.union(box); + } + return result; + } + + function frame(selections, duration, padding) { + if (!ready() || !config) return false; + const holder = document.getElementById("NGLCompare"); + if (!holder || holder.clientWidth < 10 || holder.clientHeight < 10) + return false; // Bootstrap tab or checkbox has not become visible yet. + if (typeof stage.handleResize === "function") stage.handleResize(); + const box = selectedBox(selections); + if (!box || !stage.animationControls || + typeof stage.animationControls.zoomMove !== "function" || + typeof stage.getZoomForBox !== "function") return false; + const center = box.getCenter(new window.NGL.Vector3()); + const zoom = stage.getZoomForBox(box); + if (!Number.isFinite(zoom)) return false; + stage.animationControls.zoomMove(center, zoom * padding, duration); + needsFit = false; + return true; + } + + function fitBoth() { + const a = chainSelection("a"); + const b = chainSelection("b"); + return frame([a, b], 460, 1.14); + } + + function focusPair() { + if (!selected) return fitBoth(); + const a = residueSelection(selected.a); + const b = residueSelection(selected.b); + return frame([a, b], 560, 1.55) || fitBoth(); + } + + function setHighlight(name, selection) { + if (!stage || typeof stage.getRepresentationsByName !== "function") + return; + const list = stage.getRepresentationsByName(name); + if (list && typeof list.setSelection === "function") + list.setSelection(selection || "none"); + } + + function paintSelection(zoom) { + if (!ready()) return false; + setHighlight("ram-compare-highlight-a", + residueSelection(selected && selected.a)); + setHighlight("ram-compare-highlight-b", + residueSelection(selected && selected.b)); + if (zoom) return focusPair(); + return true; + } + + function bindClicks() { + if (!ready() || !stage.signals || !stage.signals.clicked) return; + if (boundStage === stage) return; + if (boundStage && pickHandler && boundStage.signals && + boundStage.signals.clicked) { + boundStage.signals.clicked.remove(pickHandler); + } + boundStage = stage; + pickHandler = function (proxy) { + const atom = proxy && (proxy.atom || proxy.closestBondAtom); + if (!atom || atom.resno == null || !components) return; + const side = proxy.component === components[0] ? "a" : + proxy.component === components[1] ? "b" : null; + if (!side || !config) return; + const chain = String(atom.chainname || atom.chainid || ""); + const expected = side === "a" ? config.chainA : config.chainB; + const position = Number(atom.resno); + if (chain !== expected || !Number.isInteger(position)) return; + if (window.Shiny && window.Shiny.setInputValue) { + window.Shiny.setInputValue("ramCompareNglPick", { + side: side, chain: chain, resi: position, + insertion_code: String(atom.inscode || "") + }, {priority: "event"}); + } + }; + stage.signals.clicked.add(pickHandler); + } + + function refresh() { + fitScheduled = false; + if (ready()) { + bindClicks(); + if (needsFit) { + paintSelection(true); + } else { + paintSelection(false); + } + retry = 0; + } else if (retry++ < 12 && typeof window.setTimeout === "function") { + window.setTimeout(scheduleFit, 170); + } + } + + function scheduleFit() { + if (fitScheduled) return; + fitScheduled = true; + if (typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(function () { + window.requestAnimationFrame(refresh); + }); + } else refresh(); + } + + if (window.Shiny) { + window.Shiny.addCustomMessageHandler("ram-compare-config", function (value) { + config = value || null; + needsFit = true; + retry = 0; + scheduleFit(); + }); + window.Shiny.addCustomMessageHandler("ram-compare-ready", function (_value) { + needsFit = true; + retry = 0; + scheduleFit(); + }); + window.Shiny.addCustomMessageHandler("ram-compare-pair", function (value) { + selected = value && !value.clear ? value : null; + needsFit = true; + scheduleFit(); + }); + } + + document.addEventListener("click", function (event) { + const button = event.target && event.target.closest && + event.target.closest("#compareResetView"); + if (!button) return; + selected = null; // Just fit the view; server selection remains unchanged. + fitBoth(); + }); + + // The widget may have finished loading while the Compare tab was hidden. + // Fit it as soon as Bootstrap reveals its canvas. + function onCompareShown() { + needsFit = true; + scheduleFit(); + } + if (window.jQuery) { + window.jQuery(document).on("shown.bs.tab", function (event) { + const link = event && event.target; + if (link && link.getAttribute && + link.getAttribute("data-value") === "compare") onCompareShown(); + }); + } + if (window.ResizeObserver) { + const holder = document.querySelector(".ram-compare-ngl"); + if (holder) { + let lastWidth = 0; + new window.ResizeObserver(function () { + const width = holder.clientWidth; + if (width && !lastWidth) onCompareShown(); + lastWidth = width; + if (width && stage && typeof stage.handleResize === "function") + stage.handleResize(); + }).observe(holder); + } + } + + // Also support the first frame before an explicit readiness message arrives. + document.addEventListener("shiny:value", function (event) { + if (event.target && event.target.id === "NGLCompare") { + needsFit = true; + scheduleFit(); + } + }); +})(window, document); From bd3cbbb15505c3fa625c074bffb0e0c58776b6f8 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:04:37 +0200 Subject: [PATCH 06/20] Show the current sequence position and expose its pLDDT for quick navigation --- shinyRam/app.R | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shinyRam/app.R b/shinyRam/app.R index 60f9208..dad9a60 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -1151,6 +1151,8 @@ server <- function(input, output, session) { chain_name else paste("Chain",chain_name)), tags$span(sprintf("%s residues", format(nrow(chain),big.mark=","))), + tags$span(class="ram-seq-current", role="status", + "Select a residue"), tags$div(class="ram-sequence-jump", tags$label("Go to", class="sr-only"), tags$input(type="number", class="ram-seq-jump-input", @@ -1186,6 +1188,8 @@ server <- function(input, output, session) { "data-chain"=residue$chain[[1L]], "data-resi"=residue$resi[[1L]], "data-insertion"=residue$insertion_code[[1L]], + "data-plddt"=if (is.finite(score)) + sprintf("%.1f",score) else "", title=paste0(residue$resn[[1L]], " ", residue$chain[[1L]], position, " · ", if (is.na(residue$region[[1L]])) "Missing angles" From cb8a6e71834463572c84ff404731ff74030ac3f0 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:05:13 +0200 Subject: [PATCH 07/20] Make comparison scatter points select paired residues and mark both angle positions --- shinyRam/www/custom.js | 118 ++++++++++++++++++++++++++++++++++------- 1 file changed, 100 insertions(+), 18 deletions(-) diff --git a/shinyRam/www/custom.js b/shinyRam/www/custom.js index 53b2839..2c23e51 100644 --- a/shinyRam/www/custom.js +++ b/shinyRam/www/custom.js @@ -540,8 +540,44 @@ }); } + let comparisonRows = null; + let comparisonSelectedId = null; + function compareSelectedPoint(side) { + if (!comparisonRows || comparisonSelectedId == null) + return { x: [], y: [] }; + const ids = array(comparisonRows.rowIds); + const index = ids.findIndex(id => Number(id) === Number(comparisonSelectedId)); + if (index < 0) return { x: [], y: [] }; + const phi = array(side === "a" ? comparisonRows.phiA : comparisonRows.phiB)[index]; + const psi = array(side === "a" ? comparisonRows.psiA : comparisonRows.psiB)[index]; + return Number.isFinite(phi) && Number.isFinite(psi) ? + { x: [phi], y: [psi] } : { x: [], y: [] }; + } + function emphasizeComparison() { + if (!comparePlot || !window.Plotly || !comparePlot.data || + comparePlot.data.length < 4) return; + const a = compareSelectedPoint("a"), b = compareSelectedPoint("b"); + window.Plotly.restyle(comparePlot, { x: [a.x], y: [a.y] }, [2]); + window.Plotly.restyle(comparePlot, { x: [b.x], y: [b.y] }, [3]); + } + + function onComparePlotClick(event) { + const item = event && event.points && event.points[0]; + const data = item && (item.customdata || + (item.data && item.data.customdata && + item.data.customdata[item.pointNumber])); + const index = Array.isArray(data) ? Number(data[0]) : NaN; + if (!Number.isInteger(index) || index < 1) return; + comparisonSelectedId = index; + emphasizeComparison(); + if (window.Shiny && window.Shiny.setInputValue) + window.Shiny.setInputValue("ramComparePlotPick", index, + { priority: "event" }); + } + function drawComparison(obj) { if (!comparePlot || !obj) return; + comparisonRows = obj; if (!window.Plotly) { deferredComparison = obj; if (!comparisonLoadPending) { @@ -558,40 +594,86 @@ } return; } + const ids = array(obj.rowIds); const aPhi = array(obj.phiA), aPsi = array(obj.psiA); const bPhi = array(obj.phiB), bPsi = array(obj.psiB); - const asPoints = function (phi, psi) { - const x=[], y=[]; - for (let i=0; i Number.isFinite(v) ? v.toFixed(1) + "°" : "N/A"; + x.push(phi[i]); y.push(psi[i]); + text.push(escapeText(label) + " ↔ " + escapeText(other) + + "
φ " + fmt(phi[i]) + " · ψ " + fmt(psi[i]) + + "
Δφ " + fmt(dphi) + " · Δψ " + fmt(dpsi)); + customdata.push([Number(ids[i]), side]); } - return {x,y}; + return {type:"scattergl",mode:"markers",name:name,x:x,y:y, + text:text,customdata:customdata, + hovertemplate:"%{text}" + escapeText(name) + "", + marker:{color:color,size:8,opacity:.82,symbol:symbol}}; }; - const a = asPoints(aPhi,aPsi), b = asPoints(bPhi,bPsi); const traces = [ - {type:"scattergl",mode:"markers",name:String(obj.nameA || "Primary"), - x:a.x,y:a.y,marker:{color:"#CE6A4D",size:7,opacity:.77}}, - {type:"scattergl",mode:"markers",name:String(obj.nameB || "Comparison"), - x:b.x,y:b.y,marker:{color:"#317E9A",size:7,opacity:.77,symbol:"diamond"}} + makeTrace("a",aPhi,aPsi,String(obj.nameA || "Primary"),"#CE6A4D","circle"), + makeTrace("b",bPhi,bPsi,String(obj.nameB || "Comparison"),"#317E9A","diamond"), + {type:"scattergl",mode:"markers",showlegend:false,hoverinfo:"skip", + x:[],y:[],marker:{color:"#ffc04a",size:17,opacity:.95, + line:{color:"#623c17",width:2}}}, + {type:"scattergl",mode:"markers",showlegend:false,hoverinfo:"skip", + x:[],y:[],marker:{color:"#83e6f5",size:17,opacity:.95, + symbol:"diamond",line:{color:"#174958",width:2}}} ]; + // Overlay markers retain the same aligned-pair identity when clicked. + const index = ids.findIndex(id => Number(id) === Number(comparisonSelectedId)); + if (comparisonSelectedId != null && index >= 0) { + for (const [side, trace] of [["a",traces[2]],["b",traces[3]]]) { + const p = compareSelectedPoint(side); + trace.x = p.x; trace.y = p.y; + trace.customdata = p.x.length ? [[Number(ids[index]),side]] : []; + } + } const axis = {range:[-180,180],tickvals:[-180,-90,0,90,180], gridcolor:"#e3eeeb",zerolinecolor:"#a0bab9",constrain:"domain"}; - comparePlot.style.minHeight = "420px"; - window.Plotly.react(comparePlot,traces,{ + comparePlot.style.minHeight = "410px"; + Promise.resolve(window.Plotly.react(comparePlot,traces,{ autosize:true,paper_bgcolor:"#ffffff",plot_bgcolor:"#fbfdfc", - margin:{l:63,r:20,t:35,b:55}, + margin:{l:59,r:14,t:46,b:58}, xaxis:Object.assign({},axis,{title:"Phi (°)"}), yaxis:Object.assign({},axis,{title:"Psi (°)",scaleanchor:"x",scaleratio:1}), - legend:{orientation:"h",y:1.12,x:0}, - height:Math.min(660,Math.max(420,comparePlot.clientWidth+30)) - },{responsive:true,displaylogo:false}); + legend:{orientation:"h",y:1.13,x:0}, + height:Math.min(650,Math.max(410,comparePlot.clientWidth+45)) + },{responsive:true,displaylogo:false})).then(function () { + if (typeof comparePlot.on === "function" && !comparePlot.__ramCompareBound) { + comparePlot.on("plotly_click",onComparePlotClick); + comparePlot.__ramCompareBound = true; + } + }).catch(function (error) { + console.error("RamplotR comparison plot:",error); + }); } if (window.Shiny) { window.Shiny.addCustomMessageHandler("process", drawPlot); window.Shiny.addCustomMessageHandler("ram-comparison", drawComparison); + window.Shiny.addCustomMessageHandler("ram-comparison-selected", function (choice) { + comparisonSelectedId = choice && !choice.clear ? Number(choice.rowId) : null; + emphasizeComparison(); + }); window.Shiny.addCustomMessageHandler("ram-selection", function (choice) { selectedResidue = choice && !choice.clear ? choice : null; const inspector = document.querySelector(".ram-global-inspector"); From 97c0e6f3e61f913c9acd754d84e84f293c94bd25 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:05:37 +0200 Subject: [PATCH 08/20] Add direct position jump and selected-residue status to the sequence navigator --- shinyRam/www/custom.js | 64 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/shinyRam/www/custom.js b/shinyRam/www/custom.js index 2c23e51..094870e 100644 --- a/shinyRam/www/custom.js +++ b/shinyRam/www/custom.js @@ -181,6 +181,50 @@ window.Shiny.setInputValue("ramSeqPick", pick, { priority: "event" }); }); + // Jump to an actual residue number without counting hundreds of letters. + // The alignment may contain gaps and structures may use non-1 numbering. + document.addEventListener("click", function (event) { + const trigger = event.target && event.target.closest && + event.target.closest(".ram-seq-jump"); + if (!trigger || !trigger.closest) return; + const chain = trigger.closest(".ram-sequence-chain"); + if (!chain || !chain.querySelector) return; + const field = chain.querySelector(".ram-seq-jump-input"); + const note = chain.querySelector(".ram-seq-current"); + const number = field && Number(field.value); + if (!field || field.value.trim() === "" || !Number.isInteger(number)) { + if (note) note.textContent = "Enter a residue number."; + return; + } + const buttons = Array.from(chain.querySelectorAll(".ram-seq-res")); + const button = buttons.find(item => Number(item.dataset.resi) === number && + item.dataset.chain === trigger.dataset.chain) || null; + if (!button) { + if (note) note.textContent = "Residue " + number + " is not in this chain."; + return; + } + const strip = button.closest(".ram-sequence-grid"); + if (strip) strip.scrollLeft = Math.max(0, button.offsetLeft - + strip.offsetLeft - strip.clientWidth / 2); + if (button.disabled) { + if (note) note.textContent = "Residue " + number + + " is hidden by the current plot filters."; + } else if (typeof button.click === "function") { + button.click(); + } + }); + document.addEventListener("keydown", function (event) { + const input = event.target; + if (event.key !== "Enter" || !input || !input.matches || + !input.matches(".ram-seq-jump-input")) return; + const chain = input.closest(".ram-sequence-chain"); + const button = chain && chain.querySelector(".ram-seq-jump"); + if (button && typeof button.click === "function") { + event.preventDefault(); + button.click(); + } + }); + let lastSequenceScrollKey = ""; function markSequenceSelection() { if (typeof document.querySelectorAll !== "function") return; @@ -195,6 +239,16 @@ button.setAttribute("aria-pressed", String(active)); if (active) activeButton = button; }); + document.querySelectorAll(".ram-sequence-chain").forEach(function (chain) { + const note = chain.querySelector && chain.querySelector(".ram-seq-current"); + const active = chain.querySelector && + chain.querySelector('.ram-seq-res[aria-pressed="true"]'); + if (!note) return; + note.textContent = active ? + "Selected " + active.dataset.resi + (active.dataset.insertion || "") + + (active.dataset.plddt ? " · pLDDT " + active.dataset.plddt : "") : + "Select a residue"; + }); // With all chains present, keep the selected letter in view only within // its own horizontal sequence row. Do not scroll the entire page. if (activeButton && selected !== lastSequenceScrollKey && @@ -557,8 +611,14 @@ if (!comparePlot || !window.Plotly || !comparePlot.data || comparePlot.data.length < 4) return; const a = compareSelectedPoint("a"), b = compareSelectedPoint("b"); - window.Plotly.restyle(comparePlot, { x: [a.x], y: [a.y] }, [2]); - window.Plotly.restyle(comparePlot, { x: [b.x], y: [b.y] }, [3]); + window.Plotly.restyle(comparePlot, { + x: [a.x], y: [a.y], + customdata: [a.x.length ? [[Number(comparisonSelectedId), "a"]] : []] + }, [2]); + window.Plotly.restyle(comparePlot, { + x: [b.x], y: [b.y], + customdata: [b.x.length ? [[Number(comparisonSelectedId), "b"]] : []] + }, [3]); } function onComparePlotClick(event) { From 6cf0334ac03e3e33592d6cab946c18951eef5e07 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:06:12 +0200 Subject: [PATCH 09/20] Lay out paired plot/3D comparison and legible numbered pLDDT sequence controls --- shinyRam/www/styles.css | 110 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/shinyRam/www/styles.css b/shinyRam/www/styles.css index 734cc42..3375b7c 100644 --- a/shinyRam/www/styles.css +++ b/shinyRam/www/styles.css @@ -1028,3 +1028,113 @@ body > .container-fluid { max-width: none; padding: 0; } .ram-density-level { gap:6px; flex-wrap:wrap; } .ram-density-level input[type=range] { min-width:130px; } } + +/* Linked comparison workspace: the scientific plot and corresponding 3D view + remain adjacent on a laptop instead of hiding the useful viewer below a table. */ +.ram-compare-toolbar { + display:flex; flex-wrap:wrap; gap:10px 13px; align-items:end; + padding:11px 14px; margin:13px 0 16px; border-radius:10px; + background:#f4f9f7; border:1px solid #dbe9e5; +} +.ram-compare-toolbar .form-group { margin:0; min-width:130px; } +.ram-compare-toolbar .btn { background:#126e74; border:1px solid #126e74; + border-radius:8px; padding:8px 12px; font-weight:700; } +.ram-compare-toolbar-hint { margin:0 0 6px; color:#678088; + font-size:11px; flex:1 1 175px; } +.ram-compare-workspace { display:grid; + grid-template-columns:minmax(0,1fr) minmax(0,1fr); + align-items:start; gap:14px; margin:14px 0; } +.ram-compare-card { min-width:0; overflow:hidden; background:#fff; + border:1px solid #d9e8e4; border-radius:12px; padding:13px; } +.ram-compare-card-head h3 { margin:0 0 4px; font-size:14px; + font-weight:750; color:#173f48; } +.ram-compare-card-head p { margin:0 0 9px; min-height:32px; + font-size:11px; color:#617b81; line-height:1.5; } +.ram-compare-card .ram-compare-plot { min-height:410px; margin:0; + background:#fbfdfc; border:0; border-radius:8px; } +.ram-compare-ngl { width:100%; min-width:0; background:#f7fafb; + border-radius:8px; overflow:hidden; } +.ram-compare-ngl .html-widget, .ram-compare-ngl .shiny-html-output { + min-height:410px; width:100% !important; } +.ram-compare-viewer-controls { display:flex; flex-wrap:wrap; gap:7px; + align-items:center; justify-content:space-between; min-height:36px; + margin:0 0 9px; } +.ram-compare-viewer-controls .form-group { margin:0; } +.ram-compare-viewer-controls .btn { border:1px solid #b7d8d1; + color:#17626a; background:#eff7f5; border-radius:7px; font-weight:700; } +.ram-compare-selection { background:#f3f9f8; border:1px solid #c9e0dc; + border-radius:11px; padding:14px 17px; margin:13px 0 22px; + display:flex; flex-wrap:wrap; align-items:center; gap:12px 25px; + justify-content:space-between; } +.ram-compare-selection-empty { display:block; color:#537078; } +.ram-compare-selection-empty strong { color:#145861; } +.ram-compare-selection-empty p { margin:4px 0 0; font-size:11px; } +.ram-compare-selection-pair { display:flex; flex-wrap:wrap; + align-items:center; gap:12px; flex:1 1 420px; } +.ram-compare-primary, .ram-compare-secondary { + display:grid; gap:2px; padding-left:11px; border-left:4px solid #CE6A4D; } +.ram-compare-secondary { border-color:#317E9A; } +.ram-compare-selection-pair small { text-transform:uppercase; + font-size:10px; letter-spacing:.055em; color:#5b7880; font-weight:750; } +.ram-compare-selection-pair strong { font-variant-numeric:tabular-nums; + font-size:15px; color:#173e48; } +.ram-compare-selection-pair span { font-size:11px; color:#526c73; } +.ram-compare-pair-arrow { font-size:19px !important; color:#8aa7aa !important; } +.ram-compare-selection-deltas { display:flex; gap:6px; flex-wrap:wrap; } +.ram-compare-selection-deltas span { display:inline-block; padding:6px 9px; + border-radius:7px; color:#1d5358; background:#fff; border:1px solid #d2e5e0; + font-size:11px; font-weight:750; font-variant-numeric:tabular-nums; } +.ram-compare-selection-deltas .ram-compare-change { color:#994c33; + background:#fff0e8; border-color:#efd0bd; } + +/* Numbers are fixed above the letters. pLDDT is a *separate* strip/score, + never the same fill as Ramachandran classification. */ +.ram-sequence-chain .ram-sequence-grid { align-items:flex-start; + min-height:64px; gap:3px; } +.ram-seq-slot { display:flex; flex:0 0 31px; width:31px; min-width:31px; + flex-direction:column; align-items:center; gap:1px; } +.ram-seq-position { width:100%; height:16px; white-space:nowrap; + overflow:visible; font-size:10px; font-weight:750; color:#426b72; + font-variant-numeric:tabular-nums; line-height:16px; text-align:center; } +.ram-sequence-chain .ram-seq-res { display:flex; flex-direction:column; + align-items:center; justify-content:center; width:29px; min-width:29px; + height:31px; flex:0 0 auto; gap:0; padding:1px 0 2px; line-height:1.05; } +.ram-sequence-chain .ram-seq-with-confidence { height:45px; + box-shadow:inset 0 -4px var(--ram-plddt-color, #cbd7db); } +.ram-seq-aa { font-weight:750; } +.ram-seq-plddt { font-size:9px; color:#435c61; padding-top:4px; + font-variant-numeric:tabular-nums; font-weight:700; } +.ram-sequence-chain .ram-seq-res:disabled { + opacity:.48; filter:grayscale(.3); } +.ram-sequence-chain-heading .ram-seq-current { color:#126e74; + font-weight:750; font-size:11px; font-variant-numeric:tabular-nums; } +.ram-sequence-jump { margin-left:auto; display:flex; align-items:center; gap:5px; } +.ram-seq-jump-input { width:93px; min-width:0; height:29px; + border:1px solid #c5ded7; background:#fff; border-radius:6px; padding:4px 6px; + font-size:11px; font-variant-numeric:tabular-nums; } +.ram-seq-jump { height:29px; border:1px solid #b9d8d1; + border-radius:6px; color:#145e64; background:#edf7f4; + font-size:11px; font-weight:750; padding:4px 8px; } +.ram-seq-jump:hover { background:#dbeee9; border-color:#80b4aa; } +.ram-sequence-confidence-key { display:flex; align-items:center; + gap:6px; flex-wrap:wrap; margin:0 0 12px; font-size:10px; color:#637b81; } +.ram-sequence-confidence-key strong { font-size:10px; color:#2c626a; margin-right:3px; } +.ram-sequence-confidence-key span:not(:last-child) { display:inline-block; + font-variant-numeric:tabular-nums; padding:3px 7px; + border-radius:5px; border:1px solid #dce9e6; color:#244b53; font-weight:700; } +.ram-confidence-key-high { border-bottom:4px solid #126e74 !important; } +.ram-confidence-key-good { border-bottom:4px solid #7bbcb1 !important; } +.ram-confidence-key-low { border-bottom:4px solid #d6ac52 !important; } +.ram-confidence-key-poor { border-bottom:4px solid #d75e56 !important; } +@media(max-width:1050px) { + .ram-compare-workspace { grid-template-columns:minmax(0,1fr); } + .ram-compare-card-head p { min-height:0; } +} +@media(max-width:580px) { + .ram-compare-card { padding:9px; } + .ram-compare-toolbar { padding:10px; } + .ram-compare-selection { padding:11px; } + .ram-sequence-chain-heading { flex-wrap:wrap; } + .ram-sequence-jump { margin-left:0; } + .ram-sequence-scroll-hint { display:none; } +} From 07362696f016a6a651335e835041e2829f7da54d Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:06:43 +0200 Subject: [PATCH 10/20] Use NGL stage vector for framing and preserve selected highlights on reset --- shinyRam/www/compare.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shinyRam/www/compare.js b/shinyRam/www/compare.js index ce29d42..dea5141 100644 --- a/shinyRam/www/compare.js +++ b/shinyRam/www/compare.js @@ -89,7 +89,7 @@ if (!box || !stage.animationControls || typeof stage.animationControls.zoomMove !== "function" || typeof stage.getZoomForBox !== "function") return false; - const center = box.getCenter(new window.NGL.Vector3()); + const center = box.getCenter(stage.getCenter()); const zoom = stage.getZoomForBox(box); if (!Number.isFinite(zoom)) return false; stage.animationControls.zoomMove(center, zoom * padding, duration); @@ -204,7 +204,7 @@ const button = event.target && event.target.closest && event.target.closest("#compareResetView"); if (!button) return; - selected = null; // Just fit the view; server selection remains unchanged. + // Preserve the selected pair/highlights while fitting both chains. fitBoth(); }); From f95b7bc2297a8cc9e78a86b85d0eca3dd3e3a0b3 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:07:23 +0200 Subject: [PATCH 11/20] Test exact numbered navigation, confidence tiers and aligned-pair lookup --- tests/inspection.R | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/inspection.R b/tests/inspection.R index b91e45f..502dc6b 100644 --- a/tests/inspection.R +++ b/tests/inspection.R @@ -17,6 +17,17 @@ assert(identical(q$resn, c("GLY","THR","PRO","ALA")), seq <- ram_sequence_data(a) assert(identical(seq$letter, c("A","T","G","P")), "Sequence must retain amino-acid identities") +assert(identical(unname(ram_plddt_color(c(NA,49,50,69,70,89,90,100))), + c("#cbd7db","#d75e56","#d6ac52","#d6ac52", + "#7bbcb1","#7bbcb1","#126e74","#126e74")), + "Confidence colour thresholds must be independent of region labels") +assert(identical(ram_sequence_position_labels(c(98L,99L,100L,101L,104L,105L)), + c("98","","100","","","105")), + "Sequence index labels must use real residue numbering, not array positions") +assert(identical(ram_sequence_position_labels(c(101L,101L), c("","A")), + c("101","101A")), "Labels retain insertion codes") +assert(identical(ram_sequence_position_labels(integer(), character()), + character()), "Empty position labels are supported") assert(seq$insertion_code[2L]=="A", "Insertion code must be preserved") groups <- ram_sequence_groups(a) assert(identical(names(groups), c("A", "B")), @@ -46,6 +57,14 @@ other <- rbind(ref[1,,drop=FALSE], transform(ref[1,,drop=FALSE], pair <- ram_compare_torsions(ref,other) assert(nrow(pair)==5L, "Alignment should preserve inserted residues") assert(sum(pair$alignment=="Insertion")==1L, "Expected insertion") +assert(identical(ram_comparison_find(pair,"a","A",2L), 2L), + "Find the primary residue by exact PDB numbering") +assert(identical(ram_comparison_find(pair,"b","A",88L), 2L), + "Locate inserted comparison-only residue") +assert(is.na(ram_comparison_find(pair,"a","A",88L)), + "Do not assign the partner's residue number to a primary gap") +assert(is.na(ram_comparison_find(pair,"b","A",999L)), + "Unknown comparison residue must not select an unrelated pair") deletion <- ram_compare_torsions(ref, ref[-2,,drop=FALSE]) assert(sum(deletion$alignment=="Deletion")==1L, "Expected deletion") assert(any(!is.na(pair$delta_phi)), "Aligned angles should be comparable") From 42cb14371f3989404de778f30facaa20cb4992e8 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:07:33 +0200 Subject: [PATCH 12/20] Correct paired alignment index for insertion fixture --- tests/inspection.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/inspection.R b/tests/inspection.R index 502dc6b..a90f550 100644 --- a/tests/inspection.R +++ b/tests/inspection.R @@ -57,7 +57,7 @@ other <- rbind(ref[1,,drop=FALSE], transform(ref[1,,drop=FALSE], pair <- ram_compare_torsions(ref,other) assert(nrow(pair)==5L, "Alignment should preserve inserted residues") assert(sum(pair$alignment=="Insertion")==1L, "Expected insertion") -assert(identical(ram_comparison_find(pair,"a","A",2L), 2L), +assert(identical(ram_comparison_find(pair,"a","A",2L), 3L), "Find the primary residue by exact PDB numbering") assert(identical(ram_comparison_find(pair,"b","A",88L), 2L), "Locate inserted comparison-only residue") From b24ea63d16f77d86903965381fffa77f2ea368f6 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:07:57 +0200 Subject: [PATCH 13/20] Test paired NGL click mapping, selection highlights and chain-aware viewport framing --- tests/compare-ui.test.cjs | 105 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/compare-ui.test.cjs diff --git a/tests/compare-ui.test.cjs b/tests/compare-ui.test.cjs new file mode 100644 index 0000000..cd817a2 --- /dev/null +++ b/tests/compare-ui.test.cjs @@ -0,0 +1,105 @@ +// Node-only linked comparison checks; no R, network or real WebGL required. +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); + +const handlers = {}; +const events = {}; +const moves = []; +const highlights = []; +const sent = []; +let clicked = null; +let holder = {clientWidth: 630, clientHeight: 410}; +const newBox = (lo,hi) => ({ + lo, hi, + isEmpty() {return this.lo > this.hi;}, + clone() {return newBox(this.lo,this.hi);}, + union(other) {this.lo=Math.min(this.lo,other.lo); + this.hi=Math.max(this.hi,other.hi);return this;}, + getCenter(target) {target.x=(this.lo+this.hi)/2;return target;} +}); +function component(offset) { + return { + structure: { + getBoundingBox(selection) { + assert.ok(selection.value.includes(" and protein")); + const pair = /104:A|119:B/.test(selection.value); + return newBox(offset + (pair?4:0), offset + (pair?6:12)); + } + } + }; +} +const components = [component(0),component(3)]; +const stage = { + compList: components, + signals: {clicked: {add(fn) {clicked=fn;},remove(fn) { + if (clicked===fn) clicked=null; + }}}, + handleResize() {}, + getCenter() {return {x:0};}, + getZoomForBox(box) {return -10-(box.hi-box.lo);}, + animationControls: {zoomMove(center,zoom,duration) { + moves.push({center:center.x,zoom,duration}); + }}, + getRepresentationsByName(name) { + return {setSelection(sele) {highlights.push({name,sele});}}; + } +}; +const window = { + Shiny: { + addCustomMessageHandler(name,callback) { + assert.equal(callback.length,1,name+" should accept an argument"); + handlers[name]=callback; + }, + setInputValue(name,value) {sent.push({name,value});} + }, + getNGLStage() {return stage;}, + getNGLStructure() {return components;}, + NGL: {Selection: class Selection {constructor(value){this.value=value;}}}, + requestAnimationFrame(callback) {callback();} +}; +const document = { + addEventListener(name,callback) {events[name]=callback;}, + getElementById(id) {return id==="NGLCompare"?holder:null;}, + querySelector() {return null;} +}; +vm.runInNewContext(fs.readFileSync("shinyRam/www/compare.js","utf8"), + {window,document,Number,Array}); +handlers["ram-compare-config"]({ + chainA:"A",chainB:"B",modelA:1,modelB:1, + multipleA:false,multipleB:false +}); +assert.ok(moves.length, "Viewer should fit the two visible chains"); +assert.equal(moves.at(-1).center,7.5, + "Combined viewport must use both selected chains, not the full structures"); +handlers["ram-compare-pair"]({ + a:{chain:"A",resi:104,insertion_code:"",modelIndex:1,multipleModels:false}, + b:{chain:"B",resi:119,insertion_code:"",modelIndex:1,multipleModels:false} +}); +assert.equal(highlights.at(-2).sele,"104:A and protein"); +assert.equal(highlights.at(-1).sele,"119:B and protein"); +assert.equal(moves.at(-1).center,6.5, + "Pair selection should center over corresponding highlighted residues"); +assert.equal(typeof clicked,"function","NGL atom picking must be linked"); +clicked({component:components[1],atom:{ + chainname:"B",resno:119,inscode:"A" +}}); +assert.equal(sent.at(-1).name,"ramCompareNglPick"); +assert.equal(sent.at(-1).value.side,"b"); +assert.equal(sent.at(-1).value.insertion_code,"A"); +const before=moves.length; +holder.clientWidth=0; +handlers["ram-compare-pair"]({a:{ + chain:"A",resi:104,insertion_code:"",modelIndex:1,multipleModels:false +},b:null}); +assert.equal(moves.length,before,"Do not frame an invisible viewer"); +holder.clientWidth=630; +handlers["ram-compare-ready"]({}); +assert.ok(moves.length>before, + "Once the Compare canvas becomes visible, focus the pending selection"); +events.click({target:{closest(){return {id:"compareResetView"};}}}); +assert.equal(moves.at(-1).center,7.5, + "Fit both chains button must reset the comparison camera"); +assert.ok(highlights.at(-1).sele==="none", + "Alignment gaps should clear a missing partner's highlight"); +console.log("Linked comparison framing and paired NGL picks passed."); From 6f92b94633304333990b1bf81742b25a24890217 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:08 +0200 Subject: [PATCH 14/20] Run linked NGL comparison tests and JavaScript syntax check on Linux and Windows --- .github/workflows/scientific-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml index 96d8855..301e99c 100644 --- a/.github/workflows/scientific-tests.yml +++ b/.github/workflows/scientific-tests.yml @@ -39,7 +39,9 @@ jobs: - name: Test browser message handler and source selection run: | node --check shinyRam/www/custom.js + node --check shinyRam/www/compare.js node --check shinyRam/www/prediction.js + node tests/compare-ui.test.cjs node --check shinyRam/www/density.js node --check shinyRam/www/plotly-loader.js node tests/plotly-loader.test.cjs From e99df28832bd740ed86bce94973a1bb7d4b1ad1f Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:44 +0200 Subject: [PATCH 15/20] Exercise numbered sequence jump and aligned 2D/3D comparison in real browser --- tests/ui-browser.cjs | 45 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/ui-browser.cjs b/tests/ui-browser.cjs index 6a6a490..106ced1 100644 --- a/tests/ui-browser.cjs +++ b/tests/ui-browser.cjs @@ -536,6 +536,9 @@ const assert = require("node:assert/strict"); "Sequence must be integrated into the plot tab and initially collapsed."); await page.click("#ram-sequence-panel > summary"); await page.waitForSelector("#sequenceView .ram-seq-res", {timeout:18000}); + const numbered = await page.$eval(".ram-seq-position", + elements => elements.some(node => node.textContent.trim() === "10")); + assert.ok(numbered, "Sequence navigator must show permanent PDB numbers."); const sequencePick = await page.evaluate(first => { const buttons = Array.from(document.querySelectorAll(".ram-seq-res")); const other = buttons.find(b => @@ -554,6 +557,15 @@ const assert = require("node:assert/strict"); assert.equal(await page.$eval('.nav-tabs li.active a', el => el.getAttribute("data-value")), "plot", "Picking a residue must preserve the visible plot and NGL viewer."); + // Jump straight to a true PDB residue number rather than counting letters. + await page.$eval(".ram-seq-jump-input", input => { input.value = "12"; }); + await page.click(".ram-seq-jump"); + await page.waitForFunction(() => { + const pick = document.querySelector('.ram-seq-res[data-resi="12"]'); + const status = document.querySelector(".ram-seq-current"); + return pick && pick.getAttribute("aria-pressed") === "true" && + status && status.textContent.includes("12"); + }, {timeout:15000}); await page.screenshot({ path:"benchmarks/output/ui-preview/sequence-integrated.png", fullPage:true }); @@ -575,8 +587,39 @@ const assert = require("node:assert/strict"); await page.waitForSelector("#comparison tbody tr", {timeout:25000}); await page.waitForFunction(() => { const p = document.getElementById("comparePlot"); - return p && p.data && p.data.length >= 2; + return p && p.data && p.data.length >= 4 && + p.data[0].customdata.length > 10; }, {timeout:18000}); + await page.waitForFunction(() => { + const s = window.getNGLStage && window.getNGLStage("NGLCompare"); + const models = window.getNGLStructure && window.getNGLStructure("NGLCompare"); + const node = document.getElementById("NGLCompare"); + return s && models && models.length === 2 && + node && node.clientWidth > 0 && node.clientHeight > 0 && + s.getRepresentationsByName("ram-compare-highlight-a").list.length > 0; + }, {timeout:50000}); + await page.$eval("#compareJumpResidue", input => { + input.value = "12"; + input.dispatchEvent(new Event("input",{bubbles:true})); + input.dispatchEvent(new Event("change",{bubbles:true})); + }); + await page.click("#compareJump"); + await page.waitForFunction(() => + document.querySelector(".ram-compare-selection-pair") && + document.querySelector(".ram-compare-selection-pair").textContent.includes("12"), + {timeout:18000}); + const pairState = await page.evaluate(() => { + const stage = window.getNGLStage("NGLCompare"); + return { + a: stage.getRepresentationsByName("ram-compare-highlight-a").list.length, + b: stage.getRepresentationsByName("ram-compare-highlight-b").list.length, + panel: document.querySelector(".ram-compare-selection-pair").textContent, + layout: getComputedStyle(document.querySelector(".ram-compare-workspace")).display + }; + }); + assert.equal(pairState.a,1); + assert.equal(pairState.b,1); + assert.equal(pairState.layout,"grid"); await page.screenshot({ path:"benchmarks/output/ui-preview/compare-self.png",fullPage:true }); From b140972bb03b973e8fdce0ab6b480faba5c547e3 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:57 +0200 Subject: [PATCH 16/20] Verify exact pLDDT values and confidence strips in live sequence navigator --- tests/prediction-browser.cjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/prediction-browser.cjs b/tests/prediction-browser.cjs index 52733ac..a8a94a0 100644 --- a/tests/prediction-browser.cjs +++ b/tests/prediction-browser.cjs @@ -97,6 +97,28 @@ const puppeteer = require("puppeteer-core"); "ESMFold without PAE must not invent an error map"); await page.screenshot({path:path.join(output,"prediction-esmfold.png"), fullPage:true}); + await page.click("#ram-sequence-panel > summary"); + await page.waitForFunction(() => { + const buttons = Array.from(document.querySelectorAll("#sequenceView .ram-seq-res")); + return buttons.length > 20 && + buttons.some(button => button.dataset.plddt === "87.0") && + buttons.some(button => button.querySelector(".ram-seq-plddt")?.textContent === "87") && + document.querySelector(".ram-sequence-confidence-key"); + },{timeout:20000}); + const confidence = await page.$eval("#sequenceView .ram-seq-res[data-plddt]", + button => ({ + label:button.querySelector(".ram-seq-plddt").textContent, + underline:getComputedStyle(button).boxShadow, + tooltip:button.title + })); + assert.equal(confidence.label,"87"); + assert.ok(confidence.tooltip.includes("pLDDT 87.0")); + assert.ok(confidence.underline !== "none", + "Each confidence-bearing residue must have a separate colour strip"); + await page.screenshot({ + path:path.join(output,"prediction-sequence-confidence.png"),fullPage:true + }); + await page.click("#ram-sequence-panel > summary"); // Independently verify AF2 monomer PAE and UI linkage. await chooseSource("alphafold2"); From 87714654d9f4c60c8da2d9fcfba5b414747d5c6f Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:09:17 +0200 Subject: [PATCH 17/20] Document linked comparison, permanent residue indices and independent pLDDT scores --- docs/inspection-user-guide.md | 37 ++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/inspection-user-guide.md b/docs/inspection-user-guide.md index fe07e6e..20d934d 100644 --- a/docs/inspection-user-guide.md +++ b/docs/inspection-user-guide.md @@ -19,10 +19,19 @@ and missing-angle positions. Expand the navigator to reveal independently scrollable, one-letter residue strips for all selected chains. Clicking a letter updates the same inspector, Ramachandran point and NGL focus. -The map always retains full biological sequence positions, even when -amino-acid or pre-proline filters hide most plotted points. Residues excluded -by those filters appear dimmed and cannot be selected until the filters are -relaxed; the sequence itself is never shortened into an artificial fragment. +The map always retains true **PDB residue numbers**, even when amino-acid +or pre-proline filters hide most plotted points. The expanded strip labels +every tenth PDB position (plus its first and last residue) and shows the +currently selected number next to the chain heading. Use **Go to residue** +beside any chain, or press Enter in its number field, to find positions +directly, such as residue 104. A hidden residue is still located, with a +message explaining that its selection is blocked by current plot filters. + +For predicted models, each residue button also shows its numerical +**pLDDT** below the amino-acid letter. A separate coloured underline and +legend distinguish high, confident, low and very low pLDDT from the +Ramachandran classification fill. Positions with unavailable confidence +show a dash, never an invented zero. ## The residue table @@ -55,7 +64,24 @@ If atom records are inconsistent or the parser cannot extract complete multi-mod The optional **Compare** tab accepts a second PDB accession or PDB/mmCIF file. Choose one chain from each structure. Residues are paired by a bounded global amino-acid sequence alignment, **not by residue number**. The difference in each angle wraps correctly across ±180°. Gaps remain visible and do not receive invented dihedrals; classification differences are reported only for available classifications. -The paired Ramachandran plot displays the two structures in contrasting colours. Export the aligned table as CSV. The optional 3D viewer superposes the selected chains for qualitative inspection. A large-chain comparison can exceed the alignment size limit; select shorter chains instead. +The paired Ramachandran plot and the **3D superposition are side by side** +on wide screens. The 3D viewer initially fits both selected chains rather +than the complete uploaded structures, even if hidden chains are very large. +Selecting a plotted point or aligned table row highlights both corresponding +residues in the superposition and focuses the camera on their local +positions. Clicking a residue in either 3D structure finds its aligned +partner. If one structure contains an insertion/deletion at the selected +position, only the available residue is highlighted and the missing +partner is shown as an alignment gap. + +Use **Find aligned pair** to jump by the true residue number in either +selected chain (for example 104), then inspect the primary/comparison +amino acids, φ/ψ angles and wrapped Δφ/Δψ directly below the views. +**Fit both chains** resets the camera without clearing the current +selection. The shared primary-structure inspector and sequence navigator +follow the selected primary residue when it is visible under current plot +filters. Export the aligned table as CSV for reproducibility. A large-chain +comparison can exceed the alignment size limit; select shorter chains instead. Identical Ramachandran coordinates do not imply identical Cartesian structure, and an angular difference alone is not evidence of a clinically meaningful change. Distinct models from the same structure are related observations, not independent experiments. @@ -71,6 +97,7 @@ Record the software version, reference dataset, background, classification mode, - `Rscript tests/reports.R`: export real vector SVG, PNG and self-contained HTML. - `Rscript tests/model-integration.R`: an actual multi-model 1D3Z NMR PDB. - `node tests/ui.test.cjs`: JS message-handler and residue-selection contracts. +- `node tests/compare-ui.test.cjs`: aligned 3D selection, chain-aware framing, invisible viewers and fit-both reset. - `node tests/ui-browser.cjs`: live Shiny browser workflow, plot-to-NGL/table/sequence selection, default palette, responsive sizing and pairwise self-comparison. Scientific tests run on Ubuntu and Windows; full-browser checks run on Ubuntu. For changes that affect validation criteria or reference grids, the scientific regression tests must remain unchanged or include explicitly reviewed new reference fixtures. From 2fe4596bf45d8946106e780257180dd303c251bc Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:12:18 +0200 Subject: [PATCH 18/20] Use Puppeteer multi-element query when checking permanent residue labels From 00f51af66bb98c07fdc0dd35fdfcbe0e758acf82 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:12:34 +0200 Subject: [PATCH 19/20] Fix Puppeteer selector to evaluate all visible numbering markers --- tests/ui-browser.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui-browser.cjs b/tests/ui-browser.cjs index 106ced1..cd3e79b 100644 --- a/tests/ui-browser.cjs +++ b/tests/ui-browser.cjs @@ -536,7 +536,7 @@ const assert = require("node:assert/strict"); "Sequence must be integrated into the plot tab and initially collapsed."); await page.click("#ram-sequence-panel > summary"); await page.waitForSelector("#sequenceView .ram-seq-res", {timeout:18000}); - const numbered = await page.$eval(".ram-seq-position", + const numbered = await page.$$eval(".ram-seq-position", elements => elements.some(node => node.textContent.trim() === "10")); assert.ok(numbered, "Sequence navigator must show permanent PDB numbers."); const sequencePick = await page.evaluate(first => { From 97258d381363af3806d8ba3fe9ee2425ece6a603 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:13:17 +0200 Subject: [PATCH 20/20] Avoid redundant NGL highlight rebuilds and camera resets after render notifications --- shinyRam/www/compare.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/shinyRam/www/compare.js b/shinyRam/www/compare.js index dea5141..423cc59 100644 --- a/shinyRam/www/compare.js +++ b/shinyRam/www/compare.js @@ -12,6 +12,9 @@ let needsFit = true; let retry = 0; let fitScheduled = false; + let framedComponents = null; + let highlightedComponents = null; + let highlightKey = ""; function ready() { if (typeof window.getNGLStage !== "function" || @@ -93,6 +96,7 @@ const zoom = stage.getZoomForBox(box); if (!Number.isFinite(zoom)) return false; stage.animationControls.zoomMove(center, zoom * padding, duration); + framedComponents = components; needsFit = false; return true; } @@ -120,10 +124,17 @@ function paintSelection(zoom) { if (!ready()) return false; - setHighlight("ram-compare-highlight-a", - residueSelection(selected && selected.a)); - setHighlight("ram-compare-highlight-b", - residueSelection(selected && selected.b)); + const a = residueSelection(selected && selected.a); + const b = residueSelection(selected && selected.b); + const key = (a || "none") + "|" + (b || "none"); + // setSelection may rebuild WebGL representations and emit NGL_rendering. + // Do not set identical highlights after every readiness notification. + if (components !== highlightedComponents || key !== highlightKey) { + setHighlight("ram-compare-highlight-a", a); + setHighlight("ram-compare-highlight-b", b); + highlightedComponents = components; + highlightKey = key; + } if (zoom) return focusPair(); return true; } @@ -189,7 +200,11 @@ scheduleFit(); }); window.Shiny.addCustomMessageHandler("ram-compare-ready", function (_value) { - needsFit = true; + const loaded = typeof window.getNGLStructure === "function" ? + window.getNGLStructure("NGLCompare") : null; + // Rendering and highlight changes can both emit readiness. Reframe + // only for new structure components or an explicitly pending focus. + if (!loaded || loaded !== framedComponents) needsFit = true; retry = 0; scheduleFit(); });