Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 312 additions & 0 deletions app/_genes/GeneQueryDialog.tsx

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions app/_genes/gene-lists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";
// The gene queries this browser has used (recent, kept automatically) and the ones the reader named
// (saved), for the gene query dialog. Browser storage on purpose for now: a convenience per reader,
// never part of a document, and the page works the same when storage is blocked.
import { useCallback, useSyncExternalStore } from "react";
import type { GeneQueryMode } from "../../lib/gene-query.ts";

export interface GeneList { text: string; mode: GeneQueryMode; name?: string; at: number }
interface Store { recent: GeneList[]; saved: GeneList[] }

const KEY = "cbio-lab:gene-lists:v1";
const RECENT_KEPT = 10;
const EMPTY: Store = { recent: [], saved: [] };

let cache: Store | null = null;
const listeners = new Set<() => void>();

function read(): Store {
if (cache) return cache;
try {
const j = JSON.parse(window.localStorage.getItem(KEY) || "null");
cache = j && Array.isArray(j.recent) && Array.isArray(j.saved) ? j : EMPTY;
} catch { cache = EMPTY; }
return cache!;
}
function write(next: Store) {
cache = next;
try { window.localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* storage blocked: kept for this page only */ }
listeners.forEach((l) => l());
}
const same = (a: string, b: string) => a.replace(/\s+/g, " ").trim().toUpperCase() === b.replace(/\s+/g, " ").trim().toUpperCase();

export function useGeneLists() {
const store = useSyncExternalStore((l) => { listeners.add(l); return () => listeners.delete(l); }, read, () => EMPTY);
/** a query that was applied: first in recent, once */
const remember = useCallback((text: string, mode: GeneQueryMode) => {
if (!text.trim()) return;
const s = read();
write({ ...s, recent: [{ text: text.trim(), mode, at: Date.now() }, ...s.recent.filter((r) => !(r.mode === mode && same(r.text, text)))].slice(0, RECENT_KEPT) });
}, []);
const save = useCallback((name: string, text: string, mode: GeneQueryMode) => {
const s = read();
write({ ...s, saved: [{ name: name.trim(), text: text.trim(), mode, at: Date.now() }, ...s.saved.filter((r) => r.name !== name.trim())] });
}, []);
const unsave = useCallback((name: string) => { const s = read(); write({ ...s, saved: s.saved.filter((r) => r.name !== name) }); }, []);
const forget = useCallback((at: number) => { const s = read(); write({ ...s, recent: s.recent.filter((r) => r.at !== at) }); }, []);
return { recent: store.recent, saved: store.saved, remember, save, unsave, forget };
}
87 changes: 4 additions & 83 deletions app/_oncoprint/OncoprintCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,19 @@
import React, { useState } from "react";
import OncoprintFigure, { type ColumnSelection } from "./OncoprintFigure.tsx";
import { figureDisplayOf, withFigureDisplay, sortKeysOf, withSortKeys, sortKeyText } from "./view-display.ts";
import { formatGeneList, geneSymbols, normalizeGeneTracks, parseGeneList, type GeneTrackSpec } from "../../lib/gene-tracks.ts";
import { normalizeGeneTracks, type GeneTrackSpec } from "../../lib/gene-tracks.ts";
import { heatmapTrackLabel, STUDY_TRACK } from "../../lib/profile-names.ts";
import { isCustomAttr, attributeLabel, parseCustomAttr } from "../../lib/custom-data-names.ts";
import type { UnpackedContract } from "../../lib/oncoprint-binary.ts";
import type { UploadBinding } from "../lab/custom-data-client.ts";
import { Plus, Rows3 } from "lucide-react";
import { Rows3 } from "lucide-react";
import { Button } from "../_ui/button";
import { RemoveBadge } from "../_ui/remove-badge";
import { Checkbox } from "../_ui/checkbox";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../_ui/dialog";
import { ViewGenesDialog } from "../_genes/GeneQueryDialog.tsx";
import { GearMenu } from "../_ui/gear-menu.tsx";
import { Input } from "../_ui/input";
import { OptionSelect } from "../_ui/choice.tsx";
import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs";
import { Textarea } from "../_ui/textarea";
import { FIELD, TB } from "../_ui/toolbar.ts";
import { cn } from "../../lib/cn";

Expand Down Expand Up @@ -103,77 +101,6 @@ export const setDisplayKey = (v: V, key: string, value: unknown): V => {
return next;
};

// ---- the gene list editor (any gene view; merged rows are the oncoprint's) -----------------------

