diff --git a/app/_genes/GeneQueryDialog.tsx b/app/_genes/GeneQueryDialog.tsx index a1108ee4..41222bfa 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/_oncoprint/OncoprintCard.tsx b/app/_oncoprint/OncoprintCard.tsx index 3b8e5dad..eac249a3 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 bb48ff0b..a0541547 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/globals.css b/app/globals.css index d6c55847..06a36136 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); } diff --git a/app/lab/page.tsx b/app/lab/page.tsx index 321dd3f7..c48e743a 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 ec47ec66..759aaa28 100644 --- a/app/lab/plot-controls.tsx +++ b/app/lab/plot-controls.tsx @@ -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 (
diff --git a/app/lab/portal-bodies.tsx b/app/lab/portal-bodies.tsx index 374d14f2..7cfbbec1 100644 --- a/app/lab/portal-bodies.tsx +++ b/app/lab/portal-bodies.tsx @@ -187,7 +187,7 @@ export function CorrelatesBody({ v, docJson }: { v: ViewResult; docJson?: () => const [sel, setSel] = useState(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[] = [ { key: "gene", label: "Correlated Gene", value: (g) => g.gene, render: (g) => {g.gene} }, { key: "cytoband", label: "Cytoband", value: (g) => g.cytoband || null }, diff --git a/app/lab/use-catalogs.ts b/app/lab/use-catalogs.ts index a14807ef..3aeeb555 100644 --- a/app/lab/use-catalogs.ts +++ b/app/lab/use-catalogs.ts @@ -9,6 +9,7 @@ import type { StudyAccess } from "../../lib/view-coverage.ts"; import { api } from "./api.ts"; import { customDataKey } from "./custom-data-client.ts"; import { isCustomAttr } from "../../lib/custom-data-names.ts"; +import type { ProfileInfo } from "../../lib/profile-catalog.ts"; export type StudyInfo = { id: string; name: string; cancerType?: string; patients: number; samples: number; timelinePatients: number; @@ -23,10 +24,12 @@ export type TrackAttrs = { profiles: string[]; /** generic-assay profiles (the assays view) */ gaProfiles: string[]; + /** every profile as the studies describe them, once per profile id (a plot axis's Data picker) */ + profileCatalog: ProfileInfo[]; }; const NO_STUDIES: StudyInfo[] = []; -const NO_TRACKS: TrackAttrs = { key: "", attrs: [], profiles: [], gaProfiles: [] }; +const NO_TRACKS: TrackAttrs = { key: "", attrs: [], profiles: [], gaProfiles: [], profileCatalog: [] }; async function answer(res: Response) { const j = await res.json(); @@ -75,6 +78,7 @@ export function useTrackAttrs(result: AnalysisResult | null): { trackAttrs: Trac const seen = new Map(); // attribute -> kind + how many carry it const profs = new Set(); const gas = new Set(); + const described = new Map(); for (const j of js) if (j) { if (j.clinical) for (const a of j.clinical) { const prev = seen.get(a.attribute); @@ -82,6 +86,7 @@ export function useTrackAttrs(result: AnalysisResult | null): { trackAttrs: Trac } for (const p of j.profiles || []) if (p !== "cna") profs.add(p); // discrete cna isn't a heatmap for (const p of j.gaProfiles || []) gas.add(p); + for (const p of (j.profileCatalog || []) as ProfileInfo[]) if (!described.has(p.profile)) described.set(p.profile, p); } // the reader's own uploads first, then most-covered first, the way the portal orders its picker return { @@ -89,7 +94,7 @@ export function useTrackAttrs(result: AnalysisResult | null): { trackAttrs: Trac attrs: [...seen.entries()].map(([attribute, v]) => ({ attribute, ...v })) .sort((x, y) => Number(isCustomAttr(y.attribute)) - Number(isCustomAttr(x.attribute)) || y.patients - x.patients || (x.attribute < y.attribute ? -1 : 1)), - profiles: [...profs].sort(), gaProfiles: [...gas].sort(), + profiles: [...profs].sort(), gaProfiles: [...gas].sort(), profileCatalog: [...described.values()], } as TrackAttrs; }, }); diff --git a/app/lab/use-view-options.ts b/app/lab/use-view-options.ts index 233902b4..807b6f5e 100644 --- a/app/lab/use-view-options.ts +++ b/app/lab/use-view-options.ts @@ -9,6 +9,7 @@ import type { AnalysisDoc, AnalysisResult } from "../../lib/types.ts"; import { foldEventNames, treatmentEventOf } from "../../lib/event-names.ts"; import { LINE_TYPE } from "../../lib/treatment-lines.ts"; import type { ExploreResult } from "../../lib/types.ts"; +import type { ProfileInfo } from "../../lib/profile-catalog.ts"; import type { StudyInfo, TrackAttrs } from "./use-catalogs.ts"; import { cantDraw, cohortStudies } from "../../lib/view-coverage.ts"; @@ -94,6 +95,8 @@ export function useViewOptions(page: ViewOptionsPage) { const attrsLoading = (): boolean => !catalogAttrs().length && (trackAttrsLoading || exploreBusy); /** continuous molecular profiles for an axis picker: Explore's if loaded, else the shared catalog's */ const profileOptions = (): string[] => (explore && explore.profiles && explore.profiles.length ? explore.profiles : trackAttrs.profiles); + /** every profile as the studies describe them, for a plot axis's Data picker: Explore's if loaded, else the shared catalog's */ + const profileCatalog = (): ProfileInfo[] => (explore && explore.profileCatalog && explore.profileCatalog.length ? explore.profileCatalog : trackAttrs.profileCatalog); const assayOptions = (): string[] => (explore && explore.gaProfiles && explore.gaProfiles.length ? explore.gaProfiles : trackAttrs.gaProfiles); const contProfile = (): string | null => { @@ -176,5 +179,5 @@ export function useViewOptions(page: ViewOptionsPage) { return [...new Set([...fromExplore, ...legacy])].slice(0, 60); }; - return { viewGenes, viewAttrs, viewEvents, catalogAttrs, numericAttrs, attrsLoading, profileOptions, assayOptions, contProfile, noSurvivalWhy, VIEW_KINDS, drawsOwnHead, GENE_VIEWS, geneChoices, cantDrawCohort }; + return { viewGenes, viewAttrs, viewEvents, catalogAttrs, numericAttrs, attrsLoading, profileOptions, profileCatalog, assayOptions, contProfile, noSurvivalWhy, VIEW_KINDS, drawsOwnHead, GENE_VIEWS, geneChoices, cantDrawCohort }; } diff --git a/app/lab/view-cards.tsx b/app/lab/view-cards.tsx index b5970254..1824684a 100644 --- a/app/lab/view-cards.tsx +++ b/app/lab/view-cards.tsx @@ -14,6 +14,7 @@ import { MethodPanel } from "./method-panel.tsx"; import { viewRequest } from "./use-analysis.ts"; import { AxisPicker, ViewHelpButton, SplitByNote, PickMenu, CohortPicker, DrawnCohorts, ViewHead, ViewMenu, Latency, SeriesSwatch, Participation } from "./view-chrome.tsx"; import { ViewFigure } from "./figure.tsx"; +import type { ProfileInfo } from "../../lib/profile-catalog.ts"; import { PlotControls } from "./plot-controls.tsx"; import { Plus, Settings2, X } from "lucide-react"; import { Button } from "../_ui/button"; @@ -56,6 +57,8 @@ export interface ViewCtx { catalogAttrs: () => { attribute: string; kind: string }[]; attrsLoading: () => boolean; profileOptions: () => string[]; + /** the studies' profiles as they describe them, for a plot axis's Data picker */ + profileCatalog: () => ProfileInfo[]; assayOptions: () => string[]; viewEvents: () => EventOption[]; drawsOwnHead: (v: any) => boolean; @@ -124,9 +127,9 @@ function placement({ result, moveView, removeView, noteEdit }: ViewCtx, vi: numb /** A regression view's covariates: each an axis, with the reference level its ratio is measured * against once the fit has seen more than one level. */ -function CovariateList({ covariates, ratio, observedLevels, busy, attrs, profiles, loading, onChange, onRemove, onAdd }: { +function CovariateList({ covariates, ratio, observedLevels, busy, attrs, catalog, loading, onChange, onRemove, onAdd }: { covariates: any[]; ratio: "hazard" | "odds"; observedLevels: (ci: number) => string[]; busy: boolean; - attrs: { attribute: string; kind: string }[]; profiles: string[]; loading: boolean; + attrs: { attribute: string; kind: string }[]; catalog: ProfileInfo[]; loading: boolean; onChange: (ci: number, next: any) => void; onRemove: (ci: number) => void; onAdd: () => void; }) { return ( @@ -136,7 +139,7 @@ function CovariateList({ covariates, ratio, observedLevels, busy, attrs, profile return ( onChange(ci, next)} /> {levels.length > 1 &&