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
2 changes: 1 addition & 1 deletion app/_genes/GeneQueryDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ function GeneQueryBody({ onOpenChange, title, description, mode, value, onApply,
<span className="block text-xs text-muted-foreground">{h.means}</span>
</button>
))}
<p className="mt-1.5 mb-0 text-xs text-muted-foreground">Entries on separate lines (or split by “;”) are alternatives: a patient is altered if any of them matches. {OQL_REFUSED}</p>
<p className="mt-1.5 mb-0 text-xs text-muted-foreground">Entries on separate lines (or split by “;” or “,”) are alternatives: a patient is altered if any of them matches. {OQL_REFUSED}</p>
</div>
) : (
<div className="flex flex-col gap-2 text-sm text-muted-foreground">
Expand Down
5 changes: 4 additions & 1 deletion app/_oncoprint/OncoprintCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 */
Expand Down
9 changes: 7 additions & 2 deletions app/api/explore/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };
}
12 changes: 12 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,18 @@ details.cohortpick .gearmenu-items { min-width: 22rem; max-width: 30rem; gap: 0;
}
.menu-aside > .meta { display: block; }

/* a plot's point layers: the plotted cases and each layer hidden or shown, and a case a hidden
layer drew shown in the next layer that holds it (app/lab/plot-controls.tsx; the groups are
lib/figures/plot.ts's) */
.plot-figure[data-hide~="base"] g[data-layer="base"],
.plot-figure[data-hide~="0"] g[data-layer="0"],
.plot-figure[data-hide~="1"] g[data-layer="1"],
.plot-figure[data-hide~="2"] g[data-layer="2"],
.plot-figure[data-hide~="3"] g[data-layer="3"] { opacity: 0.08; }
.plot-figure[data-hide~="0"] g[data-shared][data-owner="0"],
.plot-figure[data-hide~="1"] g[data-shared][data-owner="1"],
.plot-figure[data-hide~="2"] g[data-shared][data-owner="2"],
.plot-figure[data-hide~="3"] g[data-shared][data-owner="3"] { display: inline; }
/* a plot's axis controls: source, then whatever that source needs */
.axispick { display: inline-flex; align-items: center; gap: var(--s1); }

Expand Down
4 changes: 2 additions & 2 deletions app/lab/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,15 +353,15 @@ 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;

