Design · objective #353

bio.viz and gsm.bio

A second chart library and its R package, for comparing groups and relating variables in biomarker data, with the test result on the chart. It repeats nothing safety.viz already draws.

2 Oct 2026 Six core charts Scope: charts, statistics, export, specifications Not here: data loading, mapping, the app

Decisions

Agreed with @jwildfire in session on 2 Oct 2026 unless marked as a default. A default stands until someone objects on the objective.

D1 One rule divides the two libraries: safety.viz describes, bio.viz compares and relates. A chart whose main output is a test, an effect estimate or a relationship between two variables belongs in bio.viz. A chart that shows one measure's distribution or course stays in safety.viz and is reused untouched.
D2 R computes every test. Neither chart library holds any inference code. Agreed This replaces the question of which library p-values live in. They live in R, as plain functions in gsm.bio, and a chart shows them when R is attached. No statistical library is written in JavaScript.
D3 bio.viz runs beside safety.viz on the page and borrows its shared parts. Agreed safety.viz will be present for any study using this. So bio.viz does not copy the control sidebar, filters, record listing, participant rail or the Kaplan–Meier estimator; safety.viz exposes them as a kit and bio.viz calls them. That is one small, additive change in safety.viz, and the only one this design asks of it.
D4 Charts reach R through one connection with three forms: precomputed, in the browser, or on a server. Default In the browser is the default for the app, because it needs no server and data stays on the machine. Precomputed serves static reports. The server form waits until someone needs it. The second requirement measures what the browser form costs, and that measurement confirms or overturns this default.
D5 Six chart types are the core, including the biomarker screen. Agreed Five answer the standard exploratory questions one biomarker at a time. The screen answers them across every biomarker at once, which is otherwise done by writing one figure per biomarker. It pulls the hazard ratio forward, because its survival rows need one.
D6 Static figures and tables come from R, as ggplot twins that share the chart's settings. Same pattern as the FDA static figures planned for gsm.safety. The interactive chart also offers a PNG download so nobody waits for the R path.
D7 A specification is the chart's own settings saved as JSON. No expressions are ever evaluated. A specification is data, so loading one can never run code. A filter is a column, an operator and values, nothing more.
D9 Only the results table is required. Participant data is optional. Agreed With it you get filters and participant-level variables. Without it the charts still draw and the filters are simply absent. A group may come from a column on the results rows.
D8 Names: bio.viz and gsm.bio, matching safety.viz and gsm.safety. The dot keeps the family consistent.

Statistics engine

Before writing tests in JavaScript, the question was whether a framework already exists. One partly does, and it does not cover this design. So no inference is written in JavaScript at all.

What exists in JavaScript

LibraryHasMissing for this design
stdlibt-tests, one-way ANOVA, Kruskal–Wallis, chi-square, Pearson correlation test, p-value adjustment. Apache licence.Wilcoxon rank-sum, Fisher's exact, Spearman test, log-rank, Cox, Kaplan–Meier.
Anything for survivalNothing I could find.Log-rank and hazard ratios would be ours to write.

So adopting a library still leaves five tests to write by hand, including the hardest one, and every number still has to be reconciled with R's defaults.

Option 1

A JavaScript library

stdlib for what it covers, our own code for the rest.

For

  • Instant, no extra download.
  • Fits inside one offline HTML file.

Against

  • We still write and prove rank-sum, Fisher, Spearman, log-rank and Cox.
  • Every statistic exists twice: once here, once in R for the static figure.
  • A statistician has to trust our code, not R's.
Option 2 · recommended default

R in the browser

webR is R itself compiled to WebAssembly by Posit. It runs on the user's machine, off the main thread.

For

  • No inference code to write. The number printed is R's own.
  • The same functions make the static figure, so nothing exists twice.
  • Data never leaves the machine and no server is needed.
  • Works on plain static hosting such as GitHub Pages.

Against

  • Weight: the engine is about 12 MB and the full distribution about 49 MB unpacked, fetched as needed and cached.
  • The first test waits for R to start. I have not measured how long.
  • It will not fit inside the single offline HTML file. It ships as a folder beside the page, or is fetched.
  • The project says its interface may still change, and packages are rebuilt for each release.
Option 3

R on a server

gsm.bio installed on a server and called over the network. OpenCPU exposes any R package's functions this way with no extra code; plumber and Shiny are alternatives.

For

  • Lightest page and full R, any package.
  • Familiar wherever Shiny already runs.

