From 2e82f3a2e2a86a235f96ed4b0ab501339f2ea54c Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 16 Oct 2026 03:50:44 +0000 Subject: [PATCH 1/3] Portal pages: the lab's header, a summary card, the lab's tabs and ViewHead The Results and Comparison pages now look like the rest of the app while keeping cBioPortal's tab layout. The page header is the lab's own app header. A summary card holds the study, the query (genes as chips), the headline altered count with its share of queried patients, and the groups with their figure swatch and size, plus what the page left out for this study. On the Results page, "Modify query" opens the study/genes form in that card. The page tabs use the lab's tab style, now shared as PAGE_TAB in toolbar.ts. Sub-tabs and the Mutations tab's gene choice are segmented tabs in a toolbar row. Every card renders through ViewHead (title, groups drawn, help, timing, participation). Loading shows skeletons. The legacy .portal-* rules are removed and the new chrome is Tailwind (data-tw). Verified: screenshots of the Oncoprint, Mutations and Survival tabs, the comparison page, the open query form and a 390px viewport (no horizontal scroll, no console errors); scripts/gate.sh green, including the Results page's table and lollipop e2e checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TyVfAhLnjshk2kWewGnLum --- app/_ui/toolbar.ts | 3 + app/globals.css | 11 ---- app/lab/page.tsx | 3 +- app/portal/PortalTabs.tsx | 83 +++++++++++++------------- app/portal/comparison/page.tsx | 33 ++++++----- app/portal/portal-chrome.tsx | 105 +++++++++++++++++++++++++++++++++ app/portal/results/page.tsx | 57 +++++++++--------- 7 files changed, 202 insertions(+), 93 deletions(-) create mode 100644 app/portal/portal-chrome.tsx diff --git a/app/_ui/toolbar.ts b/app/_ui/toolbar.ts index b7dc3c00..ae17535d 100644 --- a/app/_ui/toolbar.ts +++ b/app/_ui/toolbar.ts @@ -9,6 +9,9 @@ export const CHIP = "rounded-pill px-2.5 text-sm font-normal aria-pressed:border /** a text or number field in a view toolbar */ export const FIELD = "h-ctl bg-card text-md md:text-md"; +/** a page's own sections (the lab's Explore / Saved / Analysis, a portal page's tabs): a line tab one step larger than LINE_TAB */ +export const PAGE_TAB = "h-auto flex-none rounded-none px-3.5 pt-2 pb-1.5 text-base leading-normal after:bottom-[-1px] data-[state=active]:text-accent-foreground data-[state=active]:after:bg-primary"; + /** a row of page tabs underlined by a rule (TabsList variant "line"), and one of its tabs */ export const LINE_TABS = "w-full flex-wrap justify-start gap-1.5 border-b border-border p-0 group-data-[orientation=horizontal]/tabs:h-auto"; export const LINE_TAB = "h-auto flex-none rounded-none px-3 pt-1.5 pb-1.5 text-md after:bottom-[-1px] data-[state=active]:text-accent-foreground data-[state=active]:after:bg-primary"; diff --git a/app/globals.css b/app/globals.css index ef37af5c..fcf9c291 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1557,14 +1557,6 @@ td.camp-links { flex-direction: column; align-items: flex-start; gap: var(--s1); .xc-cell { fill: var(--accent); } .xc-brush { fill: color-mix(in srgb, var(--accent) 15%, transparent); stroke: var(--accent); stroke-dasharray: 3 2; } -/* the portal pages (app/portal): cBioPortal's Results and Comparison layouts */ -.portal-main { max-width: 1400px; margin: 0 auto; padding: var(--s4); } -.portal-head h1 { font-size: var(--t-xl); margin: var(--s2) 0; } -.portal-head a { color: var(--accent-ink); text-decoration: none; font-weight: 600; } -.portal-cohorts { display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; } -.portal-view { margin-top: var(--s3); } -.portal-view .vtitle { font-size: var(--t-md); margin: 0 0 var(--s2); } - /* the portal-style card bodies (app/lab/portal-bodies.tsx) */ .pb-scroll { overflow-x: auto; } .pb-row { display: flex; gap: var(--s4); align-items: flex-start; flex-wrap: wrap; } @@ -1583,9 +1575,6 @@ td.camp-links { flex-direction: column; align-items: flex-start; gap: var(--s1); .ds-controls .spacer { flex: 1; } .ds-table .ds-dl a { color: var(--accent-ink); } .ds-table .ds-study { min-width: 20rem; } -.portal-group { color: #fff; border-radius: var(--r-sm); padding: 1px var(--s2); font-size: var(--t-xs); } -.portal-head .portal-summary { display: flex; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; font-size: var(--t-sm); } -.portal-chooser { display: flex; align-items: center; gap: var(--s2); margin: 0 0 var(--s2); } .pb-loading { opacity: 0.45; transition: opacity 0.15s; } .pb-pager .tbz { flex-wrap: wrap; } diff --git a/app/lab/page.tsx b/app/lab/page.tsx index aaee065d..321dd3f7 100644 --- a/app/lab/page.tsx +++ b/app/lab/page.tsx @@ -44,6 +44,7 @@ import { cellExplorerFor } from "../../lib/cell-explorer.ts"; import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; import { Button } from "../_ui/button"; import { Input } from "../_ui/input"; +import { PAGE_TAB } from "../_ui/toolbar.ts"; const TABS = ["explore", "cohorts", "analysis", "examples"] as const; const TAB_LABELS: [typeof TABS[number], string][] = [["explore", "Explore"], ["cohorts", "Saved"], ["analysis", "Analysis"], ["examples", "Examples"]]; @@ -417,7 +418,7 @@ export default function AnalysisPage() { openTab(v as typeof TABS[number])}> {TAB_LABELS.map(([t, label]) => { if (t === tab) openTab(t); }} - className="h-auto flex-none rounded-none px-3.5 pt-2 pb-1.5 text-base leading-normal after:bottom-[-1px] data-[state=active]:text-accent-foreground data-[state=active]:after:bg-primary">{label})} + className={PAGE_TAB}>{label})} {error &&
{error}
} diff --git a/app/portal/PortalTabs.tsx b/app/portal/PortalTabs.tsx index 65e292ab..226e15d4 100644 --- a/app/portal/PortalTabs.tsx +++ b/app/portal/PortalTabs.tsx @@ -7,7 +7,6 @@ import { ViewFigure } from "../lab/figure.tsx"; import { useEffect, useRef, useState } from "react"; import type { AnalysisDoc, AnalysisResult, View } from "../../lib/types"; -import { SERIES } from "../../lib/svg-kit"; import { HotspotsBody } from "../_lollipop/HotspotsBody.tsx"; import { KindCardBody, KindCardControls, hasKindCard, drawsOwnWaiting } from "../lab/kind-cards"; import { dataPart } from "../_oncoprint/view-display.ts"; @@ -15,7 +14,8 @@ import { OncoprintCard, type TrackCatalog } from "../_oncoprint/OncoprintCard.ts import type { PageTab } from "./page-doc"; import { api } from "../lab/api.ts"; import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; -import { LINE_TAB, LINE_TABS, SEG_TAB, SEG_TABS } from "../_ui/toolbar.ts"; +import { LINE_TABS, PAGE_TAB, SEG_TAB, SEG_TABS } from "../_ui/toolbar.ts"; +import { ViewHead } from "../lab/view-chrome.tsx"; import { cn } from "../../lib/cn"; type Patch = Record; @@ -23,8 +23,8 @@ type Patch = Record; type TabState = { sent: string; result?: AnalysisResult; error?: string; loading: boolean }; -export function PortalTabs({ doc, tabs, titles, left, open, onOpen, onDoc, onCounts, catalog }: { - doc: AnalysisDoc; tabs: PageTab[]; titles: string[]; left: string[]; +export function PortalTabs({ doc, tabs, titles, open, onOpen, onDoc, onCounts, catalog }: { + doc: AnalysisDoc; tabs: PageTab[]; titles: string[]; /** what the oncoprint's Tracks menu can offer: the study's attributes and profiles */ catalog: TrackCatalog; /** the open tab, "Title" or "Title › Sub" */ @@ -37,6 +37,8 @@ export function PortalTabs({ doc, tabs, titles, left, open, onOpen, onDoc, onCou }) { const [state, setState] = useState>({}); const gen = useRef>({}); + // which header menus are open, kept outside the cards so a re-render does not close them + const menus = useRef(new Map()); const views = doc.views || []; const [mainTitle, subTitle] = open.split(" › "); @@ -71,51 +73,50 @@ export function PortalTabs({ doc, tabs, titles, left, open, onOpen, onDoc, onCou const mine = link(idx.map((j) => j === i ? next : views[j]), k); onDoc({ ...doc, views: views.map((v, j) => { const m = idx.indexOf(j); return m >= 0 ? mine[m] : v; }) }); }; - const counts = Object.values(state).find((s) => s.result)?.result?.cohorts; const st = state[key]; const spinning = (k: string) => Object.entries(state).some(([x, s]) => (x === k || x.startsWith(k + " › ")) && s.loading); + const chooser = tab && !sub ? tab.chooser : undefined; + const chooseFrom = (o: string) => { + const next = chooser!.apply(idx.map((i) => views[i]), o); + onDoc({ ...doc, views: views.map((v, j) => { const k = idx.indexOf(j); return k >= 0 ? next[k] : v; }) }); + }; return (
-
- Groups: - {Object.keys(doc.cohorts || {}).map((n, i) => ( - {n}{counts && counts[n] ? " (" + counts[n].count.toLocaleString() + ")" : ""} - ))} - {left.length > 0 && left out: {left.join("; ")}} -
- + {tabs.map((t) => ( - + {t.title}{spinning(t.title) && } ))} - {tab?.sub && ( - onOpen(tab.title + " › " + v)} className="-mt-1 mb-3"> - - {tab.sub.map((t) => ( - - {t.title}{spinning(tab.title + " › " + t.title) && } - - ))} - - + {(tab?.sub || chooser) && ( +
+ {tab?.sub && ( +
+ onOpen(tab.title + " › " + v)}> + + {tab.sub.map((t) => ( + + {t.title}{spinning(tab.title + " › " + t.title) && } + + ))} + + +
+ )} + {chooser && ( +
{chooser.label} + views[i]))} onValueChange={chooseFrom}> + + {chooser.options.map((o) => {o})} + + +
+ )} +
)} - {tab && !sub && tab.chooser && (() => { - const ch = tab.chooser, mine = idx.map((i) => views[i]), on = ch.value(mine); - return ( -
- {ch.label} - { const next = ch.apply(mine, o); onDoc({ ...doc, views: views.map((v, j) => { const k = idx.indexOf(j); return k >= 0 ? next[k] : v; }) }); }}> - - {ch.options.map((o) => {o})} - - -
- ); - })()} {idx.map((vi, i) => { const spec = views[vi] as unknown as Record; // a result belongs to the request that produced it; while a newer one runs, the cover says so @@ -130,10 +131,12 @@ export function PortalTabs({ doc, tabs, titles, left, open, onOpen, onDoc, onCou ? edit(vi, n as unknown as View)} note={v.note} studies={v.studies} /> : v && !v.error ? KindCardBody({ c: { ...mc, v } }) : null; + const drawn = Array.isArray(spec.cohorts) ? (spec.cohorts as string[]) : Object.keys(doc.cohorts || {}); return ( -
- {idx.length > 1 &&

{titles[vi]}

} - {hasKindCard(spec.kind) && } +
+ + {hasKindCard(spec.kind as string) && } {busy && !v &&
} {busy && v && !(drawsOwnWaiting(v.kind) && !v.error) &&
updating…
} {!busy && st?.error &&

{st.error}

} diff --git a/app/portal/comparison/page.tsx b/app/portal/comparison/page.tsx index 902fc441..d3dee03d 100644 --- a/app/portal/comparison/page.tsx +++ b/app/portal/comparison/page.tsx @@ -3,12 +3,14 @@ // arrive in an analysis document (?doc=, the lab's own link encoding); the page's views replace // any the link carries unless they are this page's own (a link made on this page), so every // setting changed here travels in the same ?doc= (page-doc.ts). -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import type { AnalysisDoc, View } from "../../../lib/types"; import { PortalTabs } from "../PortalTabs"; import { useStudyKnown, expressionProfile, type StudyKnown } from "../study-known"; import { arrange, decodeDoc, usePageDoc, type PageLayout } from "../page-doc"; +import { PortalHeader, PortalSummary } from "../portal-chrome.tsx"; +import { fmtInt } from "../../../lib/chart/format.ts"; const CLINICAL_SHOWN = 8; @@ -39,18 +41,21 @@ export default function ComparisonPage() { const { known, error } = useStudyKnown(study); const fresh = useMemo(() => linked && known && names.length >= 2 && names.length <= 6 ? comparisonLayout(linked, study, known) : null, [linked, known, study, names.length]); const page = usePageDoc(fresh, linked); - const head =
cbio-lab · Group comparison -
-

{known ? known.name : study || "Comparison"}

- {known && {names.length} groups: {names.join(", ")}} -
; - if (!linked || !study) return
{head}

This page compares the cohorts of an analysis document passed as ?doc= (the lab's own link encoding); none was given.

; - if (names.length < 2 || names.length > 6) return
{head}

a comparison page compares 2 to 6 cohorts; this analysis has {names.length}

; - if (error) return
{head}

{error}

; - if (!fresh || !page.doc) return
{head}
loading {study}…
; - return
{head} + const [counts, setCounts] = useState | null>(null); + const shell = (body: React.ReactNode) => <>
{body}
; + if (!linked || !study) return shell(

This page compares the cohorts of an analysis document passed as ?doc= (the lab's own link encoding); none was given.

); + if (names.length < 2 || names.length > 6) return shell(

a comparison page compares 2 to 6 cohorts; this analysis has {names.length}

); + if (error) return shell(
{error}
); + const ready = !!(fresh && page.doc); + return shell(<> + {fmtInt(known.samples)} samples · {fmtInt(known.patients)} patients} + groups={names} counts={counts} left={fresh?.left || []} /> {page.note &&

{page.note}

} - -
; + {!ready + ? <>
+ : setCounts((o) => o || c)} />} + ); } diff --git a/app/portal/portal-chrome.tsx b/app/portal/portal-chrome.tsx new file mode 100644 index 00000000..573e9c14 --- /dev/null +++ b/app/portal/portal-chrome.tsx @@ -0,0 +1,105 @@ +"use client"; +// The chrome the portal pages share: the app's header (the lab's own), the summary card over the +// tabs (the study, the query, the groups and their sizes), and the query form. +import { SeriesSwatch } from "../lab/view-chrome.tsx"; +import { Button } from "../_ui/button"; +import { Input } from "../_ui/input"; +import { Badge } from "../_ui/badge"; +import { FIELD } from "../_ui/toolbar.ts"; +import { fmtInt, fmtPct } from "../../lib/chart/format.ts"; +import { cn } from "../../lib/cn"; + +/** the app's header, as the lab draws it, naming which portal page this is */ +export function PortalHeader({ page }: { page: string }) { + return ( +
+

cbio-lab

+ {page} + + explore studies + analysis + how we verify +
+ ); +} + +/** one group's swatch, name and size; the swatch is the colour the figures draw it in */ +function GroupChip({ name, i, n }: { name: string; i: number; n?: number }) { + return ( + + + {name} + {n != null && {fmtInt(n)}} + + ); +} + +/** + * The card over a portal page's tabs: what was asked (the study and, on the Results page, the + * genes), the headline answer beside it, and the groups every tab compares — with what the page + * left out for this study, so a missing tab is never silent. + */ +export function PortalSummary({ eyebrow, title, study, detail, genes, groups, counts, left, stat, action, children }: { + eyebrow: string; title: string; study: string; detail?: React.ReactNode; genes?: string[]; + groups: string[]; counts: Record | null; left: string[]; + /** the headline number, right of the title */ + stat?: { value: number; of: number; label: string } | null; + /** a control over the whole page (the Results page's "Modify query"), at the end of the groups row */ + action?: React.ReactNode; + /** below the groups: the query form when it is open */ + children?: React.ReactNode; +}) { + return ( +
+
+
+
{eyebrow}
+

{title}

+
+ {study} + {detail && <>{detail}} + {genes && genes.length > 0 && <> + + {genes.map((g) => {g})} + } +
+
+ {stat && ( +
+
+ {fmtInt(stat.value)} ({fmtPct(stat.of ? 100 * stat.value / stat.of : 0)}) +
+
{stat.label}
+ + )} +
+
+ Groups + {groups.length ? groups.map((n, i) => ) : } + + {left.length > 0 && not shown: {left.join("; ")}} + {action} + +
+ {children} +
+ ); +} + +/** the Results page's query: a study and up to 12 genes, submitted as the page's own URL */ +export function QueryForm({ study, genes, className }: { study: string; genes: string[]; className?: string }) { + return ( +
+
+ +
+
+ + +
+
+ ); +} diff --git a/app/portal/results/page.tsx b/app/portal/results/page.tsx index 892570b7..1f165d80 100644 --- a/app/portal/results/page.tsx +++ b/app/portal/results/page.tsx @@ -10,9 +10,10 @@ import { useStudyKnown, expressionProfile, type StudyKnown } from "../study-know import { arrange, decodeDoc, usePageDoc, type PageLayout } from "../page-doc"; import { ALTERATION_CELLS, cellLabel } from "../../../lib/lollipop"; import { Button } from "../../_ui/button"; -import { Input } from "../../_ui/input"; -import { FIELD } from "../../_ui/toolbar.ts"; -import { cn } from "../../../lib/cn"; +import { TB } from "../../_ui/toolbar.ts"; +import { Pencil } from "lucide-react"; +import { fmtInt } from "../../../lib/chart/format.ts"; +import { PortalHeader, PortalSummary, QueryForm } from "../portal-chrome.tsx"; const CLINICAL_SHOWN = 8; @@ -85,30 +86,32 @@ export default function ResultsPage() { const fresh = useMemo(() => known && genes.length && genes.length <= 12 ? resultsLayout(study, genes, known) : null, [known, study, genes]); const page = usePageDoc(fresh, linked); const [counts, setCounts] = useState | null>(null); + const [editing, setEditing] = useState(false); + const queried = known?.patients || 0; const altered = counts?.Altered; - const head =
cbio-lab · Results -
-
-

