A protease family in R
Chymotrypsin cuts a peptide bond with three residues, His57, Asp102 and Ser195,
that sit far apart in the chain and side by side in the folded protein. Several
human proteins have the same peptidase S1 domain with one of those three
replaced, and cut nothing. Working in R with ggmsaview from fourteen UniProtKB
accessions, we:
- cut each chain to its peptidase S1 domain and align the domains
- build a tree from the alignment
- add a BLOSUM62 track, disulfide arcs and bands on the three catalytic columns
- read the letter each row has in those columns
- save the widget and write a link for the web viewer
Prerequisites
- R 4.3 or newer
- ggmsaview:
remotes::install_github("GMOD/ggmsaview") - Bioconductor:
BiocManager::install(c("Biostrings", "DECIPHER")) - CRAN:
install.packages(c("ape", "jsonlite", "htmlwidgets"))
The data
UniProtKB supplies the feature table, with each domain’s bounds and charge-relay residues, and the sequences. Nothing to download: the commands below fetch these.
| What it is | File |
|---|---|
| The feature table for all fourteen accessions in one request, shown here with two of them | uniprotkb-accessions |
| The sequences, from the same endpoint | uniprotkb-accessions |
| The alignment the commands below write, which the link in step 10 opens | proteases.aln |
| Its tree | proteases.nh |
The files this page writes, hosted for the figure links
| What it is | File |
|---|---|
| The layers R computed | proteases-layers.json |
1. List the proteins
UniProt annotates the first nine as active peptidases and the last five with the charge-relay system broken, which the page checks at the end.
proteins <- data.frame(
accession = c(
"P00766", "P07477", "P08246", "P10144", "P07288",
"P00734", "P00742", "P00747", "P00750",
"P00738", "P20160", "P22891", "P14210", "P26927"
),
label = c(
"Chymotrypsinogen", "Trypsin_1", "Elastase", "Granzyme_B", "PSA",
"Prothrombin", "Factor_X", "Plasminogen", "tPA",
"Haptoglobin", "Azurocidin", "Protein_Z", "HGF", "MST1"
)
)
The label becomes the FASTA header, the tree tip and the row the viewer draws down the side. Underscores keep the names through Newick, which reads a space as the end of a name.
2. Fetch the feature table
One TSV request covers all fourteen entries and three feature types.
REST <- "https://rest.uniprot.org/uniprotkb/accessions?accessions="
ids <- paste(proteins$accession, collapse = ",")
fields <- "accession,id,protein_name,ft_domain,ft_act_site,ft_disulfid"
features <- read.delim(
url(paste0(REST, ids, "&fields=", fields, "&format=tsv")),
quote = "", check.names = FALSE
)
features <- features[match(proteins$accession, features$Entry), ]
Two small parsers turn each feature string into numbers:
# "DOMAIN 364..618; /note="Peptidase S1"" -> c(364, 618)
s1_domain <- function(text) {
m <- regmatches(text, regexpr('DOMAIN (\\d+)\\.\\.(\\d+); /note="Peptidase S1"', text))
as.integer(regmatches(m, gregexpr("\\d+", m))[[1]])
}
# "ACT_SITE 406; ... ACT_SITE 462; ..." -> c(406, 462, 568)
positions <- function(text, key) {
m <- regmatches(text, gregexpr(paste0(key, " (\\d+)"), text))[[1]]
as.integer(sub(paste0(key, " "), "", m))
}
domains <- lapply(features[["Domain [FT]"]], s1_domain)
proteins$start <- vapply(domains, `[`, integer(1), 1)
proteins$end <- vapply(domains, `[`, integer(1), 2)
proteins$act <- lapply(features[["Active site"]], positions, "ACT_SITE")
protein domain residues active_site
Chymotrypsinogen 16..243 228 57, 102, 195
Trypsin_1 24..244 221 63, 107, 200
Elastase 30..247 218 70, 117, 202
Granzyme_B 21..245 225 64, 108, 203
PSA 25..258 234 65, 120, 213
Prothrombin 364..618 255 406, 462, 568
Factor_X 235..467 233 276, 322, 419
Plasminogen 581..808 228 622, 665, 760
tPA 311..561 251 357, 406, 513
Haptoglobin 162..404 243 none
Azurocidin 27..244 218 none
Protein_Z 175..400 226 none
HGF 495..721 227 none
MST1 484..709 226 none
The domain starts at residue 16 of chymotrypsinogen and at residue 581 of plasminogen, so residue 195 is a different place in each row.
3. Cut each chain to its domain
fasta <- "proteases-full.fasta"
download.file(paste0(REST, ids, "&format=fasta"), fasta, quiet = TRUE)
full <- readAAStringSet(fasta)
names(full) <- sub("^[a-z]+\\|([A-Z0-9]+)\\|.*$", "\\1", names(full))
full <- full[proteins$accession]
s1 <- subseq(full, start = proteins$start, end = proteins$end)
names(s1) <- proteins$label
full chains 245-810 aa, S1 domains 218-255 aa
The cut removes the Gla and kringle domains that prothrombin and plasminogen have ahead of the peptidase.
4. Align the domains
aln <- AlignSeqs(s1, verbose = FALSE)
writeXStringSet(aln, "proteases.aln")
alignment: 14 rows x 297 columns