Against

  • Needs hosting, and someone to keep it up.
  • Participant data travels to the server.
  • No offline use, and the app would need a server behind it.
Recommendation: R behind one connection
  • Statistics are plain R functions in gsm.bio that rely only on the stats and survival packages. They are the one source of every test.
  • A chart never computes a test. It hands over its one-row-per-participant table and receives estimates, intervals, p-values and counts.
  • Three forms of the connection run the same functions and give the same answers.
    • Precomputed: gsm.bio works out the opening view and ships the results with the widget. For static reports.
    • In the browser: webR, loaded the first time a test is asked for. The default for the app.
    • Server: built later, only if an organisation wants it.
  • With no R attached, a chart still draws and says that statistics are unavailable.
  • Start with the first two forms. The first release measures how long R takes to start and how much it downloads, and that measurement decides whether the default holds.
What this settles about p-values
  • They live in R, not in either chart library. The earlier three-way choice goes away.
  • The connection to R is built in bio.viz and handed to a chart as a setting. safety.viz's time-to-event chart can be handed the same connection to show the log-rank test the FDA figures require, without importing anything, and its static twin gets it from R as already planned.
  • The Kaplan–Meier estimate stays in safety.viz as it is. It is needed to draw the curve and is already checked against R.
  • The histogram's two approximate screens could later be swapped for R's real tests the same way. That is a separate decision.

Checked on 2 Oct 2026: webR 0.6.0, released May 2026, ships R 4.6.0; sizes read from the published package and its file headers; survival 3.8-6 is available prebuilt for it. Not checked: start-up time, and the size of survival with its dependencies. webR documentation · serving pages with webR · stdlib statistics

The boundary

Reused from safety.viz, not rebuilt

  • Keep Histogram, for one biomarker's distribution.
  • Keep Results over time, for box plots by visit, and the mean and median lines when they land there.
  • Keep Outlier explorer, for one line per participant.
  • Keep Shift plot and delta-delta, for one measure across two visits and change against change.
  • Keep Time to event, for curves by arm on a safety endpoint.
  • Keep Participant profile, as the drill-down from every bio.viz chart.

New in bio.viz

  • New Group comparison, with tests.
  • New Association scatter, with correlation.
  • New Correlation matrix.
  • New Cross-tabulation, with tests.
  • New Stratified survival, with biomarker cut-offs and a log-rank test.
  • New Biomarker screen, one row per biomarker.
Two places the line needs care
  • Scatter. Shift plot and delta-delta stay as they are. The association scatter takes any two variables and exists to report a correlation, so it does not replace either. The earlier suggestion to widen delta-delta is dropped.
  • Survival. bio.viz does not write a second Kaplan–Meier estimator. It calls safety.viz's, and adds what safety.viz lacks: a ready-made time and censor column as input, groups cut from a biomarker, and, from R, the log-rank test and a hazard ratio.

Core chart types

Six charts. The sketches show layout only; they are not drawn from data.

Kruskal–Wallis p = 0.012 n=24n=31n=22 Sketch

Group comparison

groupComparison · Widget_GroupComparison
Question
Does this biomarker differ between these groups?
Draws
One value across the levels of a category at chosen visits: box, violin or points, with the number in each group beneath.
Controls
Value and visit; category on the axis and its levels; second grouping by colour; panels by one further variable; log scale; test; pairwise comparisons on or off.
Statistics
Welch t-test or Wilcoxon for two groups; one-way ANOVA or Kruskal–Wallis for more; pairwise tests with Holm adjustment; difference in means with its interval.
Click
A box or point lists its participants and opens the profile.
Not overlap
Results over time always has visits on the axis and reports no test.
Spearman r = 0.62 (0.41, 0.77) biomarker A at baseline Sketch

Association scatter

associationScatter · Widget_AssociationScatter
Question
Do these two variables move together?
Draws
One point per participant; either axis can be a biomarker at a visit or any participant-level number.
Controls
X and Y variable; colour by group; panels; log scale per axis; fitted line (linear, smooth, identity) with band; correlation method.
Statistics
Pearson or Spearman coefficient with interval and p-value, overall and per group; slope and intercept of the linear fit.
Click
Brush a region to list participants; click a point for the profile.
Not overlap
Shift plot is one measure at two visits; delta-delta is change against change. Neither reports a correlation.
0.710.38−0.52 0.80−0.21 0.44 ABCD Sketch

Correlation matrix

