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
3 changes: 2 additions & 1 deletion app/_agent/PageTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { exposeTools, type AgentTool } from "../../lib/webmcp.ts";
import { REPRODUCTIONS } from "../../lib/reproductions.ts";
import { encodeSpec } from "../../lib/lab-links.ts";
import { encodeUrlJson } from "../../lib/url-json.ts";
import { AgentToolsMenu } from "./AgentToolsMenu.tsx";

type Kind = "reproductions" | "provenance" | "oncoprint-lab" | "rationale" | "datasets" | "verification";
Expand Down Expand Up @@ -33,7 +34,7 @@ function toolsFor(kind: Kind, spec: unknown): { tools: AgentTool[]; rows: [strin
inputSchema: { type: "object", required: ["slug"], properties: { slug: { type: "string" } } },
run: async ({ slug }) => {
const r = find(slug);
setTimeout(() => location.assign("/lab?doc=" + encodeSpec(r.doc)), 300);
setTimeout(() => location.assign("/lab?doc=" + encodeUrlJson(r.doc)), 300);
return `Opening "${r.title}" in /lab. Once it loads, use analysis_get_results there.`;
} }
];
Expand Down
4 changes: 2 additions & 2 deletions app/campaign/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// there and in the database, so this page cannot skip one even by accident.
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { encodeSpec } from "../../../lib/lab-links.ts";
import { encodeUrlJson } from "../../../lib/url-json.ts";
import { withSplit, primaryCohorts, type Hypothesis } from "../../../lib/hypothesis.ts";
import type { Campaign, Funnel, HypothesisRow } from "../../../lib/campaign.ts";
import { installAgentTools } from "../agent-tools.ts";
Expand All @@ -24,7 +24,7 @@ type View = { campaign: Campaign; funnel: Funnel; prior: Record<string, Prior[]>
const STEPS = ["Ask", "Propose", "Review", "Lock", "Test", "Report"] as const;
const fmtP = (p?: number | null) => p == null ? "–" : p < 1e-3 ? p.toExponential(1) : p.toFixed(3);
const labLink = (h: HypothesisRow, part?: "discovery" | "validation") =>
"/lab?doc=" + encodeSpec(part ? withSplit(h.spec.doc, part) : h.spec.doc);
"/lab?doc=" + encodeUrlJson(part ? withSplit(h.spec.doc, part) : h.spec.doc);
const asHyp = (h: HypothesisRow): Hypothesis => ({ statement: h.statement, ...h.spec });

function testLabel(h: HypothesisRow): string {
Expand Down
6 changes: 3 additions & 3 deletions app/lab/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ExploreCharts } from "./explore-charts";
import type { UnpackedContract } from "../../lib/oncoprint-binary.ts";
import type { AnalysisDoc, CohortDef } from "../../lib/types.ts";
import { storeCohortLists, storeDocLists } from "./id-sets-client.ts";
import { decodeUrlJson } from "../../lib/url-json.ts";
import type { SignInState } from "./list-cohort.tsx";
import { useLabTools, LAB_TOOL_NAMES } from "./agent-tools.ts";
import { AgentToolsMenu } from "../_agent/AgentToolsMenu.tsx";
Expand Down Expand Up @@ -310,7 +311,7 @@ export default function AnalysisPage() {
try {
const b64 = openedWith.current;
if (b64) {
const d = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(b64.replace(/-/g, "+").replace(/_/g, "/")), ch => ch.charCodeAt(0))));
const d = decodeUrlJson<AnalysisDoc>(b64);
if (d.study) { setStudy(d.study); setExStudies([d.study]); exSel.current = [d.study]; }
docs.replace(d, { undoable: false }); // the link the page opened: the first step, not a new one
if (tabRef.current === "explore") fetchExplore();
Expand Down Expand Up @@ -341,8 +342,7 @@ export default function AnalysisPage() {
try {
const q = new URLSearchParams(window.location.search);
q.set("tab", nextTab || tabRef.current);
if (d) q.set("doc", btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(d))))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
if (d) q.set("doc", docParam(d));
docHistory.record("?" + q.toString(), push);
} catch { /* URL state is a convenience, never a failure */ }
};
Expand Down
6 changes: 2 additions & 4 deletions app/lab/use-doc-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { useEffect, useRef, useState } from "react";
import type { AnalysisDoc } from "../../lib/types.ts";
import type { DocStore } from "./doc-store.ts";
import { readUrlJson } from "../../lib/url-json.ts";

const KEY = "labDepth";

