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}

} +