diff --git a/.RData b/.RData
new file mode 100644
index 0000000..d17abb1
Binary files /dev/null and b/.RData differ
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..64e39ea
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+__pycache__/
+*.py[cod]
+.git/
+.Rhistory
+.env
+*.env
+*.sqlite3*
+*.bak*
+*.pre-*
diff --git a/.github/workflows/boxplotr-mcp-container.yml b/.github/workflows/boxplotr-mcp-container.yml
new file mode 100644
index 0000000..a15c152
--- /dev/null
+++ b/.github/workflows/boxplotr-mcp-container.yml
@@ -0,0 +1,64 @@
+name: BoxPlotR MCP container
+
+on:
+ push:
+ paths:
+ - "Dockerfile.mcp"
+ - "requirements-mcp.txt"
+ - "boxplotr_mcp_server.py"
+ - "*.R"
+ - "deploy/boxplotr_mcp/**"
+ - "tests/test_mcp_container.py"
+ - "tests/test_mcp_regressions.py"
+ - ".github/workflows/boxplotr-mcp-container.yml"
+ pull_request:
+ paths:
+ - "Dockerfile.mcp"
+ - "requirements-mcp.txt"
+ - "boxplotr_mcp_server.py"
+ - "*.R"
+ - "deploy/boxplotr_mcp/**"
+ - "tests/test_mcp_container.py"
+ - "tests/test_mcp_regressions.py"
+ - ".github/workflows/boxplotr-mcp-container.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - name: Build MCP image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile.mcp
+ load: true
+ push: false
+ tags: boxplotr-mcp:test
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ - name: Start MCP container
+ run: docker run --detach --name boxplotr-mcp --env MCP_IDENTITY_SECRET=docker-ci-runtime-only --publish 8765:8765 boxplotr-mcp:test
+ - name: Wait for health
+ run: |
+ for attempt in $(seq 1 24); do
+ if curl --fail --silent http://127.0.0.1:8765/health >/dev/null; then
+ exit 0
+ fi
+ sleep 5
+ done
+ docker logs boxplotr-mcp
+ exit 1
+ - name: Test health, MCP and rendering
+ run: python3 tests/test_mcp_container.py
+ - name: Test whisker statistics and all output formats
+ run: docker exec boxplotr-mcp /opt/boxplotr-mcp/.venv/bin/python -m unittest discover -s /srv/shiny-server/boxplotr/tests -p test_mcp_regressions.py -v
+ - name: Show logs on failure
+ if: failure()
+ run: docker logs boxplotr-mcp
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..6f252d4
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,98 @@
+name: Docker
+
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+
+on:
+ schedule:
+ - cron: '24 21 * * *'
+ push:
+ branches: [ "master" ]
+ # Publish semver tags as releases.
+ tags: [ 'v*.*.*' ]
+ pull_request:
+ branches: [ "master" ]
+
+env:
+ # Use docker.io for Docker Hub if empty
+ REGISTRY: ghcr.io
+ # github.repository as /
+ IMAGE_NAME: ${{ github.repository }}
+
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ # This is used to complete the identity challenge
+ # with sigstore/fulcio when running outside of PRs.
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # Install the cosign tool except on PR
+ # https://github.com/sigstore/cosign-installer
+ - name: Install cosign
+ if: github.event_name != 'pull_request'
+ uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
+ with:
+ cosign-release: 'v2.2.4'
+
+ # Set up BuildKit Docker container builder to be able to build
+ # multi-platform images and export cache
+ # https://github.com/docker/setup-buildx-action
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
+
+ # Login against a Docker registry except on PR
+ # https://github.com/docker/login-action
+ - name: Log into registry ${{ env.REGISTRY }}
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Extract metadata (tags, labels) for Docker
+ # https://github.com/docker/metadata-action
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
+ # Build and push Docker image with Buildx (don't push on PR)
+ # https://github.com/docker/build-push-action
+ - name: Build and push Docker image
+ id: build-and-push
+ uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
+ with:
+ context: .
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ # Sign the resulting Docker image digest except on PRs.
+ # This will only write to the public Rekor transparency log when the Docker
+ # repository is public to avoid leaking data. If you would like to publish
+ # transparency data even for private images, pass --force to cosign below.
+ # https://github.com/sigstore/cosign
+ - name: Sign the published Docker image
+ if: ${{ github.event_name != 'pull_request' }}
+ env:
+ # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
+ TAGS: ${{ steps.meta.outputs.tags }}
+ DIGEST: ${{ steps.build-and-push.outputs.digest }}
+ # This step uses the identity token to provision an ephemeral certificate
+ # against the sigstore community Fulcio instance.
+ run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
diff --git a/.gitignore b/.gitignore
index a0fd3b3..7709ac1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,7 @@
# Example code in package build process
*-Ex.R
+
+# Python generated files
+__pycache__/
+*.py[cod]
diff --git a/BoxPlotR_functions.R b/BoxPlotR_functions.R
new file mode 100644
index 0000000..e162deb
--- /dev/null
+++ b/BoxPlotR_functions.R
@@ -0,0 +1,60 @@
+# Specifies the arrangement of data points (normal or jittered).
+# point_type: 0 = normal, 2 = jittered
+jittered_points <- function(data_matrix, my_horizontal = FALSE, point_type,
+ point_colors, point_transparency, point_size) {
+ # normal boxplots
+ if (my_horizontal) {
+ for (i in seq_len(ncol(data_matrix))) {
+ alpha_val <- 255 * (point_transparency / 100)
+ col_rgb <- rgb(
+ t(col2rgb(point_colors[i])),
+ maxColorValue = 255,
+ alpha = alpha_val
+ )
+ if (point_type == 0) {
+ points(
+ data_matrix[, i],
+ rep(i, nrow(data_matrix)),
+ col = col_rgb,
+ pch = 16,
+ cex = point_size
+ )
+ } else {
+ points(
+ data_matrix[, i],
+ jitter(rep(i, nrow(data_matrix)), amount = 0.25),
+ col = col_rgb,
+ pch = 16,
+ cex = point_size
+ )
+ }
+ }
+ } else {
+ # horizontal boxplots
+ for (i in seq_len(ncol(data_matrix))) {
+ alpha_val <- 255 * (point_transparency / 100)
+ col_rgb <- rgb(
+ t(col2rgb(point_colors[i])),
+ maxColorValue = 255,
+ alpha = alpha_val
+ )
+ if (point_type == 0) {
+ points(
+ rep(i, nrow(data_matrix)),
+ data_matrix[, i],
+ col = col_rgb,
+ pch = 16,
+ cex = point_size
+ )
+ } else {
+ points(
+ jitter(rep(i, nrow(data_matrix)), amount = 0.25),
+ data_matrix[, i],
+ col = col_rgb,
+ pch = 16,
+ cex = point_size
+ )
+ }
+ }
+ }
+}
diff --git a/Boxplot_testData3.xlsx b/Boxplot_testData3.xlsx
new file mode 100644
index 0000000..c5a7942
Binary files /dev/null and b/Boxplot_testData3.xlsx differ
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5fac614
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,20 @@
+# Use the official lightweight rocker shiny image
+FROM rocker/shiny:latest
+
+
+RUN R -e "install.packages(c('beeswarm', 'vioplot', 'beanplot', 'RColorBrewer', 'readxl', 'sm', 'testthat', 'ggplot2'), repos='https://cloud.r-project.org/')"
+
+# Remove default Shiny apps
+RUN rm -rf /srv/shiny-server/*
+
+# Copy the application files to the container
+COPY . /srv/shiny-server/
+
+# Ensure proper ownership
+RUN chown -R shiny:shiny /srv/shiny-server/
+
+# Expose the shiny server port
+EXPOSE 3838
+
+# Run the Shiny server
+CMD ["/usr/bin/shiny-server"]
diff --git a/Dockerfile.mcp b/Dockerfile.mcp
new file mode 100644
index 0000000..a01a10c
--- /dev/null
+++ b/Dockerfile.mcp
@@ -0,0 +1,33 @@
+FROM rocker/r-ver:4.5.1
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN install2.r --error --skipinstalled \
+ beanplot beeswarm ggplot2 RColorBrewer sm tidyr vioplot
+
+WORKDIR /opt/boxplotr-mcp
+COPY requirements-mcp.txt ./
+RUN python3 -m venv .venv \
+ && .venv/bin/pip install --no-cache-dir --requirement requirements-mcp.txt
+
+COPY deploy/boxplotr_mcp/app.py ./
+COPY tests/test_mcp_regressions.py /srv/shiny-server/boxplotr/tests/
+COPY boxplotr_mcp_server.py BoxPlotR_functions.R boxplot_stats_Function.R \
+ MyBeanplot.R MyVioplot.R /srv/shiny-server/boxplotr/
+
+RUN groupadd --system shiny \
+ && useradd --system --gid shiny --home-dir /nonexistent --shell /usr/sbin/nologin shiny \
+ && mkdir -p /var/lib/boxplotr-mcp/output /etc/boxplotr-mcp \
+ && printf '{}\n' > /etc/boxplotr-mcp/keys.json \
+ && chown -R shiny:shiny /var/lib/boxplotr-mcp \
+ && chmod -R a-w /opt/boxplotr-mcp /srv/shiny-server/boxplotr
+
+USER shiny
+EXPOSE 8765
+
+HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=6 \
+ CMD .venv/bin/python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=2)"
+
+CMD [".venv/bin/uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8765", "--workers", "1", "--limit-concurrency", "24", "--timeout-keep-alive", "5"]
diff --git a/MyVioplot.R b/MyVioplot.R
index f3ac400..e1ffa67 100644
--- a/MyVioplot.R
+++ b/MyVioplot.R
@@ -1,102 +1,125 @@
-vioplot<-function (x, range = 1.5, h = NULL, ylim = NULL, names = NULL,
- horizontal = FALSE, col = "cornflowerblue", border = "black", lty = 1,
- lwd = 1, rectCol = "black", colMed = "white", pchMed = 19,
- at, add = FALSE, wex = 1, drawRect = TRUE, cex.axis=1)
-{
- datas <- x
- n <- length(datas)
- if (missing(at))
- at <- 1:n
- upper <- vector(mode = "numeric", length = n)
- lower <- vector(mode = "numeric", length = n)
- q1 <- vector(mode = "numeric", length = n)
- q3 <- vector(mode = "numeric", length = n)
- med <- vector(mode = "numeric", length = n)
- base <- vector(mode = "list", length = n)
- height <- vector(mode = "list", length = n)
- baserange <- c(Inf, -Inf)
- args <- list(display = "none")
- if (!(is.null(h)))
- args <- c(args, h = h)
- for (i in 1:n) {
- datas[[i]]<-datas[[i]][!is.na(datas[[i]])]
- data <- datas[[i]]
- data.min <- min(data)
- data.max <- max(data)
- q1[i] <- quantile(data, 0.25)
- q3[i] <- quantile(data, 0.75)
- med[i] <- median(data)
- iqd <- q3[i] - q1[i]
- upper[i] <- min(q3[i] + range * iqd, data.max)
- lower[i] <- max(q1[i] - range * iqd, data.min)
- est.xlim <- c(min(lower[i], data.min), max(upper[i],
- data.max))
- smout <- do.call("sm.density", c(list(data, xlim = est.xlim),
- args))
- hscale <- 0.4/max(smout$estimate) * wex
- base[[i]] <- smout$eval.points
- height[[i]] <- smout$estimate * hscale
- t <- range(base[[i]])
- baserange[1] <- min(baserange[1], t[1])
- baserange[2] <- max(baserange[2], t[2])
+vioplot <- function(x, range = 1.5, h = NULL, ylim = NULL, names = NULL,
+ horizontal = FALSE, col = "cornflowerblue", border = "black", lty = 1,
+ lwd = 1, rectCol = "black", colMed = "white", pchMed = 19,
+ at, add = FALSE, wex = 1, drawRect = TRUE, cex.axis = 1, log = "") {
+ datas <- x
+ n <- length(datas)
+ if (missing(at)) {
+ at <- 1:n
+ }
+ upper <- rep(NA_real_, n)
+ lower <- rep(NA_real_, n)
+ q1 <- rep(NA_real_, n)
+ q3 <- rep(NA_real_, n)
+ med <- rep(NA_real_, n)
+ base <- vector(mode = "list", length = n)
+ height <- vector(mode = "list", length = n)
+ baserange <- c(Inf, -Inf)
+ args <- list(display = "none")
+ if (!(is.null(h))) {
+ args <- c(args, h = h)
+ }
+ for (i in seq_len(n)) {
+ datas[[i]] <- datas[[i]][!is.na(datas[[i]])]
+ data <- datas[[i]]
+ if (length(data) < 2) next
+ data.min <- min(data)
+ data.max <- max(data)
+ q1[i] <- quantile(data, 0.25)
+ q3[i] <- quantile(data, 0.75)
+ med[i] <- median(data)
+ iqd <- q3[i] - q1[i]
+ upper[i] <- min(q3[i] + range * iqd, data.max)
+ lower[i] <- max(q1[i] - range * iqd, data.min)
+ est.xlim <- c(min(lower[i], data.min), max(
+ upper[i],
+ data.max
+ ))
+ smout <- do.call("sm.density", c(
+ list(data, xlim = est.xlim),
+ args
+ ))
+ hscale <- 0.4 / max(smout$estimate) * wex
+ base[[i]] <- smout$eval.points
+ height[[i]] <- smout$estimate * hscale
+ t <- range(base[[i]])
+ baserange[1] <- min(baserange[1], t[1])
+ baserange[2] <- max(baserange[2], t[2])
+ }
+ if (!add) {
+ xlim <- if (n == 1) {
+ at + c(-0.5, 0.5)
+ } else {
+ range(at) + min(diff(at)) / 2 * c(-1, 1)
}
- if (!add) {
- xlim <- if (n == 1)
- at + c(-0.5, 0.5)
- else range(at) + min(diff(at))/2 * c(-1, 1)
- if (is.null(ylim)) {
- ylim <- baserange
- }
+ if (is.null(ylim)) {
+ ylim <- baserange
}
- if (is.null(names)) {
- label <- 1:n
+ }
+ if (is.null(names)) {
+ label <- 1:n
+ } else {
+ label <- names
+ }
+ boxwidth <- 0.05 * wex
+ if (!add) {
+ plot.new()
+ }
+ if (!horizontal) {
+ if (!add) {
+ plot.window(xlim = xlim, ylim = ylim, log = log)
+ axis(2, cex.axis = cex.axis)
+ axis(1, at = at, labels = label, cex.axis = cex.axis)
}
- else {
- label <- names
+ # box()
+ for (i in seq_len(n)) {
+ if (is.na(med[i])) next
+ polygon(c(at[i] - height[[i]], rev(at[i] + height[[i]])),
+ c(base[[i]], rev(base[[i]])),
+ col = ifelse(length(col) > 1, col[1 + (i - 1) %% length(col)], col),
+ border = border, lty = lty, lwd = lwd
+ )
+ if (drawRect) {
+ lines(at[c(i, i)], c(lower[i], upper[i]),
+ lwd = lwd,
+ lty = lty
+ )
+ rect(at[i] - boxwidth / 2, q1[i], at[i] + boxwidth / 2,
+ q3[i],
+ col = rectCol
+ )
+ points(at[i], med[i], pch = pchMed, col = colMed)
+ }
}
- boxwidth <- 0.05 * wex
- if (!add)
- plot.new()
- if (!horizontal) {
- if (!add) {
- plot.window(xlim = xlim, ylim = ylim)
- axis(2, cex.axis=cex.axis)
-# axis(1, at = at, label = label, cex.axis=cex.axis)
- }
- box()
- for (i in 1:n) {
- polygon(c(at[i] - height[[i]], rev(at[i] + height[[i]])),
- c(base[[i]], rev(base[[i]])), col = col, border = border,
- lty = lty, lwd = lwd)
- if (drawRect) {
- lines(at[c(i, i)], c(lower[i], upper[i]), lwd = lwd,
- lty = lty)
- rect(at[i] - boxwidth/2, q1[i], at[i] + boxwidth/2,
- q3[i], col = rectCol)
- points(at[i], med[i], pch = pchMed, col = colMed)
- }
- }
+ } else {
+ if (!add) {
+ plot.window(xlim = ylim, ylim = xlim, log = log)
+ axis(1, cex.axis = cex.axis)
+ axis(2, at = at, labels = label, cex.axis = cex.axis)
}
- else {
- if (!add) {
- plot.window(xlim = ylim, ylim = xlim)
- axis(1, cex.axis=cex.axis)
-# axis(2, at = at, label = label)
- }
- box()
- for (i in 1:n) {
- polygon(c(base[[i]], rev(base[[i]])), c(at[i] - height[[i]],
- rev(at[i] + height[[i]])), col = col, border = border,
- lty = lty, lwd = lwd)
- if (drawRect) {
- lines(c(lower[i], upper[i]), at[c(i, i)], lwd = lwd,
- lty = lty)
- rect(q1[i], at[i] - boxwidth/2, q3[i], at[i] +
- boxwidth/2, col = rectCol)
- points(med[i], at[i], pch = pchMed, col = colMed)
- }
- }
+ # box()
+ for (i in seq_len(n)) {
+ if (is.na(med[i])) next
+ polygon(c(base[[i]], rev(base[[i]])), c(
+ at[i] - height[[i]],
+ rev(at[i] + height[[i]])
+ ),
+ col = ifelse(length(col) > 1, col[1 + (i - 1) %% length(col)], col),
+ border = border, lty = lty, lwd = lwd
+ )
+ if (drawRect) {
+ lines(c(lower[i], upper[i]), at[c(i, i)],
+ lwd = lwd,
+ lty = lty
+ )
+ rect(q1[i], at[i] - boxwidth / 2, q3[i], at[i] +
+ boxwidth / 2, col = rectCol)
+ points(med[i], at[i], pch = pchMed, col = colMed)
+ }
}
- invisible(list(upper = upper, lower = lower, median = med,
- q1 = q1, q3 = q3))
+ }
+ invisible(list(
+ upper = upper, lower = lower, median = med,
+ q1 = q1, q3 = q3
+ ))
}
diff --git a/README.md b/README.md
index 911253c..8c46e52 100644
--- a/README.md
+++ b/README.md
@@ -1,48 +1,201 @@
-BoxPlotR
-========
-
-This is the repository for the Shiny application presented in "BoxPlotR: a web tool for generation of box plots" (Spitzer at al. 2014).
-
-Installation
-------------
-
-You have two options for running shiny-boxplot:
-
-1) Launch directly from R and GitHub:
- - Before running the app you will need to have R and RStudio installed (tested with R 3.0.2 and RStudio 0.97.449).
- - Launch the R console
-
-- Please run these lines in R:
- - install.packages("shiny")
- - install.packages("devtools")
- - devtools::install_github("shiny-incubator","rstudio")
- - install.packages("beeswarm")
- - install.packages("vioplot")
- - install.packages("beanplot")
- - install.packages("RColorBrewer")
-
-- Then start the app:
- - shiny::runGitHub("BoxPlotR.shiny", "VizWizard")
-
-Your web browser will open the web app.
-
-2) Install the shiny-server and implement shiny-boxplot as a web application and service:
- - In Ubuntu 12.04+
- - sudo apt-get install gdebi-core
- - wget http://download3.rstudio.org/ubuntu-12.04/x86_64/shiny-server-1.0.0.42-amd64.deb (may need to change ubuntu or server version number)
- - sudo gdebi shiny-server-1.0.0.42-amd64.deb
- - edit: /opt/shiny-server/config/default.config in a text editor
- - Change these lines to suit your environment
- - listen **SHINY_PORT**; (change **SHINY_PORT** to match the port you want)
- - site_dir **SHINY_APP_HOME**; (change **SHINY_APP_HOME** to the location for your shiny apps)
- - make sure **SHINY_PORT** is open on your firewall
- - Go to your **SHINY_APP_HOME**
- - cd **SHINY_APP_HOME**
- - Get the latest shiny-boxplot code from github:
- - wget https://github.com/jwildenhain/shiny-boxplot/archive/master.zip
- - unzip master.zip
- - mv shiny-boxplot-master shiny-boxplot
- - Restart shiny-server service:
- - sudo service shiny-server restart
-
-You should now be able to access shiny-boxplot at: http://YOURSITE:**SHINY_PORT**/shiny-boxplot
+# BoxPlotR
+
+The canonical repository is [jwildenhain/BoxPlotR.shiny](https://github.com/jwildenhain/BoxPlotR.shiny). The older [shiny-boxplot](https://github.com/jwildenhain/shiny-boxplot) repository is retained for historical reference. This repository contains the Shiny application, stdio plotting engine, public MCP gateway, deployment configuration, and illustrated guide.
+
+See [deployment instructions](deploy/boxplotr_mcp/README.md) and the [consolidation record](docs/consolidation-2026-09-18.md).
+
+[](https://www.r-project.org/)
+[](https://shiny.posit.co/)
+[](https://hub.docker.com/r/rocker/shiny)
+[](https://modelcontextprotocol.io)
+[](#advanced-statistical-capabilities)
+[](#advanced-statistical-capabilities)
+
+This is the repository for the Shiny application presented in **"BoxPlotR: a web tool for generation of box plots"** (Spitzer et al. 2014).
+
+
+
+Advanced Statistical Capabilities
+---------------------------------
+
+BoxPlotR v2.0.0 is engineered for biostatistics and rigorous exploratory data analysis, automating standard publication-quality data summaries:
+
+### 1. Robust Whisker Calculations
+* **Tukey Whiskers (`range = -1.5` in the custom BoxPlotR helper):** Whiskers extend to the most extreme data point within $1.5 \times \text{IQR}$ (Interquartile Range) from the box hinges. Outliers are plotted individually.
+* **Spear Whiskers (`range = 0`):** Whiskers span the absolute minimum and maximum data values, treating no data points as outliers.
+* **Altman Percentiles (`range > 0`):** Whiskers represent symmetric percentiles (e.g. 5th and 95th, or 2.5th and 97.5th percentiles) directly from the sample distribution—ideal for larger clinical datasets.
+
+### 2. Precise Median Notches (Confidence Intervals)
+Notches represent the $95\%$ confidence interval around the median, calculated using:
+$$\text{Median} \pm 1.58 \times \frac{\text{IQR}}{\sqrt{n}}$$
+If the notches of two box plots do not overlap, their medians differ with strong statistical evidence (approx. $95\%$ confidence level).
+
+### 3. Sample-Size Weighted Box Widths (`varwidth`)
+Align box widths proportionally to the square root of the number of observations ($\sqrt{n}$) to immediately alert reviewers to sample size variations across groups.
+
+### 4. Mean & Confidence Interval Overlays
+Superimpose sample means as high-contrast red diamonds, with customizable error bars showing $83\%$, $90\%$, or $95\%$ confidence intervals of the mean.
+
+### 5. Multi-Modal Density Estimation
+Toggle from standard summaries to **Violin Plots** or **Beanplots** to inspect kernel density bandwidths, skewness, and multimodal distributions.
+
+---
+
+Installation and Run Options
+----------------------------
+
+### 1) Run Natively via Docker (Recommended Isolated Deployment)
+
+Deploy the fully-configured modern version natively without installing R dependencies directly onto your host system:
+
+```bash
+# Build the Docker image
+docker build -t boxplotr:latest .
+
+# Run the container (maps the container server to port 3838)
+docker run -d -p 3838:3838 boxplotr:latest
+```
+Access the application in your web browser at: `http://localhost:3838`
+
+### 2) Running the Isolated Test Suite
+The container comes equipped with `testthat` to run the project's automated test suite inside the same isolated sandbox:
+
+```bash
+docker run --rm boxplotr:latest Rscript -e "library(testthat); test_dir('/srv/shiny-server/tests')"
+```
+
+---
+
+### 3) Launch Natively from R and GitHub
+
+Before running natively, ensure you have the latest versions of R and RStudio installed:
+
+1. Launch R / RStudio Console.
+2. Install the necessary packages:
+ ```R
+ install.packages(c("shiny", "beeswarm", "vioplot", "beanplot", "RColorBrewer", "readxl", "sm", "testthat", "ggplot2"))
+ ```
+3. Start the application directly:
+ ```R
+ shiny::runGitHub("BoxPlotR.shiny", "jwildenhain")
+ ```
+
+---
+
+### 4) Install Natively on Shiny Server
+
+To run BoxPlotR as a service on a dedicated Linux host (e.g. Ubuntu):
+
+1. Install Shiny Server system dependencies:
+ ```bash
+ sudo apt-get update
+ sudo apt-get install gdebi-core R-base
+ ```
+2. Download and install POSIT's Shiny Server from [posit.co/download/shiny-server/](https://posit.co/download/shiny-server/).
+3. Pull the BoxPlotR repository into your Shiny server apps directory (e.g., `/srv/shiny-server/` or your custom `SHINY_APP_HOME`).
+4. Install all required R packages system-wide:
+ ```bash
+ sudo R -e 'install.packages(c("shiny", "beeswarm", "vioplot", "beanplot", "RColorBrewer", "readxl", "sm", "ggplot2"), repos="https://cloud.r-project.org/")'
+ ```
+5. Restart the server service:
+ ```bash
+ sudo systemctl restart shiny-server
+ ```
+
+---
+
+### 5) Public Model Context Protocol (MCP) service
+
+BoxPlotR is available to MCP-compatible AI assistants over public Streamable HTTP:
+
+- Endpoint: `https://mcp.chemgrid.org/boxplotr/`
+- Tool: `generate_boxplot`
+- Authentication: none required
+- Dataset limit: 5 MiB per request
+- Usage limit: 20 plot generations per client IP per UTC day
+- Capacity: 10 plot jobs can run concurrently
+- Execution timeout: 120 seconds
+- Output formats: PNG, SVG and PDF
+
+#### Codex
+
+Register the public remote server directly:
+
+```bash
+codex mcp add boxplotr --url https://mcp.chemgrid.org/boxplotr/
+```
+
+Other clients, including Claude Desktop and Antigravity, can connect when they support remote Streamable HTTP MCP servers. No API key or custom authorization header is required.
+
+#### Tool input
+
+`generate_boxplot` accepts CSV or tab-separated data in `values`, with column headers and at least one data row. Its principal options are:
+
+| Parameter | Values / purpose |
+| --- | --- |
+| `values` | CSV or TSV dataset; columns represent groups |
+| `plot_type` | `boxplot`, `violin` or `beanplot` |
+| `plot_engine` | `ggplot2` or the supported classic engine |
+| `style_guide` | `none`, `nature`, `science`, `economist`, `ft` |
+| `orientation` | `vertical` or `horizontal` |
+| `log_scale` | Enable logarithmic scaling |
+| `title`, `x_label`, `y_label` | Figure labels |
+| `colors` | Hexadecimal colours, e.g. `#2563eb` |
+| `show_points`, `add_means` | Raw points; mean markers for box plots |
+| `output_format` | `png`, `svg` or `pdf` |
+
+Example tool arguments:
+
+```json
+{
+ "values": "Control,Treatment\n1.2,2.4\n1.5,2.9\n1.8,3.1",
+ "plot_type": "boxplot",
+ "plot_engine": "ggplot2",
+ "title": "Treatment response",
+ "show_points": true,
+ "add_means": true,
+ "output_format": "png"
+}
+```
+
+The public response contains a text summary and one attachment: PNG uses MCP
+`image` content (`mimeType` and base64 `data`); SVG/PDF use MCP `resource`
+content containing `resource.uri`, `resource.mimeType`, and base64
+`resource.blob`. The URI identifies the embedded file; clients do not need to
+retrieve it from the server filesystem. Save the attachment in the client.
+Temporary outputs become eligible for cleanup after one hour and are removed
+when a subsequent plot request performs cleanup.
+
+The public endpoint accepts `output_format`, not `output_path`. Notches,
+variable box widths, mean confidence intervals, subtitles, grids, and detailed
+point styling are local stdio options, not public HTTP tool parameters.
+
+#### Illustrated guide and tested scenarios
+
+The shareable guide at `https://boxplotr.chemgrid.org/mcp-guide.html` documents four plots generated through the live public endpoint:
+
+- the bundled five-sample CSV with custom colours, jittered observations and mean markers;
+- the bundled text scenario as a Nature-style violin plot;
+- the bundled Excel scenario, checked and converted to CSV, on a logarithmic axis with Science styling;
+- an original Economist Impact-inspired editorial plot using illustrative reconstructed values, with attribution to Figure 11a of the public LAC Infrascope 2021/22 report.
+
+The editorial example demonstrates a visual treatment only. It does not reproduce or claim to contain the report's underlying data.
+
+#### Privacy and analytics
+
+Datasets and raw client addresses are not sent to Google Analytics. The service records operational usage in its private database and sends privacy-safe GA4 events for successful and failed plot requests. Analytics parameters include the application/interface, plot type, rendering engine, output format, processing duration, dataset dimensions and error category. Client addresses are immediately converted to one-way pseudonymous identifiers for quota enforcement and analytics.
+
+### 6) Local stdio development server
+
+The repository also includes `boxplotr_mcp_server.py` for local development over standard input/output. This local mode is separate from the hosted service and does not provide hosted authentication or quotas.
+
+```bash
+./boxplotr_mcp_server.py
+```
+
+Use the nested `data_config`, `visualization`, `styling`, and `overlays`
+arguments advertised by the local server's `tools/list` response. Supply CSV
+or TSV in `data_config.values` and an `output_path` ending in `.png`, `.svg`,
+or `.pdf`. In stdio mode, PNG/SVG are MCP images and PDF is an embedded
+resource; the same file is also saved at `output_path`. This differs from the
+public HTTP interface described above.
diff --git a/RELEASE_v2.0.0.md b/RELEASE_v2.0.0.md
new file mode 100644
index 0000000..a669a96
--- /dev/null
+++ b/RELEASE_v2.0.0.md
@@ -0,0 +1,39 @@
+# Release Notes - BoxPlotR v2.0.0 (Modernized)
+
+We are proud to announce the official release of **BoxPlotR v2.0.0**. This milestone release fully modernizes the classic 13-year-old BoxPlotR codebase, bringing state-of-the-art **ggplot2 integration**, a **Model Context Protocol (MCP) server** for AI coding assistants, robust statistical overlays, and strict reactive stability.
+
+---
+
+## 🌟 What's New in v2.0.0
+
+### 📊 1. Modern ggplot2 Rendering Engine
+* **ggplot2 Integration**: Introduced a brand-new **Modern (ggplot2)** plotting engine, allowing users to toggle between Base R vector plots and modern ggplot2 layouts.
+* **Premium Theme Presets**: Added professional journal-style preset themes, including **Nature**, **Science**, **The Economist**, and the **Financial Times (FT)**, making plot export ready for publication.
+* **Smart Grid Customization**: Users can now selectively toggle background grid lines on both axis orientations (`x`, `y`, or `both`) to optimize scannability.
+
+### 📐 2. High-Fidelity Overlays & Notch Fixes
+* **Advanced Point Arrangements**: Seamlessly overlay individual data points using **normal**, **jittered**, or high-fidelity **beeswarm** point arrangements.
+* **Sample Means & Confidence Intervals**: Added the option to compute and display sample means as red diamonds, complete with customizable confidence interval error bars (83%, 90%, or 95%).
+* **Safe Logarithmic Scale Support**: Fixed a critical R layout flare-up distortion on logarithmic scales. Non-standard aesthetics (such as `notchlower` and `notchupper` under base R custom whisker calculations) are now manually pre-transformed (`log10(pmax(1e-10, val))`) to ensure flawless vector rendering.
+
+### 🔌 3. Model Context Protocol (MCP) Server Integration
+* **Dynamic AI Assistance**: Native Python implementation of an **MCP server** (`boxplotr_mcp_server.py`) working over line-by-line standard input/output (`stdio`).
+* **Structured JSON Schema Specification**: Implements a clean, nested schema layout (`data_config`, `visualization`, `styling`, `overlays`, `output_path`) for robust code validation while preserving full flat-argument backward compatibility.
+* **Verified Local Testing**: The Shiny application's **FAQ tab** has been expanded with clear command-line minified JSON-RPC testing sequences.
+
+### 🔒 4. Stability, Safety, & Formats
+* **Zero-Length Reactive Protection**: Integrated safe input validators at the top of the reactive engine, completely resolving the infamous Shiny `argument is of length zero` startup crash during transitions.
+* **Native Excel Support**: Seamlessly parse, upload, and visualize modern `.xlsx` sheets without requiring external server conversions.
+* **Automated Unit Testing**: Implemented a comprehensive `testthat` verification suite (`tests/test_ggplot_boxplot.R`) covering ggplot2 layouts, notch safety, and overlays under linear and log scales.
+
+---
+
+## 🚀 Getting Started with testing the MCP Server
+
+You can execute the newly-documented, fully-validated test sequence directly from your terminal to verify standard I/O (NDJSON) plotting:
+
+```bash
+echo '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "generate_boxplot", "arguments": {"data_config": {"values": "SampleA,SampleB\n12.5,8.9\n14.2,10.1\n15.8,11.5\n13.1,9.4"}, "visualization": {"plot_type": "boxplot", "plot_engine": "ggplot2", "style_guide": "economist", "orientation": "vertical", "log_scale": false}, "styling": {"title": "Comparison of Sample A and Sample B", "xlab": "Group", "ylab": "Value", "colors": ["#0ea5e9", "#ef4444"], "add_grid": "y"}, "overlays": {"show_points": true, "point_type": "jittered", "point_size": 1.2, "point_transparency": 30, "add_means": true, "notch": true}, "output_path": "assets/mcp_test_plot.png"}}}' | python3 boxplotr_mcp_server.py
+```
+
+This will output a successful JSON-RPC response confirming that the output image is generated at `assets/mcp_test_plot.png`!
diff --git a/assets/boxplotr_preview.png b/assets/boxplotr_preview.png
new file mode 100644
index 0000000..3b8464d
Binary files /dev/null and b/assets/boxplotr_preview.png differ
diff --git a/assets/ggplot2_altman_whiskers.png b/assets/ggplot2_altman_whiskers.png
new file mode 100644
index 0000000..90a637d
Binary files /dev/null and b/assets/ggplot2_altman_whiskers.png differ
diff --git a/assets/ggplot2_bean_plot_rendered.png b/assets/ggplot2_bean_plot_rendered.png
new file mode 100644
index 0000000..f75a6b6
Binary files /dev/null and b/assets/ggplot2_bean_plot_rendered.png differ
diff --git a/assets/ggplot2_spear_whiskers.png b/assets/ggplot2_spear_whiskers.png
new file mode 100644
index 0000000..5fd7181
Binary files /dev/null and b/assets/ggplot2_spear_whiskers.png differ
diff --git a/assets/mcp_generated_plot.png b/assets/mcp_generated_plot.png
new file mode 100644
index 0000000..ba58d14
Binary files /dev/null and b/assets/mcp_generated_plot.png differ
diff --git a/assets/mcp_test_plot.png b/assets/mcp_test_plot.png
new file mode 100644
index 0000000..5b83ccb
Binary files /dev/null and b/assets/mcp_test_plot.png differ
diff --git a/assets/modern_ggplot2_bean_plot.png b/assets/modern_ggplot2_bean_plot.png
new file mode 100644
index 0000000..cadf6a5
Binary files /dev/null and b/assets/modern_ggplot2_bean_plot.png differ
diff --git a/assets/modern_ggplot2_boxplot_jittered.png b/assets/modern_ggplot2_boxplot_jittered.png
new file mode 100644
index 0000000..bd86f6a
Binary files /dev/null and b/assets/modern_ggplot2_boxplot_jittered.png differ
diff --git a/boxplotr_mcp_server.py b/boxplotr_mcp_server.py
new file mode 100755
index 0000000..b47221b
--- /dev/null
+++ b/boxplotr_mcp_server.py
@@ -0,0 +1,848 @@
+#!/usr/bin/env python3
+import sys
+import json
+import os
+import subprocess
+import tempfile
+import base64
+import mimetypes
+import time
+from datetime import datetime, timezone
+
+APP_DIR = os.path.dirname(os.path.abspath(__file__))
+
+def log(msg):
+ sys.stderr.write(f"LOG: {msg}\n")
+ sys.stderr.flush()
+
+def file_to_mcp_content(path):
+ mime_type, _ = mimetypes.guess_type(path)
+ mime_type = mime_type or "application/octet-stream"
+ with open(path, "rb") as fh:
+ encoded = base64.b64encode(fh.read()).decode("ascii")
+ if mime_type.startswith("image/"):
+ return {
+ "type": "image",
+ "data": encoded,
+ "mimeType": mime_type
+ }
+ return {
+ "type": "resource",
+ "resource": {
+ "uri": f"file://{os.path.abspath(path)}",
+ "mimeType": mime_type,
+ "blob": encoded
+ }
+ }
+
+def record_usage(arguments, success, started, error_type=None):
+ try:
+ data_config = arguments.get("data_config", {})
+ values = data_config.get("values", arguments.get("data", ""))
+ lines = [line for line in values.splitlines() if line.strip()]
+ delimiter = "tab" if lines and "\t" in lines[0] else "comma"
+ columns = len(lines[0].split("\t" if delimiter == "tab" else ",")) if lines else 0
+ visualization = arguments.get("visualization", {})
+ output_path = arguments.get("output_path", "")
+ event = {
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "tool": "generate_boxplot",
+ "success": bool(success),
+ "duration_ms": round((time.monotonic() - started) * 1000),
+ "input_bytes": len(values.encode("utf-8")),
+ "data_rows": max(0, len(lines) - 1),
+ "data_columns": columns,
+ "plot_type": visualization.get("plot_type", arguments.get("plot_type", "boxplot")),
+ "plot_engine": visualization.get("plot_engine", arguments.get("plot_engine", "classic")),
+ "style_guide": visualization.get("style_guide", arguments.get("style_guide", "none")),
+ "output_format": os.path.splitext(output_path)[1].lower().lstrip("."),
+ }
+ if error_type:
+ event["error_type"] = error_type
+ subprocess.run(["logger", "-t", "boxplotr-mcp-usage", json.dumps(event, separators=(",", ":"))], check=False, timeout=2)
+ except Exception as telemetry_error:
+ log(f"Usage telemetry failed: {type(telemetry_error).__name__}")
+
+def generate_plot(arguments):
+ # Extract nested sections (supporting the new JSON Schema spec)
+ data_config = arguments.get("data_config", {})
+ visualization = arguments.get("visualization", {})
+ styling = arguments.get("styling", {})
+ overlays = arguments.get("overlays", {})
+
+ # Fallback to old flat structure if present (for backward compatibility)
+ data_str = data_config.get("values", arguments.get("data", ""))
+ output_path = arguments.get("output_path", "")
+
+ plot_type = visualization.get("plot_type", arguments.get("plot_type", "boxplot"))
+ plot_engine = visualization.get("plot_engine", arguments.get("plot_engine", "classic"))
+ style_guide = visualization.get("style_guide", arguments.get("style_guide", "none"))
+ orientation = visualization.get("orientation", arguments.get("orientation", "vertical"))
+ log_scale = visualization.get("log_scale", arguments.get("log_scale", False))
+
+ title = styling.get("title", arguments.get("title", ""))
+ subtitle = styling.get("subtitle", arguments.get("subtitle", ""))
+ xlab = styling.get("xlab", arguments.get("xlab", ""))
+ ylab = styling.get("ylab", arguments.get("ylab", ""))
+ colors = styling.get("colors", arguments.get("colors", []))
+ add_grid = styling.get("add_grid", arguments.get("add_grid", "none"))
+
+ show_points = overlays.get("show_points", arguments.get("show_points", False))
+ point_type = overlays.get("point_type", arguments.get("point_type", "jittered"))
+ point_size = overlays.get("point_size", arguments.get("point_size", 1.0))
+ point_transparency = overlays.get("point_transparency", arguments.get("point_transparency", 50))
+ add_means = overlays.get("add_means", arguments.get("add_means", False))
+ add_mean_ci = overlays.get("add_mean_ci", arguments.get("add_mean_ci", False))
+ mean_ci_level = overlays.get("mean_ci_level", arguments.get("mean_ci_level", 95))
+ varwidth = overlays.get("varwidth", arguments.get("varwidth", False))
+ notch = overlays.get("notch", arguments.get("notch", False))
+
+ # Validation
+ if not data_str:
+ raise ValueError("Missing 'data' or 'data_config.values' argument")
+ if not output_path:
+ raise ValueError("Missing 'output_path' argument")
+
+ # Resolve absolute paths
+ output_path = os.path.abspath(output_path)
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+ output_ext = os.path.splitext(output_path)[1].lower()
+ if output_ext not in (".png", ".svg", ".pdf"):
+ raise ValueError("Unsupported output_path extension. Use .png, .svg, or .pdf")
+
+ # Create R list for colors
+ if colors:
+ colors_r = "c(" + ", ".join(json.dumps(str(c)) for c in colors) + ")"
+ else:
+ colors_r = "NULL"
+
+ # R Template code supporting both Classic R and ggplot2 along with style guides
+ r_code_template = """
+app_dir <- __APP_DIR_R__
+source(file.path(app_dir, "BoxPlotR_functions.R"))
+source(file.path(app_dir, "boxplot_stats_Function.R"))
+library(beeswarm)
+library(vioplot)
+library(beanplot)
+library(sm)
+
+# Load data
+data_str <- __DATA_STR__
+plot_data <- read.csv(text = data_str, sep = __DATA_SEPARATOR__, header = TRUE, check.names = FALSE)
+plot_data_m <- as.matrix(plot_data)
+
+# Colors
+my_colours <- __COLORS_R__
+if (is.null(my_colours) || length(my_colours) < ncol(plot_data)) {
+ library(RColorBrewer)
+ my_colours <- brewer.pal(max(3, ncol(plot_data)), "Pastel1")[1:ncol(plot_data)]
+}
+
+my_orientation <- __ORIENTATION__
+my_log_val <- __LOG_SCALE__
+
+if ("__PLOT_ENGINE__" == "ggplot2") {
+ library(ggplot2)
+
+ # Convert plot_data to long format
+ df_long <- data.frame(
+ Value = unlist(plot_data, use.names = FALSE),
+ Group = rep(colnames(plot_data), each = nrow(plot_data))
+ )
+ df_long <- na.omit(df_long)
+ df_long$Group <- factor(df_long$Group, levels = colnames(plot_data))
+
+ # Prepare recycled colors vector
+ plot_colours <- rep(my_colours, length.out = ncol(plot_data))
+
+ if ("__PLOT_TYPE__" == "boxplot") {
+ # Calculate boxplot stats using overridden boxplot()
+ bp_stats <- boxplot(plot_data, range = -1.5, plot = FALSE)
+
+ notchlower_val <- bp_stats$conf[1, ]
+ notchupper_val <- bp_stats$conf[2, ]
+ if (my_log_val) {
+ notchlower_val <- log10(pmax(1e-10, notchlower_val))
+ notchupper_val <- log10(pmax(1e-10, notchupper_val))
+ }
+
+ df_stats <- data.frame(
+ Group = factor(bp_stats$names, levels = colnames(plot_data)),
+ ymin = bp_stats$stats[1, ],
+ lower = bp_stats$stats[2, ],
+ middle = bp_stats$stats[3, ],
+ upper = bp_stats$stats[4, ],
+ ymax = bp_stats$stats[5, ],
+ notchlower = notchlower_val,
+ notchupper = notchupper_val,
+ fill = bp_stats$names
+ )
+
+ p <- ggplot(df_stats, aes(x = Group, fill = Group)) +
+ suppressWarnings(geom_boxplot(
+ aes(
+ ymin = ymin, lower = lower, middle = middle, upper = upper, ymax = ymax,
+ notchlower = notchlower, notchupper = notchupper
+ ),
+ stat = "identity",
+ varwidth = __VARWIDTH__,
+ notch = __NOTCH__,
+ width = 0.6
+ ))
+
+ # Identify outliers matching the calculated whiskers
+ df_outliers <- df_long
+ df_outliers$ymin <- df_stats$ymin[match(df_outliers$Group, df_stats$Group)]
+ df_outliers$ymax <- df_stats$ymax[match(df_outliers$Group, df_stats$Group)]
+ df_outliers <- df_outliers[df_outliers$Value < df_outliers$ymin | df_outliers$Value > df_outliers$ymax, ]
+
+ if (!__SHOW_POINTS__ && nrow(df_outliers) > 0) {
+ p <- p + geom_point(
+ data = df_outliers,
+ aes(x = Group, y = Value),
+ color = "black",
+ size = 1.5,
+ shape = 19,
+ inherit.aes = FALSE
+ )
+ }
+ } else if ("__PLOT_TYPE__" == "violin") {
+ p <- ggplot(df_long, aes(x = Group, y = Value, fill = Group)) +
+ geom_violin(color = "black", width = 0.8)
+ } else if ("__PLOT_TYPE__" == "beanplot") {
+ p <- ggplot(df_long, aes(x = Group, y = Value, fill = Group)) +
+ geom_violin(color = "black", width = 0.8, alpha = 0.7) +
+ stat_summary(
+ fun = "median",
+ geom = "crossbar",
+ width = 0.4,
+ color = "black",
+ middle.linewidth = 0.8
+ ) +
+ geom_segment(
+ aes(
+ x = as.numeric(Group) - 0.15,
+ xend = as.numeric(Group) + 0.15,
+ y = Value,
+ yend = Value
+ ),
+ color = "#1e293b",
+ linewidth = 0.4,
+ alpha = 0.4
+ )
+ }
+
+ # Apply colors
+ p <- p + scale_fill_manual(values = plot_colours)
+
+ # Points overlay
+ if (__SHOW_POINTS__) {
+ pt_trans <- 1 - (__POINT_TRANSPARENCY__ / 100)
+ pt_sz <- __POINT_SIZE__
+ pt_col <- "#334155"
+ points_data <- if ("__PLOT_TYPE__" == "boxplot") df_long else NULL
+ points_aes <- if ("__PLOT_TYPE__" == "boxplot") aes(y = Value) else NULL
+
+ if ("__POINT_TYPE__" == "beeswarm" || "__POINT_TYPE__" == "jittered") {
+ p <- p + geom_jitter(
+ data = points_data,
+ mapping = points_aes,
+ width = if ("__POINT_TYPE__" == "beeswarm") 0.05 else 0.2,
+ height = 0,
+ color = pt_col, size = pt_sz, alpha = pt_trans
+ )
+ } else {
+ p <- p + geom_point(
+ data = points_data,
+ mapping = points_aes,
+ position = position_nudge(x = 0),
+ color = pt_col, size = pt_sz, alpha = pt_trans
+ )
+ }
+ }
+
+ # Add means
+ if (__ADD_MEANS__ && "__PLOT_TYPE__" == "boxplot") {
+ p <- p + stat_summary(
+ data = df_long,
+ aes(x = Group, y = Value),
+ fun = mean,
+ geom = "point",
+ shape = 18,
+ size = 4,
+ color = "red",
+ inherit.aes = FALSE
+ )
+ if (__ADD_MEAN_CI__) {
+ ci_fun <- function(x) {
+ n <- sum(!is.na(x))
+ if (n <= 1) return(c(ymin = NA, ymax = NA))
+ se <- sd(x, na.rm = TRUE) / sqrt(n)
+ ci_level <- __MEAN_CI_LEVEL__ / 100
+ t_val <- qt((1 + ci_level) / 2, df = n - 1)
+ me <- t_val * se
+ m <- mean(x, na.rm = TRUE)
+ c(ymin = m - me, ymax = m + me)
+ }
+ p <- p + stat_summary(
+ data = df_long,
+ aes(x = Group, y = Value),
+ fun.data = ci_fun,
+ geom = "errorbar",
+ width = 0.2,
+ color = "red",
+ linewidth = 0.8,
+ inherit.aes = FALSE
+ )
+ }
+ }
+
+ # Log scale
+ if (my_log_val) {
+ p <- p + scale_y_log10()
+ }
+
+ # Labels
+ p <- p + labs(
+ title = "__TITLE__",
+ subtitle = "__SUBTITLE__",
+ x = "__XLAB__",
+ y = "__YLAB__"
+ )
+
+ # Orientation / flipped coordinates
+ if (my_orientation) {
+ p <- p + coord_flip()
+ }
+
+ # Resolve style guide defaults for ggplot
+ style_font <- "Inter"
+ bg_fill <- "white"
+ panel_bg_fill <- "white"
+ grid_color <- "#e2e8f0"
+ axis_line_color <- "#475569"
+ plot_title_hjust <- 0.5
+
+ if ("__STYLE_GUIDE__" == "nature") {
+ style_font <- "sans"
+ } else if ("__STYLE_GUIDE__" == "science") {
+ style_font <- "serif"
+ } else if ("__STYLE_GUIDE__" == "economist") {
+ style_font <- "sans"
+ bg_fill <- "#e4eef2"
+ panel_bg_fill <- "#e4eef2"
+ grid_color <- "white"
+ axis_line_color <- "#1e293b"
+ plot_title_hjust <- 0
+ } else if ("__STYLE_GUIDE__" == "ft") {
+ style_font <- "serif"
+ bg_fill <- "#fff1e5"
+ panel_bg_fill <- "#fff1e5"
+ grid_color <- "#e2d6ca"
+ axis_line_color <- "#1e293b"
+ plot_title_hjust <- 0
+ }
+
+ # Theme minimal base
+ p <- p + theme_minimal(base_family = style_font) +
+ theme(
+ plot.title = element_text(size = 14, face = "bold", hjust = plot_title_hjust),
+ plot.subtitle = element_text(size = 11, hjust = plot_title_hjust, color = "#475569"),
+ axis.title.x = element_text(size = 12),
+ axis.title.y = element_text(size = 12),
+ axis.text = element_text(size = 10),
+ legend.position = "none",
+ panel.background = element_rect(fill = panel_bg_fill, color = NA),
+ plot.background = element_rect(fill = bg_fill, color = NA),
+ axis.line = element_line(color = axis_line_color, linewidth = 0.6),
+ axis.ticks = element_line(color = axis_line_color, linewidth = 0.6)
+ )
+
+ # Gridlines
+ if ("__ADD_GRID__" == "none") {
+ p <- p + theme(panel.grid.major = element_blank(), panel.grid.minor = element_blank())
+ } else if ("__ADD_GRID__" == "x") {
+ p <- p + theme(panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(), panel.grid.major.x = element_line(color = grid_color))
+ } else if ("__ADD_GRID__" == "y") {
+ p <- p + theme(panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), panel.grid.major.y = element_line(color = grid_color))
+ } else {
+ p <- p + theme(
+ panel.grid.major = element_line(color = grid_color),
+ panel.grid.minor = element_blank()
+ )
+ }
+
+ # Print / Save plot
+ output_path <- "__OUTPUT_PATH__"
+ output_ext <- tolower(tools::file_ext(output_path))
+ if (output_ext == "png") {
+ png(output_path, width = 800, height = 600, res = 120)
+ } else if (output_ext == "svg") {
+ svg(output_path, width = 8, height = 6)
+ } else if (output_ext == "pdf") {
+ pdf(output_path, width = 8, height = 6)
+ } else {
+ stop("Unsupported output extension. Use png, svg, or pdf.")
+ }
+ print(p)
+ dev.off()
+
+} else {
+ # Classic Base R drawing code with style guides
+ bg_fill <- "white"
+ style_font <- ""
+
+ if ("__STYLE_GUIDE__" == "nature") {
+ style_font <- "sans"
+ } else if ("__STYLE_GUIDE__" == "science") {
+ style_font <- "serif"
+ } else if ("__STYLE_GUIDE__" == "economist") {
+ style_font <- "sans"
+ bg_fill <- "#e4eef2"
+ } else if ("__STYLE_GUIDE__" == "ft") {
+ style_font <- "serif"
+ bg_fill <- "#fff1e5"
+ }
+
+ my_log <- if (my_log_val) (if (my_orientation) "x" else "y") else ""
+
+ # Ranges
+ r <- range(plot_data, na.rm = TRUE)
+ if (my_log_val) {
+ shared_lim <- c(r[1], r[2] * (10^(diff(log10(r[r > 0])) * 0.15)))
+ } else {
+ padding <- diff(r) * 0.15
+ shared_lim <- c(r[1] - (diff(r) * 0.04), r[2] + padding)
+ }
+
+ output_path <- "__OUTPUT_PATH__"
+ output_ext <- tolower(tools::file_ext(output_path))
+ if (output_ext == "png") {
+ png(output_path, width = 800, height = 600, res = 120)
+ } else if (output_ext == "svg") {
+ svg(output_path, width = 8, height = 6)
+ } else if (output_ext == "pdf") {
+ pdf(output_path, width = 8, height = 6)
+ } else {
+ stop("Unsupported output extension. Use png, svg, or pdf.")
+ }
+ par(bg = bg_fill, family = style_font)
+ par(mar = c(5, 5, 4, 2) + 0.1)
+
+ # Drawing
+ if ("__PLOT_TYPE__" == "boxplot") {
+ boxplot(
+ plot_data,
+ main = "__TITLE__",
+ sub = "__SUBTITLE__",
+ xlab = "__XLAB__",
+ ylab = "__YLAB__",
+ col = my_colours,
+ horizontal = my_orientation,
+ varwidth = __VARWIDTH__,
+ notch = __NOTCH__,
+ outline = __OUTLINE__,
+ range = -1.5,
+ log = my_log,
+ ylim = if (!my_orientation) shared_lim else NULL,
+ xlim = if (my_orientation) shared_lim else NULL,
+ frame.plot = FALSE
+ )
+ } else if ("__PLOT_TYPE__" == "violin") {
+ vioplot(
+ as.list(plot_data),
+ col = my_colours,
+ horizontal = my_orientation,
+ border = "black",
+ ylim = shared_lim,
+ names = colnames(plot_data),
+ log = my_log
+ )
+ title(
+ main = "__TITLE__",
+ sub = "__SUBTITLE__",
+ xlab = "__XLAB__",
+ ylab = "__YLAB__"
+ )
+ } else if ("__PLOT_TYPE__" == "beanplot") {
+ beanplot(
+ plot_data,
+ xlim = c(0.5, ncol(plot_data) + 0.5),
+ ylim = shared_lim,
+ col = as.list(my_colours),
+ horizontal = my_orientation,
+ border = "black",
+ names = colnames(plot_data),
+ frame.plot = FALSE,
+ log = my_log
+ )
+ title(
+ main = "__TITLE__",
+ sub = "__SUBTITLE__",
+ xlab = "__XLAB__",
+ ylab = "__YLAB__"
+ )
+ }
+
+ # Add grid
+ if ("__ADD_GRID__" == "both") {
+ grid()
+ } else if ("__ADD_GRID__" == "x") {
+ grid(nx = NULL, ny = NA)
+ } else if ("__ADD_GRID__" == "y") {
+ grid(nx = NA, ny = NULL)
+ }
+
+ # Add data points
+ if (__SHOW_POINTS__) {
+ point_style <- if ("__POINT_TYPE__" == "jittered") 2 else if ("__POINT_TYPE__" == "beeswarm") 1 else 0
+ if (point_style == 1) {
+ beeswarm(
+ plot_data,
+ add = TRUE,
+ col = "#334155",
+ horizontal = my_orientation,
+ cex = __POINT_SIZE__,
+ pch = 16
+ )
+ } else {
+ jittered_points(
+ plot_data_m,
+ my_horizontal = my_orientation,
+ point_type = point_style,
+ point_colors = rep("#334155", ncol(plot_data)),
+ point_transparency = __POINT_TRANSPARENCY__,
+ point_size = __POINT_SIZE__
+ )
+ }
+ }
+
+ # Add means
+ if (__ADD_MEANS__ && "__PLOT_TYPE__" == "boxplot") {
+ boxplot_means <- colMeans(plot_data, na.rm = TRUE)
+ if (my_orientation) {
+ points(boxplot_means, seq_along(boxplot_means), pch = 18, col = "red", cex = 1.5)
+ } else {
+ points(seq_along(boxplot_means), boxplot_means, pch = 18, col = "red", cex = 1.5)
+ }
+
+ if (__ADD_MEAN_CI__) {
+ for (i in seq_along(plot_data)) {
+ my_sample <- na.omit(plot_data[[i]])
+ n <- length(my_sample)
+ if (n > 1) {
+ standard_error <- sd(my_sample) / sqrt(n)
+ ci_level <- __MEAN_CI_LEVEL__ / 100
+ t_value <- qt((1 + ci_level) / 2, df = n - 1)
+ margin_error <- t_value * standard_error
+ lower_ci <- boxplot_means[i] - margin_error
+ upper_ci <- boxplot_means[i] + margin_error
+
+ if (my_orientation) {
+ lines(c(lower_ci, upper_ci), c(i, i), col = "red", lwd = 2)
+ lines(c(lower_ci, lower_ci), c(i - 0.1, i + 0.1), col = "red", lwd = 2)
+ lines(c(upper_ci, upper_ci), c(i - 0.1, i + 0.1), col = "red", lwd = 2)
+ } else {
+ lines(c(i, i), c(lower_ci, upper_ci), col = "red", lwd = 2)
+ lines(c(i - 0.1, i + 0.1), c(lower_ci, lower_ci), col = "red", lwd = 2)
+ lines(c(i - 0.1, i + 0.1), c(upper_ci, upper_ci), col = "red", lwd = 2)
+ }
+ }
+ }
+ }
+ }
+
+ dev.off()
+}
+"""
+
+ r_code = r_code_template
+ r_code = r_code.replace("__APP_DIR_R__", json.dumps(APP_DIR))
+ separator = "\t" if "\t" in data_str.splitlines()[0] else ","
+ r_code = r_code.replace("__DATA_SEPARATOR__", json.dumps(separator))
+ r_code = r_code.replace("__DATA_STR__", json.dumps(data_str))
+ r_code = r_code.replace("__COLORS_R__", colors_r)
+ r_code = r_code.replace("__ORIENTATION__", "TRUE" if orientation == "horizontal" else "FALSE")
+ r_code = r_code.replace("__LOG_SCALE__", "TRUE" if log_scale else "FALSE")
+ r_string_content = lambda value: json.dumps(str(value), ensure_ascii=True)[1:-1]
+ r_code = r_code.replace("__OUTPUT_PATH__", r_string_content(output_path))
+ r_code = r_code.replace("__PLOT_TYPE__", r_string_content(plot_type))
+ r_code = r_code.replace("__TITLE__", r_string_content(title))
+ r_code = r_code.replace("__SUBTITLE__", r_string_content(subtitle))
+ r_code = r_code.replace("__XLAB__", r_string_content(xlab))
+ r_code = r_code.replace("__YLAB__", r_string_content(ylab))
+ r_code = r_code.replace("__VARWIDTH__", "TRUE" if varwidth else "FALSE")
+ r_code = r_code.replace("__NOTCH__", "TRUE" if notch else "FALSE")
+ r_code = r_code.replace("__OUTLINE__", "FALSE" if show_points else "TRUE")
+ r_code = r_code.replace("__ADD_GRID__", r_string_content(add_grid))
+ r_code = r_code.replace("__SHOW_POINTS__", "TRUE" if show_points else "FALSE")
+ r_code = r_code.replace("__POINT_TYPE__", r_string_content(point_type))
+ r_code = r_code.replace("__POINT_SIZE__", str(point_size))
+ r_code = r_code.replace("__POINT_TRANSPARENCY__", str(point_transparency))
+ r_code = r_code.replace("__ADD_MEANS__", "TRUE" if add_means else "FALSE")
+ r_code = r_code.replace("__ADD_MEAN_CI__", "TRUE" if add_mean_ci else "FALSE")
+ r_code = r_code.replace("__MEAN_CI_LEVEL__", str(mean_ci_level))
+
+ r_code = r_code.replace("__PLOT_ENGINE__", r_string_content(plot_engine))
+ r_code = r_code.replace("__STYLE_GUIDE__", r_string_content(style_guide))
+
+ with tempfile.NamedTemporaryFile(suffix=".R", mode="w", delete=False) as f:
+ f.write(r_code)
+ temp_script_path = f.name
+
+ try:
+ log(f"Running Rscript on {temp_script_path}")
+ result = subprocess.run(
+ ["Rscript", temp_script_path],
+ capture_output=True,
+ text=True,
+ timeout=110,
+ env={"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8"},
+ )
+ if result.returncode != 0:
+ log(f"Rscript failed: {result.stderr}")
+ raise RuntimeError(f"R plotting failed: {result.stderr}")
+ log(f"Plot successfully generated and saved to {output_path}")
+ return output_path
+ finally:
+ if os.path.exists(temp_script_path):
+ os.remove(temp_script_path)
+
+def main():
+ log("BoxPlotR MCP Server starting...")
+ while True:
+ try:
+ line = sys.stdin.readline()
+ if not line:
+ break
+
+ message = json.loads(line)
+ method = message.get("method")
+ msg_id = message.get("id")
+
+ if method == "initialize":
+ response = {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {
+ "tools": {}
+ },
+ "serverInfo": {
+ "name": "boxplotr-mcp-server",
+ "version": "1.0.0"
+ }
+ }
+ }
+ sys.stdout.write(json.dumps(response) + "\n")
+ sys.stdout.flush()
+
+ elif method == "notifications/initialized":
+ pass
+
+ elif method == "tools/list":
+ response = {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": {
+ "tools": [
+ {
+ "name": "generate_boxplot",
+ "description": "Generates a highly-customizable box plot, violin plot, or bean plot using the BoxPlotR backend and saves it as an image.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "data_config": {
+ "type": "object",
+ "description": "Input data configurations",
+ "properties": {
+ "values": {
+ "type": "string",
+ "description": "The input data as CSV or TSV string where columns represent different samples/conditions"
+ }
+ },
+ "required": ["values"]
+ },
+ "visualization": {
+ "type": "object",
+ "description": "Plot rendering and engine structural parameters",
+ "properties": {
+ "plot_type": {
+ "type": "string",
+ "enum": ["boxplot", "violin", "beanplot"],
+ "description": "The type of plot to generate"
+ },
+ "plot_engine": {
+ "type": "string",
+ "enum": ["classic", "ggplot2"],
+ "description": "Plotting engine: 'classic' for Base R or 'ggplot2' for modern rendering (default: classic)"
+ },
+ "style_guide": {
+ "type": "string",
+ "enum": ["none", "nature", "science", "economist", "ft"],
+ "description": "Visual preset style guide: 'none', 'nature', 'science', 'economist', or 'ft' (default: none)"
+ },
+ "orientation": {
+ "type": "string",
+ "enum": ["vertical", "horizontal"],
+ "description": "Orientation of the plot (default: vertical)"
+ },
+ "log_scale": {
+ "type": "boolean",
+ "description": "Whether to use a logarithmic scale (log10) for the numeric axis"
+ }
+ },
+ "required": ["plot_type"]
+ },
+ "styling": {
+ "type": "object",
+ "description": "Custom aesthetic and text properties",
+ "properties": {
+ "title": { "type": "string", "description": "Main title of the plot" },
+ "subtitle": { "type": "string", "description": "Subtitle of the plot" },
+ "xlab": { "type": "string", "description": "X-axis label" },
+ "ylab": { "type": "string", "description": "Y-axis label" },
+ "colors": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Array of HEX colors for each sample/condition"
+ },
+ "add_grid": {
+ "type": "string",
+ "enum": ["none", "both", "x", "y"],
+ "description": "Background grid: 'none', 'both', 'x', or 'y' (default: none)"
+ }
+ }
+ },
+ "overlays": {
+ "type": "object",
+ "description": "Raw data point and statistical overlay properties",
+ "properties": {
+ "show_points": { "type": "boolean", "description": "Whether to display individual data points on top of the plot" },
+ "point_type": {
+ "type": "string",
+ "enum": ["normal", "jittered", "beeswarm"],
+ "description": "The arrangement style for data points (default: jittered)"
+ },
+ "point_size": { "type": "number", "description": "Size factor of the plotted data points (e.g. 1.0)" },
+ "point_transparency": { "type": "number", "description": "Transparency level of the plotted points from 0 to 100 (default: 50)" },
+ "add_means": { "type": "boolean", "description": "For boxplots, whether to plot the mean of each sample as a red diamond" },
+ "add_mean_ci": { "type": "boolean", "description": "For boxplots, whether to add confidence intervals for the sample means" },
+ "mean_ci_level": {
+ "type": "integer",
+ "enum": [83, 90, 95],
+ "description": "The confidence level percentage for the means CI (default: 95)"
+ },
+ "varwidth": { "type": "boolean", "description": "For boxplots, whether box widths should be proportional to square-roots of observations counts" },
+ "notch": { "type": "boolean", "description": "For boxplots, whether to add notches showing 95% CI of medians" }
+ }
+ },
+ "output_path": {
+ "type": "string",
+ "description": "Absolute path where the resulting PNG plot image should be saved"
+ }
+ },
+ "required": ["data_config", "visualization", "output_path"]
+ },
+ "examples": [
+ {
+ "arguments": {
+ "data_config": {
+ "values": "Group,Value\nSampleA,12.5\nSampleA,14.2\nSampleA,15.8\nSampleB,8.9\nSampleB,10.1\nSampleB,11.5"
+ },
+ "visualization": {
+ "plot_type": "boxplot",
+ "plot_engine": "ggplot2",
+ "style_guide": "economist",
+ "orientation": "vertical",
+ "log_scale": False
+ },
+ "styling": {
+ "title": "Comparison of Sample A and Sample B",
+ "xlab": "Group",
+ "ylab": "Value",
+ "colors": ["#0ea5e9", "#ef4444"],
+ "add_grid": "y"
+ },
+ "overlays": {
+ "show_points": True,
+ "point_type": "jittered",
+ "point_size": 1.2,
+ "point_transparency": 30,
+ "add_means": True,
+ "notch": True
+ },
+ "output_path": "/home/jw/Source/BoxPlotR.shiny/assets/example_plot.png"
+ },
+ "description": "Generates a publication-quality ggplot2 boxplot with notches, sample means, and jittered data points using the Economist style guide."
+ }
+ ]
+ }
+ ]
+ }
+ }
+ sys.stdout.write(json.dumps(response) + "\n")
+ sys.stdout.flush()
+
+ elif method == "notifications/initialized":
+ pass
+
+ elif method == "tools/call":
+ params = message.get("params", {})
+ tool_name = params.get("name")
+ arguments = params.get("arguments", {})
+
+ if tool_name == "generate_boxplot":
+ started = time.monotonic()
+ try:
+ out_path = generate_plot(arguments)
+ record_usage(arguments, True, started)
+ response = {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": {
+ "content": [
+ {
+ "type": "text",
+ "text": f"Success! BoxPlotR generated the plot successfully and saved it to: {out_path}"
+ },
+ file_to_mcp_content(out_path)
+ ],
+ "isError": False
+ }
+ }
+ except Exception as e:
+ record_usage(arguments, False, started, type(e).__name__)
+ response = {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": {
+ "content": [
+ {
+ "type": "text",
+ "text": f"Error generating plot: {str(e)}"
+ }
+ ],
+ "isError": True
+ }
+ }
+ sys.stdout.write(json.dumps(response) + "\n")
+ sys.stdout.flush()
+ else:
+ if msg_id is not None:
+ response = {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "error": {
+ "code": -32601,
+ "message": f"Method not found: {method}"
+ }
+ }
+ sys.stdout.write(json.dumps(response) + "\n")
+ sys.stdout.flush()
+ except Exception as e:
+ log(f"Unhandled error in loop: {str(e)}")
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/boxplotr_mcp/README.md b/deploy/boxplotr_mcp/README.md
new file mode 100644
index 0000000..0290a32
--- /dev/null
+++ b/deploy/boxplotr_mcp/README.md
@@ -0,0 +1,62 @@
+# BoxPlotR deployment
+
+The canonical source is `jwildenhain/BoxPlotR.shiny`. Deploy both interfaces
+from the same reviewed commit. The R files and `www/` are the Shiny application;
+`boxplotr_mcp_server.py` is the shared plotting worker and stdio MCP server;
+`deploy/boxplotr_mcp/app.py` is the public HTTP gateway.
+
+## Containers
+
+For the website, build the root `Dockerfile` and expose port 3838. Its build
+context must include the sample XLSX files and `www/` guide assets.
+
+For the HTTP gateway:
+
+```sh
+docker build -f Dockerfile.mcp -t boxplotr-mcp:local .
+docker run --rm -d --name boxplotr-mcp \
+ --env MCP_IDENTITY_SECRET --publish 127.0.0.1:8765:8765 boxplotr-mcp:local
+python3 tests/test_mcp_container.py
+```
+
+Set `MCP_IDENTITY_SECRET` to a random secret in the launching environment.
+Keep it stable across restarts to preserve anonymous quota identities. The
+container is intended to sit behind a trusted reverse proxy: do not expose
+port 8765 directly to the internet. Persist `/var/lib/boxplotr-mcp` if quotas
+and operational event history must survive container replacement.
+
+## Existing systemd installation
+
+On tyerschem2, the application lives in `/srv/shiny-server/boxplotr`, the gateway
+in `/opt/boxplotr-mcp`, and state in `/var/lib/boxplotr-mcp`. Install Python
+dependencies from `requirements-mcp.txt` into the gateway's `.venv`.
+The service and Apache virtual-host templates are in this directory.
+Provision the state directory and its `output/` subdirectory for user `shiny`.
+Keep `/etc/boxplotr-mcp/keys.json` (an empty object for keyless-only use) and
+runtime/analytics environment files outside the repository and readable only
+by the appropriate service account. TLS certificate paths and DNS names in
+the Apache template are specific to Chemgrid.
+
+The public route `/boxplotr/` proxies to the internal `/mcp/` route; `/health`
+is the internal health endpoint. Anonymous access uses a hashed client-IP
+quota. Optional bearer keys remain supported. Analytics secrets are optional;
+`MCP_IDENTITY_SECRET` is required for anonymous requests.
+
+`legacy/` preserves the earlier key issuance utilities found on the server.
+The current gateway does not import or mount the self-service email handlers.
+These utilities are not included in the container or enabled by this merge.
+Never commit issued keys, access databases, environment files, or certificates.
+
+## Validation
+
+```sh
+python3 -m unittest discover -s tests -p test_mcp_regressions.py -v
+Rscript -e 'testthat::test_dir("tests")'
+```
+
+The Python render tests require R and the plotting packages from
+`Dockerfile.mcp`. The R suite additionally requires `testthat`. The container
+workflow verifies HTTP initialization, rendering, and rejected invalid input.
+The legacy `codex/fix-review-findings` branch contains additional unmerged
+behavior changes; it is not a deployed release and is tracked separately in
+the consolidation record.
diff --git a/deploy/boxplotr_mcp/app.py b/deploy/boxplotr_mcp/app.py
new file mode 100644
index 0000000..8437a1a
--- /dev/null
+++ b/deploy/boxplotr_mcp/app.py
@@ -0,0 +1,345 @@
+import asyncio
+import base64
+import contextvars
+import csv
+import hashlib
+import hmac
+import ipaddress
+import io
+import json
+import os
+import secrets
+import sqlite3
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+
+from mcp.server.fastmcp import FastMCP, Image
+from mcp.types import BlobResourceContents, EmbeddedResource
+from starlette.applications import Starlette
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+from starlette.routing import Mount, Route
+
+APP_DIR = Path("/srv/shiny-server/boxplotr")
+STATE_DIR = Path("/var/lib/boxplotr-mcp")
+DB_PATH = STATE_DIR / "usage.sqlite3"
+KEYS_PATH = Path("/etc/boxplotr-mcp/keys.json")
+MAX_DATASET_BYTES = 5 * 1024 * 1024
+DAILY_LIMIT = 20
+MAX_CONCURRENT = 10
+EXECUTION_TIMEOUT = 120
+OUTPUT_TTL_SECONDS = 3600
+MAX_ROWS = 100000
+MAX_COLUMNS = 100
+MAX_OUTPUT_BYTES = 15 * 1024 * 1024
+IDENTITY_SECRET = os.environ.get("MCP_IDENTITY_SECRET", "").encode()
+GA4_MEASUREMENT_ID = os.environ.get("GA4_MEASUREMENT_ID", "")
+GA4_API_SECRET = os.environ.get("GA4_API_SECRET", "")
+
+sys.path.insert(0, str(APP_DIR))
+from boxplotr_mcp_server import generate_plot as legacy_generate_plot # noqa: E402
+
+client_key_id = contextvars.ContextVar("client_key_id", default="unknown")
+slots = asyncio.Semaphore(MAX_CONCURRENT)
+
+
+def utc_day() -> str:
+ return datetime.now(timezone.utc).date().isoformat()
+
+
+def load_keys() -> dict[str, str]:
+ with KEYS_PATH.open(encoding="utf-8") as handle:
+ return json.load(handle)
+
+
+def verify_key(raw_key: str) -> str | None:
+ digest = hashlib.sha256(raw_key.encode()).hexdigest()
+ for key_id, stored_digest in load_keys().items():
+ if secrets.compare_digest(digest, stored_digest):
+ return key_id
+ with database() as connection:
+ row = connection.execute(
+ "SELECT key_id FROM api_keys WHERE digest=? AND active=1", (digest,)
+ ).fetchone()
+ return row[0] if row else None
+
+
+def anonymous_client_id(request: Request) -> str:
+ """Return a privacy-safe quota ID for a client arriving through Apache.
+
+ Apache appends the connecting address to X-Forwarded-For. Using the
+ rightmost value prevents a caller-supplied leftmost value from bypassing
+ the daily quota. The raw address is never persisted or sent to analytics.
+ """
+ forwarded_for = request.headers.get("x-forwarded-for", "")
+ address = forwarded_for.rsplit(",", 1)[-1].strip() if forwarded_for else ""
+ if not address:
+ address = request.client.host if request.client else "unknown"
+ try:
+ address = ipaddress.ip_address(address).compressed
+ except ValueError:
+ address = "unknown"
+ if not IDENTITY_SECRET:
+ raise RuntimeError("MCP_IDENTITY_SECRET is not configured")
+ digest = hmac.new(IDENTITY_SECRET, address.encode(), hashlib.sha256).hexdigest()
+ return f"anonymous:{digest[:32]}"
+
+
+def database() -> sqlite3.Connection:
+ connection = sqlite3.connect(DB_PATH, timeout=10)
+ connection.execute("PRAGMA journal_mode=WAL")
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS daily_usage (key_id TEXT, day TEXT, calls INTEGER NOT NULL, PRIMARY KEY(key_id, day))"
+ )
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS events (timestamp TEXT, key_id TEXT, success INTEGER, duration_ms INTEGER, input_bytes INTEGER, rows INTEGER, columns_count INTEGER, plot_type TEXT, plot_engine TEXT, output_format TEXT, error_type TEXT)"
+ )
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS api_keys (key_id TEXT PRIMARY KEY, digest TEXT UNIQUE NOT NULL, email_hash TEXT UNIQUE, created_at TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 0)"
+ )
+ connection.execute("CREATE INDEX IF NOT EXISTS idx_api_keys_digest ON api_keys(digest)")
+ return connection
+
+
+def reserve_daily_call(key_id: str) -> int:
+ with database() as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ row = connection.execute(
+ "SELECT calls FROM daily_usage WHERE key_id=? AND day=?", (key_id, utc_day())
+ ).fetchone()
+ used = row[0] if row else 0
+ if used >= DAILY_LIMIT:
+ raise RuntimeError("Daily limit reached: 20 plot generations per UTC day")
+ remaining = DAILY_LIMIT - used - 1
+ connection.execute(
+ "INSERT INTO daily_usage(key_id, day, calls) VALUES(?,?,1) ON CONFLICT(key_id,day) DO UPDATE SET calls=calls+1",
+ (key_id, utc_day()),
+ )
+ return remaining
+
+
+def validate_data(values: str) -> tuple[int, int]:
+ if "\x00" in values:
+ raise ValueError("Dataset contains a NUL byte")
+ lines = values.splitlines()
+ delimiter = "\t" if lines and "\t" in lines[0] else ","
+ parsed = [row for row in csv.reader(io.StringIO(values), delimiter=delimiter) if any(cell.strip() for cell in row)]
+ if len(parsed) < 2 or not parsed[0]:
+ raise ValueError("Dataset must contain a header and at least one data row")
+ columns, rows = len(parsed[0]), len(parsed) - 1
+ if rows > MAX_ROWS or columns > MAX_COLUMNS:
+ raise ValueError(f"Dataset is limited to {MAX_ROWS} rows and {MAX_COLUMNS} columns")
+ if any(len(row) != columns for row in parsed):
+ raise ValueError("Every dataset row must have the same number of columns")
+ if any(not name.strip() or len(name) > 100 for name in parsed[0]):
+ raise ValueError("Column names must be non-empty and at most 100 characters")
+ for row in parsed[1:]:
+ for cell in row:
+ if not cell.strip() or cell.strip().upper() == "NA":
+ continue
+ try:
+ value = float(cell)
+ except ValueError as exc:
+ raise ValueError("Data cells must be numeric, blank, or NA") from exc
+ if not (-1.7976931348623157e308 <= value <= 1.7976931348623157e308):
+ raise ValueError("Data cells must contain finite numeric values")
+ return rows, columns
+
+def validate_text(value: str, field: str) -> None:
+ if len(value) > 200 or any(ord(char) < 32 and char not in "\t\n\r" for char in value):
+ raise ValueError(f"{field} must be at most 200 characters and contain no control characters")
+
+
+def record_event(**event) -> None:
+ with database() as connection:
+ connection.execute(
+ "INSERT INTO events VALUES(?,?,?,?,?,?,?,?,?,?,?)",
+ (
+ event["timestamp"], event["key_id"], int(event["success"]), event["duration_ms"],
+ event["input_bytes"], event["rows"], event["columns"], event["plot_type"],
+ event["plot_engine"], event["output_format"], event.get("error_type"),
+ ),
+ )
+
+
+def send_ga4_event(event: dict) -> None:
+ """Send privacy-safe operational telemetry; failures never affect users."""
+ if not GA4_MEASUREMENT_ID or not GA4_API_SECRET:
+ return
+ anonymous_client = hashlib.sha256(
+ f"boxplotr-mcp:{event['key_id']}".encode()
+ ).hexdigest()[:32]
+ payload = {
+ "client_id": f"mcp.{anonymous_client}",
+ "non_personalized_ads": True,
+ "events": [{
+ "name": "mcp_plot_generated" if event["success"] else "mcp_plot_failed",
+ "params": {
+ "app_name": "boxplotr",
+ "interface": "mcp",
+ "plot_type": event["plot_type"],
+ "plot_engine": event["plot_engine"],
+ "output_format": event["output_format"],
+ "success": int(event["success"]),
+ "duration_ms": event["duration_ms"],
+ "dataset_bytes": event["input_bytes"],
+ "dataset_rows": event["rows"],
+ "dataset_columns": event["columns"],
+ "error_type": event.get("error_type") or "none",
+ "engagement_time_msec": max(1, event["duration_ms"]),
+ },
+ }],
+ }
+ request = urllib.request.Request(
+ "https://www.google-analytics.com/mp/collect?"
+ f"measurement_id={GA4_MEASUREMENT_ID}&api_secret={GA4_API_SECRET}",
+ data=json.dumps(payload).encode(),
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=3) as response:
+ response.read()
+ except (OSError, urllib.error.URLError):
+ pass
+
+
+def cleanup_outputs() -> None:
+ cutoff = time.time() - OUTPUT_TTL_SECONDS
+ for path in (STATE_DIR / "output").glob("boxplotr-*.*"):
+ try:
+ if path.stat().st_mtime < cutoff:
+ path.unlink()
+ except FileNotFoundError:
+ pass
+
+
+mcp = FastMCP(
+ "BoxPlotR",
+ instructions="Generate publication-quality box, violin, and bean plots without submitting data through the website.",
+ stateless_http=True,
+ json_response=True,
+ streamable_http_path="/",
+ max_request_body_size=6 * 1024 * 1024,
+)
+
+
+@mcp.tool(description="Generate a publication-quality box, violin, or bean plot from column-oriented CSV or TSV data.")
+async def generate_boxplot(
+ values: str,
+ plot_type: str = "boxplot",
+ plot_engine: str = "ggplot2",
+ style_guide: str = "none",
+ orientation: str = "vertical",
+ log_scale: bool = False,
+ title: str = "",
+ x_label: str = "",
+ y_label: str = "",
+ colors: list[str] | None = None,
+ show_points: bool = False,
+ add_means: bool = False,
+ output_format: str = "png",
+) -> list:
+ started = time.monotonic()
+ key_id = client_key_id.get()
+ encoded_size = len(values.encode("utf-8"))
+ success = False
+ error_type = None
+ if encoded_size > MAX_DATASET_BYTES:
+ raise ValueError("Dataset exceeds the 5 MB limit")
+ rows, columns = validate_data(values)
+ if plot_type not in {"boxplot", "violin", "beanplot"}: raise ValueError("Unsupported plot_type")
+ if plot_engine not in {"ggplot2", "classic"}: raise ValueError("Unsupported plot_engine")
+ if style_guide not in {"none", "nature", "science", "economist", "ft"}: raise ValueError("Unsupported style_guide")
+ if orientation not in {"vertical", "horizontal"}: raise ValueError("Unsupported orientation")
+ for field, value in (("title", title), ("x_label", x_label), ("y_label", y_label)): validate_text(value, field)
+ if colors is not None and (len(colors) > MAX_COLUMNS or any(not isinstance(c, str) or len(c) not in {4, 7, 9} or not c.startswith("#") or any(ch not in "0123456789abcdefABCDEF" for ch in c[1:]) for c in colors)):
+ raise ValueError("colors must be hexadecimal CSS colours")
+ if output_format not in {"png", "svg", "pdf"}:
+ raise ValueError("output_format must be png, svg, or pdf")
+ if rows < 1 or columns < 1:
+ raise ValueError("Dataset must contain a header and at least one data row")
+ remaining = reserve_daily_call(key_id)
+ cleanup_outputs()
+ output_path = STATE_DIR / "output" / f"boxplotr-{secrets.token_hex(16)}.{output_format}"
+ arguments = {
+ "data_config": {"values": values},
+ "visualization": {
+ "plot_type": plot_type, "plot_engine": plot_engine, "style_guide": style_guide,
+ "orientation": orientation, "log_scale": log_scale,
+ },
+ "styling": {"title": title, "xlab": x_label, "ylab": y_label, "colors": colors or []},
+ "overlays": {"show_points": show_points, "add_means": add_means},
+ "output_path": str(output_path),
+ }
+ try:
+ async with slots:
+ await asyncio.wait_for(asyncio.to_thread(legacy_generate_plot, arguments), EXECUTION_TIMEOUT)
+ if not output_path.is_file() or output_path.stat().st_size > MAX_OUTPUT_BYTES:
+ raise RuntimeError("Generated output is missing or exceeds the 15 MB limit")
+ success = True
+ content = [f"Plot generated. {remaining} of 20 calls remain today."]
+ if output_format == "png":
+ content.append(Image(path=str(output_path)))
+ else:
+ content.append(EmbeddedResource(
+ type="resource",
+ resource=BlobResourceContents(
+ uri=output_path.as_uri(),
+ mimeType={"svg": "image/svg+xml", "pdf": "application/pdf"}[output_format],
+ blob=base64.b64encode(output_path.read_bytes()).decode("ascii"),
+ ),
+ ))
+ return content
+ except Exception as exc:
+ error_type = type(exc).__name__
+ raise
+ finally:
+ event = dict(
+ timestamp=datetime.now(timezone.utc).isoformat(), key_id=key_id, success=success,
+ duration_ms=round((time.monotonic()-started)*1000), input_bytes=encoded_size,
+ rows=rows, columns=columns, plot_type=plot_type, plot_engine=plot_engine,
+ output_format=output_format, error_type=error_type,
+ )
+ record_event(**event)
+ await asyncio.to_thread(send_ga4_event, event)
+
+
+class ClientIdentityMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ if request.url.path == "/health":
+ return await call_next(request)
+ authorization = request.headers.get("authorization", "")
+ if authorization:
+ if not authorization.startswith("Bearer "):
+ return JSONResponse({"error": "Unsupported authorization scheme"}, status_code=401)
+ key_id = verify_key(authorization[7:].strip())
+ if not key_id:
+ return JSONResponse({"error": "Invalid API key"}, status_code=403)
+ else:
+ key_id = anonymous_client_id(request)
+ token = client_key_id.set(key_id)
+ try:
+ return await call_next(request)
+ finally:
+ client_key_id.reset(token)
+
+
+async def health(_request: Request):
+ return JSONResponse({"status":"ok","service":"boxplotr-mcp","authentication":"optional","anonymous_quota_scope":"hashed_client_ip","max_concurrent":MAX_CONCURRENT,"daily_limit":DAILY_LIMIT,"max_dataset_bytes":MAX_DATASET_BYTES})
+
+
+app = Starlette(
+ routes=[
+ Route("/health", health),
+ Mount("/mcp", app=mcp.streamable_http_app()),
+ ],
+ lifespan=lambda _app: mcp.session_manager.run(),
+)
+app.add_middleware(ClientIdentityMiddleware)
diff --git a/deploy/boxplotr_mcp/boxplotr-mcp.service b/deploy/boxplotr_mcp/boxplotr-mcp.service
new file mode 100644
index 0000000..b997d15
--- /dev/null
+++ b/deploy/boxplotr_mcp/boxplotr-mcp.service
@@ -0,0 +1,44 @@
+[Unit]
+Description=BoxPlotR public MCP service
+After=network.target
+
+[Service]
+Type=simple
+User=shiny
+Group=shiny
+WorkingDirectory=/opt/boxplotr-mcp
+EnvironmentFile=-/etc/boxplotr-mcp/analytics.env
+EnvironmentFile=-/etc/boxplotr-mcp/access.env
+EnvironmentFile=-/etc/boxplotr-mcp/runtime.env
+ExecStart=/opt/boxplotr-mcp/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8765 --workers 1 --limit-concurrency 24 --timeout-keep-alive 5
+Restart=on-failure
+RestartSec=3
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/boxplotr-mcp
+MemoryMax=3G
+CPUQuota=800%
+UMask=0077
+PrivateDevices=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectKernelLogs=true
+ProtectControlGroups=true
+ProtectHostname=true
+RestrictSUIDSGID=true
+LockPersonality=true
+RestrictRealtime=true
+SystemCallArchitectures=native
+CapabilityBoundingSet=
+AmbientCapabilities=
+RemoveIPC=true
+ProtectClock=true
+ProtectProc=invisible
+ProcSubset=pid
+RestrictNamespaces=true
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/boxplotr_mcp/legacy/access.py b/deploy/boxplotr_mcp/legacy/access.py
new file mode 100644
index 0000000..2551517
--- /dev/null
+++ b/deploy/boxplotr_mcp/legacy/access.py
@@ -0,0 +1,177 @@
+"""Self-service API-key issuance for the BoxPlotR MCP service."""
+import hashlib
+import hmac
+import html
+import json
+import os
+import re
+import secrets
+import sqlite3
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+from datetime import datetime, timezone
+
+from starlette.requests import Request
+from starlette.responses import HTMLResponse
+
+DB_PATH = "/var/lib/boxplotr-mcp/usage.sqlite3"
+TURNSTILE_SITE_KEY = os.environ.get("TURNSTILE_SITE_KEY", "")
+TURNSTILE_SECRET_KEY = os.environ.get("TURNSTILE_SECRET_KEY", "")
+REQUEST_HASH_SECRET = os.environ.get("REQUEST_HASH_SECRET", "")
+RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
+RESEND_FROM = os.environ.get("RESEND_FROM", "BoxPlotR ")
+EXPECTED_HOSTNAME = "boxplotr.chemgrid.org"
+EXPECTED_ACTION = "request_mcp_key"
+EMAIL_DAILY_LIMIT = 50
+EMAIL_MONTHLY_LIMIT = 1000
+IP_DAILY_LIMIT = 5
+EMAIL_COOLDOWN_DAYS = 30
+FORM_BODY_LIMIT = 16 * 1024
+EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
+
+
+def database():
+ connection = sqlite3.connect(DB_PATH, timeout=10)
+ connection.execute("PRAGMA journal_mode=WAL")
+ connection.execute("CREATE TABLE IF NOT EXISTS api_keys (key_id TEXT PRIMARY KEY, digest TEXT UNIQUE NOT NULL, email_hash TEXT UNIQUE, created_at TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 0)")
+ connection.execute("CREATE INDEX IF NOT EXISTS idx_api_keys_digest ON api_keys(digest)")
+ connection.execute("CREATE TABLE IF NOT EXISTS key_requests (id TEXT PRIMARY KEY, email_hash TEXT NOT NULL, ip_hash TEXT NOT NULL, created_at TEXT NOT NULL, status TEXT NOT NULL, provider_id TEXT)")
+ connection.execute("CREATE INDEX IF NOT EXISTS idx_key_requests_email ON key_requests(email_hash, created_at)")
+ connection.execute("CREATE INDEX IF NOT EXISTS idx_key_requests_ip ON key_requests(ip_hash, created_at)")
+ connection.execute("CREATE TABLE IF NOT EXISTS email_sends (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL)")
+ return connection
+
+
+def keyed_hash(value):
+ if not REQUEST_HASH_SECRET:
+ raise RuntimeError("Self-service key requests are not configured")
+ return hmac.new(REQUEST_HASH_SECRET.encode(), value.encode(), hashlib.sha256).hexdigest()
+
+
+def client_ip(request):
+ forwarded = request.headers.get("x-forwarded-for", "")
+ candidate = forwarded.split(",", 1)[0].strip() if forwarded else ""
+ return candidate or (request.client.host if request.client else "unknown")
+
+
+def utc_now():
+ return datetime.now(timezone.utc).isoformat()
+
+
+def validate_turnstile(token, remote_ip):
+ if not TURNSTILE_SECRET_KEY or len(token) > 2048:
+ return False
+ payload = urllib.parse.urlencode({
+ "secret": TURNSTILE_SECRET_KEY,
+ "response": token,
+ "remoteip": remote_ip,
+ "idempotency_key": str(uuid.uuid4()),
+ }).encode()
+ request = urllib.request.Request(
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
+ data=payload,
+ headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "BoxPlotR-MCP/1.0"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=5) as response:
+ result = json.loads(response.read())
+ except (OSError, ValueError, urllib.error.URLError):
+ return False
+ return bool(
+ result.get("success")
+ and result.get("hostname") == EXPECTED_HOSTNAME
+ and result.get("action") == EXPECTED_ACTION
+ )
+
+
+def reserve_request(email_hash, ip_hash):
+ now = utc_now()
+ request_id = str(uuid.uuid4())
+ with database() as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ daily = connection.execute("SELECT count(*) FROM email_sends WHERE created_at >= datetime('now','-1 day')").fetchone()[0]
+ monthly = connection.execute("SELECT count(*) FROM email_sends WHERE created_at >= datetime('now','-1 month')").fetchone()[0]
+ if daily >= EMAIL_DAILY_LIMIT or monthly >= EMAIL_MONTHLY_LIMIT:
+ raise ValueError("The daily or monthly email safety limit has been reached. Please try later.")
+ recent_email = connection.execute("SELECT 1 FROM key_requests WHERE email_hash=? AND created_at >= datetime('now',?) AND status IN ('pending','sent') LIMIT 1", (email_hash, f"-{EMAIL_COOLDOWN_DAYS} days")).fetchone()
+ if recent_email:
+ raise ValueError("A key has already been requested for this email recently. Check your inbox or contact the administrator.")
+ recent_ip = connection.execute("SELECT count(*) FROM key_requests WHERE ip_hash=? AND created_at >= datetime('now','-1 day')", (ip_hash,)).fetchone()[0]
+ if recent_ip >= IP_DAILY_LIMIT:
+ raise ValueError("Too many requests from this network today. Please try tomorrow.")
+ connection.execute("INSERT INTO key_requests VALUES(?,?,?,?,?,NULL)", (request_id, email_hash, ip_hash, now, "pending"))
+ connection.execute("INSERT INTO email_sends VALUES(?,?,?)", (request_id, now, "reserved"))
+ return request_id
+
+
+def create_pending_key(request_id, email_hash):
+ raw_key = "bpr_" + secrets.token_urlsafe(32)
+ digest = hashlib.sha256(raw_key.encode()).hexdigest()
+ key_id = "self-" + email_hash[:16]
+ with database() as connection:
+ connection.execute("INSERT OR REPLACE INTO api_keys(key_id,digest,email_hash,created_at,active) VALUES(?,?,?,?,0)", (key_id, digest, email_hash, utc_now()))
+ return key_id, raw_key
+
+
+def send_key_email(recipient, raw_key, request_id):
+ if not RESEND_API_KEY:
+ raise RuntimeError("Email delivery is not configured")
+ text = f"""Your BoxPlotR MCP API key\n\nEndpoint: https://mcp.chemgrid.org/boxplotr/\nAPI key: {raw_key}\n\nKeep this key private. It permits 20 plot generations per UTC day. Datasets are limited to 5 MiB.\n\nCodex setup:\nexport BOXPLOTR_MCP_API_KEY=\"{raw_key}\"\ncodex mcp add boxplotr --url https://mcp.chemgrid.org/boxplotr/ --bearer-token-env-var BOXPLOTR_MCP_API_KEY\n\nIf you did not request this key, delete this message and contact the BoxPlotR administrator.\n"""
+ payload = json.dumps({"from": RESEND_FROM, "to": [recipient], "subject": "Your BoxPlotR MCP API key", "text": text}).encode()
+ request = urllib.request.Request(
+ "https://api.resend.com/emails",
+ data=payload,
+ headers={"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json", "User-Agent": "BoxPlotR-MCP/1.0", "Idempotency-Key": request_id},
+ method="POST",
+ )
+ with urllib.request.urlopen(request, timeout=8) as response:
+ result = json.loads(response.read())
+ return result.get("id", "unknown")
+
+
+def finish_request(request_id, key_id, provider_id=None, success=False):
+ with database() as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ connection.execute("UPDATE key_requests SET status=?,provider_id=? WHERE id=?", ("sent" if success else "failed", provider_id, request_id))
+ connection.execute("UPDATE email_sends SET status=? WHERE id=?", ("sent" if success else "failed", request_id))
+ connection.execute("UPDATE api_keys SET active=? WHERE key_id=?", (1 if success else 0, key_id))
+
+
+def page(message="", error=False):
+ notice = f'
{html.escape(message)}
' if message else ""
+ disabled = "" if TURNSTILE_SITE_KEY and RESEND_API_KEY else "disabled"
+ return HTMLResponse(f"""Request a BoxPlotR MCP key
Request a BoxPlotR MCP API key
A personal key provides up to 20 plot generations per UTC day.
{notice}Your email is used only for key delivery, abuse prevention, and access administration. It is converted to a keyed one-way hash in the service database. The address and raw key are sent to Resend for delivery but are not sent to Google Analytics. Requests are limited to one per email every {EMAIL_COOLDOWN_DAYS} days and {IP_DAILY_LIMIT} per network per day. Service-wide email limits are {EMAIL_DAILY_LIMIT}/day and {EMAIL_MONTHLY_LIMIT}/month.""", headers={"Cache-Control":"no-store", "X-Robots-Tag":"noindex, nofollow"})
+
+
+async def access_page(_request: Request):
+ return page()
+
+
+async def request_key(request: Request):
+ body = await request.body()
+ if len(body) > FORM_BODY_LIMIT:
+ return page("Request too large.", True)
+ form = urllib.parse.parse_qs(body.decode("utf-8", "replace"), keep_blank_values=True)
+ email = form.get("email", [""])[0].strip().lower()
+ token = form.get("cf-turnstile-response", [""])[0]
+ if form.get("privacy", [""])[0] != "accepted" or len(email) > 254 or not EMAIL_PATTERN.fullmatch(email):
+ return page("Enter a valid email address and accept the privacy notice.", True)
+ remote_ip = client_ip(request)
+ if not await __import__("asyncio").to_thread(validate_turnstile, token, remote_ip):
+ return page("Human verification failed or expired. Please try again.", True)
+ email_hash, ip_hash = keyed_hash(email), keyed_hash(remote_ip)
+ try:
+ request_id = reserve_request(email_hash, ip_hash)
+ key_id, raw_key = create_pending_key(request_id, email_hash)
+ provider_id = await __import__("asyncio").to_thread(send_key_email, email, raw_key, request_id)
+ finish_request(request_id, key_id, provider_id, True)
+ except (ValueError, RuntimeError) as exc:
+ return page(str(exc), True)
+ except (OSError, urllib.error.HTTPError, urllib.error.URLError):
+ if "request_id" in locals() and "key_id" in locals():
+ finish_request(request_id, key_id, success=False)
+ return page("Email delivery is temporarily unavailable. Please contact the administrator.", True)
+ return page("Your API key has been emailed. Check your inbox and spam folder.")
diff --git a/deploy/boxplotr_mcp/legacy/manage_keys.py b/deploy/boxplotr_mcp/legacy/manage_keys.py
new file mode 100755
index 0000000..db9c08d
--- /dev/null
+++ b/deploy/boxplotr_mcp/legacy/manage_keys.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""Issue, list, and revoke BoxPlotR MCP API keys."""
+import argparse
+import hashlib
+import json
+import os
+import secrets
+import tempfile
+from pathlib import Path
+
+KEYS_PATH = Path("/etc/boxplotr-mcp/keys.json")
+
+
+def load_keys():
+ return json.loads(KEYS_PATH.read_text(encoding="utf-8")) if KEYS_PATH.exists() else {}
+
+
+def save_keys(keys):
+ existing = KEYS_PATH.stat() if KEYS_PATH.exists() else None
+ fd, temporary = tempfile.mkstemp(dir=KEYS_PATH.parent, prefix="keys.", text=True)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ json.dump(keys, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ os.chmod(temporary, 0o640)
+ if existing:
+ os.chown(temporary, existing.st_uid, existing.st_gid)
+ os.replace(temporary, KEYS_PATH)
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+
+
+parser = argparse.ArgumentParser(description="Manage BoxPlotR MCP API keys")
+commands = parser.add_subparsers(dest="command", required=True)
+issue = commands.add_parser("issue", help="Create a key and display it once")
+issue.add_argument("label", help="Unique non-sensitive user or organisation label")
+revoke = commands.add_parser("revoke", help="Immediately revoke a key")
+revoke.add_argument("label")
+commands.add_parser("list", help="List labels; secret values are never displayed")
+args = parser.parse_args()
+keys = load_keys()
+
+if args.command == "list":
+ print("\n".join(sorted(keys)))
+elif args.command == "revoke":
+ if args.label not in keys:
+ raise SystemExit(f"Unknown key label: {args.label}")
+ del keys[args.label]
+ save_keys(keys)
+ print(f"Revoked {args.label}")
+else:
+ if args.label in keys:
+ raise SystemExit(f"Key label already exists: {args.label}")
+ raw_key = "bpr_" + secrets.token_urlsafe(32)
+ keys[args.label] = hashlib.sha256(raw_key.encode()).hexdigest()
+ save_keys(keys)
+ print(raw_key)
diff --git a/deploy/boxplotr_mcp/mcp.chemgrid.org-le-ssl.conf b/deploy/boxplotr_mcp/mcp.chemgrid.org-le-ssl.conf
new file mode 100644
index 0000000..4426259
--- /dev/null
+++ b/deploy/boxplotr_mcp/mcp.chemgrid.org-le-ssl.conf
@@ -0,0 +1,40 @@
+
+
+ ServerName mcp.chemgrid.org
+ ServerAdmin webmaster@chemgrid.org
+ RequestReadTimeout header=20-40,MinRate=500 body=20,MinRate=500
+
+ RedirectMatch 301 ^/boxplotr$ /boxplotr/
+ ProxyPass /boxplotr/ http://127.0.0.1:8765/mcp/ timeout=130 connectiontimeout=5
+ ProxyPassReverse /boxplotr/ http://127.0.0.1:8765/mcp/
+
+ ProxyPass /health/boxplotr http://127.0.0.1:8765/health timeout=5 connectiontimeout=2
+ ProxyPassReverse /health/boxplotr http://127.0.0.1:8765/health
+
+ RedirectMatch 302 ^/$ /boxplotr/
+
+
+ LimitRequestBody 6291456
+
+ Require all denied
+
+ Header always set Cache-Control "no-store"
+ Header always set X-Robots-Tag "noindex, nofollow"
+
+
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set Referrer-Policy "no-referrer"
+ Header always set X-Frame-Options "DENY"
+ Header always set Content-Security-Policy "default-src 'none'; frame-ancestors 'none'"
+ Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
+ Header always set Server "ChemGrid"
+ Header always set Strict-Transport-Security "max-age=31536000"
+
+ ErrorLog ${APACHE_LOG_DIR}/mcp-error.log
+ CustomLog ${APACHE_LOG_DIR}/mcp-access.log combined
+
+ Include /etc/letsencrypt/options-ssl-apache.conf
+ SSLCertificateFile /etc/letsencrypt/live/chemgrid.org/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/chemgrid.org/privkey.pem
+
+
diff --git a/docs/consolidation-2026-09-18.md b/docs/consolidation-2026-09-18.md
new file mode 100644
index 0000000..41e3b40
--- /dev/null
+++ b/docs/consolidation-2026-09-18.md
@@ -0,0 +1,62 @@
+# BoxPlotR consolidation — 18 September 2026
+
+## Sources compared
+
+| Source | Revision / location | Result |
+| --- | --- | --- |
+| Local checkout at the start | `9d35b96450ef498f997bbbcebb41bb327bbc5270` | Two commits behind the maintained default branch; local UI edits and untracked `test-data/` existed. |
+| Maintained GitHub repository | `jwildenhain/BoxPlotR.shiny`, `master` at `bce2e5d` | Canonical base, including MCP hardening, gateway, container, CI, and deployment configuration. |
+| Older GitHub repository | `jwildenhain/shiny-boxplot`, `master` at `830dfbc` | Its only commit absent from the maintained history is a README redirect to the maintained repository. No unique application implementation needs importing. |
+| Deployed Shiny application | `tyerschem2:/srv/shiny-server/boxplotr` | No `.git` directory. Imported the UI, sanitized-error setting, public MCP documentation, guide, and static examples. |
+| Deployed HTTP gateway | `tyerschem2:/opt/boxplotr-mcp/app.py` | Byte-identical to the maintained repository's gateway. |
+| Deployed worker | `tyerschem2:/srv/shiny-server/boxplotr/boxplotr_mcp_server.py` | Matches maintained source apart from whitespace, before the whisker correction below. |
+| Deployed systemd service | `/etc/systemd/system/boxplotr-mcp.service` | Byte-identical to the maintained service template. |
+
+The initial local-only assessment understated what was already committed to
+GitHub: MCP escaping, timeouts, telemetry, and the tool-discovery fix were
+already in the maintained repository.
+
+## Reconciliation decisions
+
+- Keep `BoxPlotR.shiny` as the single maintained repository. The older
+ `shiny-boxplot` README already redirects there; no remote deletion or archive
+ is needed.
+- Preserve the locally edited UI's output-delivery notes and format examples.
+ Retain its stdio explanation as a separate FAQ alongside the deployed HTTP
+ connection instructions.
+- Preserve both the deployed user documentation and repository container
+ instructions, with deployment details under `deploy/boxplotr_mcp/README.md`.
+- Import `www/` assets and the deployed `assets/mcp_test_plot.png` example.
+ Exclude server backup copies, bytecode, state, issued keys, and secrets.
+- Preserve the deployed key-management and former email-access source in
+ `deploy/boxplotr_mcp/legacy/`. These handlers are not mounted by the current
+ gateway and remain inactive.
+- Preserve the user's pre-existing `test-data/` directory without changing or
+ adding its contents to version control.
+- Fix both worker boxplot paths to pass `range = -1.5`, which selects Tukey
+ whiskers in the custom statistics helper. Positive `1.5` selected Altman
+ percentiles. The Shiny UI already passed the correct negative coefficient.
+- Stop excluding `www/`, PNGs, and XLSX files from the shared Docker context;
+ the Shiny image requires them. Keep the MCP image's explicit source copies.
+- Keep the additional `origin/codex/fix-review-findings` branch (`8f84a42`)
+ separate. It is not deployed and contains behavioral changes to log notches,
+ validation, and downloads. Its PNG-only restriction conflicts with the
+ released SVG/PDF support. Consolidation does not silently promote those
+ unmerged changes; the branch remains available for a separate review.
+
+## Validation
+
+- Compared both GitHub default-branch histories and all source file trees.
+- Python source parses; all six top-level R files parse.
+- MCP tool discovery and Tukey statistics regression tests pass locally.
+- All three regression tests pass against the merged source in a disposable
+ directory on tyerschem2, including six real renders (classic and ggplot2,
+ each with PNG, PDF, and SVG) and a quoted title.
+- Every relative image/download link in the imported HTML guide resolves.
+- Existing `testthat` tests could not run on the server because `testthat` is
+ not installed. Locally, their plotting dependencies are missing.
+- The local Docker daemon is unavailable. The MCP CI workflow now runs the
+ new regressions in addition to its HTTP integration checks.
+
+No live source, service configuration, credentials, or runtime database was
+changed. Temporary validation files were removed after execution.
diff --git a/log_scale_example.csv b/log_scale_example.csv
new file mode 100644
index 0000000..9ba360b
--- /dev/null
+++ b/log_scale_example.csv
@@ -0,0 +1,101 @@
+"Baseline","Treated","Knockout"
+394,60584,4
+57,47929,12
+144,2221,18
+188,160021,28
+150,3678,5
+90,11715,6
+453,5308,7
+91,8323,6
+753,13262,7
+94,11957,9
+369,9631,5
+984,11760,28
+25,4828,11
+76,4694,10
+88,828,13
+189,5635,10
+75,4635,9
+7,575605,21
+9,1296,9
+374,12286,5
+74,1064,12
+17,1102,8
+84,12057,8
+337,2243,6
+665,9973,12
+65,5260,9
+77,3983,13
+17,480,9
+158,1593,7
+53,13090,19
+158,23430,9
+202,4774,16
+282,10001,5
+54,53889,8
+166,86693,9
+18,1929,8
+46,8386,20
+43,60633,10
+9,4943,11
+104,9243,6
+123,8788,7
+70,2641,16
+213,5132,19
+48,9568,19
+25,5375,5
+154,53126,28
+44,4860,17
+424,5222,10
+65,28442,14
+193,2050,6
+138,9408,6
+46,976,10
+483,57589,5
+190,6633,11
+109,4957,19
+132,1561,6
+197,9884,7
+109,3011,10
+5,4492,6
+133,68999,8
+69,7685,15
+120,2004,16
+179,12774,12
+405,5804,4
+48,24230,10
+368,85731,17
+140,2256,15
+282,19778,9
+251,11358,3
+206,38319,10
+35,7085,13
+91,35076,10
+187,730,11
+39,126062,12
+58,36589,8
+179,7976,19
+216,1138,13
+159,26235,5
+41,20643,20
+33,9905,18
+454,12551,15
+129,4164,4
+109,17388,8
+89,15558,14
+30,6578,10
+184,1347,12
+80,28609,34
+83,22963,7
+254,2852,3
+227,915,11
+402,13599,7
+62,5959,12
+192,14607,7
+402,1436,30
+33,2372,9
+42,50971,8
+32,18325,9
+23,24103,15
+108,152235,10
+192,12132,4
diff --git a/requirements-mcp.txt b/requirements-mcp.txt
new file mode 100644
index 0000000..c49df76
--- /dev/null
+++ b/requirements-mcp.txt
@@ -0,0 +1,2 @@
+mcp==1.29.1
+uvicorn==0.52.4
diff --git a/server.R b/server.R
index b16b8ae..b7b7419 100644
--- a/server.R
+++ b/server.R
@@ -1,394 +1,1128 @@
+options(shiny.maxRequestSize = 200 * 1024^2)
+# Shiny's default is FALSE, which returns raw R error text to the browser.
+# Full traces remain available in the app log under /var/log/shiny-server/.
+options(shiny.sanitize.errors = TRUE)
+
+# Pre-load example datasets so they don't hit the disk constantly
+sample_data_1_cache <- read.table(
+ "Boxplot_testData2.csv",
+ sep = ",", header = TRUE, fill = TRUE,
+ check.names = FALSE
+)
+sample_data_2_cache <- read.table(
+ "Boxplot_testData.txt",
+ sep = ",", header = TRUE,
+ check.names = FALSE
+)
+sample_data_3_cache <- as.data.frame(
+ readxl::read_excel("Boxplot_testData3.xlsx")
+)
+
+# Helper function to prevent redundant color parsing
+parse_colours <- function(col_strings) {
+ if (is.null(col_strings) || length(col_strings) == 0 || col_strings == "") {
+ return(c("grey"))
+ }
+ my_colours <- gsub("\\s", "", strsplit(col_strings, ",")[[1]])
+ my_colours <- gsub("0x", "#", my_colours)
+ if (length(my_colours) == 0) {
+ return(c("grey"))
+ }
+ return(my_colours)
+}
+
shinyServer(function(input, output, session) {
+ library(RColorBrewer)
+ library(beeswarm)
+ library(vioplot)
+ source("MyVioplot.R")
+ library(beanplot)
+ library(readxl)
+ source("boxplot_stats_Function.R")
+ source("BoxPlotR_functions.R")
- library(RColorBrewer)
- library(beeswarm)
- library(vioplot)
- source("MyVioplot.R")
- library(beanplot)
- source("boxplot_stats_Function.R")
-
- observe({
- if (input$clearText_button == 0) return()
- isolate({ updateTextInput(session, "myData", label = ",", value = "") })
- })
- # *** Read in data matrix ***
- dataM <- reactive({
- if(input$dataInput==1){
- if(input$sampleData==1){
- data<-read.table("Boxplot_testData2.csv", sep=",", header=TRUE, fill=TRUE)
- } else {
- data<-read.table("Boxplot_testData.txt", sep=",", header=TRUE)
- }
- } else if(input$dataInput==2){
- inFile <- input$upload
- # Avoid error message while file is not uploaded yet
- if (is.null(input$upload)) {return(NULL)}
- # Get the separator
- mySep<-switch(input$fileSepDF, '1'=",",'2'="\t",'3'=";", '4'="") #list("Comma"=1,"Tab"=2,"Semicolon"=3)
- if(file.info(inFile$datapath)$size<=10485800){
- data<-read.table(inFile$datapath, sep=mySep, header=TRUE, fill=TRUE)
- } else print("File is bigger than 10MB and will not be uploaded.")
- } else { # To be looked into again - for special case when last column has empty entries in some rows
- if(is.null(input$myData)) {return(NULL)}
- tmp<-matrix(strsplit(input$myData, "\n")[[1]])
- mySep<-switch(input$fileSepP, '1'=",",'2'="\t",'3'=";")
- myColnames<-strsplit(tmp[1], mySep)[[1]]
- data<-matrix(0, length(tmp)-1, length(myColnames))
- colnames(data)<-myColnames
- for(i in 2:length(tmp)){
- myRow<-as.numeric(strsplit(paste(tmp[i],mySep,mySep,sep=""), mySep)[[1]])
- data[i-1,]<-myRow[-length(myRow)]
- }
- data<-data.frame(data)
- }
- return(data)
- })
-
- # *** The plot dimensions ***
- heightSize <- reactive ({ input$myHeight })
- widthSize <- reactive ({ input$myWidth })
-
- # *** Determine extent of whisker range ***
- # whiskerDefinition 0 - Tukey (default), 1 - Spear (min/max, range=0), 2 - Altman (5% and 95% quantiles)
- # radioButtons("whiskerType", "", list("Tukey"=0, "Spear"=1, "Altman"=2)),
- myRange <- reactive({
- if(input$whiskerType==0){myRange<-c(-1.5)}
- else if(input$whiskerType==1){myRange<-c(0)}
- else if (input$whiskerType==2){myRange<-c(5)}
- return(myRange)
- })
-
- # *** Get boxplot statistics ***
- boxplotStats <- reactive({
- return(boxplot(dataM(), na.rm=TRUE, range=myRange(), plot=FALSE))
- })
-
- # *** Generate the box plot ***
- generateBoxPlot<-function(plotData){
- par(mar=c(5,8,4,2)) # c(bottom, left, top, right)
- myColours<-gsub("\\s","", strsplit(input$myColours,",")[[1]])
- myColours<-gsub("0x","#", myColours)
-
- myColours2<-gsub("\\s","", strsplit(input$myOtherPlotColours,",")[[1]])
- myColours2<-gsub("0x","#", myColours2)
-
-
- nrOfSamples<-ncol(plotData)
- # generate colour vector
- if(length(myColours)==1){
- myColours<-rep(myColours, nrOfSamples)
- } else if(length(myColours) < nrOfSamples){
- myColours<-rep(myColours,times=(round(nrOfSamples/length(myColours)))+1)
- }
- plotPoints<-c() # vector for indices of samples that are to be plotted as points, not as boxplots
- notPlotPoints <- seq(1:nrOfSamples) # samples to plot as boxes/violins/beans
- plotDataM<-plotData
- # Determine plot range
- if(as.numeric(input$myOrientation)==0){
- if(input$ylimit==""){myLim<-range(plotData,na.rm=TRUE)+c(-1,+1)} else {myLim<-as.numeric(strsplit(input$ylimit,",")[[1]])}
- } else {
- if(input$xlimit==""){myLim<-range(plotData,na.rm=TRUE)+c(-1,+1)} else {myLim<-as.numeric(strsplit(input$xlimit,",")[[1]])}
- }
- # Data point count for each sample
- datapointCounts<-apply(!apply(plotData, 2, is.na),2,sum) # Count number of valid data points for each sample
- # Check if columns with few data points should be plotted as points
-
- # minimum number of points is 4 -> check that nrOfDataPoints is larger than that
- mnp<-max(4,input$nrOfDataPoints)
-
- if(input$plotDataPoints==TRUE){
- #toPlot <- seq(1:ncol(plotData))[datapointCounts>=input$nrOfDataPoints] # samples to barplot
- plotPoints <- seq(1:nrOfSamples)[datapointCounts=mnp] # samples to plot as boxes/violins/beans
- }
-
- # Generate plotDataM matrix such that columns that should be plotted as points are filled with data points outside of visible plot area to 'reserve' spot for points
- for(i in plotPoints){
- plotDataM[,i]<-c(rep(myLim[2]+10, nrow(plotData)-1),myLim[2]+20)
- }
-
- # Angle the sample names
- if(input$xaxisLabelAngle){
- xaxisLabelAngleNr<-45
- labelPos<-2
- } else {
- xaxisLabelAngleNr<-0
- labelPos<-1
- }
-
- par(mar=c(12.1, 11.1, 4.1, 2.1))
-
- # *** 1) Vertical boxplots ***
- par(las=1)
- if(as.numeric(input$myOrientation)==0){
- # *** Generate boxplot ***
- if(input$plotType=='0'){
- boxplot(plotDataM, col=myColours, ylab=input$myYlab, xlab=input$myXlab, ylim=myLim,
- cex.lab=input$cexAxislabel/10, cex.axis=input$cexAxis/10, cex.main=input$cexTitle/10,
- main=input$myTitle, sub=input$mySubtitle, horizontal=as.numeric(input$myOrientation), frame=F,
- na.rm=TRUE, xaxt="n", range=myRange(), varwidth=input$myVarwidth, notch=input$myNotch) #notch=TRUE
- axis(1,at=c(1:nrOfSamples), labels=FALSE, cex.axis=input$cexAxis/10) #
- text(x=c(1:nrOfSamples), y=rep(myLim[1]-3,nrOfSamples), labels=colnames(plotData),
- pos=labelPos, xpd=TRUE, srt=xaxisLabelAngleNr, cex=input$cexAxis/10)
- # * Add data points to plot if selected *
- if(input$showDataPoints==TRUE){
- if(length(plotPoints)==0){ # all samples are box plots --> add points for all of them
- if(input$datapointType==0){
- for(i in c(1:nrOfSamples)){ points(rep(i, nrow(plotData)), plotData[,i], col="black") }
- } else { beeswarm(plotData, add=TRUE) }
- } else { # remove the ones that are already plotted as points
- if(input$datapointType==0){
- for(i in c(1:nrOfSamples)[-plotPoints]){ points(rep(i, nrow(plotData)), plotData[,i], col="black") }
- } else { beeswarm(plotData, add=TRUE) }
-# } else { beeswarm(plotData[,-plotPoints], at=c(1:nrOfSamples)[-plotPoints], add=TRUE) }
- }
- }
- } else { # *** Generate violin or bean plot ***
- if(input$otherPlotType==0){ # Violin plot
- vioplot(as.list(data.frame(plotDataM)), col=myColours2, ylim=myLim, cex.axis=input$cexAxis/10,
- horizontal=as.numeric(input$myOrientation), range=myRange(), border=input$violinBorder)
- title(main=input$myTitle, ylab=input$myYlab, xlab=input$myXlab, cex.main=input$cexTitle/10, cex.lab=input$cexAxislabel/10)
-# axis(1,at=c(1:nrOfSamples), labels=colnames(plotData), cex.axis=input$cexAxis/10, sub=input$mySubtitle)
- axis(1,at=c(1:nrOfSamples), labels=FALSE, cex.axis=input$cexAxis/10) #
- text(x=c(1:nrOfSamples), y=rep(myLim[1]-3,nrOfSamples), labels=colnames(plotData),
- pos=labelPos, xpd=TRUE, srt=xaxisLabelAngleNr, cex=input$cexAxis/10)
-
- } else {
- beanplot(data.frame(plotDataM[,notPlotPoints]), at=notPlotPoints, ylim=myLim,
- horizontal=as.numeric(input$myOrientation), xlim=c(0.5, ncol(plotDataM)+0.5),
- col=myColours2, border=input$beanBorder)
- title(main=input$myTitle, ylab=input$myYlab, xlab=input$myXlab, cex.main=input$cexTitle/10, cex.lab=input$cexAxislabel/10)
- # axis(1,at=c(1:nrOfSamples), labels=colnames(plotData), cex.axis=input$cexAxis/10)
- axis(1,at=c(1:nrOfSamples), labels=FALSE, cex.axis=input$cexAxis/10) #
-# text(x=c(1:nrOfSamples), y=rep(myLim[1]-3,nrOfSamples), labels=colnames(plotData),
-# pos=labelPos, xpd=TRUE, srt=xaxisLabelAngleNr, cex=input$cexAxis/10)
- }
- }
- # * Add points for samples with less then mnp data points *
- # replace "white" with "black" otherwise data points will not be visible
- for(i in plotPoints){
- if(input$datapointType==0 | input$plotType==1 | (input$datapointType==1 & input$showDataPoints==FALSE)){
- if(myColours[i]!="white"){
- points(rep(i, nrow(plotData)), plotData[,i], col=myColours[i])
- } else {
- points(rep(i, nrow(plotData)), plotData[,i], col="black")
- }
- }
- }
- if(input$showNrOfPoints==TRUE){text(x=1:ncol(dataM()), y=myLim[1], labels=boxplotStats()$n)}
- # Add mean and CIs for mean
- if(input$addMeans==TRUE & input$plotType=='0'){
- boxplotMeans<-apply(dataM(), 2, mean, na.rm=TRUE)
- points(x=1:ncol(dataM()), y=boxplotMeans, pch="+", cex=2)
- if(input$addMeanCI==TRUE){
- # Calculate the error using the quartile function * Standard error; SE=sd/sqrt(n)
- myQuartile<-1-((1-(as.numeric(input$meanCI)/100))/2)
- myError<-qt(myQuartile, df=(boxplotStats()$n)-1)*(apply(dataM(), 2, sd, na.rm=TRUE)/sapply(boxplotStats()$n, sqrt))
- for(ii in 1:ncol(dataM())) {
-# lines(y=c(ii,ii), x=c(boxplotMeans[ii]-myError[ii], boxplotMeans[ii]+myError[ii]), col="red")
- rect(ii-0.05, boxplotMeans[ii]-myError[ii], ii+0.05, boxplotMeans[ii]+myError[ii], col="darkgrey", border="darkgrey")
- }
- points(x=1:ncol(dataM()), y=boxplotMeans, pch="+", cex=2)
- }
- }
-
-
- # *** 2) Horizontal boxplots ***
- } else {
- if(input$plotType=='0'){
- boxplot(plotDataM, col=myColours, ylab=input$myYlab, xlab=input$myXlab, las=1, ylim=myLim,
- cex.lab=input$cexAxislabel/10, cex.axis=input$cexAxis/10, cex.main=input$cexTitle/10,
- main=input$myTitle, sub=input$mySubtitle, horizontal=as.numeric(input$myOrientation), frame=F,
- na.rm=TRUE, yaxt="n", range=myRange(), varwidth=input$myVarwidth, notch=input$myNotch) #notch=TRUE
- axis(2,at=c(1:nrOfSamples), labels=colnames(plotData), cex.axis=input$cexAxis/10)
- # Add data points if option has been selected
- if(input$showDataPoints==TRUE){
- if(length(plotPoints)==0){ # all samples are boxplots --> add points for all of them
- if(input$datapointType==0){
- for(i in c(1:nrOfSamples)){ points(plotData[,i], rep(i, nrow(plotData)), col="black") }
- } else { beeswarm(plotData, add=TRUE, horizontal=TRUE) }
- } else { # remove the ones that are already plotted as points
- if(input$datapointType==0){
- for(i in c(1:nrOfSamples)[-plotPoints]){ points(plotData[,i], rep(i, nrow(plotData)), col="black") }
- } else { beeswarm(plotData, add=TRUE, horizontal=TRUE) }
- }
- }
- } else {
- if(input$otherPlotType==0){ # Violin plot
- vioplot(as.list(data.frame(plotDataM)), col=myColours2[1], ylim=myLim, cex.axis=input$cexAxis/10,
- horizontal=as.numeric(input$myOrientation), range=myRange(), border=input$violinBorder)
- title(main=input$myTitle, ylab=input$myYlab, xlab=input$myXlab, cex.main=input$cexTitle/10, cex.lab=input$cexAxislabel/10)
- axis(2,at=c(1:nrOfSamples), labels=colnames(plotData), cex.axis=input$cexAxis/10)
-
- } else { # Bean plot
- beanplot(data.frame(plotDataM[,notPlotPoints]), at=notPlotPoints, ylim=myLim,
- horizontal=as.numeric(input$myOrientation), xlim=c(0.5, ncol(plotDataM)+0.5),
- col=myColours2, border=input$beanBorder)
- title(main=input$myTitle, ylab=input$myYlab, xlab=input$myXlab, cex.main=input$cexTitle/10, cex.lab=input$cexAxislabel/10)
- axis(2,at=c(1:nrOfSamples), labels=FALSE, cex.axis=input$cexAxis/10) # labels=colnames(plotData)
- }
- }
-
- # if there are columns with less than x data points, then add the points
- for(i in plotPoints){
- if(input$datapointType==0){
- if(myColours[i]!="white"){
- points(plotData[,i], rep(i, nrow(plotData)), col=myColours[i])
- } else {
- points(plotData[,i], rep(i, nrow(plotData)), col="white")
- }
- }
- }
- if(input$showNrOfPoints==TRUE){text(y=1:ncol(dataM()), x=myLim[1], labels=boxplotStats()$n)}
- # Add mean and CIs for mean
- if(input$addMeans==TRUE & input$plotType=='0'){
- boxplotMeans<-apply(dataM(), 2, mean, na.rm=TRUE)
- points(y=1:ncol(dataM()), x=boxplotMeans, pch="+", cex=2)
- if(input$addMeanCI==TRUE){
- # Calculate the error using the quartile function * Standard error; SE=sd/sqrt(n)
- myQuartile<-1-((1-(as.numeric(input$meanCI)/100))/2)
- myError<-qt(myQuartile, df=(boxplotStats()$n)-1)*(apply(dataM(), 2, sd, na.rm=TRUE)/sapply(boxplotStats()$n, sqrt))
- for(ii in 1:ncol(dataM())) {
-# lines(y=c(ii,ii), x=c(boxplotMeans[ii]-myError[ii], boxplotMeans[ii]+myError[ii]), col="red")
- rect(boxplotMeans[ii]-myError[ii], ii-0.05, boxplotMeans[ii]+myError[ii], ii+0.05, col="darkgrey", border="darkgrey")
- }
- points(y=1:ncol(dataM()), x=boxplotMeans, pch="+", cex=2)
- }
- }
-
- }
- # Add grid based on option selected
- if(input$addGrid==0){}
- else if(input$addGrid==1){grid()}
- else if (input$addGrid==2){grid(ny=NA)}
- else if (input$addGrid==3){grid(NA, ny=NULL)}
- }
-
- ## *** Data in table ***
- output$filetable <- renderTable({
- print(nrow(dataM()))
- if(nrow(dataM())<500){
- return(dataM())
- } else {return(dataM()[1:100,])}
- })
-
- # *** Boxplot (using 'generateBoxPlot'-function) ***
- output$boxPlot <- renderPlot({
- print(class(dataM()))
- generateBoxPlot(dataM())
- }, height = heightSize, width = widthSize)
-
- ## *** Download EPS file ***
- output$downloadPlotEPS <- downloadHandler(
- filename <- function() { paste('Boxplot.eps') },
- content <- function(file) {
- postscript(file, horizontal = FALSE, onefile = FALSE, paper = "special", width = input$myWidth/72, height = input$myHeight/72)
- ## ---------------
- generateBoxPlot(dataM())
- ## ---------------
- dev.off()
- },
- contentType = 'application/postscript'
- )
- ## *** Download PDF file ***
- output$downloadPlotPDF <- downloadHandler(
- filename <- function() { paste('Boxplot.pdf') },
- content <- function(file) {
- pdf(file, width = input$myWidth/72, height = input$myHeight/72)
- ## ---------------
- generateBoxPlot(dataM())
- ## ---------------
- dev.off()
- },
- contentType = 'application/pdf' # MIME type of the image
- )
- ## *** Download SVG file ***
- output$downloadPlotSVG <- downloadHandler(
- filename <- function() { paste('Boxplot.svg') },
- content <- function(file) {
- svg(file, width = input$myWidth/72, height = input$myHeight/72)
- ## ---------------
- generateBoxPlot(dataM())
- ## ---------------
- dev.off()
- },
- contentType = 'image/svg'
- )
-
- # *** Output boxplot statistics in table below plot ***
- output$boxplotStatsTable <- renderTable({
- if(input$addMeans){
- M<-rbind(boxplotStats()$stats[c(5,4,3,2,1),],boxplotStats()$n)
- M<-rbind(M, apply(dataM(), 2, mean, na.rm=TRUE))
- rownames(M)<-c("Upper whisker","3rd quartile","Median","1st quartile","Lower whisker", "Nr. of data points", "Mean")
- colnames(M)<-colnames(dataM())
- } else {
- M<-rbind(boxplotStats()$stats[c(5,4,3,2,1),],boxplotStats()$n)
- rownames(M)<-c("Upper whisker","3rd quartile","Median","1st quartile","Lower whisker", "Nr. of data points")
- colnames(M)<-colnames(dataM())
- }
- M
- })
-
- # *** Print figure legend ***
- output$FigureLegend <- renderPrint({
- # Center lines show the medians; box limits indicate the 25th and 75th percentiles as determined by R software; whiskers extend to minimum and maximum values; crosses represent means; bars indicate 95% confidence intervals. n = 100, 76, 16, 76, 41 sample points.
- # Generate vector with pieces of the legend based on user selections
- FL<-vector()
- # Figure legend for boxplot
- if(input$plotType=='0'){
- FL<-c("Center lines show the medians; box limits indicate the 25th and 75th percentiles as determined by R software")
- # one of these three, depending on whisker definition choice:
- # - Spear: "; whiskers extend to minimum and maximum values."
- # - Tukey: "; whiskers extend 1.5 times the interquartile range from the 25th and 75th percentiles; outliers are represented by dots."
- # - Altman: " and whiskers the 5th and 95th percentiles; outliers are represented by dots."
- if(input$whiskerType==0){
- FL<-append(FL, paste("; whiskers extend 1.5 times the interquartile range from the 25th and 75th percentiles, outliers are represented by dots", sep=""))
- } else if(input$whiskerType==1){
- FL<-append(FL, "; whiskers extend to minimum and maximum values")
- } else {
- FL<-append(FL, paste("; whiskers extend to 5th and 95th percentiles, outliers are represented by dots", sep=""))
- }
- # Means are added as crosses
- if(input$addMeans & input$plotType=='0'){ FL<-append(FL, c("; crosses represent sample means")) }
- # Confidence intervals of means are displayed as grey bars
- if(input$addMeans & input$addMeanCI & input$plotType=='0'){ FL<-append(FL, paste("; bars indicate ", input$meanCI,"% confidence intervals of the means", sep="")) }
- # Variable width of boxplots
- if(input$myVarwidth){ FL<-append(FL, c("; width of the boxes is proportional to the square root of the sample size")) }
- # Points are plotted on top of boxplots
- if(input$showDataPoints){ FL<-append(FL, c("; data points are plotted as open circles")) }
- # Sample size
- sampleSizes<-boxplotStats()$n
- if(length(unique(sampleSizes))==1){ FL<-append(FL, paste(". n = ", sampleSizes[1], " sample points", sep="")) }
- else { FL<-append(FL, paste(". n = ",paste(sampleSizes, collapse=", "), " sample points", sep="")) }
- FL<-append(FL, ".")
- } else {
- # radioButtons("otherPlotType", "", list("Violin plot"=0, "Bean plot"=1)),
- if (input$otherPlotType=='0'){ # Violin plot
- FL<-c("White circles show the medians;
- box limits indicate the 25th and 75th percentiles as determined by R software;
- whiskers extend 1.5 times the interquartile range from the 25th and 75th percentiles;
- polygons represent density estimates of data and extend to extreme values.")
- } else if (input$otherPlotType=='1') { # Bean plot
- FL<-c("Black lines show the medians;
- white lines represent individual data points;
- polygons represent the estimated density of the data.")
- #if(input$beanplotOverall){FL<-append(FL, c("dotted line represents overall "))}
- }
- } # END: other plot types
- cat(paste(FL, collapse=""))
- #- I am not sure what to put for the notches because we don't add '*'s to the box plots.
- })
-
- # *** Download boxplot data in csv format ***
- output$downloadBoxplotData <- downloadHandler(
- filename = function() { "BoxplotData.csv" },
- content = function(file) {
- write.csv(dataM(), file, row.names=FALSE)
- }) ###
+ observe({
+ if (input$clearText_button == 0) {
+ return()
+ }
+ isolate({
+ updateTextInput(session, "myData", label = ",", value = "")
+ })
+ })
-})
+ # *** Preset Style Guides Observer ***
+ observeEvent(input$styleGuide, {
+ if (input$styleGuide == "none") {
+ return()
+ }
+
+ # 1. Nature Journal
+ if (input$styleGuide == "nature") {
+ updateTextInput(session, "myColours", value = "light grey, white")
+ updateTextInput(session, "myOtherPlotColours", value = "light grey, white")
+ updateRadioButtons(session, "addGrid", selected = "0")
+ updateCheckboxInput(session, "fontSizes", value = TRUE)
+ updateNumericInput(session, "cexTitle", value = 14)
+ updateNumericInput(session, "cexAxislabel", value = 12)
+ updateNumericInput(session, "cexAxis", value = 10)
+ updateTextInput(session, "violinBorder", value = "grey")
+ updateTextInput(session, "beanBorder", value = "grey")
+ updateTextInput(session, "pointColors", value = "black")
+ }
+ # 2. Science Journal
+ else if (input$styleGuide == "science") {
+ updateTextInput(session, "myColours", value = "#0A2540, #FF6B6B, #4D96FF, #6BCB77, #F9D976")
+ updateTextInput(session, "myOtherPlotColours", value = "#0A2540, #FF6B6B, #4D96FF")
+ updateRadioButtons(session, "addGrid", selected = "0")
+ updateCheckboxInput(session, "fontSizes", value = TRUE)
+ updateNumericInput(session, "cexTitle", value = 14)
+ updateNumericInput(session, "cexAxislabel", value = 12)
+ updateNumericInput(session, "cexAxis", value = 10)
+ updateTextInput(session, "violinBorder", value = "black")
+ updateTextInput(session, "beanBorder", value = "black")
+ updateTextInput(session, "pointColors", value = "black")
+ }
+ # 3. The Economist
+ else if (input$styleGuide == "economist") {
+ updateTextInput(session, "myColours", value = "#005A9C, #7D7D7D, #E50011, #FFD100, #00A4E4")
+ updateTextInput(session, "myOtherPlotColours", value = "#005A9C, #7D7D7D, #E50011")
+ updateRadioButtons(session, "addGrid", selected = "3") # Y only
+ updateCheckboxInput(session, "fontSizes", value = TRUE)
+ updateNumericInput(session, "cexTitle", value = 16)
+ updateNumericInput(session, "cexAxislabel", value = 12)
+ updateNumericInput(session, "cexAxis", value = 11)
+ updateTextInput(session, "violinBorder", value = "white")
+ updateTextInput(session, "beanBorder", value = "white")
+ updateTextInput(session, "pointColors", value = "#E50011")
+ }
+ # 4. Financial Times
+ else if (input$styleGuide == "ft") {
+ updateTextInput(session, "myColours", value = "#0F5499, #990F3D, #3F3F3F, #D9A752, #5C88BF")
+ updateTextInput(session, "myOtherPlotColours", value = "#0F5499, #990F3D, #3F3F3F")
+ updateRadioButtons(session, "addGrid", selected = "3") # Y only
+ updateCheckboxInput(session, "fontSizes", value = TRUE)
+ updateNumericInput(session, "cexTitle", value = 16)
+ updateNumericInput(session, "cexAxislabel", value = 12)
+ updateNumericInput(session, "cexAxis", value = 11)
+ updateTextInput(session, "violinBorder", value = "#1e293b")
+ updateTextInput(session, "beanBorder", value = "#1e293b")
+ updateTextInput(session, "pointColors", value = "#990F3D")
+ }
+ })
+
+ # *** Read in data matrix ***
+ data_m <- reactive({
+ if (input$dataInput == 1) {
+ if (input$sampleData == 1) {
+ data <- sample_data_1_cache
+ } else if (input$sampleData == 2) {
+ data <- sample_data_2_cache
+ } else {
+ data <- sample_data_3_cache
+ }
+ } else if (input$dataInput == 2) {
+ in_file <- input$upload
+ # Avoid error message while file is not uploaded yet
+ if (is.null(input$upload)) {
+ return(NULL)
+ }
+ # Get the separator and extension
+ ext <- tolower(tools::file_ext(in_file$name))
+
+ if (ext %in% c("xls", "xlsx")) {
+ data <- as.data.frame(readxl::read_excel(in_file$datapath))
+ } else {
+ my_sep <- switch(input$fileSepDF,
+ "1" = ",",
+ "2" = "\t",
+ "3" = ";",
+ "4" = ""
+ )
+ data <- read.table(
+ in_file$datapath,
+ sep = my_sep, header = TRUE, fill = TRUE,
+ check.names = FALSE
+ )
+ }
+ } else {
+ # For special case when last column has empty entries in some rows
+ if (is.null(input$myData) || input$myData == "") {
+ return(NULL)
+ }
+ my_sep <- switch(input$fileSepP,
+ "1" = ",",
+ "2" = "\t",
+ "3" = ";"
+ )
+ data <- read.table(
+ text = input$myData, sep = my_sep, header = TRUE, fill = TRUE,
+ check.names = FALSE
+ )
+ }
+ return(data)
+ })
+
+ # *** The plot dimensions ***
+ height_size <- reactive({
+ input$myHeight
+ })
+ width_size <- reactive({
+ input$myWidth
+ })
+
+ # *** Determine extent of whisker range ***
+ # whiskerDefinition 0 - Tukey (default), 1 - Spear (min/max, range=0),
+ # 2 - Altman (5% and 95% quantiles)
+ my_range <- reactive({
+ if (input$whiskerType == 0) {
+ my_range <- c(-1.5)
+ } else if (input$whiskerType == 1) {
+ my_range <- c(0)
+ } else if (input$whiskerType == 2) {
+ my_range <- c(5)
+ }
+ return(my_range)
+ })
+
+ # *** Get boxplot statistics ***
+ boxplot_stats <- reactive({
+ if (is.null(data_m())) {
+ return(NULL)
+ }
+ return(boxplot(data_m(), na.rm = TRUE, range = my_range(), plot = FALSE))
+ })
+
+ # *** Helper function for stats table ***
+ get_stats_matrix <- function(data, stats, add_means) {
+ stats_matrix <- rbind(
+ as.matrix(stats$stats[c(5, 4, 3, 2, 1), ]),
+ stats$n
+ )
+
+ if (add_means) {
+ stats_matrix <- rbind(stats_matrix, colMeans(data, na.rm = TRUE))
+ rownames(stats_matrix) <- c(
+ "Upper whisker", "3rd quartile", "Median", "1st quartile",
+ "Lower whisker", "Nr. of data points", "Mean"
+ )
+ } else {
+ rownames(stats_matrix) <- c(
+ "Upper whisker", "3rd quartile", "Median", "1st quartile",
+ "Lower whisker", "Nr. of data points"
+ )
+ }
+ colnames(stats_matrix) <- colnames(data)
+ return(stats_matrix)
+ }
+
+ # *** Helper function for figure legend ***
+ generate_figure_legend <- function(stats, plot_type, other_plot_type,
+ whisker_type, add_means, add_mean_ci,
+ mean_ci_val, my_varwidth,
+ bean_plot_center_type) {
+ fl <- "Center lines show the medians; "
+
+ if (plot_type == "0") { # Boxplot
+ fl <- paste0(
+ fl,
+ "box limits indicate the 25th and 75th percentiles ",
+ "as determined by R software"
+ )
+
+ if (whisker_type == 0) {
+ fl <- paste0(
+ fl,
+ "; whiskers extend 1.5 times the interquartile range ",
+ "from the 25th and 75th percentiles, outliers are ",
+ "represented by dots"
+ )
+ } else if (whisker_type == 1) {
+ fl <- paste0(fl, "; whiskers extend to minimum and maximum values")
+ } else {
+ fl <- paste0(
+ fl,
+ "; whiskers extend to 5th and 95th percentiles, ",
+ "outliers are represented by dots"
+ )
+ }
+
+ if (add_means) {
+ fl <- paste0(fl, "; crosses represent sample means")
+ if (add_mean_ci) {
+ fl <- paste0(
+ fl, "; bars indicate ", mean_ci_val,
+ "% confidence intervals of the means"
+ )
+ }
+ }
+
+ if (my_varwidth) {
+ fl <- paste0(
+ fl, "; width of the boxes is proportional to the square root ",
+ "of the sample size"
+ )
+ }
+ } else {
+ if (other_plot_type == "0") { # Violin plot
+ fl <- paste0(
+ "White circles show the medians; box limits indicate the 25th ",
+ "and 75th percentiles; whiskers extend 1.5 times the interquartile ",
+ "range; polygons represent density estimates."
+ )
+ } else { # Bean plot
+ center_label <- if (bean_plot_center_type == 0) "median" else "mean"
+ fl <- paste0(
+ "Black lines show the ", center_label,
+ "s; white lines represent individual data points; ",
+ "polygons represent density estimates."
+ )
+ }
+ }
+
+ fl <- paste0(
+ fl, ". n = ",
+ paste(stats$n, collapse = ", "),
+ " sample points."
+ )
+ return(fl)
+ }
+
+ # *** Generate the box plot ***
+ generate_box_plot <- function(plot_data) {
+ # Safe input resolvers to prevent "argument is of length zero" or NULL crashes during reactive updates
+ plot_engine <- if (is.null(input$plotEngine) || length(input$plotEngine) == 0) "classic" else input$plotEngine
+ plot_type <- if (is.null(input$plotType) || length(input$plotType) == 0) "0" else input$plotType
+ other_plot_type <- if (is.null(input$otherPlotType) || length(input$otherPlotType) == 0) "0" else input$otherPlotType
+ bean_plot_median_mean <- if (is.null(input$beanPlotMedianMean) || length(input$beanPlotMedianMean) == 0) 0 else as.numeric(input$beanPlotMedianMean)
+ my_varwidth <- if (is.null(input$myVarwidth) || length(input$myVarwidth) == 0) FALSE else (input$myVarwidth == TRUE)
+ my_notch <- if (is.null(input$myNotch) || length(input$myNotch) == 0) FALSE else (input$myNotch == TRUE)
+ show_data_points <- if (is.null(input$showDataPoints) || length(input$showDataPoints) == 0) FALSE else (input$showDataPoints == TRUE)
+ datapoint_type <- if (is.null(input$datapointType) || length(input$datapointType) == 0) 0 else as.numeric(input$datapointType)
+ add_means <- if (is.null(input$addMeans) || length(input$addMeans) == 0) FALSE else (input$addMeans == TRUE)
+ add_mean_ci <- if (is.null(input$addMeanCI) || length(input$addMeanCI) == 0) FALSE else (input$addMeanCI == TRUE)
+ mean_ci <- if (is.null(input$meanCI) || length(input$meanCI) == 0) 95 else as.numeric(input$meanCI)
+ log_scale <- if (is.null(input$logScale) || length(input$logScale) == 0) FALSE else (input$logScale == TRUE)
+ my_orientation <- if (is.null(input$myOrientation) || length(input$myOrientation) == 0) FALSE else (input$myOrientation == 1)
+ add_grid <- if (is.null(input$addGrid) || length(input$addGrid) == 0) 0 else as.numeric(input$addGrid)
+ show_nr_of_points <- if (is.null(input$showNrOfPoints) || length(input$showNrOfPoints) == 0) FALSE else (input$showNrOfPoints == TRUE)
+ style_guide <- if (is.null(input$styleGuide) || length(input$styleGuide) == 0) "none" else input$styleGuide
+ plot_data_points <- if (is.null(input$plotDataPoints) || length(input$plotDataPoints) == 0) FALSE else (input$plotDataPoints == TRUE)
+ nr_of_data_points <- if (is.null(input$nrOfDataPoints) || length(input$nrOfDataPoints) == 0) 5 else as.numeric(input$nrOfDataPoints)
+ xaxis_label_angle <- if (is.null(input$xaxisLabelAngle) || length(input$xaxisLabelAngle) == 0) FALSE else (input$xaxisLabelAngle == TRUE)
+
+ cex_title <- if (is.null(input$cexTitle) || length(input$cexTitle) == 0) 14 else as.numeric(input$cexTitle)
+ cex_axislabel <- if (is.null(input$cexAxislabel) || length(input$cexAxislabel) == 0) 14 else as.numeric(input$cexAxislabel)
+ cex_axis <- if (is.null(input$cexAxis) || length(input$cexAxis) == 0) 12 else as.numeric(input$cexAxis)
+
+ my_title <- if (is.null(input$myTitle) || length(input$myTitle) == 0) "" else input$myTitle
+ my_subtitle <- if (is.null(input$mySubtitle) || length(input$mySubtitle) == 0) "" else input$mySubtitle
+ my_xlab <- if (is.null(input$myXlab) || length(input$myXlab) == 0) "" else input$myXlab
+ my_ylab <- if (is.null(input$myYlab) || length(input$myYlab) == 0) "" else input$myYlab
+
+ ylimit_val <- if (is.null(input$ylimit) || length(input$ylimit) == 0) "" else input$ylimit
+ xlimit_val <- if (is.null(input$xlimit) || length(input$xlimit) == 0) "" else input$xlimit
+
+ my_colours_val <- if (is.null(input$myColours) || length(input$myColours) == 0) "light grey, white" else input$myColours
+ my_other_colours_val <- if (is.null(input$myOtherPlotColours) || length(input$myOtherPlotColours) == 0) "light grey, white" else input$myOtherPlotColours
+ point_colors_val <- if (is.null(input$pointColors) || length(input$pointColors) == 0) "black" else input$pointColors
+
+ violin_border <- if (is.null(input$violinBorder) || length(input$violinBorder) == 0) "grey" else input$violinBorder
+ bean_border <- if (is.null(input$beanBorder) || length(input$beanBorder) == 0) "grey" else input$beanBorder
+
+ point_transparency <- if (is.null(input$pointTransparency) || length(input$pointTransparency) == 0) 50 else as.numeric(input$pointTransparency)
+ point_size <- if (is.null(input$pointSize) || length(input$pointSize) == 0) 10 else as.numeric(input$pointSize)
+
+ if (plot_engine == "ggplot") {
+ library(ggplot2)
+
+ # Convert plot_data to long format
+ df_long <- data.frame(
+ Value = unlist(plot_data, use.names = FALSE),
+ Group = rep(colnames(plot_data), each = nrow(plot_data))
+ )
+ df_long <- na.omit(df_long)
+
+ # Make sure Group is a factor with original order
+ df_long$Group <- factor(df_long$Group, levels = colnames(plot_data))
+
+ # Parse colours
+ my_colours <- parse_colours(my_colours_val)
+ my_colours_2 <- parse_colours(my_other_colours_val)
+ point_colors <- parse_colours(point_colors_val)
+
+ nr_of_samples <- ncol(plot_data)
+ # Always recycle color vectors to match exact number of samples so ggplot manual scale never errors
+ my_colours <- rep(my_colours, length.out = nr_of_samples)
+ my_colours_2 <- rep(my_colours_2, length.out = nr_of_samples)
+ point_colors <- rep(point_colors, length.out = nr_of_samples)
+
+ plot_colours <- if (plot_type == "0") my_colours else my_colours_2
+
+ # Initialize ggplot and Plot Types
+ if (plot_type == "0") { # Boxplot
+ # Get natively calculated boxplot statistics matching the whiskerType (Tukey, Spear, Altman)
+ bp_stats <- boxplot_stats()
+
+ notchlower_val <- bp_stats$conf[1, ]
+ notchupper_val <- bp_stats$conf[2, ]
+ if (log_scale) {
+ # Safely transform to log10 space since scale_y_log10 doesn't automatically transform custom aesthetics
+ notchlower_val <- log10(pmax(1e-10, notchlower_val))
+ notchupper_val <- log10(pmax(1e-10, notchupper_val))
+ }
+
+ df_stats <- data.frame(
+ Group = factor(bp_stats$names, levels = colnames(plot_data)),
+ ymin = bp_stats$stats[1, ],
+ lower = bp_stats$stats[2, ],
+ middle = bp_stats$stats[3, ],
+ upper = bp_stats$stats[4, ],
+ ymax = bp_stats$stats[5, ],
+ notchlower = notchlower_val,
+ notchupper = notchupper_val,
+ fill = bp_stats$names
+ )
+
+ p <- ggplot(df_stats, aes(x = Group, fill = Group)) +
+ suppressWarnings(geom_boxplot(
+ aes(
+ ymin = ymin, lower = lower, middle = middle, upper = upper, ymax = ymax,
+ notchlower = notchlower, notchupper = notchupper
+ ),
+ stat = "identity",
+ varwidth = my_varwidth,
+ notch = my_notch,
+ width = 0.6
+ ))
+
+ # Identify outliers matching the calculated whiskers
+ df_outliers <- df_long
+ df_outliers$ymin <- df_stats$ymin[match(df_outliers$Group, df_stats$Group)]
+ df_outliers$ymax <- df_stats$ymax[match(df_outliers$Group, df_stats$Group)]
+ df_outliers <- df_outliers[df_outliers$Value < df_outliers$ymin | df_outliers$Value > df_outliers$ymax, ]
+
+ # Overlay outliers if they are NOT already showing all points
+ if (!show_data_points && nrow(df_outliers) > 0) {
+ p <- p + geom_point(
+ data = df_outliers,
+ aes(x = Group, y = Value),
+ color = "black",
+ size = 1.5,
+ shape = 19,
+ inherit.aes = FALSE
+ )
+ }
+ } else {
+ # Initialize ggplot for Violin/Bean plot
+ p <- ggplot(df_long, aes(x = Group, y = Value, fill = Group))
+
+ if (other_plot_type == "0") { # Violin
+ p <- p + geom_violin(
+ color = violin_border,
+ width = 0.8
+ )
+ } else { # Beanplot
+ p <- p + geom_violin(
+ color = bean_border,
+ width = 0.8,
+ alpha = 0.7
+ )
+
+ # Median/Mean crossbar
+ center_fun <- if (bean_plot_median_mean == 0) "median" else "mean"
+ p <- p + stat_summary(
+ fun = center_fun,
+ geom = "crossbar",
+ width = 0.4,
+ color = "black",
+ middle.linewidth = 0.8
+ )
+
+ # Add individual horizontal data line segments inside the bean density shape
+ p <- p + geom_segment(
+ aes(
+ x = as.numeric(Group) - 0.15,
+ xend = as.numeric(Group) + 0.15,
+ y = Value,
+ yend = Value
+ ),
+ color = "#1e293b",
+ linewidth = 0.4,
+ alpha = 0.4
+ )
+ }
+ }
+
+ # Apply custom fill colors
+ p <- p + scale_fill_manual(values = plot_colours)
+
+ # Data points overlay
+ if (show_data_points) {
+ pt_trans <- 1 - (point_transparency / 100)
+ pt_sz <- point_size / 10
+ pt_col <- point_colors[1]
+
+ # Specify data and mapping for Boxplot type since p uses df_stats as default
+ points_data <- if (plot_type == "0") df_long else NULL
+ points_aes <- if (plot_type == "0") aes(y = Value) else NULL
+
+ if (datapoint_type == 1) { # Beeswarm / Minimal jitter
+ p <- p + geom_jitter(
+ data = points_data,
+ mapping = points_aes,
+ width = 0.05, height = 0,
+ color = pt_col, size = pt_sz, alpha = pt_trans
+ )
+ } else if (datapoint_type == 2) { # Jittered
+ p <- p + geom_jitter(
+ data = points_data,
+ mapping = points_aes,
+ width = 0.2, height = 0,
+ color = pt_col, size = pt_sz, alpha = pt_trans
+ )
+ } else { # Normal/Centered stripchart
+ p <- p + geom_point(
+ data = points_data,
+ mapping = points_aes,
+ position = position_nudge(x = 0),
+ color = pt_col, size = pt_sz, alpha = pt_trans
+ )
+ }
+ }
+
+ # Means and CIs for Boxplot
+ if (add_means && plot_type == "0") {
+ p <- p + stat_summary(
+ data = df_long,
+ aes(x = Group, y = Value),
+ fun = mean,
+ geom = "point",
+ shape = 18,
+ size = 4,
+ color = "red",
+ inherit.aes = FALSE
+ )
+
+ if (add_mean_ci) {
+ ci_fun <- function(x) {
+ n <- sum(!is.na(x))
+ if (n <= 1) return(c(ymin = NA, ymax = NA))
+ se <- sd(x, na.rm = TRUE) / sqrt(n)
+ ci_level <- mean_ci / 100
+ t_val <- qt((1 + ci_level) / 2, df = n - 1)
+ me <- t_val * se
+ m <- mean(x, na.rm = TRUE)
+ c(ymin = m - me, ymax = m + me)
+ }
+ p <- p + stat_summary(
+ data = df_long,
+ aes(x = Group, y = Value),
+ fun.data = ci_fun,
+ geom = "errorbar",
+ width = 0.2,
+ color = "red",
+ linewidth = 0.8,
+ inherit.aes = FALSE
+ )
+ }
+ }
+
+ # Log Scale
+ if (log_scale) {
+ p <- p + scale_y_log10()
+ }
+
+ # Labels & Font sizes
+ p <- p + labs(
+ title = my_title,
+ subtitle = my_subtitle,
+ x = my_xlab,
+ y = my_ylab
+ )
+
+ # Resolve Y limits
+ ymin <- NA
+ ymax <- NA
+ xmin <- NA
+ xmax <- NA
+
+ if (ylimit_val != "" && !my_orientation) {
+ ymin <- as.numeric(gsub("\\s", "", strsplit(ylimit_val, ",")[[1]][1]))
+ ymax <- as.numeric(gsub("\\s", "", strsplit(ylimit_val, ",")[[1]][2]))
+ }
+ if (xlimit_val != "" && my_orientation) {
+ xmin <- as.numeric(gsub("\\s", "", strsplit(xlimit_val, ",")[[1]][1]))
+ xmax <- as.numeric(gsub("\\s", "", strsplit(xlimit_val, ",")[[1]][2]))
+ }
+
+ lims <- if (!my_orientation && !is.na(ymin)) {
+ c(ymin, ymax)
+ } else if (my_orientation && !is.na(xmin)) {
+ c(xmin, xmax)
+ } else {
+ NULL
+ }
+
+ if (my_orientation) {
+ p <- p + coord_flip(ylim = lims)
+ } else {
+ if (!is.null(lims)) {
+ p <- p + coord_cartesian(ylim = lims)
+ }
+ }
+
+ # Resolve style guide defaults for ggplot
+ style_font <- "Inter"
+ bg_fill <- "white"
+ panel_bg_fill <- "white"
+ grid_color <- "#e2e8f0"
+ axis_line_color <- "#475569"
+ plot_title_hjust <- 0.5
+
+ if (style_guide == "nature") {
+ style_font <- "sans"
+ } else if (style_guide == "science") {
+ style_font <- "serif"
+ } else if (style_guide == "economist") {
+ style_font <- "sans"
+ bg_fill <- "#e4eef2"
+ panel_bg_fill <- "#e4eef2"
+ grid_color <- "white"
+ axis_line_color <- "#1e293b"
+ plot_title_hjust <- 0
+ } else if (style_guide == "ft") {
+ style_font <- "serif"
+ bg_fill <- "#fff1e5"
+ panel_bg_fill <- "#fff1e5"
+ grid_color <- "#e2d6ca"
+ axis_line_color <- "#1e293b"
+ plot_title_hjust <- 0
+ }
+
+ # Theme
+ p <- p + theme_minimal(base_family = style_font) +
+ theme(
+ plot.title = element_text(size = cex_title * 1.5, face = "bold", hjust = plot_title_hjust),
+ plot.subtitle = element_text(size = cex_title * 1.1, hjust = plot_title_hjust, color = "#475569"),
+ axis.title.x = element_text(size = cex_axislabel * 1.2),
+ axis.title.y = element_text(size = cex_axislabel * 1.2),
+ axis.text = element_text(size = cex_axis * 1.1),
+ legend.position = "none",
+ panel.background = element_rect(fill = panel_bg_fill, color = NA),
+ plot.background = element_rect(fill = bg_fill, color = NA),
+ axis.line = element_line(color = axis_line_color, linewidth = 0.6),
+ axis.ticks = element_line(color = axis_line_color, linewidth = 0.6)
+ )
+
+ # Gridlines
+ if (add_grid == 0) {
+ p <- p + theme(panel.grid.major = element_blank(), panel.grid.minor = element_blank())
+ } else if (add_grid == 2) { # X only (perpendicular to X)
+ p <- p + theme(panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(), panel.grid.major.x = element_line(color = grid_color))
+ } else if (add_grid == 3) { # Y only (perpendicular to Y)
+ p <- p + theme(panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), panel.grid.major.y = element_line(color = grid_color))
+ } else {
+ p <- p + theme(
+ panel.grid.major = element_line(color = grid_color),
+ panel.grid.minor = element_blank()
+ )
+ }
+
+ # Display N count text at top/right if requested
+ if (show_nr_of_points) {
+ # Calculate stats for the labels
+ nr_points <- sapply(plot_data, function(x) sum(!is.na(x)))
+ df_labels <- data.frame(
+ Group = factor(colnames(plot_data), levels = colnames(plot_data)),
+ y_pos = if (log_scale) {
+ 10^(log10(max(plot_data, na.rm = TRUE)) + 0.1)
+ } else {
+ max(plot_data, na.rm = TRUE) * 1.05
+ },
+ label = paste0("n=", nr_points)
+ )
+
+ # Overlay standard text labels
+ p <- p + geom_text(
+ data = df_labels,
+ aes(x = Group, y = y_pos, label = label),
+ inherit.aes = FALSE,
+ size = cex_axis * 0.35,
+ color = "#475569",
+ vjust = 0
+ )
+ }
+
+ print(p)
+ return()
+ }
+
+ # Resolve style guide defaults for Classic R
+ bg_fill <- "white"
+ style_font <- ""
+
+ if (style_guide == "nature") {
+ style_font <- "sans"
+ } else if (style_guide == "science") {
+ style_font <- "serif"
+ } else if (style_guide == "economist") {
+ style_font <- "sans"
+ bg_fill <- "#e4eef2"
+ } else if (style_guide == "ft") {
+ style_font <- "serif"
+ bg_fill <- "#fff1e5"
+ }
+
+ if (style_font != "") {
+ par(mar = c(5, 8, 4, 2), bg = bg_fill, family = style_font)
+ } else {
+ par(mar = c(5, 8, 4, 2), bg = bg_fill)
+ }
+
+ nr_of_samples <- ncol(plot_data)
+
+ plot_data_m <- plot_data
+ not_plot_points <- seq_len(nr_of_samples)
+ plot_points <- integer(0)
+
+ if (plot_data_points) {
+ nr_needed <- nr_of_data_points
+ not_plot_points <- integer(0)
+ for (i in seq_len(nr_of_samples)) {
+ if (sum(!is.na(plot_data[[i]])) < nr_needed) {
+ plot_data_m[[i]] <- NA
+ plot_points <- c(plot_points, i)
+ } else {
+ not_plot_points <- c(not_plot_points, i)
+ }
+ }
+ }
+ my_colours <- parse_colours(my_colours_val)
+ my_colours_2 <- parse_colours(my_other_colours_val)
+ point_colors <- parse_colours(point_colors_val)
+ # Replicate colors if only one is provided
+ if (length(my_colours) == 1) {
+ my_colours <- rep(my_colours, nr_of_samples)
+ }
+ if (length(my_colours_2) == 1) {
+ my_colours_2 <- rep(my_colours_2, nr_of_samples)
+ }
+ if (length(point_colors) == 1) {
+ point_colors <- rep(point_colors, nr_of_samples)
+ }
+ point_t <- 1 - (point_transparency / 100)
+ point_c <- NA
+ if (point_t < 1) {
+ point_c <- rgb(
+ t(col2rgb(point_colors)),
+ alpha = 255 * point_t,
+ maxColorValue = 255
+ )
+ } else {
+ point_c <- point_colors
+ }
+ my_log <- ""
+ xmin <- NA
+ xmax <- NA
+ ymin <- NA
+ ymax <- NA
+
+ if (log_scale) {
+ my_log <- if (my_orientation) "x" else "y"
+ }
+
+ if (ylimit_val != "" && !my_orientation) {
+ ymin <- as.numeric(gsub("\\s", "", strsplit(ylimit_val, ",")[[1]][1]))
+ ymax <- as.numeric(gsub("\\s", "", strsplit(ylimit_val, ",")[[1]][2]))
+ }
+ if (xlimit_val != "" && my_orientation) {
+ xmin <- as.numeric(gsub("\\s", "", strsplit(xlimit_val, ",")[[1]][1]))
+ xmax <- as.numeric(gsub("\\s", "", strsplit(xlimit_val, ",")[[1]][2]))
+ }
+
+ # Calculate a shared default range for consistent axes across plot types
+ shared_lim <- if (all(is.na(plot_data))) {
+ NULL
+ } else {
+ r <- range(plot_data, na.rm = TRUE)
+ if (show_nr_of_points) {
+ if (log_scale && length(r[r > 0]) > 0) {
+ # Log scale requires multiplicative expansion to prevent negatives
+ c(r[1], r[2] * (10^(diff(log10(r[r > 0])) * 0.15)))
+ } else {
+ # Expand top geometrically to make space for data counts
+ padding <- diff(r) * 0.15
+ c(r[1] - (diff(r) * 0.04), r[2] + padding)
+ }
+ } else {
+ r
+ }
+ }
+
+ vals_lim <- if (!my_orientation && ylimit_val != "") {
+ c(ymin, ymax)
+ } else if (my_orientation && xlimit_val != "") {
+ c(xmin, xmax)
+ } else {
+ shared_lim
+ }
+
+ par(las = if (xaxis_label_angle) 2 else 1)
+
+ if (plot_type == "0") { # Boxplot
+ boxplot(
+ plot_data_m,
+ main = my_title,
+ sub = my_subtitle,
+ xlab = my_xlab,
+ ylab = my_ylab,
+ col = my_colours,
+ horizontal = my_orientation,
+ varwidth = my_varwidth,
+ notch = my_notch,
+ outline = !show_data_points,
+ range = my_range(),
+ log = my_log,
+ ylim = vals_lim,
+ las = if (xaxis_label_angle) 2 else 1,
+ frame.plot = FALSE,
+ # Font sizes
+ cex.main = cex_title / 10,
+ cex.lab = cex_axislabel / 10,
+ cex.axis = cex_axis / 10
+ )
+ } else {
+ if (other_plot_type == "0") { # Violin plot
+ if (length(not_plot_points) > 0) {
+ vioplot(
+ as.list(data.frame(plot_data_m)),
+ col = my_colours_2,
+ horizontal = my_orientation,
+ border = violin_border,
+ cex.axis = cex_axis / 10,
+ ylim = vals_lim,
+ names = colnames(plot_data_m),
+ log = my_log
+ )
+ } else {
+ plot(
+ 1,
+ type = "n", axes = FALSE, xlab = "", ylab = "",
+ xlim = if (my_orientation) {
+ shared_lim
+ } else {
+ c(0.5, nr_of_samples + 0.5)
+ },
+ ylim = if (!my_orientation) {
+ shared_lim
+ } else {
+ c(0.5, nr_of_samples + 0.5)
+ }
+ )
+ axis(if (my_orientation) 1 else 2, cex.axis = cex_axis / 10)
+ axis(
+ if (my_orientation) 2 else 1,
+ at = seq_len(nr_of_samples),
+ labels = colnames(plot_data),
+ cex.axis = cex_axis / 10
+ )
+ }
+ title(
+ main = my_title,
+ sub = my_subtitle,
+ xlab = my_xlab,
+ ylab = my_ylab,
+ cex.main = cex_title / 10,
+ cex.lab = cex_axislabel / 10
+ )
+ } else { # Bean plot
+ my_beanplot_center <- if (bean_plot_median_mean == 0) {
+ "median"
+ } else {
+ "mean"
+ }
+ if (length(not_plot_points) > 0) {
+ beanplot(
+ data.frame(plot_data_m[, not_plot_points, drop = FALSE]),
+ at = not_plot_points,
+ xlim = c(0.5, nr_of_samples + 0.5),
+ ylim = vals_lim,
+ col = if (length(my_colours_2) > 1) {
+ as.list(my_colours_2)
+ } else {
+ my_colours_2
+ },
+ horizontal = my_orientation,
+ border = bean_border,
+ what = c(1, 1, 1, as.logical(bean_plot_median_mean)),
+ cex.axis = cex_axis / 10,
+ overallline = my_beanplot_center,
+ names = colnames(plot_data)[not_plot_points],
+ frame.plot = FALSE,
+ log = my_log
+ )
+ axis(
+ if (my_orientation) 2 else 1,
+ at = seq_len(nr_of_samples),
+ labels = colnames(plot_data),
+ cex.axis = cex_axis / 10
+ )
+ } else {
+ plot(
+ 1,
+ type = "n", axes = FALSE, xlab = "", ylab = "",
+ xlim = if (my_orientation) {
+ shared_lim
+ } else {
+ c(0.5, nr_of_samples + 0.5)
+ },
+ ylim = if (!my_orientation) {
+ shared_lim
+ } else {
+ c(0.5, nr_of_samples + 0.5)
+ }
+ )
+ axis(if (my_orientation) 1 else 2, cex.axis = cex_axis / 10)
+ axis(
+ if (my_orientation) 2 else 1,
+ at = seq_len(nr_of_samples),
+ labels = colnames(plot_data),
+ cex.axis = cex_axis / 10
+ )
+ }
+ title(
+ main = my_title,
+ sub = my_subtitle,
+ xlab = my_xlab,
+ ylab = my_ylab,
+ cex.main = cex_title / 10,
+ cex.lab = cex_axislabel / 10
+ )
+ }
+ }
+
+ # Add grid
+ if (add_grid == 1) {
+ grid()
+ } else if (add_grid == 2) {
+ grid(nx = NULL, ny = NA)
+ } else if (add_grid == 3) {
+ grid(nx = NA, ny = NULL)
+ }
+
+ # Samples means
+ if (add_means && plot_type == "0") {
+ boxplot_means <- colMeans(plot_data, na.rm = TRUE)
+ if (my_orientation) {
+ points(boxplot_means, seq_along(boxplot_means), pch = 18, col = "red")
+ } else {
+ points(seq_along(boxplot_means), boxplot_means, pch = 18, col = "red")
+ }
+
+ # Add CI of means
+ if (add_mean_ci) {
+ for (i in seq_along(plot_data)) {
+ my_sample <- na.omit(plot_data[[i]])
+ n <- length(my_sample)
+ if (n > 1) {
+ standard_error <- sd(my_sample) / sqrt(n)
+ ci_level <- mean_ci / 100
+ t_value <- qt((1 + ci_level) / 2, df = n - 1)
+ margin_error <- t_value * standard_error
+ lower_ci <- boxplot_means[i] - margin_error
+ upper_ci <- boxplot_means[i] + margin_error
+
+ if (my_orientation) {
+ lines(c(lower_ci, upper_ci), c(i, i), col = "red", lwd = 2)
+ lines(
+ c(lower_ci, lower_ci), c(i - 0.1, i + 0.1),
+ col = "red", lwd = 2
+ )
+ lines(
+ c(upper_ci, upper_ci), c(i - 0.1, i + 0.1),
+ col = "red", lwd = 2
+ )
+ } else {
+ lines(c(i, i), c(lower_ci, upper_ci), col = "red", lwd = 2)
+ lines(
+ c(i - 0.1, i + 0.1), c(lower_ci, lower_ci),
+ col = "red", lwd = 2
+ )
+ lines(
+ c(i - 0.1, i + 0.1), c(upper_ci, upper_ci),
+ col = "red", lwd = 2
+ )
+ }
+ }
+ }
+ }
+ }
+
+ # Add numbers of data points
+ if (show_nr_of_points) {
+ nr_points <- boxplot_stats()$n
+ if (my_orientation) {
+ pos_x <- if (log_scale) 10^par("usr")[2] else par("usr")[2]
+ text(
+ x = pos_x,
+ y = seq_along(nr_points),
+ labels = nr_points,
+ pos = 2
+ )
+ } else {
+ pos_y <- if (log_scale) 10^par("usr")[4] else par("usr")[4]
+ text(
+ x = seq_along(nr_points),
+ y = pos_y,
+ labels = nr_points,
+ pos = 1
+ )
+ }
+ }
+
+ # Add data points if selected or if forced by plotDataPoints limit
+ if (show_data_points || length(plot_points) > 0) {
+ plot_data_points <- plot_data
+ if (!show_data_points && length(plot_points) > 0) {
+ # Only plot points for samples below the limit
+ plot_data_points[, not_plot_points] <- NA
+ }
+
+ if (datapoint_type == 1) { # Bee swarm
+ beeswarm(
+ plot_data_points,
+ add = TRUE,
+ col = point_c,
+ horizontal = my_orientation,
+ cex = point_size / 10,
+ pch = 16
+ )
+ } else { # Jittered or Default
+ jittered_points(
+ plot_data_points,
+ my_orientation,
+ datapoint_type,
+ point_colors,
+ point_transparency,
+ point_size / 10
+ )
+ }
+ }
+ }
+
+ ## *** Data in table ***
+ output$filetable <- renderTable({
+ if (is.null(data_m())) {
+ return(NULL)
+ }
+ if (nrow(data_m()) < 500) {
+ return(data_m())
+ } else {
+ return(data_m()[1:100, ])
+ }
+ })
+
+ # *** Boxplot (using 'generate_box_plot'-function) ***
+ output$boxPlot <- renderPlot(
+ {
+ if (is.null(data_m())) {
+ return(NULL)
+ }
+ generate_box_plot(data_m())
+ },
+ height = function() {
+ input$myHeight
+ },
+ width = function() {
+ input$myWidth
+ }
+ )
+
+ ## *** Download EPS file ***
+ output$downloadPlotEPS <- downloadHandler(
+ filename = function() {
+ "Boxplot.eps"
+ },
+ content = function(file) {
+ postscript(
+ file,
+ horizontal = FALSE, onefile = FALSE, paper = "special",
+ width = input$myWidth / 72, height = input$myHeight / 72
+ )
+ generate_box_plot(data_m())
+ dev.off()
+ },
+ contentType = "application/postscript"
+ )
+
+ ## *** Download PDF file ***
+ output$downloadPlotPDF <- downloadHandler(
+ filename = function() {
+ "Boxplot.pdf"
+ },
+ content = function(file) {
+ pdf(file, width = input$myWidth / 72, height = input$myHeight / 72)
+ generate_box_plot(data_m())
+ dev.off()
+ },
+ contentType = "application/pdf"
+ )
+
+ ## *** Download SVG file ***
+ output$downloadPlotSVG <- downloadHandler(
+ filename = function() {
+ "Boxplot.svg"
+ },
+ content = function(file) {
+ svg(file, width = input$myWidth / 72, height = input$myHeight / 72)
+ generate_box_plot(data_m())
+ dev.off()
+ },
+ contentType = "image/svg"
+ )
+
+ # *** Output boxplot statistics in table below plot ***
+ output$boxplotStatsTable <- renderTable(
+ {
+ if (is.null(data_m()) || is.null(boxplot_stats())) {
+ return(NULL)
+ }
+ get_stats_matrix(data_m(), boxplot_stats(), input$addMeans)
+ },
+ rownames = TRUE
+ )
+
+ # *** Print figure legend ***
+ output$FigureLegend <- renderPrint({
+ if (is.null(data_m()) || is.null(boxplot_stats())) {
+ return(invisible())
+ }
+ fl <- generate_figure_legend(
+ stats = boxplot_stats(),
+ plot_type = input$plotType,
+ other_plot_type = input$otherPlotType,
+ whisker_type = input$whiskerType,
+ add_means = input$addMeans,
+ add_mean_ci = input$addMeanCI,
+ mean_ci_val = input$meanCI,
+ my_varwidth = input$myVarwidth,
+ bean_plot_center_type = input$beanPlotMedianMean
+ )
+ cat(fl, "\n")
+ })
+
+
+ # *** Download boxplot data in csv format ***
+ output$downloadBoxplotData <- downloadHandler(
+ filename = function() {
+ "BoxplotData.csv"
+ },
+ content = function(file) {
+ write.csv(data_m(), file, row.names = FALSE)
+ }
+ )
+})
diff --git a/tests/test_ggplot_boxplot.R b/tests/test_ggplot_boxplot.R
new file mode 100644
index 0000000..d61d65c
--- /dev/null
+++ b/tests/test_ggplot_boxplot.R
@@ -0,0 +1,123 @@
+library(testthat)
+library(ggplot2)
+
+context("BoxPlotR ggplot2 Boxplot Tests")
+
+test_that("ggplot2 boxplot rendering works with notches and overlays", {
+ # Mock dataset
+ plot_data <- list(
+ Sample1 = c(1, 2, 3, 4, 5, 6, 7, 8, 9, 10),
+ Sample2 = c(5, 5, 6, 7, 8, 8, 9, 10, 11, 12)
+ )
+
+ # Calculate boxplot stats using our custom myboxplot.stats
+ source("../boxplot_stats_Function.R")
+
+ bp_stats <- boxplot(plot_data, na.rm = TRUE, range = 1.5, plot = FALSE)
+
+ df_stats <- data.frame(
+ Group = factor(bp_stats$names, levels = names(plot_data)),
+ ymin = bp_stats$stats[1, ],
+ lower = bp_stats$stats[2, ],
+ middle = bp_stats$stats[3, ],
+ upper = bp_stats$stats[4, ],
+ ymax = bp_stats$stats[5, ],
+ notchlower = bp_stats$conf[1, ],
+ notchupper = bp_stats$conf[2, ],
+ fill = bp_stats$names
+ )
+
+ df_long <- data.frame(
+ Value = unlist(plot_data, use.names = FALSE),
+ Group = rep(names(plot_data), each = 10)
+ )
+
+ # Verify that building the ggplot with notches works
+ test_notches <- tryCatch({
+ p <- ggplot(df_stats, aes(x = Group, fill = Group)) +
+ suppressWarnings(geom_boxplot(
+ aes(ymin = ymin, lower = lower, middle = middle, upper = upper, ymax = ymax,
+ notchlower = notchlower, notchupper = notchupper),
+ stat = "identity",
+ varwidth = FALSE,
+ notch = TRUE,
+ width = 0.6
+ ))
+ ggplot_build(p)
+ TRUE
+ }, error = function(e) {
+ FALSE
+ })
+ expect_true(test_notches)
+
+ # Verify that building with data points overlay works
+ test_data_points <- tryCatch({
+ p <- ggplot(df_stats, aes(x = Group, fill = Group)) +
+ suppressWarnings(geom_boxplot(
+ aes(ymin = ymin, lower = lower, middle = middle, upper = upper, ymax = ymax,
+ notchlower = notchlower, notchupper = notchupper),
+ stat = "identity",
+ varwidth = FALSE,
+ notch = TRUE,
+ width = 0.6
+ )) +
+ geom_jitter(
+ data = df_long,
+ mapping = aes(y = Value),
+ width = 0.05, height = 0,
+ color = "black", size = 1, alpha = 0.5
+ )
+ ggplot_build(p)
+ TRUE
+ }, error = function(e) {
+ FALSE
+ })
+ expect_true(test_data_points)
+
+ # Verify that building with means and CI overlays works
+ test_means_ci <- tryCatch({
+ ci_fun <- function(x) {
+ n <- sum(!is.na(x))
+ if (n <= 1) return(c(ymin = NA, ymax = NA))
+ se <- sd(x, na.rm = TRUE) / sqrt(n)
+ t_val <- qt(0.975, df = n - 1)
+ me <- t_val * se
+ m <- mean(x, na.rm = TRUE)
+ c(ymin = m - me, ymax = m + me)
+ }
+ p <- ggplot(df_stats, aes(x = Group, fill = Group)) +
+ suppressWarnings(geom_boxplot(
+ aes(ymin = ymin, lower = lower, middle = middle, upper = upper, ymax = ymax,
+ notchlower = notchlower, notchupper = notchupper),
+ stat = "identity",
+ varwidth = FALSE,
+ notch = TRUE,
+ width = 0.6
+ )) +
+ stat_summary(
+ data = df_long,
+ aes(x = Group, y = Value),
+ fun = mean,
+ geom = "point",
+ shape = 18,
+ size = 4,
+ color = "red",
+ inherit.aes = FALSE
+ ) +
+ stat_summary(
+ data = df_long,
+ aes(x = Group, y = Value),
+ fun.data = ci_fun,
+ geom = "errorbar",
+ width = 0.2,
+ color = "red",
+ linewidth = 0.8,
+ inherit.aes = FALSE
+ )
+ ggplot_build(p)
+ TRUE
+ }, error = function(e) {
+ FALSE
+ })
+ expect_true(test_means_ci)
+})
diff --git a/tests/test_mcp_container.py b/tests/test_mcp_container.py
new file mode 100644
index 0000000..8fa6045
--- /dev/null
+++ b/tests/test_mcp_container.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Exercise real HTTP MCP responses, including embedded vector attachments."""
+import argparse
+import base64
+import json
+import urllib.request
+import xml.etree.ElementTree as ET
+
+
+def run(mcp_url, health_url, engines):
+ def post(payload):
+ request = urllib.request.Request(
+ mcp_url, data=json.dumps(payload).encode(),
+ headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream"},
+ method="POST",
+ )
+ with urllib.request.urlopen(request, timeout=130) as response:
+ return json.load(response)
+
+ with urllib.request.urlopen(health_url, timeout=5) as response:
+ health = json.load(response)
+ assert health["status"] == "ok"
+ assert health["max_concurrent"] == 10
+ assert health["daily_limit"] == 20
+ assert health["max_dataset_bytes"] == 5 * 1024 * 1024
+ assert health["authentication"] == "optional"
+
+ initialized = post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
+ "protocolVersion": "2025-06-18", "capabilities": {},
+ "clientInfo": {"name": "boxplotr-contract-test", "version": "1"},
+ }})
+ assert initialized["result"]["serverInfo"]["name"] == "BoxPlotR"
+ tools = post({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})["result"]["tools"]
+ schema = next(t for t in tools if t["name"] == "generate_boxplot")["inputSchema"]
+ assert "output_format" in schema["properties"]
+ assert "output_path" not in schema["properties"]
+
+ for engine in engines:
+ for separator in (",", "\t"):
+ values = "\n".join(separator.join(row) for row in [
+ ["Control", "Treatment"], ["1", "2"], ["2", "4"], ["3", "5"], ["100", "6"],
+ ])
+ for extension, mime in [("png", "image/png"), ("svg", "image/svg+xml"), ("pdf", "application/pdf")]:
+ result = post({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {
+ "name": "generate_boxplot", "arguments": {
+ "values": values, "plot_type": "boxplot", "plot_engine": engine,
+ "style_guide": "nature", "colors": ["#2563EB", "#16A34A"],
+ "title": 'MCP test: "CSV and TSV"', "show_points": True,
+ "add_means": True, "output_format": extension,
+ },
+ }})["result"]
+ assert not result.get("isError"), result
+ content = result["content"]
+ expected_type = "image" if extension == "png" else "resource"
+ assert [item["type"] for item in content] == ["text", expected_type], content
+ if extension == "png":
+ attachment = content[1]
+ data = base64.b64decode(attachment["data"], validate=True)
+ assert data.startswith(b"\x89PNG\r\n\x1a\n")
+ else:
+ attachment = content[1]["resource"]
+ assert attachment["uri"].endswith("." + extension)
+ data = base64.b64decode(attachment["blob"], validate=True)
+ if extension == "svg":
+ assert ET.fromstring(data).tag == "{http://www.w3.org/2000/svg}svg"
+ else:
+ assert data.startswith(b"%PDF") and b"%%EOF" in data[-1024:]
+ assert attachment["mimeType"] == mime
+ assert len(data) > 100
+ print(f"PASS {engine} {'TSV' if separator == chr(9) else 'CSV'} {extension}: {expected_type} ({len(data)} bytes)", flush=True)
+
+ rejected = post({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {
+ "name": "generate_boxplot", "arguments": {"values": "A,B\n1,2", "colors": ['red"); system("id"); #']},
+ }})
+ assert rejected["result"]["isError"] is True
+ assert "hexadecimal CSS colours" in rejected["result"]["content"][0]["text"]
+ print("PASS health, tool schema, initialization, and invalid-input rejection")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("base_url", nargs="?", default="http://127.0.0.1:8765")
+ parser.add_argument("--mcp-url")
+ parser.add_argument("--health-url")
+ parser.add_argument("--engines", nargs="+", choices=["classic", "ggplot2"], default=["classic", "ggplot2"])
+ args = parser.parse_args()
+ run(args.mcp_url or args.base_url.rstrip("/") + "/mcp/",
+ args.health_url or args.base_url.rstrip("/") + "/health", args.engines)
diff --git a/tests/test_mcp_regressions.py b/tests/test_mcp_regressions.py
new file mode 100644
index 0000000..a82e23f
--- /dev/null
+++ b/tests/test_mcp_regressions.py
@@ -0,0 +1,77 @@
+"""Regression checks for the consolidated stdio plotting worker."""
+import importlib.util
+import json
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("boxplotr_worker", ROOT / "boxplotr_mcp_server.py")
+worker = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(worker)
+
+
+class MCPRegressionTests(unittest.TestCase):
+ def test_tool_discovery(self):
+ result = subprocess.run(
+ [sys.executable, str(ROOT / "boxplotr_mcp_server.py")],
+ input=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + "\n",
+ capture_output=True, text=True, check=True,
+ )
+ self.assertEqual(json.loads(result.stdout)["result"]["tools"][0]["name"], "generate_boxplot")
+
+ @unittest.skipUnless(shutil.which("Rscript"), "Rscript is required")
+ def test_both_render_paths_use_tukey_whiskers(self):
+ scripts = []
+
+ def capture(command, **kwargs):
+ scripts.append(Path(command[1]).read_text())
+ return subprocess.CompletedProcess(command, 0, "", "")
+
+ with tempfile.TemporaryDirectory() as directory:
+ with patch.object(worker.subprocess, "run", side_effect=capture):
+ worker.generate_plot({"data": "A\n1\n2", "output_path": str(Path(directory) / "test.png")})
+ coefficients = re.findall(r"range = (-?[0-9.]+)", scripts[0])
+ self.assertEqual(len(coefficients), 2)
+ # Evaluate each actual template coefficient against the standard R
+ # Tukey result, including a point that must be classified as an outlier.
+ for coefficient in coefficients:
+ code = (
+ f'source({json.dumps(str(ROOT / "boxplot_stats_Function.R"))}); '
+ f'x <- c(1:10, 100); actual <- myboxplot.stats(x, coef={coefficient}); '
+ 'expected <- grDevices::boxplot.stats(x); '
+ 'stopifnot(isTRUE(all.equal(unname(actual$stats), expected$stats)), '
+ 'identical(actual$out, expected$out))'
+ )
+ subprocess.run(["Rscript", "-e", code], check=True, capture_output=True, text=True)
+
+ @unittest.skipUnless(shutil.which("Rscript"), "Rscript is required")
+ def test_render_formats_in_both_engines(self):
+ available = subprocess.run(
+ ["Rscript", "-e", 'p <- c("beeswarm","vioplot","beanplot","sm","ggplot2","RColorBrewer"); quit(status=if(all(vapply(p,requireNamespace,logical(1),quietly=TRUE))) 0 else 1)'],
+ capture_output=True,
+ )
+ if available.returncode:
+ self.skipTest("R plotting packages are not installed")
+ signatures = {"png": b"\x89PNG\r\n\x1a\n", "pdf": b"%PDF", "svg": b"Data in delimited text files can be separated by comma, tab or semicolon.
- For example, Excel data can be exported in .csv (comma separated) or .tab (tab separated) format.
')
- ),
- conditionalPanel(condition="input.dataInput=='3'",
- h5("Paste data below:"),
- tags$textarea(id="myData", rows=10, cols=5, ""),
- actionButton('clearText_button','Clear data'),
- radioButtons("fileSepP", "Separator:", list("Comma"=1,"Tab"=2,"Semicolon"=3))
- )
- ),
- conditionalPanel(condition="input.tabs1=='Data visualization'",
-
- radioButtons("plotType", "", list("Boxplot"=0, "Other"=1)),
- conditionalPanel(condition="input.plotType=='1'",
- radioButtons("otherPlotType", "", list("Violin plot"=0, "Bean plot"=1)),
- textInput("myOtherPlotColours", "Colour(s):", value=c("light grey, white")),
- conditionalPanel(condition="input.otherPlotType=='0'",
- helpText("Colour of the 'violin area'"),
- textInput("violinBorder", "Border colour:", value=c("grey"))
- ),
- conditionalPanel(condition="input.otherPlotType=='1'",
- helpText("up to 4 colours can be specified: area of the beans, lines inside the bean, lines outside the bean, and average line per bean"),
- textInput("beanBorder", "Border colour:", value=c("grey"))
- )
- ),
-
- h4("Plot options"),
- checkboxInput("plotDataPoints", "Minimum number of data points", FALSE),
- conditionalPanel(condition="input.plotDataPoints",
- numericInput("nrOfDataPoints", "Data point limit: ", value=5, min=5)
- ),
-
- conditionalPanel(condition="input.plotType=='0'",
- checkboxInput("showDataPoints", "Add data points", FALSE),
- conditionalPanel(condition="input.showDataPoints",
- radioButtons("datapointType", "", list("Default"=0, "Bee swarm"=1))
- ),
- checkboxInput("whiskerDefinition", "Definition of whisker extent", FALSE),
- conditionalPanel(condition="input.whiskerDefinition",
- radioButtons("whiskerType", "", list("Tukey"=0, "Spear"=1, "Altman"=2)),
-# conditionalPanel(condition="input.whiskerType=='0'",
-# numericInput("TukeyRange", "Define whisker extent (x IQR):", min=0, step=0.5, value=1.5)
-# ),
-# conditionalPanel(condition="input.whiskerType=='1'",
-# HTML('
Spear - Whiskers extend to minimum and maximum values.
')
-# ),
-# conditionalPanel(condition="input.whiskerType=='2'",
-# numericInput("AltmanRange", "Define whisker extent in percentiles (ie, '5' means that whiskers extend to 5th and 95th percentile):", min=0, step=0.5, value=5)
-# ),
- HTML('
Tukey - whiskers extend to data points that are less than 1.5 x IQR away from 1st/3rd quartile;
- Spear - whiskers extend to minimum and maximum values;
- Altman - whiskers extend to 5th and 95th percentile (use only if n>40)
')
- ),
- checkboxInput("showNrOfPoints", "Display number of data points", FALSE),
- checkboxInput("addMeans", "Add sample means", FALSE),
- conditionalPanel(condition="input.addMeans",
- checkboxInput("addMeanCI", "Add confidence intervals of means", FALSE),
- conditionalPanel(condition="input.addMeanCI",
- radioButtons("meanCI", "Define confidence interval of means:", list("83%"=83, "90%"=90, "95%"=95))
- )
- ),
-
- checkboxInput("myVarwidth", "Variable width boxes", FALSE),
- helpText("Widths of boxes are proportional to square-roots of the number of observations."),
- checkboxInput("myNotch", "Add notches", FALSE),
- HTML('
+/-1.58*IQR/sqrt(n) - gives roughly 95% confidence that two medians differ (Chambers et al., 1983)
The notches are defined as +/-1.58*IQR/sqrt(n) and represent the 95% confidence interval for each median.
- Non-overlapping notches give roughly 95% confidence that two medians differ, ie, in 19 out of 20 cases the population
- medians (estimated based on the samples) are in fact different (Chambers et al., 1983).
This application allows users to generate customized box plots in a number of variants based on their data. A data matrix
- can be uploaded as a file or pasted into the application. Basic box plots are generated based on the data and can be modified to include
- additional information. Additional features become available when checking that option. Information about sample sizes can be represented
- by the width of each box where the widths are proportional to the square roots of the number of observations n. Notches can be added to the
- boxes. These are defined as +/-1.58*IQR/sqrt(n) which gives roughly 95% confidence that two medians are different. It is also possible to define
- the whiskers based on the ideas of Spear and Tukey. Additional options of data visualization (violin and bean plots) reveal more information
- about the underlying data distribution. Plots can be labeled, customized (colors, dimensions, orientation) and exported as eps, pdf and svg files.
'),
- h5("Software references"),
- HTML('
R Development Core Team. R: A Language and Environment for Statistical Computing. R Foundation for Statistical Computing, Vienna (2013)
- RStudio and Inc. shiny: Web Application Framework for R. R package version 0.5.0 (2013)
- Adler, D. vioplot: Violin plot. R package version 0.2 (2005)
- Eklund, A. beeswarm: The bee swarm plot, an alternative to stripchart. R package version 0.1.5 (2012)
- Kampstra, P. Beanplot: A Boxplot Alternative for Visual Comparison of Distributions. Journal of Statistical Software, Code Snippets 28(1). 1-9 (2008)
- Neuwirth, E. RColorBrewer: ColorBrewer palettes. R package version 1.0-5. (2011)
Supports .csv, .tab, .txt, .xls, and .xlsx. ",
+ "Data in delimited text files can be ",
+ "separated by comma, tab or semicolon. Excel files will be read ",
+ "automatically from the first sheet.
Tukey - whiskers extend to data ",
+ "points that are less than 1.5 x IQR away from 1st/3rd ",
+ "quartile",
+ "; Spear - whiskers extend to minimum and maximum values; ",
+ "Altman - whiskers extend to 5th and 95th percentile ",
+ "(use only if n>40)
The notches are defined as +/-1.58*IQR/sqrt(n) and ",
+ "represent the 95% confidence interval for each median. ",
+ "Non-overlapping notches give roughly 95% confidence that two ",
+ "medians differ, ie, in 19 out of 20 cases the population ",
+ "medians (estimated based on the samples) are in fact ",
+ "different (Chambers et al., 1983).
",
+ sep = ""
+ ))
+ ),
+ textInput("myColours", "Colour(s):", value = c("light grey, white")),
+ helpText(paste(
+ "Colours in HEX format can be chosen on ",
+ "http://colorbrewer2.org/",
+ sep = ""
+ ))
+ ),
+ checkboxInput("labelsTitle", "Modify labels and title", FALSE),
+ conditionalPanel(
+ condition = "input.labelsTitle",
+ checkboxInput("xaxisLabelAngle", "Rotate sample names", FALSE),
+ textInput("myXlab", "X-axis label:", value = c("")),
+ textInput("myYlab", "Y-axis label:", value = c("")),
+ textInput("myTitle", "Boxplot title:", value = c("")),
+ textInput("mySubtitle", "Boxplot subtitle:", value = c(""))
+ ),
+ checkboxInput("plotSize", "Adjust plot size", FALSE),
+ conditionalPanel(
+ condition = "input.plotSize",
+ numericInput("myHeight", "Plot height:", value = 550),
+ numericInput("myWidth", "Plot width:", value = 750)
+ ),
+ checkboxInput("fontSizes", "Change font sizes", FALSE),
+ conditionalPanel(
+ condition = "input.fontSizes",
+ numericInput("cexTitle", "Title font size:", value = 14),
+ numericInput("cexAxislabel", "Axis label size:", value = 14),
+ numericInput("cexAxis", "Axis font size:", value = 12)
+ ),
+ h5("Orientation of box plots:"),
+ radioButtons(
+ "myOrientation", "",
+ list("Vertical" = 0, "Horizontal" = 1)
+ ),
+ conditionalPanel(
+ condition = "input.myOrientation=='0'",
+ h5("Y-axis range (eg., '0,10'):"),
+ textInput("ylimit", "", value = "")
+ ),
+ conditionalPanel(
+ condition = "input.myOrientation=='1'",
+ h5("X-axis range (eg., '0,10'):"),
+ textInput("xlimit", "", value = "")
+ ),
+ checkboxInput(
+ "logScale", "Change to log scale (only for data >0)", FALSE
+ ),
+ h5("Add grid: "),
+ radioButtons(
+ "addGrid", "",
+ list("None" = 0, "X & Y" = 1, "X only" = 2, "Y only" = 3)
+ )
+ )
+ ),
+ mainPanel(
+ tabsetPanel(
+ tabPanel(
+ "About",
+ HTML(paste(
+ "
This application was developed with Nature Methods and ",
+ "you can find the publication here. ",
+ "The BoxPlotR has also been mentioned in this ",
+ "editorial and this ",
+ "blog entry. Nature methods also dedicated a Points of View and a Points of Significance",
+ " column to box plots. We hope that you find the BoxPlotR useful and we welcome suggestions for ",
+ "additional features by our users.
R Development Core Team. R: A Language and Environment for Statistical ",
+ "Computing. R Foundation for Statistical Computing, Vienna ",
+ "(2013) RStudio and Inc. shiny: Web Application Framework for R. R ",
+ "package version 0.5.0 (2013) Adler, D. vioplot",
+ ": Violin plot. R package version 0.2 (2005) Eklund, ",
+ "A. beeswarm: The bee swarm plot, an alternative ",
+ "to stripchart. R package version 0.1.5 (2012) Kampstra, ",
+ "P. Beanplot: A Boxplot Alternative for Visual ",
+ "Comparison of Distributions. Journal of Statistical ",
+ "Software, Code Snippets 28(1). 1-9 (2008) Neuwirth, E. ",
+ "RColorBrewer: ColorBrewer palettes. R ",
+ "package version 1.0-5. (2011)
",
+ sep = ""
+ )),
+ h6(paste(
+ "This application was created by the Tyers and Rappsilber labs. ",
+ "Please send bugs and feature requests to Michaela Spitzer ",
+ "(michaela.spitzer(at)gmail.com) and Jan Wildenhain ",
+ "(jan.wildenhain(at)gmail.com). This application uses the shiny ",
+ "package from RStudio.",
+ sep = ""
+ ))
+ ),
+ tabPanel(
+ "Data upload",
+ tableOutput("filetable"),
+ h6("This application was created by the Tyers and Rappsilber labs.")
+ ),
+ tabPanel(
+ "Data visualization",
+ div(
+ class = "controls-row",
+ downloadButton("downloadPlotEPS", "Download eps-file"),
+ downloadButton("downloadPlotPDF", "Download pdf-file"),
+ downloadButton("downloadPlotSVG", "Download svg-file")
+ ),
+ div(
+ class = "plot-card",
+ plotOutput("boxPlot", height = "100%", width = "100%")
+ ),
+ div(
+ class = "table-card",
+ h4("Box plot statistics"),
+ tableOutput("boxplotStatsTable")
+ ),
+ br(),
+ h6("This application was created by the Tyers and Rappsilber labs.")
+ ),
+ tabPanel(
+ "Figure legend template",
+ h5("Box plot description for figure legend:"),
+ textOutput("FigureLegend"),
+ h5("Further information to be added to the figure legend:"),
+ p("What do the box plots show, explain colours if used."),
+ downloadButton(
+ "downloadBoxplotData", "Download box plot data as .CSV file"
+ ),
+ h6("This application was created by the Tyers and Rappsilber labs.")
+ ),
+ tabPanel(
+ "News",
+ h5("September 13, 2026"),
+ HTML(paste(
+ "
Public BoxPlotR MCP launch: AI assistants can now connect directly to https://mcp.chemgrid.org/boxplotr/ without an account or API key. ",
+ "The live service was verified through a registered Codex client using the bundled scenarios. A new illustrated MCP guide shows custom colours, ",
+ "raw-point and mean overlays, violin geometry, logarithmic axes, journal styles and an original Economist Impact-inspired editorial example.
MCP output delivery update: The BoxPlotR MCP server now returns generated plot files directly in the JSON-RPC tool response, ",
+ "while still saving the file to output_path. PNG and SVG outputs are returned as MCP image content, ",
+ "and PDF outputs are returned as MCP resource content with a base64 payload. This makes BoxPlotR easier to use from AI assistants ",
+ "because the generated figure can be displayed inline without separately retrieving the saved file.
Introduced support for the Modern (ggplot2) rendering engine! ",
+ "Users can now seamlessly toggle between Classic (Base R) and Modern (ggplot2) plot rendering. ",
+ "Implemented stunning ggplot2 box plots, violin plots, and bean plots with real-time customized fill colors, ",
+ "alpha levels, jittered raw data point overlays, red sample means, and error bars showing confidence intervals.
Upgraded the application environment and Docker container configurations to fully support ",
+ "the latest R version 4.6.0 and Shiny version 1.13.0, ensuring long-term compatibility, stability, ",
+ "and security. In addition, the application's user interface has been fully modernized with a premium ",
+ "glassmorphic theme, responsive page layouts, customized form controls, and improved plot statistics tables.
The shiny server backend has been updated. The number of ",
+ "concurrent sessions has been limited to 15 and the session ",
+ "idle timeout set to 10 minutes. We are currently reworking ",
+ "the code to support the latest R and shiny versions.
There are several recent updates. The jitter of points is ",
+ "now consistent for all samples. When data points are added to ",
+ "the plot, the size can now be modified with sliders.
",
+ sep = ""
+ ))
+ ),
+ tabPanel(
+ "MCP API",
+ h4("Use BoxPlotR directly from an AI assistant"),
+ HTML(paste(
+ "
BoxPlotR provides a public Model Context Protocol service using Streamable HTTP. No account or API key is required.
",
+ "
",
+ "
Endpoint:https://mcp.chemgrid.org/boxplotr/
",
+ "
Tool:generate_boxplot
",
+ "
Authentication: None required.
",
+ "
Limits: 5 MiB per dataset, 20 plot generations per client IP per UTC day, and 10 simultaneous plot jobs.
",
+ "
Input: CSV or TSV text in values, with one numeric group per column.
",
+ "
Formats: Set output_format to png, svg or pdf. PNG is returned as an MCP image; SVG and PDF as embedded file resources.
Claude Desktop, Antigravity and other clients can use the same endpoint when configured for remote Streamable HTTP MCP. No authorization header is required.
The service records privacy-safe usage events. Datasets and raw client addresses are never sent to Google Analytics. Client addresses are immediately converted to one-way pseudonymous identifiers for quota enforcement and analytics. Analytics records the interface, plot options, processing time, dataset dimensions and success/failure.
",
+ sep = ""
+ ))
+ ),
+ tabPanel(
+ "FAQ",
+ h5("Q: I have trouble editing the graphic files."),
+ p(paste(
+ "A: For EPS files make sure to 'ungroup' all objects so they ",
+ "can be edited independently. In Adobe Illustrator you will ",
+ "also need to use the 'release compound path' command.",
+ sep = ""
+ )),
+ h5("Q: How do I install Docker, clone BoxPlotR from GitHub, and run it in a container?"),
+ HTML(paste(
+ "
A: Here is the step-by-step guide to installing Docker, pulling the repository from GitHub, and running BoxPlotR inside a container:
docker run -d -p 3838:3838 --name boxplotr-app boxplotr
",
+ "
Now you can open http://localhost:3838 in your browser to run the full glassmorphic web app!
",
+ "",
+ sep = ""
+ )),
+ h5("Q: Does BoxPlotR support integration with AI coding assistants (e.g. Claude Desktop, Cursor, Antigravity)?"),
+ HTML(paste(
+ "
A: Yes. Register the public Streamable HTTP endpoint with codex mcp add boxplotr --url https://mcp.chemgrid.org/boxplotr/, ",
+ "or enter the same URL in another remote-MCP client. No account, API key or SSH access is required. The generate_boxplot tool returns the generated figure directly in the MCP response. ",
+ "See the illustrated MCP guide for tested examples.
",
+ sep = ""
+ )),
+ h5("Q: Can I run the MCP server locally?"),
+ HTML("
A: Run boxplotr_mcp_server.py over standard I/O (stdio). The local tool supports both rendering engines and returns the generated figure in its MCP response while saving it to output_path.
"),
+ h5("Q: Which plot modifications can an AI assistant request?"),
+ HTML(paste(
+ "
A: The public tool supports box, violin and bean plots; ggplot2 and supported classic rendering; Nature, Science, Economist and Financial Times styles; ",
+ "vertical or horizontal orientation; linear or logarithmic axes; custom titles, axis labels and colours; raw-point overlays; mean markers for box plots; and PNG, SVG or PDF output. ",
+ "Requests are limited to 5 MiB and 20 plot generations per client IP per UTC day.
",
+ sep = ""
+ )),
+ h5("Q: Which options are available through the public MCP endpoint?"),
+ HTML("
A: The public tool accepts values, plot_type, plot_engine, style_guide, orientation, log_scale, title, x_label, y_label, colors, show_points, add_means and output_format. Use six-digit hexadecimal colours such as #2563eb. Notches, variable box widths, mean confidence intervals, subtitles, grid selection and point-style controls are local stdio options, not public tool parameters.
"),
+ h5("Q: What does the public MCP endpoint return?"),
+ HTML("
A: Set output_format to png, svg or pdf. The response contains a text summary plus a PNG image or an embedded SVG/PDF resource with a MIME type and base64 blob. Save the returned attachment in your client. The public tool chooses a temporary server path; it does not accept output_path.
"),
+ h5("Q: How do output formats work with the local stdio server?"),
+ HTML(paste(
+ "
A: Set the requested format by changing the file extension in output_path. Use .png for a raster image, ",
+ ".svg for an editable vector image, or .pdf for a publication-ready document. The MCP response includes a text summary plus the file payload: ",
+ "PNG and SVG are returned as MCP image content, while PDF is returned as MCP resource content with MIME type and base64 blob. ",
+ "The same file is also saved on disk at output_path for reproducibility.
",
+ sep = ""
+ )),
+ h5("Q: How can I run the local stdio MCP server inside a Shiny Docker container?"),
+ HTML(paste(
+ "
A: You can easily route MCP commands to run inside the active BoxPlotR Docker container. ",
+ "First, make sure the Dockerfile installs Python 3 (e.g., RUN apt-get update && apt-get install -y python3). ",
+ "Then, add the following configuration to your AI assistant's configuration file (e.g., claude_desktop_config.json) ",
+ "to execute the server via standard input/output redirection:
This maps standard stdio streams directly into the running R environment in the container without exposing ports!
",
+ sep = ""
+ )),
+ h5("Q: How can I test the MCP server locally with a JSON-RPC request?"),
+ HTML(paste(
+ "
A: You can test the stdio MCP server from your command line by piping a standard JSON-RPC 2.0 tools/call request directly into the Python script. Since the server operates over line-by-line stdio (readline()), the JSON-RPC request payload must be sent as a single line (no newlines within the JSON string itself). Here is a concrete JSON example using a wide-format dataset (where columns represent samples) to generate a ggplot2 box plot with the Economist style preset:
This command runs the Python server, triggers the R script dynamically, and outputs a JSON-RPC response with two content items: ",
+ "a text summary and the generated plot image payload. For PNG output, the second content item has type: image and mimeType: image/png. ",
+ "You can change assets/mcp_test_plot.png to assets/mcp_test_plot.svg or assets/mcp_test_plot.pdf to test vector outputs.
Create publication-ready plots from an AI assistant
+
The public BoxPlotR MCP server turns CSV or TSV data into box, violin and bean plots. It works without an account or API key and returns PNG, SVG or PDF output directly to the client.
+
+
+
+
Public endpointhttps://mcp.chemgrid.org/boxplotr/
+
5 MiBMaximum dataset per request
+
20 per dayPlot generations per client IP
+
10 concurrentPlot jobs across the service
+
+
+
Register the service
+
Codex users can register the remote Streamable HTTP endpoint with one command:
Then ask the assistant to call generate_boxplot. Claude Desktop, Antigravity and other remote-MCP clients can use the same URL. No authorization header is required.
+
+
Examples generated through the public MCP server
+
+ Points and means
Show the observations behind each summary
The bundled five-sample CSV uses custom colours, jittered raw points and red diamonds for sample means. This makes distribution spread and outliers visible alongside medians and quartiles.
Example promptUse the BoxPlotR MCP tool with the attached five-sample CSV. Create a vertical ggplot2 box plot titled “Five sample distributions”. Label the axes “Sample” and “Measured value”, use five distinct blue, cyan, teal, amber and red colours, show the individual jittered observations, add sample means, and return a PNG.
The bundled text scenario is rendered with the ggplot2 engine and Nature-style typography. Violin geometry exposes density while overlaid points retain the individual observations.
Example promptUse the BoxPlotR MCP tool with the attached three-sample CSV. Create a vertical violin plot with the ggplot2 engine and Nature style. Title it “Distribution shape by sample”, label the y-axis “Measured value”, colour the groups green, orange and purple, overlay the raw observations, omit mean markers, and return a PNG.
The supplied Excel example contains Baseline, Treated and Knockout measurements on very different scales. After a checked CSV conversion, logarithmic rendering keeps all three conditions legible.
Example promptRead the attached Excel example, use its Baseline, Treated and Knockout columns, and call the BoxPlotR MCP tool. Create a ggplot2 box plot using Science style and a logarithmic y-axis. Title it “Responses across measurement scales”, use blue, red and teal fills, add mean markers without raw points, and return a PNG.
This original demonstration follows the visual language of Figure 11a in the public Economist Impact LAC Infrascope 2021/22 report: a restrained background, direct labels and three strongly separated categories.
The values are illustrative, reconstructed from the published chart ranges, and are not the report's underlying dataset. Source: Economist Impact, Figure 11a.
Example promptUse the BoxPlotR MCP tool with the attached illustrative score data. Recreate the restrained editorial look of Economist Impact Figure 11a without copying the original figure. Make a vertical ggplot2 box plot in Economist style titled “Overall index score by risk-allocation score”. Label both axes clearly, colour Score 0 red, Score 50 grey and Score 100 blue, omit raw points and means, and return a PNG. State that the values are illustrative.
Available modifications include plot type, rendering engine, journal or editorial style, orientation, logarithmic scaling, title and axis labels, group colours, raw-point overlays, mean markers for box plots and output format.
+
+
Output attachments and supported options
+
Set output_format to png, svg or pdf. The public response contains a text summary plus a PNG image or an embedded SVG/PDF resource. Resources include uri, mimeType and a base64 blob; the complete file is embedded in the response. Save the attachment in your client.
+
The public tool accepts values, plot_type, plot_engine, style_guide, orientation, log_scale, title, x_label, y_label, colors, show_points, add_means and output_format. Use hexadecimal colours such as #2563eb. Styles are none, nature, science, economist and ft.
+
The separate local stdio server accepts output_path and additional controls for notches, variable widths, mean confidence intervals, subtitles, grids and point styling. These are not parameters of the public endpoint.
+
+
Privacy and fair use
+
Datasets and raw client addresses are not sent to Google Analytics. Client addresses are immediately converted to one-way pseudonymous identifiers for quota enforcement and usage reporting. Temporary generated files are eligible for deletion after one hour and cleaned up on subsequent plot requests.