Expand All @@ -31,10 +32,7 @@ const depthOf = (state: unknown): number | null => {

/** `?doc=` → the document it holds; null when there is none or it does not decode */
export function decodeDocParam(search: string): AnalysisDoc | null {
const b64 = new URLSearchParams(search).get("doc");
if (!b64) return null;
try { return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(b64.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)))); }
catch { return null; }
return readUrlJson<AnalysisDoc>(new URLSearchParams(search).get("doc"));
}

export function useDocHistory(docs: DocStore, emptyDoc: AnalysisDoc, currentTab: () => string): DocHistory {
Expand Down
6 changes: 3 additions & 3 deletions app/lab/use-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ import type { AnalysisDoc, TemplateDoc, CohortDef } from "../../lib/types.ts";
import type { DslExample } from "../../lib/dsl-reference.ts";
import { DOC_VERSION } from "../../lib/doc-version.ts";
import { LINE_TYPE } from "../../lib/treatment-lines.ts";
import { encodeUrlJson } from "../../lib/url-json.ts";

import type { DocStore } from "./doc-store.ts";
import type { SavedCohort } from "./use-saved.ts";

const IMMUNO_EVENT: any = { kind: "event", a: { eventType: "Treatment", attrs: [{ key: "SUBTYPE", value: "Immuno" }] }, rel: { type: "NONE" } };

interface Example { name: string; desc: string; def: CohortDef }
/** A document as a ?doc= value: base64url of its UTF-8 JSON, as the page reads it back. */
/** A document as a ?doc= value (lib/url-json.ts), as the page reads it back. */
export function docParam(d: AnalysisDoc | TemplateDoc): string {
return btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(d))))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return encodeUrlJson(d);
}

export const EXAMPLES: Example[] = [
Expand Down
7 changes: 4 additions & 3 deletions app/portal/comparison/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";
// cBioPortal's Comparison page for 2–6 cohorts, laid out as the portal lays it out. The cohorts
// 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).
// any the link carries unless they are this page's own (a link made on this page). A setting
// changed here travels as ?changes= beside the cohorts (page-doc.ts).
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { AnalysisDoc, View } from "../../../lib/types";
Expand Down Expand Up @@ -41,7 +41,8 @@ export default function ComparisonPage() {
const names = Object.keys(linked?.cohorts || {});
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 input = useMemo(() => linked && { dsl: linked.dsl, study: linked.study, cohorts: linked.cohorts }, [linked]);
const page = usePageDoc(fresh, linked, input);
const [counts, setCounts] = useState<Record<string, number> | null>(null);
const shell = (body: React.ReactNode) => <><SiteHeader page="group comparison" /><main className="analysis-main">{body}</main></>;
if (!linked || !study) return shell(<p className="note">This page compares the cohorts of an analysis document passed as <code>?doc=</code> (the lab&apos;s own link encoding); none was given.</p>);
Expand Down
50 changes: 50 additions & 0 deletions app/portal/page-changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// What a portal page's link carries of its settings: only how its views differ from the page's
// defaults. The defaults are rebuilt from the link's query (?study=, ?genes=, ?filters=) or its
// cohorts, so a link with nothing changed carries nothing, and a changed setting costs its own
// bytes rather than every view's.
//
// The changes are tied to the page's layout — the kinds of its views, in order — by a short key.
// When the layout has changed since the link was made (a tab added, a view moved), an index would
// name another view, so the changes are not applied at all and the page says so. A default whose
// value changed under an unchanged layout is taken up: the link holds what the reader changed, not
// what they left alone.
import type { View } from "../../lib/types";

/** each changed view: its index, its settings that differ, and the default settings it no longer has */
export type ViewChange = [index: number, set: Record<string, unknown>, unset?: string[]];
export interface PageChanges { layout: string; views: ViewChange[] }

/** a short key for the kinds of a page's views, in order (FNV-1a) */
export function layoutKey(views: View[]): string {
let h = 0x811c9dc5;
for (const ch of views.map((v) => v.kind).join(",")) h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193) >>> 0;
return h.toString(36);
}

const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);

/** how `now` differs from the page's defaults `fresh`; null when it does not */
export function pageChanges(fresh: View[], now: View[]): PageChanges | null {
const views: ViewChange[] = [];
now.forEach((v, i) => {
const d = (fresh[i] || {}) as unknown as Record<string, unknown>, n = v as unknown as Record<string, unknown>;
const set = Object.fromEntries(Object.entries(n).filter(([k, x]) => !same(x, d[k])));
const unset = Object.keys(d).filter((k) => !(k in n));
if (Object.keys(set).length || unset.length) views.push(unset.length ? [i, set, unset] : [i, set]);
});
return views.length ? { layout: layoutKey(fresh), views } : null;
}