correlationMatrix · Widget_CorrelationMatrix
Question
Which of these biomarkers, or which visits of one biomarker, are related?
Draws
A grid: marks sized and coloured by the coefficient on one side, the numbers on the other. For six variables or fewer, a small-multiple scatter mode.
Controls
Mode (across biomarkers at one visit, or across visits for one biomarker); which biomarkers or visits; value type; method; minimum pairs required to show a cell.
Statistics
Pairwise Pearson or Spearman on complete pairs, with the pair count and interval per cell. No p-values on the grid.
Click
A cell opens the association scatter for that pair.
Not overlap
Nothing like it in safety.viz.
lowhigh lowhigh resp.none 189 1221 Fisher p = 0.020 Sketch

Cross-tabulation

crossTab · Widget_CrossTab
Question
Is this category associated with that one?
Draws
A two-way table of counts with totals, beside stacked proportion bars of the same numbers.
Controls
Row and column variable; either can be a biomarker cut at the median, tertiles, quartiles or typed values; row or column percentages; test.
Statistics
Chi-square or Fisher's exact test, with a warning when expected counts are too small for chi-square.
Click
A cell lists its participants.
Not overlap
safety.viz has fixed count tables inside the QT, liver and kidney charts; none is general and none is tested.
log-rank p = 0.004 cut at median low n=41 · high n=40 Sketch

Stratified survival

stratifiedSurvival · Widget_StratifiedSurvival
Question
Do participants with high and low levels of this biomarker have different outcomes?
Draws
Kaplan–Meier curves per group with band, censor marks and at-risk strip, above a small histogram of the biomarker showing where the cut falls and how many land each side.
Controls
Endpoint; grouping by a biomarker cut (median, tertile, quartile, typed value, on raw, baseline, change or fold change at a chosen visit) or by any category; drag the cut line.
Statistics
Log-rank test; median survival with interval; participants and events per group; hazard ratio with interval when there are two groups.
Click
A curve or an at-risk cell lists its participants.
Not overlap
Uses safety.viz's estimator and drawing. safety.viz's own chart builds a safety endpoint from event records and groups by arm only.
IL-6CRPTNF-αIL-10IFN-γIL-2 Sketch

Biomarker screen

biomarkerScreen · Widget_BiomarkerScreen
Question
Across every biomarker, where is the signal?
Draws
One row per biomarker: an estimate and its interval for a comparison chosen once, sorted, with raw and adjusted p-values alongside. The estimate is unit-free so rows can be compared.
Controls
The comparison (group difference, correlation with one fixed variable, or survival split); visit; adjustment method; sort.
Statistics
Per biomarker: a standardised difference between two groups, a correlation coefficient, or a hazard ratio for high against low. The same tests as the single charts, with Benjamini–Hochberg or Holm adjustment across the rows.
Click
A row opens the matching single chart for that biomarker.
Not overlap
Nothing like it in safety.viz. The usual alternative is one figure per biomarker. This is the one place where adjustment for many tests has a clear meaning.

The shared core

All six charts are thin layers over the same four steps. Steps 1, 2 and 4 are bio.viz; step 3 is R.

1 · VariableName a variable once: a biomarker at a visit with a value type, or a participant-level column. Any axis, group or split takes one.
2 · FrameResolve the named variables to one row per participant. Rows that cannot be resolved are dropped and counted.
3 · StatisticThe frame goes to R, which returns estimates, intervals, p-values and the counts it used.
4 · DrawThe chart draws the frame and prints the statistic with its method and counts.
BioViz.groupComparison('#chart', {
  y:     { measure: 'IL6', visit: 'Week 4', value: 'change' },
  x:     { col: 'ARM' },
  panel: { measure: 'CRP', visit: 'Baseline', cut: 'median' },
  test:  'wilcoxon'
}).init({ results, participants });

Statistics

One thin R function per row in gsm.bio, each a wrapper that fixes the inputs and the shape of the answer around the R function named. Nothing here is reimplemented.