export function GeneListEditor({ view, busy, onGenes }: { view: V; busy: boolean; onGenes: (next: GeneTrackSpec[]) => void }) {
const [add, setAdd] = useState("");
const [text, setText] = useState<string | null>(null);
const [textErr, setTextErr] = useState<string | null>(null);
const list: GeneTrackSpec[] = view.genes || [];
// a list that cannot be read is still editable as text, which is how it gets fixed
let rows: { label: string; members: string[] }[] = [];
let listErr: string | null = null;
try { rows = normalizeGeneTracks(list).tracks; } catch (e) { listErr = String((e as Error).message || e); }
const symbols = geneSymbols(list);
// co-occurrence and co-expression are pairwise: one gene yields no pairs
const floor = (view.kind === "cooccurrence" || view.kind === "coexpression") ? 2 : 1;
const merges = view.kind === "oncoprint";
const drop = (label: string) => onGenes(list.filter((e) => geneRowLabel(e) !== label));
const commit = () => {
const g = add.trim().toUpperCase();
if (!g || symbols.includes(g)) { setAdd(""); return; }
onGenes([...list, g]);
setAdd("");
};
const applyText = () => {
try { onGenes(parseGeneList(text || "")); setTextErr(null); setText(null); }
catch (e) { setTextErr(String((e as Error).message || e)); }
};
return (
<div className="setcol">
<span className="meta">{rows.length} of 12 rows{merges && symbols.length !== rows.length ? " · " + symbols.length + " genes" : ""} — click one to remove it</span>
{listErr && <span className="mark">{listErr}</span>}
<div className="flex flex-wrap gap-1.5">
{rows.map(r => (
<span key={r.label} className={r.members.length > 1 ? "inline-flex items-center rounded-full border border-dashed border-primary pr-2" : "contents"}>
<RemoveBadge label={r.label} disabled={busy || rows.length <= floor} className={r.members.length > 1 ? "border-transparent bg-transparent" : undefined}
title={rows.length <= floor
? (floor === 2 ? "this view compares pairs, so it needs at least two genes" : "a view needs at least one gene")
: r.members.length > 1 ? "remove this merged row (" + r.members.join(", ") + ")" : "remove " + r.label}
onClick={() => drop(r.label)} />
{merges && r.members.length > 1 && (
<Button type="button" variant="link" size="inline" disabled={busy}
title="draw each of these genes as its own row" onClick={() => onGenes(splitGeneTrack(view, r.label).genes)}>split</Button>
)}
</span>
))}
</div>
<div className="setrow">
<Input list="view-genes" placeholder="add a gene…" value={add} disabled={busy || rows.length >= 12} className={cn(FIELD, "w-40 uppercase placeholder:normal-case")}
onChange={e => setAdd(e.target.value.toUpperCase())}
onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); commit(); } }} />
<Button type="button" variant="outline" size="sm" className={TB} disabled={busy || !add.trim() || rows.length >= 12}
onClick={commit}><Plus />add</Button>
</div>
{merges && (
<details onToggle={(e) => { if ((e.target as HTMLDetailsElement).open && text === null) setText(formatGeneList(list)); }}>
<summary className="meta">edit as text — cBioPortal&apos;s gene list, where <code>[TP53 MDM2]</code> is one merged row</summary>
<div className="setcol" style={{ marginTop: "0.3rem" }}>
<Textarea value={text ?? formatGeneList(list)} spellCheck={false} rows={3} disabled={busy}
onChange={(e) => setText(e.target.value)} className="min-h-16 bg-muted font-mono text-md md:text-md" />
<div className="setrow">
<Button type="button" variant="outline" size="sm" className={TB} disabled={busy} onClick={applyText}>apply</Button>
<Button type="button" variant="link" size="inline" onClick={() => { setText(formatGeneList(list)); setTextErr(null); }}>reset</Button>
<span className="meta">a label is optional: <code>[&quot;TP53 pathway&quot; TP53 MDM2]</code></span>
</div>
{textErr && <span className="mark">{textErr}</span>}
</div>
</details>
)}
</div>
);
}