/** the page's views with a link's changes made to them; null when they were made on another layout */
export function applyChanges(fresh: View[], c: PageChanges): View[] | null {
if (!c || c.layout !== layoutKey(fresh) || !Array.isArray(c.views)) return null;
const out = fresh.slice();
for (const [i, set, unset] of c.views) {
if (!Number.isInteger(i) || !out[i] || !set || typeof set !== "object") return null;
const v = { ...out[i], ...set } as Record<string, unknown>;
for (const k of unset || []) delete v[k];
if (v.kind !== fresh[i].kind) return null;
out[i] = v as unknown as View;
}
return out;
}
53 changes: 33 additions & 20 deletions app/portal/page-doc.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"use client";
// A portal page's state is one ordinary analysis document — the page's cohorts and ALL its views —
// carried in the URL as the lab carries its own (?doc=), plus the open tab (?tab=). The tabs are
// the page's: each lists the indices of the views it shows, and opening one sends only that slice
// as an analysis request, so the engine never sees a tab or more views than one run computes.
// A setting changed on the page is an edit to that view in this document, so a shared link
// reproduces it.
// plus the open tab (?tab=). The tabs are the page's: each lists the indices of the views it shows,
// and opening one sends only that slice as an analysis request, so the engine never sees a tab or
// more views than one run computes. A setting changed on the page is an edit to that view in this
// document, and the URL carries how the views differ from the page's defaults (?changes=,
// page-changes.ts), so a shared link reproduces it. A link made before that carries the whole
// document in ?doc=, and still opens.
import { useEffect, useState } from "react";
import type { AnalysisDoc, View } from "../../lib/types";
import { encodeUrlJson, readUrlJson } from "../../lib/url-json.ts";
import { applyChanges, pageChanges, type PageChanges } from "./page-changes.ts";

/**
* A control over a whole tab — the portal's gene chips over its Mutations tab: one choice that
Expand Down Expand Up @@ -36,18 +39,12 @@ export interface PageLayout {
left: string[];
}

/** any JSON value as a URL parameter (base64url) */
export function encodeParam(x: unknown): string {
return btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(x)))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** any JSON value as a URL parameter (lib/url-json.ts) */
export const encodeParam = (x: unknown): string => encodeUrlJson(x);
/** a URL parameter written by encodeParam; null when there is none or it does not decode */
export function decodeParam<T>(b64: string | null): T | null {
if (!b64) return null;
try { return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(b64.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)))); }
catch { return null; }
}
export const decodeParam = <T,>(s: string | null): T | null => readUrlJson<T>(s);
export const encodeDoc = (d: AnalysisDoc): string => encodeParam(d);
export const decodeDoc = (b64: string | null): AnalysisDoc | null => decodeParam<AnalysisDoc>(b64);
export const decodeDoc = (s: string | null): AnalysisDoc | null => decodeParam<AnalysisDoc>(s);

