From fe39761cc8ccde300f23a71012dc63372ce4be56 Mon Sep 17 00:00:00 2001
From: Aaron Lisman
Date: Tue, 20 Oct 2026 19:15:50 +0000
Subject: [PATCH 01/10] Plot: show one point layer alone, from a row of buttons
above the figure
Each point layer is now its own group in the SVG, , with the plotted
cohort as data-layer="base". A layer's cases that an earlier layer drew (the overlap
rule) travel as `shared` and are drawn as a second group with display="none". The
figure for MCP and exports looks exactly as before.
On the page, PlotControls shows a "show" zone above the figure: one chip per drawn
layer, in its colour and with its count. Clicking a chip sets data-solo on the figure's
wrapper, and one CSS rule steps every other group back to 8% opacity while revealing
that layer's shared group. So a layer shown alone is complete, e.g. KRAS G12C's 480
samples, not only the 414 it drew itself. Clicking the chip again or pressing Esc
restores every layer. It is a reading gesture kept in browser state like a hover
(invariant 9): it re-runs nothing and does not change the document.
Verified on msk_chord_2024 TP53-mutant samples with [outliers, extremes x 20, KRAS
G12C]: soloing KRAS shows its 414 drawn and 66 shared points at full opacity, the
other groups at 0.08, and Esc restores them. A figure test checks the groups and the
hidden shared group; an e2e check clicks a chip, checks the opacities, presses Esc and
checks the document is untouched.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
---
app/globals.css | 11 +++++++
app/lab/plot-controls.tsx | 37 +++++++++++++++++-----
docs/plot-case-level.md | 17 +++++-----
e2e.cjs | 19 +++++++++++
lib/figures/plot.ts | 66 +++++++++++++++++++++++++--------------
lib/plot.ts | 2 ++
lib/views/plot.ts | 2 ++
test/view-svg.test.ts | 12 +++++++
8 files changed, 129 insertions(+), 37 deletions(-)
diff --git a/app/globals.css b/app/globals.css
index d6c5584..493a37b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1235,6 +1235,17 @@ details.cohortpick .gearmenu-items { min-width: 22rem; max-width: 30rem; gap: 0;
}
.menu-aside > .meta { display: block; }
+/* a plot's point layers: one shown alone, the rest stepped back, and the cases it shares with an
+ earlier layer revealed (app/lab/plot-controls.tsx; the groups are lib/figures/plot.ts's) */
+.plot-figure[data-solo] g[data-layer] { opacity: 0.08; }
+.plot-figure[data-solo="0"] g[data-layer="0"],
+.plot-figure[data-solo="1"] g[data-layer="1"],
+.plot-figure[data-solo="2"] g[data-layer="2"],
+.plot-figure[data-solo="3"] g[data-layer="3"] { opacity: 1; }
+.plot-figure[data-solo="0"] g[data-layer="0"][data-shared],
+.plot-figure[data-solo="1"] g[data-layer="1"][data-shared],
+.plot-figure[data-solo="2"] g[data-layer="2"][data-shared],
+.plot-figure[data-solo="3"] g[data-layer="3"][data-shared] { display: inline; }
/* a plot's axis controls: source, then whatever that source needs */
.axispick { display: inline-flex; align-items: center; gap: var(--s1); }
diff --git a/app/lab/plot-controls.tsx b/app/lab/plot-controls.tsx
index ec47ec6..408e84f 100644
--- a/app/lab/plot-controls.tsx
+++ b/app/lab/plot-controls.tsx
@@ -12,7 +12,7 @@ import { Checkbox } from "../_ui/checkbox";
import { Choice, OptionSelect } from "../_ui/choice.tsx";
import { Input } from "../_ui/input";
import { cn } from "../../lib/cn";
-import { FIELD } from "../_ui/toolbar.ts";
+import { CHIP, FIELD } from "../_ui/toolbar.ts";
import { DEFAULT_POINT_LAYERS, POINTS_CAP, LAYER_COLORS, POINT_LAYER_CAP, POINT_RULE_N, POINT_RULE_N_MAX, layerLabel, type PointLayerSpec } from "../../lib/plot-layers.ts";
type Update = (fn: (view: any) => any) => void;
@@ -113,8 +113,9 @@ function PointLayers({ layers, plot, cohortOptions, axisName, busy, onChange }:
export function PlotControls({ spec, plot, attrs, profiles, loading, busy, cohortOptions, update, children }: {
/** the view as the document holds it */
spec: any;
- /** the last result's plot: which shape is on screen, each axis's name and kind, how many cases it shows */
- plot?: { shape: string; n: number; x: { kind: string; label: string }; y: { kind: string; label: string }; coverage?: { total: number } };
+ /** the last result's plot: which shape is on screen, each axis's name and kind, how many cases it shows, its layers */
+ plot?: { shape: string; n: number; x: { kind: string; label: string }; y: { kind: string; label: string }; coverage?: { total: number };
+ layers?: { label: string; color: string; n: number; shown: string }[] };
attrs: { attribute: string; kind: string }[]; profiles: string[]; loading?: boolean; busy: boolean;
/** the cohorts a point layer may be: every one but the plotted one and a split */
cohortOptions: { name: string; label: string; depth: number }[];
@@ -123,6 +124,18 @@ export function PlotControls({ spec, plot, attrs, profiles, loading, busy, cohor
children: React.ReactNode;
}) {
const layers: PointLayerSpec[] = Array.isArray(spec.points) ? spec.points : DEFAULT_POINT_LAYERS;
+ // One layer shown alone, the rest stepped back: a reading gesture, kept in the browser like a
+ // hover (invariant 9), and dropped when the layers drawn change.
+ const drawn = (plot?.layers || []).map((l, i) => ({ ...l, i })).filter((l) => l.shown !== "none");
+ const drawnKey = drawn.map((l) => l.i + ":" + l.label).join("|");
+ const [solo, setSolo] = useState(null);
+ useEffect(() => setSolo(null), [drawnKey]);
+ useEffect(() => {
+ if (solo == null) return;
+ const off = (e: KeyboardEvent) => { if (e.key === "Escape") setSolo(null); };
+ window.addEventListener("keydown", off);
+ return () => window.removeEventListener("keydown", off);
+ }, [solo]);
const axisName = (side: "x" | "y") => plot?.[side].label || side;
const unit = spec.unit || "sample";
// A box is drawn with its categories across, whichever axis the document holds them on, so a
@@ -184,8 +197,8 @@ export function PlotControls({ spec, plot, attrs, profiles, loading, busy, cohor
}
{/* colour and ring split a box's violin and points; a scatter's density and a contingency
have no per-case colour, so the controls appear only with a box on screen */}
- {plot?.shape === "box" &&
-
+ {(plot?.shape === "box" || drawn.length > 0) &&
+ {plot?.shape === "box" && <>
colour samples by update((vv) => { const n = { ...vv }; if (a) n.colorBy = a; else delete n.colorBy; return n; })} />
@@ -193,9 +206,19 @@ export function PlotControls({ spec, plot, attrs, profiles, loading, busy, cohor
diff --git a/docs/plot-case-level.md b/docs/plot-case-level.md
index bc2f106..9a36024 100644
--- a/docs/plot-case-level.md
+++ b/docs/plot-case-level.md
@@ -75,23 +75,26 @@ One commit per item; `npm test` and a focused check per item; the gate before ea
the figure beside it (`PlotControls`, shared by the lab card and the Results page).
3. **Done.** A point is a sample by default; patient mode combines samples by a stated rule.
4. **Done.** Point layers (above), replacing `highlight`, with the sidebar's Points section.
-5. **Measure the cap.** SVG draw time and payload at 5k, 10k, 20k and 50k points on msk_chord;
+5. **Done.** Show one layer alone: each layer is a group in the SVG tagged with its place in the
+ list (its cases an earlier layer drew are a hidden group of their own), and a row of buttons
+ above the figure steps the rest back with one CSS rule; Esc restores. Browser state only.
+6. **Measure the cap.** SVG draw time and payload at 5k, 10k, 20k and 50k points on msk_chord;
set `POINTS_CAP` from it.
-6. **Multiple genes.** An alteration axis whose OQL has several statements gets one category per
+7. **Multiple genes.** An alteration axis whose OQL has several statements gets one category per
statement (overlaps as their own category, never assigned arbitrarily), and commas separate
statements as well as `;` and newlines.
-7. **Colour by gene per case** (phase 2). `colorBy: { source: "gene", gene }`: fill from the case's
+8. **Colour by gene per case** (phase 2). `colorBy: { source: "gene", gene }`: fill from the case's
highest-priority mutation (oncoprint priority, driver/VUS by the pinned OncoKB), ring from
CNA/SV, "not profiled" its own class (panel coverage, reuse the oncoprint's). On scatters too;
the rare classes become a point layer over the cap. Check per-class counts against the portal's
Plots tab for one study and gene.
-8. **Interaction** (phase 3): one overlay over the SVG maps the cursor to the nearest point (a grid
+9. **Interaction** (phase 3): one overlay over the SVG maps the cursor to the nearest point (a grid
index over the drawn points); hover tooltip (sample id linking to the patient view, values,
- alterations), legend-click highlight, case-id / protein-change search whose matches come back
+ alterations), case-id / protein-change search whose matches come back
from the server as a layer.
-9. **Brush and drill-down** (phase 4): a region or a cell returns its cases as a layer; "N cases →
+10. **Brush and drill-down** (phase 4): a region or a cell returns its cases as a layer; "N cases →
study view" built from the full list on the server.
-10. **Sidebar parity** (phase 5): "same gene", Driver vs VUS and VAF axes, box category filter and
+11. **Sidebar parity** (phase 5): "same gene", Driver vs VUS and VAF axes, box category filter and
sort-by-median, regression line toggle, R² and the equation, connect samples from the same
patient.
diff --git a/e2e.cjs b/e2e.cjs
index f178f94..9da3c24 100644
--- a/e2e.cjs
+++ b/e2e.cjs
@@ -592,6 +592,25 @@ async function main() {
} catch (e) { layerWhat = String(e.message).split("\n")[0]; }
ok("a plot's point layers are listed in the sidebar, and adding one draws it with a key entry", layerOk, layerWhat);
+ // Showing one layer alone is instant and the browser's own: the others step back, Esc restores.
+ let soloWhat = "no layer buttons", soloOk = false;
+ try {
+ // every group, a layer's shared cases included: [layer, opacity, display]
+ const groups = () => page.evaluate(() => [...document.querySelectorAll(".plot-figure g[data-layer]")]
+ .map((g) => [g.getAttribute("data-layer"), getComputedStyle(g).opacity, getComputedStyle(g).display]));
+ const docBefore = await page.locator("#docjson").inputValue();
+ await page.locator(".plot-solo button").last().click();
+ const solo = await page.locator(".plot-figure").getAttribute("data-solo");
+ const alone = await groups();
+ await page.keyboard.press("Escape");
+ const back = await groups();
+ soloOk = solo != null && alone.some(([k]) => k === solo) &&
+ alone.every(([k, o, d]) => k === solo ? o === "1" && d !== "none" : o !== "1") &&
+ back.every(([, o]) => o === "1") && (await page.locator("#docjson").inputValue()) === docBefore;
+ soloWhat = "layer " + solo + " alone " + JSON.stringify(alone) + "; after Esc " + JSON.stringify(back) + "; document unchanged";
+ } catch (e) { soloWhat = String(e.message).split("\n")[0]; }
+ ok("clicking a layer's button shows it alone, Esc shows every layer, and the document is untouched", soloOk, soloWhat);
+
// The oncoprint's gear keeps ONLY the link out to cBioPortal; genes moved
// to a named button and a modal, so the gear is not where editing hides.
let gearSplit = "no oncoprint rendered", gearOk = false;
diff --git a/lib/figures/plot.ts b/lib/figures/plot.ts
index e49fd3a..63e114e 100644
--- a/lib/figures/plot.ts
+++ b/lib/figures/plot.ts
@@ -5,7 +5,7 @@
// picture draws for a hundred cases or four hundred thousand. The only cases drawn one by one are
// the points the engine chose (every case under the cap, the outliers over it), at most POINTS_CAP.
import type { ViewResult } from "../types.ts";
-import type { PlotData } from "../plot.ts";
+import type { HighlightPoint, PlotData } from "../plot.ts";
import { isAbsent, jitterFor } from "../plot-draw.ts";
import { LAYER_BASE } from "../plot-layers.ts";
import {
@@ -66,6 +66,13 @@ function layerItems(d: PlotData): LegendItem[] {
}
/** the layers drawn at all: with any, the plotted cohort steps back to a grey so their colours read */
const drawnLayers = (d: PlotData) => (d.layers || []).filter((l) => l.shown !== "none");
+/**
+ * One layer's marks as a group tagged with its place in the view's list, so a page can show that
+ * layer alone; `base` is the plotted cohort. A layer's cases an earlier layer drew are a second,
+ * hidden group the page reveals only when that layer stands alone.
+ */
+const layerGroup = (key: number | "base", marks: string[], shared = false) =>
+ marks.length ? `${marks.join("")}` : "";
function scatter(v: ViewResult, d: PlotData, W: number, p: Pal): string {
const { x0, x1, y0, y1 } = d.extent!, [gx, gy] = d.grid!;
@@ -100,17 +107,24 @@ function scatter(v: ViewResult, d: PlotData, W: number, p: Pal): string {
const shaded = d.pointSet?.rule === "all" && d.xyPoints?.length ? []
: cellMax ? d.bins!.filter((b) => b.n > cellMax) : d.bins!;
// under point layers the plotted cohort is a floor-raised grey, so its faintest cells still read
- for (const b of shaded) out.push(cell(b, base, hs.length ? 0.3 + 0.7 * shade(b.n) : shade(b.n)));
const own = d.xyPoints || [];
- for (const pt of own)
- out.push(``);
- // a cohort layer too large to draw as points is cells on the same grid, shaded by its own densest cell
- for (const h of hs) if (h.shown === "density" && h.bins) {
- const hMax = Math.max(1, ...h.bins.map((b) => b.n));
- for (const b of h.bins) out.push(cell(b, h.color, 0.3 + 0.7 * Math.log1p(b.n) / Math.log1p(hMax)));
+ out.push(layerGroup("base", [
+ ...shaded.map((b) => cell(b, base, hs.length ? 0.3 + 0.7 * shade(b.n) : shade(b.n))),
+ ...own.map((pt) => ``)]));
+ const dot = (pt: { x: number; y: number }, color: string) =>
+ ``;
+ for (const h of hs) {
+ const key = d.layers!.indexOf(h);
+ // a cohort layer too large to draw as points is cells on the same grid, shaded by its own densest cell
+ if (h.shown === "density" && h.bins) {
+ const hMax = Math.max(1, ...h.bins.map((b) => b.n));
+ out.push(layerGroup(key, h.bins.map((b) => cell(b, h.color, 0.3 + 0.7 * Math.log1p(b.n) / Math.log1p(hMax)))));
+ }
+ if (h.shown === "points") {
+ out.push(layerGroup(key, h.points!.flatMap((pt) => "x" in pt ? [dot(pt, h.color)] : [])));
+ out.push(layerGroup(key, (h.shared || []).flatMap((pt) => "x" in pt ? [dot(pt, h.color)] : []), true));
+ }
}
- for (const h of hs) if (h.shown === "points") for (const pt of h.points!) if ("x" in pt)
- out.push(``);
// the fitted line, over every case rather than over what is visible, clipped to the frame
if (st && st.slope != null && st.intercept != null && x1 > x0) {
const yAt = (xv: number) => st.intercept! + st.slope! * xv;
@@ -209,6 +223,8 @@ function box(v: ViewResult, d: PlotData, W0: number, p: Pal): string {
gs.forEach((g, i) => {
const cx = L + bw * (i + 0.5), half = Math.min(24, bw * 0.32);
+ // the plotted cohort's violin and points, as one group a page can step back from
+ const baseMarks: string[] = [];
if (d.overlay === "violin" && vio.has(g.g)) {
const cells = vio.get(g.g)!;
// a bin stands for a range of VALUES, mapped through the extent the bins were cut over
@@ -224,7 +240,7 @@ function box(v: ViewResult, d: PlotData, W0: number, p: Pal): string {
const yTop = ys(Math.min(cHi, vHi));
const step = Math.max(1.2, ys(Math.max(cLo, vLo)) - yTop);
if (!colorKey.length) {
- out.push(``);
+ baseMarks.push(``);
continue;
}
let x = cx - w;
@@ -233,7 +249,7 @@ function box(v: ViewResult, d: PlotData, W0: number, p: Pal): string {
for (const sg of segs) {
if (!sg.n) continue;
const seg = 2 * w * (sg.n / total);
- out.push(``);
+ baseMarks.push(``);
x += seg;
}
}
@@ -245,14 +261,15 @@ function box(v: ViewResult, d: PlotData, W0: number, p: Pal): string {
const ring = pt.r != null && !isAbsent(pt.r) ? ringOf(pt.r) : null;
if (pt.v > vhi || pt.v < vlo) {
ptOffScale++;
- out.push(edgeMark(cx + jitterFor(pt.id) * half * 0.9, pt.v > vhi, rBase, fill));
+ baseMarks.push(edgeMark(cx + jitterFor(pt.id) * half * 0.9, pt.v > vhi, rBase, fill));
continue;
}
- out.push(``);
}
}
+ out.push(layerGroup("base", baseMarks));
out.push(``);
out.push(`${esc(g.g)}: median ${fmtNum(g.median)}, ` +
@@ -265,26 +282,29 @@ function box(v: ViewResult, d: PlotData, W0: number, p: Pal): string {
const hg = h.groups!.find((x) => x.g === g.g);
if (!hg) return;
const hw = Math.max(1.5, half * 0.42), hx = cx + (j - (boxed.length - 1) / 2) * hw * 1.25;
- out.push(`` +
+ out.push(layerGroup(d.layers!.indexOf(h), [`` +
`` +
- ``);
+ ``]));
});
// A layer's value past the axis is pinned to the frame's edge as a triangle pointing off it, and
// counted in the caption, rather than drawn outside the figure. With the cases coloured by a
// category, a layer is a ring round the case, so fill and ring each keep one meaning.
for (const h of hs) if (h.shown === "points") {
const rH = PLOT_POINT_R;
- for (const pt of h.points!) if ("g" in pt && pt.g === g.g) {
+ const marks = (pts: HighlightPoint[], counted: boolean) => pts.flatMap((pt) => {
+ if (!("g" in pt) || pt.g !== g.g) return [];
const px = cx + jitterFor(pt.id) * half * 0.9;
if (pt.v > vhi || pt.v < vlo) {
- offScale++;
- out.push(edgeMark(px, pt.v > vhi, rH, h.color));
- continue;
+ if (counted) offScale++;
+ return [edgeMark(px, pt.v > vhi, rH, h.color)];
}
- out.push(categorised
+ return [categorised
? ``
- : ``);
- }
+ : ``];
+ });
+ const key = d.layers!.indexOf(h);
+ out.push(layerGroup(key, marks(h.points!, true)));
+ out.push(layerGroup(key, marks(h.shared || [], false), true));
}
});
out.push(``);
diff --git a/lib/plot.ts b/lib/plot.ts
index bf9e071..7bd7692 100644
--- a/lib/plot.ts
+++ b/lib/plot.ts
@@ -679,6 +679,8 @@ export interface PlotLayer {
n: number;
/** points drawn, once cases an earlier layer already drew are left to it */
drawn?: number;
+ /** the layer's cases an earlier layer drew: hidden on the figure, shown when the reader picks this layer alone */
+ shared?: HighlightPoint[];
shown: "points" | "density" | "boxes" | "none";
/** why it is not drawn as points, when it is not */
why?: string;
diff --git a/lib/views/plot.ts b/lib/views/plot.ts
index e74e756..98e6c96 100644
--- a/lib/views/plot.ts
+++ b/lib/views/plot.ts
@@ -136,8 +136,10 @@ async function pointLayers(pv: PlotView, data: PlotData, c: ViewRun, cn: string,
const { layer, keys } = picked;
if (layer.shown === "points" && layer.points && keys) {
const kept = layer.points.filter((_, j) => !seen.has(keys[j]));
+ const shared = layer.points.filter((_, j) => seen.has(keys[j]));
for (const k of keys) seen.add(k);
layer.drawn = kept.length; layer.points = kept;
+ if (shared.length) layer.shared = shared;
budget -= kept.length;
}
out.push(layer);
diff --git a/test/view-svg.test.ts b/test/view-svg.test.ts
index c960905..72dae3b 100644
--- a/test/view-svg.test.ts
+++ b/test/view-svg.test.ts
@@ -404,6 +404,18 @@ describe("plot point layers: subsets drawn over the plotted cohort", () => {
assert.match(svg, /1 value of a point layer lies more than a whisker span past the/);
});
+ test("each layer is a group tagged with its place in the list, its shared cases a hidden group of their own", () => {
+ const svg = viewToSvg(scatter([
+ hl({ points: [{ id: "a", x: 10, y: 5 }] }),
+ hl({ name: "S", color: LAYER_COLORS[1], points: [{ id: "b", x: 20, y: 8 }], shared: [{ id: "a", x: 10, y: 5 }] }),
+ ]))!;
+ assert.match(svg, //);
+ assert.match(svg, /]*fill="#0072B2"/);
+ assert.match(svg, / {
const v: ViewResult = { kind: "plot", plot: {
shape: "box", n: 3, x: { label: "G", kind: "cat" }, y: { label: "V", kind: "num" },
From d9447360804ce8265787e9088f142808107eb35b Mon Sep 17 00:00:00 2001
From: Aaron Lisman
Date: Tue, 20 Oct 2026 19:24:36 +0000
Subject: [PATCH 02/10] Plot: the points cap is 10,000, set from a measurement
The plot renderer drew a scatter and a box at 5k, 10k, 20k and 50k points, and headless
Chrome on the dev server inserted and painted each SVG (median of 3):
points paint scatter/box one hit-test JSON raw/gzip
5,000 84 / 117 ms 0.4 ms 220 / 35 KB
10,000 180 / 190 ms 1.7 ms 440 / 70 KB
20,000 352 / 337 ms 3.2 ms 880 / 140 KB
50,000 904 / 752 ms 7 ms 2.2 MB / 350 KB
Painting, not building the string, is the cost: about 18 ms per thousand points, paid
again on every redraw. 10,000 keeps a redraw near 200 ms on this CPU-only machine; 5,000
was inherited from the box plot's first version, not measured. POINTS_CAP moves to
lib/plot-layers.ts (browser-safe) so the sidebar's labels read it, and the DSL reference
and the controls print the constant instead of a typed "5,000". The measurement is
recorded in docs/plot-case-level.md.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
---
app/lab/plot-controls.tsx | 10 ++++++----
docs/dsl.md | 14 +++++++-------
docs/plot-case-level.md | 19 ++++++++++++++++---
lib/dsl-reference.ts | 20 ++++++++++++--------
lib/plot-layers.ts | 5 ++++-
lib/types.ts | 4 ++--
test/plot-layers.test.ts | 7 ++++---
test/plot-points.test.ts | 9 +++++----
8 files changed, 56 insertions(+), 32 deletions(-)
diff --git a/app/lab/plot-controls.tsx b/app/lab/plot-controls.tsx
index 408e84f..7625114 100644
--- a/app/lab/plot-controls.tsx
+++ b/app/lab/plot-controls.tsx
@@ -16,6 +16,8 @@ import { CHIP, FIELD } from "../_ui/toolbar.ts";
import { DEFAULT_POINT_LAYERS, POINTS_CAP, LAYER_COLORS, POINT_LAYER_CAP, POINT_RULE_N, POINT_RULE_N_MAX, layerLabel, type PointLayerSpec } from "../../lib/plot-layers.ts";
type Update = (fn: (view: any) => any) => void;
+/** the points cap as the controls print it */
+const CAP = POINTS_CAP.toLocaleString("en-US");
function LogToggle({ spec, side, numeric, busy, update }: { spec: any; side: "x" | "y"; numeric: boolean; busy: boolean; update: Update }) {
const axis = spec[side];
@@ -71,7 +73,7 @@ function PointLayers({ layers, plot, cohortOptions, axisName, busy, onChange }:
}) {
const has = (f: (l: PointLayerSpec) => boolean) => layers.some(f);
const offers: [string, string, PointLayerSpec][] = [];
- if (!has((l) => "rule" in l && l.rule === "outliers")) offers.push(["outliers", "outliers (past 5,000 cases)", { rule: "outliers" }]);
+ if (!has((l) => "rule" in l && l.rule === "outliers")) offers.push(["outliers", "outliers (past " + CAP + " cases)", { rule: "outliers" }]);
for (const side of ["x", "y"] as const)
if (plot?.[side].kind === "num" && !has((l) => "rule" in l && l.rule === "extremes" && l.axis === side))
offers.push(["extremes:" + side, "top and bottom " + POINT_RULE_N + " by " + axisName(side), { rule: "extremes", axis: side }]);
@@ -179,11 +181,11 @@ export function PlotControls({ spec, plot, attrs, profiles, loading, busy, cohor
Points update((vv) => ({ ...vv, points: next }))} />
diff --git a/docs/dsl.md b/docs/dsl.md
index 9f3efc2..320666c 100644
--- a/docs/dsl.md
+++ b/docs/dsl.md
@@ -275,7 +275,7 @@ patient — so the arm counts summing to more than the base cohort is expected,
| `logistic` | `outcome:axis, outcomeEvent?:"", covariates:[axis, ...(max 8)], cohorts:[names]` | multivariate logistic regression: a BINARY outcome (not time-to-event — no endpoint/months convention here) regressed on several covariates at once, reported as odds ratios with Wald confidence intervals, drawn as a forest plot the same shape as `cox`'s. `outcome` is a plot axis, exactly like a covariate (clinical, molecular, or alteration status). A categorical outcome (a clinical attribute, or an alteration axis) needs `outcomeEvent` naming which observed value counts as the event (1) — every other observed value collapses into "not event" (0), and which values got collapsed is disclosed in the caveat; omitting it, or naming a value never observed, is refused with the actual observed values listed rather than guessed at. A numeric outcome must already be coded exactly 0/1 (refused otherwise) and must NOT be given `outcomeEvent`. Covariates use the exact same dummy-coding and reference-level rules as `cox` (see above): a mutated-vs-not axis defaults to `"Not mutated"`, others default to the most frequent level, `reference` overrides either. Same patient-level-only boundary as `cox` |
| `course` | `eventTypes:[≤6], anchor?:{eventType,key?,value?,occurrence?}, binMonths?:3, fromMonths?:-12, toMonths?:60, cohorts:[names]` | the **clinical course**: per time bin, the share of the cohort in each event state. Two things it refuses to fake. (1) The denominator is the patients still under observation in that bin — first recorded event to last contact (the later of their last event and their OS follow-up) — never the whole cohort, which would make a curve fall merely because follow-up ran out. (2) `anchor` is optional for one study but **required across studies**: day 0 is birth in some studies and first sequencing in others, and nothing in the schema records which, so raw day 0 compares incomparable clocks. Event types that carry real durations are drawn as a state the patient is IN; the rest as an event that HAPPENED — read from the data, not from the name. Bins are half-open [t, t+w). Event-name spellings differing only in case are folded (`Treatment`/`TREATMENT`) and the fold is named in the caveat; nothing else is merged |
| `sequence` | `labelKey?:"SUBTYPE", eventType?:"treatment", lineClasses?:"systemic"|"all"|[classes], exclude?:[values], lineGap?:90, maxSteps?:3, topN?:8, cohorts:[names]` | **what follows what**: therapy transitions as lines, drawn as a Sankey. Records are first grouped into LINES — therapies less than `lineGap` days apart are one line, labelled by their whole combination (sorted, so `A + B` and `B + A` are one regimen). That grouping is the point: a patient has a median of 6 treatment records and 4 distinct agents, and combination therapy is several records describing ONE decision. Paths that end flow into TWO explicit terminals, never one: **no further line** (the study watched for at least one `lineGap` and saw no further therapy) and **record ends here** (it did not, so nothing can be concluded). Measured on MSK-CHORD, 68% of patients have under three months of observation after their last therapy and the median is 15 days — so collapsing the two would turn record-keeping into an apparent clinical decision. Flows out of a node sum to it, so nobody vanishes. Up to two cohorts are drawn as panels sharing one scale and one colour per regimen. Lines are the shared definition (see "Treatment line (derived)" above): systemic-therapy records by default, `lineClasses` `"all"` for every treatment record or a list of class names for your own; `labelKey` only names a line’s therapies (e.g. AGENT), it never decides which records form lines. `exclude` drops records by label value, matched ignoring case. More than two cohorts is refused |
-| `plot` | `x:axis, y:axis, unit?:"sample"\|"patient", bins?:[240,160], allPoints?:true, points?:[layer(max 4)], colorBy?:axis, ringBy?:axis, cohorts:[names]` | two axes over the same cases, aggregated **inside the database**: a scatter comes back as counts per grid cell, a box as quartiles per group, two categoricals as a contingency table. The payload is a few kilobytes whatever the cohort's size. A point is a sample, as on cBioPortal's Plots tab; `unit:"patient"` makes it a patient, combining a patient's samples: a numeric value is their mean, a category their common value or "Multiple" where they differ, an alteration counts if any sample has it, and the caption says how many patients had more than one sample. Every case is a point up to 5,000 (`allPoints: false` turns that off), from a participant that may show cases; `points` lists up to four subsets drawn in colour over it — `{rule:"outliers"}` (the default), `{rule:"extremes",axis,n}`, `{rule:"trend",n}`, `{cohort}` — see **Points** below |
+| `plot` | `x:axis, y:axis, unit?:"sample"\|"patient", bins?:[240,160], allPoints?:true, points?:[layer(max 4)], colorBy?:axis, ringBy?:axis, cohorts:[names]` | two axes over the same cases, aggregated **inside the database**: a scatter comes back as counts per grid cell, a box as quartiles per group, two categoricals as a contingency table. The payload is a few kilobytes whatever the cohort's size. A point is a sample, as on cBioPortal's Plots tab; `unit:"patient"` makes it a patient, combining a patient's samples: a numeric value is their mean, a category their common value or "Multiple" where they differ, an alteration counts if any sample has it, and the caption says how many patients had more than one sample. Every case is a point up to 10,000 (`allPoints: false` turns that off), from a participant that may show cases; `points` lists up to four subsets drawn in colour over it — `{rule:"outliers"}` (the default), `{rule:"extremes",axis,n}`, `{rule:"trend",n}`, `{cohort}` — see **Points** below |
| `attrition` | `cohorts:[names] (≤6)` | **cohort attrition**, CONSORT-style: for each cohort, the study base and then the patients left after each clause of its `all`, in the order written, with how many that clause alone removed. Each step is evaluated as its own cohort (the first k clauses ANDed), with the same compiler and the same per-study counts, so the last step is the cohort's own count by construction — a mismatch is reported, never smoothed over. The order of clauses changes the path, never the final count. Counts only, so every participant (aggregate-only and remote included) can answer; across studies the counts are summed and overlap is not deduplicated |
| `genome` | `binMb?:2, threshold?:0.2, includeX?:false, cohorts:[names] (≤4)` | **genome-wide copy-number frequency** from the copy-number SEGMENTS: the share of each cohort's segmented samples gained (log2 segment mean ≥ `threshold`) or lost (≤ −`threshold`) in every `binMb`-megabase bin (1–50) along chromosomes 1–22 (and X with `includeX` — its log ratio mostly records sex, so it is off by default), drawn as gains up and losses down, one panel per cohort. Each bin's denominator is the samples whose segmentation covers it — a bin nothing covers is drawn as unmeasured, never as copy-neutral. A bin takes the value of the segment over its midpoint, so an event narrower than a bin can be missed; the caption says so. Unit is the SAMPLE, not the patient. Exactly two cohorts add a per-bin two-sided Fisher test (gains and losses separately, Benjamini–Hochberg across bins), drawn as a strip coloured by which cohort is higher. Studies on different reference genomes (hg19/hg38) are refused rather than drawn on one axis. Counts only; a remote peer cannot answer yet and is named as left out |
| `pathways` | `pathways?:[names], sets?:[{name, genes:[≤40]} ×≤4], measure?:"driver"|"any", topGenes?:6, cohorts:[names] (≤4)` | **oncogenic signalling pathways**: the ten canonical pathways of Sanchez-Vega et al. (Cell 2018, Table S3: RTK-RAS, PI3K, TP53, Cell cycle, MYC, WNT, NOTCH, Hippo, TGF-β, NRF2 — 334 genes), and/or the document's own `sets`, each as the share of the cohort with at least one member altered. `measure` defaults to `"driver"` (OncoKB-annotated drivers, as the paper counted curated oncogenic events; refused without an annotation pin) or `"any"`. A pathway's denominator is the patients profiled for at least ONE member (the union of the members' panels, the oncoprint's merged-row rule, computed in the database with the same event table, CNA rule and driver join as `compare`, so a one-gene set equals that gene's own count); each member's % is over the patients profiled for THAT gene. The `topGenes` most-altered members are drawn per pathway; the rest are pooled as "+N more altered", and members on no participating panel are listed as never profiled. Two cohorts add Fisher tests with BH q across pathways, and separately across member genes. Counts only; a remote peer cannot answer yet and is named |
@@ -372,20 +372,20 @@ uses, not yet built for a box); its quartiles are a size-weighted approximation
same one Explore's federated numeric histograms already disclose.
**Points: every case when it is a drawing, the outliers when it is not.** cBioPortal draws every
-case as a point. So does this, up to 5,000 cases, on a scatter as on a box, and only when the
+case as a point. So does this, up to 10,000 cases, on a scatter as on a box, and only when the
study is one this participant may see cases in — the points carry case ids, so they come from the
same entitlement that lets a patient-level participant list cohort members, and an aggregate-only
participant has no method that could return them. The jitter across a box is fixed per case, so
the figure does not move between the server SVG, the browser and the Python package.
-Past 5,000 the crowd is drawn as density — a quarter of a million dots is a solid block, and a
+Past 10,000 the crowd is drawn as density — a quarter of a million dots is a solid block, and a
hover on it would land on an arbitrary case — and, by default, the **outliers** are a point layer
over it (see **Points**): on a box, the
values beyond each group's Tukey whiskers, drawn over a **violin** (from per-group bins) that stops
at the whiskers; on a scatter, the cases in **sparse cells** of the grid — every cell holding at
-most 3 cases (fewer, if that is what keeps the points under 5,000) is drawn as its cases instead of
+most 3 cases (fewer, if that is what keeps the points under 10,000) is drawn as its cases instead of
as a shade. Both subsets are chosen in the database, against the same grid and whiskers the figure
-draws, and if a subset is itself past 5,000 none of it is drawn, never the first 5,000. With
+draws, and if a subset is itself past 10,000 none of it is drawn, never the first 10,000. With
`allPoints: false` and `points: []`, or from a participant that may not disclose a case, the
figure is density alone.
The caption always says which it drew, how many points, and why there are none when there are
@@ -416,7 +416,7 @@ or per sample following the plot's `unit`. Because it is a categorical axis, it
`colorBy`/`ringBy` and as a Cox or logistic covariate, where "Unaltered" is the reference level.
OQL is for alteration axes only: a molecular axis stays one gene's value.
-**Points.** Every case is a point when there are at most 5,000 of them and every study may show
+**Points.** Every case is a point when there are at most 10,000 of them and every study may show
cases (`allPoints: false` draws the density alone at any size). Over that the plotted cohort is a
density, and `points` lists the subsets worth seeing one by one, each drawn in its own colour with
the plotted cohort turned grey beneath them (at most four layers, in order):
@@ -428,7 +428,7 @@ grid cell holding at most 3 cases, left unshaded under its points); `{"rule": "e
`{"cohort": "KRAS G12C"}` — the plotted cohort's cases that are also in that cohort (how many of its
patients fall outside the plotted cohort is stated, and they are not drawn). The default is
`[{"rule": "outliers"}]`; `[]` draws none. A case in two layers is drawn by the first, and the
-caption counts what each layer left to an earlier one. Every layer shares one 5,000-point budget,
+caption counts what each layer left to an earlier one. Every layer shares one 10,000-point budget,
and a layer is never cut to its first cases: a cohort layer past the budget, or from a study that
may not show cases, is drawn as an aggregate on the plot's own scale (cells on the scatter's grid,
or a narrow box per group), and a rule layer is not drawn — the caption says which and why.
diff --git a/docs/plot-case-level.md b/docs/plot-case-level.md
index 9a36024..336c19d 100644
--- a/docs/plot-case-level.md
+++ b/docs/plot-case-level.md
@@ -35,7 +35,7 @@ a hover lands on an arbitrary one. So:
combines the patient's samples by a stated rule: a number is their mean, a category their common
value or "Multiple" where they differ, an alteration counts if any sample has it, and the caption
says how many patients had more than one sample.
-- **At or under `POINTS_CAP` (5,000), every case is a point** (`allPoints`, default true), on a
+- **At or under `POINTS_CAP` (10,000), every case is a point** (`allPoints`, default true), on a
scatter as on a box — the portal's picture, and what most single studies get.
- **Over the cap, the crowd is density, and point layers draw chosen subsets in colour over it.**
`points` is an ordered list of at most four layers (`lib/plot-layers.ts`):
@@ -78,8 +78,21 @@ One commit per item; `npm test` and a focused check per item; the gate before ea
5. **Done.** Show one layer alone: each layer is a group in the SVG tagged with its place in the
list (its cases an earlier layer drew are a hidden group of their own), and a row of buttons
above the figure steps the rest back with one CSS rule; Esc restores. Browser state only.
-6. **Measure the cap.** SVG draw time and payload at 5k, 10k, 20k and 50k points on msk_chord;
- set `POINTS_CAP` from it.
+6. **Done.** The cap, measured. The plot renderer drew a scatter and a box at n points
+ (synthetic values, real-shaped ids), and headless Chrome on the dev server (6 cores, no GPU)
+ inserted and painted each SVG, median of 3:
+
+ | points | paint, scatter / box | one hover hit-test | JSON (raw / gzip) |
+ |---|---|---|---|
+ | 5,000 | 84 / 117 ms | 0.4 ms | 220 / 35 KB |
+ | 10,000 | 180 / 190 ms | 1.7 ms | 440 / 70 KB |
+ | 20,000 | 352 / 337 ms | 3.2 ms | 880 / 140 KB |
+ | 50,000 | 904 / 752 ms | 7 ms | 2.2 MB / 350 KB |
+
+ Building the SVG string is 25–190 ms and not the limit; painting is, at about 18 ms per
+ thousand points, paid again on every redraw (a resize, a re-run). `POINTS_CAP` is 10,000, which
+ keeps a redraw near 200 ms on this machine (a laptop with a GPU is faster). Hover will go
+ through one overlay (item 9), so per-circle hit-testing does not set the cap.
7. **Multiple genes.** An alteration axis whose OQL has several statements gets one category per
statement (overlaps as their own category, never assigned arbitrarily), and commas separate
statements as well as `;` and newlines.
diff --git a/lib/dsl-reference.ts b/lib/dsl-reference.ts
index af3aa84..74d549e 100644
--- a/lib/dsl-reference.ts
+++ b/lib/dsl-reference.ts
@@ -17,12 +17,16 @@
// ============================================================================
import { DOC_VERSION } from "./doc-version.ts";
+import { POINTS_CAP } from "./plot-layers.ts";
import type { TemplateDoc } from "./types.ts";
import type { CustomDataset } from "./custom-data-names.ts";
import { LUAD_BIOPSY_SITE_DEMO } from "./custom-data-demo.ts";
import { SPECTRUM_SC_DEMO } from "./spectrum-sc-demo.ts";
import { HTA11_SC_DEMO } from "./hta11-sc-demo.ts";
+/** the plot's points cap as the docs print it */
+const POINTS_CAP_TEXT = POINTS_CAP.toLocaleString("en-US");
+
export const DSL_VERSION = DOC_VERSION;
export interface DslExample {
@@ -1522,7 +1526,7 @@ patient — so the arm counts summing to more than the base cohort is expected,
| \`logistic\` | \`outcome:axis, outcomeEvent?:"", covariates:[axis, ...(max 8)], cohorts:[names]\` | multivariate logistic regression: a BINARY outcome (not time-to-event \u2014 no endpoint/months convention here) regressed on several covariates at once, reported as odds ratios with Wald confidence intervals, drawn as a forest plot the same shape as \`cox\`'s. \`outcome\` is a plot axis, exactly like a covariate (clinical, molecular, or alteration status). A categorical outcome (a clinical attribute, or an alteration axis) needs \`outcomeEvent\` naming which observed value counts as the event (1) \u2014 every other observed value collapses into "not event" (0), and which values got collapsed is disclosed in the caveat; omitting it, or naming a value never observed, is refused with the actual observed values listed rather than guessed at. A numeric outcome must already be coded exactly 0/1 (refused otherwise) and must NOT be given \`outcomeEvent\`. Covariates use the exact same dummy-coding and reference-level rules as \`cox\` (see above): a mutated-vs-not axis defaults to \`\"Not mutated\"\`, others default to the most frequent level, \`reference\` overrides either. Same patient-level-only boundary as \`cox\` |
| \`course\` | \`eventTypes:[\u22646], anchor?:{eventType,key?,value?,occurrence?}, binMonths?:3, fromMonths?:-12, toMonths?:60, cohorts:[names]\` | the **clinical course**: per time bin, the share of the cohort in each event state. Two things it refuses to fake. (1) The denominator is the patients still under observation in that bin \u2014 first recorded event to last contact (the later of their last event and their OS follow-up) \u2014 never the whole cohort, which would make a curve fall merely because follow-up ran out. (2) \`anchor\` is optional for one study but **required across studies**: day 0 is birth in some studies and first sequencing in others, and nothing in the schema records which, so raw day 0 compares incomparable clocks. Event types that carry real durations are drawn as a state the patient is IN; the rest as an event that HAPPENED \u2014 read from the data, not from the name. Bins are half-open [t, t+w). Event-name spellings differing only in case are folded (\`Treatment\`/\`TREATMENT\`) and the fold is named in the caveat; nothing else is merged |
| \`sequence\` | \`labelKey?:"SUBTYPE", eventType?:"treatment", lineClasses?:"systemic"|"all"|[classes], exclude?:[values], lineGap?:90, maxSteps?:3, topN?:8, cohorts:[names]\` | **what follows what**: therapy transitions as lines, drawn as a Sankey. Records are first grouped into LINES \u2014 therapies less than \`lineGap\` days apart are one line, labelled by their whole combination (sorted, so \`A + B\` and \`B + A\` are one regimen). That grouping is the point: a patient has a median of 6 treatment records and 4 distinct agents, and combination therapy is several records describing ONE decision. Paths that end flow into TWO explicit terminals, never one: **no further line** (the study watched for at least one \`lineGap\` and saw no further therapy) and **record ends here** (it did not, so nothing can be concluded). Measured on MSK-CHORD, 68% of patients have under three months of observation after their last therapy and the median is 15 days \u2014 so collapsing the two would turn record-keeping into an apparent clinical decision. Flows out of a node sum to it, so nobody vanishes. Up to two cohorts are drawn as panels sharing one scale and one colour per regimen. Lines are the shared definition (see "Treatment line (derived)" above): systemic-therapy records by default, \`lineClasses\` \`"all"\` for every treatment record or a list of class names for your own; \`labelKey\` only names a line\u2019s therapies (e.g. AGENT), it never decides which records form lines. \`exclude\` drops records by label value, matched ignoring case. More than two cohorts is refused |
-| \`plot\` | \`x:axis, y:axis, unit?:"sample"\\|"patient", bins?:[240,160], allPoints?:true, points?:[layer(max 4)], colorBy?:axis, ringBy?:axis, cohorts:[names]\` | two axes over the same cases, aggregated **inside the database**: a scatter comes back as counts per grid cell, a box as quartiles per group, two categoricals as a contingency table. The payload is a few kilobytes whatever the cohort's size. A point is a sample, as on cBioPortal's Plots tab; \`unit:"patient"\` makes it a patient, combining a patient's samples: a numeric value is their mean, a category their common value or "Multiple" where they differ, an alteration counts if any sample has it, and the caption says how many patients had more than one sample. Every case is a point up to 5,000 (\`allPoints: false\` turns that off), from a participant that may show cases; \`points\` lists up to four subsets drawn in colour over it — \`{rule:"outliers"}\` (the default), \`{rule:"extremes",axis,n}\`, \`{rule:"trend",n}\`, \`{cohort}\` — see **Points** below |
+| \`plot\` | \`x:axis, y:axis, unit?:"sample"\\|"patient", bins?:[240,160], allPoints?:true, points?:[layer(max 4)], colorBy?:axis, ringBy?:axis, cohorts:[names]\` | two axes over the same cases, aggregated **inside the database**: a scatter comes back as counts per grid cell, a box as quartiles per group, two categoricals as a contingency table. The payload is a few kilobytes whatever the cohort's size. A point is a sample, as on cBioPortal's Plots tab; \`unit:"patient"\` makes it a patient, combining a patient's samples: a numeric value is their mean, a category their common value or "Multiple" where they differ, an alteration counts if any sample has it, and the caption says how many patients had more than one sample. Every case is a point up to ${POINTS_CAP_TEXT} (\`allPoints: false\` turns that off), from a participant that may show cases; \`points\` lists up to four subsets drawn in colour over it — \`{rule:"outliers"}\` (the default), \`{rule:"extremes",axis,n}\`, \`{rule:"trend",n}\`, \`{cohort}\` — see **Points** below |
| \`attrition\` | \`cohorts:[names] (≤6)\` | **cohort attrition**, CONSORT-style: for each cohort, the study base and then the patients left after each clause of its \`all\`, in the order written, with how many that clause alone removed. Each step is evaluated as its own cohort (the first k clauses ANDed), with the same compiler and the same per-study counts, so the last step is the cohort's own count by construction — a mismatch is reported, never smoothed over. The order of clauses changes the path, never the final count. Counts only, so every participant (aggregate-only and remote included) can answer; across studies the counts are summed and overlap is not deduplicated |
| \`genome\` | \`binMb?:2, threshold?:0.2, includeX?:false, cohorts:[names] (≤4)\` | **genome-wide copy-number frequency** from the copy-number SEGMENTS: the share of each cohort's segmented samples gained (log2 segment mean ≥ \`threshold\`) or lost (≤ −\`threshold\`) in every \`binMb\`-megabase bin (1–50) along chromosomes 1–22 (and X with \`includeX\` — its log ratio mostly records sex, so it is off by default), drawn as gains up and losses down, one panel per cohort. Each bin's denominator is the samples whose segmentation covers it — a bin nothing covers is drawn as unmeasured, never as copy-neutral. A bin takes the value of the segment over its midpoint, so an event narrower than a bin can be missed; the caption says so. Unit is the SAMPLE, not the patient. Exactly two cohorts add a per-bin two-sided Fisher test (gains and losses separately, Benjamini–Hochberg across bins), drawn as a strip coloured by which cohort is higher. Studies on different reference genomes (hg19/hg38) are refused rather than drawn on one axis. Counts only; a remote peer cannot answer yet and is named as left out |
| \`pathways\` | \`pathways?:[names], sets?:[{name, genes:[≤40]} ×≤4], measure?:"driver"\|"any", topGenes?:6, cohorts:[names] (≤4)\` | **oncogenic signalling pathways**: the ten canonical pathways of Sanchez-Vega et al. (Cell 2018, Table S3: RTK-RAS, PI3K, TP53, Cell cycle, MYC, WNT, NOTCH, Hippo, TGF-β, NRF2 — 334 genes), and/or the document's own \`sets\`, each as the share of the cohort with at least one member altered. \`measure\` defaults to \`"driver"\` (OncoKB-annotated drivers, as the paper counted curated oncogenic events; refused without an annotation pin) or \`"any"\`. A pathway's denominator is the patients profiled for at least ONE member (the union of the members' panels, the oncoprint's merged-row rule, computed in the database with the same event table, CNA rule and driver join as \`compare\`, so a one-gene set equals that gene's own count); each member's % is over the patients profiled for THAT gene. The \`topGenes\` most-altered members are drawn per pathway; the rest are pooled as "+N more altered", and members on no participating panel are listed as never profiled. Two cohorts add Fisher tests with BH q across pathways, and separately across member genes. Counts only; a remote peer cannot answer yet and is named |
@@ -1619,20 +1623,20 @@ uses, not yet built for a box); its quartiles are a size-weighted approximation
same one Explore's federated numeric histograms already disclose.
**Points: every case when it is a drawing, the outliers when it is not.** cBioPortal draws every
-case as a point. So does this, up to 5,000 cases, on a scatter as on a box, and only when the
+case as a point. So does this, up to ${POINTS_CAP_TEXT} cases, on a scatter as on a box, and only when the
study is one this participant may see cases in — the points carry case ids, so they come from the
same entitlement that lets a patient-level participant list cohort members, and an aggregate-only
participant has no method that could return them. The jitter across a box is fixed per case, so
the figure does not move between the server SVG, the browser and the Python package.
-Past 5,000 the crowd is drawn as density — a quarter of a million dots is a solid block, and a
+Past ${POINTS_CAP_TEXT} the crowd is drawn as density — a quarter of a million dots is a solid block, and a
hover on it would land on an arbitrary case — and, by default, the **outliers** are a point layer
over it (see **Points**): on a box, the
values beyond each group's Tukey whiskers, drawn over a **violin** (from per-group bins) that stops
at the whiskers; on a scatter, the cases in **sparse cells** of the grid — every cell holding at
-most 3 cases (fewer, if that is what keeps the points under 5,000) is drawn as its cases instead of
+most 3 cases (fewer, if that is what keeps the points under ${POINTS_CAP_TEXT}) is drawn as its cases instead of
as a shade. Both subsets are chosen in the database, against the same grid and whiskers the figure
-draws, and if a subset is itself past 5,000 none of it is drawn, never the first 5,000. With
+draws, and if a subset is itself past ${POINTS_CAP_TEXT} none of it is drawn, never the first ${POINTS_CAP_TEXT}. With
\`allPoints: false\` and \`points: []\`, or from a participant that may not disclose a case, the
figure is density alone.
The caption always says which it drew, how many points, and why there are none when there are
@@ -1663,7 +1667,7 @@ or per sample following the plot's \`unit\`. Because it is a categorical axis, i
\`colorBy\`/\`ringBy\` and as a Cox or logistic covariate, where "Unaltered" is the reference level.
OQL is for alteration axes only: a molecular axis stays one gene's value.
-**Points.** Every case is a point when there are at most 5,000 of them and every study may show
+**Points.** Every case is a point when there are at most ${POINTS_CAP_TEXT} of them and every study may show
cases (\`allPoints: false\` draws the density alone at any size). Over that the plotted cohort is a
density, and \`points\` lists the subsets worth seeing one by one, each drawn in its own colour with
the plotted cohort turned grey beneath them (at most four layers, in order):
@@ -1675,7 +1679,7 @@ grid cell holding at most 3 cases, left unshaded under its points); \`{"rule": "
\`{"cohort": "KRAS G12C"}\` — the plotted cohort's cases that are also in that cohort (how many of its
patients fall outside the plotted cohort is stated, and they are not drawn). The default is
\`[{"rule": "outliers"}]\`; \`[]\` draws none. A case in two layers is drawn by the first, and the
-caption counts what each layer left to an earlier one. Every layer shares one 5,000-point budget,
+caption counts what each layer left to an earlier one. Every layer shares one ${POINTS_CAP_TEXT}-point budget,
and a layer is never cut to its first cases: a cohort layer past the budget, or from a study that
may not show cases, is drawn as an aggregate on the plot's own scale (cells on the scatter's grid,
or a narrow box per group), and a rule layer is not drawn — the caption says which and why.
@@ -1902,7 +1906,7 @@ export const AGENT_GRAMMAR = `Document shape:
// | {"source":"alteration","gene":G,"by":"mutated|mutationType|cna|driver"?}
// | {"source":"alteration","genes":[G,...(2-20)],"by":"mutated"?} // any-of-N as one category; only "mutated"
// | {"source":"alteration","oql":"KRAS: G12C; STK11: MUT"} // Altered (matches any entry) vs Unaltered
- // two numeric axes -> scatter (binned; every case a point up to 5,000, else sparse-cell outliers); one categorical -> box;
+ // two numeric axes -> scatter (binned; every case a point up to ${POINTS_CAP_TEXT}, else sparse-cell outliers); one categorical -> box;
// two categorical -> a contingency table
{"kind":"attrition","cohorts":[names?(max 6)]},
{"kind":"genome","binMb":2,"threshold":0.2,"includeX":false,"cohorts":[names?(max 4)]},
diff --git a/lib/plot-layers.ts b/lib/plot-layers.ts
index 14913bf..c4bc096 100644
--- a/lib/plot-layers.ts
+++ b/lib/plot-layers.ts
@@ -4,8 +4,11 @@
/**
* How many cases a plot draws as points before it stops being a drawing: at or under it every case
* is a point; past it the plotted cohort is density, and the point layers share this many points.
+ * Set from a measurement (docs/plot-case-level.md): an SVG of n points paints in about 18 ms per
+ * thousand on the dev server's headless Chrome, so 10,000 keeps a redraw near 200 ms; the points
+ * travel as about 44 bytes each.
*/
-export const POINTS_CAP = 5000;
+export const POINTS_CAP = 10000;
/** layers one plot may draw: each takes a colour of its own, and past four they stop being told apart */
export const POINT_LAYER_CAP = 4;
/** a layer's colour, by its place in the view's list */
diff --git a/lib/types.ts b/lib/types.ts
index 4dc954f..acf1495 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -549,7 +549,7 @@ export interface PlotView {
/** the grid a scatter is binned onto (default 240 x 160) */
bins?: [number, number];
/**
- * Every case is a point when there are at most 5,000 of them and the reader may see cases
+ * Every case is a point when there are at most POINTS_CAP (10,000) of them and the reader may see cases
* (default true); false draws the density alone at any size.
*/
allPoints?: boolean;
@@ -558,7 +558,7 @@ export interface PlotView {
* outliers (past the cap: a box's values beyond its whiskers, a scatter's sparse cells), the
* extremes on an axis, the cases furthest from the fitted line, or a cohort's cases. Default
* [{rule:"outliers"}]; [] draws none. A case in two layers is drawn by the first. Every layer
- * shares one 5,000-point budget; a cohort layer past it is drawn as density cells or boxes.
+ * shares one POINTS_CAP budget; a cohort layer past it is drawn as density cells or boxes.
*/
points?: PointLayerSpec[];
/**
diff --git a/test/plot-layers.test.ts b/test/plot-layers.test.ts
index 68011bd..5fb0930 100644
--- a/test/plot-layers.test.ts
+++ b/test/plot-layers.test.ts
@@ -10,6 +10,7 @@ import type { ViewRun, EvaluatedCohort } from "../lib/views/run.ts";
import type { PlotData, ViewResult } from "../lib/types.ts";
type Calls = string[];
+const CAP = POINTS_CAP.toLocaleString("en-US");
const EXTENT = { x0: 0, x1: 100, y0: 0, y1: 50 };
const scatter = (): PlotData => ({ shape: "scatter", n: 1000, x: { label: "AGE", kind: "num" }, y: { label: "TMB", kind: "num" },
extent: { ...EXTENT }, grid: [240, 160], bins: [{ bx: 1, by: 1, n: 1000 }],
@@ -83,7 +84,7 @@ describe("a highlight on one study", () => {
assert.match(r.note!, /statistics are over the whole plotted cohort/);
});
- test("past the cap the same cases come back as cells on the plot's own grid and extent, never the first 5,000", async () => {
+ test("past the cap the same cases come back as cells on the plot's own grid and extent, never the first POINTS_CAP", async () => {
const calls: Calls = [];
const r = await run({ s: fake("patient-level", calls, { points: null }) });
const h = r.plot!.layers![0];
@@ -92,7 +93,7 @@ describe("a highlight on one study", () => {
assert.deepEqual(h.bins, [{ bx: 2, by: 3, n: 7 }]);
assert.equal(h.n, 7);
assert.ok(calls.includes("bins [240,160] " + JSON.stringify(EXTENT)), "the highlight is binned on the scatter's grid: " + calls.join("; "));
- assert.match(r.note!, /drawn as shaded cells \(more than 5,000 cases/);
+ assert.match(r.note!, new RegExp("drawn as shaded cells \\(more than " + CAP + " cases"));
});
test("an aggregate-only participant is never asked for points, and its highlight is an aggregate", async () => {
@@ -226,7 +227,7 @@ describe("rule layers", () => {
for (const extra of [{}, { allPoints: false }]) {
const r = await run({ s: fake("patient-level", []) }, { points: [{ rule: "outliers" }], extra });
assert.deepEqual([r.plot!.layers![0].shown, r.plot!.layers![0].why],
- ["none", "at or under 5,000 cases there is no density for them to stand out of"], JSON.stringify(extra));
+ ["none", "at or under " + CAP + " cases there is no density for them to stand out of"], JSON.stringify(extra));
}
});
diff --git a/test/plot-points.test.ts b/test/plot-points.test.ts
index 82c38c8..cfcb271 100644
--- a/test/plot-points.test.ts
+++ b/test/plot-points.test.ts
@@ -9,6 +9,7 @@ import { POINTS_CAP, OUTLIER_CELL_MAX, sparseCellMax } from "../lib/plot.ts";
import type { ViewRun, EvaluatedCohort } from "../lib/views/run.ts";
import type { PlotData, ViewResult } from "../lib/types.ts";
+const CAP = POINTS_CAP.toLocaleString("en-US");
const EXTENT = { x0: 0, x1: 100, y0: 0, y1: 50 };
const scatter = (n: number, bins: { bx: number; by: number; n: number }[]): PlotData => ({
shape: "scatter", n, x: { label: "AGE", kind: "num" }, y: { label: "TMB", kind: "num" },
@@ -83,7 +84,7 @@ describe("a scatter's points", () => {
assert.equal(calls.length, 1, "over the cap the every-case query is not run");
assert.deepEqual(calls[0].extra, { grid: [240, 160], extent: EXTENT, cellMax: OUTLIER_CELL_MAX });
assert.equal(calls[0].cap, POINTS_CAP);
- assert.deepEqual(r.plot!.pointSet, { rule: "none", drawn: 0, why: "more than 5,000 cases" });
+ assert.deepEqual(r.plot!.pointSet, { rule: "none", drawn: 0, why: "more than " + CAP + " cases" });
const l = r.plot!.layers![0];
assert.deepEqual([l.kind, l.shown, l.n, l.drawn, l.cellMax], ["outliers", "points", 1, 1, OUTLIER_CELL_MAX]);
assert.match(r.note!, /outliers: 1 sample in cells holding at most 3, left unshaded under them/);
@@ -98,7 +99,7 @@ describe("a scatter's points", () => {
assert.equal(r.plot!.pointSet!.rule, "none");
assert.equal(r.plot!.xyPoints, undefined);
assert.equal(r.plot!.layers![0].shown, "none");
- assert.match(r.note!, /outliers: not drawn, the cases alone in a cell are more than the 5,000 points left to draw/);
+ assert.match(r.note!, new RegExp("outliers: not drawn, the cases alone in a cell are more than the " + CAP + " points left to draw"));
});
test("an aggregate-only participant is never asked, and the caption names it", async () => {
@@ -139,11 +140,11 @@ describe("a box's points", () => {
assert.match(r.note!, /outliers: 1 value beyond the whiskers/);
});
- test("more outliers than the cap: none, never the first 5,000", async () => {
+ test("more outliers than the cap: none, never the first POINTS_CAP", async () => {
const calls: Call[] = [];
const r = await run(fake("patient-level", calls, () => box(POINTS_CAP * 3), { outliers: null }));
assert.equal(r.plot!.points, undefined);
assert.equal(r.plot!.layers![0].shown, "none");
- assert.match(r.note!, /outliers: not drawn, more than 5,000 values lie beyond the whiskers/);
+ assert.match(r.note!, new RegExp("outliers: not drawn, more than " + CAP + " values lie beyond the whiskers"));
});
});
From 995791f1b8dd63860f318e902008e3e7704a4dc7 Mon Sep 17 00:00:00 2001
From: Aaron Lisman
Date: Tue, 20 Oct 2026 19:44:20 +0000
Subject: [PATCH 03/10] Plot: OQL axes by statement, commas between statements,
and "Not profiled" never counted as unaltered
An OQL axis's statements may now be separated by commas as well as ";" and new lines,
everywhere OQL is read: a cohort's oql leaf, a plot axis, splits. The engine's parser
and the gene query dialog share OQL_SEPARATOR / oqlStatements in lib/oql-parse.ts; a
comma used to be an error, so nothing that worked changes meaning.
`by: "statement"` gives each statement its own group, named as written with the gene
in capitals. A case matching two statements is the pair ("KRAS: G12C + STK11: MUT"),
three or more is "3 or more lines", and there are at most 6 statements. `by: "any"`, the
default, stays Altered/Unaltered, which Cox and logistic covariates take with
"Unaltered" as reference.
In both, a case that matches no statement is "Unaltered" only when it is profiled for
every statement's gene in the alteration types that statement asks about. Otherwise it
is "Not profiled". Before, the axis counted a sample whose panel omits the gene as
Unaltered, the wild-type error docs/PERFORMANCE.md's denominator rule forbids. The
panel test is the `profiled` clause's own (sample_to_gene_panel_derived, WES counted),
exported as cohort2's profiledCasesSql. EXP/PROT statements read a value, not a panel,
so they need no profiling. Inner columns are named oql_case / profiled_id, because
ClickHouse resolved a bare `id` to another axis's alias once the query was embedded
in the plot's.
The axis control offers "matches OQL (any line)" and "OQL, one group per line". The OQL
gallery example gains a by-line view (STK11, KEAP1, KRAS G12C in lung cancer). The DSL
reference and docs/plot-case-level.md say all of this.
Verified on msk_chord_2024, all 25,040 samples, `KRAS: G12C, RRAGC: MUT` (RRAGC is on
the panels of 20,048 samples):
- by line: KRAS G12C 1,111, RRAGC 61, both 3, Unaltered 19,115, Not profiled 4,750
- any: Altered 1,175
Each equals a direct ClickHouse query over the panel and event tables. The OQL
example's stated numbers (1,535 Altered, 6,274 Unaltered) still hold. docs:check runs
every example and check:methods reproduces every view's numbers, Cox and logistic with
OQL covariates included.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
---
app/lab/view-chrome.tsx | 21 ++++++++-----
docs/dsl.md | 38 ++++++++++++++++++-----
docs/plot-case-level.md | 10 ++++--
lib/cohort2.ts | 18 +++++++++++
lib/dsl-reference.ts | 33 +++++++++++++-------
lib/gene-query.ts | 4 +--
lib/oql-parse.ts | 9 +++++-
lib/plot-oql.ts | 8 ++++-
lib/plot.ts | 67 ++++++++++++++++++++++++++++++-----------
test/gene-query.test.ts | 3 ++
test/plot.test.ts | 21 +++++++++++--
11 files changed, 178 insertions(+), 54 deletions(-)
diff --git a/app/lab/view-chrome.tsx b/app/lab/view-chrome.tsx
index f48a5af..82ce335 100644
--- a/app/lab/view-chrome.tsx
+++ b/app/lab/view-chrome.tsx
@@ -134,28 +134,33 @@ export function AxisPicker({ side, axis, attrs, profiles, busy, loading, onChang
const isOql = a.oql != null;
const genes: string[] = a.genes && a.genes.length ? a.genes : a.gene ? [a.gene] : [];
const isSet = !isOql && genes.length > 1;
- const groupBy = {
const by = v;
// to OQL: the genes become a query (a bare gene is "any alteration"); from OQL: the
// query's first gene, so a round trip keeps the gene
- if (by === "oql") set({ source: "alteration", oql: genes.length ? genes.join("; ") : "TP53: MUT" });
+ if (by === "oql" || by === "oqlEach") {
+ const oql = isOql ? a.oql : genes.length ? genes.join("; ") : "TP53: MUT";
+ set(by === "oqlEach" ? { source: "alteration", oql, by: "statement" } : { source: "alteration", oql });
+ }
else if (isOql) set({ source: "alteration", gene: (/^\s*([A-Za-z0-9.-]+)/.exec(a.oql) || [])[1]?.toUpperCase() || "TP53", by });
else set({ ...a, by });
}}>
-
+
+
;
// stacked, the grouping comes first: it decides whether the box below takes genes or a query
return <>
{stacked && field("Group cases by", groupBy)}
{field(isOql ? "OQL query" : "Gene(s)", isOql
- ? set({ source: "alteration", oql })} />
+ ? set({ ...a, source: "alteration", oql })} />
: {
// A gene SET (2+) only supports "mutated vs not" — combining several genes'
@@ -234,10 +239,10 @@ export function OqlInput({ query, busy, onCommit, className }: { query: string;
useEffect(() => setDraft(query), [query]);
const commit = (v: string) => { if (timer.current) clearTimeout(timer.current); if (v.trim() && v !== query) onCommit(v.trim()); };
return (
- 2. " +
- "A case is Altered when it matches any of them."}
+ title={"OQL, one statement per line, ';' or ',' — e.g. KRAS: G12C, STK11: MUT, EGFR: AMP, TP53: DRIVER, ERBB2: EXP>2. " +
+ "Grouped by any line (Altered or not) or one group per line, as chosen beside it."}
onChange={(e) => {
const v = e.target.value;
setDraft(v);
diff --git a/docs/dsl.md b/docs/dsl.md
index 320666c..c396343 100644
--- a/docs/dsl.md
+++ b/docs/dsl.md
@@ -407,13 +407,19 @@ bin's width is shared out between the classes in it. That stays an aggregate, so
may not disclose a case can still colour its plot. The key is capped at 12 classes.
**OQL on an axis.** An alteration axis can be an OQL query instead of genes:
-`{"source":"alteration","oql":"KRAS: G12C; STK11: MUT"}`. Each case is **Altered** when it matches
-any entry — the portal's own reading of a multi-gene query — and **Unaltered** otherwise; every
-case of the cohort is one or the other, so the unaltered are counted rather than missing. It is
-the same OQL a cohort's `oql` leaf takes (mutation classes and protein changes, AMP/HOMDEL,
-FUSION, DRIVER against the pinned OncoKB version, EXP/PROT thresholds), and it matches per patient
-or per sample following the plot's `unit`. Because it is a categorical axis, it also works as
-`colorBy`/`ringBy` and as a Cox or logistic covariate, where "Unaltered" is the reference level.
+`{"source":"alteration","oql":"KRAS: G12C, STK11: MUT"}`, its statements separated by a comma, a
+semicolon or a new line (as in a cohort's `oql` leaf). Each case is **Altered** when it matches any
+statement — the portal's own reading of a multi-gene query — and **Unaltered** when it matches none
+and is profiled for every statement's gene in the alteration types that statement asks about; a
+case whose gene panel omits one of them is **Not profiled**, never counted as wild type (the
+panel-aware rule of `docs/PERFORMANCE.md`). Every case of the cohort is one of the three, so none is
+missing. With `"by": "statement"` each statement is its own group, named as written: a case matching
+two is the pair ("KRAS: G12C + STK11: MUT"), three or more "3 or more lines", none Unaltered or Not
+profiled by the same rule (at most 6 statements). It is the same OQL a cohort's `oql` leaf takes
+(mutation classes and protein changes, AMP/HOMDEL, FUSION, DRIVER against the pinned OncoKB version,
+EXP/PROT thresholds), and it matches per patient or per sample following the plot's `unit`.
+Because it is a categorical axis, it also works as `colorBy`/`ringBy` and as a Cox or logistic
+covariate, where "Unaltered" is the reference level.
OQL is for alteration axes only: a molecular axis stays one gene's value.
**Points.** Every case is a point when there are at most 10,000 of them and every study may show
@@ -2189,7 +2195,7 @@ TCGA lung adenocarcinoma and squamous cell carcinoma pooled (1,053 patients). KR
### Example 20: OQL on a plot axis: STK11 or KEAP1 mutant lung cancer
-Non-small cell lung cancer in MSK-CHORD (7,809 patients). The first view groups tumour mutational burden by the OQL query `STK11: MUT; KEAP1: MUT`: 1,535 patients are Altered (a mutation in either gene), the same count as a cohort defined by that OQL, and 6,274 are Unaltered. The second view groups by sample type and colours each violin by the same query, counting samples rather than patients.
+Non-small cell lung cancer in MSK-CHORD (7,809 patients). The first view groups tumour mutational burden by the OQL query `STK11: MUT; KEAP1: MUT`: 1,535 patients are Altered (a mutation in either gene), the same count as a cohort defined by that OQL, and 6,274 are Unaltered. The second view groups by sample type and colours each violin by the same query, counting samples rather than patients. The third gives each statement of `STK11: MUT, KEAP1: MUT, KRAS: G12C` its own group, over samples: 620 KRAS G12C, 459 KEAP1, 377 STK11, 403 with both STK11 and KEAP1 (median TMB 8.6 against 4.1 for the 5,720 with none), the other pairs as their own groups and 109 matching all three. None of these lung samples is on a panel without the three genes, so no group is Not profiled.
```json
{
@@ -2244,6 +2250,22 @@ Non-small cell lung cancer in MSK-CHORD (7,809 patients). The first view groups
"source": "alteration",
"oql": "STK11: MUT; KEAP1: MUT"
}
+ },
+ {
+ "kind": "plot",
+ "unit": "sample",
+ "cohorts": [
+ "NSCLC"
+ ],
+ "x": {
+ "source": "alteration",
+ "oql": "STK11: MUT, KEAP1: MUT, KRAS: G12C",
+ "by": "statement"
+ },
+ "y": {
+ "source": "clinical",
+ "attribute": "TMB_NONSYNONYMOUS"
+ }
}
]
}
diff --git a/docs/plot-case-level.md b/docs/plot-case-level.md
index 336c19d..e33db78 100644
--- a/docs/plot-case-level.md
+++ b/docs/plot-case-level.md
@@ -93,9 +93,13 @@ One commit per item; `npm test` and a focused check per item; the gate before ea
thousand points, paid again on every redraw (a resize, a re-run). `POINTS_CAP` is 10,000, which
keeps a redraw near 200 ms on this machine (a laptop with a GPU is faster). Hover will go
through one overlay (item 9), so per-circle hit-testing does not set the cap.
-7. **Multiple genes.** An alteration axis whose OQL has several statements gets one category per
- statement (overlaps as their own category, never assigned arbitrarily), and commas separate
- statements as well as `;` and newlines.
+7. **Done.** Multiple genes. An OQL axis's statements are separated by ",", ";" or a new line
+ (everywhere OQL is read). `by: "statement"` gives each statement its own group, a case matching
+ two the pair, three or more "3 or more lines". Either way a case matching nothing is
+ "Unaltered" only when it is profiled for every statement's gene (panel-aware), otherwise "Not
+ profiled". On msk_chord, KRAS G12C and RRAGC (on 20,048 of 25,040 samples' panels): 1,111 /
+ 61 / 3 both / 19,115 Unaltered / 4,750 Not profiled, equal to a direct query; the 4,750 were
+ counted as Unaltered before.
8. **Colour by gene per case** (phase 2). `colorBy: { source: "gene", gene }`: fill from the case's
highest-priority mutation (oncoprint priority, driver/VUS by the pinned OncoKB), ring from
CNA/SV, "not profiled" its own class (panel coverage, reuse the oncoprint's). On scatters too;
diff --git a/lib/cohort2.ts b/lib/cohort2.ts
index 94d0024..d6af877 100644
--- a/lib/cohort2.ts
+++ b/lib/cohort2.ts
@@ -232,6 +232,24 @@ function derivedLeafSql(leaf: any, ctx: Ctx): string {
" WHERE " + conds.join(" AND ");
}
+/**
+ * The cases (samples, or patients with a profiled sample) profiled for `gene` in one alteration
+ * type, as bare ids in the column `profiled_id`: the same panel tables, and the same whole-exome rule, as the `profiled` clause
+ * and the gene-table denominator. What an OQL plot axis needs to tell "not altered" from "not
+ * profiled", per sample or per patient.
+ */
+export function profiledCasesSql(gene: string, type: "MUTATION" | "CNA" | "SV", o: { study: string; db: string; unit: "patient" | "sample" }): string {
+ const g = String(gene).toUpperCase().trim();
+ if (!/^[A-Z0-9][A-Z0-9.-]{0,19}$/.test(g)) throw new Error("profiled: invalid gene symbol");
+ const col = o.unit === "sample" ? "sd.sample_unique_id" : "sd.patient_unique_id";
+ return "SELECT DISTINCT " + PID(col, o.study) + " AS profiled_id\n" +
+ " FROM " + o.db + ".sample_to_gene_panel_derived sp\n" +
+ " JOIN " + o.db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" +
+ " WHERE sp.cancer_study_identifier = " + sq(o.study) + " AND sp.alteration_type = " + sq(PROFILED_TYPE[type]) +
+ "\n AND (sp.gene_panel_id = 'WES' OR sp.gene_panel_id IN (\n" +
+ " SELECT gene_panel_id FROM " + o.db + ".gene_panel_to_gene_derived WHERE gene = " + sq(g) + "))";
+}
+
function profiledLeafSql(leaf: ProfiledLeaf, ctx: Ctx): string {
const t = PROFILED_TYPE[String(leaf.type || "").toUpperCase()];
if (!t) throw new Error("profiled clause: type must be MUTATION, CNA, or SV");
diff --git a/lib/dsl-reference.ts b/lib/dsl-reference.ts
index 74d549e..0fdabab 100644
--- a/lib/dsl-reference.ts
+++ b/lib/dsl-reference.ts
@@ -18,6 +18,7 @@
import { DOC_VERSION } from "./doc-version.ts";
import { POINTS_CAP } from "./plot-layers.ts";
+import { OQL_STATEMENT_CAP } from "./plot-oql.ts";
import type { TemplateDoc } from "./types.ts";
import type { CustomDataset } from "./custom-data-names.ts";
import { LUAD_BIOPSY_SITE_DEMO } from "./custom-data-demo.ts";
@@ -463,9 +464,9 @@ export const EXAMPLES: DslExample[] = [
},
{
title: "OQL on a plot axis: STK11 or KEAP1 mutant lung cancer",
- demonstrates: "An alteration axis written as an OQL query — several genes, Altered when a case matches any of them — used as a box plot's groups and as a colour.",
+ demonstrates: "An alteration axis written as an OQL query — several genes, Altered when a case matches any of them, or one group per statement — used as a box plot's groups and as a colour.",
tags: ["OQL"],
- note: "Non-small cell lung cancer in MSK-CHORD (7,809 patients). The first view groups tumour mutational burden by the OQL query `STK11: MUT; KEAP1: MUT`: 1,535 patients are Altered (a mutation in either gene), the same count as a cohort defined by that OQL, and 6,274 are Unaltered. The second view groups by sample type and colours each violin by the same query, counting samples rather than patients.",
+ note: "Non-small cell lung cancer in MSK-CHORD (7,809 patients). The first view groups tumour mutational burden by the OQL query `STK11: MUT; KEAP1: MUT`: 1,535 patients are Altered (a mutation in either gene), the same count as a cohort defined by that OQL, and 6,274 are Unaltered. The second view groups by sample type and colours each violin by the same query, counting samples rather than patients. The third gives each statement of `STK11: MUT, KEAP1: MUT, KRAS: G12C` its own group, over samples: 620 KRAS G12C, 459 KEAP1, 377 STK11, 403 with both STK11 and KEAP1 (median TMB 8.6 against 4.1 for the 5,720 with none), the other pairs as their own groups and 109 matching all three. None of these lung samples is on a panel without the three genes, so no group is Not profiled.",
doc: {
study: "msk_chord_2024",
cohorts: {
@@ -478,7 +479,10 @@ export const EXAMPLES: DslExample[] = [
{ kind: "plot", unit: "sample", cohorts: ["NSCLC"], allPoints: false, points: [],
x: { source: "clinical", attribute: "SAMPLE_TYPE" },
y: { source: "clinical", attribute: "TMB_NONSYNONYMOUS" },
- colorBy: { source: "alteration", oql: "STK11: MUT; KEAP1: MUT" } }
+ colorBy: { source: "alteration", oql: "STK11: MUT; KEAP1: MUT" } },
+ { kind: "plot", unit: "sample", cohorts: ["NSCLC"],
+ x: { source: "alteration", oql: "STK11: MUT, KEAP1: MUT, KRAS: G12C", by: "statement" },
+ y: { source: "clinical", attribute: "TMB_NONSYNONYMOUS" } }
]
}
},
@@ -1658,13 +1662,19 @@ bin's width is shared out between the classes in it. That stays an aggregate, so
may not disclose a case can still colour its plot. The key is capped at 12 classes.
**OQL on an axis.** An alteration axis can be an OQL query instead of genes:
-\`{"source":"alteration","oql":"KRAS: G12C; STK11: MUT"}\`. Each case is **Altered** when it matches
-any entry — the portal's own reading of a multi-gene query — and **Unaltered** otherwise; every
-case of the cohort is one or the other, so the unaltered are counted rather than missing. It is
-the same OQL a cohort's \`oql\` leaf takes (mutation classes and protein changes, AMP/HOMDEL,
-FUSION, DRIVER against the pinned OncoKB version, EXP/PROT thresholds), and it matches per patient
-or per sample following the plot's \`unit\`. Because it is a categorical axis, it also works as
-\`colorBy\`/\`ringBy\` and as a Cox or logistic covariate, where "Unaltered" is the reference level.
+\`{"source":"alteration","oql":"KRAS: G12C, STK11: MUT"}\`, its statements separated by a comma, a
+semicolon or a new line (as in a cohort's \`oql\` leaf). Each case is **Altered** when it matches any
+statement — the portal's own reading of a multi-gene query — and **Unaltered** when it matches none
+and is profiled for every statement's gene in the alteration types that statement asks about; a
+case whose gene panel omits one of them is **Not profiled**, never counted as wild type (the
+panel-aware rule of \`docs/PERFORMANCE.md\`). Every case of the cohort is one of the three, so none is
+missing. With \`"by": "statement"\` each statement is its own group, named as written: a case matching
+two is the pair ("KRAS: G12C + STK11: MUT"), three or more "3 or more lines", none Unaltered or Not
+profiled by the same rule (at most ${OQL_STATEMENT_CAP} statements). It is the same OQL a cohort's \`oql\` leaf takes
+(mutation classes and protein changes, AMP/HOMDEL, FUSION, DRIVER against the pinned OncoKB version,
+EXP/PROT thresholds), and it matches per patient or per sample following the plot's \`unit\`.
+Because it is a categorical axis, it also works as \`colorBy\`/\`ringBy\` and as a Cox or logistic
+covariate, where "Unaltered" is the reference level.
OQL is for alteration axes only: a molecular axis stays one gene's value.
**Points.** Every case is a point when there are at most ${POINTS_CAP_TEXT} of them and every study may show
@@ -1905,7 +1915,8 @@ export const AGENT_GRAMMAR = `Document shape:
// | {"source":"molecular","profileType":P,"gene":G,"log":?}
// | {"source":"alteration","gene":G,"by":"mutated|mutationType|cna|driver"?}
// | {"source":"alteration","genes":[G,...(2-20)],"by":"mutated"?} // any-of-N as one category; only "mutated"
- // | {"source":"alteration","oql":"KRAS: G12C; STK11: MUT"} // Altered (matches any entry) vs Unaltered
+ // | {"source":"alteration","oql":"KRAS: G12C, STK11: MUT","by":"any|statement"?}
+ // // any: Altered / Unaltered / Not profiled; statement: one group per statement (max 6)
// two numeric axes -> scatter (binned; every case a point up to ${POINTS_CAP_TEXT}, else sparse-cell outliers); one categorical -> box;
// two categorical -> a contingency table
{"kind":"attrition","cohorts":[names?(max 6)]},
diff --git a/lib/gene-query.ts b/lib/gene-query.ts
index 5236296..512ca7d 100644
--- a/lib/gene-query.ts
+++ b/lib/gene-query.ts
@@ -2,7 +2,7 @@
// (each line or ";"-separated entry a gene and what counts as altered in it) or a plain gene list
// (cBioPortal's, where `[A B]` is one merged row). Pure and browser-safe; the symbols it finds are
// checked against the warehouse's gene table separately (POST /api/genes).
-import { parseOql, type OqlSpec } from "./oql-parse.ts";
+import { parseOql, OQL_SEPARATOR, type OqlSpec } from "./oql-parse.ts";
import { parseGeneList, geneSymbols, normalizeGeneTracks, MAX_GENE_TRACKS, type GeneTrackSpec } from "./gene-tracks.ts";
export type GeneQueryMode = "oql" | "genes";
@@ -64,7 +64,7 @@ export function checkGeneQuery(text: string, mode: GeneQueryMode): QueryCheck {
return { entries: [], genes: [], error: String((e as Error).message || e) };
}
}
- const parts = text.split(/[;\n]/).flatMap(splitBareGenes);
+ const parts = text.split(OQL_SEPARATOR).flatMap(splitBareGenes);
const entries: QueryEntry[] = parts.map((p) => {
const head = (p.match(/^([A-Za-z0-9.-]+)/)?.[1] || "").toUpperCase();
try {
diff --git a/lib/oql-parse.ts b/lib/oql-parse.ts
index 1fa8340..dc99b8e 100644
--- a/lib/oql-parse.ts
+++ b/lib/oql-parse.ts
@@ -27,11 +27,18 @@ const REJECT: Record = {
SOMATIC: "SOMATIC modifier is not supported (all stored mutations are somatic/unknown)"
};
+/** what separates one OQL statement from the next: a semicolon, a new line or a comma */
+export const OQL_SEPARATOR = /[;\n,]/;
+/** a query's statements as typed, trimmed, empty ones dropped */
+export function oqlStatements(q: string): string[] {
+ return q.split(OQL_SEPARATOR).map((s) => s.trim()).filter(Boolean);
+}
+
// -> [{gene, specs: [{type:'MUT'|'CNA'|'SV'|'ANY', pchange?, cls?, cna?, driver}]}]
export function parseOql(q: string): OqlEntry[] {
if (typeof q !== "string" || !q.trim()) throw new Error("empty OQL");
if (q.length > 1000) throw new Error("OQL too long");
- const entries = q.split(/[;\n]/).map(s => s.trim()).filter(Boolean);
+ const entries = oqlStatements(q);
// Each entry compiles to its own UNION-DISTINCT'd subquery (see below), so cost scales
// roughly linearly rather than sitting at a wall near any particular number — 30 covers a
// full pathway gene set (e.g. a 20-30 gene DDR panel) with room to spare, not a measured
diff --git a/lib/plot-oql.ts b/lib/plot-oql.ts
index 51b7358..93fe52a 100644
--- a/lib/plot-oql.ts
+++ b/lib/plot-oql.ts
@@ -1,4 +1,10 @@
-// The two categories an OQL plot axis takes. Browser-safe, so the method panel names an OQL
+// The categories an OQL plot axis takes. Browser-safe, so the method panel names an OQL
// covariate's reference level the way the engine chose it.
export const OQL_ALTERED = "Altered";
export const OQL_UNALTERED = "Unaltered";
+/** a case not profiled for a statement it did not match: not altered as far as anyone knows, but never "Unaltered" */
+export const OQL_NOT_PROFILED = "Not profiled";
+/** one group per statement: a case matching three or more statements */
+export const OQL_SEVERAL = "3 or more lines";
+/** one group per statement: at most this many statements, so the groups (and their pairs) stay readable */
+export const OQL_STATEMENT_CAP = 6;
diff --git a/lib/plot.ts b/lib/plot.ts
index 7bd7692..653c02e 100644
--- a/lib/plot.ts
+++ b/lib/plot.ts
@@ -17,7 +17,14 @@ import { DB } from "./ch.ts";
import { STYLE } from "./oncoprint-style.ts";
import { clinicalTable, ATTR_NAME_RE } from "./custom-data.ts";
import { parseOql, oqlCasesSql } from "./oql.ts";
-import { OQL_ALTERED, OQL_UNALTERED } from "./plot-oql.ts";
+import { OQL_ALTERED, OQL_UNALTERED, OQL_NOT_PROFILED, OQL_SEVERAL, OQL_STATEMENT_CAP } from "./plot-oql.ts";
+import { oqlStatements } from "./oql-parse.ts";
+import { profiledCasesSql } from "./cohort2.ts";
+
+/** the alteration types a statement must be profiled in before "not matched" can mean "not altered" */
+const OQL_PROFILED_TYPES: Partial> = {
+ MUT: ["MUTATION"], CNA: ["CNA"], SV: ["SV"], ANY: ["MUTATION", "CNA", "SV"]
+};
/** what goes on one axis */
export type PlotAxis =
@@ -32,19 +39,22 @@ export type PlotAxis =
| { source: "alteration"; gene: string; by?: AlterationBy }
| { source: "alteration"; genes: string[]; by?: "mutated" }
/**
- * An OQL query as one category: "Altered" when the case matches any entry, the portal's own
- * reading of a multi-gene query, "Unaltered" otherwise. Per patient (any of its samples) or per
- * sample, following the plot's unit.
+ * An OQL query, its statements separated by ";", a new line or ",". `by: "any"` (the default)
+ * is one category: "Altered" when the case matches any statement, the portal's own reading of a
+ * multi-gene query, "Unaltered" when it matches none and is profiled for every statement's gene,
+ * "Not profiled" otherwise. `by: "statement"` gives each statement its own group, a case
+ * matching two a group of the pair, three or more "3 or more lines", none "Unaltered" or "Not
+ * profiled" by the same rule. Per patient (any of its samples) or per sample, following the unit.
*/
- | { source: "alteration"; oql: string }
+ | { source: "alteration"; oql: string; by?: "any" | "statement" }
| { source: "none" };
export type AlterationBy = "mutated" | "mutationType" | "cna";
const AXIS_SOURCES: readonly string[] = ["clinical", "molecular", "alteration", "none"] satisfies PlotAxis["source"][];
/** an alteration axis given as an OQL query, rather than as genes */
-function isOqlAxis(a: PlotAxis): a is { source: "alteration"; oql: string } {
+function isOqlAxis(a: PlotAxis): a is { source: "alteration"; oql: string; by?: "any" | "statement" } {
return a.source === "alteration" && "oql" in a && a.oql != null;
}
-export { OQL_ALTERED, OQL_UNALTERED };
+export { OQL_ALTERED, OQL_UNALTERED, OQL_NOT_PROFILED, OQL_SEVERAL, OQL_STATEMENT_CAP };
/** every gene named on an alteration axis, singular or set, uppercased */
function alterationGenes(a: { gene?: string; genes?: string[] }): string[] {
@@ -119,8 +129,8 @@ export function axisLabel(a: PlotAxis): string {
if (a.source === "clinical") return a.log ? "log10(" + a.attribute + " + 1)" : a.attribute;
if (a.source === "molecular") return a.log ? "log10(" + a.gene + " " + a.profileType + " + 1)" : a.gene + " " + a.profileType;
if (isOqlAxis(a)) {
- const q = a.oql.trim().replace(/\s*[;\n]\s*/g, "; ");
- return (q.length > 48 ? q.slice(0, 47) + "…" : q) + " (OQL)";
+ const q = oqlStatements(a.oql).join("; ");
+ return (q.length > 48 ? q.slice(0, 47) + "…" : q) + (a.by === "statement" ? " (OQL, by line)" : " (OQL)");
}
if (a.source === "alteration") {
const genes = alterationGenes(a);
@@ -158,10 +168,15 @@ export function validateAxis(a: PlotAxis, which: string): void {
throw new Error(which + " axis: a molecular axis needs one gene. Plotting a whole profile would read every gene of every case; name the gene you mean.");
}
if (isOqlAxis(a)) {
- if ("gene" in a || "genes" in a || "by" in a)
- throw new Error(which + " axis: an OQL axis is the query alone; drop gene, genes and by");
+ if ("gene" in a || "genes" in a)
+ throw new Error(which + " axis: an OQL axis is the query alone; drop gene and genes");
+ if (a.by != null && a.by !== "any" && a.by !== "statement")
+ throw new Error(which + " axis: an OQL axis groups by \"any\" (Altered or not) or \"statement\" (one group per line); not " + JSON.stringify(a.by));
try { parseOql(String(a.oql)); }
catch (e) { throw new Error(which + " axis: " + (e as Error).message, { cause: e }); }
+ const n = oqlStatements(String(a.oql)).length;
+ if (a.by === "statement" && n > OQL_STATEMENT_CAP)
+ throw new Error(which + " axis: one group per line takes at most " + OQL_STATEMENT_CAP + " lines; this query has " + n);
return;
}
if (a.source === "alteration") {
@@ -245,14 +260,30 @@ export function axisSelect(a: PlotAxis, ctx: AxisCtx, kind: AxisKind = "num"): s
}
if (isOqlAxis(a)) {
- // every case in the cohort, so "unaltered" is counted rather than missing
- const matched = oqlCasesSql(a.oql, { oncokbPin: ctx.oncokbPin, profiles: ctx.profiles, study, db, unit: ctx.unit });
+ // Every case in the cohort, so "unaltered" is counted rather than missing. Each statement is
+ // matched on its own, and a case it did not match is "Unaltered" only when it is profiled for
+ // that statement's gene in every alteration type the statement asks about (panel-aware, as a
+ // cohort split decides it); a sample whose panel omits the gene is never counted as wild type.
+ // each statement names its group as typed, its gene in capitals
+ const statements = oqlStatements(a.oql).map((st) => st.replace(/^[A-Za-z0-9.-]+/, (g) => g.toUpperCase()).replace(/\s+/g, " "));
const idCol = strip(sample ? "sd.sample_unique_id" : "sd.patient_unique_id", study);
- return "SELECT " + idCol + " AS id, if(" + idCol + " IN (SELECT pid FROM (" + matched + ")), " +
- sq(OQL_ALTERED) + ", " + sq(OQL_UNALTERED) + ") AS v\n" +
- "FROM " + db + ".sample_derived sd\n" +
- "WHERE sd.cancer_study_identifier = " + sq(study) + " AND " + inCohort("sd.patient_unique_id") + "\n" +
- "GROUP BY id";
+ const o = { oncokbPin: ctx.oncokbPin, profiles: ctx.profiles, study, db, unit: ctx.unit };
+ // the case column is `oql_case`, not `id`: ClickHouse would otherwise resolve `id` to another axis's alias
+ const matched = statements.map((st) => "oql_case IN (SELECT pid FROM (" + oqlCasesSql(st, o) + "))");
+ const profiled = statements.map((st) => {
+ const types = [...new Set(parseOql(st)[0].specs.flatMap((sp) => OQL_PROFILED_TYPES[sp.type] || []))];
+ const gene = parseOql(st)[0].gene;
+ // expression and protein thresholds read a value, not a panel: a case with no value is not matched, and not "not profiled"
+ return types.length ? types.map((t) => "oql_case IN (SELECT profiled_id FROM (" + profiledCasesSql(gene, t, { study, db, unit: ctx.unit }) + "))").join(" AND ") : "1";
+ });
+ const none = "if(" + profiled.map((p) => "(" + p + ")").join(" AND ") + ", " + sq(OQL_UNALTERED) + ", " + sq(OQL_NOT_PROFILED) + ")";
+ const value = a.by === "statement"
+ ? "multiIf(length(hit) = 0, " + none + ", length(hit) = 1, hit[1], length(hit) = 2, concat(hit[1], ' + ', hit[2]), " + sq(OQL_SEVERAL) + ")"
+ : "if(length(hit) > 0, " + sq(OQL_ALTERED) + ", " + none + ")";
+ return "SELECT oql_case AS id, " + value + " AS v FROM (\n" +
+ "SELECT oql_case, arrayFilter((l, m) -> m, [" + statements.map(sq).join(", ") + "], [" + matched.join(", ") + "]) AS hit FROM (\n" +
+ "SELECT DISTINCT " + idCol + " AS oql_case FROM " + db + ".sample_derived sd\n" +
+ "WHERE sd.cancer_study_identifier = " + sq(study) + " AND " + inCohort("sd.patient_unique_id") + "))";
}
if (a.source === "alteration") {
const genes = alterationGenes(a), by = a.by || "mutated";
diff --git a/test/gene-query.test.ts b/test/gene-query.test.ts
index 44285b4..32f7be4 100644
--- a/test/gene-query.test.ts
+++ b/test/gene-query.test.ts
@@ -35,6 +35,9 @@ describe("gene query: OQL", () => {
test("the browser-safe parser is the one the engine uses", () => {
assert.equal(parseOql, parsePure);
});
+ test("the engine reads a comma between statements as the dialog does: a cohort's or an axis's OQL alike", () => {
+ assert.deepEqual(parseOql("KRAS: G12C, STK11: MUT;EGFR: AMP\nTP53").map((e) => e.gene), ["KRAS", "STK11", "EGFR", "TP53"]);
+ });
});
describe("gene query: gene list", () => {
diff --git a/test/plot.test.ts b/test/plot.test.ts
index 8bd7d4e..c06dbef 100644
--- a/test/plot.test.ts
+++ b/test/plot.test.ts
@@ -212,7 +212,7 @@ test("log: true reads log10(v + 1), leaves out what has no such log, and says so
assert.equal(axisLabel({ source: "molecular", profileType: "mrna", gene: "ESR1", log: true }), "log10(ESR1 mrna + 1)");
});
-test("an OQL axis: each case is Altered when it matches any entry, Unaltered otherwise", () => {
+test("an OQL axis: Altered when a case matches any statement, Unaltered only when it is profiled for every one", () => {
const oql = { source: "alteration" as const, oql: "KRAS: G12C; STK11: MUT" };
validateAxis(oql, "x");
assert.equal(axisKind(oql), "cat");
@@ -220,9 +220,26 @@ test("an OQL axis: each case is Altered when it matches any entry, Unaltered oth
const sql = axisSelect(oql, ctx, "cat");
// every case of the cohort is a row, so the unaltered are counted, not missing
assert.match(sql, /FROM db\.sample_derived sd/);
- assert.match(sql, /'Altered', 'Unaltered'\) AS v/);
+ assert.match(sql, /if\(length\(hit\) > 0, 'Altered', if\(/);
+ assert.match(sql, /, 'Unaltered', 'Not profiled'\)\) AS v/);
assert.match(sql, /hugo_gene_symbol = 'KRAS'/);
assert.match(sql, /hugo_gene_symbol = 'STK11'/);
+ // panel-aware: a sample whose panel omits the gene is not profiled for it, never wild type
+ assert.match(sql, /sample_to_gene_panel_derived sp[\s\S]*alteration_type = 'MUTATION_EXTENDED'[\s\S]*WHERE gene = 'KRAS'/);
+ assert.match(sql, /WHERE gene = 'STK11'/);
+});
+
+test("an OQL axis by statement: a group per line, a pair for two, '3 or more lines' past that; commas separate lines", () => {
+ const oql = { source: "alteration" as const, oql: "kras: G12C, STK11: MUT\nKEAP1", by: "statement" as const };
+ validateAxis(oql, "x");
+ assert.equal(axisLabel(oql), "kras: G12C; STK11: MUT; KEAP1 (OQL, by line)");
+ const sql = axisSelect(oql, ctx, "cat");
+ assert.match(sql, /\['KRAS: G12C', 'STK11: MUT', 'KEAP1'\]/, "each group is named by its statement, the gene in capitals");
+ assert.match(sql, /length\(hit\) = 2, concat\(hit\[1\], ' \+ ', hit\[2\]\), '3 or more lines'\)/);
+ // a bare gene is any alteration, so it must be profiled for all three kinds before "Unaltered"
+ assert.match(sql, /'COPY_NUMBER_ALTERATION'[\s\S]*WHERE gene = 'KEAP1'/);
+ assert.throws(() => validateAxis({ source: "alteration", oql: "A;B;C;D;E;F;G", by: "statement" }, "x"), /at most 6 lines; this query has 7/);
+ assert.throws(() => validateAxis({ source: "alteration", oql: "KRAS", by: "cna" } as never, "x"), /groups by "any" .* or "statement"/);
});
test("an OQL axis matches samples in sample mode and patients in patient mode", () => {
From bec0a90743606497e856fe5d54966b6a75869b3a Mon Sep 17 00:00:00 2001
From: Aaron Lisman
Date: Tue, 20 Oct 2026 20:27:41 +0000
Subject: [PATCH 04/10] Plot axes: genes and OQL entered through the app's gene
query dialog
Every axis field that takes genes or OQL gets a button beside it that opens
GeneQueryDialog, the app's one way of entering genes, with search over genes, aliases and
gene sets, a check of every entry as it is typed, alias fixes, and recent and saved lists.
That covers an alteration axis's OQL query or gene(s), a molecular axis's gene, and the
colour/ring pickers, which share AxisPicker. The fields stay editable as text.
- OQL opens one line per statement and applies them comma-separated. The limit is 6
statements when grouping by line, 30 otherwise, and the description says how lines are
read for the chosen grouping.
- A gene set opens in gene-list mode, capped at ALTERATION_GENE_SET_CAP (20). A
molecular axis takes exactly one gene.
ALTERATION_GENE_SET_CAP moves to the browser-safe lib/plot-oql.ts so the dialog can
enforce it. The dialog's help says commas separate entries too.
Verified in the browser: the OQL dialog opens with "KRAS: G12C\nRRAGC: MUT", and adding
a line applies "KRAS: G12C, RRAGC: MUT, STK11: MUT" with by: "statement" kept; the gene
dialog turns a TP53 axis into the set [TP53, KRAS]; no page errors. A new e2e check
opens, edits and applies the OQL dialog.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
---
app/_genes/GeneQueryDialog.tsx | 2 +-
app/lab/view-chrome.tsx | 52 +++++++++++++++++++++++++++++-----
e2e.cjs | 23 +++++++++++++++
lib/plot-oql.ts | 7 +++--
lib/plot.ts | 6 ++--
5 files changed, 76 insertions(+), 14 deletions(-)
diff --git a/app/_genes/GeneQueryDialog.tsx b/app/_genes/GeneQueryDialog.tsx
index a1108ee..41222bf 100644
--- a/app/_genes/GeneQueryDialog.tsx
+++ b/app/_genes/GeneQueryDialog.tsx
@@ -245,7 +245,7 @@ function GeneQueryBody({ onOpenChange, title, description, mode, value, onApply,
{h.means}
))}
-
Entries on separate lines (or split by “;”) are alternatives: a patient is altered if any of them matches. {OQL_REFUSED}
+
Entries on separate lines (or split by “;” or “,”) are alternatives: a patient is altered if any of them matches. {OQL_REFUSED}
) : (
diff --git a/app/lab/view-chrome.tsx b/app/lab/view-chrome.tsx
index 82ce335..d6a646e 100644
--- a/app/lab/view-chrome.tsx
+++ b/app/lab/view-chrome.tsx
@@ -8,7 +8,11 @@ import type { AnalysisSource, ViewParticipation } from "../../lib/types.ts";
import { VIEW_HELP } from "../../lib/view-help.ts";
import { SERIES, seriesStyle } from "../../lib/chart/style.ts";
import { Waiting } from "../_oncoprint/OncoprintCard.tsx";
-import { ArrowDown, ArrowUp, Ellipsis, ExternalLink, Lock, LockOpen, Pencil, Plus, Save, Split, StickyNote, Trash2 } from "lucide-react";
+import { ArrowDown, ArrowUp, Ellipsis, ExternalLink, ListChecks, Lock, LockOpen, Pencil, Plus, Save, Split, StickyNote, Trash2 } from "lucide-react";
+import { GeneQueryDialog } from "../_genes/GeneQueryDialog.tsx";
+import type { GeneQueryMode, QueryCheck } from "../../lib/gene-query.ts";
+import { oqlStatements } from "../../lib/oql-parse.ts";
+import { ALTERATION_GENE_SET_CAP, OQL_STATEMENT_CAP } from "../../lib/plot-oql.ts";
import { Button } from "../_ui/button";
import { Input } from "../_ui/input";
import { cn } from "../../lib/cn";
@@ -67,6 +71,23 @@ export function Participation({ p }: { p?: ViewParticipation }) {
);
}
+/**
+ * Beside a field that takes genes or OQL, the app's gene query dialog: search, a check of every
+ * entry as it is typed, alias fixes and remembered lists. The field stays editable as text; the
+ * dialog hands back what it applied.
+ */
+function QueryButton({ mode, value, max, title, description, busy, onApply }: {
+ mode: GeneQueryMode; value: string; max: number; title: string; description: string; busy?: boolean;
+ onApply: (text: string, check: QueryCheck) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ return <>
+
+
+ >;
+}
+
/**
* One axis of a plot. The source decides what else is asked for: a clinical attribute, a gene
* in a molecular profile, or a gene's alterations. It is the same vocabulary the engine uses,
@@ -90,8 +111,10 @@ export function AxisPicker({ side, axis, attrs, profiles, busy, loading, onChang
const set = (next: any) => onChange(next);
const w = stacked ? "w-full max-w-none" : "max-w-44";
/** a control with its name above it when stacked; inline, the toolbar's side label names the run */
- const field = (name: string, control: React.ReactNode) =>
- stacked ?
+ : <>{control}{extra}>;
const srcName: Record = { clinical: "Clinical attribute", molecular: "Molecular profile", alteration: "Genes" };
return (
@@ -128,7 +151,9 @@ export function AxisPicker({ side, axis, attrs, profiles, busy, loading, onChang
{!profiles.includes(a.profileType || "") && }
{profiles.map((pt) => )}
)}
- {field("Gene", set({ ...a, gene })} />)}
+ {field("Gene", set({ ...a, gene })} />,
+ set({ ...a, gene: c.genes[0] || "" })} />)}
>}
{src === "alteration" && (() => {
const isOql = a.oql != null;
@@ -160,8 +185,8 @@ export function AxisPicker({ side, axis, attrs, profiles, busy, loading, onChang
return <>
{stacked && field("Group cases by", groupBy)}
{field(isOql ? "OQL query" : "Gene(s)", isOql
- ? set({ ...a, source: "alteration", oql })} />
- : set({ ...a, source: "alteration", oql })} />
+ : {
// A gene SET (2+) only supports "mutated vs not" — combining several genes'
// mutation types or copy-number states into one category has no honest reading,
@@ -170,7 +195,20 @@ export function AxisPicker({ side, axis, attrs, profiles, busy, loading, onChang
const by = next.length > 1 && a.by && a.by !== "mutated" ? "mutated" : a.by;
set(next.length > 1 ? { ...a, gene: undefined, genes: next, by }
: { ...a, genes: undefined, gene: next[0] || "", by });
- }} />)}
+ }} />,
+ isOql
+ ? set({ ...a, source: "alteration", oql: oqlStatements(text).join(", ") })} />
+ : {
+ const next = c.genes;
+ const by = next.length > 1 && a.by && a.by !== "mutated" ? "mutated" : a.by;
+ set(next.length > 1 ? { ...a, gene: undefined, genes: next, by } : { ...a, genes: undefined, gene: next[0] || "", by });
+ }} />)}
{!stacked && groupBy}
>;
})()}
diff --git a/e2e.cjs b/e2e.cjs
index 9da3c24..dbe8551 100644
--- a/e2e.cjs
+++ b/e2e.cjs
@@ -611,6 +611,29 @@ async function main() {
} catch (e) { soloWhat = String(e.message).split("\n")[0]; }
ok("clicking a layer's button shows it alone, Esc shows every layer, and the document is untouched", soloOk, soloWhat);
+ // An OQL axis is edited through the app's gene query dialog: it opens with one line per
+ // statement, and what it applies is the axis's query.
+ let oqlWhat = "no OQL axis", oqlOk = false;
+ try {
+ await page.goto(BASE + "/lab?tab=analysis&doc=" + encodeURIComponent(Buffer.from(JSON.stringify({
+ study: "msk_chord_2024", cohorts: { All: { all: [] } },
+ views: [{ kind: "plot", cohorts: ["All"], x: { source: "alteration", oql: "KRAS: G12C, STK11: MUT", by: "statement" },
+ y: { source: "clinical", attribute: "TMB_NONSYNONYMOUS" } }]
+ })).toString("base64")));
+ await page.waitForSelector(".plot-side", { timeout: 300000 });
+ await page.getByRole("button", { name: "OQL for this axis" }).click();
+ const dlg = page.locator("[role=dialog]");
+ await dlg.waitFor({ timeout: 30000 });
+ const opened = await dlg.locator("textarea").inputValue();
+ await dlg.locator("textarea").fill("KRAS: G12C\nSTK11: MUT\nKEAP1: MUT");
+ await dlg.getByRole("button", { name: /^Apply/ }).click({ timeout: 30000 });
+ await page.waitForFunction(() => /KEAP1/.test(JSON.parse(document.querySelector("#docjson").value).views[0].x.oql), null, { timeout: 30000 });
+ const x = await page.evaluate(() => JSON.parse(document.querySelector("#docjson").value).views[0].x);
+ oqlOk = opened === "KRAS: G12C\nSTK11: MUT" && x.oql === "KRAS: G12C, STK11: MUT, KEAP1: MUT" && x.by === "statement";
+ oqlWhat = "opened " + JSON.stringify(opened) + " -> axis " + JSON.stringify(x);
+ } catch (e) { oqlWhat = String(e.message).split("\n")[0]; }
+ ok("a plot's OQL axis is edited in the gene query dialog, and its lines become the axis's statements", oqlOk, oqlWhat);
+
// The oncoprint's gear keeps ONLY the link out to cBioPortal; genes moved
// to a named button and a modal, so the gear is not where editing hides.
let gearSplit = "no oncoprint rendered", gearOk = false;
diff --git a/lib/plot-oql.ts b/lib/plot-oql.ts
index 93fe52a..3eecde3 100644
--- a/lib/plot-oql.ts
+++ b/lib/plot-oql.ts
@@ -1,5 +1,6 @@
-// The categories an OQL plot axis takes. Browser-safe, so the method panel names an OQL
-// covariate's reference level the way the engine chose it.
+// The categories an alteration plot axis takes, and its limits. Browser-safe, so the method panel
+// names an OQL covariate's reference level the way the engine chose it, and the axis's gene dialog
+// holds the reader to the limits the engine enforces.
export const OQL_ALTERED = "Altered";
export const OQL_UNALTERED = "Unaltered";
/** a case not profiled for a statement it did not match: not altered as far as anyone knows, but never "Unaltered" */
@@ -8,3 +9,5 @@ export const OQL_NOT_PROFILED = "Not profiled";
export const OQL_SEVERAL = "3 or more lines";
/** one group per statement: at most this many statements, so the groups (and their pairs) stay readable */
export const OQL_STATEMENT_CAP = 6;
+/** a gene-set alteration axis takes at most this many genes */
+export const ALTERATION_GENE_SET_CAP = 20;
diff --git a/lib/plot.ts b/lib/plot.ts
index 653c02e..6ab959e 100644
--- a/lib/plot.ts
+++ b/lib/plot.ts
@@ -17,7 +17,7 @@ import { DB } from "./ch.ts";
import { STYLE } from "./oncoprint-style.ts";
import { clinicalTable, ATTR_NAME_RE } from "./custom-data.ts";
import { parseOql, oqlCasesSql } from "./oql.ts";
-import { OQL_ALTERED, OQL_UNALTERED, OQL_NOT_PROFILED, OQL_SEVERAL, OQL_STATEMENT_CAP } from "./plot-oql.ts";
+import { OQL_ALTERED, OQL_UNALTERED, OQL_NOT_PROFILED, OQL_SEVERAL, OQL_STATEMENT_CAP, ALTERATION_GENE_SET_CAP } from "./plot-oql.ts";
import { oqlStatements } from "./oql-parse.ts";
import { profiledCasesSql } from "./cohort2.ts";
@@ -54,7 +54,7 @@ const AXIS_SOURCES: readonly string[] = ["clinical", "molecular", "alteration",
function isOqlAxis(a: PlotAxis): a is { source: "alteration"; oql: string; by?: "any" | "statement" } {
return a.source === "alteration" && "oql" in a && a.oql != null;
}
-export { OQL_ALTERED, OQL_UNALTERED, OQL_NOT_PROFILED, OQL_SEVERAL, OQL_STATEMENT_CAP };
+export { OQL_ALTERED, OQL_UNALTERED, OQL_NOT_PROFILED, OQL_SEVERAL, OQL_STATEMENT_CAP, ALTERATION_GENE_SET_CAP };
/** every gene named on an alteration axis, singular or set, uppercased */
function alterationGenes(a: { gene?: string; genes?: string[] }): string[] {
@@ -62,8 +62,6 @@ function alterationGenes(a: { gene?: string; genes?: string[] }): string[] {
}
/** a patient-mode category for a patient whose samples carry different values */
export const MULTIPLE = "Multiple";
-/** a gene-set alteration axis takes at most this many genes */
-export const ALTERATION_GENE_SET_CAP = 20;
export type AxisKind = "num" | "cat" | "none";
export type PlotShape = "scatter" | "box" | "table" | "waterfall";
From d14e036d71e3284f63a48fe0d8bf763e3d3b26b1 Mon Sep 17 00:00:00 2001
From: Aaron Lisman
Date: Tue, 20 Oct 2026 21:45:02 +0000
Subject: [PATCH 05/10] Plot axes read the study's own profiles: one Data
choice, and what an axis is follows the profile
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An axis is now a clinical attribute, one of the study's own molecular profiles, or a query
across genes. A profile axis, {source: "profile", profile, gene}, is read as the study
describes the profile in genetic_profile (alteration type and datatype), never from its
name:
- a mutation profile (MAF) by mutation type, or `by: "mutated"`;
- a structural-variant profile by whether the case has one;
- a discrete copy-number profile by its own calls (Deep deletion, Shallow deletion,
Diploid, Gain, Amplification, drawn in that order), not only the ±2 events;
- a continuous one (mRNA, z-scores, log2 copy number, protein) as a number.
lib/profile-catalog.ts (browser-safe) holds that reading, and lib/plot-profile.ts resolves
a profile axis against every study a view draws over into the forms the engine already
runs (molecular with `discrete`, alteration with `by`). A profile a study lacks is refused
with the list it has, and one two pooled studies describe differently is refused.
Generic-assay profiles are offered but disabled until they get an entity picker. Plots,
Cox and logistic all resolve their axes this way.
Panel-aware single-gene axes: a case with no mutation, high-level copy-number call or SV
in the gene is "Not mutated" / "Neutral" / "No structural variant" only when it is
profiled for that gene in that alteration type; otherwise it is "Not profiled", as the
OQL axis already is. In TCGA BRCA, 18 samples outside the mutation profile were counted as
not mutated before.
The page: studyProfiles() also returns each study's catalog; Explore returns it
(profileCatalog), and the lab and portal catalogs carry it. AxisPicker's Data is one
grouped list: Clinical, then each alteration type with the study's own profile names under
it, then Across genes (OQL, gene set). The fields that follow come from the choice:
attribute; gene (with the gene query dialog) plus "Plot mutations by" for a mutation
profile; or the query. The Results page picks its expression and discrete copy-number
profiles by type and datatype, no longer by matching profile names, and its Plots tab uses
profile axes. The co-expression pair plot uses profile axes too. The DSL reference
documents the profile axis, and a gallery example shows it.
Verified on brca_tcga_pan_can_atlas_2018:
- ERBB2 GISTIC x mRNA z-scores: Diploid 480, Shallow deletion 260, Gain 206,
Amplification 122.
- TP53 mutation type: Missense 209, Truncating 102, Splice 24, Inframe 5, Multiple 4,
Not mutated 706, Not profiled 18.
- ERBB2 SV: 15 / 1,037 / 18 not profiled.
- msk_chord's `cna` profile shows its five levels, plus one sample whose stored value is
-1.5, shown as written.
- The quoted APC Cox and logistic results in the gallery are unchanged.
In the browser, picking Mutations in the Data list rewrites the axis and redraws, and the
Cox covariates use the same picker; no page errors. Unit tests cover the reading,
resolution, refusals and SQL; the new example's cohort is pinned.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
---
app/_oncoprint/OncoprintCard.tsx | 5 +-
app/api/explore/route.ts | 9 +-
app/lab/page.tsx | 4 +-
app/lab/plot-controls.tsx | 15 ++-
app/lab/portal-bodies.tsx | 2 +-
app/lab/use-catalogs.ts | 9 +-
app/lab/use-view-options.ts | 5 +-
app/lab/view-cards.tsx | 23 ++--
app/lab/view-chrome.tsx | 198 ++++++++++++++++--------------
app/portal/PortalTabs.tsx | 2 +-
app/portal/comparison/page.tsx | 4 +-
app/portal/results/page.tsx | 9 +-
app/portal/study-known.ts | 16 ++-
docs/dsl.md | 173 ++++++++++++++++++--------
e2e.cjs | 2 +-
lib/analysis.ts | 22 +++-
lib/dsl-reference.ts | 49 ++++++--
lib/plot-draw.ts | 2 +-
lib/plot-profile.ts | 42 +++++++
lib/plot.ts | 68 ++++++++--
lib/profile-catalog.ts | 62 ++++++++++
lib/types.ts | 3 +
lib/views/cox.ts | 12 +-
lib/views/logistic.ts | 14 ++-
lib/views/plot.ts | 23 +++-
lib/views/run.ts | 3 +-
lib/views/shared.ts | 6 +
test/fixtures/example-counts.json | 5 +-
test/plot-profile.test.ts | 84 +++++++++++++
test/plot.test.ts | 2 +-
30 files changed, 657 insertions(+), 216 deletions(-)
create mode 100644 lib/plot-profile.ts
create mode 100644 lib/profile-catalog.ts
create mode 100644 test/plot-profile.test.ts
diff --git a/app/_oncoprint/OncoprintCard.tsx b/app/_oncoprint/OncoprintCard.tsx
index 3b8e5da..eac249a 100644
--- a/app/_oncoprint/OncoprintCard.tsx
+++ b/app/_oncoprint/OncoprintCard.tsx
@@ -6,6 +6,7 @@
// document) and hands back the next view; whether that re-runs is the page's to decide, with
// the rule from view-display.ts: the figure's own display options and sort never re-run.
import React, { useState } from "react";
+import type { ProfileInfo } from "../../lib/profile-catalog.ts";
import OncoprintFigure, { type ColumnSelection } from "./OncoprintFigure.tsx";
import { figureDisplayOf, withFigureDisplay, sortKeysOf, withSortKeys, sortKeyText } from "./view-display.ts";
import { normalizeGeneTracks, type GeneTrackSpec } from "../../lib/gene-tracks.ts";
@@ -199,7 +200,9 @@ export function TracksMenu({ attrs, profiles, clinical, heatmap, cohorts, derive
// ---- the card ----------------------------------------------------------------------------------
-export interface TrackCatalog { attrs: { attribute: string; kind: string; patients: number }[]; profiles: string[]; loading: boolean }
+export interface TrackCatalog { attrs: { attribute: string; kind: string; patients: number }[]; profiles: string[]; loading: boolean;
+ /** every profile as the studies describe them, for a plot axis's Data picker */
+ profileCatalog?: ProfileInfo[] }
export function OncoprintCard({ view, spec, busy, cohortNames, catalog, onView, onGenes, onCreateCohort, onSaveFeature, onContract, contract, note, studies, uploads, onUndo }: {
/** the view as the document holds it */
diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts
index bb48ff0..a054154 100644
--- a/app/api/explore/route.ts
+++ b/app/api/explore/route.ts
@@ -8,6 +8,7 @@ import { cacheLife } from "next/cache";
import { pack, throughCache, dataVersion } from "../../../lib/next-cache.ts";
import { studyProfiles, resolveStudy } from "../../../lib/analysis.ts";
import { chQuery } from "../../../lib/ch.ts";
+import type { ProfileInfo } from "../../../lib/profile-catalog.ts";
import { withRequestCustomData } from "../../../lib/custom-data-server.ts";
import { assertCustomDataPresent } from "../../../lib/custom-data.ts";
import { idSetRefsIn } from "../../../lib/id-sets.ts";
@@ -112,9 +113,13 @@ async function exploreFor(body: any) {
if (!local.length) {
return remoteParts.length === 1 ? remoteParts[0] : mergeExplore(remoteParts);
}
+ const { catalog: _c, ...oneProfiles } = profilesByStudy.get(local[0]) || {};
const localResult = local.length === 1
? await buildExplore({ study: local[0], draft, oncokbPin: pin, geneSearch, attributes, geneCharts, derived,
- ...profilesByStudy.get(local[0]), conn: connByStudy.get(local[0]) })
+ ...oneProfiles, conn: connByStudy.get(local[0]) })
: await buildExploreFederated({ studies: local, draft, oncokbPin: pin, profilesByStudy, connByStudy, geneSearch, attributes, geneCharts, derived });
- return remoteParts.length ? mergeExplore([localResult, ...remoteParts]) : localResult;
+ // every local study's profiles as it describes them, once per profile id, for a plot axis's Data picker
+ const profileCatalog = [...new Map(local.flatMap((st) => (profilesByStudy.get(st)?.catalog || []).map((p: ProfileInfo) => [p.profile, p] as const))).values()];
+ const result = remoteParts.length ? mergeExplore([localResult, ...remoteParts]) : localResult;
+ return { ...result, profileCatalog };
}
diff --git a/app/lab/page.tsx b/app/lab/page.tsx
index 321dd3f..c48e743 100644
--- a/app/lab/page.tsx
+++ b/app/lab/page.tsx
@@ -353,7 +353,7 @@ export default function AnalysisPage() {
const { setAddChartOpen, addClause, addClauses, addDraftToAnalysis, addMode, analyzeDraft, barDef, clearDraft, closeAddCohort, exMsg, exSel, exStudies, explore, exploreBusy, fetchExplore, geneCharts, openAddCohort, openCustomManager, parseDraft, removeDraftClause, setExStudies, setExploreStudies } = exploreState;
const openTab = (t: typeof TABS[number]) => { goTab(t); if (t === "explore" && !explore) fetchExplore(); };
const viewOptions = useViewOptions({ cat, catLoading, doc, explore, exploreBusy, result, trackAttrs, trackAttrsLoading, studies });
- const { VIEW_KINDS, assayOptions, attrsLoading, cantDrawCohort, catalogAttrs, contProfile, drawsOwnHead, geneChoices, numericAttrs, profileOptions, viewAttrs, viewEvents, viewGenes } = viewOptions;
+ const { VIEW_KINDS, assayOptions, attrsLoading, cantDrawCohort, catalogAttrs, contProfile, drawsOwnHead, geneChoices, numericAttrs, profileOptions, profileCatalog, viewAttrs, viewEvents, viewGenes } = viewOptions;
const viewEdits = useViewEdits({ VIEW_KINDS, assayOptions, busy, cantDrawCohort, catalogAttrs, contProfile, doc, docs, numericAttrs, profileOptions, result, setChooser, setToast, study, viewAttrs, viewEvents, viewGenes, viewIds });
const { addCohortToView, addView, cohortChoice, docView, emptyViewHint, maxCohortsOf, moveView, ocView, openCohortChooser, patchView, removeView, removeViews, setViewAttrs, setViewCohorts, setViewGenes, updateView, updateViewLocal, viewTitle } = viewEdits;
@@ -361,7 +361,7 @@ export default function AnalysisPage() {
/** what Explore's sections read and the edits they can make (app/lab/explore-sections.tsx) */
const exploreCtx: ExploreCtx = { ...exploreState, menuStore: menuOpenRef.current, showClauseData, studies };
/** what every view card reads and the edits it can make (app/lab/view-cards.tsx) */
- const viewCtx = { VIEW_KINDS, docHistory, study, assayOptions, attrsLoading, busy, cat, catLoading, catalogAttrs, cohortChoice, cohortFromSelection, contracts, doc, docView, drawsOwnHead, explore, featureFromSelection, maxCohortsOf, menuOpenRef, moveView, noteEdit, ocView, openCohortChooser, openGenes, patchView, pendingKeys, profileOptions, removeView, setContracts, setViewAttrs, setViewCohorts, trackAttrs, trackAttrsLoading, updateView, updateViewLocal, viewEvents, viewIds };
+ const viewCtx = { VIEW_KINDS, docHistory, study, assayOptions, attrsLoading, busy, cat, catLoading, catalogAttrs, cohortChoice, cohortFromSelection, contracts, doc, docView, drawsOwnHead, explore, featureFromSelection, maxCohortsOf, menuOpenRef, moveView, noteEdit, ocView, openCohortChooser, openGenes, patchView, pendingKeys, profileOptions, profileCatalog, removeView, setContracts, setViewAttrs, setViewCohorts, trackAttrs, trackAttrsLoading, updateView, updateViewLocal, viewEvents, viewIds };
/** what the Analysis tab's sections read and the actions they take (app/lab/analysis-sections.tsx) */
const analysisCtx: AnalysisCtx = { lockCohorts, unlockCohort, unlockAll, canSave, analysisStudies, requestLogin, signIn: (!customState.enabled ? "off" : !loggedIn ? "locked" : "ready") as SignInState, VIEW_KINDS, addView, docHistory, applyText, busy, catalogAttrs, clearAnalysis, doc, docDialog, docText, docs, explore, fetchExplore, cohortCells, cohortSaveState, cohortSource, openCohortCells, emptyViewHint, genesFor, goTab, loggedIn, menuOpenRef, moveView, openCohortChooser, openCohortEdit, openCustomManager, openWorkspaceSave, removeCohort, removePartitionNamed, removeView, removeViews, renamePartitionRow, result, saveCohortToLibrary, saved, setCdFocus, setGenesFor, setPartitionDef, setUpgradeNote, setViewGenes, showClauseData, spaces, splitCohortBy, splitsFlat, studyNames, tab, updateFromSaved, upgradeNote, viewCtx, viewIds, viewTitle };
// what the page offers the researcher's AI assistant (app/lab/agent-tools.ts), current every render
diff --git a/app/lab/plot-controls.tsx b/app/lab/plot-controls.tsx
index 7625114..4b9ef44 100644
--- a/app/lab/plot-controls.tsx
+++ b/app/lab/plot-controls.tsx
@@ -13,6 +13,7 @@ import { Choice, OptionSelect } from "../_ui/choice.tsx";
import { Input } from "../_ui/input";
import { cn } from "../../lib/cn";
import { CHIP, FIELD } from "../_ui/toolbar.ts";
+import type { ProfileInfo } from "../../lib/profile-catalog.ts";
import { DEFAULT_POINT_LAYERS, POINTS_CAP, LAYER_COLORS, POINT_LAYER_CAP, POINT_RULE_N, POINT_RULE_N_MAX, layerLabel, type PointLayerSpec } from "../../lib/plot-layers.ts";
type Update = (fn: (view: any) => any) => void;
@@ -21,7 +22,7 @@ const CAP = POINTS_CAP.toLocaleString("en-US");
function LogToggle({ spec, side, numeric, busy, update }: { spec: any; side: "x" | "y"; numeric: boolean; busy: boolean; update: Update }) {
const axis = spec[side];
- if (!numeric || !axis || !("source" in axis) || (axis.source !== "clinical" && axis.source !== "molecular")) return null;
+ if (!numeric || !axis || !("source" in axis) || (axis.source !== "clinical" && axis.source !== "molecular" && axis.source !== "profile")) return null;
return (
>}
{drawn.length > 0 &&
- show
- {drawn.map((l) => (
-
- ))}
+ show
+ {chip("base", LAYER_BASE, "all " + unit + "s (" + plot!.n.toLocaleString() + ")")}
+ {drawn.map((l) => chip(String(l.i), l.color, l.label + " (" + l.n.toLocaleString() + ")"))}
}
}
-
{children}
+
{children}
diff --git a/app/lab/view-chrome.tsx b/app/lab/view-chrome.tsx
index 49c02bb..9d1f115 100644
--- a/app/lab/view-chrome.tsx
+++ b/app/lab/view-chrome.tsx
@@ -173,9 +173,9 @@ export function AxisPicker({ side, axis, attrs, catalog, busy, loading, onChange
{!groups.some((g) => g.items.some((i) => i.value === data)) && data !== "none" &&
{data.replace(/^profile:/, "")} (not in this study)}
{groups.map((g, gi) => (
- 0 && "mt-1 border-t pt-1")}>
- {g.label}
- {g.items.map((i) => {i.label})}
+ 0 && "mt-1.5")}>
+ {g.label}
+ {g.items.map((i) => {i.label})}
))}
diff --git a/e2e.cjs b/e2e.cjs
index 93632b2..e7a2231 100644
--- a/e2e.cjs
+++ b/e2e.cjs
@@ -592,24 +592,31 @@ async function main() {
} catch (e) { layerWhat = String(e.message).split("\n")[0]; }
ok("a plot's point layers are listed in the sidebar, and adding one draws it with a key entry", layerOk, layerWhat);
- // Showing one layer alone is instant and the browser's own: the others step back, Esc restores.
+ // Hiding the plotted cases or a layer is instant and the browser's own: its group steps back,
+ // a case it drew reappears in the next layer holding it, Esc shows everything.
let soloWhat = "no layer buttons", soloOk = false;
try {
- // every group, a layer's shared cases included: [layer, opacity, display]
const groups = () => page.evaluate(() => [...document.querySelectorAll(".plot-figure g[data-layer]")]
- .map((g) => [g.getAttribute("data-layer"), getComputedStyle(g).opacity, getComputedStyle(g).display]));
+ .map((g) => [g.getAttribute("data-layer") + (g.hasAttribute("data-shared") ? "<" + g.getAttribute("data-owner") : ""), getComputedStyle(g).opacity, getComputedStyle(g).display]));
const docBefore = await page.locator("#docjson").inputValue();
- await page.locator(".plot-solo button").last().click();
- const solo = await page.locator(".plot-figure").getAttribute("data-solo");
- const alone = await groups();
+ const chips = page.locator(".plot-solo button");
+ const names = await chips.allInnerTexts();
+ await chips.nth(1).click(); // the first layer
+ const hid = await groups();
+ await chips.first().click(); // the plotted cases too
+ const both = await groups();
await page.keyboard.press("Escape");
const back = await groups();
- soloOk = solo != null && alone.some(([k]) => k === solo) &&
- alone.every(([k, o, d]) => k === solo ? o === "1" && d !== "none" : o !== "1") &&
- back.every(([, o]) => o === "1") && (await page.locator("#docjson").inputValue()) === docBefore;
- soloWhat = "layer " + solo + " alone " + JSON.stringify(alone) + "; after Esc " + JSON.stringify(back) + "; document unchanged";
+ const of = (gs, k) => gs.filter(([key]) => key === k);
+ soloOk = /^all samples/.test(names[0]) && names.length >= 2 &&
+ of(hid, "0").every(([, o]) => o !== "1") && of(hid, "base").every(([, o]) => o === "1") &&
+ hid.filter(([k]) => /<0$/.test(k)).every(([, o, d]) => o === "1" && d !== "none") &&
+ of(both, "base").every(([, o]) => o !== "1") &&
+ back.filter(([k]) => !k.includes("<")).every(([, o]) => o === "1") &&
+ (await page.locator("#docjson").inputValue()) === docBefore;
+ soloWhat = "chips " + JSON.stringify(names) + "; first layer hidden " + JSON.stringify(hid) + "; after Esc " + JSON.stringify(back) + "; document unchanged";
} catch (e) { soloWhat = String(e.message).split("\n")[0]; }
- ok("clicking a layer's button shows it alone, Esc shows every layer, and the document is untouched", soloOk, soloWhat);
+ ok("the plotted cases and each layer can be hidden from their chips, shared cases follow, Esc shows everything, and the document is untouched", soloOk, soloWhat);
// An OQL axis is edited through the app's gene query dialog: it opens with one line per
// statement, and what it applies is the axis's query.
diff --git a/lib/figures/plot.ts b/lib/figures/plot.ts
index 63e114e..4d8f899 100644
--- a/lib/figures/plot.ts
+++ b/lib/figures/plot.ts
@@ -5,7 +5,7 @@
// picture draws for a hundred cases or four hundred thousand. The only cases drawn one by one are
// the points the engine chose (every case under the cap, the outliers over it), at most POINTS_CAP.
import type { ViewResult } from "../types.ts";
-import type { HighlightPoint, PlotData } from "../plot.ts";
+import type { HighlightPoint, PlotData, PlotLayer } from "../plot.ts";
import { isAbsent, jitterFor } from "../plot-draw.ts";
import { LAYER_BASE } from "../plot-layers.ts";
import {
@@ -67,12 +67,18 @@ function layerItems(d: PlotData): LegendItem[] {
/** the layers drawn at all: with any, the plotted cohort steps back to a grey so their colours read */
const drawnLayers = (d: PlotData) => (d.layers || []).filter((l) => l.shown !== "none");
/**
- * One layer's marks as a group tagged with its place in the view's list, so a page can show that
- * layer alone; `base` is the plotted cohort. A layer's cases an earlier layer drew are a second,
- * hidden group the page reveals only when that layer stands alone.
+ * One layer's marks as a group tagged with its place in the view's list, so a page can hide it;
+ * `base` is the plotted cohort. A layer's cases an earlier layer drew are hidden groups, one per
+ * layer that drew them (`owner`), which the page shows when that owner is hidden.
*/
-const layerGroup = (key: number | "base", marks: string[], shared = false) =>
- marks.length ? `${marks.join("")}` : "";
+const layerGroup = (key: number | "base", marks: string[], owner?: number) =>
+ marks.length ? `${marks.join("")}` : "";
+/** a layer's shared cases, grouped by the layer that drew each */
+function sharedGroups(h: PlotLayer, key: number, mark: (pt: HighlightPoint) => string[]): string {
+ const by = new Map();
+ (h.shared || []).forEach((pt, i) => { const o = h.sharedOwners?.[i] ?? 0; (by.get(o) ?? by.set(o, []).get(o)!).push(...mark(pt)); });
+ return [...by.entries()].map(([o, marks]) => layerGroup(key, marks, o)).join("");
+}
function scatter(v: ViewResult, d: PlotData, W: number, p: Pal): string {
const { x0, x1, y0, y1 } = d.extent!, [gx, gy] = d.grid!;
@@ -122,7 +128,7 @@ function scatter(v: ViewResult, d: PlotData, W: number, p: Pal): string {
}
if (h.shown === "points") {
out.push(layerGroup(key, h.points!.flatMap((pt) => "x" in pt ? [dot(pt, h.color)] : [])));
- out.push(layerGroup(key, (h.shared || []).flatMap((pt) => "x" in pt ? [dot(pt, h.color)] : []), true));
+ out.push(sharedGroups(h, key, (pt) => "x" in pt ? [dot(pt, h.color)] : []));
}
}
// the fitted line, over every case rather than over what is visible, clipped to the frame
@@ -304,7 +310,7 @@ function box(v: ViewResult, d: PlotData, W0: number, p: Pal): string {
});
const key = d.layers!.indexOf(h);
out.push(layerGroup(key, marks(h.points!, true)));
- out.push(layerGroup(key, marks(h.shared || [], false), true));
+ out.push(sharedGroups(h, key, (pt) => marks([pt], false)));
}
});
out.push(``);
diff --git a/lib/plot.ts b/lib/plot.ts
index 89aae63..e4f6a04 100644
--- a/lib/plot.ts
+++ b/lib/plot.ts
@@ -760,8 +760,10 @@ export interface PlotLayer {
n: number;
/** points drawn, once cases an earlier layer already drew are left to it */
drawn?: number;
- /** the layer's cases an earlier layer drew: hidden on the figure, shown when the reader picks this layer alone */
+ /** the layer's cases an earlier layer drew: hidden on the figure, shown when the layer that drew them is hidden */
shared?: HighlightPoint[];
+ /** for each shared case, the place in the list of the layer that drew it */
+ sharedOwners?: number[];
shown: "points" | "density" | "boxes" | "none";
/** why it is not drawn as points, when it is not */
why?: string;
diff --git a/lib/views/plot.ts b/lib/views/plot.ts
index 85a1f0d..7e8bc91 100644
--- a/lib/views/plot.ts
+++ b/lib/views/plot.ts
@@ -132,7 +132,8 @@ async function pointLayers(pv: PlotView, data: PlotData, c: ViewRun, cn: string,
const def = c.doc.cohorts[cn];
const xk = data.x.kind, yk = data.y.kind;
const closed = sites.filter((s) => !isPatientLevel(c.resolvers[s]));
- const seen = new Set();
+ // each drawn case, and the layer (its place in the list) that drew it
+ const seen = new Map();
let budget = POINTS_CAP;
const out: PlotLayer[] = [];
for (const [i, l] of specs.entries()) {
@@ -147,10 +148,12 @@ async function pointLayers(pv: PlotView, data: PlotData, c: ViewRun, cn: string,
const { layer, keys } = picked;
if (layer.shown === "points" && layer.points && keys) {
const kept = layer.points.filter((_, j) => !seen.has(keys[j]));
- const shared = layer.points.filter((_, j) => seen.has(keys[j]));
- for (const k of keys) seen.add(k);
- layer.drawn = kept.length; layer.points = kept;
- if (shared.length) layer.shared = shared;
+ const sharedAt = keys.map((k, j) => seen.has(k) ? j : -1).filter((j) => j >= 0);
+ const owners = sharedAt.map((j) => seen.get(keys[j])!);
+ for (const k of keys) if (!seen.has(k)) seen.set(k, i);
+ layer.drawn = kept.length;
+ if (sharedAt.length) { layer.shared = sharedAt.map((j) => layer.points![j]); layer.sharedOwners = owners; }
+ layer.points = kept;
budget -= kept.length;
}
out.push(layer);
diff --git a/test/view-svg.test.ts b/test/view-svg.test.ts
index 72dae3b..d9248b7 100644
--- a/test/view-svg.test.ts
+++ b/test/view-svg.test.ts
@@ -404,16 +404,16 @@ describe("plot point layers: subsets drawn over the plotted cohort", () => {
assert.match(svg, /1 value of a point layer lies more than a whisker span past the/);
});
- test("each layer is a group tagged with its place in the list, its shared cases a hidden group of their own", () => {
+ test("each layer is a group tagged with its place in the list, its shared cases hidden groups by the layer that drew them", () => {
const svg = viewToSvg(scatter([
hl({ points: [{ id: "a", x: 10, y: 5 }] }),
- hl({ name: "S", color: LAYER_COLORS[1], points: [{ id: "b", x: 20, y: 8 }], shared: [{ id: "a", x: 10, y: 5 }] }),
+ hl({ name: "S", color: LAYER_COLORS[1], points: [{ id: "b", x: 20, y: 8 }], shared: [{ id: "a", x: 10, y: 5 }], sharedOwners: [0] }),
]))!;
assert.match(svg, //);
assert.match(svg, /]*fill="#0072B2"/);
- assert.match(svg, / {