/**
* A control whose options are still being fetched. It stands where the select will be, so the
* toolbar doesn't jump when the options arrive, and it says what it is waiting for — an empty
Expand Down Expand Up @@ -371,13 +298,7 @@ export function OncoprintCard({ view, spec, busy, cohortNames, catalog, onView,
</>}
/>
{!onGenes && (
<Dialog open={genesOpen} onOpenChange={setGenesOpen}>
<DialogContent aria-describedby={undefined} className="genedialog gap-3 sm:max-w-md">
<DialogHeader><DialogTitle className="text-base">Genes</DialogTitle></DialogHeader>
<GeneListEditor view={view} busy={busy} onGenes={(g) => edit(withGenes(view, g))} />
<DialogFooter><DialogClose asChild><Button>Done</Button></DialogClose></DialogFooter>
</DialogContent>
</Dialog>
<ViewGenesDialog view={view} open={genesOpen} onOpenChange={setGenesOpen} onGenes={(g) => edit(withGenes(view, g))} />
)}
</>
);
Expand Down
111 changes: 111 additions & 0 deletions app/_studies/study-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"use client";
// The study list as Explore draws it — a search over name, id and cancer type, capability filters,
// and one row per study — for every page that asks for studies: Explore's picker (several, staged)
// and the Results page's query (one).
import { Checkbox } from "../_ui/checkbox";
import { Button } from "../_ui/button";
import { Input } from "../_ui/input";
import { RadioGroup, RadioGroupItem } from "../_ui/radio-group";
import { CHIP } from "../_ui/toolbar.ts";
import type { StudyInfo } from "../lab/use-catalogs.ts";

/** the capability filters, each a test on a study row */
export const STUDY_CAPS: [string, string, (s: StudyInfo) => boolean][] = [
["timeline", "timeline events", (s) => s.timelinePatients > 0],
["mrna", "mRNA", (s) => !!s.caps?.mrna],
["protein", "protein", (s) => !!s.caps?.protein],
["ga", "generic assay", (s) => !!s.caps?.ga],
["oncokb", "OncoKB-synced", (s) => !!s.oncokb],
["peer", "other instance", (s) => !!s.instance && s.instance !== "a"],
];

/** the most rows drawn; the rest are counted and the search narrows them */
export const STUDY_ROWS_SHOWN = 200;

export function filterStudies(studies: StudyInfo[], query: string, caps: Set<string>): StudyInfo[] {
const q = query.trim().toLowerCase();
return studies.filter((s) =>
(!q || (s.name + " " + s.id + " " + (s.cancerType || "")).toLowerCase().includes(q)) &&
STUDY_CAPS.every(([k, , pred]) => !caps.has(k) || pred(s)));
}

/** the search box, the capability chips and the count; `children` are the caller's own actions on the matches */
export function StudySearch({ query, onQuery, caps, onCaps, matches, total, children }: {
query: string; onQuery: (q: string) => void; caps: Set<string>; onCaps: (next: Set<string>) => void;
matches: number; total: number; children?: React.ReactNode;
}) {
return (
<div className="row g5 center mt-2.5 mb-2.5">
<Input placeholder="search name, id, or cancer type…" value={query} onChange={(e) => onQuery(e.target.value)} aria-label="search studies"
className="h-8 min-w-64 flex-1 text-md md:text-md" />
{STUDY_CAPS.map(([k, label]) => (
<Button key={k} variant="outline" size="xs" className={CHIP} aria-pressed={caps.has(k)}
onClick={() => { const n = new Set(caps); if (n.has(k)) n.delete(k); else n.add(k); onCaps(n); }}>
{label}
</Button>
))}
<span className="meta">{matches.toLocaleString()} of {total.toLocaleString()}</span>
{children}
</div>
);
}

function StudyName({ s }: { s: StudyInfo }) {
return (
<span className="studyname">
<strong>{s.name}</strong>
{s.instance && s.instance !== "a" && <span className="peerbadge" title={"held by the federated peer " + s.instanceLabel}>⧉ {s.instanceLabel}</span>}
</span>
);
}