// ---- what the sections read ------------------------------------------------------------
/** 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
Expand Down
67 changes: 49 additions & 18 deletions app/lab/plot-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ 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 { 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";
import { CHIP, FIELD } from "../_ui/toolbar.ts";
import type { ProfileInfo } from "../../lib/profile-catalog.ts";
import { DEFAULT_POINT_LAYERS, POINTS_CAP, LAYER_BASE, 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];
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 (
<label className="chk text-md text-foreground" title="draw this axis on a log scale — log10(value + 1), so zero stays on the axis and a negative value is left out">
<Checkbox checked={!!axis.log} disabled={busy}
Expand Down Expand Up @@ -71,7 +74,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 }]);
Expand Down Expand Up @@ -110,27 +113,50 @@ function PointLayers({ layers, plot, cohortOptions, axisName, busy, onChange }:
);
}

export function PlotControls({ spec, plot, attrs, profiles, loading, busy, cohortOptions, update, children }: {
export function PlotControls({ spec, plot, attrs, catalog, 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 } };
attrs: { attribute: string; kind: string }[]; profiles: string[]; loading?: boolean; busy: boolean;
/** 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 }[];
/** the studies' profiles as they describe them, for each axis's Data picker */
catalog: ProfileInfo[]; 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 }[];
update: Update;
/** the figure, and whatever the page covers it with while it waits */
children: React.ReactNode;
}) {
const layers: PointLayerSpec[] = Array.isArray(spec.points) ? spec.points : DEFAULT_POINT_LAYERS;
// Which of the plotted cohort and its layers are hidden: 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 [hidden, setHidden] = useState<string[]>([]);
useEffect(() => setHidden([]), [drawnKey]);
useEffect(() => {
if (!hidden.length) return;
const off = (e: KeyboardEvent) => { if (e.key === "Escape") setHidden([]); };
window.addEventListener("keydown", off);
return () => window.removeEventListener("keydown", off);
}, [hidden]);
const toggle = (k: string) => setHidden((h) => h.includes(k) ? h.filter((x) => x !== k) : [...h, k]);
const chip = (k: string, color: string, text: string) => {
const shown = !hidden.includes(k);
return <Button key={k} type="button" variant="outline" size="xs" className={cn(CHIP, "h-ctl gap-1.5", !shown && "text-muted-foreground line-through")} aria-pressed={shown}
title={shown ? "hide " + text + " (Esc shows everything)" : "show " + text} onClick={() => toggle(k)}>
<span className="size-2.5 flex-none rounded-full" style={{ background: color, opacity: shown ? 1 : 0.35 }} />{text}
</Button>;
};
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
// box whose categories sit on y shows its "vertical" axis across. Swapping makes the two agree.
const boxTransposed = plot?.shape === "box" && plot.x.kind === "num";
const boxDrawn = plot?.shape === "box" && !boxTransposed;
const axisFields = (side: "x" | "y") => <>
<AxisPicker side={side} stacked axis={spec[side]} attrs={attrs} profiles={profiles} loading={loading} busy={busy}
<AxisPicker side={side} stacked axis={spec[side]} attrs={attrs} catalog={catalog} loading={loading} busy={busy}
onChange={(a) => update((vv) => ({ ...vv, [side]: a }))} />
<LogToggle spec={spec} side={side} numeric={plot?.[side].kind === "num"} busy={busy} update={update} />
</>;
Expand Down Expand Up @@ -166,11 +192,11 @@ export function PlotControls({ spec, plot, attrs, profiles, loading, busy, cohor
<span className="axfield-name text-sm font-semibold text-foreground"
title="subsets of the plotted cases drawn in their own colour over it; the first layer draws a case two share">Points</span>
<label className="chk text-md text-foreground" title={overCap
? "this plot has " + plot!.n.toLocaleString() + " " + unit + "s, more than 5,000, so it is drawn as density whatever this says"
: "every case as a point when there are at most 5,000 and the study may show cases; off draws the density alone"}>
? "this plot has " + plot!.n.toLocaleString() + " " + unit + "s, more than " + CAP + ", so it is drawn as density whatever this says"
: "every case as a point when there are at most " + CAP + " and the study may show cases; off draws the density alone"}>
<Checkbox checked={spec.allPoints !== false} disabled={busy || overCap}
onCheckedChange={(c) => update((vv) => { const n = { ...vv }; if (c === true) delete n.allPoints; else n.allPoints = false; return n; })} />
every {unit} (up to 5,000)
every {unit} (up to {CAP})
</label>
<PointLayers layers={layers} plot={plot} cohortOptions={cohortOptions} axisName={axisName} busy={busy}
onChange={(next) => update((vv) => ({ ...vv, points: next }))} />
Expand All @@ -184,18 +210,23 @@ export function PlotControls({ spec, plot, attrs, profiles, loading, busy, cohor
</p>}
{/* 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" && <div className="viewtb mt-0 mb-2.5">
<div className="tbz">
{(plot?.shape === "box" || drawn.length > 0) && <div className="viewtb mt-0 mb-2.5">
{plot?.shape === "box" && <><div className="tbz">
<span className="tbl" title="colours each group's violin and points by a category">colour samples by</span>
<AxisPicker side="fill" axis={spec.colorBy} allowNone categorical attrs={attrs} profiles={profiles} loading={loading} busy={busy}
<AxisPicker side="fill" axis={spec.colorBy} allowNone categorical attrs={attrs} catalog={catalog} loading={loading} busy={busy}
onChange={(a) => update((vv) => { const n = { ...vv }; if (a) n.colorBy = a; else delete n.colorBy; return n; })} />
</div>
<div className="tbz">
<AxisPicker side="ring" axis={spec.ringBy} allowNone categorical attrs={attrs} profiles={profiles} loading={loading} busy={busy}
<AxisPicker side="ring" axis={spec.ringBy} allowNone categorical attrs={attrs} catalog={catalog} loading={loading} busy={busy}
onChange={(a) => update((vv) => { const n = { ...vv }; if (a) n.ringBy = a; else delete n.ringBy; return n; })} />
</div>
</div></>}
{drawn.length > 0 && <div className="tbz plot-solo">
<span className="tbl" title="this browser only: hide or show the plotted cases and each layer; Esc shows everything">show</span>
{chip("base", LAYER_BASE, "all " + unit + "s (" + plot!.n.toLocaleString() + ")")}
{drawn.map((l) => chip(String(l.i), l.color, l.label + " (" + l.n.toLocaleString() + ")"))}
</div>}
</div>}
{children}
<div className="plot-figure" data-hide={hidden.length ? hidden.join(" ") : undefined}>{children}</div>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/lab/portal-bodies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export function CorrelatesBody({ v, docJson }: { v: ViewResult; docJson?: () =>
const [sel, setSel] = useState<CoRow | null>(null);
const picked = sel && rows.includes(sel) ? sel : [...rows].sort((a, b) => a.q - b.q || Math.abs(b.rho ?? 0) - Math.abs(a.rho ?? 0))[0] || null;
const plot = usePairPlot(docJson, r.cohort, picked ? { kind: "plot", unit: "patient",
x: { source: "molecular", profileType: r.profileType, gene: r.gene }, y: { source: "molecular", profileType: r.otherProfile, gene: picked.gene } } : null);
x: { source: "profile", profile: r.profileType, gene: r.gene }, y: { source: "profile", profile: r.otherProfile, gene: picked.gene } } : null);
const cols: Col<CoRow>[] = [
{ key: "gene", label: "Correlated Gene", value: (g) => g.gene, render: (g) => <strong>{g.gene}</strong> },
{ key: "cytoband", label: "Cytoband", value: (g) => g.cytoband || null },
Expand Down
Loading
Loading