/**
* The document a link carries, if it is this page's: the same cohorts and the same kinds of view
Expand Down Expand Up @@ -87,22 +84,38 @@ export function arrange(base: Omit<AnalysisDoc, "views">, spec: TabSpec[], left:
}


const param = (k: string): string | null => { try { return new URLSearchParams(window.location.search).get(k); } catch { return null; } };

/**
* The page's document and open tab, kept in the URL: the link's document when it is this page's,
* else the page's own defaults (and a note saying the link's could not be used).
* The page's document and open tab, kept in the URL: the page's defaults with the link's changes
* made to them when they were made on this layout, else the link's whole document when it is this
* page's, else the defaults (and a note saying the link's could not be used). `input` is what of
* the link's ?doc= the page is built from (the comparison page's cohorts), kept in the URL without
* the views; a page built from its query alone drops ?doc= once it writes its changes.
*/
export function usePageDoc(fresh: PageLayout | null, linked: AnalysisDoc | null) {
export function usePageDoc(fresh: PageLayout | null, linked: AnalysisDoc | null, input: AnalysisDoc | null = null) {
const [doc, setDoc] = useState<AnalysisDoc | null>(null);
const [note, setNote] = useState<string | null>(null);
const [open, setOpen] = useState<string>(() => { try { return new URLSearchParams(window.location.search).get("tab") || ""; } catch { return ""; } });
const [open, setOpen] = useState<string>(() => param("tab") || "");
const [changes] = useState(() => readUrlJson<PageChanges>(param("changes")));
const freshKey = fresh ? JSON.stringify(fresh.doc) : "";
useEffect(() => {
if (!fresh) return;
if (changes) {
const views = applyChanges(fresh.doc.views || [], changes);
setDoc(views ? { ...fresh.doc, views } : fresh.doc);
setNote(views ? null : "the settings in this link were made on another layout of this page, so its views are shown with their defaults");
return;
}
const kept = compatible(linked, fresh.doc);
setDoc(kept || fresh.doc);
setNote(linked && linked.views && linked.views.length && !kept ? "the views in this link are not this page's own, so the page's are shown with their defaults" : null);
}, [freshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const update = (d: AnalysisDoc) => { setDoc(d); syncUrl({ doc: encodeDoc(d) }); };
const update = (d: AnalysisDoc) => {
setDoc(d);
const c = fresh ? pageChanges(fresh.doc.views || [], d.views || []) : null;
syncUrl({ changes: c ? encodeUrlJson(c) : null, doc: input ? encodeDoc(input) : null });
};
const openTab = (k: string) => { setOpen(k); syncUrl({ tab: k }); };
return { doc, note, open, update, openTab };
}
3 changes: 2 additions & 1 deletion app/portal/results/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
// the genes with the profiled rest. What is
// queried is one study (?study=) or several (?studies=a,b), narrowed by Explore's filters
// (?filters=) or not; ?genes= is the query, as OQL. The page's state — every setting changed on
// it — is one analysis document in ?doc= (page-doc.ts), built by results-layout.ts.
// it — is one analysis document built by results-layout.ts, whose changes travel in ?changes=
// (page-doc.ts).
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { PortalTabs } from "../PortalTabs";
Expand Down
5 changes: 3 additions & 2 deletions app/reproductions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
// list of real AnalysisDocs — nothing here queries the database itself, that happens once each
// reader clicks through to /lab, exactly like the Examples tab's "copy link"/"Run" pattern.
//
// Each card's "Open in Lab" link carries the FULL document as a ?doc= base64url blob (the same
// Each card's "Open in Lab" link carries the FULL document as a ?doc= value (lib/url-json.ts, the same
// encoding /lab's own docLink() produces client-side) — the document's own `notes` and each
// view's own `note` are what actually explain the paper connection, so it travels with the
// analysis rather than living only on this listing page.
import { REPRODUCTIONS, type PaperReproduction } from "../../lib/reproductions.ts";
import { PageTools } from "../_agent/PageTools.tsx";
import { SiteHeader } from "../_ui/site-header.tsx";
import { encodeUrlJson } from "../../lib/url-json.ts";

export const metadata = { title: "Paper reproductions — cbio-lab" };

Expand All @@ -18,7 +19,7 @@ const BADGE_LABEL: Record<PaperReproduction["badge"], string> = {
};

function docLink(doc: unknown): string {
return "/lab?doc=" + Buffer.from(JSON.stringify(doc), "utf-8").toString("base64url");
return "/lab?doc=" + encodeUrlJson(doc);
}

export default function ReproductionsPage() {
Expand Down
7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ The map of the code: what each part does, where it lives, and the one place each
`use-explore.ts` (keyed by everything that decides the answer, so a superseded request is
cancelled and can never land; `e2e-lab-races.cjs` holds requests to prove it). The standalone query-builder (`/`) and interactive oncoprint (`/oncoprint`)
demo pages were retired once the Analysis app subsumed them.
- **Links (`lib/url-json.ts`)** — a document in a URL (`?doc=`, and every JSON parameter) is written
`1.<base64url>`: the JSON raw-deflated, about half the plain form's length on the gallery and less on
large documents, readable by any zlib. The plain base64url JSON of
older links, scripts and hand-made links still opens. The portal pages (`app/portal/`) carry only how their
views differ from the page's defaults (`?changes=`, `page-changes.ts`), tied to the kinds of the
page's views in order, so changes made on another layout are refused and said so rather than
applied to the wrong view; the defaults themselves are rebuilt from the query.
- **The homepage (`/`, `app/_home/`)** — cBioPortal's: choose studies, then **Explore selected
studies** (the lab's Explore, `/lab?tab=explore&study=a,b`) or **Query by gene** (the gene query
dialog, whose Submit opens the Results page). One study chooser serves it, Explore's picker and
Expand Down
3 changes: 2 additions & 1 deletion docs/dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ the first time an analysis uses a server dataset. A `custom:` name with no pin i
browser that holds it.

Sent to `POST /api/analysis`, edited by the researcher's assistant through the page's WebMCP tools, and encoded in shareable
`/analysis?doc=<base64url>` links.
`/analysis?doc=<base64url>` links: the base64url of the document's UTF-8 JSON opens as it is, and the app writes it
deflated (`1.<base64url>`, `lib/url-json.ts`) to keep links short.

## Cohort definitions

Expand Down
Loading
Loading