AlignSeqs in accession-table order, the nine active
peptidases above the five without a charge-relay system. The gaps fall in the
loops, which differ in length between families.5. Build a tree
nj builds the tree from the alignment’s distances, and ladderize sorts each
node so the smaller clade comes first.
tree <- ladderize(nj(as.dist(DistanceMatrix(aln, verbose = FALSE))))
write.tree(tree, "proteases.nh")
An ape::phylo goes into msaview() as it is, along with the AAStringSet:
msaview(msa = aln, tree = tree, color_scheme = "clustalx_protein_dynamic")

6. Score each column with BLOSUM62
Mean pairwise BLOSUM62 weights agreement in a column by how rare the residue is, so a column of conserved cysteines scores 9 and one of conserved alanines 4.
data(BLOSUM62, package = "Biostrings")
m <- as.matrix(aln)
pairs <- combn(nrow(m), 2)
blosum <- apply(m, 2, function(column) {
a <- column[pairs[1, ]]
b <- column[pairs[2, ]]
keep <- a %in% rownames(BLOSUM62) & b %in% rownames(BLOSUM62)
if (!any(keep)) 0 else mean(BLOSUM62[cbind(a[keep], b[keep])])
})
column_tracks takes the vector as one bar per column, scaled by
scales$y$domainMax:
blosum_track <- list(
id = "blosum", name = "Mean pairwise BLOSUM62", kind = "bar",
values = pmax(blosum, 0), scales = list(y = list(domainMax = 9)),
color = "#6a51a3", height = 60
)
7. Draw the disulfide bonds as arcs
The feature table lists five disulfide bonds in chymotrypsinogen, and four lie inside the aligned domain.
disulfides <- function(text) {
m <- regmatches(text, gregexpr("DISULFID (\\d+)\\.\\.(\\d+)", text))[[1]]
matrix(as.integer(unlist(regmatches(m, gregexpr("\\d+", m)))), ncol = 2, byrow = TRUE)
}
ref <- which(proteins$label == "Chymotrypsinogen")
ss <- disulfides(features[["Disulfide bond"]][ref])
ss <- ss[ss[, 1] >= proteins$start[ref] & ss[, 2] <= proteins$end[ref], , drop = FALSE]
bonds <- data.frame(
start = ss[, 1] - proteins$start[ref] + 1,
end = ss[, 2] - proteins$start[ref] + 1
)
ss_track <- list(
id = "ss", name = "Disulfide bonds (chymotrypsinogen)", kind = "arc",
arcs = bonds, row = "Chymotrypsinogen", color = "#b35806", height = 50
)
The subtraction converts a chain position to a position in the cut domain, and
row = "Chymotrypsinogen" has the viewer walk that row’s gaps to find each
column.

8. Band the catalytic columns
Subtracting the domain start moves the charge-relay residues to domain numbering too, and the viewer projects them onto columns:
triad <- proteins$act[[ref]] - proteins$start[ref] + 1
names(triad) <- c("His57", "Asp102", "Ser195")
bands <- Map(
function(residue, label) {
list(row = "Chymotrypsinogen", start = residue, end = residue, label = label)
},
triad, names(triad)
)
widget <- msaview(
msa = aln,
tree = tree,
color_scheme = "clustalx_protein_dynamic",
column_tracks = list(blosum_track, ss_track),
highlights = bands,
height = 520
)
widget
9. Read each row’s catalytic residues
A cumulative sum over the reference row finds the three banded columns in the matrix, the same walk the viewer does:
residue_column <- cumsum(m[ref, ] != "-")
triad_column <- vapply(triad, function(r) which(residue_column == r)[1], integer(1))
data.frame(
protein = proteins$label,
charge_relay = ifelse(lengths(proteins$act) > 0, "annotated", "none"),
His57 = m[, triad_column[1]],
Asp102 = m[, triad_column[2]],
Ser195 = m[, triad_column[3]]
)
protein charge_relay His57 Asp102 Ser195
Chymotrypsinogen annotated H D S
Trypsin_1 annotated H D S
Elastase annotated H D S
Granzyme_B annotated H D S
PSA annotated H D S
Prothrombin annotated H D S
Factor_X annotated H D S
Plasminogen annotated H D S
tPA annotated H D S
Haptoglobin none K D A
Azurocidin none S D G
Protein_Z none K D M
HGF none Q D Y
MST1 none Q Q Y
All nine active peptidases read H, D and S. Each of the other five reads something else at His57 and at Ser195, and four of them keep the aspartate.