/**
* One row per matching study, in one grid so every column lines up. `pick` "many" ticks
* checkboxes; "one" is a radio group. `actions` draws a row's own buttons (Explore's per-study
* icons), which act on that study alone.
*/
export function StudyRows({ hits, loading, pick, selected, onToggle, actions }: {
hits: StudyInfo[]; loading?: boolean;
pick: "many" | "one"; selected: string[]; onToggle: (id: string) => void;
actions?: (id: string) => React.ReactNode;
}) {
const head = hits.length > 0 && (
<div className="study-row study-head" aria-hidden>
<span /><span>Study</span><span>Cancer type</span><span className="pts">Patients</span><span />
</div>
);
const more = hits.length > STUDY_ROWS_SHOWN &&
<p className="note studylist-note">Showing the {STUDY_ROWS_SHOWN} largest of {hits.length.toLocaleString()} matches — refine the search.</p>;
const cells = (s: StudyInfo) => <>
<StudyName s={s} />
<span className="meta">{s.cancerType}</span>
<span className="meta pts">{s.patients.toLocaleString()}</span>
{actions ? actions(s.id) : <span />}
</>;
return <>
{loading && <div className="loading-block"><span className="spinner" /> Loading studies…</div>}
{pick === "many" ? (
<div className="scrollbox studylist">
{head}
{hits.slice(0, STUDY_ROWS_SHOWN).map((s) => (
<label key={s.id} title={s.name + " (" + s.id + ")"} className={"study-row" + (selected.includes(s.id) ? " sel" : "")}>
<Checkbox checked={selected.includes(s.id)} onCheckedChange={() => onToggle(s.id)} aria-label={"stage " + s.name} />
{cells(s)}
</label>
))}
{more}
</div>
) : (
<RadioGroup value={selected[0] ?? ""} onValueChange={onToggle} aria-label="study" className="scrollbox studylist gap-x-2.5 gap-y-0">
{head}
{hits.slice(0, STUDY_ROWS_SHOWN).map((s) => (
<label key={s.id} title={s.name + " (" + s.id + ")"} className={"study-row" + (selected.includes(s.id) ? " sel" : "")}>
<RadioGroupItem value={s.id} aria-label={s.name} className="p-0" />
{cells(s)}
</label>
))}
{more}
</RadioGroup>
)}
</>;
}
3 changes: 3 additions & 0 deletions app/_ui/toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
14 changes: 14 additions & 0 deletions app/api/genes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { parseBody, GenesRequest } from "../../../lib/api-schemas.ts";
import { checkGenes, searchGenes, geneSetMembers } from "../../../lib/genes.ts";

// The gene catalog behind the gene query dialog (lib/genes.ts): reference data, no patient rows.
export async function POST(req: Request) {
try {
const body = parseBody(GenesRequest, await req.json());
if (body.geneset) return Response.json({ members: await geneSetMembers(body.geneset) });
if (body.search != null) return Response.json(await searchGenes(body.search));
return Response.json({ checks: await checkGenes(body.symbols || []) });
} catch (e) {
return Response.json({ error: String((e as Error)?.message || e) }, { status: 400 });
}
}
23 changes: 4 additions & 19 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -867,13 +867,13 @@ main.analysis-main { max-width: min(132rem, 100%); }

/* Name-a-cohort dialog. Native <dialog>, so Esc, focus trapping and the
backdrop are the platform's rather than ours. */
.genedialog, .exportdialog {
.exportdialog {
border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--card);
color: var(--ink); padding: 1rem 1.1rem; min-width: 20rem; max-width: 26rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);
}
.genedialog::backdrop, .exportdialog::backdrop { background: rgba(0, 0, 0, 0.32); }
.genedialog h3, .exportdialog h3 { margin: 0 0 0.5rem; font-size: var(--t-base); }
.exportdialog::backdrop { background: rgba(0, 0, 0, 0.32); }
.exportdialog h3 { margin: 0 0 0.5rem; font-size: var(--t-base); }
/* "+ Add cohort" on a view: the analysis's cohorts, the library, or Explore, as three tabs */
.acd-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: grid; grid-template-columns: fit-content(16rem) minmax(8rem, 1fr) fit-content(40%) auto; column-gap: var(--s3); }
.acd-row { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: baseline; padding: var(--s1) 0; border-top: 1px solid var(--line); }
Expand Down Expand Up @@ -998,11 +998,7 @@ button.customtag:hover { text-decoration: underline; }
}
.customlist td { vertical-align: top; }
.customlist td.meta { white-space: normal; }
.genedialog { min-width: 24rem; max-width: 30rem; }
.genedialog .setcol > .meta { color: var(--muted); font-size: var(--t-sm); }
.genedialog textarea { width: 100%; box-sizing: border-box; }
.genedialog .setrow, .exportdialog .setrow { display: flex; gap: 0.4rem; margin-top: 0.5rem; align-items: center; }
.genedialog .setrow input { flex: 1; min-width: 0; }
.exportdialog .setrow { display: flex; gap: 0.4rem; margin-top: 0.5rem; align-items: center; }
/* most of the viewport, so the preview is big enough to actually judge the export by — capped
and scrollable so a poster-sized export never pushes the dialog itself past the viewport.
Scoped to [open]: an author `display` on a bare .exportdialog would beat the UA stylesheet's
Expand Down Expand Up @@ -1557,14 +1553,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; }
Expand All @@ -1583,9 +1571,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; }
Expand Down
Loading
Loading