StatisticUsed byR function calledNote
Welch two-sample tGroup comparisont.test()Unequal variances, R's default.
Wilcoxon rank-sumGroup comparisonwilcox.test()R's defaults, including its switch between exact and approximate.
One-way ANOVAGroup comparisonaov()
Kruskal–WallisGroup comparisonkruskal.test()With tie correction.
Pearson correlationScatter, matrixcor.test()Interval by Fisher's z.
Spearman correlationScatter, matrixcor.test(method = "spearman")
Chi-squareCross-tabulationchisq.test()Continuity correction on two-by-two, R's default; small expected counts flagged.
Fisher's exactCross-tabulationfisher.test()Any table size R will accept.
Standardised differenceScreenA few lines of our own RUnit-free, with interval, so biomarkers on different scales share one axis. The one row not handed to an existing function, to avoid a heavy dependency; tested against effectsize::hedges_g().
Log-rankStratified survival, screensurvdiff()Two or more groups.
Median survival and intervalStratified survivalsurvfit()For the printed table. The curve itself is still drawn from safety.viz's estimate.
Holm, Benjamini–HochbergPairwise tests, screenp.adjust()
Cox hazard ratioStratified survival, screencoxph()Arrives with stratified survival, because the screen's survival rows need it.
How a p-value is shown
  • These rules are written once and one shared function applies them, whichever chart prints the number.
  • Always with the method's name and the counts it used, never alone.
  • Recomputed on whatever the filters leave, with the filter stated in the footnote.
  • Labelled exploratory and unadjusted by default. Adjustment is offered only where the family of tests is visible: pairwise comparisons inside one chart, and rows of the screen.
  • No stars and no word “significant”.
  • Not computed below a minimum group size; the chart says why instead of printing a number.

Data the charts expect

One required table and two optional ones, each with column names supplied as settings and ADaM names as defaults, so the basic app's mapping page can drive them the same way it drives safety.viz.

TableRequiredOne row perNeedsWhat it adds
resultsYesparticipant, biomarker, visitparticipant, measure, value, visit, visit orderEvery chart except stratified survival runs on this alone.
participantsNoparticipantparticipant; any categories and numbersFilters, and participant-level variables for groups, panels and axes.
outcomesNoparticipant, endpointparticipant, endpoint, time, censor flagStratified survival, and survival rows in the screen.

Repositories

safety.viz existing · one change Exposes its shared parts as a kit: control sidebar, filters, axis limits, record listing, participant rail, box drawing, Kaplan–Meier estimator, and the copy of Chart.js it already carries.
bio.viz new · needs safety.viz on the page The connection to R and the p-value formatter, the six charts, the variable and frame core, PNG download, specification read and write. No statistics code. Same stack and conventions as safety.viz.
gsm.safety existing · unchanged Still carries the safety.viz bundle for R users.
gsm.bio new · imports gsm.safety The statistics functions, a widget per chart, a static ggplot twin per chart, table builders with RTF output, and a batch runner for saved specifications.

Getting results out

Order of work

Eleven requirements, one session and one release each. The first three do not depend on one another and can run in parallel; only the first touches safety.viz.

RequirementShips inWhy this order
1safety.viz's shared parts opened to a second library (#354)safety.viz 1.10.0One additive pull request. Merging it to dev is all bio.viz needs.
2R in the browser, measured (#363)bio.viz 0.1.0Settles the one unmeasured cost, with no wait on safety.viz.
3gsm.bio's statistics and the synthetic study (#364)gsm.bio 0.1.0Plain R that needs nothing else here, so it is proven first.
4Group comparison, the picture (#355)bio.viz 0.2.0Proves the kit and the core on the simplest chart. Needs 1 and the study from 3.
5Group comparison, the tests (#356)gsm.bio 0.2.0, bio.viz 0.3.0Joins the chart, the connection and the statistics. Needs 2, 3 and 4.
6Association scatter and correlation matrix (#357)bio.viz 0.4.0, gsm.bio 0.3.0One statistic, two charts, and the matrix drills into the scatter.
7Biomarker screen (#358)bio.viz 0.5.0, gsm.bio 0.4.0The way in: find the biomarker, then open its chart.
8Cross-tabulation and the shared cut rule (#359)bio.viz 0.6.0, gsm.bio 0.5.0The cut rule is needed by the next one.
9Stratified survival, and survival rows in the screen (#360)bio.viz 0.7.0, gsm.bio 0.6.0Depends on the cut rule and on the estimator from the kit.
10Results out of the browser (#361)bio.viz 0.8.0Export is cheaper once the charts have stopped moving.
11Results out of R (#362)gsm.bio 0.7.0The static twins copy settings that are by then stable.

The first seven are the smallest useful product. Version numbers after the first three are proposed and fixed when each requirement is prepped.

Risks and open points

Sources: clinical-priorities decision, 21 Aug · basic app design · FDA static figures phase 1 · time-to-event phase 1