10. Save the widget, or share the link
The BLOSUM62 track, the arcs and the bands go into a URL that opens the hosted alignment in the web viewer:
snapshot <- list(msaview = list(
type = "MsaView",
height = 560,
treeAreaWidth = 150,
colorSchemeName = "clustalx_protein_dynamic",
msaFilehandle = list(uri = "data/proteases/proteases.aln"),
treeFilehandle = list(uri = "data/proteases/proteases.nh"),
columnTracks = list(blosum_track, ss_track),
highlights = unname(bands)
))
json <- as.character(jsonlite::toJSON(snapshot, auto_unbox = TRUE, digits = 3))
paste0("https://gmod.org/JBrowseMSA/demo/#data=", URLencode(json, reserved = TRUE))the link is 3075 charactersunname() is required, because Map over a named vector names its result and a
named list serializes as a JSON object where the viewer reads an array.
saveWidget writes a page that opens without R, and msaview_plot() draws the
widget as a ggplot2 figure. In a fresh session, the hosted alignment, tree and
proteases-layers.json rebuild widget:
library(ggmsaview)
data <- "https://gmod.org/JBrowseMSA/demo/data/proteases/"
layers <- jsonlite::fromJSON(paste0(data, "proteases-layers.json"),
simplifyVector = FALSE)
widget <- msaview(
msa = paste0(data, "proteases.aln"),
tree = paste0(data, "proteases.nh"),
color_scheme = "clustalx_protein_dynamic",
column_tracks = layers$columnTracks,
highlights = layers$highlights
)
htmlwidgets::saveWidget(widget, "proteases.html", selfcontained = FALSE)
ggplot2::ggsave("r-protease-triad-r.png",
msaview_plot(widget, tracks = c("blosum", "ss")),
width = 12, height = 6)
Reproduce it end to end
build_r_protease_triad.R
runs the R above in order:
- It reads the domain bounds, charge-relay residues and disulfide bonds from UniProt in one request, then cuts each chain to its S1 domain.
- DECIPHER aligns the domains, and ape builds a neighbor-joining tree.
- It scores every column by mean pairwise BLOSUM62 and prints each row’s letters at His57, Asp102 and Ser195 beside UniProt’s annotation.
- It draws the result with
msaview(), saves the widget, and writes the layers as JSON and as a URL for the web viewer.
It needs the tools under Prerequisites.
curl -O https://raw.githubusercontent.com/GMOD/JBrowseMSA/main/docs/tutorials/scripts/build_r_protease_triad.R
Rscript build_r_protease_triad.R out/
The script writes out/proteases.aln, out/proteases.nh,
out/proteases-layers.json and out/proteases.html.
See also
References
- Blow DM, Birktoft JJ, Hartley BS. Role of a buried acid group in the mechanism of action of chymotrypsin. Nature 221:337-340 (1969).
- Rawlings ND, et al. The MEROPS database of proteolytic enzymes, their substrates and inhibitors in 2017. Nucleic Acids Research 46:D624-D632 (2018), which classifies the five rows above as non-peptidase homologues.
- Wright ES. DECIPHER: harnessing local sequence context to improve protein multiple sequence alignment. BMC Bioinformatics 16:322 (2015).
- Paradis E, Schliep K. ape 5.0: an environment for modern phylogenetics and evolutionary analyses in R. Bioinformatics 35:526-528 (2019).
- Henikoff S, Henikoff JG. Amino acid substitution matrices from protein blocks. PNAS 89:10915-10919 (1992).
- The UniProt Consortium. UniProt: the Universal Protein Knowledgebase in 2025. Nucleic Acids Research 53:D609-D617 (2025).