{known ? known.name : study || "Results"}

- {known && All samples ({known.samples.toLocaleString()} samples / {known.patients.toLocaleString()} patients) — {genes.join(", ")}} -
- {known && altered != null && Queried genes are altered in {altered.toLocaleString()} ({Math.round(100 * altered / (known.patients || 1))}%) of queried patients} -
; - if (!study || !genes.length) return ( -
{head} -
- {" "} - {" "} - -
-
); - if (genes.length > 12) return
{head}

a results page takes at most 12 genes, the oncoprint's rows; this asks for {genes.length}

; - if (error) return
{head}

{error}

; - if (!fresh || !page.doc) return
{head}
loading {study}…
; - return
{head} + const shell = (body: React.ReactNode) => <>
{body}
; + if (!study || !genes.length) return shell( +
+
Query
+

A study and up to 12 genes: the page shows the patients altered in any of them against the rest, tab by tab, as cBioPortal's Results page does.

+ +
); + if (genes.length > 12) return shell(

a results page takes at most 12 genes, the oncoprint's rows; this asks for {genes.length}

); + if (error) return shell(
{error}
); + const ready = !!(fresh && page.doc); + return shell(<> + {fmtInt(known.samples)} samples · {fmtInt(known.patients)} patients} + groups={Object.keys(fresh?.doc.cohorts || {})} counts={counts} left={fresh?.left || []} + stat={altered != null && queried ? { value: altered, of: queried, label: "queried patients altered in " + (genes.length === 1 ? genes[0] : "any of the " + genes.length + " genes") } : null} + action={}> + {editing && } + {page.note &&

{page.note}

} - setCounts((o) => o || c)} /> -
; + {!ready + ? <>
+ : setCounts((o) => o || c)} />} + ); } From 5a3b7088405d64fd346646451039e1f9a88e656f Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 16 Oct 2026 04:19:05 +0000 Subject: [PATCH 2/3] Results page Plots tab: the lab's plot controls, over every queried patient The plot view's settings bar (both axes with log, a box plot's colour and ring, highlight, unit, swap) moves out of the lab's PlotCard into app/lab/plot-controls.tsx. The lab's card and the portal pages' plot view now render the same toolbar, and each edit goes into the document, so a link reproduces it. The Results page's Plots tab handed the plot [Altered, Unaltered], but a plot draws only its first cohort, so the tab showed the altered patients alone. As on cBioPortal, it now plots every queried patient ("All patients"), with Altered highlighted. Its card is titled "Plot", since a fixed " copy number vs mRNA" was wrong as soon as an axis changed. Verified: on brca_tcga KRAS/NRAS/BRAF the tab draws 526 of 1,101 patients with the 33 altered ones highlighted; switching unit to samples re-ran once (529 of 1,108 samples) and wrote the document to the URL; the lab's plot card renders unchanged; no page errors; scripts/gate.sh green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TyVfAhLnjshk2kWewGnLum --- app/lab/plot-controls.tsx | 83 ++++++++++++++++++++++++++++++++++++ app/lab/view-cards.tsx | 84 +++++-------------------------------- app/portal/PortalTabs.tsx | 4 ++ app/portal/results/page.tsx | 4 +- 4 files changed, 99 insertions(+), 76 deletions(-) create mode 100644 app/lab/plot-controls.tsx diff --git a/app/lab/plot-controls.tsx b/app/lab/plot-controls.tsx new file mode 100644 index 00000000..6843214f --- /dev/null +++ b/app/lab/plot-controls.tsx @@ -0,0 +1,83 @@ +"use client"; +// The plot view's settings bar, shared by the lab's plot card and the portal pages' Plots tab: +// both axes (with a log scale for a numeric one), a box plot's colour and ring, the highlighted +// cohorts, the unit and the axis swap. Every control is an edit to the view, handed to `update`. +import { ArrowLeftRight } from "lucide-react"; +import { AxisPicker, HighlightPicker } from "./view-chrome.tsx"; +import { Button } from "../_ui/button"; +import { Checkbox } from "../_ui/checkbox"; +import { Choice } from "../_ui/choice.tsx"; +import { TB } from "../_ui/toolbar.ts"; + +type Update = (fn: (view: any) => any) => void; + +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; + return ( + + ); +} + +export function PlotControls({ spec, plot, attrs, profiles, loading, busy, highlightOptions, update }: { + /** the view as the document holds it */ + spec: any; + /** the last result's plot, for which shape is on screen */ + plot?: { shape: string; x: { kind: string }; y: { kind: string } }; + attrs: { attribute: string; kind: string }[]; profiles: string[]; loading?: boolean; busy: boolean; + /** the cohorts that may be highlighted: every one but the plotted one and a split */ + highlightOptions: { name: string; label: string; depth: number }[]; + update: Update; +}) { + const chosen: string[] = Array.isArray(spec.highlight) ? spec.highlight : []; + // Every control here changes what is DRAWN and costs a query, so there is one zone and it says + // so — the same toolbar the oncoprint uses. + return ( +
+
+ data + update((vv) => ({ ...vv, x: a }))} /> + +
+
+ update((vv) => ({ ...vv, y: a }))} /> + +
+ {/* colour/ring only affect a box plot's violin — a scatter's density and a contingency table + have no per-case colour to split, so they are offered only once a box is on screen */} + {plot?.shape === "box" &&
+ colour + update((vv) => { const n = { ...vv }; if (a) n.colorBy = a; else delete n.colorBy; return n; })} /> + update((vv) => { const n = { ...vv }; if (a) n.ringBy = a; else delete n.ringBy; return n; })} /> +
} + {plot?.shape !== "table" && (highlightOptions.length > 0 || chosen.length > 0) &&
+ update((vv) => { const n = { ...vv }; if (next.length) n.highlight = next; else delete n.highlight; return n; })} /> +
} +
+ + {/* a box plot is always drawn categories-on-x, values-on-y whichever axis is which in the + document, so swapping would change nothing a reader can see */} + +
+
+ ); +} diff --git a/app/lab/view-cards.tsx b/app/lab/view-cards.tsx index 2d7c88fc..689273db 100644 --- a/app/lab/view-cards.tsx +++ b/app/lab/view-cards.tsx @@ -12,9 +12,10 @@ import type { AnalysisDoc, AnalysisResult, OncoprintView, ExploreResult } from " import { pDigits } from "../../lib/chart/format.ts"; import { MethodPanel } from "./method-panel.tsx"; import { viewRequest } from "./use-analysis.ts"; -import { AxisPicker, HighlightPicker, ViewHelpButton, SplitByNote, PickMenu, CohortPicker, DrawnCohorts, ViewHead, ViewMenu, Latency, SeriesSwatch, Participation } from "./view-chrome.tsx"; +import { AxisPicker, ViewHelpButton, SplitByNote, PickMenu, CohortPicker, DrawnCohorts, ViewHead, ViewMenu, Latency, SeriesSwatch, Participation } from "./view-chrome.tsx"; import { ViewFigure } from "./figure.tsx"; -import { ArrowLeftRight, Plus, Settings2, X } from "lucide-react"; +import { PlotControls } from "./plot-controls.tsx"; +import { Plus, Settings2, X } from "lucide-react"; import { Button } from "../_ui/button"; import { ErrorAlert } from "../_ui/error-alert.tsx"; import { Checkbox } from "../_ui/checkbox"; @@ -648,83 +649,18 @@ function SequenceKeyHint({ ctx, v, vi }: CardProps) { } /** a numeric plot axis on a log scale: log10(v + 1), so a zero stays on the axis (lib/plot.ts) */ -function LogToggle({ ctx, vi, side, numeric }: { ctx: ViewCtx; vi: number; side: "x" | "y"; numeric: boolean }) { - const axis = ctx.docView(vi)[side]; - if (!numeric || !axis || !("source" in axis) || (axis.source !== "clinical" && axis.source !== "molecular")) return null; - return ( - - ); -} - function PlotCard({ ctx, v, vi }: CardProps) { const { attrsLoading, busy, catalogAttrs, cohortChoice, docView, menuOpenRef, profileOptions, updateView } = ctx; + // every cohort the document evaluates, but the plotted one and a split (which stands for several groups, not one set of cases) + const ch = cohortChoice(vi, docView(vi), 1); + const highlightOptions = ch.nodes.filter((nd) => nd.kind !== "split" && nd.name !== ch.drawn[0]) + .map((nd) => ({ name: nd.name, label: nd.label, depth: nd.depth })); return <> {v.kind === "plot" && <> - {v.plot.shape === "scatter" ? "density" : v.plot.shape === "box" ? "box" : "contingency"} · {v.plot.n.toLocaleString()} {(docView(vi).unit || "patient")}s : "two axes over the same cases"} /> - {/* Every control here changes what is DRAWN and costs a query, so there is one - zone and it says so — the same toolbar the oncoprint uses. */} -
-
- data - updateView(vi, (vv: any) => ({ ...vv, x: a }))} /> - -
-
- updateView(vi, (vv: any) => ({ ...vv, y: a }))} /> - -
- {/* colour/ring only affect a box plot's violin — a scatter's density and a - contingency table have no per-case colour to split, so the controls would - sit there doing nothing until a box shape is actually on screen */} - {v.plot?.shape === "box" &&
- colour - updateView(vi, (vv: any) => { const n = { ...vv }; if (a) n.colorBy = a; else delete n.colorBy; return n; })} /> - updateView(vi, (vv: any) => { const n = { ...vv }; if (a) n.ringBy = a; else delete n.ringBy; return n; })} /> -
} - {v.plot?.shape !== "table" && (() => { - // every cohort the document evaluates, but the plotted one and a split (which stands for - // several groups, not one set of cases) - const ch = cohortChoice(vi, docView(vi), 1); - const plotted = ch.drawn[0]; - const options = ch.nodes.filter((nd) => nd.kind !== "split" && nd.name !== plotted) - .map((nd) => ({ name: nd.name, label: nd.label, depth: nd.depth })); - const chosen: string[] = Array.isArray(docView(vi).highlight) ? docView(vi).highlight : []; - if (!options.length && !chosen.length) return null; - return
- updateView(vi, (vv: any) => { const n = { ...vv }; if (next.length) n.highlight = next; else delete n.highlight; return n; })} /> -
; - })()} -
- - {/* a box plot is always drawn categories-on-x, values-on-y regardless of - which axis is which in the document (lib/federation.ts's box branch - routes by kind, not by x/y) — swapping which is "x" changes nothing a - reader can see, so the button is disabled rather than pretending to act */} - -
-
+ updateView(vi, fn)} /> } ; diff --git a/app/portal/PortalTabs.tsx b/app/portal/PortalTabs.tsx index 226e15d4..96b9f712 100644 --- a/app/portal/PortalTabs.tsx +++ b/app/portal/PortalTabs.tsx @@ -16,6 +16,7 @@ import { api } from "../lab/api.ts"; import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; import { LINE_TABS, PAGE_TAB, SEG_TAB, SEG_TABS } from "../_ui/toolbar.ts"; import { ViewHead } from "../lab/view-chrome.tsx"; +import { PlotControls } from "../lab/plot-controls.tsx"; import { cn } from "../../lib/cn"; type Patch = Record; @@ -137,6 +138,9 @@ export function PortalTabs({ doc, tabs, titles, open, onOpen, onDoc, onCounts, c {hasKindCard(spec.kind as string) && } + {spec.kind === "plot" && edit(vi, fn(spec))} + highlightOptions={Object.keys(doc.cohorts || {}).filter((n) => n !== drawn[0]).map((n) => ({ name: n, label: n, depth: 0 }))} />} {busy && !v &&
} {busy && v && !(drawsOwnWaiting(v.kind) && !v.error) &&
updating…
} {!busy && st?.error &&

{st.error}

} diff --git a/app/portal/results/page.tsx b/app/portal/results/page.tsx index 1f165d80..46fbcb51 100644 --- a/app/portal/results/page.tsx +++ b/app/portal/results/page.tsx @@ -46,7 +46,7 @@ function resultsLayout(study: string, genes: string[], known: StudyKnown): PageL Unaltered: { all: genes.map((g) => ({ not: { kind: "oql", q: g } })) } as CohortDef, }; const expr = expressionProfile(known.profiles); - // the portal correlates over every queried sample, not one group + // the portal plots and correlates over every queried sample, not one group if (expr) cohorts["All patients"] = { all: [] } as CohortDef; const both = ["Altered", "Unaltered"]; const v = (title: string, view: Record) => ({ title, view: view as unknown as View }); @@ -55,7 +55,7 @@ function resultsLayout(study: string, genes: string[], known: StudyKnown): PageL { title: "Oncoprint", views: [v("Oncoprint", { kind: "oncoprint", genes, cohorts: both })] }, { title: "Cancer Types Summary", views: [v("Cancer types summary", { kind: "cancertypes", genes, cohorts: ["Altered"] })] }, ...(genes.length >= 2 ? [{ title: "Mutual Exclusivity", views: [v("Mutual exclusivity", { kind: "cooccurrence", genes, cohorts: both })] }] : []), - ...(expr ? [{ title: "Plots", views: [v(genes[0] + " copy number vs mRNA", { kind: "plot", x: { source: "alteration", gene: genes[0], by: "cna" }, y: { source: "molecular", profileType: expr, gene: genes[0] }, cohorts: both })] }] : []), + ...(expr ? [{ title: "Plots", views: [v("Plot", { kind: "plot", x: { source: "alteration", gene: genes[0], by: "cna" }, y: { source: "molecular", profileType: expr, gene: genes[0] }, cohorts: ["All patients"], highlight: ["Altered"] })] }] : []), // the portal's Mutations tab: one gene at a time, its lollipop over its mutation table { title: "Mutations", views: [v("Mutation diagram", { kind: "hotspots", genes: [genes[0]], cohorts: ["Altered"] }), v("Mutation table", { kind: "mutations", gene: genes[0], cohorts: ["Altered"] })], From 41a652a0e2b873697e59d4b17aed400355470068 Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 16 Oct 2026 18:02:00 +0000 Subject: [PATCH 3/3] Gene query dialog (prototype), and the Results page's query on Explore's study list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One dialog for entering genes, meant to become the standard everywhere a page asks for them (app/_genes/GeneQueryDialog.tsx): - Search over genes, their aliases and the warehouse's gene sets (a set over 30 genes is offered disabled, with the reason). - The query as text, checked line by line as it is typed: what each entry selects, in words, or what is wrong with it. Symbols are checked against the gene and gene_alias tables: an alias (p53, HER2, MLL) is caught and replaced in one click; an unknown symbol, unsupported OQL (GAIN, HETLOSS, …) or a count outside the place's limits blocks Apply, and Apply's title says what it waits for. - Recent and saved lists in this browser (app/_genes/gene-lists.ts, localStorage), and clickable OQL help. - Two modes: "oql" (each entry a gene and what counts as altered in it) and "genes" (the portal's gene list; [A B] merged rows only where the oncoprint can draw them). Used by: - The lab's gene views (compare, oncoprint, co-occurrence, co-expression, lollipop) and the oncoprint card's genes button, in place of the chip editor (GeneListEditor, removed). - The Results page's query (app/portal/query-editor.tsx): Explore's study list, single-select, beside the gene query. The page now reads ?genes= as OQL as the portal does (genes split by spaces or commas; "TP53 KRAS: MUT" is two entries), so old links mean what they did. Each entry decides the Altered/Unaltered groups; the page says that the oncoprint and gene tabs still draw every alteration of the genes. Supporting changes: - lib/oql-parse.ts: the OQL parser alone, browser-safe; lib/oql.ts re-exports it. - lib/gene-query.ts: the pure line-by-line checker; POST /api/genes (lib/genes.ts) for symbol checks, search and gene-set members, reference data only. - app/_studies/study-list.tsx: Explore's search, capability filters and rows, shared by its picker and the Results page; the portal layout gets the lab's query cache. - e2e: the three checks that drove the chip editor now edit the dialog's text and Apply. Verified: the dialog on brca_tcga (aliases fixed, FOOBAR1 and GAIN blocked, a list saved, applied); KRAS: G12C / TP53 / ERBB2: AMP gives 381 altered patients; old comma links render; Explore's picker unchanged; 9 unit tests; scripts/gate.sh green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TyVfAhLnjshk2kWewGnLum --- app/_genes/GeneQueryDialog.tsx | 312 +++++++++++++++++++++++++++++++ app/_genes/gene-lists.ts | 48 +++++ app/_oncoprint/OncoprintCard.tsx | 87 +-------- app/_studies/study-list.tsx | 111 +++++++++++ app/api/genes/route.ts | 14 ++ app/globals.css | 12 +- app/lab/analysis-sections.tsx | 25 +-- app/lab/api.ts | 5 +- app/lab/explore-sections.tsx | 52 +----- app/portal/PortalTabs.tsx | 4 +- app/portal/layout.tsx | 6 +- app/portal/portal-chrome.tsx | 21 +-- app/portal/query-editor.tsx | 68 +++++++ app/portal/results/page.tsx | 34 ++-- e2e-lab-races.cjs | 12 +- e2e.cjs | 21 ++- lib/api-schemas.ts | 11 ++ lib/gene-query.ts | 117 ++++++++++++ lib/genes.ts | 86 +++++++++ lib/oql-parse.ts | 110 +++++++++++ lib/oql.ts | 110 +---------- test/gene-query.test.ts | 57 ++++++ 22 files changed, 1012 insertions(+), 311 deletions(-) create mode 100644 app/_genes/GeneQueryDialog.tsx create mode 100644 app/_genes/gene-lists.ts create mode 100644 app/_studies/study-list.tsx create mode 100644 app/api/genes/route.ts create mode 100644 app/portal/query-editor.tsx create mode 100644 lib/gene-query.ts create mode 100644 lib/genes.ts create mode 100644 lib/oql-parse.ts create mode 100644 test/gene-query.test.ts diff --git a/app/_genes/GeneQueryDialog.tsx b/app/_genes/GeneQueryDialog.tsx new file mode 100644 index 00000000..a1108ee4 --- /dev/null +++ b/app/_genes/GeneQueryDialog.tsx @@ -0,0 +1,312 @@ +"use client"; +// THE way a reader enters genes: one dialog for every place a page asks for a gene list or an OQL +// query. Search (genes, their aliases, gene sets), the query as text, a check of every entry as it +// is typed (does it parse, is each symbol a gene, an alias of one, or neither), and the lists this +// browser has used or saved. `mode` decides what the text is: "oql" — each entry a gene and what +// counts as altered in it — or "genes", cBioPortal's gene list, where `[A B]` is one merged row. +import { useEffect, useMemo, useRef, useState } from "react"; +import { BookmarkPlus, Check, CircleAlert, History, Search, Star, Trash2, TriangleAlert, X } from "lucide-react"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../_ui/dialog"; +import { Button } from "../_ui/button"; +import { Input } from "../_ui/input"; +import { Textarea } from "../_ui/textarea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../_ui/tabs"; +import { SEG_TAB, SEG_TABS, TB, FIELD } from "../_ui/toolbar.ts"; +import { formatGeneList, MAX_GENE_SYMBOLS, type GeneTrackSpec } from "../../lib/gene-tracks.ts"; +import { checkGeneQuery, replaceSymbol, OQL_HELP, OQL_REFUSED, type GeneQueryMode, type QueryCheck } from "../../lib/gene-query.ts"; +import type { GeneCheck, GeneHit, GeneSetHit } from "../../lib/genes.ts"; +import { api } from "../lab/api.ts"; +import { useGeneLists, type GeneList } from "./gene-lists.ts"; +import { cn } from "../../lib/cn"; + +// ---- the catalog, asked once per symbol per page ---------------------------------------------- + +const known = new Map(); + +/** each symbol's check, fetched for those not yet asked; `pending` while any is in flight */ +function useSymbolChecks(symbols: string[]): { checks: Map; pending: boolean; error: string | null } { + const [, bump] = useState(0); + const [error, setError] = useState(null); + const missing = symbols.filter((s) => !known.has(s)); + const key = missing.join(","); + useEffect(() => { + if (!key) return; + let live = true; + setError(null); + const t = setTimeout(() => { + api.genes({ symbols: key.split(",") }).then((r) => r.json()).then((j) => { + if (j.error) throw new Error(j.error); + for (const c of j.checks as GeneCheck[]) known.set(c.symbol, c); + if (live) bump((n) => n + 1); + }).catch((e) => { if (live) setError(String(e.message || e)); }); + }, 250); + return () => { live = false; clearTimeout(t); }; + }, [key]); + return { checks: known, pending: missing.length > 0 && !error, error }; +} + +function useSearch(q: string): { genes: GeneHit[]; sets: GeneSetHit[]; loading: boolean } { + const [res, setRes] = useState<{ q: string; genes: GeneHit[]; sets: GeneSetHit[] }>({ q: "", genes: [], sets: [] }); + useEffect(() => { + if (!q.trim()) return; + let live = true; + const t = setTimeout(() => { + api.genes({ search: q }).then((r) => r.json()).then((j) => { if (live && !j.error) setRes({ q, genes: j.genes, sets: j.sets }); }).catch(() => {}); + }, 200); + return () => { live = false; clearTimeout(t); }; + }, [q]); + return { genes: res.q === q ? res.genes : [], sets: res.q === q ? res.sets : [], loading: !!q.trim() && res.q !== q }; +} + +// ---- the dialog ----------------------------------------------------------------------------- + +export interface GeneQueryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + /** what the query is for, under the title */ + description?: string; + mode: GeneQueryMode; + /** the query as the page holds it */ + value: string; + /** the query applied: its text, as the reader left it, and what it says */ + onApply: (text: string, check: QueryCheck) => void; + /** the fewest and most distinct genes the place it is for takes */ + min?: number; + max?: number; + /** in "genes" mode, whether `[A B]` merged rows are taken (the oncoprint's) */ + groups?: boolean; +} + +export function GeneQueryDialog(p: GeneQueryDialogProps) { + return ( + + {p.open && } + + ); +} + +const PLACEHOLDER: Record = { + oql: "KRAS: MUT\nBRAF: V600E\nCDKN2A: HOMDEL", + genes: "TP53 KRAS PIK3CA", +}; + +function GeneQueryBody({ onOpenChange, title, description, mode, value, onApply, min = 1, max = 30, groups = false }: GeneQueryDialogProps) { + const [text, setText] = useState(value); + const [q, setQ] = useState(""); + const [setError, setSetError] = useState(null); + const [saving, setSaving] = useState(null); + const area = useRef(null); + const lists = useGeneLists(); + const check = useMemo(() => checkGeneQuery(text, mode), [text, mode]); + const { checks, pending, error: checkError } = useSymbolChecks(check.genes); + const found = useSearch(q); + + const sep = mode === "oql" ? "\n" : " "; + const append = (more: string[]) => { + const have = new Set(check.genes); + const add = more.map((g) => g.toUpperCase()).filter((g) => !have.has(g)); + if (!add.length) return; + setText((t) => (t.trim() ? t.replace(/\s+$/, "") + sep : "") + add.join(sep)); + setQ(""); + area.current?.focus(); + }; + const addSet = (name: string) => { + setSetError(null); + api.genes({ geneset: name }).then((r) => r.json()).then((j) => { if (j.error) setSetError(j.error); else append(j.members); }) + .catch((e) => setSetError(String(e.message || e))); + }; + + // what stands between the text and Apply, each said in words + const aliases = check.genes.map((g) => checks.get(g)).filter((c): c is GeneCheck => c?.status === "alias"); + const unknown = check.genes.map((g) => checks.get(g)).filter((c): c is GeneCheck => c?.status === "unknown"); + const entryErrors = check.entries.filter((e) => e.error); + const problems = [ + check.error, + mode === "genes" && !groups && check.tracks?.some((t) => typeof t !== "string") ? "merged rows ([A B]) are the oncoprint's; list each gene on its own here" : null, + ...entryErrors.map((e) => e.text + " — " + e.error), + ...unknown.map((c) => c.symbol + " is not a gene symbol"), + ...aliases.map((c) => c.symbol + " is an alias of " + c.hugo!.join(" / ")), + !check.genes.length ? "enter at least " + (min === 1 ? "one gene" : min + " genes") : null, + check.genes.length && check.genes.length < min ? "this takes at least " + min + " genes; there are " + check.genes.length : null, + check.genes.length > max ? "this takes at most " + max + " genes; there are " + check.genes.length : null, + ].filter(Boolean) as string[]; + const blocked = problems.length > 0 || pending; + const fixAll = () => setText((t) => aliases.reduce((acc, c) => c.hugo!.length === 1 ? replaceSymbol(acc, c.symbol, c.hugo![0]) : acc, t)); + + const apply = () => { lists.remember(text, mode); onApply(text.trim(), check); onOpenChange(false); }; + const mine = (l: GeneList) => l.mode === mode; + + return ( + { if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !blocked) apply(); }}> + + {title} + + {description || (mode === "oql" ? "One gene per line, optionally with what counts as altered in it (OQL)." : "Gene symbols separated by spaces" + (groups ? "; [A B] draws A and B as one row." : "."))} + + +
+ {/* ---- the query ---- */} +
+
+
+ {setError &&

{setError}

} +